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; } }