diff --git a/.travis.yml b/.travis.yml index 46854ff3..3c7719a8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -40,4 +40,4 @@ before_script: - export HTTP_ACCEPT="application/json" - export HTTP_USER_AGENT="Travis CI" -script: php zk test action=test subaction=test +script: php zk validate diff --git a/config/controller_config.php b/config/controller_config.php index e7ae66ee..690803fd 100644 --- a/config/controller_config.php +++ b/config/controller_config.php @@ -16,4 +16,5 @@ 'api' => ZK\Controllers\API::class, 'sso' => ZK\Controllers\SSOLogin::class, 'push' => ZK\Controllers\PushServer::class, + 'validate' => ZK\Controllers\Validate::class, ]; diff --git a/controllers/API.php b/controllers/API.php index eb09c0ae..4df2be54 100644 --- a/controllers/API.php +++ b/controllers/API.php @@ -123,7 +123,7 @@ public function emitDataSetArray($name, $fields, &$data) { echo "$nextToken{"; $nextProp = ""; foreach($fields as $field) { - $val = $row[$field]; + $val = $row[$field] ?? ""; if($name == "albumrec") { switch($field) { case "category": @@ -220,7 +220,7 @@ public function emitDataSetArray($name, $fields, &$data) { foreach($data as $row) { echo "<$name>\n"; foreach($fields as $field) { - $val = $row[$field]; + $val = $row[$field] ?? ""; if($name == "albumrec") { switch($field) { case "category": @@ -361,7 +361,7 @@ class API extends CommandTarget implements IController { private $serializer; public function processRequest() { - $wantXml = $_REQUEST["xml"] || + $wantXml = $_REQUEST["xml"] ?? false || isset($_SERVER["HTTP_ACCEPT"]) && substr($_SERVER["HTTP_ACCEPT"], 0, 8) == "text/xml"; $this->serializer = $wantXml?new XMLSerializer():new JSONSerializer(); @@ -623,7 +623,7 @@ public function getCurrents() { } private function emitHeader($method) { - $preflight = $_SERVER['REQUEST_METHOD'] == "OPTIONS"; + $preflight = ($_SERVER['REQUEST_METHOD'] ?? null) == "OPTIONS"; if($preflight) http_response_code(204); // 204 No Content @@ -632,7 +632,7 @@ private function emitHeader($method) { // Go ahead and give the Content-type in all cases. header("Content-type: ". $this->serializer->getContentType()); - $origin = $_SERVER['HTTP_ORIGIN']; + $origin = $_SERVER['HTTP_ORIGIN'] ?? null; if($origin) { if($method && in_array($method, self::$publicMethods)) { header("Access-Control-Allow-Origin: *"); diff --git a/controllers/Validate.php b/controllers/Validate.php new file mode 100644 index 00000000..5bb13e62 --- /dev/null +++ b/controllers/Validate.php @@ -0,0 +1,258 @@ + + * @copyright Copyright (C) 1997-2021 Jim Mason + * @link https://zookeeper.ibinx.com/ + * @license GPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License, + * version 3, along with this program. If not, see + * http://www.gnu.org/licenses/ + * + */ + +namespace ZK\Controllers; + +use ZK\Engine\Engine; +use ZK\Engine\IChart; +use ZK\Engine\IDJ; +use ZK\Engine\IPlaylist; +use ZK\Engine\IUser; +use ZK\Engine\PlaylistEntry; + +use ZK\UI\UICommon as UI; + +class Validate implements IController { + private $success = true; + private $session; + private $testUser; + private $testPass; + + private const TEST_NAME = "TEST User"; + private const TEST_ACCESS = "qr"; // some unused roles for safety + private const TEST_COMMENT = "TEST comment!"; + private const TEST_TRACK = "TEST Grommet Track"; // second word is search key + + private const FAIL = "\033[0;31m"; + private const OK = "\033[0;32m"; + private const SKIP = "\033[0;33m"; + private const NORMAL = "\033[0m"; + + private function doTest($name, $runTest = null) { + if($runTest === null) + $runTest = $this->success; + + echo "\t${name}: "; + if(!$runTest) + echo self::SKIP."SKIPPED".self::NORMAL."\n"; + return $runTest; + } + + private function showSuccess($success, $critical = true) { + if($critical) + $this->success &= $success; + + if($success) + echo self::OK."OK"; + else { + echo self::FAIL."FAILED!"; + if(!$critical) + echo " (soft failure)"; + } + echo self::NORMAL."\n"; + } + + public function processRequest() { + if(php_sapi_name() != "cli") { + http_response_code(400); + return; + } + + $this->session = Engine::session(); + echo "\nStarting Validation...\n\n"; + try { + $this->validateCreateUser(); + $this->validateSignon(); + $this->validatePlaylists(); + $this->validateCategories(); + $this->validateDeleteUser(); + } catch (\Exception $e) { + // if there is a db configuration issue (wrong db name + // or password, etc.), we'll get an exception. + echo self::FAIL."\nFATAL: ".$e->getMessage().self::NORMAL."\n"; + $this->success = false; + } + echo "\nDone.\n"; + exit($this->success?0:1); + } + + public function validateCreateUser() { + $api = Engine::api(IUser::class); + + $this->doTest("create user"); + $this->testUser = "__".substr(md5(uniqid(rand())), 0, 6); + $this->testPass = md5(uniqid(rand())); + $success = $api->insertUser($this->testUser, $this->testPass, + self::TEST_NAME, self::TEST_ACCESS, ""); + $this->showSuccess($success); + + if($this->doTest("validate user", $success)) { + $user = $api->getUser($this->testUser); + $success2 = $user['realname'] == self::TEST_NAME; + $this->showSuccess($success2); + } + + if(!$success) { + error_reporting(0); + $api->deleteUser($this->testUser); + error_reporting(E_ALL & ~E_NOTICE); + + $this->testUser = null; + } + } + + public function validateSignon() { + if($this->doTest("validate signon")) { + $this->showSuccess( + Engine::api(IUser::class)->validatePassword( + $this->testUser, $this->testPass, 1, $access)); + } + + if($this->doTest("validate session")) { + // Suppress warnings from session cookie creation + error_reporting(E_ERROR); + $sessionID = md5(uniqid(rand())); + $this->session->create($sessionID, $this->testUser, $access); + + // Validate session + $this->session->validate($sessionID); + $success = $this->session->isAuth(substr(self::TEST_ACCESS, 1, 1)); + $this->showSuccess($success); + + // Resume normal error reporting + error_reporting(E_ALL & ~E_NOTICE); + } + } + + public function validatePlaylists() { + if($this->doTest("create airname")) { + $djapi = Engine::api(IDJ::class); + $airname = self::TEST_NAME." ".$this->testUser; // make unique + $success = $djapi->insertAirname($airname, $this->testUser); + $this->showSuccess($success); + } else + $success = false; + + if($this->doTest("create playlist", $success)) { + $aid = $djapi->lastInsertId(); + $papi = Engine::api(IPlaylist::class); + $success = $papi->insertPlaylist($this->testUser, "2020-01-01", "1200-1400", "TEST Show", $aid); + if($success) + $pid = $papi->lastInsertId(); + $this->showSuccess($success); + } + + if($this->doTest("insert comment", $success)) { + $comment = (new PlaylistEntry())->setComment(self::TEST_COMMENT); + $success2 = $papi->insertTrackEntry($pid, $comment, $status); + $this->showSuccess($success2); + } else + $success2 = false; + + if($this->doTest("insert spin", $success)) { + $spin = new PlaylistEntry([ + 'artist'=>'TEST, Artist', + 'album'=>'TEST Album', + 'track'=>self::TEST_TRACK, + 'label'=>'TEST Label' + ]); + $success3 = $papi->insertTrackEntry($pid, $spin, $status); + $this->showSuccess($success3); + } else + $success3 = false; + + if($this->doTest("move track", $success2 && $success3)) { + $success4 = $papi->moveTrack($pid, $spin->getId(), $comment->getId()); + $this->showSuccess($success4); + } else + $success4 = false; + + if($this->doTest("view playlist", $success4)) { + $stream = popen(__DIR__."/../". + "zk main action=viewListById subaction= playlist=$pid", "r"); + $page = stream_get_contents($stream); + pclose($stream); + + // scrape the page looking for the comment and spin we inserted. + // both should be present, and the comment should follow the spin + $commentPos = strpos($page, self::TEST_COMMENT); + $trackPos = strpos($page, self::TEST_TRACK); + $success5 = $commentPos !== false && $trackPos !== false && + $commentPos > $trackPos; + $this->showSuccess($success5); + } + + if($this->doTest("validate search", $success3)) { + $stream = popen(__DIR__."/../". + "zk api method=searchRq type= offset= size=5 key=". + explode(' ', self::TEST_TRACK)[1], "r"); + $page = stream_get_contents($stream); + pclose($stream); + + // parse the json looking for the spin + $success6 = false; + $json = json_decode($page); + foreach($json->data as $data) { + if($data->type == "playlists") { + foreach($data->data as $playlist) { + if($playlist->track == self::TEST_TRACK) { + $success6 = true; + break 2; + } + } + break; + } + } + $this->showSuccess($success6); + } + + if($this->doTest("delete playlist", $success)) { + $papi->deletePlaylist($pid); + $this->showSuccess(true); + } + + if($this->doTest("purge playlists", $success)) { + $success = $papi->purgeDeletedPlaylists(0); + $this->showSuccess($success); + } + } + + public function validateCategories() { + if($this->doTest("validate categories", isset($this->testUser))) { + $cats = Engine::api(IChart::class)->getCategories(); + $success = sizeof($cats) == 16; + $this->showSuccess($success); + } + } + + public function validateDeleteUser() { + if($this->doTest("delete user", isset($this->testUser))) { + // invalidate session + $this->session->invalidate(); + + $success = Engine::api(IUser::class)->deleteUser($this->testUser); + $this->showSuccess($success); + } + } +} diff --git a/engine/IPlaylist.php b/engine/IPlaylist.php index d7401307..0db124b5 100644 --- a/engine/IPlaylist.php +++ b/engine/IPlaylist.php @@ -67,7 +67,7 @@ function getLastPlays($tag, $count=0); function getRecentPlays($airname, $count); function deletePlaylist($playlist); function restorePlaylist($playlist); - function purgeDeletedPlaylists(); + function purgeDeletedPlaylists($days=30); function getDeletedPlaylistCount($user); function getListsSelNormal($user); function getListsSelDeleted($user); diff --git a/engine/IUser.php b/engine/IUser.php index 8837a5b3..bbdf832e 100644 --- a/engine/IUser.php +++ b/engine/IUser.php @@ -43,4 +43,5 @@ function createNewAccount($fullname, $account); function validatePassword($user, $password, $updateTimestamp, &$groups=0); function updateUser($user, $password, $realname="XXZZ", $groups="XXZZ", $expiration="XXZZ"); function insertUser($user, $password, $realname, $groups, $expiration); + function deleteUser($user); } diff --git a/engine/impl/Playlist.php b/engine/impl/Playlist.php index 73f2a3ee..87e31ae3 100644 --- a/engine/impl/Playlist.php +++ b/engine/impl/Playlist.php @@ -820,13 +820,14 @@ public function restorePlaylist($playlist) { $stmt->execute(); } - public function purgeDeletedPlaylists() { + public function purgeDeletedPlaylists($days=30) { $query = "DELETE FROM tracks, lists, lists_del USING lists_del " . "INNER JOIN lists " . "LEFT OUTER JOIN tracks ON lists_del.listid = tracks.list " . "WHERE lists_del.listid = lists.id ". - "AND ADDDATE(deleted, 30) < NOW()"; + "AND ADDDATE(deleted, ?) < NOW()"; $stmt = $this->prepare($query); + $stmt->bindValue(1, $days); return $stmt->execute(); } diff --git a/engine/impl/User.php b/engine/impl/User.php index 0a37cf19..ef997be0 100644 --- a/engine/impl/User.php +++ b/engine/impl/User.php @@ -253,4 +253,29 @@ public function insertUser($user, $password, $realname, $groups, $expiration) { $stmt->execute(); return ($stmt->rowCount() > 0); } + + public function deleteUser($user) { + // validate this user has no playlists nor reviews + $query = "SELECT COUNT(*) c FROM lists WHERE dj = ?"; + $stmt = $this->prepare($query); + $stmt->bindValue(1, $user); + $result = $stmt->executeAndFetch(); + if($result['c']) + return false; + + $query = "SELECT COUNT(*) c FROM reviews WHERE user = ?"; + $stmt = $this->prepare($query); + $stmt->bindValue(1, $user); + $result = $stmt->executeAndFetch(); + if($result['c']) + return false; + + // remove any airnames + Engine::api(IDJ::class)->getAirnames($user); + + $query = "DELETE FROM users WHERE name = ?"; + $stmt = $this->prepare($query); + $stmt->bindValue(1, $user); + return $stmt->execute(); + } } diff --git a/ui/UIController.php b/ui/UIController.php index d779ad11..8b0d04a0 100644 --- a/ui/UIController.php +++ b/ui/UIController.php @@ -128,7 +128,8 @@ public function processRequest() { $this->preProcessRequest(); - $isJson = substr($_SERVER["HTTP_ACCEPT"], 0, 16) === 'application/json'; + $isJson = isset($_SERVER["HTTP_ACCEPT"]) && + substr($_SERVER["HTTP_ACCEPT"], 0, 16) === 'application/json'; if ($isJson) { $action = $_REQUEST["action"]; $subaction = $_REQUEST["subaction"]; @@ -174,7 +175,6 @@ protected function preProcessRequest() { } protected function emitResponseHeader() { - $userAgent = $_SERVER["HTTP_USER_AGENT"]; $banner = Engine::param('application'); $station = Engine::param('station'); $station_full = Engine::param('station_full');