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 + +
+ Playground - 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 @@ + + +

escape($page_title) ?>

+ +
+ + + + +
+ + + + diff --git a/app_data/template/en/app/footer.php b/app_data/template/en/app/footer.php new file mode 100644 index 0000000..e622664 --- /dev/null +++ b/app_data/template/en/app/footer.php @@ -0,0 +1,10 @@ + + + + + diff --git a/app_data/template/en/app/header.php b/app_data/template/en/app/header.php new file mode 100644 index 0000000..7d08679 --- /dev/null +++ b/app_data/template/en/app/header.php @@ -0,0 +1,22 @@ + + + + + + + + <?= $app->escape($page_title) ?> + + + +
+render()] is called. +?> \ No newline at end of file diff --git a/app_data/template/en/app/home.php b/app_data/template/en/app/home.php new file mode 100644 index 0000000..ab4e494 --- /dev/null +++ b/app_data/template/en/app/home.php @@ -0,0 +1,13 @@ +

escape($page_title) ?>

+ +See Script Time and Memory Usage + +
+ + +
+ + diff --git a/app_data/template/en/calc.js b/app_data/template/en/calc.js new file mode 100644 index 0000000..f7ff5ca --- /dev/null +++ b/app_data/template/en/calc.js @@ -0,0 +1,85 @@ +// Validates with [jshint] +// The playground site includes JSHint and provides linting as you enter code. +// +// Uncomment the line below to see what happens: +// Error Test + +/* jshint strict: true */ +(function() { + 'use strict'; + + function setup() { + var valueX = document.getElementById('value-x'); + var op = document.querySelector('select'); + var valueY = document.getElementById('value-y'); + var btn = document.querySelector('button'); + + btn.onclick = function() { + var data = { + x: valueX.value, + op: op.value, + y: valueY.value, + }; + + var url = 'api/calculate'; + fetch(url, { + method: 'POST', + cache: 'no-store', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }) + .then(function(response) { + return response.json(); + }) + .then(function(data) { + if (!data.success) { + throw data.error; + } + showCalcResult(data.result); + op.selectedIndex = getRandomInt(4); + valueX.value = getRandomInt(1000000); + valueY.value = getRandomInt(1000000); + }) + .catch(function(error) { + showCalcResult('Error: ' + error); + }); + }; + } + + function getRandomInt(max) { + return Math.floor(Math.random() * Math.floor(max)); + } + + function showCalcResult(text) { + var section = document.querySelector('.calc-result'); + var ul = section.querySelector('ul'); + var item = document.createElement('li'); + item.textContent = text; + ul.insertAdjacentElement('afterbegin', item); + section.style.display = ''; + } + + function loadPolyfill() { + var url = 'https://polyfill.io/v3/polyfill.min.js?features=fetch'; + var script = document.createElement('script'); + script.onload = function() { setup(); }; + script.onerror = function() { + showCalcResult('Error loading Script: ' + url); + document.querySelector('.calc-result').style.backgroundColor = 'red'; + }; + script.src = url; + document.head.appendChild(script); + } + + // Once content is loaded run [setup] or if using IE or an + // Older Mobile Device download a polyfill for [fetch, Promise, etc]. + document.addEventListener('DOMContentLoaded', function() { + if (window.fetch === undefined) { + loadPolyfill(); + } else { + setup(); + } + }); +})(); diff --git a/app_data/template/en/index.php b/app_data/template/en/index.php new file mode 100644 index 0000000..28a0767 --- /dev/null +++ b/app_data/template/en/index.php @@ -0,0 +1,58 @@ +setup('UTC'); + +// Set Config for the User's Playground Site +$app->show_detailed_errors = true; +$app->template_dir = __DIR__ . '/app/'; +$app->controller_root = 'App'; +$app->middleware_root = 'App'; + +// Disable most streams, [file] and [php] are allowed while [http/https] +// will be disabled by the [php.ini] setting [allow_url_fopen = Off]. +// http://docs.php.net/manual/en/wrappers.php +foreach (stream_get_wrappers() as $protocal) { + if ($protocal !== 'file' && $protocal !== 'php' && $protocal !== 'http' && $protocal !== 'https') { + stream_wrapper_unregister($protocal); + } +} + +// Include the App File for the Site. +require __DIR__ . '/app/app.php'; + +// ----------------------------------------------------------- +// Run the application +// ----------------------------------------------------------- + +// Run the app to determine and show the specified URL +$app->run(); + +// If debug then add script time and memory info to the end of the page +if (isset($show_debug_info) && $show_debug_info) { + $showDebugInfo(); +} diff --git a/app_data/template/en/page.htm b/app_data/template/en/page.htm new file mode 100644 index 0000000..411f4c4 --- /dev/null +++ b/app_data/template/en/page.htm @@ -0,0 +1,72 @@ + + + + + + + Webpage + + + + +

Hello World

