Files
thetool/lib/mvcfronk/mfBase/mfBaseApicontroller.php

774 lines
21 KiB
PHP

<?php
// include BaseModel if available
include_once(realpath(dirname(__FILE__))."/mfBaseModel.php");
class mfBaseApicontroller {
protected $log;
protected $requestLog;
protected $needlogin = false;
protected $siteTitle;
private $mfAction;
private $mfDBI;
private $mfMenu;
private $mfUser;
protected $requireAuth = true;
protected $me;
protected $mod;
protected $action;
protected $apiversion;
protected $headers = [];
protected $route;
protected $request_json;
protected $raw_post_body;
protected $get = [];
protected $post = [];
protected $format = "default";
protected $allowed_origins = [];
protected $allowMissingOrigin = true;
private $http_method;
private $routes = [];
public function __construct($params = NULL) {
// load logging facility
$this->log = mfLoghandler::singleton();
$this->requestLog = new mfLog_File();
$this->requestLog->init(BASEDIR."/var/log/api-request.log");
$this->logRequest();
$this->loadRequest($params);
$this->logRequest2();
register_shutdown_function(["mfBaseApicontroller", "return_errors"]);
// allow origins from config file
if(defined("API_CORS_ALLOWED_HOSTNAMES") && is_array(API_CORS_ALLOWED_HOSTNAMES)) {
foreach(API_CORS_ALLOWED_HOSTNAMES as $origin) {
$this->addAllowedOrigin($origin);
}
}
// CORS preflight
// allow all origins
if($this->http_method == "OPTIONS") {
// dont execute route, OPTIONS only requires CORS headers
header("Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS");
header("Access-Control-Allow-Headers: X-Api-Key, accept, Content-Type");
if(preg_match('#^(https?)://([^/:]+)(:\d+)?/?$#i', $this->headers['origin'], $m)) {
$origin_proto = $m[1];
$origin_hostname = $m[2];
$origin_port = $m[3];
$allowed_origin = $origin_proto."://".$origin_hostname;
if($origin_port) {
$allowed_origin .= "$origin_port";
}
header("Access-Control-Allow-Origin: $allowed_origin");
$this->return(mfResponse::Ok());
}
}
// run Controllers init() function
if(method_exists($this,"init")) {
$this->init();
}
if($this->requireAuth) {
$this->authenticateUser();
if(method_exists($this,"authenticated")) {
$afterAuthResult = $this->authenticated(); // event defined in extending class
if(mfResponse::isResponse($afterAuthResult)) {
$this->return($afterAuthResult);
}
}
}
// Apicontroller should add allowed hostnames with $this->addAllowedOrigin()
$this->createCorsHeaders();
// route to action
$this->route = $params['apicall'].((array_key_exists("apiparams", $params)) ? $params['apiparams'] : "");
$responseData = $this->runRoute($this->route);
if(!$responseData) {
$this->return(mfResponse::InternalServerError());
}
// return respnse
$this->return($responseData);
}
private function logRequest() {
$this->requestLog->debug("==================================================================");
$this->requestLog->debug("new API request: ".$_SERVER['REQUEST_METHOD']." ".$_SERVER['REQUEST_URI']. " from ".$_SERVER['REMOTE_ADDR']);
$this->requestLogstr = "";
foreach($_GET as $key => $value) {
$this->requestLogstr .= "; $key='$value'";
}
$this->requestLog->debug("GET: ".print_r($_GET, true));
$this->requestLogstr = "";
foreach($_POST as $key => $value) {
$this->requestLogstr .= "; $key='$value'";
}
$this->requestLog->debug("POST: ".print_r($_POST, true));
}
// things to log after loadRequest()
private function logRequest2() {
$this->requestLog->debug("POST Raw: ".$this->raw_post_body);
$this->requestLog->debug("POST JSON: ".$this->request_json);
$this->requestLog->debug("Headers: ".print_r($this->headers, true));
}
private function authenticateUser() {
$key = false;
//var_dump($this->headers);exit;
if(array_key_exists("x-api-key", $this->headers) && $this->headers['x-api-key']) {
$key = $this->headers['x-api-key']; // change to X-Auth-Token
}
if(array_key_exists("apikey", $this->get)) {
$key = $this->get['apikey']; // token
}
$me = new User;
$me->loadByApikey($key);
if(!$me->id) {
header("Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS");
header("Access-Control-Allow-Headers: X-Api-Key, accept, Content-Type");
if(preg_match('#^(https?)://([^/:]+)(:\d+)?/?$#i', $this->headers['origin'], $m)) {
$origin_proto = $m[1];
$origin_hostname = $m[2];
header("Access-Control-Allow-Origin: ".$origin_proto."://".$origin_hostname);
}
$this->return(mfResponse::Unauthorized(['message' => "API key missing or invalid"]));
}
$_SESSION[MFAPPNAME.'_username'] = $me->username;
$this->log->info("Authenticated '".$me->username."' with api key");
$this->me = $me;
}
private function loadRequest($params) {
foreach(apache_request_headers() as $header => $value) {
$this->headers[strtolower($header)] = $value;
}
// GET parameters
$get = $params;
unset($get['mod']);
unset($get['action']);
unset($get['apiv']);
unset($get['apicall']);
unset($get['apiparams']);
unset($get['http_method']);
$this->get = $get;
$this->mod = $params['mod'];
$this->action = $params['action'];
// check for api version
$apiversion = API_VERSION;
if($params['apiv'] && $params['apiv'] != $apiversion) {
$apiversion = $params['apiv'];
}
$this->apiversion = $apiversion;
$this->http_method = strtoupper($_SERVER['REQUEST_METHOD']);
if($this->http_method == "BREW") {
// easter egg :)
$this->return(mfResponse::ImATeaPot());
}
// POST Request
$post = [];
if($this->http_method == "POST" || $this->http_method == "PUT") {
$post = $this->getPostRequest();
if($post === false) {
$post = [];
//$this->return(mfResponse::BadRequest(["message" => "Invalid request body; expected Form-Urlencoded or JSON format"]));
}
$this->post = $post;
}
return true;
}
private function getPostRequest() {
$body = $this->getRequestBody();
if(!is_string($body) && is_array($body)) {
// request is parsed already ($_POST)
return $body;
}
// otherwise request likely is json
$json_request = json_decode($body);
if(json_last_error() === JSON_ERROR_NONE) {
//var_dump((array)$json_request);exit;
$this->request_json = $body;
return (array)$json_request;
}
return false;
}
private function getRequestBody() {
$request_charset = "utf-8";
if(array_key_exists("CONTENT_TYPE", $_SERVER) && preg_match('#application/json#i', $_SERVER["CONTENT_TYPE"])) {
// request body is JSON
$request_body = file_get_contents('php://input');
$m = [];
if(preg_match('#charset\s*=\s*["\']?([^ "\']+)["\']?\s*;?#i', $_SERVER["CONTENT_TYPE"], $m)) {
$request_charset = strtolower($m[1]);
}
if($request_charset != "utf-8") {
$request_body = mb_convert_encoding($request_body, "utf-8", $request_charset);
}
//var_dump(mb_detect_encoding($request_body), $charset);
return $request_body;
}
if($this->http_method == "PUT") {
// PUT requests in application/x-www-form-urlencoded format need special handling
parse_str(file_get_contents("php://input"), $_POST);
}
// Request body is urlencoded or multipart-formdata
if(array_key_exists("CONTENT_TYPE", $_SERVER) && preg_match('#charset\s*=\s*["\']?([^ "\']+)["\']?\s*;?#i', $_SERVER["CONTENT_TYPE"], $m)) {
$request_charset = strtolower($m[1]);
}
$post = [];
if($request_charset == "utf-8") {
$post = $_POST;
} else {
foreach($_POST as $key => $value) {
$post[mb_convert_encoding($key, "utf-8", $request_charset)] = mb_convert_encoding($value, "utf-8", $request_charset);
}
}
return $post;
}
protected function return($response) {
//var_dump($response);exit;
$code = 500;
$status = "Internal Server Error";
$data = [];
if($response['code']) {
$code = $response['code'];
}
if($response['status']) {
$status = $response['status'];
}
if(is_array($response['data'])) {
$data = $response['data'];
}
$proto = "HTTP/1.0";
if($_SERVER["SERVER_PROTOCOL"]) {
$proto = $_SERVER["SERVER_PROTOCOL"];
}
$this->requestLog->debug("$proto $code $status");
$this->requestLog->debug("status $status, result: ");
foreach($data as $key => $res) {
if(is_array($res)) {
$this->requestLog->debug($key.": (".count($res).")");
} elseif(is_object($res)) {
$this->requestLog->debug($key.": object");
} else {
$this->requestLog->debug($key.": ".$res);
}
}
header("$proto $code $status");
header("Content-type: application/json");
//http_response_code($code);
echo json_encode(["status" => $status, "result" => $data]);
exit;
}
public static function staticReturn($response) {
//var_dump($response);exit;
$code = 500;
$status = "Internal Server Error";
$data = [];
if($response['code']) {
$code = $response['code'];
}
if($response['status']) {
$status = $response['status'];
}
if(is_array($response['data'])) {
$data = $response['data'];
}
$proto = "HTTP/1.0";
if($_SERVER["SERVER_PROTOCOL"]) {
$proto = $_SERVER["SERVER_PROTOCOL"];
}
$log = new mfLog_File();
$log->init(BASEDIR."/var/log/api-request.log");
$log->debug("$proto $code $status");
$log->debug("status $status, result: ");
foreach($data as $key => $res) {
if(is_array($res)) {
$log->debug($key.": (".count($res).")");
} else {
$log->debug($key.": ".$res);
}
}
header("$proto $code $status");
header("Content-type: application/json");
//http_response_code($code);
echo json_encode(["status" => $status, "result" => $data]);
exit;
}
private function checkAuth() {
}
private function createCorsHeaders() {
header("Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS");
header("Access-Control-Allow-Headers: X-Api-Key, accept, Content-Type");
if(!is_array($this->allowed_origins) || !count($this->allowed_origins)) {
return true;
}
if(!array_key_exists("origin", $this->headers)) {
if(!$this->allowMissingOrigin) {
$this->return(mfResponse::Forbidden());
}
return true;
}
$request_origin = ["proto" => false, "hostname" => "", "port" => false];
$m = [];
if(preg_match('#^(https?)://([^/:]+)(:\d+)?/?$#i', $this->headers['origin'], $m)) {
$request_origin['proto'] = $m[1];
$request_origin['hostname'] = $m[2];
if(array_key_exists(3, $m) && $m[3]) {
$request_origin['port'] = $m[3];
}
} else {
$this->return(mfResponse::Forbidden(["message" => "Malformed Origin header"]));
}
if($request_origin['hostname'] == "localhost") {
// always allow requests from localhost
$this->log->debug("Allowing localhost Origin");
$allowed_origin = $request_origin['proto']."://".$request_origin['hostname'];
if($request_origin['port']) {
$allowed_origin .= $request_origin['port'];
}
header("Access-Control-Allow-Origin: $allowed_origin");
return true;
}
foreach($this->allowed_origins as $origin) {
//echo $origin." -> ".$_SERVER["HTTP_HOST"];
$proto = false;
$hostname = $origin;
$m = [];
if(preg_match('#^(https?)://([^/]+)/?$#i', $origin, $m)) {
$proto = $m[1];
$hostname = $m[2];
}
if(substr($hostname, 0, 2) == "*.") {
$hostname = str_replace("*.", ".*\\.", $hostname);
}
//var_dump($hostname);exit;
//if($hostname == $request_origin['hostname']) {
if(preg_match('/^'.$hostname.'$/', $request_origin['hostname'])) {
if($proto) {
if($proto == $request_origin['proto']) {
$allowed_origin = $proto."://".$request_origin['hostname'];
if($request_origin['port']) {
$allowed_origin .= $request_origin['port'];
}
header("Access-Control-Allow-Origin: $allowed_origin");
return true;
}
} else {
$allowed_origin = $request_origin['proto']."://".$request_origin['hostname'];
if($request_origin['port']) {
$allowed_origin .= $request_origin['port'];
}
header("Access-Control-Allow-Origin: $allowed_origin");
return true;
}
}
}
if(!$this->allowMissingOrigin) {
$this->return(mfResponse::Forbidden());
exit;
}
return true;
}
protected function runRoute($params) {
if(!is_array($this->routes) || !count($this->routes)) {
return false;
}
//var_dump($params);exit;
$params = trim($params, "/");
$m = [];
if(preg_match('/\.(csv|json|html|txt)$/', $params, $m)) {
if($m[1]) {
$format = strtolower($m[1]);
$params = preg_replace("/\.$format$/", "", $params);
$this->format = $format;
}
}
//var_dump($params);exit;
$req_parts = explode("/", $params);
$req_count = count($req_parts);
foreach($this->routes as $route) {
if($route['method'] != $this->http_method) {
continue;
}
$route_string = trim($route['route'], "/");
$route_parts = explode("/", $route_string);
$route_count = count($route_parts);
if($req_count != $route_count) {
continue;
}
// same number of parts
$vars = [];
foreach($route_parts as $i => $rp) {
if(substr($rp,0,1) == ":") {
// part is variable
$var_name = substr($rp, 1);
$vars[$var_name] = $req_parts[$i];
continue;
} else {
if($rp != $req_parts[$i]) {
continue 2; // break out of this loop and continue outer foreach (try next route)
}
}
}
// found valid route
return $this->call($route['action'], $vars);
}
// no route found
$this->return(mfResponse::BadRequest(["message" => "Invalid endpoint"]));
exit;
}
/**
* Shutdown handler to return PHP errors as API response.
* Errors are still logged in error_log
*/
public static function return_errors() {
$error = error_get_last();
//var_dump($error);exit;
if($error && $error['type'] & (E_ERROR|E_CORE_ERROR|E_COMPILE_ERROR|E_USER_ERROR|E_RECOVERABLE_ERROR)) {
mfBaseApicontroller::staticReturn(mfResponse::InternalServerError(["message" => "An internal error occured, please try again"]));
}
/*header("Content-type: application/json");
http_response_code($code);
echo json_encode(["status" => $status, "result" => $data]);
exit;*/
}
private function call($function, $params = []) {
$args = $params;
if(count($params) === 1) {
$args = reset($params);
}
if(is_array($function)) {
return call_user_func($function, $args);
}
return $this->__call($function,$args);
}
protected function addRoute($route, $action, $method) {
$this->routes[] = [
"route" => $route,
"action" => $action,
"method" => $method
];
return true;
}
protected function addAllowedOrigin($origin) {
$this->allowed_origins[] = trim($origin);
$this->allowed_origins = array_unique($this->allowed_origins);
return true;
}
public static function loadApiClass($name) {
//var_dump($name);exit;
if(!$name) {
return false;
}
$folder = APPDIR."Api/".((defined("CURRENT_API_VERSION")) ? CURRENT_API_VERSION : API_VERSION);
$m = [];
if(preg_match('/(.+)Apicontroller/',$name, $m)) {
$classname = $m[1]."Apicontroller";
$filename = "$classname.php";
if(file_exists("$folder/$filename")) {
require_once "$folder/$filename";
}
}
}
protected function user() {
if(!MFUSELOGIN) {
trigger_error("mvcfronk: Tried to access mfBaseController::user(), though MFUSELOGIN is set to false.", E_USER_WARNING);
return false;
}
if(!$this->mfUser) {
$this->mfUser=mfUser::singleton();
}
return $this->mfUser;
}
protected function db() {
$args=func_get_args();
// if no arguments, just return a DB instance
if(!$args) {
// don't allow managed FronkDB instance, but new custom instance is allowed
if(!FRONKDB) {
return false;
}
if(!is_object($this->mfDBI)) {
$this->mfDBI=FronkDB::singleton();
}
} else {
// else return a new instance
$dbhost=$args[0];
$dbuser=$args[1];
$dbpass=$args[2];
$dbname=$args[3];
$this->mfDBI = $this->getNewDBInstance($dbhost,$dbuser,$dbpass,$dbname);
}
return $this->mfDBI;
}
public function __call($name,$params) {
if(method_exists($this,$name)) {
return call_user_func(array($this, $name), $params);
} else { // if function doesn't exist, maybe it's an Action
$funcname=lcfirst($name);
if(!preg_match('/Action$/',$name)) {
$funcname.="Action";
}
if(method_exists($this,$funcname)) {
return call_user_func(array($this, $funcname), $params);
} else {
throw new Exception(get_class($this).": $name not found",404);
}
}
}
public function __get($name) {
if($name == "db") {
return $this->db();
}
return null;
}
protected function logout() {
mfLoginController::logout();
$this->redirect(DEFAULT_ROUTE);
}
/*
* private internal functions
*/
private function getNewDBInstance($dbhost=false,$dbuser=false,$dbpass=false,$dbname=false) {
if(!$dbhost) $dbhost=FRONKDB_DBHOST;
if(!$dbuser) $dbhost=FRONKDB_DBUSER;
if(!$dbpass) $dbhost=FRONKDB_DBPASS;
if(!$dbname) $dbname=FRONKDB_DBNAME;
return new FronkDB($dbhost,$dbuser,$dbpass,$dbname);
}
public static function redirect($mod=false,$action=false,$params=false,$anker=false) {
//var_dump($mod);
//var_dump($action);
$log = mfLoghandler::singleton();
if(MFUSEFANCYURLS && defined('MFFANCYBASEURL')) {
// use fancy urls
$url=MFFANCYBASEURL;
if($mod) {
$url.="/$mod";
if($action) {
$url.="/$action";
}
}
} else {
// no fancy urls
if(!$mod) {
$url="?";
} elseif($mod) {
$url="?action=$mod";
if($action) {
$url.="_$action";
}
}
}
/*
if(is_array($params) && count($params)) {
foreach($params as $k => $v) {
$url.="&$k=$v";
}
}*/
if(is_array($params) && count($params)) {
$url .= (MFUSEFANCYURLS) ? "/?" : "&";
foreach($params as $k => $v) {
$v = urlencode($v);
if($k) {
$k = urlencode($k);
$url .= "$k=$v&";
} else {
$url .= "$v&";
}
}
$url = preg_replace('/&$/', '', $url);
}
if($anker) {
$url.="#$anker";
}
$log->debug("Redirecting to $url");
header("Location: $url");
exit;
}
public static function getUrl($mod, $action=null, $param=null) {
if(!$mod) {
return "";
}
if(MFUSEFANCYURLS) {
// use fancy urls
$url=MFFANCYBASEURL;
if($mod) {
$url.="/$mod";
if($action) {
$url.="/$action";
}
}
$url = preg_replace('#//#','/',$url);
} else {
// no fancy urls
$url="?action=$mod";
if($action) {
$url.="_$action";
}
}
if(is_array($param) && count($param)) {
$url .= (MFUSEFANCYURLS) ? "/" : "&amp;";
$param_qs = http_build_query($param);
$url .= "$param_qs";
}
return $url;
}
public static function returnJson($data) {
if(is_array($data)) {
header("Content-Type: application/json");
echo json_encode($data);
exit;
} else {
throw new Exception("Data not an array");
}
}
// Helper functions
public static function dateToTimestamp($date) {
$t = array(0,0,0);
// extract day, month, year
if (!preg_match('/^(\d{1,2})\.(\d{1,2})\.(\d{2,4})/',$date,$d)) {
return false;
}
// extract time if available
if (preg_match('/(\d\d):(\d\d):(\d\d)$/',$date,$t)) {
if (!$t[3]) {
$t[3] = 0;
}
}
// make and return timestamp
$ts = mktime($t[1],$t[2],$t[3],$d[2],$d[1],$d[3]);
return $ts;
}
public static function dateToDB($date,$type='l') {
// get timestamp
$ts = self::dateToTimestamp($date);
// only proceed if timestamp conversion was successful
if(!$ts) {
return false;
}
// return date and time if long type requested
if($type = 'l') {
$dbdate = date('Y-m-d H:i:s',$ts);
} else {
$dbdate = date('Y-m-d',$ts);
}
return $dbdate;
}
}