From d54792f064d5d77c506ae7fc906fec0d2c7e5337 Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Wed, 12 Jan 2022 10:29:02 +0100 Subject: [PATCH 01/16] Change docker-compose.yml to mount local application directory That way a restart is not needed when code changes are made. --- docker-compose.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 9057a1a..03bc797 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,10 @@ services: ports: - 80:80 - 443:443 + volumes: + - .:/app/ + # @TODO: The storage directory should be mounted separately + # as it really _should_ live outside the code directory pubsub: build: context: https://github.com/pdsinterop/php-solid-pubsub-server.git#main From 23066c1a963e6a6c25c8f5d00af90d07c7427154 Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Wed, 12 Jan 2022 10:32:23 +0100 Subject: [PATCH 02/16] Change project README to be cleaner - Remove commented out sections - Update ToC - Update Project structure overview - Change inline links to references --- README.md | 75 ++++++++++++++++++------------------------------------- 1 file changed, 24 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index d8a1729..46d3f90 100644 --- a/README.md +++ b/README.md @@ -16,18 +16,17 @@ _Standalone Solid Server written in PHP by PDS Interop_ - [Background](#background) - * [Installation](#installation) +- [Installation](#installation) - [Usage](#usage) - * [Docker images](#docker-images) - * [Local environment](#local-environment) - + [Built-in PHP HTTP server](#built-in-php-http-server) + - [Docker images](#docker-images) + - [Local environment](#local-environment) + - [Built-in PHP HTTP server](#built-in-php-http-server) - [Security](#security) - [Running solid/webid-provider-tests](#running-solidwebid-provider-tests) - [Available Features](#available-features) - [Development](#development) - * [Project structure](#project-structure) - * [Coding conventions](#coding-conventions) - * [Testing](#testing) + - [Project structure](#project-structure) + - [Testing](#testing) - [Contributing](#contributing) - [License](#license) @@ -47,10 +46,6 @@ they define: - Resource (reading and writing) API - Social Web App Protocols (Notifications, Friends Lists, Followers and Following) - - ## Installation To install the project, clone it from GitHub and install the PHP dependencies @@ -67,8 +62,6 @@ At this point, the application is ready to run. The PHP Solid server can be run in several different ways. - - The application can be run with a Docker image of your choice or on a local environment, using Apache, NginX, or PHP's internal HTTP server. The latter is only advised in development. @@ -78,11 +71,6 @@ For security reasons, the server expects to run on HTTPS (also known as HTTP+TLS To run insecure, set the environment variable `ENVIRONMENT` to `develop`. This will prohibit the application from running in production mode. - - ### Docker images When running with your own Docker image, make sure to mount the project folder @@ -213,67 +201,52 @@ The underlying functionality for these features is provided by: This project is structured as follows: - ``` . - ├── src <- Source code - ├── vendor <- Third-party and vendor code - ├── web <- Web content + ├── bin/ <- CLI scripts + ├── config/ <- Empty directory where server configuration is generated + ├── docs/ <- Documentation + ├── src/ <- Source code + ├── tests/ <- Test fixtures, Integration- and unit-tests + ├── vendor/ <- Third-party and vendor code + ├── web/ <- Web content ├── composer.json <- PHP package and dependency configuration └── README.md <- You are now here ``` - ## Contributing -Questions or feedback can be given by [opening an issue on GitHub](https://github.com/pdsinterop/php-solid-server/issues). +Questions or feedback can be given by [opening an issue on GitHub][issues-link]. All PDS Interop projects are open source and community-friendly. Any contribution is welcome! -For more details read the [contribution guidelines](CONTRIBUTING.md). +For more details read the [contribution guidelines][contributing-link]. All PDS Interop projects adhere to [the Code Manifesto](http://codemanifesto.com) -as its [code-of-conduct](CODE_OF_CONDUCT.md). Contributors are expected to abide by its terms. +as its [code-of-conduct][code-of-conduct]. Contributors are expected to abide by its terms. There is [a list of all contributors on GitHub][contributors-page]. -For a list of changes see the [CHANGELOG](CHANGELOG.md) or the GitHub releases page. +For a list of changes see the [CHANGELOG][changelog] or [the GitHub releases page][releases-page]. ## License All code created by PDS Interop is licensed under the [MIT License][license-link]. -[contributors-page]: https://github.com/pdsinterop/php-solid-server/contributors +[changelog]: CHANGELOG.md +[code-of-conduct]: CODE_OF_CONDUCT.md +[contributing-link]: CONTRIBUTING.md +[contributors-page]: https://github.com/pdsinterop/php-solid-server/contributors +[issues-link]: https://github.com/pdsinterop/php-solid-server/issues +[releases-page]: https://github.com/pdsinterop/php-solid-server/releases [keep-a-changelog-link]: https://keepachangelog.com/ [keep-a-changelog-shield]: https://img.shields.io/badge/Keep%20a%20Changelog-f15d30.svg?logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9IiNmZmYiIHZpZXdCb3g9IjAgMCAxODcgMTg1Ij48cGF0aCBkPSJNNjIgN2MtMTUgMy0yOCAxMC0zNyAyMmExMjIgMTIyIDAgMDAtMTggOTEgNzQgNzQgMCAwMDE2IDM4YzYgOSAxNCAxNSAyNCAxOGE4OSA4OSAwIDAwMjQgNCA0NSA0NSAwIDAwNiAwbDMtMSAxMy0xYTE1OCAxNTggMCAwMDU1LTE3IDYzIDYzIDAgMDAzNS01MiAzNCAzNCAwIDAwLTEtNWMtMy0xOC05LTMzLTE5LTQ3LTEyLTE3LTI0LTI4LTM4LTM3QTg1IDg1IDAgMDA2MiA3em0zMCA4YzIwIDQgMzggMTQgNTMgMzEgMTcgMTggMjYgMzcgMjkgNTh2MTJjLTMgMTctMTMgMzAtMjggMzhhMTU1IDE1NSAwIDAxLTUzIDE2bC0xMyAyaC0xYTUxIDUxIDAgMDEtMTItMWwtMTctMmMtMTMtNC0yMy0xMi0yOS0yNy01LTEyLTgtMjQtOC0zOWExMzMgMTMzIDAgMDE4LTUwYzUtMTMgMTEtMjYgMjYtMzMgMTQtNyAyOS05IDQ1LTV6TTQwIDQ1YTk0IDk0IDAgMDAtMTcgNTQgNzUgNzUgMCAwMDYgMzJjOCAxOSAyMiAzMSA0MiAzMiAyMSAyIDQxLTIgNjAtMTRhNjAgNjAgMCAwMDIxLTE5IDUzIDUzIDAgMDA5LTI5YzAtMTYtOC0zMy0yMy01MWE0NyA0NyAwIDAwLTUtNWMtMjMtMjAtNDUtMjYtNjctMTgtMTIgNC0yMCA5LTI2IDE4em0xMDggNzZhNTAgNTAgMCAwMS0yMSAyMmMtMTcgOS0zMiAxMy00OCAxMy0xMSAwLTIxLTMtMzAtOS01LTMtOS05LTEzLTE2YTgxIDgxIDAgMDEtNi0zMiA5NCA5NCAwIDAxOC0zNSA5MCA5MCAwIDAxNi0xMmwxLTJjNS05IDEzLTEzIDIzLTE2IDE2LTUgMzItMyA1MCA5IDEzIDggMjMgMjAgMzAgMzYgNyAxNSA3IDI5IDAgNDJ6bS00My03M2MtMTctOC0zMy02LTQ2IDUtMTAgOC0xNiAyMC0xOSAzN2E1NCA1NCAwIDAwNSAzNGM3IDE1IDIwIDIzIDM3IDIyIDIyLTEgMzgtOSA0OC0yNGE0MSA0MSAwIDAwOC0yNCA0MyA0MyAwIDAwLTEtMTJjLTYtMTgtMTYtMzEtMzItMzh6bS0yMyA5MWgtMWMtNyAwLTE0LTItMjEtN2EyNyAyNyAwIDAxLTEwLTEzIDU3IDU3IDAgMDEtNC0yMCA2MyA2MyAwIDAxNi0yNWM1LTEyIDEyLTE5IDI0LTIxIDktMyAxOC0yIDI3IDIgMTQgNiAyMyAxOCAyNyAzM3MtMiAzMS0xNiA0MGMtMTEgOC0yMSAxMS0zMiAxMXptMS0zNHYxNGgtOFY2OGg4djI4bDEwLTEwaDExbC0xNCAxNSAxNyAxOEg5NnoiLz48L3N2Zz4K [license-link]: ./LICENSE From b484ed7ae7c5ff12130b99fa9f43145021f32538 Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Wed, 12 Jan 2022 10:35:02 +0100 Subject: [PATCH 03/16] Change Card controller to allow Content-Type based on file extension. --- src/Controller/Profile/CardController.php | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/Controller/Profile/CardController.php b/src/Controller/Profile/CardController.php index 6dc95a5..45a3d82 100644 --- a/src/Controller/Profile/CardController.php +++ b/src/Controller/Profile/CardController.php @@ -27,7 +27,7 @@ class CardController extends AbstractController */ final public function __invoke(ServerRequestInterface $request, array $args): ResponseInterface { - // @FIXME: Target file is hard-coded for not, replace with path from $request->getRequestTarget() + // @FIXME: Target file is hard-coded for now, replace with path from $request->getRequestTarget() $filePath = '/foaf.rdf'; $filesystem = $this->getFilesystem(); $extension = '.ttl'; @@ -47,11 +47,19 @@ final public function __invoke(ServerRequestInterface $request, array $args): Re $contentType = $this->getContentTypeForFormat($format); - $serverParams = $request->getServerParams(); - $url = $serverParams["REQUEST_URI"] ?? ''; + $url = (string) $request->getUri(); - /** @noinspection PhpUndefinedMethodInspection */ // Method `readRdf` is defined by plugin - $content = $filesystem->readRdf($filePath, $format, $url); + if (substr($url, -strlen($extension)) === $extension) { + $url = substr($url, 0, -strlen($extension)); + } + + try { + $content = $filesystem->readRdf($filePath, $format, $url); + } catch (\Pdsinterop\Rdf\Flysystem\Exception $exception) { + $content = $exception->getMessage(); + + return $this->createTextResponse($content)->withStatus(500); + } return $this->createTextResponse($content)->withHeader('Content-Type', $contentType); } @@ -105,6 +113,7 @@ private function getFormatForExtension(string $extension) : string $format = ''; switch ($extension) { + case '.json': case '.jsonld': $format = Format::JSON_LD; break; @@ -117,6 +126,7 @@ private function getFormatForExtension(string $extension) : string $format = Format::NOTATION_3; break; + case '.xml': case '.rdf': $format = Format::RDF_XML; break; @@ -130,5 +140,5 @@ private function getFormatForExtension(string $extension) : string } return $format; -} + } } From f7fbae8af5bcf98a98bc1f979c606dd0b6f1f419 Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Wed, 12 Jan 2022 10:36:17 +0100 Subject: [PATCH 04/16] Add more error handling to AuthorizeController. --- src/Controller/AuthorizeController.php | 93 ++++++++++++++++++-------- src/Controller/ServerController.php | 5 +- 2 files changed, 68 insertions(+), 30 deletions(-) diff --git a/src/Controller/AuthorizeController.php b/src/Controller/AuthorizeController.php index bb0a427..267d10d 100644 --- a/src/Controller/AuthorizeController.php +++ b/src/Controller/AuthorizeController.php @@ -10,62 +10,99 @@ class AuthorizeController extends ServerController final public function __invoke(ServerRequestInterface $request, array $args): ResponseInterface { if (!isset($_SESSION['userid'])) { - $response = $this->getResponse(); - $response = $response->withStatus(302, "Approval required"); - // FIXME: Generate a proper url for this; - $baseUrl = $this->baseUrl; - $loginUrl = $baseUrl . "/login/?returnUrl=" . urlencode($_SERVER['REQUEST_URI']); - $response = $response->withHeader("Location", $loginUrl); - return $response; + $loginUrl = $this->baseUrl . "/login/?returnUrl=" . urlencode($_SERVER['REQUEST_URI']); + + return $this->getResponse() + ->withHeader("Location", $loginUrl) + ->withStatus(302, "Approval required") + ; } + + $queryParams = $request->getQueryParams(); + + if (! isset($queryParams['request'])) { + return $this->getResponse() + ->withStatus(400, "Bad request, missing request") + ; + } + $parser = new \Lcobucci\JWT\Parser(); + try { + $token = $parser->parse($queryParams['request']); + } catch (\Exception $exception) { + return $this->getResponse() + ->withStatus(400, $exception->getMessage()) + ; + } + try { - $token = $parser->parse($request->getQueryParams()['request']); $_SESSION["nonce"] = $token->getClaim('nonce'); - } catch(\Exception $e) { - $_SESSION["nonce"] = $request->getQueryParams()['nonce']; + } catch(\OutOfBoundsException $e) { + if (! isset($queryParams['nonce'])) { + return $this->getResponse() + ->withStatus(400, "Bad request, missing nonce") + ; + } + + $_SESSION["nonce"] = $queryParams['nonce']; } - $getVars = $request->getQueryParams(); + /*/ Prepare GET parameters for OAUTH server request /*/ + $getVars = $queryParams; + + $getVars['response_type'] = $this->getResponseType($queryParams); + $getVars['scope'] = "openid" ; + if (!isset($getVars['grant_type'])) { $getVars['grant_type'] = 'implicit'; } - $getVars['response_type'] = $this->getResponseType(); - $getVars['scope'] = "openid" ; if (!isset($getVars['redirect_uri'])) { try { $getVars['redirect_uri'] = $token->getClaim("redirect_uri"); } catch(\Exception $e) { - $response = $this->getResponse(); - $response->withStatus(400, "Bad request, missing redirect uri"); - return $response; + return $this->getResponse() + ->withStatus(400, "Bad request, missing redirect uri") + ; } } - $clientId = $getVars['client_id']; - $approval = $this->checkApproval($clientId); + + if (! isset($queryParams['client_id'])) { + return $this->getResponse() + ->withStatus(400, "Bad request, missing client_id") + ; + } + + $clientId = $getVars['client_id']; + $approval = $this->checkApproval($clientId); if (!$approval) { - $response = $this->getResponse(); - $response = $response->withStatus(302, "Approval required"); - // FIXME: Generate a proper url for this; - $baseUrl = $this->baseUrl; - $approvalUrl = $baseUrl . "/sharing/$clientId/?returnUrl=" . urlencode($_SERVER['REQUEST_URI']); - $response = $response->withHeader("Location", $approvalUrl); - return $response; + $approvalUrl = $this->baseUrl . "/sharing/$clientId/?returnUrl=" . urlencode($_SERVER['REQUEST_URI']); + + return $this->getResponse() + ->withHeader("Location", $approvalUrl) + ->withStatus(302, "Approval required") + ; } + // replace the request getVars with the morphed version + $request = $request->withQueryParams($getVars); + $user = new \Pdsinterop\Solid\Auth\Entity\User(); $user->setIdentifier($this->getProfilePage()); - $request = $request->withQueryParams($getVars); // replace the request getVars with the morphed version; $response = new \Laminas\Diactoros\Response(); $server = new \Pdsinterop\Solid\Auth\Server($this->authServerFactory, $this->authServerConfig, $response); $response = $server->respondToAuthorizationRequest($request, $user, $approval); - $response = $this->tokenGenerator->addIdTokenToResponse($response, $clientId, $this->getProfilePage(), $_SESSION['nonce'], $this->config->getPrivateKey()); - return $response; + + return $this->tokenGenerator->addIdTokenToResponse($response, + $clientId, + $this->getProfilePage(), + $_SESSION['nonce'], + $this->config->getPrivateKey() + ); } } diff --git a/src/Controller/ServerController.php b/src/Controller/ServerController.php index afce921..93d44d0 100644 --- a/src/Controller/ServerController.php +++ b/src/Controller/ServerController.php @@ -126,9 +126,10 @@ public function getProfilePage() : string return $this->baseUrl . "/profile/card#me"; // FIXME: would be better to base this on the available routes if possible. } - public function getResponseType() : string + public function getResponseType($params) : string { - $responseTypes = explode(" ", $_GET['response_type'] ?? ''); + $responseTypes = explode(" ", $params['response_type'] ?? ''); + foreach ($responseTypes as $responseType) { switch ($responseType) { case "token": From 127f12c66e4c274e2b2b1298c3f4cb419f52a409 Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Wed, 12 Jan 2022 10:40:42 +0100 Subject: [PATCH 05/16] Change ResourceController to be cleaner - Add typehint comments - Whitespace fixes - Code restructure --- src/Controller/ResourceController.php | 63 +++++++++++++++------------ 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/src/Controller/ResourceController.php b/src/Controller/ResourceController.php index 1699029..cf20f0c 100644 --- a/src/Controller/ResourceController.php +++ b/src/Controller/ResourceController.php @@ -14,8 +14,11 @@ class ResourceController extends ServerController /** @var Server */ private $server; - private $DPop; - private $WAC; + /** @var DPop */ + private $DPop; + /** @var WAC */ + private $WAC; + //////////////////////////////// PUBLIC API \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ final public function __construct(Server $server) @@ -45,25 +48,27 @@ final public function __invoke(Request $request, array $args) : Response $allowedOrigins = $this->config->getAllowedOrigins(); $origins = $request->getHeader('Origin'); - $isAllowed = false; - foreach ($origins as $origin) { - if ($this->WAC->isAllowed($request, $webId, $origin, $allowedOrigins)) { - $isAllowed = true; - break; - } - } + if ($origins !== []) { + foreach ($origins as $origin) { + if ($this->WAC->isAllowed($request, $webId, $origin, $allowedOrigins)) { + $response = $this->server->respondToRequest($request); - if (! $isAllowed) { + return $this->WAC->addWACHeaders($request, $response, $webId); + } + } return $this->server->getResponse()->withStatus(403, 'Access denied'); - } + } else { + $response = $this->server->respondToRequest($request); - $response = $this->server->respondToRequest($request); - - return $this->WAC->addWACHeaders($request, $response, $webId); + return $this->WAC->addWACHeaders($request, $response, $webId); + } } - private function generateDefaultAcl() { - $defaultProfile = <<< EOF + ////////////////////////////// UTILITY METHODS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\ + + private function generateDefaultAcl() + { + $defaultProfile = <<< EOF # Root ACL resource for the user account @prefix acl: . @prefix foaf: . @@ -74,26 +79,26 @@ private function generateDefaultAcl() { acl:accessTo ; acl:default ; acl:mode - acl:Read. + acl:Read. # The owner has full access to every resource in their pod. # Other agents have no access rights, # unless specifically authorized in other .acl resources. <#owner> - a acl:Authorization; - acl:agent <{user-profile-uri}>; - # Set the access to the root storage folder itself - acl:accessTo ; - # All resources will inherit this authorization, by default - acl:default ; - # The owner has all of the access modes allowed - acl:mode - acl:Read, acl:Write, acl:Control. + a acl:Authorization; + acl:agent <{user-profile-uri}>; + # Set the access to the root storage folder itself + acl:accessTo ; + # All resources will inherit this authorization, by default + acl:default ; + # The owner has all of the access modes allowed + acl:mode + acl:Read, acl:Write, acl:Control. EOF; - $profileUri = $this->getUserProfile(); - $defaultProfile = str_replace("{user-profile-uri}", $profileUri, $defaultProfile); - return $defaultProfile; + $profileUri = $this->getUserProfile(); + + return str_replace("{user-profile-uri}", $profileUri, $defaultProfile); } private function getUserProfile() { From 93e87e94c5cccf719684cb702ab1f7c80a827b82 Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Wed, 12 Jan 2022 10:41:39 +0100 Subject: [PATCH 06/16] Change StorageController to be cleaner - Whitespace fixes - Code restructure --- src/Controller/StorageController.php | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Controller/StorageController.php b/src/Controller/StorageController.php index d54c0a9..c4c80a9 100644 --- a/src/Controller/StorageController.php +++ b/src/Controller/StorageController.php @@ -8,7 +8,7 @@ class StorageController extends ServerController { final public function __invoke(ServerRequestInterface $request, array $args): ResponseInterface - { + { $body = <<< EOF @prefix : <#>. @prefix inbox: <>. @@ -23,10 +23,7 @@ final public function __invoke(ServerRequestInterface $request, array $args): Re st:mtime 1576853574.389; st:size 4096. EOF; - $response = $this->getResponse(); - - $response->getBody()->write($body); - return $response - ->withHeader("Content-type", "text/turtle"); + + return $this->createTextResponse($body)->withHeader("Content-type", "text/turtle"); } } From c0ed27505eb385d08d6d31a394d5f47a387f376e Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Wed, 12 Jan 2022 10:43:15 +0100 Subject: [PATCH 07/16] Change ServerController to be cleaner. --- src/Controller/ServerController.php | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/Controller/ServerController.php b/src/Controller/ServerController.php index 93d44d0..6f17a9b 100644 --- a/src/Controller/ServerController.php +++ b/src/Controller/ServerController.php @@ -2,6 +2,10 @@ namespace Pdsinterop\Solid\Controller; +use Pdsinterop\Solid\Auth\Config\Client; +use Pdsinterop\Solid\Auth\Enum\Authorization; +use Pdsinterop\Solid\Auth\Factory\ConfigFactory; + abstract class ServerController extends AbstractController { protected $authServerConfig; @@ -58,20 +62,15 @@ public function createAuthServerConfig() $clientId = $_GET['client_id']; // FIXME: No request object here to get the client Id from. $client = $this->getClient($clientId); $keys = $this->getKeys(); - try { - $config = (new ConfigFactory( - $client, - $keys['encryptionKey'], - $keys['privateKey'], - $keys['publicKey'], - $this->getOpenIdEndpoints() - ))->create(); - } catch (Throwable $e) { - // var_dump($e); - } - return $config; - } + return (new ConfigFactory( + $client, + $keys['encryptionKey'], + $keys['privateKey'], + $keys['publicKey'], + $this->getOpenIdEndpoints() + ))->create(); + } public function getClient($clientId) { @@ -93,11 +92,9 @@ public function getClient($clientId) public function createConfig() { - // if (isset($_GET['client_id'])) { $clientId = $_GET['client_id']; $client = $this->getClient($clientId); - // } return (new ConfigFactory( $client, $this->keys['encryptionKey'], @@ -106,6 +103,7 @@ public function createConfig() $this->openIdConfiguration ))->create(); } + public function checkApproval($clientId) { $approval = Authorization::DENIED; @@ -121,6 +119,7 @@ public function checkApproval($clientId) return $approval; } + public function getProfilePage() : string { return $this->baseUrl . "/profile/card#me"; // FIXME: would be better to base this on the available routes if possible. From 7070f11a045913789e9bf765853b834cd1c8232b Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Wed, 12 Jan 2022 10:44:33 +0100 Subject: [PATCH 08/16] Add exception type to catch-all error message. --- web/index.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/index.php b/web/index.php index 40feee9..2a0f3e5 100644 --- a/web/index.php +++ b/web/index.php @@ -214,7 +214,8 @@ $response = new HtmlResponse($html, $status, $exception->getHeaders()); } catch (\Throwable $exception) { - $html = "

Oh-no! The developers messed up!

{$exception->getMessage()}

"; + $class = get_class($exception); + $html = "

Oh-no! The developers messed up!

{$exception->getMessage()} ($class)

"; if (getenv('ENVIRONMENT') === 'development') { $html .= From 6f79a72e0b5074fee942c238f12654e33361f86d Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Wed, 12 Jan 2022 10:47:42 +0100 Subject: [PATCH 09/16] Add route for favicon. --- web/index.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/web/index.php b/web/index.php index 2a0f3e5..a572b8d 100644 --- a/web/index.php +++ b/web/index.php @@ -7,6 +7,7 @@ use Laminas\Diactoros\Request; use Laminas\Diactoros\Response; use Laminas\Diactoros\Response\HtmlResponse; +use Laminas\Diactoros\Response\TextResponse; use Laminas\Diactoros\ServerRequestFactory; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; use League\Container\Container; @@ -160,6 +161,14 @@ // @FIXME: CORS handling, slash-adding (and possibly others?) should be added as middleware instead of "catchall" URLs map /*/ Create URI groups /*/ +if (file_exists(__DIR__. '/favicon.ico') === false) { + $router->map('GET', '/favicon.ico', static function () { + return (new TextResponse( + '', + ))->withHeader('Content-type', 'image/svg+xml'); + }); +} + $router->map('GET', '/login', AddSlashToPathController::class); $router->map('GET', '/profile', AddSlashToPathController::class); From 7b95c681c034bc11fb833cc840fd7ba69d122e8e Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Wed, 12 Jan 2022 10:49:09 +0100 Subject: [PATCH 10/16] Change Storage directory to be set from environment. --- web/index.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/index.php b/web/index.php index a572b8d..dd23583 100644 --- a/web/index.php +++ b/web/index.php @@ -57,9 +57,9 @@ $container->add(ResponseInterface::class, Response::class); $container->share(FilesystemInterface::class, function () use ($request) { - // @FIXME: Filesystem root and the $adapter should be configurable. + // @FIXME: Filesystem $adapter should be configurable. // Implement this with `$filesystem = \MJRider\FlysystemFactory\create(getenv('STORAGE_ENDPOINT'));` - $filesystemRoot = __DIR__ . '/../tests/fixtures'; + $filesystemRoot = getenv('STORAGE_ENDPOINT') ?: __DIR__ . '/../tests/fixtures'; $adapter = new \League\Flysystem\Adapter\Local($filesystemRoot); From 23d7507dab966d73281c7b77d632451585f21c49 Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Wed, 12 Jan 2022 10:51:38 +0100 Subject: [PATCH 11/16] Fix "405 Method Not Allowed" bug caused by routing. --- web/index.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/web/index.php b/web/index.php index dd23583..b8d0a39 100644 --- a/web/index.php +++ b/web/index.php @@ -156,6 +156,13 @@ $router->map('GET', '/{page:(?:.|/)*}', HttpToHttpsController::class)->setScheme('http'); } +/*/ To prevent "405 Method Not Allowed" from the Router we only map `/*` to + * OPTIONS when OPTIONS are actually requested. +/*/ +if ($request->getMethod() === 'OPTIONS') { + $router->map('OPTIONS', '/{path:.*}', CorsController::class); +} + $router->map('GET', '/', HelloWorldController::class); // @FIXME: CORS handling, slash-adding (and possibly others?) should be added as middleware instead of "catchall" URLs map @@ -171,8 +178,6 @@ $router->map('GET', '/login', AddSlashToPathController::class); $router->map('GET', '/profile', AddSlashToPathController::class); - -$router->map('OPTIONS', '/{path:.*}', CorsController::class); $router->map('GET', '/.well-known/openid-configuration', OpenidController::class); $router->map('GET', '/jwks', JwksController::class); $router->map('GET', '/login/', LoginPageController::class); From e399baa7c0552ff81de1de9f3c5ae942c6dfa9a8 Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Wed, 12 Jan 2022 11:07:33 +0100 Subject: [PATCH 12/16] Change ServerController whitespace. --- src/ServerConfig.php | 275 ++++++++++++++++++++++++------------------- 1 file changed, 152 insertions(+), 123 deletions(-) diff --git a/src/ServerConfig.php b/src/ServerConfig.php index d58794e..a2306a0 100644 --- a/src/ServerConfig.php +++ b/src/ServerConfig.php @@ -2,19 +2,24 @@ namespace Pdsinterop\Solid; -class ServerConfig { - private $path; - private $serverConfig; - private $userConfig; - - public function __construct($path) { - $this->path = $path; - $this->serverConfigFile = $this->path . "serverConfig.json"; - $this->userConfigFile = $this->path . "user.json"; - $this->serverConfig = $this->loadConfig(); - $this->userConfig = $this->loadUserConfig(); - - } +class ServerConfig +{ + ////////////////////////////// CLASS PROPERTIES \\\\\\\\\\\\\\\\\\\\\\\\\\\\ + private $path; + private $serverConfig; + private $userConfig; + + //////////////////////////////// PUBLIC API \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ + + public function __construct($path) + { + $this->path = $path; + $this->serverConfigFile = $this->path . "serverConfig.json"; + $this->userConfigFile = $this->path . "user.json"; + $this->serverConfig = $this->loadConfig(); + $this->userConfig = $this->loadUserConfig(); + + } public function getAllowedOrigins() { @@ -23,7 +28,7 @@ public function getAllowedOrigins() $serverConfig = $this->serverConfig; foreach ($serverConfig as $value) { if (isset($value['redirect_uris'])) { - foreach($value['redirect_uris'] as $url) { + foreach ($value['redirect_uris'] as $url) { $allowedOrigins[] = parse_url($url)['host']; } } @@ -32,113 +37,137 @@ public function getAllowedOrigins() return array_unique($allowedOrigins); } - private function loadConfig() { - if (!file_exists($this->serverConfigFile)) { - $keySet = $this->generateKeySet(); - $this->serverConfig = array( - "encryptionKey" => $keySet['encryptionKey'], - "privateKey" => $keySet['privateKey'] - ); - $this->saveConfig(); - } - return json_decode(file_get_contents($this->serverConfigFile), true); - } - private function saveConfig() { - file_put_contents($this->serverConfigFile, json_encode($this->serverConfig, JSON_PRETTY_PRINT)); - } - private function loadUserConfig() { - if (!file_exists($this->userConfigFile)) { - $this->userConfig = array( - "allowedClients" => array() - ); - $this->saveUserConfig(); - } - return json_decode(file_get_contents($this->userConfigFile), true); - } - private function saveUserConfig() { - file_put_contents($this->userConfigFile, json_encode($this->userConfig, JSON_PRETTY_PRINT)); - } - - /* Server data */ - public function getPrivateKey() { - return $this->serverConfig['privateKey']; - } - - public function getEncryptionKey() { - return $this->serverConfig['encryptionKey']; - } - - public function getClientConfigById($clientId) { - $clients = (array)$this->serverConfig['clients']; - - if (array_key_exists($clientId, $clients)) { - return $clients[$clientId]; - } - return null; - } - - public function saveClientConfig($clientConfig) { - $clientId = uuidv4(); - $this->serverConfig['clients'][$clientId] = $clientConfig; - $this->saveConfig(); - return $clientId; - } - - public function saveClientRegistration($origin, $clientData) { - $originHash = md5($origin); - $existingRegistration = $this->getClientRegistration($originHash); - if ($existingRegistration && isset($existingRegistration['client_name'])) { - return $originHash; - } - - $clientData['client_name'] = $origin; - $clientData['client_secret'] = md5(random_bytes(32)); - $this->serverConfig['client-' . $originHash] = $clientData; - $this->saveConfig(); - return $originHash; - } - - public function getClientRegistration($clientId) { - if (isset($this->serverConfig['client-' . $clientId])) { - return $this->serverConfig['client-' . $clientId]; - } else { - return array(); - } - } - - /* User specific data */ - public function getAllowedClients($userId) { - return $this->userConfig['allowedClients']; - } - - public function addAllowedClient($userId, $clientId) { - $this->userConfig['allowedClients'][] = $clientId; - $this->userConfig['allowedClients'] = array_unique($this->userConfig['allowedClients']); - $this->saveUserConfig(); - } - - public function removeAllowedClient($userId, $clientId) { - $this->userConfig['allowedClients'] = array_diff($this->userConfig['allowedClients'], array($clientId)); - $this->saveUserConfig(); - } - - /* Helper functions */ - private function generateKeySet() { - $config = array( - "digest_alg" => "sha256", - "private_key_bits" => 2048, - "private_key_type" => OPENSSL_KEYTYPE_RSA, - ); - // Create the private and public key - $key = openssl_pkey_new($config); - - // Extract the private key from $key to $privateKey - openssl_pkey_export($key, $privateKey); - $encryptionKey = base64_encode(random_bytes(32)); - $result = array( - "privateKey" => $privateKey, - "encryptionKey" => $encryptionKey - ); - return $result; - } + private function loadConfig() + { + if ( ! file_exists($this->serverConfigFile)) { + $keySet = $this->generateKeySet(); + $this->serverConfig = [ + "encryptionKey" => $keySet['encryptionKey'], + "privateKey" => $keySet['privateKey'], + ]; + $this->saveConfig(); + } + + return json_decode(file_get_contents($this->serverConfigFile), true); + } + + private function saveConfig() + { + file_put_contents($this->serverConfigFile, json_encode($this->serverConfig, JSON_PRETTY_PRINT)); + } + + private function loadUserConfig() + { + if ( ! file_exists($this->userConfigFile)) { + $this->userConfig = [ + "allowedClients" => [], + ]; + $this->saveUserConfig(); + } + + return json_decode(file_get_contents($this->userConfigFile), true); + } + + private function saveUserConfig() + { + file_put_contents($this->userConfigFile, json_encode($this->userConfig, JSON_PRETTY_PRINT)); + } + + /* Server data */ + public function getPrivateKey() + { + return $this->serverConfig['privateKey']; + } + + public function getEncryptionKey() + { + return $this->serverConfig['encryptionKey']; + } + + public function getClientConfigById($clientId) + { + $clients = (array) $this->serverConfig['clients']; + + if (array_key_exists($clientId, $clients)) { + return $clients[$clientId]; + } + + return null; + } + + public function saveClientConfig($clientConfig) + { + $clientId = uuidv4(); + $this->serverConfig['clients'][$clientId] = $clientConfig; + $this->saveConfig(); + + return $clientId; + } + + public function saveClientRegistration($origin, $clientData) + { + $originHash = md5($origin); + $existingRegistration = $this->getClientRegistration($originHash); + if ($existingRegistration && isset($existingRegistration['client_name'])) { + return $originHash; + } + + $clientData['client_name'] = $origin; + $clientData['client_secret'] = md5(random_bytes(32)); + $this->serverConfig['client-' . $originHash] = $clientData; + $this->saveConfig(); + + return $originHash; + } + + public function getClientRegistration($clientId) + { + if (isset($this->serverConfig['client-' . $clientId])) { + return $this->serverConfig['client-' . $clientId]; + } else { + return []; + } + } + + /* User specific data */ + public function getAllowedClients() + { + return $this->userConfig['allowedClients']; + } + + public function addAllowedClient($userId, $clientId) + { + $this->userConfig['allowedClients'][] = $clientId; + $this->userConfig['allowedClients'] = array_unique($this->userConfig['allowedClients']); + $this->saveUserConfig(); + } + + public function removeAllowedClient($userId, $clientId) + { + $this->userConfig['allowedClients'] = array_diff($this->userConfig['allowedClients'], [$clientId]); + $this->saveUserConfig(); + } + + ////////////////////////////// UTILITY METHODS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\ + + private function generateKeySet() + { + $config = [ + "digest_alg" => "sha256", + "private_key_bits" => 2048, + "private_key_type" => OPENSSL_KEYTYPE_RSA, + ]; + // Create the private and public key + $key = openssl_pkey_new($config); + + // Extract the private key from $key to $privateKey + openssl_pkey_export($key, $privateKey); + $encryptionKey = base64_encode(random_bytes(32)); + $result = [ + "privateKey" => $privateKey, + "encryptionKey" => $encryptionKey, + ]; + + return $result; + } } From 212b85bca7f4c203eed7895f396231a7ae518474 Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Wed, 12 Jan 2022 11:08:46 +0100 Subject: [PATCH 13/16] Change index.php to be cleaner. --- web/index.php | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/web/index.php b/web/index.php index b8d0a39..c06d5a4 100644 --- a/web/index.php +++ b/web/index.php @@ -68,7 +68,17 @@ // Create Formats objects $formats = new \Pdsinterop\Rdf\Formats(); - $serverUri = "https://" . $request->getServerParams()["SERVER_NAME"] . $request->getServerParams()["REQUEST_URI"]; // FIXME: doublecheck that this is the correct url; + $serverParams = $request->getServerParams(); + + $serverUri = ''; + if (isset($serverParams['SERVER_NAME'])) { + $serverUri = vsprintf("%s://%s%s", [ + // FIXME: doublecheck that this is the correct url; + getenv('PROXY_MODE') ? 'http' : 'https', + $serverParams['SERVER_NAME'], + $serverParams['REQUEST_URI'] ?? '', + ]); + } // Create the RDF Adapter $rdfAdapter = new \Pdsinterop\Rdf\Flysystem\Adapter\Rdf( @@ -77,11 +87,11 @@ $formats, $serverUri ); - + $filesystem = new \League\Flysystem\Filesystem($rdfAdapter); $filesystem->addPlugin(new \Pdsinterop\Rdf\Flysystem\Plugin\AsMime($formats)); - + $plugin = new \Pdsinterop\Rdf\Flysystem\Plugin\ReadRdf($graph); $filesystem->addPlugin($plugin); @@ -90,7 +100,7 @@ $container->share(\PHPTAL::class, function () { $template = new \PHPTAL(); - $template->setTemplateRepository(__DIR__.'/../src/Template'); + $template->setTemplateRepository(__DIR__ . '/../src/Template'); return $template; }); @@ -134,12 +144,12 @@ $traitMethods = array_keys($traits); -array_walk($controllers, function ($controller) use ($container, $traits, $traitMethods) { +array_walk($controllers, static function ($controller) use ($container, $traits, $traitMethods) { $definition = $container->add($controller); $methods = get_class_methods($controller); - array_walk ($methods, function ($method) use ($definition, $traitMethods, $traits) { + array_walk ($methods, static function ($method) use ($definition, $traitMethods, $traits) { if (in_array($method, $traitMethods, true)) { $definition->addMethodCall($method, $traits[$method]); } From 006b58381cd36d24bd96822d985796cf61382fd8 Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Wed, 12 Jan 2022 11:10:22 +0100 Subject: [PATCH 14/16] Add mention of PROXY_MODE --- README.md | 10 ++++++++-- docker-compose.yml | 5 +++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 46d3f90..4689dee 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,9 @@ only advised in development. For security reasons, the server expects to run on HTTPS (also known as HTTP+TLS). -To run insecure, set the environment variable `ENVIRONMENT` to `develop`. This -will prohibit the application from running in production mode. +To run insecure, for instance when the application is run behind a proxy or in a +PHP-FPM (or similar) setup, set the environment variable `PROXY_MODE`. +This will allow the application to accept HTTP requests. ### Docker images @@ -197,6 +198,11 @@ The underlying functionality for these features is provided by: ## Development +The easiest way to develop this project is by running the environment provided +by the `docker-compose.yml` file. This can be done by running `docker-compose up`. + +This will start the application and a pubsub server in separate docker containers. + ### Project structure This project is structured as follows: diff --git a/docker-compose.yml b/docker-compose.yml index 03bc797..d358389 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,8 +4,13 @@ services: build: context: . environment: + # During development it can be useful to set the ENVIRONMENT to "development" + # in order to see more details about the errors. + # ENVIRONMENT: development USERNAME: alice PASSWORD: alice123 + # to run in HTTP mode, set PROXY_MODE + # PROXY_MODE: true PUBSUB_URL: http://pubsub:8080 ports: - 80:80 From 60a9a5309d185e32c8e01a7e925380e397debe2e Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Wed, 12 Jan 2022 11:20:25 +0100 Subject: [PATCH 15/16] Change LoginController to be cleaner - Remove separate LoginPageController - Only attempt login on POST request - Show Login page for other request --- src/Controller/LoginController.php | 67 +++++++++++++++----------- src/Controller/LoginPageController.php | 14 ------ web/index.php | 4 +- 3 files changed, 39 insertions(+), 46 deletions(-) delete mode 100644 src/Controller/LoginPageController.php diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index 4d997a3..8e52414 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -12,37 +12,46 @@ final public function __invoke(ServerRequestInterface $request, array $args): Re $postBody = $request->getParsedBody(); $response = $this->getResponse(); - // var_dump($_SESSION); - if (isset($_SESSION['userid'])) { - $user = $_SESSION['userid']; - if ($request->getQueryParams()['returnUrl']) { - $response = $response->withStatus(302, "Redirecting"); - $response = $response->withHeader("Location", $request->getQueryParams()['returnUrl']); - return $response; - } - $response->getBody()->write("

Already logged in as $user

"); - } else if ( - ($postBody['username'] == $_ENV['USERNAME'] && $postBody['password'] == $_ENV['PASSWORD']) || - ($postBody['username'] == $_SERVER['USERNAME'] && $postBody['password'] == $_SERVER['PASSWORD']) - ) { - $user = $postBody['username']; - $_SESSION['userid'] = $user; - if ($request->getQueryParams()['returnUrl']) { - $response = $response->withStatus(302, "Redirecting"); - $response = $response->withHeader("Location", $request->getQueryParams()['returnUrl']); - return $response; - } - $response->getBody()->write("

Welcome $user

\n"); - // echo("session started\n"); - //var_dump($_SESSION); + if ($request->getMethod() === 'POST') { + if (isset($_SESSION['userid'])) { + $user = $_SESSION['userid']; + + if (isset($request->getQueryParams()['returnUrl'])) { + return $response + ->withHeader("Location", $request->getQueryParams()['returnUrl']) + ->withStatus(302) + ; + } + + $response->getBody()->write("

Already logged in as $user

"); + } elseif ($postBody['username'] && $postBody['password']) { + $user = $postBody['username']; + $password = $postBody['password']; + + if ( + ($user === $_ENV['USERNAME'] && $password === $_ENV['PASSWORD']) + || ($user === $_SERVER['USERNAME'] && $password === $_SERVER['PASSWORD']) + ) { + $_SESSION['userid'] = $user; + + if (isset($request->getQueryParams()['returnUrl'])) { + return $response + ->withHeader("Location", $request->getQueryParams()['returnUrl']) + ->withStatus(302) + ; + } + + $response->getBody()->write("

Welcome $user

\n"); + } else { + $response->getBody()->write("

Login as $user failed

\n"); + } + } else { + $response->getBody()->write("

Login failed

\n"); + } } else { - // var_dump($postBody); - //echo("cookie:\n"); - //var_dump($_COOKIE); - //echo("session:\n"); - //var_dump($_SESSION); - $response->getBody()->write("

No (try posting username=alice&password=alice123)

\n"); + return $this->createTemplateResponse('login.html'); } + return $response; } } diff --git a/src/Controller/LoginPageController.php b/src/Controller/LoginPageController.php deleted file mode 100644 index 56f5a2a..0000000 --- a/src/Controller/LoginPageController.php +++ /dev/null @@ -1,14 +0,0 @@ -createTemplateResponse('login.html'); - } -} diff --git a/web/index.php b/web/index.php index c06d5a4..91d0285 100644 --- a/web/index.php +++ b/web/index.php @@ -26,7 +26,6 @@ use Pdsinterop\Solid\Controller\HttpToHttpsController; use Pdsinterop\Solid\Controller\JwksController; use Pdsinterop\Solid\Controller\LoginController; -use Pdsinterop\Solid\Controller\LoginPageController; use Pdsinterop\Solid\Controller\OpenidController; use Pdsinterop\Solid\Controller\Profile\CardController; use Pdsinterop\Solid\Controller\Profile\ProfileController; @@ -128,7 +127,6 @@ HttpToHttpsController::class, JwksController::class, LoginController::class, - LoginPageController::class, OpenidController::class, ProfileController::class, RegisterController::class, @@ -190,7 +188,7 @@ $router->map('GET', '/profile', AddSlashToPathController::class); $router->map('GET', '/.well-known/openid-configuration', OpenidController::class); $router->map('GET', '/jwks', JwksController::class); -$router->map('GET', '/login/', LoginPageController::class); +$router->map('GET', '/login/', LoginController::class); $router->map('POST', '/login', LoginController::class); $router->map('POST', '/login/', LoginController::class); $router->map('POST', '/register', RegisterController::class); From 5b39d755e29605a19ddbffb29665b241eedc2b26 Mon Sep 17 00:00:00 2001 From: Ben Peachey Date: Wed, 12 Jan 2022 11:25:46 +0100 Subject: [PATCH 16/16] Add nicer UI for homepage, profile, and login screens --- src/Controller/HelloWorldController.php | 63 +++++++++++++++++++++++-- src/Template/card.html | 47 +++++++----------- src/Template/default.html | 7 ++- src/Template/login.html | 23 ++++----- src/Traits/HasTemplateTrait.php | 4 +- 5 files changed, 93 insertions(+), 51 deletions(-) diff --git a/src/Controller/HelloWorldController.php b/src/Controller/HelloWorldController.php index 57ddbb2..b66f50e 100644 --- a/src/Controller/HelloWorldController.php +++ b/src/Controller/HelloWorldController.php @@ -9,10 +9,67 @@ class HelloWorldController extends AbstractController { final public function __invoke(ServerRequestInterface $request, array $args): ResponseInterface { - $response = $this->getResponse(); - $response->getBody()->write('

Hello, World!

'); + $body = <<<'HTML' +

Hello, World!

+

The following main URL are available on this server:

+ - return $response; +
+

Footnotes

+
    +
  1. Also available without trailing slash /
  2. +
  3. A file extension can be added to force a specific format. For instance /profile/card can be + requested as /profile/card.ttl to request a Turtle file, or /profile/card.json to + request a JSON-LD file +
  4. +
  5. This URL also accepts POST requests
  6. +
  7. This URL only accepts POST requests
  8. +
+
+HTML; + + return $this->createTemplateResponse($body); } } diff --git a/src/Template/card.html b/src/Template/card.html index d948414..9c8f715 100644 --- a/src/Template/card.html +++ b/src/Template/card.html @@ -1,12 +1,5 @@ - - - - - - -
-
-

- Profile in various RDF formats -

- -
+
+

Profile in various RDF formats

+
-
-
- - -
-

RDF Format

-
-
-
-
-
- - +
+ + +
+

RDF Format

+
+
+
+
+ + diff --git a/src/Template/default.html b/src/Template/default.html index 71b5204..de8a4b3 100644 --- a/src/Template/default.html +++ b/src/Template/default.html @@ -7,14 +7,13 @@ - + + - +

This will be replaced with content from other templates

diff --git a/src/Template/login.html b/src/Template/login.html index 89402d1..9a371a6 100644 --- a/src/Template/login.html +++ b/src/Template/login.html @@ -1,11 +1,12 @@ - - - -

Please login

-
- - - -
- - \ No newline at end of file + +
+
+

Please login

+ + + + +
+
+
+ diff --git a/src/Traits/HasTemplateTrait.php b/src/Traits/HasTemplateTrait.php index dce11c1..9bb2201 100644 --- a/src/Traits/HasTemplateTrait.php +++ b/src/Traits/HasTemplateTrait.php @@ -48,9 +48,9 @@ private function createTemplateFromString(string $template) : string { return <<<"TAL" -
+
{$template} -
+
TAL; }