+ Back to Main Site + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app_data/template/en/shape.svg b/app_data/template/en/shape.svg new file mode 100644 index 0000000..ac8631a --- /dev/null +++ b/app_data/template/en/shape.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app_data/template/en/site.css b/app_data/template/en/site.css new file mode 100644 index 0000000..ddc5fca --- /dev/null +++ b/app_data/template/en/site.css @@ -0,0 +1,130 @@ +html, body { height:100%; margin:0; } + +:root { + --dark-color: hsl(243, 20%, 20%); + --medium-color: hsla(243, 20%, 40%, 1); + --light-color: hsla(243, 20%, 60%, 1); + --lightest-color: hsla(243, 20%, 80%, 1); +} + +/* Main element fallback colors IE because IE does not support CSS variables */ +@media screen and (-ms-high-contrast: active), screen and (-ms-high-contrast: none) { + nav { background-color:#2A293E; } + nav a, h1, footer { background-color:hsla(243, 20%, 40%, 1); } + .calc-result ul { border-bottom:1px solid #2A293E; } + .calc-result ul li { border:1px solid #2A293E; } + .calc-result ul li:nth-child(odd) { background-color: hsla(243, 20%, 80%, 1); } +} + +body { + display: flex; + flex-direction: column; + text-align: center; + /* Using 'Native font stack' - See Bootstrap 4 Docs for info on 'Native font stack' */ + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; +} + +nav { + padding: 20px; + background-color: var(--dark-color); + display: flex; + flex-wrap: wrap; + justify-content: center; +} + +nav a { + color: #fff; + padding: 10px 20px; + background-color: var(--medium-color); + margin: 10px; + text-decoration: none; + font-weight: bold; + border-radius: 4px; + box-shadow: 0 0 2px 2px rgba(0,0,0,.5); +} +nav a:hover { + background-color: var(--light-color); + text-decoration: underline; + box-shadow: 0 0 4px 4px rgba(0,0,0,.7); +} +nav a:visited { color:#fff; } + +main { + flex: 1 0 auto; + padding: 50px 20px; + display: flex; + flex-direction: column; + align-items: center; +} + +footer { padding:10px; background-color:var(--medium-color); color: #fff; } + +h1 { + background-color: var(--medium-color); + padding: 20px 80px; + display: inline-flex; + color: #fff; + border-radius: 8px; + margin-bottom: 40px; +} + +section { + box-shadow: 0 1px 5px 0 rgba(0,0,0,.5); + padding: 30px 60px; + margin: 40px; + border-radius: 20px; +} + +input, select, button { padding:.5em 1em; margin:.5em; } + +a, +a:visited { color:var(--dark-color); } + +a:hover { color:var(--medium-color); text-decoration:none; } + +.calc-result ul { + list-style-type: none; + margin: 0; + padding: 0; + border-bottom: 1px solid var(--dark-color); +} + +.calc-result ul li { + padding: 10px 20px; + border: 1px solid var(--dark-color); + border-bottom: none; +} + +.calc-result ul li:nth-child(odd) { + background-color: var(--lightest-color); +} + +.home-page-animation { + width: 102px; + margin: 150px auto; + display: inline-flex; +} + +.home-page-animation .shape-1, +.home-page-animation .shape-2 { + position: absolute; + will-change: transform; +} + +.home-page-animation .shape-1 { + animation: rotate-and-spin-clockwise 5s ease-in-out infinite; +} + +.home-page-animation .shape-2 { + animation: rotate-and-spin-counter-clockwise 7.5s ease-in-out infinite; +} + +@keyframes rotate-and-spin-clockwise { + from { transform: rotate(0deg) translate(-100px); } + to { transform: rotate(360deg) translate(-100px); } +} + +@keyframes rotate-and-spin-counter-clockwise { + from { transform: rotate(360deg) translate(100px); } + to { transform: rotate(0deg) translate(100px); } +} diff --git a/docs/PHP Custom Build Instructions.txt b/docs/PHP Custom Build Instructions.txt new file mode 100644 index 0000000..7c602a1 --- /dev/null +++ b/docs/PHP Custom Build Instructions.txt @@ -0,0 +1,105 @@ + +This file provides info on how which files to edit for the custom build of PHP. +Basically a single INI setting is added [file_access_is_limited] that allows for +some requests to have access to writing and modifying files while other requests +will only have read access - using the feature properly requires the function +[ini_set()] to be disabled from [php.ini]. + +See the file [Playground Server Setup.txt] for full build and setup instructions. + +-------------------------------------------------------------------------------------- +| Helpful Links for building and working with PHP Source +-------------------------------------------------------------------------------------- + +https://www.php.net/manual/en/install.unix.apache2.php +https://devzone.zend.com/303/extension-writing-part-i-introduction-to-php-and-zend/ +https://phpinternals.net/categories/extensions +https://askubuntu.com/questions/1102910/how-to-build-php-7-3 +https://gist.github.com/m1st0/1c41b8d0eb42169ce71a +http://www.linuxfromscratch.org/blfs/view/svn/general/php.html +https://docs.moodle.org/37/en/Compiling_PHP_from_source +http://www.phpinternalsbook.com/ +https://www.rapidspike.com/blog/php72-pthreads/ +http://nikic.github.io/ +https://www.slideshare.net/pierrej/extending-php-7-the-basics +https://medium.com/@anjesh/diving-into-php-internals-a-quick-attempt-to-understand-that-weird-error-c62eaf309204 +https://wiki.php.net/internals/extensions +https://flylib.com/books/en/2.565.1/starting_up_and_shutting_down.html +https://eddmann.com/posts/introduction-to-creating-a-basic-php-extension/ + +-------------------------------------------------------------------------------------- +| ext\standard\file.h +-------------------------------------------------------------------------------------- + *) Under: + typedef struct { + *) Add: + zend_bool file_access_is_limited; + *) The actual struct is [php_file_globals] + *) The result looks like this: + typedef struct { + zend_bool file_access_is_limited; + int pclose_ret; + +-------------------------------------------------------------------------------------- +| ext\standard\file.c +-------------------------------------------------------------------------------------- + *) Before: + PHP_INI_END() + *) Add: + STD_PHP_INI_ENTRY("file_access_is_limited", "0", PHP_INI_ALL, OnUpdateBool, file_access_is_limited, php_file_globals, file_globals) + -------------------------------------------------------------------------------------------- + *) Under Code Block: + PHP_FUNCTION(file_put_contents) + ZEND_PARSE_PARAMETERS_START + ... + ZEND_PARSE_PARAMETERS_END + *) Add: + if (FG(file_access_is_limited)) { + php_error_docref(NULL, E_WARNING, "You cannot write files using this build of PHP."); + RETURN_FALSE; + } + -------------------------------------------------------------------------------------------- + *) Under Code Block: + PHP_FUNCTION(mkdir) + ZEND_PARSE_PARAMETERS_START + *) Add: + if (FG(file_access_is_limited)) { + php_error_docref(NULL, E_WARNING, "You cannot create directories using this build of PHP."); + RETURN_FALSE; + } + -------------------------------------------------------------------------------------------- + *) Under Code Block: + PHP_FUNCTION(rmdir) + ZEND_PARSE_PARAMETERS_START + *) Add: + if (FG(file_access_is_limited)) { + php_error_docref(NULL, E_WARNING, "You cannot delete directories using this build of PHP."); + RETURN_FALSE; + } + -------------------------------------------------------------------------------------------- + *) Under Code Block: + PHP_FUNCTION(rename) + ZEND_PARSE_PARAMETERS_START + *) Add: + if (FG(file_access_is_limited)) { + php_error_docref(NULL, E_WARNING, "You cannot rename files using this build of PHP."); + RETURN_FALSE; + } + -------------------------------------------------------------------------------------------- + *) Under Code Block: + PHP_FUNCTION(unlink) + ZEND_PARSE_PARAMETERS_START + *) Add: + if (FG(file_access_is_limited)) { + php_error_docref(NULL, E_WARNING, "You cannot delete files using this build of PHP."); + RETURN_FALSE; + } + -------------------------------------------------------------------------------------------- + *) Under Code Block: + PHP_FUNCTION(copy) + ZEND_PARSE_PARAMETERS_START + *) Add: + if (FG(file_access_is_limited)) { + php_error_docref(NULL, E_WARNING, "You cannot copy files using this build of PHP."); + RETURN_FALSE; + } diff --git a/docs/PHP INI Settings.txt b/docs/PHP INI Settings.txt new file mode 100644 index 0000000..60fa712 --- /dev/null +++ b/docs/PHP INI Settings.txt @@ -0,0 +1,38 @@ +# See notes from [Playground Server Setup.txt] +# The [php.ini] file is manually edited with these values. +# Some of the functions to disable will not exist when PHP is built +# using the instructions from [PHP Custom Build Instructions.txt]. +# +# The purpose of disabled functions and settings: +# 1) Disable file writing outside of the admin app +# - A single users could easily fill up disk space if allowed to write files +# 2) Prevent network communication on the server (HTTP Requests, Sending emails, etc) +# - Spammers could easily take of the server and generate a lot of charges +# and use the server for malicious purposes. +# 3) Prevent too many resources from being used +# - Because users have the ability to defined any PHP code it's easy to +# cause CPU spikes, however limits are in place (small memory and quick timeouts). +# 4) These settings also depend on the custom PHP build for file writing +# and settings from a local [.htaccess] file for each user site. +# 5) Some of the disabled classes many be ok to allow. Further review of PHP source code +# is needed to decide for certain. For example, do the blocked classes use settings +# from [open_basedir] or do they allow full server access. More time is needed to +# determine this; and also if other allowed PHP functions/classes should be allowed +# or blocked. + +# Add this line to the end of the file: +zend_extension=opcache.so + +# Edit and verify the following: +allow_url_fopen = Off +allow_url_include = Off +file_uploads = Off +max_execution_time = 1 +max_input_time = 1 +memory_limit = 16M +post_max_size = 1M +default_socket_timeout = 1 +enable_dl = Off +opcache.revalidate_freq=0 +disable_functions = ini_set, ini_restore, exec, passthru, shell_exec, system, proc_open, proc_nice, popen, curl_exec, curl_multi_exec, dl, sleep, usleep, gc_disable, set_include_path, set_time_limit, tempnam, tmpfile, fopen, fwrite, ftruncate, fputcsv, link, umask, touch, chown, chmod, chgrp, glob, symlink, stream_socket_client, stream_socket_server, stream_context_create, stream_socket_pair, dns_get_record, dns_check_record, dns_get_mx, fsockopen, pfsockopen, setcookie, setrawcookie, syslog, openlog, stream_wrapper_restore, finfo_set_flags, gzwrite, gzwrite, mail, session_start, session_create_id +disable_classes = SplFileObject, SplTempFileObject, FilesystemIterator, DirectoryIterator, GlobIterator diff --git a/docs/Playground Server Setup.txt b/docs/Playground Server Setup.txt new file mode 100644 index 0000000..701dcda --- /dev/null +++ b/docs/Playground Server Setup.txt @@ -0,0 +1,174 @@ + +# ------------------------------------------------------------------------------------ +# +# This file describes step by step how the Playground Server is setup. +# These instructions apply specifically to Ubuntu 18.04.2 LTS; +# if using them for another build of Linux or Unix you will likely +# need to make changes to the commands used. The PHP custom build itself +# and PHP settings will provide the needed security settings so it can be +# used with Windows Servers as well, however Windows setup is different. +# +# http://releases.ubuntu.com/18.04/ +# +# ------------------------------------------------------------------------------------ + +# Connect to Server +# NOTE - The [pem] file path would vary on your machine +# and IP is based on the cloud machine at the time of development. +# +# This command assumes a Mac and key saved in similar location. +# Replace {variables} with your file/directory names. +# +# A Web Based Terminal such as AWS Lightsail can be used for this +# however because a large PHP source file is being edited on the directly +# on the server a GUI program can be used to make the process faster. +# +cd /Users/{your-name}/sites/keys +sudo chmod 400 {key}.pem +ssh-add -K {key}.pem +ssh -i /Users/{your-name}/sites/keys/{key}.pem ubuntu@1.1.1.1 + +# Install Apache and Required Develompment Libraries +sudo apt-get update +sudo apt-get install apache2 apache2-dev libxml2-dev + +# Find latest version from: +# https://www.php.net/downloads.php +# If a new major or minor version of PHP is used then the updates +# should be reviewed to see if there are new security issues to handle. +wget https://www.php.net/distributions/php-7.3.11.tar.bz2 + +# Uncompress +tar xjf php-7.3.11.tar.bz2 + +# Edit PHP Source Files - see notes in: [PHP Custom Build Instructions.txt] +# To edit with Nano: + nano ~/php-7.3.11/ext/standard/file.h + nano ~/php-7.3.11/ext/standard/file.c +# Or use a program such as Transmit: + https://panic.com/transmit/ + +# Build PHP +# [make] may take 2 to 10 minutes on the first build. A standard PHP build typically takes +# 10+ minutes however the option [--disable-all] makes this custom build much faster. +# If changes and there are additional builds it runs much faster. +cd php-7.3.11 +./configure --with-apxs2=/usr/bin/apxs --disable-all --enable-hash --enable-json --enable-filter --enable-ctype --enable-opcache +make +sudo make install + +# Configure PHP and Apache +# For a quick test that runs with all functions enabled run this: +sudo cp php.ini-production /usr/local/lib/php.ini +# +# Otherwise copy the php.ini file to a another place for editing: +cp php.ini-production ~/php.ini +# +# See [PHP INI Settings.txt] for what edits to make +# Then after edits: +sudo cp ~/php.ini /usr/local/lib/php.ini + +# Change Apache to use prefork (required after PHP is enabled otherwise Apache won't start) +sudo a2dismod mpm_event +sudo a2enmod mpm_prefork + +# File [/etc/apache2/mods-enabled/php7.load] is created +# however [/etc/apache2/mods-enabled/php7.conf] is not created +# +# Create File [php7.conf]: +sudo nano /etc/apache2/mods-enabled/php7.conf +# +# Add Contents: + + SetHandler application/x-httpd-php + +# +# Save using: +# {control+x} -> {y} -> {enter} + +# Enable Gzip Compression for JSON Responses +# (This is not enabled by default on Apache) +sudo nano /etc/apache2/mods-available/deflate.conf +# Add the following under similar commands: +# AddOutputFilterByType DEFLATE application/json + +# Edit Apache Config +sudo nano /etc/apache2/apache2.conf +# +# Under: +# +# Add: +# FallbackResource /index.php +# And Change: +# AllowOverride None +# To: +# AllowOverride All +# Save using: +# {control+x} -> {y} -> {enter} + +# Restart Apache +sudo service apache2 restart + +# Set Permissions +sudo adduser ubuntu www-data +sudo chown ubuntu:www-data -R /var/www +sudo chmod 0775 -R /var/www + +# Download and Unzip Playground Site (or upload the source). +# This URL is temporary and will be linked to a GitHub Repository +cd /var/www +wget https://fastsitephp.s3-us-west-1.amazonaws.com/tmp/playground726b.zip +sudo apt-get install unzip +unzip playground726b.zip + +# If needed create and view a PHP test file +cd /var/www/html +echo "" | sudo tee phpinfo.php +# http://54.184.49.72/phpinfo.php + +# Delete default page and phpinfo +rm index.html +sudo rm phpinfo.php + +# After copying files up, permissions need to be reset, +# and also to edit files that were created by the web user. +sudo chown ubuntu:www-data -R /var/www +sudo chmod 0775 -R /var/www + +# Generate a new key and update [app.php] +# For info on key generation with (xxd...urandom) see: +# https://www.fastsitephp.com/en/documents/file-encryption-bash +xxd -l 32 -c 32 -p /dev/urandom +# Example Output (don't use this, generate your own key): +# 85ef7bb21b3ee94b9e3e953c9aea23cf6ed03ba3252e19afe7210c788739eb87 +# Copy the key to the clipboard and update the PHP file +nano /var/www/app/app.php +# View the file using [nano] one more time after saving to verify the key changed + +# TODO - this step +# Test errors on a custom site by modifying [app.php] with contents from: +Playground\scripts\app-error-testing.php + +# Update Local Playground JavaScript File with the new URL +# Seach for "localhost:3000" or "urlRoot:" in the file and make related changes +# +# File: +# FastSitePHP\website\public\js\playground.js +# Example: +# urlRoot: 'http://35.161.242.238/', + +# Test the site from the Playground UI to verify it works + +# Setup a Cron Job using sudo to check for and delete expired sites. +# Runs once per minute, if not using [sudo] then sites will end up +# not being deleted. +sudo crontab -e +* * * * * php /var/www/scripts/delete-expired-sites.php + +# To view cron history: +grep CRON /var/log/syslog + +# View last result and sites directory: +cat /var/www/app_data/delete-sites-last-result.txt +cd /var/www/html/sites +ls -la diff --git a/html/favicon.ico b/html/favicon.ico new file mode 100644 index 0000000..b76729c Binary files /dev/null and b/html/favicon.ico differ diff --git a/html/index.php b/html/index.php new file mode 100644 index 0000000..c57f3cc --- /dev/null +++ b/html/index.php @@ -0,0 +1,54 @@ +setup('UTC'); + +// ------------------------------------------------------------------------------ +// Include the App File for the Site. +// The file [app.php] is where routes, functions, and features unique to a site +// will be defined. Having the site's core app code outside of the web root is +// good security practice because if a site is compromised or if a server update +// prevented php from working then it can reduce the chance of someone being able +// to view the site's server code. Additionally during development if there is a +// parsing error with the file then it will be caught and displayed on an error +// page because [$app->setup()] is called above, however if a lot of code was +// included in this file and there is a parsing error then a PHP WSOD +// (White Screen of Death) error would occur instead. +// ------------------------------------------------------------------------------ + +require __DIR__ . '/../app/app.php'; + +// ----------------------------------------------------------- +// Run the application +// ----------------------------------------------------------- + +// Run the app to determine and show the specified URL +$app->run(); + +// If debug then add script time and memory info to the end of the page +if (isset($show_debug_info) && $show_debug_info) { + $showDebugInfo(); +} diff --git a/html/robots.txt b/html/robots.txt new file mode 100644 index 0000000..94c5aae --- /dev/null +++ b/html/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: /sites \ No newline at end of file diff --git a/index.php b/index.php new file mode 100644 index 0000000..065f252 --- /dev/null +++ b/index.php @@ -0,0 +1,28 @@ +get('/', function() use ($app) { + $html = <<<'HTML' +

Error Testing

+ +HTML; + return $html; +}); + +// Error when using Custom PHP Build: +// file_put_contents(): You cannot write files using this build of PHP. +$app->get('/file-test', function() { + $path = __DIR__ . '/test.txt'; + file_put_contents($path, 'This is a test'); + return 'File Created: ' . json_encode(is_file($path)); +}); + +// Error when using Custom PHP Build: +// unlink(): You cannot delete files using this build of PHP. +$app->get('/unlink-test', function() { + $path = __DIR__ . '/test.txt'; + if (!is_file($path)) { + $path = __FILE__; + } + unlink($path); + return 'File Exits: ' . json_encode(is_file($path)); +}); + +// Error when using Custom PHP Build: +// copy(): You cannot copy files using this build of PHP. +$app->get('/copy-test', function() { + $source = __DIR__ . '/app.php'; + $dest = __DIR__ . '/copy.php'; + copy($source, $dest); + return 'File Copied: ' . json_encode(is_file($dest)); +}); + +// Error when using Custom PHP Build: +// mkdir(): You cannot create directories using this build of PHP. +$app->get('/mkdir-test', function() { + $path = __DIR__ . '/test'; + mkdir($path); + return 'Directory Created: ' . json_encode(is_dir($path)); +}); + +// Error when using Custom PHP Build: +// mkdir(): You cannot create directories using this build of PHP. +$app->get('/rmdir-test', function() { + $path = __DIR__ . '/app'; + rmdir($path); + return 'Directory Removed: ' . json_encode(is_dir($path)); +}); + +// Error when using Custom PHP Build: +// mkdir(): You cannot rename files using this build of PHP. +$app->get('/rename-test', function() { + $source = __DIR__ . '/app.php'; + $dest = __DIR__ . '/renamed.php'; + rename($source, $dest); + return 'File Renamed: ' . json_encode(is_file($dest)); +}); + +// Error when [allow_url_fopen=0]: +// file_get_contents(): http:// wrapper is disabled in the server configuration by allow_url_fopen=0 +$app->get('/http-test', function() { + return file_get_contents('http://www.example.com/'); +}); + +// [https] will also error however the custom PHP build does not include [openssl] for [https] +// support so this will give a different error message than the http test. +$app->get('/https-test', function() { + return file_get_contents('https://www.example.com/'); +}); + +// With correct [php.ini] settings this will generate: +// stream_socket_client() has been disabled for security reasons +$app->get('/smtp-test', function() { + $reply_lines = []; + $debug_callback = function($message) use (&$reply_lines) { + $reply_lines[] = '[' . date('H:i:s') . '] ' . trim($message); + }; + $host = 'smtp.gmail.com'; + $port = 587; + $timeout = 5; + $smtp = new \FastSitePHP\Net\SmtpClient($host, $port, $timeout, $debug_callback); + $smtp->noop(); + $smtp->help(); + $smtp = null; + return $reply_lines; +}); + +// Error when function is disabled: +// ini_set() has been disabled for security reasons +$app->get('/ini-set', function() { + ini_set('display_errors', 'off'); + return ini_get('display_errors'); +}); + +// Error when function is disabled: +// shell_exec() has been disabled for security reasons +$app->get('/shell-exec', function() { + return shell_exec('whoami'); +}); + +// Valid because user will have access to this directory +// based on the [.] in [.htaccess] -> [php_value open_basedir /var/www/vendor:.]. +// [:] is the path separator in Linux/Unix. +$app->get('/get-valid-file-1', function() use ($app) { + $app->header('Content-Type', 'text/plain'); + return file_get_contents(__FILE__); +}); + +// Valid because user will have access to this directory +// based on the [/var/www/vendor] in [.htaccess] -> [php_value open_basedir] +$app->get('/get-valid-file-2', function() use ($app) { + $path = __DIR__ . '/../../../../vendor/fastsitephp/src/Application.php'; + $app->header('Content-Type', 'text/plain'); + return file_get_contents($path); +}); + +// This works when testing local without the [open_basedir] setting. +// In production it gives this error: +// file_get_contents(): open_basedir restriction in effect. ... +$app->get('/get-error-file', function() use ($app) { + $path = __DIR__ . '/../../../../app/app.php'; + $app->header('Content-Type', 'text/plain'); + return file_get_contents($path); +}); + +// [php.ini] should be set to [max_execution_time = 1] so requests with +// this should timeout quickly. The browser will try for many seconds +// and the give up and show an error such as "ERR_EMPTY_RESPONSE". +// This function uses a lot of resources as well so the CPU will spike +// but the site should still function ok when it runs. +$app->get('/timeout', function() { + while (1 === 1) { + \password_hash('password', PASSWORD_BCRYPT, ['cost' => 20]); + } +}); + +// When using [memory_limit = 16M]: +// Allowed memory size of 16777216 bytes exhausted (tried to allocate 10485792 bytes) +$app->get('/memory', function() { + $str = ''; + while (1 === 1) { + $str .= str_repeat(' ', (1024 * 1024)); + } +}); + +// This should return the standard error template +$app->get('/error-page', function() { + throw new \Exception('Test'); +}); + +// Use this to see what is enabled on the server +$app->get('/php-func', function() use ($app) { + $classes = array_values(get_declared_classes()); + $disabled_classes = ini_get('disable_classes'); + $disabled_classes = explode(',', str_replace(' ', '', $disabled_classes)); + $classes = array_diff($classes, $disabled_classes); + $text = str_repeat('-', 80) . "\n"; + $text .= count($classes) . " Classes\n"; + $text .= str_repeat('-', 80) . "\n"; + foreach ($classes as $name) { + $text .= $name . "\n"; + } + + $functions = get_defined_functions(true); + $functions = array_values($functions['internal']); + $text .= "\n\n" . str_repeat('-', 80) . "\n"; + $text .= count($functions) . ' Functions' . "\n"; + $text .= str_repeat('-', 80) . "\n"; + foreach ($functions as $name) { + $text .= $name . "\n"; + } + + $app->header('Content-Type', 'text/plain'); + return $text; +}); + +// View the standard PHP Info Page +$app->get('/php-info', function() { + phpinfo(); +}); diff --git a/scripts/app-quick-site-create.php b/scripts/app-quick-site-create.php new file mode 100644 index 0000000..f1ec335 --- /dev/null +++ b/scripts/app-quick-site-create.php @@ -0,0 +1,62 @@ +show_detailed_errors = true; + +// The key for signing is hard-coded. The value below is for dev +// while the actual production server has a different value. +$app->config['SIGNING_KEY'] = 'ab2403a36467b59b20cc314bb211e1812668b3bffb00358c161f26fe003073ed'; + +// ---------------------------------------------------------------------------- +// Routes +// ---------------------------------------------------------------------------- + +$app->get('/', function() { + return 'Playground Site'; +}); + +// Later this will require a POST +// Homepage will then be a simple single file PHP page rendered from this directory +$app->get('/create-site', function() { + // Get list of files to copy + $copy_from = __DIR__ . '/../app_data/template/en/'; + $search = new Search(); + $files = $search + ->dir($copy_from) + ->recursive(true) + ->includeRoot(false) + ->files(); + + // Build a random hex string for the site + $site = bin2hex(random_bytes(10)); + $copy_to = __DIR__ . '/../html/sites/' . $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 + $expires = time() + (60 * 60); + file_put_contents($copy_to . 'expires.txt', $expires); + + // Show Site Info + $token = Crypto::sign($site); + $verified = Crypto::verify($token); + return '

