Files
dev-chiefworks 47f4fad75c Added Other AP
2022-04-26 11:30:34 -04:00

416 lines
13 KiB
PHP

<?php
require_once('../common/vendor/autoload.php');
use Phpfastcache\CacheManager;
use Phpfastcache\Drivers\Redis\Config;
abstract class Api
{
public $apiName = ''; //trips
protected $method = ''; //GET|POST|PUT|DELETE
public $requestUri = [];
public $requestParams = [];
public $requestHeaders = [];
public $requestWhitelist = [];
public $clientIP = null;
protected $action = '';
//Method name to execute
protected $encryption = true;
public $cacheWhitelist = [];
public $cache;
public $cacheEnabled = false;
public function __construct($requestUri, $encryption=true) {
global $savvyext;
header("Access-Control-Allow-Orgin: *");
header("Access-Control-Allow-Methods: *");
header("Content-Type: application/json");
//GET parameter array separated by slash
//$this->requestUri = explode('/', trim($_SERVER['REQUEST_URI'],'/'));
$this->requestUri = $requestUri;
if (is_array($requestUri) && count($requestUri)>1) {
if (($pos=strpos($requestUri[1],'?'))!==false) {
$requestUri[1] = substr($requestUri[1],1+$pos);
}
parse_str($requestUri[1],$this->requestParams);
}
$this->encryption = $encryption;
//Define the request method
$this->method = $_SERVER['REQUEST_METHOD'];
if ($this->method == 'POST' && array_key_exists('HTTP_X_HTTP_METHOD', $_SERVER)) {
if ($_SERVER['HTTP_X_HTTP_METHOD'] == 'DELETE') {
$this->method = 'DELETE';
}
else if ($_SERVER['HTTP_X_HTTP_METHOD'] == 'PUT') {
$this->method = 'PUT';
}
else {
throw new Exception("Unexpected Header");
}
}
$this->cacheEnabled = ($savvyext->cfgReadLong('cache.enabled') != NULL && $savvyext->cfgReadLong('cache.enabled') == 1);
if ($this->cacheEnabled ) {
$hostsString = $savvyext->cfgReadChar( 'cache.servers' );
$serverConf = [];
if ( ! empty( $hostsString ) ) {
$hostsInfo = explode( ",", $hostsString );
foreach ( $hostsInfo as $hostInfo ) {
$hostInfoItems = explode( ":", $hostInfo );
if ( count( $hostInfoItems ) > 0 ) {
$serverConf = [
'host' => $hostInfoItems[0],
'port' => $hostInfoItems[1] ? intval( $hostInfoItems[1] ) : 6379,
];
break;
}
}
}
if ( count( $serverConf ) != 0 ) {
$conf = new Config($serverConf);
$this->cache = CacheManager::getInstance( 'redis', $conf );
} else {
$this->cacheEnabled = false;
}
}
if ($this->method=="POST") {
$raw_json = file_get_contents("php://input");
$this->requestParams = json_decode($raw_json, true);
}
if ($this->method == "PUT") {
// Do we do key=val&key=val ?
$raw_json = file_get_contents("php://input");
if (strpos($raw_json, 'encrypted_payload') !== false) {
$this->requestParams = json_decode($raw_json, true);
} else {
parse_str($raw_json, $this->requestParams);
}
}
// We can inspect the headers later on
$this->loadRequestHeaders();
// Decrypt the input
if (isset($this->requestParams['encrypted_payload'])) {
$encryptionAlg = $savvyext->cfgReadChar('encryption.algorithm');
$encryptionKey = $savvyext->cfgReadChar('encryption.key');
$encryptionIV = $savvyext->cfgReadChar('encryption.iv');
$payload = openssl_decrypt(
hex2bin(
$this->requestParams['encrypted_payload']
),
$encryptionAlg,
$encryptionKey,
OPENSSL_RAW_DATA,
$encryptionIV
);
unset($this->requestParams['encrypted_payload']);
if (is_array($this->requestParams) && count($this->requestParams)>0) {
$this->requestParams = array_merge($this->requestParams, json_decode($payload, true));
} else {
$data = json_decode($payload, true);
$this->requestParams = is_array($data) ? $data : [];
}
}
}
// No action taken YET!
// TODO: delegate the decision into controller, the default (unset) behaviour is to block
protected function checkRequestHeaders($db, $action) {
error_log('Checking '.$this->apiName.'::'.$action.'...');
if (array_key_exists($action,$this->requestWhitelist)) {
error_log('whitelisted!');
return true;
}
$sessionID = null;
$deviceToken = null;
if (array_key_exists("x-session-id",$this->requestHeaders)) {
$sessionID = $this->requestHeaders["x-session-id"];
}
if (array_key_exists("x-devicetoken",$this->requestHeaders)) {
$deviceToken = $this->requestHeaders["x-devicetoken"];
}
error_log('X-Session-ID: '.$sessionID);
error_log('X-DeviceToken: '.$deviceToken);
// Step 1a: Get member_id by X-DeviceToken
$header_member_id = 0;
$conn = $db->getConnect();
$q = "SELECT * FROM members_devices WHERE access_token='".pg_escape_string($deviceToken)."'";
$r = pg_query($conn, $q);
if ($r && pg_num_rows($r) && $f=pg_fetch_assoc($r)) {
$header_member_id = $f['member_id'];
$q = "UPDATE members_devices SET updated=now(), status=1 WHERE id=".((int)$f['id'])." RETURNING *";
$r = pg_query($conn, $q);
if ($r && pg_num_rows($r) && $f=pg_fetch_assoc($r)) {
error_log('Status updated at: '.$f['updated']);
}
}
if ($header_member_id<1) {
//return false; //throw new RuntimeException('Invalid header member ID', 500);
}
// Step 1b: Get member_id by X-Session-ID
$session_member_id = 0;
$q = "SELECT * FROM members_session WHERE session='".pg_escape_string($sessionID)."'";
$r = pg_query($conn, $q);
if ($r && pg_num_rows($r) && $f=pg_fetch_assoc($r)) {
$session_member_id = $f['member_id'];
if ($header_member_id<1) {
$q = "UPDATE members_devices SET updated=now(), status=1 WHERE id=".((int)$f['id'])." RETURNING *";
$r = pg_query($conn, $q);
if ($r && pg_num_rows($r) && $f=pg_fetch_assoc($r)) {
error_log('Status updated at: '.$f['updated']);
}
}
}
if ($session_member_id<1) {
//return false; //throw new RuntimeException('Invalid session member ID', 500);
}
// Step 2: Get member_id from $this->requestParams
$request_member_id = 0;
if (array_key_exists('member_id',$this->requestParams)) {
$request_member_id = (int)$this->requestParams['member_id'];
}
error_log('member_id[request] = '.$request_member_id);
error_log('member_id[token] = '.$header_member_id);
error_log('member_id[session] = '.$session_member_id);
if ($request_member_id != $session_member_id && $session_member_id>0) {
//$request_member_id = $session_member_id;
$this->requestParams['member_id'] = $session_member_id;
}
// Step 3a: Match Step 1 and 2 result
if ($request_member_id>0) {
// Step 3b: Fallback to X-Session-ID?
if ($request_member_id!=$header_member_id || $request_member_id!=$session_member_id) {
//return false; //throw new RuntimeException('Invalid request member ID', 500);
}
}
return true;
}
protected function loadRequestHeaders() {
$this->requestHeaders = [];
foreach (getallheaders() as $key=>$val) {
// https://cloud.google.com/load-balancing/docs/https/
// After September 30, HTTP(S) Load Balancers will convert HTTP/1.1 header names to lowercase
// in the request and response directions; header values will not be affected.
//error_log('DEBUG: "'.$key.'" => "'.$val.'"');
$this->requestHeaders[strtolower($key)] = $val;
}
return count($this->requestHeaders);
}
protected function checkThrottling($db) {
if (!empty($_SERVER['HTTP_CLIENT_IP']) && filter_var($_SERVER['HTTP_CLIENT_IP'], FILTER_VALIDATE_IP)) {
$ip = pg_escape_string($_SERVER['HTTP_CLIENT_IP']);
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR']) && filter_var($_SERVER['HTTP_X_FORWARDED_FOR'], FILTER_VALIDATE_IP)) {
$ip = pg_escape_string($_SERVER['HTTP_X_FORWARDED_FOR']);
} else {
// Will not make much sense since we are behind the reverse proxy
$ip = pg_escape_string($_SERVER['REMOTE_ADDR']);
}
$this->clientIP = $ip;
$lastRec = NULL;
$rec = NULL;
$conn = $db->getConnect();
$q = "SELECT *,(EXTRACT(EPOCH FROM time_last) * 1000)::bigint AS last_ms FROM throttling_ip WHERE ip='${ip}'";
$r = pg_query($conn, $q);
if ($r && pg_num_rows($r) && $f=pg_fetch_assoc($r)) {
$lastRec = $f;
$q = "UPDATE throttling_ip SET total=total+1,time_last=NOW() WHERE ip='${ip}' RETURNING *,(EXTRACT(EPOCH FROM time_last) * 1000)::bigint AS last_ms";
$r = pg_query($conn, $q);
if ($r && pg_num_rows($r) && $f=pg_fetch_assoc($r)) {
$rec = $f;
}
} else {
$q = "INSERT INTO throttling_ip (ip) VALUES ('${ip}') RETURNING *,(EXTRACT(EPOCH FROM time_last) * 1000)::bigint AS last_ms";
$r = pg_query($conn, $q);
if ($r && pg_num_rows($r) && $f=pg_fetch_assoc($r)) {
$rec = $f;
}
}
if (!$lastRec && $rec && is_array($rec) && count($rec)>0) {
error_log('New IP spotted!');
return true; // New record - never seen this IP before...
}
if (!$lastRec && (!$rec || !is_array($rec) || !count($rec)<1)) {
// Failed to insert new record
error_log('Error: not throttling!');
return true; // TODO: should we fail?
}
// Compare $lastRec to $rec
if (($rec["last_ms"]-$lastRec["last_ms"]) > 10) {
error_log('Not throttle!');
return true;
} else {
error_log('Throttle: ' . $rec["last_ms"] . " - " . $lastRec["last_ms"] . " = ".($rec["last_ms"]-$lastRec["last_ms"]));
return true; // Throttle if less than a second within
}
return true; // OK
}
protected function isActionCached($action) {
error_log('Check is it '.$this->apiName.'::'.$action.' could be cached...');
if (array_key_exists($action,$this->cacheWhitelist) || in_array($action,$this->cacheWhitelist)) {
$ttl = $this->cacheWhitelist[$action]['ttl'] ?? 60;
if ($ttl == 0) {
return false;
}
error_log('whitelisted!');
return true;
}
else return false;
}
public function run() {
//The first 2 elemets of URI array must by "api" and table name
if(array_shift($this->requestUri) !== $this->apiName){
throw new RuntimeException('API Not Found', 404);
}
//Select the action to execute
$this->action = $this->getAction();
$db = new Db();
// Inspect throttling
if (!$this->checkThrottling($db)) {
unset($db);
throw new RuntimeException('Too Many Requests', 429);
}
// Inspect header
if (!$this->checkRequestHeaders($db, $this->action)) {
unset($db);
throw new RuntimeException('Request check failed', 500);
}
unset($db);
//If the method (action) defined in the child API class
if (method_exists($this, $this->action)) {
$result = null;
if( $this->cacheEnabled && $this->isActionCached($this->action)) {
$key = $this->getCacheKey();
$ttl = $this->cacheWhitelist[$this->action]['ttl'] ?? 300;
$cachedString = $this->cache->getItem($key);
$result = $cachedString->get();
if (!is_null($result)) {
return $result;
}
$result = $this->{
$this->action
}
();
if($this->storeInCache()) {
$cachedString->set($result)->expiresAfter($ttl);
$this->cache->save($cachedString);
}
return $result;
} else {
return $this->{
$this->action
}
();
}
}
else {
throw new RuntimeException('Invalid Method', 405);
}
}
protected function response($data, $status = 500) {
global $savvyext;
header("HTTP/1.1 " . $status . " " . $this->requestStatus($status));
if ($this->encryption) {
// encrypt data
$encryptionAlg = $savvyext->cfgReadChar('encryption.algorithm');
$encryptionKey = $savvyext->cfgReadChar('encryption.key');
$encryptionIV = $savvyext->cfgReadChar('encryption.iv');
$payload = json_encode($data);
$encrypted_payload = bin2hex(
openssl_encrypt(
$payload,
$encryptionAlg,
$encryptionKey,
OPENSSL_RAW_DATA,
$encryptionIV
)
);
$data = array(); // Comment out to see the data
$data["payload"] = $encrypted_payload;
}
return json_encode($data);
}
private function requestStatus($code) {
$status = array(
200 => 'OK',
404 => 'Not Found',
405 => 'Method Not Allowed',
500 => 'Internal Server Error',
);
return ($status[$code])?$status[$code]:$status[500];
}
protected function getAction()
{
$method = $this->method;
switch ($method) {
case 'GET':
$pos = strpos($this->requestUri[0],'?');
if ($pos!==false && $pos==0) {
// We want to get "all"
$this->requestUri[0] = "all".$this->requestUri[0];
}
$tok = strtok($this->requestUri[0],'?');
if($tok===false || $tok=='all'){
return 'indexAction';
} else {
return 'viewAction';
}
break;
case 'POST':
return 'createAction';
break;
case 'PUT':
return 'updateAction';
break;
case 'DELETE':
return 'deleteAction';
break;
default:
return null;
}
}
abstract protected function indexAction();
abstract protected function viewAction();
abstract protected function createAction();
abstract protected function updateAction();
abstract protected function deleteAction();
protected function getCacheKey() {
return hash('md5', $this->method.'|'.$this->apiName.'|'.$this->action.'|'.json_encode($this->requestParams));
}
protected function storeInCache() {
return http_response_code() === 200;
}
}