diff --git a/LICENSE.md b/LICENSE.md index 63a11e3..56351c0 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,19 +1,7 @@ -Copyright (c) 2018 Netsyms Technologies. +Copyright (c) 2017-2019 Netsyms Technologies. Some rights reserved. -If you modify and redistribute this project, you must replace the branding -assets with your own. - -The branding assets include: - * the application icon - * the Netsyms N punchcard logo - * the Netsyms for Business graph logo - -If you are unsure if your usage is allowed, please contact us: -https://netsyms.com/contact -legal@netsyms.com - -All other portions of this application, -unless otherwise noted (in comments, headers, etc), are licensed as follows: +Licensed under the Mozilla Public License Version 2.0. Files without MPL header +comments, including third party code, may be under a different license. Mozilla Public License Version 2.0 ================================== diff --git a/action.php b/action.php index e282a7c..0c0fd90 100644 --- a/action.php +++ b/action.php @@ -36,7 +36,7 @@ if ($VARS['action'] != "signout" && !(new User($_SESSION['uid']))->hasPermission switch ($VARS['action']) { case "edititem": $insert = true; - if (is_empty($VARS['itemid'])) { + if (empty($VARS['itemid'])) { $insert = true; } else { if ($database->has('items', ['itemid' => $VARS['itemid']])) { @@ -45,42 +45,42 @@ switch ($VARS['action']) { returnToSender("invalid_itemid"); } } - if (is_empty($VARS['name'])) { + if (empty($VARS['name'])) { returnToSender('missing_name'); } - if (!is_empty($VARS['catstr']) && is_empty($VARS['cat'])) { + if (!empty($VARS['catstr']) && empty($VARS['cat'])) { if ($database->count("categories", ["catname" => $VARS['catstr']]) == 1) { $VARS['cat'] = $database->get("categories", 'catid', ["catname" => $VARS['catstr']]); } else { returnToSender('use_the_drop_luke'); } } - if (!is_empty($VARS['locstr']) && is_empty($VARS['loc'])) { + if (!empty($VARS['locstr']) && empty($VARS['loc'])) { if ($database->count("locations", ["locname" => $VARS['locstr']]) == 1) { $VARS['loc'] = $database->get("locations", 'locid', ["locname" => $VARS['locstr']]); } else { returnToSender('use_the_drop_luke'); } } - if (is_empty($VARS['cat']) || is_empty($VARS['loc'])) { + if (empty($VARS['cat']) || empty($VARS['loc'])) { returnToSender('invalid_parameters'); } - if (is_empty($VARS['qty'])) { + if (empty($VARS['qty'])) { $VARS['qty'] = 1; } else if (!is_numeric($VARS['qty'])) { returnToSender('field_nan'); } - if (is_empty($VARS['want'])) { + if (empty($VARS['want'])) { $VARS['want'] = 0; } else if (!is_numeric($VARS['want'])) { returnToSender('field_nan'); } - if (is_empty($VARS['cost'])) { + if (empty($VARS['cost'])) { $VARS['cost'] = null; } else if (!is_numeric($VARS['cost'])) { returnToSender('field_nan'); } - if (is_empty($VARS['price'])) { + if (empty($VARS['price'])) { $VARS['price'] = null; } else if (!is_numeric($VARS['price'])) { returnToSender('field_nan'); @@ -128,7 +128,7 @@ switch ($VARS['action']) { returnToSender("item_saved"); case "editcat": $insert = true; - if (is_empty($VARS['catid'])) { + if (empty($VARS['catid'])) { $insert = true; } else { if ($database->has('categories', ['catid' => $VARS['catid']])) { @@ -137,7 +137,7 @@ switch ($VARS['action']) { returnToSender("invalid_catid"); } } - if (is_empty($VARS['name'])) { + if (empty($VARS['name'])) { returnToSender('invalid_parameters'); } @@ -154,7 +154,7 @@ switch ($VARS['action']) { returnToSender("category_saved"); case "editloc": $insert = true; - if (is_empty($VARS['locid'])) { + if (empty($VARS['locid'])) { $insert = true; } else { if ($database->has('locations', ['locid' => $VARS['locid']])) { @@ -163,7 +163,7 @@ switch ($VARS['action']) { returnToSender("invalid_locid"); } } - if (is_empty($VARS['name'])) { + if (empty($VARS['name'])) { returnToSender('invalid_parameters'); } @@ -217,9 +217,9 @@ switch ($VARS['action']) { $client = new GuzzleHttp\Client(); $response = $client - ->request('POST', PORTAL_API, [ + ->request('POST', $SETTINGS['accounthub']['api'], [ 'form_params' => [ - 'key' => PORTAL_KEY, + 'key' => $SETTINGS['accounthub']['key'], 'action' => "usersearch", 'search' => $VARS['q'] ] @@ -237,7 +237,7 @@ switch ($VARS['action']) { } break; case "imageupload": - $destpath = FILE_UPLOAD_PATH; + $destpath = $SETTINGS['file_upload_path']; if (!is_writable($destpath)) { returnToSender("unwritable_folder", "&id=$VARS[itemid]"); } @@ -274,7 +274,7 @@ switch ($VARS['action']) { default: $err = "could not be uploaded."; } - $errors[] = htmlspecialchars($f['name']) . " $err"; + $errors[] = htmlentities($f['name']) . " $err"; continue; } @@ -296,7 +296,7 @@ switch ($VARS['action']) { } if (!$imagevalid) { - $errors[] = htmlspecialchars($f['name']) . " is not a supported image type (JPEG, GIF, PNG, WEBP)."; + $errors[] = htmlentities($f['name']) . " is not a supported image type (JPEG, GIF, PNG, WEBP)."; continue; } @@ -319,7 +319,7 @@ switch ($VARS['action']) { } $database->insert('images', ['itemid' => $VARS['itemid'], 'imagename' => $filename, 'primary' => $primary]); } else { - $errors[] = htmlspecialchars($f['name']) . " could not be uploaded."; + $errors[] = htmlentities($f['name']) . " could not be uploaded."; } } @@ -350,7 +350,7 @@ switch ($VARS['action']) { $imagename = $database->get('images', 'imagename', ['imageid' => $VARS['imageid']]); if ($database->count('images', ['imagename' => $imagename]) <= 1) { - unlink(FILE_UPLOAD_PATH . "/" . $imagename); + unlink($SETTINGS['file_upload_path'] . "/" . $imagename); } $database->delete('images', ['AND' => ['itemid' => $VARS['itemid'], 'imageid' => $VARS['imageid']]]); @@ -361,6 +361,6 @@ switch ($VARS['action']) { returnToSender("image_deleted", "&id=$VARS[itemid]"); case "signout": session_destroy(); - header('Location: index.php'); + header('Location: index.php?logout=1'); die("Logged out."); } diff --git a/api.php b/api.php index 931929f..870c44f 100644 --- a/api.php +++ b/api.php @@ -4,35 +4,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -/** - * Simple JSON API to allow other apps to access data from this app. - * - * Requests can be sent via either GET or POST requests. POST is recommended - * as it has a lower chance of being logged on the server, exposing unencrypted - * user passwords. - */ -require __DIR__ . '/required.php'; -header("Content-Type: application/json"); -$username = $VARS['username']; -$password = $VARS['password']; -$user = User::byUsername($username); -if ($user->exists() !== true || Login::auth($username, $password) !== Login::LOGIN_OK) { - header("HTTP/1.1 403 Unauthorized"); - die("\"403 Unauthorized\""); -} - -// query max results -$max = 20; -if (preg_match("/^[0-9]+$/", $VARS['max']) === 1 && $VARS['max'] <= 1000) { - $max = (int) $VARS['max']; -} - -switch ($VARS['action']) { - case "ping": - $out = ["status" => "OK", "maxresults" => $max, "pong" => true]; - exit(json_encode($out)); - default: - header("HTTP/1.1 400 Bad Request"); - die("\"400 Bad Request\""); -} +// Load in new API from legacy location (a.k.a. here) +require __DIR__ . "/api/index.php"; diff --git a/api/.htaccess b/api/.htaccess new file mode 100644 index 0000000..9a4efe4 --- /dev/null +++ b/api/.htaccess @@ -0,0 +1,5 @@ +# Rewrite for Nextcloud Notes API + + RewriteEngine on + RewriteRule ([a-zA-Z0-9]+) index.php?action=$1 [PT] + \ No newline at end of file diff --git a/api/actions/ping.php b/api/actions/ping.php new file mode 100644 index 0000000..c764967 --- /dev/null +++ b/api/actions/ping.php @@ -0,0 +1,9 @@ + [ + "load" => "ping.php", + "vars" => [ + ] + ] +]; diff --git a/api/functions.php b/api/functions.php new file mode 100644 index 0000000..b0e6d09 --- /dev/null +++ b/api/functions.php @@ -0,0 +1,144 @@ + 5) { + for ($i = 2; $i < strlen($key) - 2; $i++) { + $resp[$i] = "*"; + } + } + return $resp; +} + +/** + * Check if the request is allowed + * @global array $VARS + * @return bool true if the request should continue, false if the request is bad + */ +function authenticate(): bool { + global $VARS; + // HTTP basic auth + if (!empty($_SERVER['PHP_AUTH_USER']) && !empty($_SERVER['PHP_AUTH_PW'])) { + $user = User::byUsername($_SERVER['PHP_AUTH_USER']); + if (!$user->checkPassword($_SERVER['PHP_AUTH_PW'])) { + return false; + } + return true; + } + // Form auth + if (empty($VARS['username']) || empty($VARS['password'])) { + return false; + } else { + $username = $VARS['username']; + $password = $VARS['password']; + $user = User::byUsername($username); + if ($user->exists() !== true || Login::auth($username, $password) !== Login::LOGIN_OK) { + return false; + } + } + return true; +} + +/** + * Get the User whose credentials were used to make the request. + */ +function getRequestUser(): User { + global $VARS; + if (!empty($_SERVER['PHP_AUTH_USER'])) { + return User::byUsername($_SERVER['PHP_AUTH_USER']); + } else { + return User::byUsername($VARS['username']); + } +} + +function checkVars($vars, $or = false) { + global $VARS; + $ok = []; + foreach ($vars as $key => $val) { + if (strpos($key, "OR") === 0) { + checkVars($vars[$key], true); + continue; + } + + // Only check type of optional variables if they're set, and don't + // mark them as bad if they're not set + if (strpos($key, " (optional)") !== false) { + $key = str_replace(" (optional)", "", $key); + if (empty($VARS[$key])) { + continue; + } + } else { + if (empty($VARS[$key])) { + $ok[$key] = false; + continue; + } + } + + if (strpos($val, "/") === 0) { + // regex + $ok[$key] = preg_match($val, $VARS[$key]) === 1; + } else { + $checkmethod = "is_$val"; + $ok[$key] = !($checkmethod($VARS[$key]) !== true); + } + } + if ($or) { + $success = false; + $bad = ""; + foreach ($ok as $k => $v) { + if ($v) { + $success = true; + break; + } else { + $bad = $k; + } + } + if (!$success) { + http_response_code(400); + die("400 Bad request: variable $bad is missing or invalid"); + } + } else { + foreach ($ok as $key => $bool) { + if (!$bool) { + http_response_code(400); + die("400 Bad request: variable $key is missing or invalid"); + } + } + } +} diff --git a/api/index.php b/api/index.php new file mode 100644 index 0000000..8875860 --- /dev/null +++ b/api/index.php @@ -0,0 +1,79 @@ += 1) { + $VARS["action"] = $route[0]; + } + if (count($route) >= 2 && strpos($route[1], "?") !== 0) { + for ($i = 1; $i < count($route); $i++) { + if (empty($route[$i]) || strpos($route[$i], "=") === false) { + continue; + } + $key = explode("=", $route[$i], 2)[0]; + $val = explode("=", $route[$i], 2)[1]; + $VARS[$key] = $val; + } + } + + if (strpos($route[count($route) - 1], "?") === 0) { + $morevars = explode("&", substr($route[count($route) - 1], 1)); + foreach ($morevars as $var) { + $key = explode("=", $var, 2)[0]; + $val = explode("=", $var, 2)[1]; + $VARS[$key] = $val; + } + } +} + +if (!authenticate()) { + header('WWW-Authenticate: Basic realm="' . $SETTINGS['site_title'] . '"'); + header('HTTP/1.1 401 Unauthorized'); + die("401 Unauthorized: you need to supply valid credentials."); +} + +if (empty($VARS['action'])) { + http_response_code(404); + die("404 No action specified"); +} + +if (!isset($APIS[$VARS['action']])) { + http_response_code(404); + die("404 Action not defined"); +} + +$APIACTION = $APIS[$VARS["action"]]; + +if (!file_exists(__DIR__ . "/actions/" . $APIACTION["load"])) { + http_response_code(404); + die("404 Action not found"); +} + +if (!empty($APIACTION["vars"])) { + checkVars($APIACTION["vars"]); +} + +require_once __DIR__ . "/actions/" . $APIACTION["load"]; diff --git a/app.php b/app.php index 2325ffa..a09ff4b 100644 --- a/app.php +++ b/app.php @@ -1,5 +1,4 @@ ; rel=preload; as=script", fals - <?php echo SITE_TITLE; ?> + <?php echo $SETTINGS['site_title']; ?> @@ -66,28 +65,35 @@ header("Link: ; rel=preload; as=script", fals get(MESSAGES[$_GET['msg']]['string'], false); + if (!empty($_GET['msg'])) { + if (array_key_exists($_GET['msg'], MESSAGES)) { + // optional string generation argument + if (empty($_GET['arg'])) { + $alertmsg = $Strings->get(MESSAGES[$_GET['msg']]['string'], false); + } else { + $alertmsg = $Strings->build(MESSAGES[$_GET['msg']]['string'], ["arg" => strip_tags($_GET['arg'])], false); + } + $alerttype = MESSAGES[$_GET['msg']]['type']; + $alerticon = "square-o"; + switch (MESSAGES[$_GET['msg']]['type']) { + case "danger": + $alerticon = "times"; + break; + case "warning": + $alerticon = "exclamation-triangle"; + break; + case "info": + $alerticon = "info-circle"; + break; + case "success": + $alerticon = "check"; + break; + } } else { - $alertmsg = $Strings->build(MESSAGES[$_GET['msg']]['string'], ["arg" => strip_tags($_GET['arg'])], false); - } - $alerttype = MESSAGES[$_GET['msg']]['type']; - $alerticon = "square-o"; - switch (MESSAGES[$_GET['msg']]['type']) { - case "danger": - $alerticon = "times"; - break; - case "warning": - $alerticon = "exclamation-triangle"; - break; - case "info": - $alerticon = "info-circle"; - break; - case "success": - $alerticon = "check"; - break; + // We don't have a message for this, so just assume an error and escape stuff. + $alertmsg = htmlentities($Strings->get($_GET['msg'], false)); + $alerticon = "times"; + $alerttype = "danger"; } echo << @@ -121,7 +127,7 @@ END; - + diff --git a/image.php b/image.php index dc2341d..4b1a7bf 100644 --- a/image.php +++ b/image.php @@ -8,7 +8,7 @@ require_once __DIR__ . "/required.php"; -$base = FILE_UPLOAD_PATH . "/"; +$base = $SETTINGS['file_upload_path'] . "/"; if (isset($_GET['i'])) { $file = $_GET['i']; $filepath = $base . $file; @@ -16,7 +16,7 @@ if (isset($_GET['i'])) { http_response_code(404); die("404 File Not Found"); } - if (strpos(realpath($filepath), FILE_UPLOAD_PATH) !== 0) { + if (strpos(realpath($filepath), $SETTINGS['file_upload_path']) !== 0) { http_response_code(404); die("404 File Not Found"); } diff --git a/index.php b/index.php index 78c8423..4a2ce49 100644 --- a/index.php +++ b/index.php @@ -1,175 +1,131 @@ get("no access permission", false); +/** + * Show a simple HTML page with a line of text and a button. Matches the UI of + * the AccountHub login flow. + * + * @global type $SETTINGS + * @global type $SECURE_NONCE + * @global type $Strings + * @param string $title Text to show, passed through i18n + * @param string $button Button text, passed through i18n + * @param string $url URL for the button + */ +function showHTML(string $title, string $button, string $url) { + global $SETTINGS, $SECURE_NONCE, $Strings; + ?> + + + + + + <?php echo $SETTINGS['site_title']; ?> + + + + + + +
+
+
+ +
+ +
+