Site Created: ' . $site . '

View Site

Key: ' . $token . '

Verified: ' . $verified; +}); diff --git a/scripts/delete-expired-sites.php b/scripts/delete-expired-sites.php new file mode 100644 index 0000000..8859822 --- /dev/null +++ b/scripts/delete-expired-sites.php @@ -0,0 +1,87 @@ +')); +const VENDOR_DIR = __DIR__ . '/../vendor'; + +// CA certificates are download from [https://curl.haxx.se/docs/caextract.html] +// and saved at the following location: +// http://fastsitephp.s3-us-west-1.amazonaws.com/cacert/2019-10-16/cacert.pem +// http://fastsitephp.s3-us-west-1.amazonaws.com/cacert/2019-10-16/cacert.pem.sha256 +// The file is over 220 kb so rather than including it with source the file is +// downloaded and verified from an HTTP link before any HTTPS requests are made. +const CACERT_URL = 'http://fastsitephp.s3-us-west-1.amazonaws.com/cacert/2019-10-16/cacert.pem'; +const CACERT_SHA256 = '5cd8052fcf548ba7e08899d8458a32942bf70450c9af67a0850b4c711804a2e4'; +define('CACERT_FILE', sys_get_temp_dir() . '/install-cacert.pem'); + +/** + * Projects to download + */ +$downloads = array( + array( + 'url' => 'https://www.fastsitephp.com/downloads/framework', + 'save_file' => __DIR__ . '/FastSitePHP.zip', + 'check_file' => VENDOR_DIR . '/fastsitephp/src/Application.php', + 'skip_check' => __DIR__ . '/../src/Application.php', // Skip download if running within Framework + ), + array( + 'url' => 'https://github.com/php-fig/log/archive/1.1.0.zip', + 'save_file' => __DIR__ . '/psr-log.zip', + 'check_file' => VENDOR_DIR . '/psr/log/Psr/Log/AbstractLogger.php', + 'mkdir' => VENDOR_DIR . '/psr', + 'rename_from' => VENDOR_DIR . '/log-1.1.0', + 'rename_to' => VENDOR_DIR . '/psr/log', + ), +); + +/** + * Create the root [vendor] directory where + * PHP projects are downloaded and extracted to. + */ +function createVendorDir() { + if (!is_dir(VENDOR_DIR)) { + mkdir(VENDOR_DIR); + } +} + +/** + * Download and verify a [cacert.pem] file for HTTPS requests. + * The file will downloaded via an HTTP request and then saved + * to the system's temp directory. Everytime the file is used + * the contents are verified using a SHA-256 hash. + */ +function downloadCACert() { + // File already downloaded and verified + if (is_file(CACERT_FILE) && hash_file('sha256', CACERT_FILE) === CACERT_SHA256) { + return; + } + + // Status + echo 'Downloading: ' . CACERT_URL . LINE_BREAK; + + // Download and verify contents + $contents = file_get_contents(CACERT_URL); + if (hash('sha256', $contents) !== CACERT_SHA256) { + echo 'ERROR - Downloaded CA Cert Contents does not match the known SHA-256 Hash:' . LINE_BREAK; + echo 'Expected: ' . CACERT_SHA256 . LINE_BREAK; + echo 'Content Hash: ' . hash('sha256', $contents) . LINE_BREAK; + echo 'Response Headers:' . LINE_BREAK; + foreach ($http_response_header as $header) { + echo $header . LINE_BREAK; + } + echo 'Response:' . LINE_BREAK; + var_dump($contents); + exit(); + } + + // Write to file and verify written file + file_put_contents(CACERT_FILE, $contents); + if (hash_file('sha256', CACERT_FILE) !== CACERT_SHA256) { + echo 'ERROR - Downloaded CA Cert File does not match the known SHA-256 Hash:' . LINE_BREAK; + echo 'Expected: ' . CACERT_SHA256 . LINE_BREAK; + echo 'File Hash: ' . hash_file('sha256', CACERT_FILE) . LINE_BREAK; + exit(); + } + + // File is valid if code execution makes it here + echo 'CA Cert File Saved: ' . CACERT_FILE . LINE_BREAK; + echo 'CA Cert File Hash Verified: ' . CACERT_SHA256 . LINE_BREAK; +} + +/** + * Download the Zip File if not already downloaded. + * + * @param string $url + * @param string $path + */ +function downloadZip($url, $path) { + // Does the file already exist? + if (is_file($path)) { + echo 'Skipping Download, File already exists: ' . $path . LINE_BREAK; + return; + } + + // Make sure [cacert.pem] is downloaded and valid + if (strpos($url, 'https://') === 0) { + downloadCACert(); + } + + // Status + echo 'Downloading: ' . $url . LINE_BREAK; + echo 'Save Location: ' . $path . LINE_BREAK; + + // Build HTTP Request + $http_options = array( + 'ssl' => array( + 'ciphers' => 'HIGH', + 'cafile' => CACERT_FILE, + ), + ); + $context = stream_context_create($http_options); + $response = file_get_contents($url, null, $context); + + // Look for a 200 Response Code, example: + // 'HTTP/1.0 200 OK' + // 'HTTP/1.1 200 OK' + // Headers are returned in order so all redirect headers will appear first. + $is_ok = false; + foreach ($http_response_header as $header) { + preg_match('/HTTP\/[1|2].[0|1] ([0-9]{3})/', $header, $matches); + if ($matches) { + $status_code = (int)$matches[1]; + if ($status_code === 200) { + $is_ok = true; + break; + } + } + } + + // Save Zip file if response is ok + if ($is_ok) { + file_put_contents($path, $response); + echo 'Download Ok' . LINE_BREAK; + } else { + echo 'Error Downloading File, Response Headers:' . LINE_BREAK; + foreach ($http_response_header as $header) { + echo $header . LINE_BREAK; + } + exit(); + } +} + +/** + * Extract the downloaded Zip File + * + * @param array $download + */ +function extractZip($download) { + // Status + $path = $download['save_file']; + echo 'Extracting: ' . $path . LINE_BREAK; + + // Extract from Zip + $zip = new \ZipArchive; + $zip->open($path); + $success = $zip->extractTo(VENDOR_DIR); + $zip->close(); + + // Check Result + if ($success) { + echo 'File extracted successfully' . LINE_BREAK; + } else { + echo 'Error extracting Zip' . LINE_BREAK; + exit(); + } + + // Create the [vendor/{owner}] Directory if needed + if (isset($download['mkdir'])) { + $dir = $download['mkdir']; + if (!is_dir($dir)) { + echo 'Making Directory: ' . $dir . LINE_BREAK; + mkdir($dir); + } + } + + // Rename from version specific directory to the + // directory that is compatible for composer. + if (isset($download['rename_from'])) { + echo 'Rename From: ' . $download['rename_from'] . LINE_BREAK; + echo 'Rename To: ' . $download['rename_to'] . LINE_BREAK; + rename($download['rename_from'], $download['rename_to']); + } + + // Make sure an expected file is found to verify the zip extraction + $path = $download['check_file']; + if (is_file($path)) { + echo 'Confirmed File: ' . $path . LINE_BREAK; + } else { + echo 'ERROR - the following file was not found after unzipping the Zip file. Check your file permissions and any warning or error messages' . LINE_BREAK; + echo $path . LINE_BREAK; + exit(); + } + + // Status + echo 'Success project extracted' . LINE_BREAK; +} + +/** + * Return true if project already exists in the correct location. + * + * @param array $download + * @return bool + */ +function projectIsDownloaded($download) { + if (isset($download['skip_check']) && is_file($download['skip_check'])) { + return true; + } + return is_file($download['check_file']); +} + +/** + * Return true if the request is running from localhost '127.0.0.1' (IPv4) + * or '::1' (IPv6) and if the web server software is also running on localhost. + * + * This function is based on [FastSitePHP\Web\Request->isLocal()]. + * See comments in the source function for full details. + * + * @return bool + */ +function isLocal() { + // Get Client IP + $client_ip = (isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : null); + + // Get Server IP + $server_ip = null; + if (isset($_SERVER['SERVER_ADDR'])) { + $server_ip = $_SERVER['SERVER_ADDR']; + } elseif (isset($_SERVER['LOCAL_ADDR'])) { + $server_ip = $_SERVER['LOCAL_ADDR']; + } elseif (php_sapi_name() === 'cli-server' && isset($_SERVER['REMOTE_ADDR'])) { + $server_ip = $_SERVER['REMOTE_ADDR']; + } + + // Normalize IP's if needed + $client_ip = ($client_ip === '[::1]' ? '::1' : $client_ip); + $server_ip = ($server_ip === '[::1]' ? '::1' : $server_ip); + + // Check IP's + return ( + ($client_ip === '127.0.0.1' || $client_ip === '::1') + && ($server_ip === '127.0.0.1' || $server_ip === '::1') + ); +} + +/** + * Main function + * + * @param array $downloads + */ +function main($downloads) { + // Running from command line or localhost? (both client and server are required) + if (!(IS_CLI || isLocal())) { + echo 'No Action Taken - Exiting Script. Running this file requires server access. You need to run from either Command Line or directly on the server/computer.' . LINE_BREAK; + exit(); + } + + // Download and Extract Zip Files + createVendorDir(); + foreach ($downloads as $download) { + echo str_repeat('-', 80) . LINE_BREAK; + $is_downloaded = projectIsDownloaded($download); + if ($is_downloaded) { + echo 'Project [' . $download['url'] . '] is already downloaded' . LINE_BREAK; + } else { + downloadZip($download['url'], $download['save_file']); + extractZip($download); + unlink($download['save_file']); // Delete the Zip file + } + } + + // Create a [vendor/autoload.php] file unless one already exists + echo str_repeat('-', 80) . LINE_BREAK; + $autoload_path = VENDOR_DIR . '/autoload.php'; + if (is_file($autoload_path)) { + echo 'Using existing autoloader file: ' . $autoload_path . LINE_BREAK; + } else { + $source = __DIR__ . '/fast_autoloader.php'; + echo 'Creating autoloader file' . LINE_BREAK; + echo 'Copying from: ' . $source . LINE_BREAK; + echo 'Copying to: ' . $autoload_path . LINE_BREAK; + copy($source, $autoload_path); + } + + // PHP continues code execution by default when there is + // an error so make sure there were no errors. + echo str_repeat('=', 80) . LINE_BREAK; + $err = error_get_last(); + if ($err !== null) { + echo 'WARNING - an error has occurred. This site may or may not work. Review the full output and take any action needed (for example setting file permissions).' . LINE_BREAK; + } else { + echo 'SUCCESS - A files are downloaded and extracted to the correct folder' . LINE_BREAK; + } +} + +/** + * Start of Script + */ +main($downloads);