Add comprehensive unit tests for Security, UserModel, and Validation utilities
- Implemented SecurityTest to validate token generation, CSRF protection, input sanitization, and rate limiting. - Created UserModelTest to ensure correct database operations for user management, including creation, updating, banning, and fetching active users. - Developed ValidationTest to verify input validation and sanitization for user IDs, nicknames, messages, and API requests. - Introduced Security and Validation utility classes with methods for secure token generation, input sanitization, and comprehensive validation rules.
This commit is contained in:
parent
5692874b10
commit
41cd7a4fd8
32 changed files with 5796 additions and 368 deletions
337
utils/Security.php
Normal file
337
utils/Security.php
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
<?php
|
||||
/**
|
||||
* Security Utilities Class
|
||||
* Handles authentication, CSRF protection, and security-related functions
|
||||
*/
|
||||
|
||||
class Security
|
||||
{
|
||||
/**
|
||||
* Generate a cryptographically secure user ID
|
||||
*/
|
||||
public static function generateSecureUserId($length = 8)
|
||||
{
|
||||
return bin2hex(random_bytes($length));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a CSRF token
|
||||
*/
|
||||
public static function generateCSRFToken()
|
||||
{
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
return $_SESSION['csrf_token'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate CSRF token
|
||||
*/
|
||||
public static function validateCSRFToken($token = null)
|
||||
{
|
||||
$sessionToken = $_SESSION['csrf_token'] ?? '';
|
||||
|
||||
$providedToken = $token ?? ($_POST['csrf_token'] ?? $_GET['csrf_token'] ?? '');
|
||||
|
||||
if (empty($sessionToken) || empty($providedToken)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals($sessionToken, $providedToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate admin user
|
||||
*/
|
||||
public static function authenticateAdmin($username, $password)
|
||||
{
|
||||
$storedUsername = Config::get('admin.username', 'admin');
|
||||
$storedHash = Config::get('admin.password_hash');
|
||||
|
||||
if (empty($storedHash)) {
|
||||
error_log("Warning: Admin password hash not configured in .env file");
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($username !== $storedUsername) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!password_verify($password, $storedHash)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Set admin session
|
||||
$_SESSION['admin_authenticated'] = true;
|
||||
$_SESSION['admin_username'] = $username;
|
||||
$_SESSION['admin_login_time'] = time();
|
||||
|
||||
session_regenerate_id(true); // Prevent session fixation
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated as admin
|
||||
*/
|
||||
public static function isAdminAuthenticated()
|
||||
{
|
||||
if (!isset($_SESSION['admin_authenticated']) || !$_SESSION['admin_authenticated']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check session timeout
|
||||
$timeout = Config::get('admin.session_timeout', 3600);
|
||||
$loginTime = $_SESSION['admin_login_time'] ?? 0;
|
||||
|
||||
if (time() - $loginTime > $timeout) {
|
||||
self::logoutAdmin();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout admin user
|
||||
*/
|
||||
public static function logoutAdmin()
|
||||
{
|
||||
unset($_SESSION['admin_authenticated']);
|
||||
unset($_SESSION['admin_username']);
|
||||
unset($_SESSION['admin_login_time']);
|
||||
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash password using bcrypt
|
||||
*/
|
||||
public static function hashPassword($password)
|
||||
{
|
||||
$options = [
|
||||
'cost' => 12, // Increase cost for production
|
||||
];
|
||||
|
||||
return password_hash($password, PASSWORD_ARGON2I, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify password hash needs rehash
|
||||
*/
|
||||
public static function passwordNeedsRehash($hash)
|
||||
{
|
||||
$options = [
|
||||
'cost' => 12,
|
||||
];
|
||||
|
||||
return password_needs_rehash($hash, PASSWORD_ARGON2I, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate secure random string
|
||||
*/
|
||||
public static function generateSecureToken($length = 32)
|
||||
{
|
||||
return bin2hex(random_bytes($length));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize input data
|
||||
*/
|
||||
public static function sanitizeInput($input, $type = 'string')
|
||||
{
|
||||
switch ($type) {
|
||||
case 'string':
|
||||
$sanitized = trim($input);
|
||||
$sanitized = filter_var($sanitized, FILTER_SANITIZE_STRING, FILTER_FLAG_NO_ENCODE_QUOTES);
|
||||
return htmlspecialchars($sanitized, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
case 'email':
|
||||
$sanitized = filter_var(trim($input), FILTER_SANITIZE_EMAIL);
|
||||
return filter_var($sanitized, FILTER_VALIDATE_EMAIL) ? $sanitized : '';
|
||||
|
||||
case 'url':
|
||||
$sanitized = filter_var(trim($input), FILTER_SANITIZE_URL);
|
||||
return filter_var($sanitized, FILTER_VALIDATE_URL, FILTER_FLAG_QUERY_REQUIRED) ? $sanitized : '';
|
||||
|
||||
case 'int':
|
||||
return filter_var($input, FILTER_VALIDATE_INT);
|
||||
|
||||
case 'float':
|
||||
return filter_var($input, FILTER_VALIDATE_FLOAT);
|
||||
|
||||
default:
|
||||
return htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate URL against whitelist
|
||||
*/
|
||||
public static function isValidStreamUrl($url)
|
||||
{
|
||||
if (empty($url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowedDomains = Config::get('stream.allowed_domains', []);
|
||||
if (empty($allowedDomains)) {
|
||||
$allowedDomains = ['38.64.28.91:23456']; // Default fallback
|
||||
}
|
||||
|
||||
if (!is_array($allowedDomains)) {
|
||||
$allowedDomains = explode(',', $allowedDomains);
|
||||
}
|
||||
|
||||
// Normalize domains
|
||||
$allowedDomains = array_map(function($domain) {
|
||||
return trim(strtolower($domain));
|
||||
}, $allowedDomains);
|
||||
|
||||
// Parse URL
|
||||
$parsedUrl = parse_url($url);
|
||||
if (!$parsedUrl || !isset($parsedUrl['host']) || !isset($parsedUrl['scheme'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$urlDomain = strtolower($parsedUrl['host']);
|
||||
$urlPort = $parsedUrl['port'] ?? ($parsedUrl['scheme'] === 'https' ? 443 : 80);
|
||||
$fullDomain = $urlDomain . ':' . $urlPort;
|
||||
|
||||
// Check if domain is allowed
|
||||
foreach ($allowedDomains as $allowed) {
|
||||
if ($fullDomain === $allowed || $urlDomain === $allowed) {
|
||||
// Additional validation: only allow specific paths
|
||||
$path = $parsedUrl['path'] ?? '';
|
||||
if (preg_match('#^/.*\.(m3u8|ts)$#', $path) || $path === '/stream.m3u8') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate rate limiting key
|
||||
*/
|
||||
public static function getRateLimitKey($identifier, $action = 'general')
|
||||
{
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
return "rate_limit:{$action}:{$ip}:{$identifier}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request is within rate limits
|
||||
*/
|
||||
public static function checkRateLimit($identifier, $action = 'general', $maxRequests = null, $timeWindow = 60)
|
||||
{
|
||||
if (!$maxRequests) {
|
||||
$maxRequests = Config::get('rate_limit.requests_per_minute', 60);
|
||||
}
|
||||
|
||||
$key = self::getRateLimitKey($identifier, $action);
|
||||
|
||||
// Simple file-based rate limiting (use Redis/APCu in production)
|
||||
$cacheFile = sys_get_temp_dir() . '/rate_limit_' . md5($key);
|
||||
$data = [];
|
||||
|
||||
if (file_exists($cacheFile)) {
|
||||
$data = json_decode(file_get_contents($cacheFile), true) ?: [];
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$windowStart = $now - $timeWindow;
|
||||
|
||||
// Filter old requests
|
||||
$data = array_filter($data, function($timestamp) use ($windowStart) {
|
||||
return $timestamp > $windowStart;
|
||||
});
|
||||
|
||||
if (count($data) >= $maxRequests) {
|
||||
return false; // Rate limit exceeded
|
||||
}
|
||||
|
||||
// Add current request
|
||||
$data[] = $now;
|
||||
|
||||
// Keep only recent requests
|
||||
$data = array_slice($data, -$maxRequests);
|
||||
|
||||
file_put_contents($cacheFile, json_encode($data));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address
|
||||
*/
|
||||
public static function getClientIP()
|
||||
{
|
||||
$headers = [
|
||||
'HTTP_X_FORWARDED_FOR',
|
||||
'HTTP_X_FORWARDED',
|
||||
'HTTP_X_REAL_IP',
|
||||
'HTTP_CLIENT_IP',
|
||||
'REMOTE_ADDR'
|
||||
];
|
||||
|
||||
foreach ($headers as $header) {
|
||||
if (!empty($_SERVER[$header])) {
|
||||
// Handle comma-separated IPs (X-Forwarded-For)
|
||||
$ip = trim(explode(',', $_SERVER[$header])[0]);
|
||||
|
||||
// Basic IP validation
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Log security event
|
||||
*/
|
||||
public static function logSecurityEvent($event, $details = [])
|
||||
{
|
||||
$logData = [
|
||||
'timestamp' => date('Y-m-d H:i:s'),
|
||||
'event' => $event,
|
||||
'ip' => self::getClientIP(),
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
|
||||
'details' => $details
|
||||
];
|
||||
|
||||
error_log("SECURITY: " . json_encode($logData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for suspicious request patterns
|
||||
*/
|
||||
public static function detectSuspiciousActivity()
|
||||
{
|
||||
$warnings = [];
|
||||
|
||||
// Check for multiple failed authentication attempts
|
||||
if (isset($_SESSION['auth_attempts']) && $_SESSION['auth_attempts'] > 3) {
|
||||
$warnings[] = 'Multiple authentication failures';
|
||||
}
|
||||
|
||||
// Check for rapid requests (potential DDoS)
|
||||
$requestCount = $_SESSION['recent_requests'] ?? 0;
|
||||
if ($requestCount > 100) {
|
||||
$warnings[] = 'High request frequency detected';
|
||||
}
|
||||
|
||||
// Log warnings
|
||||
foreach ($warnings as $warning) {
|
||||
self::logSecurityEvent('suspicious_activity', ['warning' => $warning]);
|
||||
}
|
||||
|
||||
return $warnings;
|
||||
}
|
||||
}
|
||||
316
utils/Validation.php
Normal file
316
utils/Validation.php
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
<?php
|
||||
/**
|
||||
* Input Validation Utilities
|
||||
* Comprehensive input validation and sanitization functions
|
||||
*/
|
||||
|
||||
class Validation
|
||||
{
|
||||
/**
|
||||
* Validate and sanitize nickname
|
||||
*/
|
||||
public static function validateNickname($nickname)
|
||||
{
|
||||
if (!is_string($nickname)) {
|
||||
return self::createValidationResult(false, 'Nickname must be a string');
|
||||
}
|
||||
|
||||
$trimmed = trim($nickname);
|
||||
|
||||
// Check length
|
||||
if (strlen($trimmed) < 1) {
|
||||
return self::createValidationResult(false, 'Nickname cannot be empty');
|
||||
}
|
||||
|
||||
if (strlen($trimmed) > 20) {
|
||||
return self::createValidationResult(false, 'Nickname must be 20 characters or less');
|
||||
}
|
||||
|
||||
// Check allowed characters (alphanumeric, spaces, hyphens, apostrophes)
|
||||
if (!preg_match('/^[a-zA-Z0-9\s\'-]+$/', $trimmed)) {
|
||||
return self::createValidationResult(false, 'Nickname can only contain letters, numbers, spaces, hyphens, and apostrophes');
|
||||
}
|
||||
|
||||
// Sanitize
|
||||
$sanitized = Security::sanitizeInput($trimmed);
|
||||
|
||||
return self::createValidationResult(true, 'Valid nickname', $sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and sanitize chat message
|
||||
*/
|
||||
public static function validateMessage($message, $maxLength = 1000)
|
||||
{
|
||||
if (!is_string($message)) {
|
||||
return self::createValidationResult(false, 'Message must be a string');
|
||||
}
|
||||
|
||||
$trimmed = trim($message);
|
||||
|
||||
// Check length
|
||||
if (strlen($trimmed) < 1) {
|
||||
return self::createValidationResult(false, 'Message cannot be empty');
|
||||
}
|
||||
|
||||
if (strlen($trimmed) > $maxLength) {
|
||||
return self::createValidationResult(false, 'Message exceeds maximum length of ' . $maxLength . ' characters');
|
||||
}
|
||||
|
||||
// Basic XSS check (additional to sanitization)
|
||||
$xssPatterns = [
|
||||
'/<script[^>]*>.*?<\/script>/is',
|
||||
'/javascript:/i',
|
||||
'/on\w+\s*=/i',
|
||||
'/<iframe[^>]*>.*?<\/iframe>/is'
|
||||
];
|
||||
|
||||
foreach ($xssPatterns as $pattern) {
|
||||
if (preg_match($pattern, $trimmed)) {
|
||||
return self::createValidationResult(false, 'Message contains potentially harmful content');
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize
|
||||
$sanitized = Security::sanitizeInput($trimmed);
|
||||
|
||||
return self::createValidationResult(true, 'Valid message', $sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate user ID format
|
||||
*/
|
||||
public static function validateUserId($userId)
|
||||
{
|
||||
if (!is_string($userId)) {
|
||||
return self::createValidationResult(false, 'User ID must be a string');
|
||||
}
|
||||
|
||||
// Should be hexadecimal (from bin2hex)
|
||||
if (!preg_match('/^[a-f0-9]+$/', $userId)) {
|
||||
return self::createValidationResult(false, 'Invalid user ID format');
|
||||
}
|
||||
|
||||
// Check length (16 characters for 8 bytes)
|
||||
if (strlen($userId) !== 16) {
|
||||
return self::createValidationResult(false, 'Invalid user ID length');
|
||||
}
|
||||
|
||||
return self::createValidationResult(true, 'Valid user ID', $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate admin login credentials
|
||||
*/
|
||||
public static function validateAdminLogin($username, $password)
|
||||
{
|
||||
// Username validation
|
||||
if (empty($username) || !is_string($username)) {
|
||||
return self::createValidationResult(false, 'Username is required');
|
||||
}
|
||||
|
||||
$username = trim($username);
|
||||
if (strlen($username) < 3 || strlen($username) > 50) {
|
||||
return self::createValidationResult(false, 'Username must be between 3-50 characters');
|
||||
}
|
||||
|
||||
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $username)) {
|
||||
return self::createValidationResult(false, 'Username can only contain letters, numbers, hyphens, and underscores');
|
||||
}
|
||||
|
||||
// Password validation
|
||||
if (empty($password) || !is_string($password)) {
|
||||
return self::createValidationResult(false, 'Password is required');
|
||||
}
|
||||
|
||||
$password = trim($password);
|
||||
if (strlen($password) < 6) {
|
||||
return self::createValidationResult(false, 'Password must be at least 6 characters long');
|
||||
}
|
||||
|
||||
// Additional password strength checks (optional)
|
||||
$hasUppercase = preg_match('/[A-Z]/', $password);
|
||||
$hasLowercase = preg_match('/[a-z]/', $password);
|
||||
$hasNumbers = preg_match('/[0-9]/', $password);
|
||||
|
||||
if (!$hasUppercase || !$hasLowercase || !$hasNumbers) {
|
||||
return self::createValidationResult(false, 'Password must contain at least one uppercase letter, one lowercase letter, and one number');
|
||||
}
|
||||
|
||||
return self::createValidationResult(true, 'Credentials format valid', [
|
||||
'username' => $username,
|
||||
'password' => $password
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate API request parameters
|
||||
*/
|
||||
public static function validateApiRequest($params, $rules)
|
||||
{
|
||||
$validated = [];
|
||||
$errors = [];
|
||||
|
||||
foreach ($rules as $field => $rule) {
|
||||
$value = $params[$field] ?? null;
|
||||
|
||||
// Check required fields
|
||||
if (isset($rule['required']) && $rule['required'] && $value === null) {
|
||||
$errors[$field] = $rule['required_message'] ?? "{$field} is required";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip validation if not required and not provided
|
||||
if ($value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type validation
|
||||
if (isset($rule['type'])) {
|
||||
$valid = self::validateType($value, $rule['type'], $rule);
|
||||
if (!$valid['valid']) {
|
||||
$errors[$field] = $valid['message'];
|
||||
continue;
|
||||
}
|
||||
$validated[$field] = $valid['value'];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply sanitization
|
||||
if (isset($rule['sanitize'])) {
|
||||
$value = Security::sanitizeInput($value, $rule['sanitize']);
|
||||
}
|
||||
|
||||
$validated[$field] = $value;
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => empty($errors),
|
||||
'validated' => $validated,
|
||||
'errors' => $errors
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate value against specific type
|
||||
*/
|
||||
private static function validateType($value, $type, $rule = [])
|
||||
{
|
||||
switch ($type) {
|
||||
case 'string':
|
||||
if (!is_string($value)) {
|
||||
return ['valid' => false, 'message' => 'Must be a string'];
|
||||
}
|
||||
$value = trim($value);
|
||||
|
||||
if (isset($rule['min_length']) && strlen($value) < $rule['min_length']) {
|
||||
return ['valid' => false, 'message' => "Must be at least {$rule['min_length']} characters long"];
|
||||
}
|
||||
|
||||
if (isset($rule['max_length']) && strlen($value) > $rule['max_length']) {
|
||||
return ['valid' => false, 'message' => "Must be no more than {$rule['max_length']} characters long"];
|
||||
}
|
||||
|
||||
if (isset($rule['pattern']) && !preg_match($rule['pattern'], $value)) {
|
||||
return ['valid' => false, 'message' => $rule['pattern_message'] ?? 'Invalid format'];
|
||||
}
|
||||
|
||||
return ['valid' => true, 'value' => $value];
|
||||
|
||||
case 'int':
|
||||
$intVal = filter_var($value, FILTER_VALIDATE_INT);
|
||||
if ($intVal === false) {
|
||||
return ['valid' => false, 'message' => 'Must be a valid integer'];
|
||||
}
|
||||
|
||||
if (isset($rule['min']) && $intVal < $rule['min']) {
|
||||
return ['valid' => false, 'message' => "Must be at least {$rule['min']}"];
|
||||
}
|
||||
|
||||
if (isset($rule['max']) && $intVal > $rule['max']) {
|
||||
return ['valid' => false, 'message' => "Must be no more than {$rule['max']}"];
|
||||
}
|
||||
|
||||
return ['valid' => true, 'value' => $intVal];
|
||||
|
||||
case 'email':
|
||||
$email = filter_var(trim($value), FILTER_VALIDATE_EMAIL);
|
||||
if (!$email) {
|
||||
return ['valid' => false, 'message' => 'Invalid email address'];
|
||||
}
|
||||
return ['valid' => true, 'value' => $email];
|
||||
|
||||
case 'boolean':
|
||||
$boolVal = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
if ($boolVal === null) {
|
||||
return ['valid' => false, 'message' => 'Must be a boolean value'];
|
||||
}
|
||||
return ['valid' => true, 'value' => $boolVal];
|
||||
|
||||
default:
|
||||
return ['valid' => true, 'value' => $value];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate heartbeat data
|
||||
*/
|
||||
public static function validateHeartbeat($data)
|
||||
{
|
||||
$rules = [
|
||||
'nickname' => [
|
||||
'required' => false,
|
||||
'type' => 'string',
|
||||
'max_length' => 20,
|
||||
'pattern' => '/^[a-zA-Z0-9\s\'-]*$/',
|
||||
'pattern_message' => 'Nickname can only contain letters, numbers, spaces, hyphens, and apostrophes'
|
||||
]
|
||||
];
|
||||
|
||||
return self::validateApiRequest($data, $rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate message send request
|
||||
*/
|
||||
public static function validateMessageSend($data)
|
||||
{
|
||||
$rules = [
|
||||
'nickname' => [
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
'min_length' => 1,
|
||||
'max_length' => 20,
|
||||
'pattern' => '/^[a-zA-Z0-9\s\'-]+$/',
|
||||
'pattern_message' => 'Nickname can only contain letters, numbers, spaces, hyphens, and apostrophes',
|
||||
'required_message' => 'Nickname is required'
|
||||
],
|
||||
'message' => [
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
'min_length' => 1,
|
||||
'max_length' => 1000,
|
||||
'required_message' => 'Message cannot be empty'
|
||||
]
|
||||
];
|
||||
|
||||
return self::validateApiRequest($data, $rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create validation result array
|
||||
*/
|
||||
private static function createValidationResult($valid, $message, $data = null)
|
||||
{
|
||||
$result = [
|
||||
'valid' => $valid,
|
||||
'message' => $message
|
||||
];
|
||||
|
||||
if ($data !== null) {
|
||||
$result['data'] = $data;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue