diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..eba1110
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+# Auto detect text files and perform LF normalization
+* text=auto
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2a71591
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+# Directories
+vendor/
+__pycache__/
+node_modules/
+sites/
+
+# OS Files
+.DS_Store
+Desktop.ini
+Thumbs.db
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..97a4688
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2019 Conrad Sollitt and Authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..9898839
--- /dev/null
+++ b/README.md
@@ -0,0 +1,25 @@
+# 🌟 FastSitePHP Playground
+
+**Thanks for visiting!** 🌠👍
+
+This repository contains playground website for FastSitePHP. The UI (User Interface) exists on the main website in the main FastSitePHP repository, while this repository only contains code that exists on the separate playground web server.
+
+* __Playground UI__: https://www.fastsitephp.com/en/playground
+* __Playground Server__: https://playground.fastsitephp.com/
+
+## ⚙️ How it works
+
+
+
+
+
+## 🤝 Contributing
+
+* If you find a typo or grammar error please fix and submit.
+* Additional language template translations are needed. Refer to the main project if you can help with translations.
+* Any changes to the core code will likely not be accepted unless you first open an issue. A lot of security is needed in order to make this site work so every line of code must be carefully considered.
+* If you think you’ve found an issue with security or have additional security ideas please open an issue. No financial transactions, etc are dependent on this site so opening a public issue is ok.
+
+## :memo: License
+
+This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details.
diff --git a/app/404.htm b/app/404.htm
new file mode 100644
index 0000000..725f514
--- /dev/null
+++ b/app/404.htm
@@ -0,0 +1,127 @@
+
+
+
+
+
+ FastSitePHP Code Playground
+
+
+
+
+
FastSitePHP Code Playground 404 - Page not found
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/app.php b/app/app.php
new file mode 100644
index 0000000..6f1c53f
--- /dev/null
+++ b/app/app.php
@@ -0,0 +1,445 @@
+template_dir = __DIR__;
+$app->not_found_template = '404.htm';
+$app->show_detailed_errors = true;
+
+// The key for signing is hard-coded. The value below can be used for testing
+// while the actual production server has a different value. See API docs for
+// [Security\Crypto\SignedData] as new keys can be generated on the playground.
+// The config key is used with [Crypto::sign()] and [Crypto::verify()].
+/*
+$app->get('/get-key', function() use ($app) {
+ $csd = new \FastSitePHP\Security\Crypto\SignedData();
+ $key = $csd->generateKey();
+ $app->header('Content-Type', 'text/plain');
+ return $key;
+});
+*/
+$app->config['SIGNING_KEY'] = '85ef7bb21b3ee94b9e3e953c9aea23cf6ed03ba3252e19afe7210c788739eb87';
+
+// Allow CORS with Headers for posting data with Auth.
+// This allows the web service to run from any site.
+if (isset($_SERVER['HTTP_ORIGIN']) && $_SERVER['HTTP_ORIGIN'] !== 'null') {
+ $app->cors([
+ 'Access-Control-Allow-Origin' => $_SERVER['HTTP_ORIGIN'],
+ 'Access-Control-Allow-Headers' => 'Authorization, X-File, X-Rename',
+ 'Access-Control-Allow-Credentials' => 'true',
+ ]);
+} else {
+ $app->cors('*');
+}
+
+// --------------------------------------------------------------------------------------
+// Site Functions
+// --------------------------------------------------------------------------------------
+
+// More lanuages can be added here as templates are created.
+// Available languages must be white-listed for security.
+function getLangauage($lang) {
+ if ($lang !== 'en') {
+ $lang = 'en';
+ }
+ return $lang;
+}
+
+function getTemplateRoot($lang) {
+ $lang = getLangauage($lang);
+ return __DIR__ . '/../app_data/template/' . $lang . '/';
+}
+
+function getSitePath($site) {
+ return __DIR__ . '/../html/sites/' . $site . '/';
+}
+
+// Return path and extension of a user file.
+// [*.php] files only exist under the [app] dir.
+function getFilePath($site_dir, $file) {
+ $ext = pathinfo($file, PATHINFO_EXTENSION);
+ $dir = ($ext === 'php' ? $site_dir . 'app/' : $site_dir);
+ return [$dir, $ext];
+}
+
+
+// Send an error as a 500 JSON Response and end script execution.
+// The client UI handles this and shows server error messages.
+// Errors are not translated because typically no errors should happen
+// unless the user manually makes calls using invalid parameters.
+// The client UI hides buttons that the user shouldn't have access too.
+function sendError($message) {
+ // Create a new Response Object passing CORS headers from the App object
+ global $app;
+ $res = new Response($app);
+
+ // Send 500 response with JSON error message
+ $res
+ ->statusCode(500)
+ ->json([
+ 'success' => false,
+ 'error' => $message,
+ ])
+ ->send();
+ exit();
+}
+
+
+// Get list of files names that appear on the User's UI. This excludes
+// the directory structure [app] folder along with hidden files.
+function getSiteFiles($path) {
+ $files = array_diff(scandir($path), ['.', '..', 'app', 'index.php', '.htaccess', 'expires.txt']);
+ $app_files = array_diff(scandir($path . 'app'), ['.', '..', '.htaccess']);
+ $files = array_merge($files, $app_files);
+ return array_values($files);
+}
+
+
+// Returns an Object/Array with a list of site files and
+// app code for displaying when the UI first loads.
+function getSite($path) {
+ return [
+ 'files' => getSiteFiles($path),
+ 'app_code' => file_get_contents($path . 'app/app.php'),
+ ];
+}
+
+
+// Validate if a user supplied file name will be accepted.
+// Only specific file types and basic ASCII letters are allowed.
+// [index.php] is not allowed because users are not allowed to modify it,
+// and [index.htm] because it could override the default page.
+function fileNameIsValid($name) {
+ $pattern = '/^[a-zA-Z0-9_\-]{1,}.(php|htm|js|css|svg)$/';
+ if (!preg_match($pattern, $name)) {
+ return false;
+ }
+ if (strtolower($name) === 'index.php' || strtolower($name) === 'index.htm') {
+ return false;
+ }
+ return true;
+}
+
+
+// Route Filter Function to get and validate the submitted site.
+// This is the core security function that prevents users from modifying
+// content on a site that they do not have the key for.
+$require_auth = function () use ($app) {
+ // Read Key from Auth Header
+ // OPTIONS requests will not contain the header
+ $req = new Request();
+ if ($req->method() === 'OPTIONS') {
+ return;
+ } else {
+ $token = $req->header('Authorization');
+ if ($token === null) {
+ sendError('Missing Request Header [Authorization]');
+ }
+ }
+
+ // Validate Token
+ $token = str_replace('Bearer ', '', $token);
+ $site = Crypto::verify($token);
+ if ($site === null) {
+ sendError('The site has already expired or has been deleted.');
+ }
+
+ // Make sure the site exists
+ $path = getSitePath($site);
+ if (!is_dir($path)) {
+ sendError('The site has been deleted.');
+ }
+
+ // Assign to App
+ $app->locals['site'] = $site;
+};
+
+
+// Get file name from Request Header
+$require_file_name = function() use ($app) {
+ $req = new Request();
+ if ($req->method() === 'OPTIONS') {
+ return;
+ }
+ $file = $req->header('X-File');
+ if ($file === null) {
+ sendError('Missing Request Header [X-File]');
+ }
+ $app->locals['file'] = $file;
+};
+
+
+// Get file name and new name from Request Header
+$require_file_rename = function() use ($app) {
+ $req = new Request();
+ if ($req->method() === 'OPTIONS') {
+ return;
+ }
+ $file = $req->header('X-File');
+ $rename = $req->header('X-Rename');
+ if ($file === null) {
+ sendError('Missing Request Header [X-File]');
+ }
+ if ($rename === null) {
+ sendError('Missing Request Header [X-Rename]');
+ }
+ $app->locals['file'] = $file;
+ $app->locals['rename'] = $rename;
+};
+
+// ----------------------------------------------------------------------------
+// Routes
+// ----------------------------------------------------------------------------
+
+/**
+ * Home Page - Return a simple HTML page
+ */
+$app->get('/', function() {
+ return file_get_contents(__DIR__ . '/index.htm');
+});
+
+
+/**
+ * Return the template code and files as a JSON object
+ */
+$app->post('/:lang/site-template', function($lang) {
+ $path = getTemplateRoot($lang);
+ return getSite($path);
+});
+
+
+/**
+ * Create a site for the user by copying the template site
+ */
+$app->post('/:lang/create-site', function($lang) {
+ // Get list of files to copy
+ $copy_from = getTemplateRoot($lang);
+ $search = new Search();
+ $files = $search
+ ->dir($copy_from)
+ ->recursive(true)
+ ->includeRoot(false)
+ ->files();
+
+ // Build a random hex string for the site.
+ // It's unlikely that a site hex string would be duplicated because
+ // the format used allows for 18,446,744,073,709,551,616 possible sites,
+ // however just in case check to make sure the site doesn't exist.
+ $n = 0;
+ do {
+ $site = bin2hex(random_bytes(10));
+ $copy_to = getSitePath($site);
+ $n++;
+ if ($n > 2) {
+ sendError('Unexpected error. Unable to create site.');
+ }
+ } while (is_dir($site));
+
+ // Copy files
+ mkdir($copy_to . '/app', 0777, true);
+ foreach ($files as $file) {
+ copy($copy_from . $file, $copy_to . $file);
+ }
+
+ // Create the [expires.txt] with a Unix Timestamp set for 1 hour from now.
+ // This file is used by a CLI script to delete expired sites.
+ $expires = time() + (60 * 60);
+ file_put_contents($copy_to . 'expires.txt', $expires);
+
+ // Return site info (site string and expires time) as signed data.
+ // Signed data is similar to JWT but uses a different format.
+ // By default [Crypto::sign()] uses a 1 hour timeout.
+ return [
+ 'site' => Crypto::sign($site),
+ ];
+});
+
+
+/**
+ * Return a user site. File list and Code for [app.php] which is
+ * the page that appears when the UI is first loaded.
+ */
+$app->post('/download-site', function() use ($app) {
+ $path = getSitePath($app->locals['site']);
+ return getSite($path);
+})
+->filter($require_auth);
+
+
+/**
+ * Download a file from a user site
+ */
+$app->post('/get-file', function() use ($app) {
+ // Get site directory and file
+ $dir = getSitePath($app->locals['site']);
+ $file = $app->locals['file'];
+ list($dir, $ext) = getFilePath($dir, $file);
+
+ // Validate that the file exists only in the user's directory.
+ // If the user manually submits a request for a hidden file
+ // [index.php, .htaccess, expires.txt] it will be returned
+ // however the standard UI does not show it.
+ if (!Security::dirContainsFile($dir, $file)) {
+ return $app->pageNotFound();
+ }
+
+ // Return as an Object for a JSON Response
+ return [
+ 'file' => $file,
+ 'type' => $ext,
+ 'content' => file_get_contents($dir . $file),
+ ];
+})
+->filter($require_auth)
+->filter($require_file_name);
+
+
+/**
+ * Save a file on a user site. This service handles both existing and new files.
+ */
+$app->post('/save-file', function() use ($app) {
+ // Get site directory, file list, and file
+ $dir = getSitePath($app->locals['site']);
+ $files = getSiteFiles($dir);
+ $file = $app->locals['file'];
+ list($dir, $ext) = getFilePath($dir, $file);
+
+ // Validate file name
+ if (!fileNameIsValid($file)) {
+ sendError('File name is not allowed');
+ }
+
+ // Limit the number of files that a user can create
+ if (count($files) >= 30 && !is_file($dir . $file)) {
+ sendError('You have reached the limit of 30 files on a single site.');
+ }
+
+ // Get file contents from the POST body content.
+ // [Content-Type] used is 'text/plain' for all files.
+ $contents = file_get_contents('php://input');
+
+ // Save file and return success
+ file_put_contents($dir . $file, $contents);
+ return [ 'success' => true ];
+})
+->filter($require_auth)
+->filter($require_file_name);
+
+
+/**
+ * Rename a file on a user site
+ */
+$app->post('/rename-file', function() use ($app) {
+ // Get site directory and files
+ $dir = getSitePath($app->locals['site']);
+ $file = $app->locals['file'];
+ $rename = $app->locals['rename'];
+ list($dir, $ext) = getFilePath($dir, $file);
+
+ // Make sure file type is the same
+ $new_ext = pathinfo($rename, PATHINFO_EXTENSION);
+ if ($ext !== $new_ext) {
+ sendError('Renaming to a new file type is not allowed');
+ }
+
+ // Validate file names
+ if (!fileNameIsValid($file)) {
+ sendError('File name to rename from is not allowed');
+ } elseif (!fileNameIsValid($rename)) {
+ sendError('New File name is not allowed');
+ } elseif (strtolower($file) === 'app.php') {
+ sendError('Cannot rename [app.php]');
+ }
+
+ // Make sure file to rename exists and new file does not
+ if (!Security::dirContainsFile($dir, $file)) {
+ sendError('File to rename was not found or has already been deleted');
+ } elseif (is_file($dir . $rename)) {
+ sendError('A file with new name already exists. Please refresh the page and try again.');
+ }
+
+ // Rename file
+ $source = $dir . $file;
+ $dest = $dir . $rename;
+ rename($source, $dest);
+
+ // Update file contents as this route handles both file name and contents.
+ // The user sees a [Rename] or [Rename and Save] depending on what changed,
+ // however this service simply overwrites the file each time.
+ $contents = file_get_contents('php://input');
+ file_put_contents($dest, $contents);
+
+ // Return Success
+ return [ 'success' => true ];
+})
+->filter($require_auth)
+->filter($require_file_rename);
+
+
+/**
+ * Delete a file on a user site
+ */
+$app->post('/delete-file', function() use ($app) {
+ // Get site directory and file
+ $dir = getSitePath($app->locals['site']);
+ $file = $app->locals['file'];
+ list($dir, $ext) = getFilePath($dir, $file);
+
+ // Validate file name and that the user is not deleting [app.php]
+ if (!fileNameIsValid($file)) {
+ sendError('File name is not allowed');
+ } elseif (strtolower($file) === 'app.php') {
+ sendError('File [app.php] cannot be deleted');
+ }
+
+ // Validate and delete the file
+ if (Security::dirContainsFile($dir, $file)) {
+ unlink($dir . $file);
+ } else {
+ sendError('File was not found or has already been deleted');
+ }
+ return [ 'success' => true ];
+})
+->filter($require_auth)
+->filter($require_file_name);
+
+
+/**
+ * Delete a user site
+ */
+$app->post('/delete-site', function() use ($app) {
+ // Get directory to look for file in.
+ $dir = getSitePath($app->locals['site']);
+
+ // Delete all files and directories.
+ // If there is an error (file locked, etc) then the
+ // site will later be deleted by the CLI script.
+ $app_files = array_diff(scandir($dir . 'app'), ['.', '..']);
+ $files = array_diff(scandir($dir), ['.', '..', 'app']);
+ foreach ($app_files as $file) {
+ unlink($dir . 'app/' . $file);
+ }
+ foreach ($files as $file) {
+ unlink($dir . $file);
+ }
+ rmdir($dir . 'app');
+ rmdir($dir);
+
+ // Result
+ return [ 'success' => true ];
+})
+->filter($require_auth);
diff --git a/app/index.htm b/app/index.htm
new file mode 100644
index 0000000..72b75b0
--- /dev/null
+++ b/app/index.htm
@@ -0,0 +1,127 @@
+
+
+
+
+
+ FastSitePHP Code Playground
+
+
+
+
+
FastSitePHP Code Playground
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app_data/template/en/.htaccess b/app_data/template/en/.htaccess
new file mode 100644
index 0000000..02e9f07
--- /dev/null
+++ b/app_data/template/en/.htaccess
@@ -0,0 +1,3 @@
+FallbackResource index.php
+php_flag file_access_is_limited on
+php_value open_basedir /var/www/vendor:.
diff --git a/app_data/template/en/app/.htaccess b/app_data/template/en/app/.htaccess
new file mode 100644
index 0000000..b66e808
--- /dev/null
+++ b/app_data/template/en/app/.htaccess
@@ -0,0 +1 @@
+Require all denied
diff --git a/app_data/template/en/app/Calculator.php b/app_data/template/en/app/Calculator.php
new file mode 100644
index 0000000..7343f6e
--- /dev/null
+++ b/app_data/template/en/app/Calculator.php
@@ -0,0 +1,30 @@
+add($x, $y);
+ case '-':
+ return $this->subtract($x, $y);
+ case '*':
+ return $this->multiply($x, $y);
+ case '/':
+ return $this->divide($x, $y);
+ }
+ }
+
+ public function add($x, $y) { return $x + $y; }
+ public function subtract($x, $y) { return $x - $y; }
+ public function multiply($x, $y) { return $x * $y; }
+ public function divide($x, $y) { return $x / $y; }
+}
diff --git a/app_data/template/en/app/app.php b/app_data/template/en/app/app.php
new file mode 100644
index 0000000..b9207b6
--- /dev/null
+++ b/app_data/template/en/app/app.php
@@ -0,0 +1,86 @@
+render()] is called
+$app->header_templates = 'header.php';
+$app->footer_templates = 'footer.php';
+
+// Home Page
+$app->get('/', function() use ($app) {
+ return $app->render('home.php', [
+ 'page_title' => 'Hello World',
+ ]);
+});
+
+// Calculator Page
+$app->get('/calc', function() use ($app) {
+ return $app->render('calc.php', [
+ 'page_title' => 'Calculator',
+ // Create new random numbers each time the page is refreshed
+ 'x' => rand(0, 1000000),
+ 'y' => rand(0, 1000000),
+ ]);
+});
+
+// Web Service called from the [calc] page.
+// It reads a JSON post and returns JSON.
+$app->post('/api/calculate', function() {
+ // Read the JSON Post
+ $req = new Request();
+ $data = $req->content();
+
+ // Validate
+ $v = new Validator();
+ $v->addRules([
+ // Field, Title, Rules
+ ['x', 'Value X', 'required type="number"'],
+ ['op', 'Operator', 'required list="+, -, *, /"'],
+ ['y', 'Value Y', 'required type="number"'],
+ ]);
+ list($errors, $fields) = $v->validate($data);
+ if ($errors) {
+ return [
+ 'success' => false,
+ // In PHP [implode()] is similar to [join()]
+ // in other programming languages.
+ 'error' => implode(' ', $errors),
+ ];
+ }
+
+ // Calculate the result
+ try {
+ $calc = new Calculator();
+ $x = (float)$data['x']; // Convert to a number
+ $op = $data['op'];
+ $y = (float)$data['y'];
+ $result = $calc->calculate($x, $op, $y);
+ return [
+ 'success' => true,
+ 'result' => "${x} ${op} ${y} = ${result}",
+ ];
+ } catch (\Exception $e) {
+ return [
+ 'success' => false,
+ 'error' => $e->getMessage(),
+ ];
+ }
+});
+
+// Show the standard PHP info page which provides Server and PHP version info.
+$app->get('/phpinfo', function() {
+ phpinfo();
+});
diff --git a/app_data/template/en/app/calc.php b/app_data/template/en/app/calc.php
new file mode 100644
index 0000000..8b3a0eb
--- /dev/null
+++ b/app_data/template/en/app/calc.php
@@ -0,0 +1,25 @@
+
+
+