416 lines
13 KiB
PHP
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;
|
|
}
|
|
|
|
|
|
}
|
|
|