['type' => 'string', 'min' => 5, 'max' => 36], # 'user_enabled' => ['type' => 'int', 'min' => 0, 'max' => 99], # 'user_active' => ['type' => 'enum', 'values' => ['active', 'inactive', 'banned']], # 'user_email' => ['type' => 'string', 'format' => 'email'], private $requiredFields = []; private $optionalFields = []; public function __construct() { # Setup Database connection require_once $_SERVER['DOCUMENT_ROOT'] . '/bin/php/db_connect.php'; $this->conn = $GLOBALS['conn']; if (!empty($_SESSION['user']['user_uuid'])) { $this->InitUserTypeFrontend(); } else { $this->InitUserTypeAPI(); } $this->return_url = $this->setReturnUrl(); # user_uuid will be set if the user is authorized if (!$this->user_uuid) { $this->apiOutput(401, ['error' => 'Unauthorized']); } # Only allow POST, GET, PUT and DELETE if (!$this->checkRequestMethod()) { $this->apiOutput(405, ['error' => 'Method not allowed']); } if (!$this->checkContentType()) { $this->apiOutput(400, ['error' => 'Unsupported Content-Type.']); } if ($this->content_type === 'application/json') { if (!$this->checkJson()) { $this->apiOutput(400, ['error' => 'Invalid JSON format']); } } // Disable builder input for non-GET requests to prevent potential SQL injection vulnerabilities. // Also disable the builder for users with the 'frontend' user type as an extra security measure. // The builder should only be active for API users making GET requests. // When building a frontend page, you can still programmatically construct a builder array // and set it via $_GET like so after the API class creation: // $_GET['builder'] = [1 => ['where' => [0 => 'permission_uuid', 1 => $permission_uuid]]]; if ($this->request_method !== 'GET' || $this->user_type === 'frontend') { $this->disableBuilder(); } # This converts the posted data if needed to an PHP array $this->postedData = $this->processPostedData(); } private function InitUserTypeFrontend() { $this->user_uuid = $_SESSION['user']['user_uuid']; $this->user_type = 'frontend'; # Load the locale for the user, this is used for the return message in the frontend and other globalFunctions. include_once $_SERVER['DOCUMENT_ROOT'] . '/bin/php/Functions/globalFunctions.php'; $locale = getPreferredLocale(); global $translations; $translations = require $_SERVER['DOCUMENT_ROOT'] . "/bin/locales/{$locale}.php"; } protected function RecursiveDeleteFolder($folderPath): bool { // Check if the folder exists if (!is_dir($folderPath)) { $this->apiOutput(500, ['error' => 'directory not found: ' . $folderPath]); } // Get all files and folders in the directory $items = array_diff(scandir($folderPath), array('.', '..')); // Loop through each item foreach ($items as $item) { $itemPath = $folderPath . DIRECTORY_SEPARATOR . $item; if (is_dir($itemPath)) { if (!$this->RecursiveDeleteFolder($itemPath)) { $this->apiOutput(500, ['error' => "Unable to remove directory: $itemPath"]); } } else { if (!unlink($itemPath)) { $this->apiOutput(500, ['error' => "Unable to delete file: $itemPath"]); } } } // Remove the main folder after all contents are gone if (!rmdir($folderPath)) { $this->apiOutput(500, ['error' => "Unable to remove directory: $folderPath"]); } return true; } private function InitUserTypeAPI() { $this->user_type = 'api'; $headers = getallheaders(); $authHeader = $headers['Authorization'] ?? ''; if (!preg_match('/^Bearer\s+(.+)$/', $authHeader, $matches)) { $this->apiOutput(401, ['error' => 'Unauthorized, missing bearer token.']); } $bearerToken = trim($matches[1]); if (!preg_match('/^[a-f0-9\-]{36}\.[a-f0-9]{64}$/i', $bearerToken)) { $this->apiOutput(401, ['error' => 'Unauthorized, invalid token format.']); } [$tokenId, $tokenSecret] = explode('.', $bearerToken, 2); $this->user_uuid = $this->validateToken($tokenId, $tokenSecret); if ($this->user_uuid === false) { $this->apiOutput(401, ['error' => 'Unauthorized, invalid or expired token.']); } $api_token_last_used_timestamp = time(); $stmt = $this->conn->prepare("UPDATE vc_api_tokens SET api_token_last_used_timestamp = ? WHERE api_token_uuid = ?"); $stmt->bind_param("is", $api_token_last_used_timestamp, $tokenId); $stmt->execute(); } public function validateSingleData($value, $rules) { if (!$this->validateField($value, $rules)) { $this->apiOutput(400, ['error' => "Invalid value: $value"]); } return $this->sanitizeData($value, $rules['type']); } public function validateData($requiredFields, $optionalFields = []) { $inputData = $this->postedData; $this->requiredFields = $requiredFields; $this->optionalFields = $optionalFields; $sanitizedData = []; foreach ($this->requiredFields as $field => $rules) { if (!array_key_exists($field, $inputData)) { $this->apiOutput(400, ['error' => "Missing required field: $field"]); } $value = $inputData[$field]; if (!$this->validateField($value, $rules)) { $this->apiOutput(400, ['error' => "Invalid value for $field"]); } $sanitizedData[$field] = $this->sanitizeData($value, $rules['type']); } // Check optional fields foreach ($this->optionalFields as $field => $rules) { if (isset($inputData[$field])) { $value = $inputData[$field]; if (!$this->validateField($value, $rules)) { $this->apiOutput(422, ['error' => "Invalid value for optional field: $field"]); } $sanitizedData[$field] = $this->sanitizeData($value, $rules['type']); } } if (isset($_GET['builder']) && is_array($_GET['builder'])) { foreach ($_GET['builder'] as $builder) { if (!isset($builder['where']) || count($builder['where']) !== 2) { continue; // skip invalid builders } $field = $builder['where'][0]; $value = $builder['where'][1]; // Check if the field is allowed (in required or optional) $rules = $requiredFields[$field] ?? $optionalFields[$field] ?? null; if (!$rules) { $this->apiOutput(403, ['error' => "Field not allowed in query: $field"]); } // Validate and sanitize if (!$this->validateField($value, $rules)) { $this->apiOutput(422, ['error' => "Invalid value for builder field: $field"]); } $sanitizedData[$field] = $this->sanitizeData($value, $rules['type']); } } $this->data = $sanitizedData; } private function isValidLength($value, $rules) { $length = strlen($value); if (isset($rules['min']) && $length < $rules['min']) return false; if (isset($rules['max']) && $length > $rules['max']) return false; return true; } private function isValidNumberRange($value, $rules) { if (isset($rules['min']) && $value < $rules['min']) return false; if (isset($rules['max']) && $value > $rules['max']) return false; return true; } private function validateField($value, $rules) { switch ($rules['type']) { case 'string': if (!is_string($value)) return false; return $this->isValidLength($value, $rules); case 'slugify': if (!is_string($value) || !preg_match('/^[a-z0-9]+(-[a-z0-9]+)*$/', $value)) return false; return $this->isValidLength($value, $rules); case 'boolean': if (is_bool($value)) return true; if (is_string($value)) { $value = strtolower($value); return $value === 'true' || $value === 'false' || $value === '1' || $value === '0'; } if (is_int($value)) { return $value === 1 || $value === 0; } return false; case 'email': if (!is_string($value)) return false; if (!filter_var($value, FILTER_VALIDATE_EMAIL)) return false; return $this->isValidLength($value, $rules); case 'password': if (!is_string($value)) return false; return $this->isValidLength($value, $rules); case 'html': if (!is_string($value)) return false; return $this->isValidLength($value, $rules); case 'int': if (!is_int($value) && !ctype_digit($value)) return false; $value = (int)$value; return $this->isValidNumberRange($value, $rules); case 'float': // Accept floats or numeric strings if (!is_float($value) && !is_numeric($value)) { return false; } $value = (float)$value; return $this->isValidNumberRange($value, $rules); case 'timestamp': if (is_null($value)) return true; if (!is_int($value) && !ctype_digit($value)) return false; $value = (int)$value; if ($value < 0) return false; $min = $rules['min'] ?? 1; $max = $rules['max'] ?? 4102444800; return $value >= $min && $value <= $max; case 'enum': if (!isset($rules['values']) || !in_array($value, $rules['values'], true)) return false; return true; case 'uuid': if (!is_string($value)) return false; return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $value); case 'base64': if (!is_string($value)) return false; return base64_encode(base64_decode($value, true)) === $value; case 'uuid': if (!is_string($value)) return false; return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $value); case 'json': if (!is_string($value)) return false; json_decode($value); return json_last_error() === JSON_ERROR_NONE; case 'array': if (!is_array($value)) return false; return $value; default: return false; } } private function sanitizeData($value, $type) { switch ($type) { case 'string': case 'enum': case 'uuid': // Remove HTML tags and encode special characters return htmlspecialchars(strip_tags($value), ENT_QUOTES, 'UTF-8'); case 'email': // Remove illegal characters from email address return filter_var($value, FILTER_SANITIZE_EMAIL); case 'password': // Passwords may contain special characters; just trim spaces return trim($value); case 'html': // Allow safe HTML, you can customize allowed tags return strip_tags($value, '


