Files
Sentri/pub/api/classes/API.php

752 lines
24 KiB
PHP

<?php
namespace api\classes;
class API
{
public $conn;
# The user uuid that requested the API
protected $user_uuid;
# $user_type is either an API call (api) or an call from the frontend (frontend)
protected $user_type;
# Either GET POST PUT or DELETE
public $request_method;
protected $content_type;
# The original posted data to the API, this data is NOT sanitized and validated, never use this data for queries!
public $postedData;
# The validated and sanitized data can be uses for the API actions
public $data;
# The permission of the user to check if the action is allowed.
public $permissions;
# The return url that the frontend request will forward to after the api call is done. if set to false it will only output
# the json response with an http code. API calls always respond with json. $return_url can be set to supply the form with an input
# with the name _return and value of the url to return to.
public $return_url;
# Required fields & optional fields set by the API actions. This is an array like:
# Example:
# 'user_uuid' => ['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 = [];
# Used for the query builder base
public $baseQuery = false;
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];
$rules = $requiredFields[$field] ?? $optionalFields[$field] ?? null;
if (!$rules) {
$this->apiOutput(403, ['error' => "Field not allowed in query: $field"]);
}
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, '<b><i><u><strong><em><p><br>');
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
{
if (!$this->baseQuery) {
$this->baseQuery = "SELECT * FROM " . $tableName;
}
$whereClauses = [];
$types = '';
$values = [];
if (!isset($_GET['builder']) || !is_array($_GET['builder'])) {
return [$this->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)) {
$this->baseQuery .= " WHERE " . implode(" AND ", $whereClauses);
}
return [$this->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']);
}
}
}