diff --git a/README.md b/README.md index 90fef4e..e5d5709 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ The compiled dist files will be automatically moved to `/js`. Each API endpoint has usually one overlying category, for example all user and authorization endpoints belong to the [UserAPI](/core/Api/UserAPI.class.php). These endpoints can be accessed by requesting URLs starting with `/api/user`, for example: `/api/user/login`. There are also endpoints, which don't have -a category, e.g. [PatchSQL](/core/Api/PatchSQL.class.php). These functions can be called directly, for example with `/api/patchSQL`. Both methods have one thing in common: +a category, e.g. [VerifyCaptcha](/core/Api/VerifyCaptcha.class.php). These functions can be called directly, for example with `/api/verifyCaptcha`. Both methods have one thing in common: Each endpoint is represented by a class inheriting the [Request Class](/core/Api/Request.class.php). An example endpoint looks like this: ```php @@ -112,7 +112,7 @@ If any result is expected from the api call, the `$req->getResult()` method can This step is not really required, as and changes made to the database must not be presented inside the code. On the other hand, it is recommended to keep track of any modifications for later use or to deploy the application to other systems. Therefore, either the [default installation script](/core/Configuration/CreateDatabase.class.php) or -an additional patch file, which can be executed using the API (`/api/PatchSQL`), can be created. The patch files are usually +an additional patch file, which can be executed using the [CLI](/cli.php), can be created. The patch files are usually located in [/core/Configuration/Patch](/core/Configuration/Patch) and have the following structure: ```php diff --git a/cli.php b/cli.php new file mode 100644 index 0000000..6439613 --- /dev/null +++ b/cli.php @@ -0,0 +1,410 @@ +getSQL() || !$user->getSQL()->isConnected()) { + _exit("Could not establish database connection"); + } + + return $user; +} + +function printHelp() { + // TODO: help +} + +function handleDatabase(array $argv) { + $action = $argv[2] ?? ""; + + if ($action === "migrate") { + $class = $argv[3] ?? null; + if (!$class) { + _exit("Usage: cli.php db migrate "); + } + + $class = str_replace('/', '\\', $class); + $className = "\\Configuration\\$class"; + $classPath = getClassPath($className); + if (!file_exists($classPath) || !is_readable($classPath)) { + _exit("Database script file does not exist or is not readable"); + } + + include_once $classPath; + $obj = new $className(); + if (!($obj instanceof DatabaseScript)) { + _exit("Not a database script"); + } + + $user = getUser(); + $sql = $user->getSQL(); + $queries = $obj->createQueries($sql); + foreach ($queries as $query) { + if (!$query->execute($sql)) { + _exit($sql->getLastError()); + } + } + } else if ($action === "export" || $action === "import") { + + // database config + $config = getDatabaseConfig(); + $dbType = $config->getProperty("type") ?? null; + $user = $config->getLogin(); + $password = $config->getPassword(); + $database = $config->getProperty("database"); + $host = $config->getHost(); + $port = $config->getPort(); + + // subprocess config + $env = []; + $options = array_slice($argv, 3); + $dataOnly = in_array("--data-only", $options) || in_array("-d", $options); + $descriptorSpec = [STDIN, STDOUT, STDOUT]; + $inputData = null; + + // argument config + if ($action === "import") { + $file = $argv[3] ?? null; + if (!$file) { + _exit("Usage: cli.php db import "); + } + + if (!file_exists($file) || !is_readable($file)) { + _exit("File not found or not readable"); + } + + $inputData = file_get_contents($file); + } + + if ($dbType === "mysql") { + $command_args = ["-u", $user, '-h', $host, '-P', $port, "--password=$password"]; + if ($action === "export") { + $command_bin = "mysqldump"; + if ($dataOnly) { + $command_args[] = "--skip-triggers"; + $command_args[] = "--compact"; + $command_args[] = "--no-create-info"; + } + } else if ($action === "import") { + $command_bin = "mysql"; + $descriptorSpec[0] = ["pipe", "r"]; + } + } else if ($dbType === "postgres") { + + $env["PGPASSWORD"] = $password; + $command_args = ["-U", $user, '-h', $host, '-p', $port]; + + if ($action === "export") { + $command_bin = "/usr/bin/pg_dump"; + if ($dataOnly) { + $command_args[] = "--data-only"; + } + } else if ($action === "import") { + $command_bin = "/usr/bin/psql"; + $descriptorSpec[0] = ["pipe", "r"]; + } + + } else { + _exit("Unsupported database type"); + } + + if ($database) { + $command_args[] = $database; + } + + $command = array_merge([$command_bin], $command_args); + $process = proc_open($command, $descriptorSpec, $pipes, null, $env); + + if (is_resource($process)) { + if ($action === "import" && $inputData && count($pipes) > 0) { + fwrite($pipes[0], $inputData); + fclose($pipes[0]); + } + + proc_close($process); + } + } else { + _exit("Usage: cli.php db [options...]"); + } +} + +function findPullBranch(array $output): ?string { + foreach ($output as $line) { + $parts = preg_split('/\s+/', $line); + if (count($parts) >= 3 && $parts[2] === '(fetch)') { + $remoteName = $parts[0]; + $url = $parts[1]; + if (endsWith($url, "@github.com:rhergenreder/web-base.git") || + endsWith($url, "@romanh.de:Projekte/web-base.git") || + $url === 'https://github.com/rhergenreder/web-base.git' || + $url === 'https://git.romanh.de/Projekte/web-base.git') { + return "$remoteName/master"; + } + } + } + + return null; +} + +function onMaintenance(array $argv) { + $action = $argv[2] ?? "status"; + $maintenanceFile = "MAINTENANCE"; + $isMaintenanceEnabled = file_exists($maintenanceFile); + + if ($action === "status") { + _exit("Maintenance: " . ($isMaintenanceEnabled ? "on" : "off")); + } else if ($action === "on") { + $file = fopen($maintenanceFile, 'w') or _exit("Unable to create maintenance file"); + fclose($file); + _exit("Maintenance enabled"); + } else if ($action === "off") { + if (file_exists($maintenanceFile)) { + if (!unlink($maintenanceFile)) { + _exit("Unable to delete maintenance file"); + } + } + _exit("Maintenance disabled"); + } else if ($action === "update") { + + printLine("$ git remote -v"); + exec("git remote -v", $gitRemote, $ret); + if ($ret !== 0) { + die(); + } + + $pullBranch = findPullBranch($gitRemote); + if ($pullBranch === null) { + $pullBranch = 'origin/master'; + printLine("Unable to find remote update branch. Make sure, you are still in a git repository, and one of the remote branches " . + "have the original fetch url"); + printLine("Trying to continue with '$pullBranch'"); + } else { + printLine("Using remote update branch: $pullBranch"); + } + + printLine("$ git fetch " . str_replace("/", " ", $pullBranch)); + exec("git fetch " . str_replace("/", " ", $pullBranch), $gitFetch, $ret); + if ($ret !== 0) { + die(); + } + + printLine("$ git log HEAD..$pullBranch --oneline"); + exec("git log HEAD..$pullBranch --oneline", $gitLog, $ret); + if ($ret !== 0) { + die(); + } else if (count($gitLog) === 0) { + _exit("Already up to date."); + } + + printLine("Found updates, checking repository state"); + printLine("$ git diff-index --quiet HEAD --"); // check for any uncommitted changes + exec("git diff-index --quiet HEAD --", $gitDiff, $ret); + if ($ret !== 0) { + _exit("You have uncommitted changes. Please commit them before updating."); + } + + // enable maintenance mode if it wasn't turned on before + if (!$isMaintenanceEnabled) { + printLine("Turning on maintenance mode"); + $file = fopen($maintenanceFile, 'w') or _exit("Unable to create maintenance file"); + fclose($file); + } + + printLine("Ready to update, pulling and merging"); + printLine("$ git pull " . str_replace("/", " ", $pullBranch) . " --no-ff"); + exec("git pull " . str_replace("/", " ", $pullBranch) . " --no-ff", $gitPull, $ret); + if ($ret !== 0) { + printLine(); + printLine("Update could not be applied, check the git output."); + printLine("Follow the instructions and afterwards turn off the maintenance mode again using:"); + printLine("cli.php maintenance off"); + die(); + } + + // disable maintenance mode again + if (!$isMaintenanceEnabled) { + printLine("Turning off maintenance mode"); + if (file_exists($maintenanceFile)) { + if (!unlink($maintenanceFile)) { + _exit("Unable to delete maintenance file"); + } + } + } + } else { + _exit("Usage: cli.php maintenance "); + } +} + +function printTable(array $head, array $body) { + + $columns = []; + foreach ($head as $key) { + $columns[$key] = strlen($key); + } + + foreach ($body as $row) { + foreach ($head as $key) { + $value = $row[$key] ?? ""; + $length = strlen($value); + $columns[$key] = max($columns[$key], $length); + } + } + + // print table + foreach ($head as $key) { + echo str_pad($key, $columns[$key]) . ' '; + } + printLine(); + + foreach ($body as $row) { + foreach ($head as $key) { + echo str_pad($row[$key] ?? "", $columns[$key]) . ' '; + } + printLine(); + } +} + +// TODO: add missing api functions (should be all internal only i guess) +function onRoutes(array $argv) { + + $user = getUser(); + $action = $argv[2] ?? "list"; + + if ($action === "list") { + $req = new Api\Routes\Fetch($user); + $success = $req->execute(); + if (!$success) { + _exit("Error fetching routes: " . $req->getLastError()); + } else { + $routes = $req->getResult()["routes"]; + $head = ["uid", "request", "action", "target", "extra", "active"]; + + // strict boolean + foreach ($routes as &$route) { + $route["active"] = $route["active"] ? "true" : "false"; + } + + printTable($head, $routes); + } + } else if ($action === "add") { + if (count($argv) < 6) { + _exit("Usage: cli.php routes add [extra]"); + } + + $params = array( + "request" => $argv[3], + "action" => $argv[4], + "target" => $argv[5], + "extra" => $argv[6] ?? "" + ); + + $req = new Api\Routes\Add($user); + $success = $req->execute($params); + if (!$success) { + _exit($req->getLastError()); + } else { + _exit("Route added successfully"); + } + } else if (in_array($action, ["remove","modify","enable","disable"])) { + $uid = $argv[3] ?? null; + if ($uid === null || ($action === "modify" && count($argv) < 7)) { + if ($action === "modify") { + _exit("Usage: cli.php routes $action [extra]"); + } else { + _exit("Usage: cli.php routes $action "); + } + } + + $params = ["uid" => $uid]; + if ($action === "remove") { + $input = null; + do { + if ($input === "n") { + die(); + } + echo "Remove route #$uid? (y|n): "; + } while(($input = trim(fgets(STDIN))) !== "y"); + + $req = new Api\Routes\Remove($user); + } else if ($action === "enable") { + $req = new Api\Routes\Enable($user); + } else if ($action === "disable") { + $req = new Api\Routes\Disable($user); + } else if ($action === "modify") { + $req = new Api\Routes\Update($user); + $params["request"] = $argv[4]; + $params["action"] = $argv[5]; + $params["target"] = $argv[6]; + $params["extra"] = $argv[7] ?? ""; + } else { + _exit("Unsupported action"); + } + + $success = $req->execute($params); + if (!$success) { + _exit($req->getLastError()); + } else { + _exit("Route updated successfully"); + } + } else { + _exit("Usage: cli.php routes [options...]"); + } +} + +$argv = $_SERVER['argv']; +if (count($argv) < 2) { + _exit("Usage: cli.php [options...]"); +} + +$command = $argv[1]; +switch ($command) { + case 'help': + printHelp(); + exit; + case 'db': + handleDatabase($argv); + break; + case 'routes': + onRoutes($argv); + break; + case 'maintenance': + onMaintenance($argv); + break; + default: + printLine("Unknown command '$command'"); + printLine(); + printHelp(); + exit; +} \ No newline at end of file diff --git a/core/Api/PatchSQL.class.php b/core/Api/PatchSQL.class.php deleted file mode 100644 index 3c12800..0000000 --- a/core/Api/PatchSQL.class.php +++ /dev/null @@ -1,65 +0,0 @@ - new StringType("className", 64) - )); - $this->loginRequired = true; - $this->csrfTokenRequired = false; - } - - public function execute($values = array()): bool { - if (!parent::execute($values)) { - return false; - } - - $className = $this->getParam("className"); - $fullClassName = "\\Configuration\\Patch\\" . $className; - $path = getClassPath($fullClassName, true); - if (!file_exists($path)) { - return $this->createError("File not found"); - } - - if(!class_exists($fullClassName)) { - return $this->createError("Class not found."); - } - - try { - $reflection = new \ReflectionClass($fullClassName); - if (!$reflection->isInstantiable()) { - return $this->createError("Class is not instantiable"); - } - - if (!$reflection->isSubclassOf(DatabaseScript::class)) { - return $this->createError("Not a database script."); - } - - $sql = $this->user->getSQL(); - $obj = $reflection->newInstance(); - $queries = $obj->createQueries($sql); - if (!is_array($queries)) { - return $this->createError("Database script returned invalid values"); - } - - foreach($queries as $query) { - if (!$query->execute()) { - return $this->createError("Query error: " . $sql->getLastError()); - } - } - - $this->success = true; - } catch (\ReflectionException $e) { - return $this->createError("Error reflecting class: " . $e->getMessage()); - } - - return $this->success; - } -} \ No newline at end of file diff --git a/core/Api/Request.class.php b/core/Api/Request.class.php index c5b8838..4e42e6c 100644 --- a/core/Api/Request.class.php +++ b/core/Api/Request.class.php @@ -111,13 +111,15 @@ class Request { return false; } - if (!in_array($_SERVER['REQUEST_METHOD'], $this->allowedMethods)) { - $this->lastError = 'This method is not allowed'; - header('HTTP 1.1 405 Method Not Allowed'); - return false; - } - if ($this->externalCall) { + + // check the request method + if (!in_array($_SERVER['REQUEST_METHOD'], $this->allowedMethods)) { + $this->lastError = 'This method is not allowed'; + header('HTTP 1.1 405 Method Not Allowed'); + return false; + } + $apiKeyAuthorized = false; // Logged in or api key authorized? diff --git a/core/Api/RoutesAPI.class.php b/core/Api/RoutesAPI.class.php index 3247d42..da96438 100644 --- a/core/Api/RoutesAPI.class.php +++ b/core/Api/RoutesAPI.class.php @@ -1,8 +1,13 @@ user->getSQL(); + $res = $sql->select($sql->count()) + ->from("Route") + ->where(new Compare("uid", $uid)) + ->execute(); + + $this->success = ($res !== false); + $this->lastError = $sql->getLastError(); + if ($this->success) { + if ($res[0]["count"] === 0) { + return $this->createError("Route not found"); + } + } + + return $this->success; + } + + protected function toggleRoute($uid, $active): bool { + if (!$this->routeExists($uid)) { + return false; + } + + $sql = $this->user->getSQL(); + $this->success = $sql->update("Route") + ->set("active", $active) + ->where(new Compare("uid", $uid)) + ->execute(); + + $this->lastError = $sql->getLastError(); + return $this->success; + } } } @@ -25,8 +63,10 @@ namespace Api\Routes { use Api\Parameter\StringType; use Api\RoutesAPI; use Driver\SQL\Column\Column; + use Driver\SQL\Condition\Compare; use Driver\SQL\Condition\CondBool; use Driver\SQL\Condition\CondRegex; + use Objects\User; class Fetch extends RoutesAPI { @@ -162,7 +202,7 @@ namespace Api\Routes { return $this->success; } - private function validateRoutes() { + private function validateRoutes(): bool { $this->routes = array(); $keys = array( @@ -173,10 +213,6 @@ namespace Api\Routes { "active" => Parameter::TYPE_BOOLEAN ); - $actions = array( - "redirect_temporary", "redirect_permanently", "static", "dynamic" - ); - foreach($this->getParam("routes") as $index => $route) { foreach($keys as $key => $expectedType) { if (!array_key_exists($key, $route)) { @@ -193,7 +229,7 @@ namespace Api\Routes { } $action = $route["action"]; - if (!in_array($action, $actions)) { + if (!in_array($action, self::ACTIONS)) { return $this->createError("Invalid action: $action"); } @@ -213,5 +249,149 @@ namespace Api\Routes { return true; } } + + class Add extends RoutesAPI { + + public function __construct(User $user, bool $externalCall = false) { + parent::__construct($user, $externalCall, array( + "request" => new StringType("request", 128), + "action" => new StringType("action"), + "target" => new StringType("target", 128), + "extra" => new StringType("extra", 64, true, ""), + )); + $this->isPublic = false; + } + + public function execute($values = array()): bool { + if (!parent::execute($values)) { + return false; + } + + $request = $this->formatRegex($this->getParam("request"), true); + $action = $this->getParam("action"); + $target = $this->getParam("target"); + $extra = $this->getParam("extra"); + + if (!in_array($action, self::ACTIONS)) { + return $this->createError("Invalid action: $action"); + } + + $sql = $this->user->getSQL(); + $this->success = $sql->insert("Route", ["request", "action", "target", "extra"]) + ->addRow($request, $action, $target, $extra) + ->execute(); + + $this->lastError = $sql->getLastError(); + return $this->success; + } + } + + class Update extends RoutesAPI { + public function __construct(User $user, bool $externalCall = false) { + parent::__construct($user, $externalCall, array( + "uid" => new Parameter("uid", Parameter::TYPE_INT), + "request" => new StringType("request", 128), + "action" => new StringType("action"), + "target" => new StringType("target", 128), + "extra" => new StringType("extra", 64, true, ""), + )); + $this->isPublic = false; + } + + public function execute($values = array()): bool { + if (!parent::execute($values)) { + return false; + } + + $uid = $this->getParam("uid"); + if (!$this->routeExists($uid)) { + return false; + } + + $request = $this->formatRegex($this->getParam("request"), true); + $action = $this->getParam("action"); + $target = $this->getParam("target"); + $extra = $this->getParam("extra"); + if (!in_array($action, self::ACTIONS)) { + return $this->createError("Invalid action: $action"); + } + + $sql = $this->user->getSQL(); + $this->success = $sql->update("Route") + ->set("request", $request) + ->set("action", $action) + ->set("target", $target) + ->set("extra", $extra) + ->where(new Compare("uid", $uid)) + ->execute(); + + $this->lastError = $sql->getLastError(); + return $this->success; + } + } + + class Remove extends RoutesAPI { + public function __construct(User $user, bool $externalCall = false) { + parent::__construct($user, $externalCall, array( + "uid" => new Parameter("uid", Parameter::TYPE_INT) + )); + $this->isPublic = false; + } + + public function execute($values = array()): bool { + if (!parent::execute($values)) { + return false; + } + + $uid = $this->getParam("uid"); + if (!$this->routeExists($uid)) { + return false; + } + + $sql = $this->user->getSQL(); + $this->success = $sql->delete("Route") + ->where(new Compare("uid", $uid)) + ->execute(); + + $this->lastError = $sql->getLastError(); + return $this->success; + } + } + + class Enable extends RoutesAPI { + public function __construct(User $user, bool $externalCall = false) { + parent::__construct($user, $externalCall, array( + "uid" => new Parameter("uid", Parameter::TYPE_INT) + )); + $this->isPublic = false; + } + + public function execute($values = array()): bool { + if (!parent::execute($values)) { + return false; + } + + $uid = $this->getParam("uid"); + return $this->toggleRoute($uid, true); + } + } + + class Disable extends RoutesAPI { + public function __construct(User $user, bool $externalCall = false) { + parent::__construct($user, $externalCall, array( + "uid" => new Parameter("uid", Parameter::TYPE_INT) + )); + $this->isPublic = false; + } + + public function execute($values = array()): bool { + if (!parent::execute($values)) { + return false; + } + + $uid = $this->getParam("uid"); + return $this->toggleRoute($uid, false); + } + } } diff --git a/core/Configuration/CreateDatabase.class.php b/core/Configuration/CreateDatabase.class.php index 5de3e77..1c8af14 100755 --- a/core/Configuration/CreateDatabase.class.php +++ b/core/Configuration/CreateDatabase.class.php @@ -126,7 +126,8 @@ class CreateDatabase extends DatabaseScript { ->addString("target", 128) ->addString("extra", 64, true) ->addBool("active", true) - ->primaryKey("uid"); + ->primaryKey("uid") + ->unique("request"); $queries[] = $sql->insert("Route", array("request", "action", "target", "extra")) ->addRow("^/admin(/.*)?$", "dynamic", "\\Documents\\Admin", NULL) @@ -192,8 +193,7 @@ class CreateDatabase extends DatabaseScript { ->addRow("User/edit", array(USER_GROUP_ADMIN), "Allows users to edit details and group memberships of any user") ->addRow("User/delete", array(USER_GROUP_ADMIN), "Allows users to delete any other user") ->addRow("Permission/fetch", array(USER_GROUP_ADMIN), "Allows users to list all API permissions") - ->addRow("Visitors/stats", array(USER_GROUP_ADMIN, USER_GROUP_SUPPORT), "Allows users to see visitor statistics") - ->addRow("PatchSQL", array(USER_GROUP_ADMIN), "Allows users to import database patches"); + ->addRow("Visitors/stats", array(USER_GROUP_ADMIN, USER_GROUP_SUPPORT), "Allows users to see visitor statistics"); self::loadPatches($queries, $sql); diff --git a/core/Configuration/Settings.class.php b/core/Configuration/Settings.class.php index 7a981aa..0418823 100644 --- a/core/Configuration/Settings.class.php +++ b/core/Configuration/Settings.class.php @@ -33,7 +33,7 @@ class Settings { } public static function loadDefaults(): Settings { - $hostname = $_SERVER["SERVER_NAME"]; + $hostname = $_SERVER["SERVER_NAME"] ?? "localhost"; $protocol = getProtocol(); $jwt = generateRandomString(32); diff --git a/core/Documents/Install.class.php b/core/Documents/Install.class.php index d873da5..861ded5 100644 --- a/core/Documents/Install.class.php +++ b/core/Documents/Install.class.php @@ -280,9 +280,7 @@ namespace Documents\Install { $success = false; $msg = "Unable to write file"; } - } - if ($sql) { $sql->close(); } } diff --git a/core/Driver/SQL/PostgreSQL.class.php b/core/Driver/SQL/PostgreSQL.class.php index e3e8794..d5cc4cf 100644 --- a/core/Driver/SQL/PostgreSQL.class.php +++ b/core/Driver/SQL/PostgreSQL.class.php @@ -53,7 +53,7 @@ class PostgreSQL extends SQL { } } - $this->connection = @pg_connect(implode(" ", $connectionString)); + $this->connection = @pg_connect(implode(" ", $connectionString), PGSQL_CONNECT_FORCE_NEW); if (!$this->connection) { $this->lastError = "Failed to connect to Database"; $this->connection = NULL; diff --git a/core/core.php b/core/core.php index 6f57d3c..c41d07e 100644 --- a/core/core.php +++ b/core/core.php @@ -1,9 +1,24 @@ false, "msg" => "Configuration directory is not readable, check permissions before proceeding." ))); } -spl_autoload_extensions(".php"); -spl_autoload_register(function($class) { - $full_path = getClassPath($class, true); - if(file_exists($full_path)) { - include_once $full_path; - } else { - include_once getClassPath($class, false); - } -}); - $config = new Configuration(); $user = new Objects\User($config); $sql = $user->getSQL(); diff --git a/js/install.js b/js/install.js index 174ffd3..64789c2 100644 --- a/js/install.js +++ b/js/install.js @@ -138,22 +138,26 @@ $(document).ready(function() { }); // DATABASE PORT - let prevPort = $("#port").val(); - let prevDbms = $("#type option:selected").val(); + let portField = $("#port"); + let typeField = $("#type"); + + let prevPort = parseInt(portField.val()); + let prevDbms = typeField.find("option:selected").val(); function updateDefaultPort() { let defaultPorts = { "mysql": 3306, "postgres": 5432 }; - let curDbms = $("#type option:selected").val(); + let curDbms = typeField.find("option:selected").val(); if(defaultPorts[prevDbms] === prevPort) { - $("#port").val(defaultPorts[curDbms]); + prevDbms = curDbms; + portField.val(prevPort = defaultPorts[curDbms]); } } updateDefaultPort(); - $("#type").change(function() { + typeField.change(function() { updateDefaultPort(); }); }); diff --git a/static/maintenance.html b/static/maintenance.html new file mode 100644 index 0000000..d954309 --- /dev/null +++ b/static/maintenance.html @@ -0,0 +1,36 @@ + + + + + + + + Maintenance + + + + + +
+
+
+
+

Service unavailable

+

Temporarily down for maintenance

+
+

+ Sorry for the inconvenience but we're performing some maintenance at the moment. + we'll be back online shortly! +

+
+ + Retry + +
+
+
+ [maintenance] +
+
+
+ \ No newline at end of file