get($title); ?>

+
+ +
+ +
+
+
+ exists()) { - $status = $user->getStatus()->getString(); - switch ($status) { - case "LOCKED_OR_DISABLED": - $alert = $Strings->get("account locked", false); - break; - case "TERMINATED": - $alert = $Strings->get("account terminated", false); - break; - case "CHANGE_PASSWORD": - $alert = $Strings->get("password expired", false); - break; - case "NORMAL": - $username_ok = true; - break; - case "ALERT_ON_ACCESS": - $mail_resp = $user->sendAlertEmail(); - if (DEBUG) { - var_dump($mail_resp); - } - $username_ok = true; - break; - default: - if (!is_empty($error)) { - $alert = $error; - } else { - $alert = $Strings->get("login error", false); - } - break; - } - if ($username_ok) { - if ($user->checkPassword($VARS['password'])) { - $_SESSION['passok'] = true; // stop logins using only username and authcode - if ($user->has2fa()) { - $multiauth = true; - } else { - Session::start($user); - header('Location: app.php'); - die("Logged in, go to app.php"); - } - } else { - $alert = $Strings->get("login incorrect", false); - } +if (!empty($_GET['logout'])) { + showHTML("You have been logged out.", "Log in again", "./index.php"); + die(); +} +if (empty($_SESSION["login_code"])) { + $redirecttologin = true; +} else { + try { + $uidinfo = AccountHubApi::get("checkloginkey", ["code" => $_SESSION["login_code"]]); + if ($uidinfo["status"] == "ERROR") { + throw new Exception(); + } + if (is_numeric($uidinfo['uid'])) { + $user = new User($uidinfo['uid'] * 1); + foreach ($SETTINGS['permissions'] as $perm) { + if (!$user->hasPermission($perm)) { + showHTML("no access permission", "sign out", "./action.php?action=signout"); + die(); } - } else { // User does not exist anywhere - $alert = $Strings->get("login incorrect", false); } - } else { - $alert = $Strings->get("captcha error", false); - } - } else if ($VARS['progress'] == "2") { - $user = User::byUsername($VARS['username']); - if ($_SESSION['passok'] !== true) { - // stop logins using only username and authcode - sendError("Password integrity check failed!"); - } - if ($user->check2fa($VARS['authcode'])) { Session::start($user); + $_SESSION["login_code"] = null; header('Location: app.php'); - die("Logged in, go to app.php"); + showHTML("Logged in", "Continue", "./app.php"); + die(); } else { - $alert = $Strings->get("2fa incorrect", false); + throw new Exception(); } + } catch (Exception $ex) { + $redirecttologin = true; + } +} + +if ($redirecttologin) { + try { + $urlbase = (isset($_SERVER['HTTPS']) ? "https" : "http") . "://" . $_SERVER['HTTP_HOST'] . (($_SERVER['SERVER_PORT'] != 80 && $_SERVER['SERVER_PORT'] != 443) ? ":" . $_SERVER['SERVER_PORT'] : ""); + $iconurl = $urlbase . str_replace("index.php", "", $_SERVER["REQUEST_URI"]) . "static/img/logo.svg"; + $codedata = AccountHubApi::get("getloginkey", ["appname" => $SETTINGS["site_title"], "appicon" => $iconurl]); + + if ($codedata['status'] != "OK") { + throw new Exception($Strings->get("login server unavailable", false)); + } + + $redirecturl = $urlbase . $_SERVER['REQUEST_URI']; + + $_SESSION["login_code"] = $codedata["code"]; + + $locationurl = $codedata["loginurl"] . "?code=" . htmlentities($codedata["code"]) . "&redirect=" . htmlentities($redirecturl); + header("Location: $locationurl"); + showHTML("Continue", "Continue", $locationurl); + die(); + } catch (Exception $ex) { + sendError($ex->getMessage()); } -} else { - $alert = $Strings->get("login server unavailable", false); } -header("Link: ; rel=preload; as=style", false); -header("Link: ; rel=preload; as=style", false); -header("Link: ; rel=preload; as=style", false); -header("Link: ; rel=preload; as=style", false); -header("Link: ; rel=preload; as=script", false); -header("Link: ; rel=preload; as=script", false); -?> - - - - - - - - <?php echo SITE_TITLE; ?> - - - - - - - - - - - -
-
- -
-
-
-
-
-
get("sign in"); ?>
-
- -
- -
- - " required="required" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus />
- " required="required" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
- -
-
- - - -
- get("2fa prompt"); ?> -
- " required="required" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus />
- - - - -
-
-
-
- - - - - - diff --git a/lang/en_us.php b/lang/en_us.php deleted file mode 100644 index a405785..0000000 --- a/lang/en_us.php +++ /dev/null @@ -1,114 +0,0 @@ - "Sign In", - "username" => "Username", - "password" => "Password", - "continue" => "Continue", - "authcode" => "Authentication code", - "2fa prompt" => "Enter the six-digit code from your mobile authenticator app.", - "2fa incorrect" => "Authentication code incorrect.", - "login incorrect" => "Login incorrect.", - "login server unavailable" => "Login server unavailable. Try again later or contact technical support.", - "account locked" => "This account has been disabled. Contact technical support.", - "password expired" => "You must change your password before continuing.", - "account terminated" => "Account terminated. Access denied.", - "account state error" => "Your account state is not stable. Log out, restart your browser, and try again.", - "welcome user" => "Welcome, {user}!", - "no permission" => "You do not have permission to access this system.", - "sign out" => "Sign out", - "settings" => "Settings", - "options" => "Options", - "404 error" => "404 Error", - "page not found" => "Page not found.", - "invalid parameters" => "Invalid request parameters.", - "login server error" => "The login server returned an error: {arg}", - "login server user data error" => "The login server refused to provide account information. Try again or contact technical support.", - "captcha error" => "There was a problem with the CAPTCHA (robot test). Try again.", - "no edit permission" => "You do not have permission to modify records.", - "no access permission" => "You do not have permission to access this system.", - "home" => "Home", - "more" => "More", - "invalid itemid" => "The item ID is invalid.", - "invalid category" => "The category is invalid.", - "invalid location" => "The location does not exist.", - "item saved" => "Item saved.", - "item deleted" => "Item deleted.", - "total items" => "Total Items", - "items" => "Items", - "locations" => "Locations", - "categories" => "Categories", - "actions" => "Actions", - "name" => "Name", - "category" => "Category", - "location" => "Location", - "code" => "Code", - "code 1" => "Code 1", - "code 2" => "Code 2", - "qty" => "Qty", - "quantity" => "Quantity", - "assigned to" => "Assigned To", - "view items" => "View Items", - "nobody" => "Nobody", - "view categories" => "View Categories", - "view locations" => "View Locations", - "edit" => "Edit", - "clone" => "Copy", - "item count" => "Items", - "delete" => "Delete", - "new item" => "New Item", - "editing item" => "Editing {item}", - "editing category" => "Editing {cat}", - "editing location" => "Editing {loc}", - "cloning item" => "Copying {oitem} {nitem}", - "adding item" => "Adding new item", - "adding category" => "Adding new category", - "invalid catid" => "Invalid category ID.", - "category deleted" => "Category deleted.", - "category in use" => "Cannot delete category because there is at least one item still in it.", - "new category" => "New Category", - "category saved" => "Category saved.", - "adding location" => "Adding new location", - "invalid locid" => "Invalid location ID.", - "location deleted" => "Location deleted.", - "location in use" => "Cannot delete location because there is at least one item still in it.", - "new location" => "New Location", - "location saved" => "Location saved.", - "name" => "Name", - "save" => "Save", - "placeholder item name" => "Foo Bar", - "placeholder category name" => "Widgets", - "placeholder location name" => "Over the Hills", - "description" => "Description", - "notes" => "Notes", - "comments" => "Comments", - "minwant" => "Minimum On Hand", - "want" => "Need", - "field not a number" => "You entered something that isn't a number when a number was expected.", - "understocked items" => "Understocked Items", - "view understocked" => "View Understocked", - "only showing understocked" => "Only showing understocked items.", - "show all items" => "Show all items", - "missing name" => "You need to enter a name.", - "use the dropdowns" => "Whoops, you need to use the category and location autocomplete boxes.", - "make categories and locations" => "Please create at least one category and location before adding an item.", - "search" => "Search Items", - "report export" => "Reports/Export", - "report type" => "Report type", - "format" => "Format", - "generate report" => "Generate report", - "choose an option" => "Choose an option", - "csv file" => "CSV text file", - "ods file" => "ODS spreadsheet", - "html file" => "HTML web page", - "itemid" => "Item ID", - "id" => "ID", - "item cost" => "Item cost", - "sale price" => "Sale price", - "cost" => "Cost", - "price" => "Price" -]); diff --git a/langs/en/core.json b/langs/en/core.json index 639d829..4fd38f5 100644 --- a/langs/en/core.json +++ b/langs/en/core.json @@ -1,21 +1,5 @@ { - "sign in": "Sign In", - "username": "Username", - "password": "Password", - "continue": "Continue", - "authcode": "Authentication code", - "2fa prompt": "Enter the six-digit code from your mobile authenticator app.", - "2fa incorrect": "Authentication code incorrect.", - "login incorrect": "Login incorrect.", - "login server unavailable": "Login server unavailable. Try again later or contact technical support.", - "account locked": "This account has been disabled. Contact technical support.", - "password expired": "You must change your password before continuing.", - "account terminated": "Account terminated. Access denied.", - "account state error": "Your account state is not stable. Log out, restart your browser, and try again.", - "welcome user": "Welcome, {user}!", "sign out": "Sign out", - "settings": "Settings", - "options": "Options", "404 error": "404 Error", "page not found": "Page not found.", "invalid parameters": "Invalid request parameters.", diff --git a/langs/en/images.json b/langs/en/images.json index bb9a405..896e6a0 100644 --- a/langs/en/images.json +++ b/langs/en/images.json @@ -6,5 +6,7 @@ "Promoted": "Promoted", "Promote": "Promote", "Delete": "Delete", - "Back": "Back" + "Back": "Back", + "Image uploaded.": "Image uploaded.", + "Upload finished with errors: {arg}": "Upload finished with errors: {arg}" } diff --git a/langs/en/index.json b/langs/en/index.json new file mode 100644 index 0000000..c516bbb --- /dev/null +++ b/langs/en/index.json @@ -0,0 +1,8 @@ +{ + "You have been logged out.": "You have been logged out.", + "Log in again": "Log in again", + "login server unavailable": "Login server unavailable. Try again later or contact technical support.", + "no access permission": "You do not have permission to access this system.", + "Logged in": "Logged in", + "Continue": "Continue" +} diff --git a/langs/messages.php b/langs/messages.php index b1602fa..3155be1 100644 --- a/langs/messages.php +++ b/langs/messages.php @@ -88,5 +88,13 @@ define("MESSAGES", [ "noloccat" => [ "string" => "make categories and locations", "type" => "info" + ], + "upload_warning" => [ + "string" => "Upload finished with errors: {arg}", + "type" => "warning" + ], + "upload_success" => [ + "string" => "Image uploaded.", + "type" => "success" ] ]); diff --git a/lib/AccountHubApi.lib.php b/lib/AccountHubApi.lib.php new file mode 100644 index 0000000..4d23f9e --- /dev/null +++ b/lib/AccountHubApi.lib.php @@ -0,0 +1,56 @@ + $action, + "key" => $SETTINGS['accounthub']['key'] + ]; + if (!is_null($data)) { + $content = array_merge($content, $data); + } + $options = [ + 'http' => [ + 'method' => 'POST', + 'content' => json_encode($content), + 'header' => "Content-Type: application/json\r\n" . + "Accept: application/json\r\n", + "ignore_errors" => true + ] + ]; + + $context = stream_context_create($options); + $result = file_get_contents($SETTINGS['accounthub']['api'], false, $context); + $response = json_decode($result, true); + if ($result === false || !AccountHubApi::checkHttpRespCode($http_response_header) || json_last_error() != JSON_ERROR_NONE) { + if ($throwex) { + throw new Exception($result); + } else { + sendError($result); + } + } + return $response; + } + + private static function checkHttpRespCode(array $headers): bool { + foreach ($headers as $header) { + if (preg_match("/HTTP\/[0-9]\.[0-9] [0-9]{3}.*/", $header)) { + $respcode = explode(" ", $header)[1] * 1; + if ($respcode >= 200 && $respcode < 300) { + return true; + } + } + } + return false; + } + +} diff --git a/lib/FormBuilder.lib.php b/lib/FormBuilder.lib.php new file mode 100644 index 0000000..35e8fe3 --- /dev/null +++ b/lib/FormBuilder.lib.php @@ -0,0 +1,275 @@ +title = $title; + $this->icon = $icon; + $this->action = $action; + $this->method = $method; + } + + /** + * Set the title of the form. + * @param string $title + */ + public function setTitle(string $title) { + $this->title = $title; + } + + /** + * Set the icon for the form. + * @param string $icon FontAwesome icon (example: "fas fa-toilet-paper") + */ + public function setIcon(string $icon) { + $this->icon = $icon; + } + + /** + * Set the URL the form will submit to. + * @param string $action + */ + public function setAction(string $action) { + $this->action = $action; + } + + /** + * Set the form submission method (GET, POST, etc) + * @param string $method + */ + public function setMethod(string $method = "POST") { + $this->method = $method; + } + + /** + * Set the form ID. + * @param string $id + */ + public function setID(string $id = "editform") { + $this->id = $id; + } + + /** + * Add an input to the form. + * + * @param string $name Element name + * @param string $value Element value + * @param string $type Input type (text, number, date, select, tel...) + * @param bool $required If the element is required for form submission. + * @param string $id Element ID + * @param array $options Array of [value => text] pairs for a select element + * @param string $label Text label to display near the input + * @param string $icon FontAwesome icon (example: "fas fa-toilet-paper") + * @param int $width Bootstrap column width for the input, out of 12. + * @param int $minlength Minimum number of characters for the input. + * @param int $maxlength Maximum number of characters for the input. + * @param string $pattern Regex pattern for custom client-side validation. + * @param string $error Message to show if the input doesn't validate. + */ + public function addInput(string $name, string $value = "", string $type = "text", bool $required = true, string $id = null, array $options = null, string $label = "", string $icon = "", int $width = 4, int $minlength = 1, int $maxlength = 100, string $pattern = "", string $error = "") { + $item = [ + "name" => $name, + "value" => $value, + "type" => $type, + "required" => $required, + "label" => $label, + "icon" => $icon, + "width" => $width, + "minlength" => $minlength, + "maxlength" => $maxlength + ]; + if (!empty($id)) { + $item["id"] = $id; + } + if (!empty($options) && $type == "select") { + $item["options"] = $options; + } + if (!empty($pattern)) { + $item["pattern"] = $pattern; + } + if (!empty($error)) { + $item["error"] = $error; + } + $this->items[] = $item; + } + + /** + * Add a button to the form. + * + * @param string $text Text string to show on the button. + * @param string $icon FontAwesome icon to show next to the text. + * @param string $href If not null, the button will actually be a hyperlink. + * @param string $type Usually "button" or "submit". Ignored if $href is set. + * @param string $id The element ID. + * @param string $name The element name for the button. + * @param string $value The form value for the button. Ignored if $name is null. + * @param string $class The CSS classes for the button, if a standard success-colored one isn't right. + */ + public function addButton(string $text, string $icon = "", string $href = null, string $type = "button", string $id = null, string $name = null, string $value = "", string $class = "btn btn-success") { + $button = [ + "text" => $text, + "icon" => $icon, + "class" => $class, + "type" => $type, + "id" => $id, + "href" => $href, + "name" => $name, + "value" => $value + ]; + $this->buttons[] = $button; + } + + /** + * Add a hidden input. + * @param string $name + * @param string $value + */ + public function addHiddenInput(string $name, string $value) { + $this->hiddenitems[$name] = $value; + } + + /** + * Generate the form HTML. + * @param bool $echo If false, returns HTML string instead of outputting it. + */ + public function generate(bool $echo = true) { + $html = << +
+

+
+ $this->title +
+

+ +
+
+HTMLTOP; + + foreach ($this->items as $item) { + $required = $item["required"] ? "required" : ""; + $id = empty($item["id"]) ? "" : "id=\"$item[id]\""; + $pattern = empty($item["pattern"]) ? "" : "pattern=\"$item[pattern]\""; + if (empty($item['type'])) { + $item['type'] = "text"; + } + $itemhtml = ""; + $itemlabel = ""; + if ($item['type'] != "checkbox") { + $itemlabel = ""; + } + $strippedlabel = strip_tags($item['label']); + $itemhtml .= << +
+ $itemlabel +
+
+ +
+ITEMTOP; + switch ($item['type']) { + case "select": + $itemhtml .= <<"; + break; + case "checkbox": + $itemhtml .= << + + +
+CHECKBOX; + break; + default: + $itemhtml .= << +INPUT; + break; + } + + if (!empty($item["error"])) { + $itemhtml .= << + $item[error] +
+ERROR; + } + $itemhtml .= << +
+
\n +ITEMBOTTOM; + $html .= $itemhtml; + } + + $html .= << +
+HTMLBOTTOM; + + if (!empty($this->buttons)) { + $html .= "\n
"; + foreach ($this->buttons as $btn) { + $btnhtml = ""; + $inner = " $btn[text]"; + $id = empty($btn['id']) ? "" : "id=\"$btn[id]\""; + if (!empty($btn['href'])) { + $btnhtml = "$inner"; + } else { + $name = empty($btn['name']) ? "" : "name=\"$btn[name]\""; + $value = (!empty($btn['name']) && !empty($btn['value'])) ? "value=\"$btn[value]\"" : ""; + $btnhtml = ""; + } + $html .= "\n $btnhtml"; + } + $html .= "\n
"; + } + + $html .= "\n "; + foreach ($this->hiddenitems as $name => $value) { + $value = htmlentities($value); + $html .= "\n "; + } + $html .= "\n\n"; + + if ($echo) { + echo $html; + } + return $html; + } + +} diff --git a/lib/Login.lib.php b/lib/Login.lib.php index fe22a38..219cfea 100644 --- a/lib/Login.lib.php +++ b/lib/Login.lib.php @@ -45,50 +45,13 @@ class Login { return Login::LOGIN_OK; } - public static function verifyCaptcha(string $session, string $answer, string $url): bool { - $data = [ - 'session_id' => $session, - 'answer_id' => $answer, - 'action' => "verify" - ]; - $options = [ - 'http' => [ - 'header' => "Content-type: application/x-www-form-urlencoded\r\n", - 'method' => 'POST', - 'content' => http_build_query($data) - ] - ]; - $context = stream_context_create($options); - $result = file_get_contents($url, false, $context); - $resp = json_decode($result, TRUE); - if (!$resp['result']) { - return false; - } else { - return true; - } - } - /** * Check the login server API for sanity * @return boolean true if OK, else false */ public static function checkLoginServer() { try { - $client = new GuzzleHttp\Client(); - - $response = $client - ->request('POST', PORTAL_API, [ - 'form_params' => [ - 'key' => PORTAL_KEY, - 'action' => "ping" - ] - ]); - - if ($response->getStatusCode() != 200) { - return false; - } - - $resp = json_decode($response->getBody(), TRUE); + $resp = AccountHubApi::get("ping"); if ($resp['status'] == "OK") { return true; } else { @@ -107,19 +70,7 @@ class Login { */ function checkAPIKey($key) { try { - $client = new GuzzleHttp\Client(); - - $response = $client - ->request('POST', PORTAL_API, [ - 'form_params' => [ - 'key' => $key, - 'action' => "ping" - ] - ]); - - if ($response->getStatusCode() === 200) { - return true; - } + $resp = AccountHubApi::get("ping", null, true); return false; } catch (Exception $e) { return false; diff --git a/lib/Notifications.lib.php b/lib/Notifications.lib.php index c1d93a9..812af26 100644 --- a/lib/Notifications.lib.php +++ b/lib/Notifications.lib.php @@ -32,27 +32,15 @@ class Notifications { $timestamp = date("Y-m-d H:i:s", strtotime($timestamp)); } - $client = new GuzzleHttp\Client(); - - $response = $client - ->request('POST', PORTAL_API, [ - 'form_params' => [ - 'key' => PORTAL_KEY, - 'action' => "addnotification", - 'uid' => $user->getUID(), - 'title' => $title, - 'content' => $content, - 'timestamp' => $timestamp, - 'url' => $url, - 'sensitive' => $sensitive - ] - ]); - - if ($response->getStatusCode() > 299) { - sendError("Login server error: " . $response->getBody()); - } - - $resp = json_decode($response->getBody(), TRUE); + $resp = AccountHubApi::get("addnotification", [ + 'uid' => $user->getUID(), + 'title' => $title, + 'content' => $content, + 'timestamp' => $timestamp, + 'url' => $url, + 'sensitive' => $sensitive + ] + ); if ($resp['status'] == "OK") { return $resp['id'] * 1; } else { diff --git a/lib/Strings.lib.php b/lib/Strings.lib.php index b094bbf..b03058f 100644 --- a/lib/Strings.lib.php +++ b/lib/Strings.lib.php @@ -21,6 +21,10 @@ class Strings { $this->load("en"); + if ($language == "en") { + return; + } + if (file_exists(__DIR__ . "/../langs/$language/")) { $this->language = $language; $this->load($language); diff --git a/lib/User.lib.php b/lib/User.lib.php index 7852e31..763acc5 100644 --- a/lib/User.lib.php +++ b/lib/User.lib.php @@ -17,22 +17,7 @@ class User { public function __construct(int $uid, string $username = "") { // Check if user exists - $client = new GuzzleHttp\Client(); - - $response = $client - ->request('POST', PORTAL_API, [ - 'form_params' => [ - 'key' => PORTAL_KEY, - 'action' => "userexists", - 'uid' => $uid - ] - ]); - - if ($response->getStatusCode() > 299) { - sendError("Login server error: " . $response->getBody()); - } - - $resp = json_decode($response->getBody(), TRUE); + $resp = AccountHubApi::get("userexists", ["uid" => $uid]); if ($resp['status'] == "OK" && $resp['exists'] === true) { $this->exists = true; } else { @@ -43,22 +28,7 @@ class User { if ($this->exists) { // Get user info - $client = new GuzzleHttp\Client(); - - $response = $client - ->request('POST', PORTAL_API, [ - 'form_params' => [ - 'key' => PORTAL_KEY, - 'action' => "userinfo", - 'uid' => $uid - ] - ]); - - if ($response->getStatusCode() > 299) { - sendError("Login server error: " . $response->getBody()); - } - - $resp = json_decode($response->getBody(), TRUE); + $resp = AccountHubApi::get("userinfo", ["uid" => $uid]); if ($resp['status'] == "OK") { $this->uid = $resp['data']['uid'] * 1; $this->username = $resp['data']['username']; @@ -71,22 +41,7 @@ class User { } public static function byUsername(string $username): User { - $client = new GuzzleHttp\Client(); - - $response = $client - ->request('POST', PORTAL_API, [ - 'form_params' => [ - 'key' => PORTAL_KEY, - 'username' => $username, - 'action' => "userinfo" - ] - ]); - - if ($response->getStatusCode() > 299) { - sendError("Login server error: " . $response->getBody()); - } - - $resp = json_decode($response->getBody(), TRUE); + $resp = AccountHubApi::get("userinfo", ["username" => $username]); if (!isset($resp['status'])) { sendError("Login server error: " . $resp); } @@ -105,22 +60,8 @@ class User { if (!$this->exists) { return false; } - $client = new GuzzleHttp\Client(); - - $response = $client - ->request('POST', PORTAL_API, [ - 'form_params' => [ - 'key' => PORTAL_KEY, - 'action' => "hastotp", - 'username' => $this->username - ] - ]); - - if ($response->getStatusCode() > 299) { - sendError("Login server error: " . $response->getBody()); - } - $resp = json_decode($response->getBody(), TRUE); + $resp = AccountHubApi::get("hastotp", ['username' => $this->username]); if ($resp['status'] == "OK") { return $resp['otp'] == true; } else { @@ -150,23 +91,7 @@ class User { * @return bool */ function checkPassword(string $password): bool { - $client = new GuzzleHttp\Client(); - - $response = $client - ->request('POST', PORTAL_API, [ - 'form_params' => [ - 'key' => PORTAL_KEY, - 'action' => "auth", - 'username' => $this->username, - 'password' => $password - ] - ]); - - if ($response->getStatusCode() > 299) { - sendError("Login server error: " . $response->getBody()); - } - - $resp = json_decode($response->getBody(), TRUE); + $resp = AccountHubApi::get("auth", ['username' => $this->username, 'password' => $password]); if ($resp['status'] == "OK") { return true; } else { @@ -178,23 +103,8 @@ class User { if (!$this->has2fa) { return true; } - $client = new GuzzleHttp\Client(); - - $response = $client - ->request('POST', PORTAL_API, [ - 'form_params' => [ - 'key' => PORTAL_KEY, - 'action' => "verifytotp", - 'username' => $this->username, - 'code' => $code - ] - ]); - - if ($response->getStatusCode() > 299) { - sendError("Login server error: " . $response->getBody()); - } - $resp = json_decode($response->getBody(), TRUE); + $resp = AccountHubApi::get("verifytotp", ['username' => $this->username, 'code' => $code]); if ($resp['status'] == "OK") { return $resp['valid']; } else { @@ -209,23 +119,7 @@ class User { * @return boolean TRUE if the user has the permission (or admin access), else FALSE */ function hasPermission(string $code): bool { - $client = new GuzzleHttp\Client(); - - $response = $client - ->request('POST', PORTAL_API, [ - 'form_params' => [ - 'key' => PORTAL_KEY, - 'action' => "permission", - 'username' => $this->username, - 'code' => $code - ] - ]); - - if ($response->getStatusCode() > 299) { - sendError("Login server error: " . $response->getBody()); - } - - $resp = json_decode($response->getBody(), TRUE); + $resp = AccountHubApi::get("permission", ['username' => $this->username, 'code' => $code]); if ($resp['status'] == "OK") { return $resp['has_permission']; } else { @@ -238,23 +132,7 @@ class User { * @return \AccountStatus */ function getStatus(): AccountStatus { - - $client = new GuzzleHttp\Client(); - - $response = $client - ->request('POST', PORTAL_API, [ - 'form_params' => [ - 'key' => PORTAL_KEY, - 'action' => "acctstatus", - 'username' => $this->username - ] - ]); - - if ($response->getStatusCode() > 299) { - sendError("Login server error: " . $response->getBody()); - } - - $resp = json_decode($response->getBody(), TRUE); + $resp = AccountHubApi::get("acctstatus", ['username' => $this->username]); if ($resp['status'] == "OK") { return AccountStatus::fromString($resp['account']); } else { @@ -262,24 +140,13 @@ class User { } } - function sendAlertEmail(string $appname = SITE_TITLE) { - $client = new GuzzleHttp\Client(); - - $response = $client - ->request('POST', PORTAL_API, [ - 'form_params' => [ - 'key' => PORTAL_KEY, - 'action' => "alertemail", - 'username' => $this->username, - 'appname' => SITE_TITLE - ] - ]); - - if ($response->getStatusCode() > 299) { - return "An unknown error occurred."; + function sendAlertEmail(string $appname = null) { + global $SETTINGS; + if (is_null($appname)) { + $appname = $SETTINGS['site_title']; } + $resp = AccountHubApi::get("alertemail", ['username' => $this->username, 'appname' => $SETTINGS['site_title']]); - $resp = json_decode($response->getBody(), TRUE); if ($resp['status'] == "OK") { return true; } else { diff --git a/lib/getitemtable.php b/lib/getitemtable.php index 88b157c..afe32b5 100644 --- a/lib/getitemtable.php +++ b/lib/getitemtable.php @@ -57,7 +57,7 @@ switch ($VARS['order'][0]['column']) { } // search -if (!is_empty($VARS['search']['value'])) { +if (!empty($VARS['search']['value'])) { $filter = true; $wherenolimit = []; if ($showwant) { diff --git a/mobile/index.php b/mobile/index.php index 506608e..beaad4a 100644 --- a/mobile/index.php +++ b/mobile/index.php @@ -8,10 +8,6 @@ * Mobile app API */ -// The name of the permission needed to log in. -// Set to null if you don't need it. -$access_permission = "INV_VIEW"; - require __DIR__ . "/../required.php"; header('Content-Type: application/json'); @@ -23,21 +19,7 @@ if ($VARS['action'] == "ping") { } function mobile_enabled() { - $client = new GuzzleHttp\Client(); - - $response = $client - ->request('POST', PORTAL_API, [ - 'form_params' => [ - 'key' => PORTAL_KEY, - 'action' => "mobileenabled" - ] - ]); - - if ($response->getStatusCode() > 299) { - return false; - } - - $resp = json_decode($response->getBody(), TRUE); + $resp = AccountHubApi::get("mobileenabled"); if ($resp['status'] == "OK" && $resp['mobile'] === TRUE) { return true; } else { @@ -46,26 +28,15 @@ function mobile_enabled() { } function mobile_valid($username, $code) { - $client = new GuzzleHttp\Client(); - - $response = $client - ->request('POST', PORTAL_API, [ - 'form_params' => [ - 'key' => PORTAL_KEY, - "code" => $code, - "username" => $username, - 'action' => "mobilevalid" - ] - ]); + try { + $resp = AccountHubApi::get("mobilevalid", ["code" => $code, "username" => $username], true); - if ($response->getStatusCode() > 299) { - return false; - } - - $resp = json_decode($response->getBody(), TRUE); - if ($resp['status'] == "OK" && $resp['valid'] === TRUE) { - return true; - } else { + if ($resp['status'] == "OK" && $resp['valid'] === TRUE) { + return true; + } else { + return false; + } + } catch (Exception $ex) { return false; } } @@ -75,7 +46,7 @@ if (mobile_enabled() !== TRUE) { } // Make sure we have a username and access key -if (is_empty($VARS['username']) || is_empty($VARS['key'])) { +if (empty($VARS['username']) || empty($VARS['key'])) { http_response_code(401); die(json_encode(["status" => "ERROR", "msg" => "Missing username and/or access key."])); } @@ -95,13 +66,14 @@ switch ($VARS['action']) { if ($user->exists()) { if ($user->getStatus()->getString() == "NORMAL") { if ($user->checkPassword($VARS['password'])) { - if (is_null($access_permission) || $user->hasPermission($access_permission)) { - Session::start($user); - $_SESSION['mobile'] = true; - exit(json_encode(["status" => "OK"])); - } else { - exit(json_encode(["status" => "ERROR", "msg" => lang("no permission", false)])); + foreach ($SETTINGS['permissions'] as $perm) { + if (!$user->hasPermission($perm)) { + exit(json_encode(["status" => "ERROR", "msg" => $Strings->get("no permission", false)])); + } } + Session::start($user); + $_SESSION['mobile'] = true; + exit(json_encode(["status" => "OK"])); } } } diff --git a/pages.php b/pages.php index e324a56..57e889b 100644 --- a/pages.php +++ b/pages.php @@ -99,5 +99,13 @@ define("PAGES", [ ], "404" => [ "title" => "404 error" + ], + "form" => [ + "title" => "Form", + "navbar" => true, + "icon" => "fas fa-file-alt", + "scripts" => [ + "static/js/form.js" + ] ] ]); diff --git a/pages/editcat.php b/pages/editcat.php index ed95dcf..8924007 100644 --- a/pages/editcat.php +++ b/pages/editcat.php @@ -28,43 +28,24 @@ if (!empty($VARS['id'])) { header('Location: app.php?page=editcat'); } } -?> -
-
-

- - build("editing category", ['cat' => "" . htmlspecialchars($catdata['catname']) . ""]); ?> - - get("Adding new category"); ?> - -

-
-
- - -
-
+$form = new FormBuilder("", "fas fa-edit"); - " /> - - +if ($editing) { + $form->setTitle($Strings->build("editing category", ['cat' => "" . htmlentities($catdata['catname']) . ""], false)); +} else { + $form->setTitle($Strings->get("Adding new category", false)); +} +$form->addInput("name", htmlentities($catdata['catname']), "text", true, "name", null, $Strings->get("name", false), "fas fa-archive", 12); + +$form->addHiddenInput("catid", isset($VARS['id']) ? htmlspecialchars($VARS['id']) : ""); +$form->addHiddenInput("action", "editcat"); +$form->addHiddenInput("source", "categories"); + +$form->addButton($Strings->get("save", false), "fas fa-save", null, "submit"); + +if ($editing) { + $form->addButton($Strings->get("delete", false), "fas fa-times", "action.php?action=deletecat&source=categories&catid=" . htmlspecialchars($VARS['id']), "", null, null, "", "btn btn-danger ml-auto"); +} - -
-
\ No newline at end of file +$form->generate(); \ No newline at end of file diff --git a/pages/editimages.php b/pages/editimages.php index 0170470..9bb2364 100644 --- a/pages/editimages.php +++ b/pages/editimages.php @@ -84,7 +84,7 @@ if (!empty($VARS['id']) && $database->has('items', ['itemid' => $VARS['id']])) {