'); case 'int': // Remove anything that's not a number return filter_var($value, FILTER_SANITIZE_NUMBER_INT); case 'base64': // Only allow base64 valid characters return preg_replace('/[^a-zA-Z0-9\/\+=]/', '', $value); case 'boolean': if (is_string($value)) { $value = strtolower(trim($value)); return in_array($value, ['true', '1'], true) ? true : false; } return (bool)$value; default: // Return as-is if unknown type return $value; } } public function checkPermissions($permission_name, $accessRightsRequired, $returnBoolean = false) { $accessLevels = [ 'NA' => 0, // No Access 'RO' => 1, // Read Only 'RW' => 2, // Read Write ]; $query = "SELECT vc_permissions.permission_name, vc_user_group_permissions_portal.permission_value FROM vc_user_group_permissions_portal INNER JOIN vc_permissions ON vc_user_group_permissions_portal.permission_uuid =vc_permissions.permission_uuid INNER JOIN vc_users ON vc_user_group_permissions_portal.user_group_uuid = vc_users.user_group_uuid WHERE user_uuid = ? AND permission_name = ?"; $stmt = $this->conn->prepare($query); $stmt->bind_param("ss", $this->user_uuid, $permission_name); $stmt->execute(); $result = $stmt->get_result()->fetch_assoc(); if (!$result) { if ($returnBoolean) { return false; } $this->apiOutput(500, ['error' => 'Did not find permission required']); } $userAccess = $result['permission_value']; if (!isset($accessLevels[$userAccess]) || !isset($accessLevels[$accessRightsRequired])) { if ($returnBoolean) { return false; } $this->apiOutput(500, ['error' => 'Server error.']); } // Compare user's access level with the required access level if ($accessLevels[$userAccess] < $accessLevels[$accessRightsRequired]) { if ($returnBoolean) { return false; } $this->apiOutput(403, ['error' => 'Permission denied. You do not have the required access level.']); } if ($returnBoolean) { return true; } } protected function setReturnUrl() { if ($this->user_type !== 'frontend') { return false; } $method = $_SERVER['REQUEST_METHOD']; if ($method === 'POST' && isset($_POST['_return'])) { return $_POST['_return']; } if ($method === 'PUT') { parse_str(file_get_contents("php://input"), $putData); if (isset($putData['_return'])) { return $putData['_return']; } } if ($method === 'GET') { return false; } return $_SERVER['HTTP_REFERER']; } protected function checkRequestMethod() { $allowedMethods = ['GET', 'POST', 'PUT', 'DELETE']; $method = $_SERVER['REQUEST_METHOD'] ?? ''; if (!in_array($method, $allowedMethods)) { return false; } # Since browser doesnt allow DELETE or PUTs from the frontend forms (apart from some javascript/ajax fuckery) # we need to check the _method POST value. if ($this->user_type === 'frontend' && $method === 'POST' && isset($_POST['_method'])) { $overrideMethod = strtoupper($_POST['_method']); if (in_array($overrideMethod, ['PUT', 'DELETE'])) { $this->request_method = $overrideMethod; return true; } } $this->request_method = $method; return true; } protected function checkJson() { $rawInput = file_get_contents('php://input'); if (empty($rawInput)) { return false; } json_decode($rawInput, true); if (json_last_error() !== JSON_ERROR_NONE) { return false; } return true; } protected function processPostedData() { if ($this->user_type === 'api') { return json_decode(file_get_contents("php://input"), true); } switch ($this->request_method) { case 'GET': return $_GET; case 'POST': return $_POST; case 'PUT': case 'DELETE': # When an image is uploaded from the front end the data needs to be specified its in $_POST and not $_FILES if ($this->content_type === 'multipart/form-data') { return $_POST; } else { parse_str(file_get_contents("php://input"), $data); return $data; } default: return []; } } protected function validateToken(string $tokenId, string $tokenSecret) { $stmt = $this->conn->prepare("SELECT user_uuid, api_token FROM vc_api_tokens WHERE api_token_uuid = ? AND api_token_expiration_timestamp > UNIX_TIMESTAMP()"); $stmt->bind_param("s", $tokenId); $stmt->execute(); $row = $stmt->get_result()->fetch_assoc(); if (!$row) { return false; } if (!password_verify($tokenSecret, $row['api_token'])) { return false; } return $row['user_uuid']; } protected function checkContentType() { # api will need to post with an application/json type Content type. # frontend will post with application/x-www-form-urlencoded content type but also is capable of application/json # frontend can also post multipart/form-data # GET requests dont have an content type $contentType = $_SERVER['CONTENT_TYPE'] ?? ''; if ($this->request_method === 'GET') { $this->content_type = ''; return true; } if ($this->user_type === 'api') { $this->content_type = 'application/json'; return true; } if (strpos($contentType, 'application/json') !== false) { $this->content_type = 'application/json'; return true; } if (strpos($contentType, 'application/x-www-form-urlencoded') !== false) { $this->content_type = 'application/x-www-form-urlencoded'; return true; } if (strpos($contentType, 'multipart/form-data') !== false) { $this->content_type = 'multipart/form-data'; return true; } return false; } public function getUserUuid() { return $this->user_uuid; } public function apiOutput($code = 200, $data = [], $frontendMessage = false) { if ($this->user_type === 'api') { http_response_code($code); header('Content-Type: application/json'); if ($code === 200) { echo json_encode(reset($data)); } else { echo json_encode($data); } exit; } if ($this->user_type === 'frontend') { if (in_array($this->request_method, ['POST', 'PUT', 'DELETE'])) { http_response_code($code); if ($this->return_url) { # sometimes the PUT doesnt need an return or response set (Think of js actions to api from frontend) $_SESSION['response'] = json_encode($data); # When a request is successfull the api will recieve the data, the frontend needs a friendly message if ($frontendMessage) { $_SESSION['response'] = json_encode([key($data) => __($frontendMessage)]); } header('Location: ' . $this->return_url); exit; } } header('Content-Type: application/json'); echo json_encode($data); exit; } exit; } public function prepareStatement($query) { // Enable MySQLi to throw exceptions on errors mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); try { $stmt = $this->conn->prepare($query); } catch (mysqli_sql_exception $e) { // If an error occurs during prepare, catch it and return a proper response $this->apiOutput(500, ['error' => 'Database error: ' . $e->getMessage()]); return null; } return $stmt; } public function executeStatement($stmt) { mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); try { $stmt->execute(); return true; } catch (mysqli_sql_exception $e) { if ($e->getCode() === 1451) { $this->apiOutput(409, ['error' => 'Cannot delete record: dependent data exists.']); } else { $this->apiOutput(500, ['error' => 'Database error: ' . $e->getMessage()]); } return null; } } public function isSuperuser() { $query = "SELECT * FROM vc_users WHERE vc_users.user_uuid = ?"; $stmt = $this->prepareStatement($query); $stmt->bind_param('s', $this->user_uuid); $this->executeStatement($stmt); $result = $stmt->get_result(); $user_data = $result->fetch_assoc(); if ($user_data['user_email'] == 'superuser') { return true; } else { return false; } } protected function buildDynamicQuery(string $tableName): array { $baseQuery = "SELECT * FROM " . $tableName; $whereClauses = []; $types = ''; $values = []; if (!isset($_GET['builder']) || !is_array($_GET['builder'])) { return [$baseQuery, $types, $values]; } foreach ($_GET['builder'] as $builder) { if (!isset($builder['where']) || !is_array($builder['where']) || count($builder['where']) !== 2) { continue; } $column = $builder['where'][0]; $value = $builder['where'][1]; $whereClauses[] = "$column = ?"; $types .= 's'; $values[] = $value; } if (!empty($whereClauses)) { $baseQuery .= " WHERE " . implode(" AND ", $whereClauses); } return [$baseQuery, $types, $values]; } protected function generalGetFunction($query, $types, $params, $returnBoolean, $itemName) { $stmt = $this->prepareStatement($query); if (!empty($params)) { $stmt->bind_param($types, ...$params); } $this->executeStatement($stmt); $result = $stmt->get_result(); if ($result->num_rows === 0) { if (!$returnBoolean) { $this->apiOutput(404, ['error' => $itemName . ' not found.']); } } $tokens = []; while ($row = $result->fetch_assoc()) { $tokens[] = $row; } return $tokens; } public function disableBuilder(): void { if (isset($_GET['builder'])) { unset($_GET['builder']); } } }