Files
phpMAE/classes/Engine.php
T

337 lines
13 KiB
PHP

<?php
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
namespace CloudObjects\PhpMAE;
use Psr\Http\Message\RequestInterface, Psr\Http\Message\ResponseInterface,
Psr\Http\Message\ServerRequestInterface;
use Psr\Container\ContainerInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Psr7\Response;
use Slim\Factory\ServerRequestCreatorFactory;
use Slim\ResponseEmitter;
use ML\IRI\IRI;
use ML\JsonLD\Node;
use Tuupola\Middleware\HttpBasicAuthentication,
Tuupola\Middleware\CorsMiddleware;
use Relay\Relay;
use Dflydev\FigCookies\SetCookie, Dflydev\FigCookies\FigResponseCookies;
use CloudObjects\SDK\COIDParser, CloudObjects\SDK\NodeReader,
CloudObjects\SDK\ObjectRetriever;
use CloudObjects\SDK\Helpers\SharedSecretAuthentication;
use CloudObjects\PhpMAE\DI\SandboxedContainer;
use CloudObjects\PhpMAE\Exceptions\PhpMAEException;
class Engine implements RequestHandlerInterface {
const SKEY = '__self';
const PREPARE_TIME_LIMIT = 2;
const CLASS_TIME_LIMIT = 5;
const CO_PUBLIC = 'coid://cloudobjects.io/Public';
private $objectRetriever;
private $classRepository;
private $container;
private $reader;
private $object;
private $runClass;
public function __construct(ObjectRetriever $objectRetriever,
ClassRepository $classRepository,
ErrorHandler $errorHandler, ContainerInterface $container) {
$this->objectRetriever = $objectRetriever;
$this->classRepository = $classRepository;
$this->container = $container;
$this->reader = new NodeReader([
'prefixes' => [
'co' => 'coid://cloudobjects.io/',
'phpmae' => 'coid://phpmae.dev/'
]
]);
register_shutdown_function(function(ErrorHandler $handler) {
$handler->getErrorResponse();
}, $errorHandler); // see: http://stackoverflow.com/questions/4410632/handle-fatal-errors-in-php-using-register-shutdown-function
}
private function isObjectPublic() {
return ($this->reader->hasProperty($this->object, 'co:isVisibleTo')
&& $this->reader->getFirstValueIRI($this->object, 'co:isVisibleTo')
->equals(self::CO_PUBLIC)
&& $this->reader->hasProperty($this->object, 'co:permitsUsageTo')
&& $this->reader->getFirstValueIRI($this->object, 'co:permitsUsageTo')
->equals(self::CO_PUBLIC));
}
private function getCORSMiddleware() {
$defaultConfig = [
'headers.allow' => [ 'Content-Type' ],
'cache' => 600
];
if ($this->container->has('global_cors_origins')
&& $this->container->get('global_cors_origins') == '*')
return new CorsMiddleware($defaultConfig); // every origin is allowed, globally
if ($this->reader->getFirstValueString($this->object, 'phpmae:allowsCORSOrigin') == '*')
return new CorsMiddleware($defaultConfig); // every origin is allowed, by class
$origins = $this->reader->getAllValuesString($this->object, 'phpmae:allowsCORSOrigin');
if ($this->container->has('global_cors_origins'))
$origins = array_merge($origins, explode('|', $this->container->get('global_cors_origins')));
if (count($origins) == 0 || trim($origins[0]) == '')
return null; // no CORS enabled
return new CorsMiddleware(array_merge($defaultConfig, [
'origin' => $origins
])); // configured CORS middleware
}
private function getAuthenticationMiddleware() {
$authSchemes = explode('|', $this->container->get('client_authentication'));
if (in_array('none', $authSchemes))
return null; // no authentication required
if (in_array('none:public_only', $authSchemes)
&& $this->isObjectPublic())
return null; // no authentication required
$object = $this->object;
return new HttpBasicAuthentication([
'secure' => !$this->container->has('client_authentication_must_be_secure')
|| $this->container->get('client_authentication_must_be_secure'),
'realm' => 'phpMAE',
'authenticator' => function($args) use ($object, $authSchemes) {
$authenticated = false;
foreach ($authSchemes as $as) {
if (substr($as, 0, 14) == 'shared_secret:') {
$authResult = (SharedSecretAuthentication::verifyCredentials(
$this->objectRetriever, $args['user'], $args['password'])
== SharedSecretAuthentication::RESULT_OK);
if ($authResult != true)
continue;
if (substr($as, 14) == 'runclass')
$authenticated = (substr($object->getId(), 7, strlen($args['user']) +1) == $args['user'].'/');
else
$authenticated = (substr($as, 14) == 'coid://'.$args['user']);
} elseif (substr($as, 0, 4) != 'none')
throw new PhpMAEException("Unsupported authentication scheme!");
if ($authenticated == true)
break;
}
return $authenticated;
}
]);
}
/**
* Executes an invokable class.
*/
private function executeInvokableClass(RequestInterface $request, $args = null) {
if (is_array($args) && count($args) > 0) {
// Explicit arguments take precedence
$input = $args;
} elseif ($request->getMethod() == 'GET') {
// Handle GET requests only if class allows it
if ($this->reader->getFirstValueBool($this->object, 'phpmae:allowsGETRequests'))
$input = $request->getQueryParams();
else
return new Response(405);
} elseif ($request->getMethod() == 'POST') {
// POST is always allowed
$input = $request->getParsedBody();
if (!is_array($input))
$input = [];
} else
return new Response(405);
set_time_limit($this->container->has('execution_time_limit')
? $this->container->get('execution_time_limit') : self::CLASS_TIME_LIMIT);
$result = $this->runClass->get(self::SKEY)->__invoke($input);
return $this->generateResponse($result);
}
/**
* Generates an response with adequate Content Type based on the format of the content.
* @param mixed $content Content for the body of the response.
*/
public function generateResponse($content) {
$lowercaseContent = is_string($content) ? strtolower($content) : '';
if (!isset($content)) {
// Empty response
$response = new Response(204);
} elseif (is_string($content) && (substr($lowercaseContent, 0, 5) == '<html' || substr($lowercaseContent, 0, 14) == '<!doctype html')) {
// HTML response
$response = new Response(200);
$response->getBody()->write($content);
} elseif (is_string($content) && (substr($lowercaseContent, 0, 7) == 'http://' || substr($lowercaseContent, 0, 8) == 'https://')) {
// Redirect response
$response = (new Response(302))->withHeader('Location', $content);
} elseif (is_string($content) || is_numeric($content)) {
// Plain text response
$response = (new Response(200))->withHeader('Content-Type', 'text/plain');
$response->getBody()->write((string)$content);
} elseif (is_object($content) && in_array(ResponseInterface::class, class_implements($content))) {
// Existing response to pass through
$response = $content;
} else {
// JSON response (default)
$response = (new Response(200))->withHeader('Content-Type', 'application/json');
$response->getBody()->write(json_encode($content));
}
// TODO: add support for XML
// Add cookies if any
if (isset($this->runClass) && $this->runClass->has('cookies')) {
foreach ($this->runClass->get('cookies') as $cookie) {
if (is_a($cookie, SetCookie::class))
$response = FigResponseCookies::set($response, $cookie);
}
}
return $response;
}
/**
* Executes a standard class using JSON-RPC.
*/
private function executeJsonRPC(RequestInterface $request) {
$transport = new JsonRPCTransport;
$server = new JsonRPCServer($this->runClass->get(self::SKEY), $transport);
set_time_limit($this->container->has('execution_time_limit')
? $this->container->get('execution_time_limit') : self::CLASS_TIME_LIMIT);
$server->receive((string)$request->getBody());
return $this->generateResponse($transport->getResponse());
}
/**
* Start execution of a request.
*/
public function execute(RequestInterface $request) {
$path = $request->getUri()->getPath();
switch ($path) {
case "":
case "/":
// Display homepage
$file = ($this->container
->get(InteractiveRunController::class)
->isEnabled()) ? 'app.html' : 'app_disabled.html';
return $this->generateResponse(file_get_contents(__DIR__.'/../static/'.$file));
case "/run":
// Run interactive code request
return $this->container
->get(InteractiveRunController::class)
->handle($request, $this);
case "/uploadTestenv":
// Upload into test environment (if enabled)
return $this->container
->get(UploadController::class)
->handle($request);
default:
if (file_exists(__DIR__.'/../static'.$path)) {
// Proxy for static files
$filename = realpath(__DIR__.'/../static'.$path);
$response = new Response(200);
$response->getBody()->write(file_get_contents($filename));
switch (pathinfo($filename, PATHINFO_EXTENSION)) {
case "css":
return $response->withHeader('Content-Type', 'text/css');
case "js":
return $response->withHeader('Content-Type', 'application/javascript');
default:
return $response;
}
}
$coid = COIDParser::fromString(substr($path, 1));
$this->loadRunClass($coid, $request);
// Process a standard request for a phpMAE class
$queue = [
$this->getCORSMiddleware(),
$this->getAuthenticationMiddleware(),
$this
];
$relay = new Relay(
array_filter($queue, function ($q) {
return $q !== null;
})
);
return $relay->handle($request);
}
}
/**
* Load a class to execute.
*/
public function loadRunClass(IRI $coid, RequestInterface $request = null) {
if (COIDParser::isValidCOID($coid) && COIDParser::getType($coid) != COIDParser::COID_ROOT) {
$this->object = $this->objectRetriever->getObjectNode($coid);
if (!isset($this->object))
throw new PhpMAEException("The object <" . (string)$coid . "> does not exist or this phpMAE instance is not allowed to access it.");
$this->runClass = $this->classRepository->createInstance($this->object, $request);
} else {
throw new PhpMAEException("You must provide a valid, non-root COID to specify the class for execution.");
}
}
/**
* Specifies the class for execution in the engine.
*/
public function setRunClass(DI\SandboxedContainer $runClass) {
$this->runClass = $runClass;
}
public function handle(ServerRequestInterface $request, $args = null): ResponseInterface {
try {
if (ClassValidator::isInvokableClass($this->runClass->get(self::SKEY))) {
// Run as invokable class
return $this->executeInvokableClass($request, $args);
} else {
// Run as RPC
// JsonRPC
return $this->executeJsonRPC($request);
}
} catch (\Exception $e) {
throw new PhpMAEException(get_class($e).": ".$e->getMessage());
}
}
/**
* Create main request and execute.
*/
public function run() {
set_time_limit(self::PREPARE_TIME_LIMIT);
$request = ServerRequestCreatorFactory::create()->createServerRequestFromGlobals();
try {
$response = $this->execute($request);
} catch (PhpMAEException $e) {
// Create plain-text error response
$response = (new Response(500))->withHeader('Content-Type', 'text/plain');
$response->getBody()->write($e->getMessage());
}
(new ResponseEmitter())->emit($response);
}
}