xx, 'lastseen' => xxx) * @return string pc state */ public static function getPcState(array $pc): string { $lastseen = (int)$pc['lastseen']; $NOW = time(); if ($pc['state'] === 'OFFLINE' && $NOW - $lastseen > 30 * 86400) { return "BROKEN"; } return $pc['state']; } /** * Return list of locationids associated with given panel. * * @param string $paneluuid panel * @param bool $recursive if true and paneltype == SUMMARY the result is recursive with all child room ids. * @return int[] locationIds */ public static function getLocationsOr404(string $paneluuid, bool $recursive = true): array { $panel = Database::queryFirst('SELECT paneltype, locationids FROM locationinfo_panel WHERE paneluuid = :paneluuid', compact('paneluuid')); if ($panel !== false) { $idArray = array_map('intval', explode(',', $panel['locationids'])); if ($panel['paneltype'] == "SUMMARY" && $recursive) { foreach ($idArray as $lid) { $idArray = array_merge($idArray, Location::getAllLocationIds($lid)); } } return $idArray; } http_response_code(404); die('Panel not found'); } /** * Set current error message of given server. Pass null or false to clear. * * @param int $serverId id of server * @param string|array $message error message to set, array of error message struct, null or false clears error. */ public static function setServerError(int $serverId, $message): void { if (is_array($message)) { $fatal = false; foreach ($message as $m) { if ($m['fatal']) { $fatal = $m['message']; } Database::exec('INSERT INTO locationinfo_backendlog (serverid, dateline, message) VALUES (:sid, :dateline, :message)', [ 'sid' => $serverId, 'dateline' => $m['time'], 'message' => ($m['fatal'] ? '[F]' : '[W]') . $m['message'], ]); } $message = $fatal; } if ($message === false || $message === null) { Database::exec("UPDATE `locationinfo_coursebackend` SET error = NULL WHERE serverid = :id", array('id' => $serverId)); } else { if (empty($message)) { $message = ''; } $error = json_encode(array( 'timestamp' => time(), 'error' => (string)$message )); Database::exec("UPDATE `locationinfo_coursebackend` SET error = :error WHERE serverid = :id", array('id' => $serverId, 'error' => $error)); } } public static function getPanelParameters(string $type): array { switch ($type) { case 'DEFAULT': $return = [ 'string' => [], 'int' => [ 'mode' => ['section' => 'general', 'default' => 1, 'min' => 1, 'max' => 4], 'startday' => ['section' => 'calendar', 'default' => 0, 'min' => 0, 'max' => 7], 'daystoshow' => ['section' => 'calendar', 'default' => 7, 'min' => 1, 'max' => 7], 'rotation' => ['section' => 'roomplan', 'default' => 0, 'min' => 0, 'max' => 3], 'scale' => ['section' => 'styling', 'default' => 50, 'min' => 10, 'max' => 90], 'switchtime' => ['section' => 'general', 'default' => 20, 'min' => 1, 'max' => 120], 'calupdate' => ['section' => 'calendar', 'default' => 120, 'min' => 5, 'max' => 300], 'roomupdate' => ['section' => 'roomplan', 'default' => 30, 'min' => 5, 'max' => 60], ], 'bool' => [ 'qrcode' => ['section' => 'general'], 'vertical' => ['section' => 'styling'], 'eco' => ['section' => 'styling'], 'prettytime' => ['section' => 'styling'], 'roomplanner' => ['section' => 'roomplan', 'default' => true], 'scaledaysauto' => ['section' => 'calendar', 'default' => true], 'hostname' => ['section' => 'roomplan', 'default' => false], ], ]; break; case 'URL': $return = [ 'string' => [ 'url' => ['section' => 'general', 'default' => 'https://www.bwlehrpool.de/'], 'blacklist' => ['section' => false], 'whitelist' => ['section' => false, 'default' => ''], 'browser' => ['section' => 'general', 'default' => 'firefox', 'enum' => [ 'firefox', 'slx-browser', 'chromium', ]], 'accept-language' => ['section' => 'general', 'default' => 'de-DE,en-US'], 'screen-rotation' => ['section' => 'general', 'default' => '', 'enum' => [ '', 'left', 'right', 'inverted', ]], 'allow-tty' => ['section' => 'general', 'enum' => [ '', 'yes', 'no', ]], ], 'int' => [ 'reload-minutes' => ['section' => 'general', 'default' => 0, 'min' => 0], 'zoom-factor' => [ 'section' => 'browser-tweaks', 'default' => 100, 'min' => 10, 'max' => 1000, 'slider' => '%', ], ], 'bool' => [ 'insecure-ssl' => [ 'section' => 'browser-tweaks', 'class' => 'browser-specific show-slx-browser show-chromium', ], 'split-login' => ['section' => 'general'], 'interactive' => [ 'section' => 'browser-tweaks', 'class' => 'browser-specific show-firefox show-chromium', ], 'hw-video' => [ 'section' => 'browser-tweaks', 'class' => 'browser-specific show-firefox show-chromium', ], ], ]; break; case 'UPCOMING': $return = [ 'string' => [ 'heading' => ['section' => 'general'], 'subheading' => ['section' => 'general'], 'color_bg' => ['section' => 'styling', 'default' => 'rgb(52, 73, 154)'], 'color_fg' => ['section' => 'styling', 'default' => '#fff'], 'color_section' => ['section' => 'styling', 'default' => 'rgba(255,255,255, .2)'], 'color_time' => ['section' => 'styling', 'default' => 'rgba(255,255,255, .5)'], ], 'bool' => [ 'qrcode' => ['section' => 'general'], ], ]; break; case 'SUMMARY': $return = [ 'string' => [], 'int' => [ 'panelupdate' => ['section' => 'roomplan', 'default' => 30, 'min' => 5, 'max' => 60], 'roomplanner' => ['section' => 'roomplan', 'default' => 0, 'enum' => [0, 1]], ], 'bool' => [ 'eco' => ['section' => 'styling'], ], ]; break; default: ErrorHandler::traceError("Invalid panel type: '$type'"); } if ($type !== 'URL') { $mods = []; $lowType = strtolower($type); foreach (glob('modules/locationinfo/templates/frontend-' . $lowType . '-*.html') as $file) { if (!preg_match('/frontend-' . $lowType . '-([a-z0-9]+)\.html$/', $file, $matches)) continue; $mods[] = $matches[1]; } if (!empty($mods)) { array_unshift($mods, ''); $return['string']['mod'] = ['section' => 'general', 'enum' => $mods]; } $return['string']['language'] = [ 'section' => 'general', 'default' => defined('LANG') ? LANG : 'en', 'enum' => Dictionary::getLanguages(), ]; $return['bool']['language-system'] = ['section' => 'general']; } return $return; } /** * Takes a configuration for a specific panel type, and creates additional keys for all * known boolean options that are set in the config, after the following pattern * ``` * 'somekey' => true * creates: * 'somekey_checked' => 'checked' * ``` * so that it can be used in templates more easily. */ public static function makeCheckedProperties(string $paneltype, array &$config): void { $params = self::getPanelParameters($paneltype); if (empty($params['bool'])) return; Render::mangleFields($config, array_keys($params['bool'])); } /** * Read panel config from POST parameters for given panel type. * @param string $type Type to process POST parameters for * @return array mangled and sanitized config */ public static function panelConfigFromPost(string $type): array { $conf = []; Request::processPostParameters($conf, self::getPanelParameters($type)); return $conf; } /** * Creates and returns a default config for room that didn't save a config yet. * * @return array Return a default config. */ public static function defaultPanelConfig(string $type): array { $p = self::getPanelParameters($type); $out = []; foreach ($p as $sub) { foreach ($sub as $field => $options) { $out[$field] = $options['default'] ?? $options['min'] ?? (isset($options['enum']) ? array_shift($options['enum']) : ''); } } return $out; } /** * Get data for editing a panel based on the type and current configuration. * The returned data is suitable for rendering it in a mustache template. * @param string $type The type of template to edit. * @param array $current The current configuration data. * @return array The edit template data formatted for display in a mustache template. */ public static function getEditTemplateData(string $type, array $current): array { $params = self::getPanelParameters($type); // Make sure panelname is the first element $params['string'] = array_reverse($params['string'], true); $params['string']['panelname'] = []; $params['string'] = array_reverse($params['string'], true); $params['bool']['ispublic'] = ['section' => 'general']; $sections = []; foreach ($params as $varType => $options) { foreach ($options as $key => $properties) { if (isset($properties['enum']) && empty($properties['enum'])) continue; // Enum with no values - ignore $section = ($properties['section'] ?? 'general'); if ($section === false) // To be hidden continue; if (!isset($sections[$section])) { $sections[$section] = []; } $sections[$section][] = [ 'title' => Dictionary::translateFile('panel-params', $key, true), 'helptext' => Dictionary::translateFile('panel-params', $key . '_helptext', true), 'html' => self::makeEditField($varType, $key, $properties, $current[$key] ?? ''), 'class' => $properties['class'] ?? null, ]; } } $output = []; foreach ($sections as $sectionName => $sectionData) { $output[] = [ 'title' => Dictionary::translateFile('panel-params', 'section_' . $sectionName, true), 'elements' => $sectionData, ]; } return $output; } /** * Transform the given variable into an edit field based on its type and configuration. * * @param string $varType The type of the variable ('int', 'bool', 'string'). * @param string $key The key used in the form field name attribute. * @param array $opts The variable configuration array. * @param mixed $current The current value of the variable. * @return string The HTML representation of the edit field for the given variable. */ private static function makeEditField(string $varType, string $key, array $opts, $current): string { $hCurrent = htmlspecialchars($current); $hDefault = htmlspecialchars($opts['default'] ?? ''); if (isset($opts['enum'])) { // Enum is a select/dropdown/combobox $return = ''; } elseif ($varType === 'int') { if (($opts['slider'] ?? false) && isset($opts['min']) && isset($opts['max'])) { // Slider requested $return = '
' . '' . $hCurrent . '' . htmlspecialchars($opts['slider']) . '
'; } else { // Normal text field for numbers $return = ''; } elseif ($varType === 'string') { $return = ''; } else { ErrorHandler::traceError("Invalid variable type: '$varType'"); } return $return; } /** * Gets the calendar of the given location IDs. * * @param int[] $idList list with the location ids. * @param ?int $deadline null = no timeout, otherwise a unix timestamp until which we expect function to return * @return array Calendar. */ public static function getCalendar(array $idList, ?int $deadline = null): array { if (empty($idList)) return []; if ($deadline !== null && $deadline <= time()) { // Deadline in the past, use cached data exclusively $resultArray = []; $res = Database::simpleQuery("SELECT locationid, calendar FROM locationinfo_locationconfig WHERE Length(calendar) > 10 AND lastcalendarupdate > UNIX_TIMESTAMP() - 86400*3 AND locationid IN (:lid)", ['lid' => $idList]); foreach ($res as $row) { $resultArray[] = [ 'id' => (int)$row['locationid'], 'calendar' => json_decode($row['calendar'], true), ]; } return $resultArray; } // Build SQL query for multiple ids. $query = "SELECT l.locationid, l.serverid, l.serverlocationid, s.servertype, s.credentials FROM `locationinfo_locationconfig` AS l INNER JOIN locationinfo_coursebackend AS s ON (s.serverid = l.serverid) WHERE l.locationid IN (:idlist)"; $rows = Database::queryAll($query, array('idlist' => array_values($idList))); return self::getCalendarCommon($rows, $deadline); } /** * Fetch all calendars from backend that seem to be actively in use by a running frontend panel, * and are about to expire. This should reduce load times next time the panel requests an update, * as it ensures the data can be served from cache. */ public static function refreshStaleButActiveCalendars() { $NOW = time(); // Get all calendars where 'lastuse' was in past ~6 minutes $rows = Database::queryAll("SELECT l.locationid, l.lastcalendarupdate, l.serverid, l.serverlocationid, s.servertype, s.credentials FROM `locationinfo_locationconfig` AS l INNER JOIN locationinfo_coursebackend AS s ON (s.serverid = l.serverid) WHERE l.lastuse > :cutoff AND l.lastuse > l.lastcalendarupdate", ['cutoff' => $NOW - 400]); foreach (array_keys($rows) as $i) { $row = $rows[$i]; $serverInstance = CourseBackend::getInstance($row['servertype']); if ($serverInstance === false) continue; $secsSinceLastFetch = $NOW - $row['lastcalendarupdate']; // Must hold: // Last update was between max cache time and opportunistic refresh time // Also refresh time has to be at least 10 minutes if ($serverInstance->getRefreshTime() < 600 || $secsSinceLastFetch > $serverInstance->getCacheTime() || $secsSinceLastFetch < $serverInstance->getRefreshTime()) { // Nope unset($rows[$i]); } } self::getCalendarCommon($rows, -1); } /** * @param ?int $deadline accepts special value -1 to ignore cache and always fetch */ private static function getCalendarCommon(array $wantedCalendars, ?int $deadline): array { $serverList = []; foreach ($wantedCalendars as $cal) { if (!isset($serverList[$cal['serverid']])) { $serverList[$cal['serverid']] = [ 'credentials' => (array)json_decode($cal['credentials'], true), 'type' => $cal['servertype'], 'idlist' => [] ]; } $serverList[$cal['serverid']]['idlist'][] = $cal['locationid']; } $resultArray = []; foreach ($serverList as $serverid => $server) { $serverInstance = CourseBackend::getInstance($server['type']); if ($serverInstance === false) { EventLog::warning('Cannot fetch schedule (' . implode(', ', $server['idlist']) . ')' . ': Backend type ' . $server['type'] . ' unknown. Disabling location.'); Database::exec("UPDATE locationinfo_locationconfig SET serverid = NULL WHERE locationid IN (:lid)", ['lid' => $server['idlist']]); continue; } if ($serverInstance->setCredentials($serverid, $server['credentials'])) { $calendarFromBackend = $serverInstance->fetchSchedule($server['idlist'], $deadline); } else { $calendarFromBackend = []; } LocationInfo::setServerError($serverid, $serverInstance->getErrors()); foreach ($calendarFromBackend as $locationId => $calendar) { $resultArray[] = [ 'id' => (int)$locationId, 'calendar' => $calendar, ]; } } return $resultArray; } /** * Get all calendars. Can update or fetch from cache. If $deadline is NULL, all calendars that are stale will * be fetched from the according backends. Otherwise, $deadline is expected to be a UNIX TIMESTAMP until which * we request the function to return, trying to update as many stale calendars as possible during that time, * returning cached values for all that weren't updated. Passing a timestamp in the past (e.g. 0) ensures * that all calendars will be served from cache, regardless of age. * @param ?int $deadline null = no timeout, otherwise a unix timestamp until which we expect to return */ public static function getAllCalendars(?int $deadline = null): array { $locations = Database::queryColumnArray("SELECT locationid FROM location"); $calendars = []; foreach (LocationInfo::getCalendar($locations, $deadline) as $cal) { if (empty($cal['calendar'])) continue; $calendars[$cal['id']] = $cal['calendar']; } return $calendars; } public static function extractCurrentEvent(array $calendar): string { $NOW = time(); foreach ($calendar as $event) { $start = strtotime($event['start']); $end = strtotime($event['end']) + 60; if ($NOW >= $start && $NOW <= $end) return $event['title']; } return ''; } /** * Transform legacy url filter config if found, remove no-op filter setups. * @param array $config The configuration array to be cleaned up. By reference. */ public static function cleanupUrlFilter(array &$config): void { // First, simple trimming of white-space around the list, and making sure the keys exist $config['blacklist'] = trim($config['blacklist'] ?? ''); $config['whitelist'] = trim($config['whitelist'] ?? ''); if (empty($config['blacklist']) && empty($config['whitelist'])) { // Mangle non-upgraded configurations: They only have one list and a bool specifying if it's a black- or whitelist if (!empty($config['urllist'])) { if ($config['iswhitelist'] ?? false) { $config['whitelist'] = str_replace(' ', "\n", $config['urllist']); } else { $config['blacklist'] = str_replace(' ', "\n", $config['urllist']); } unset($config['urllist'], $config['iswhitelist']); } } elseif ((empty($config['blacklist']) || self::isAnyUrlMatch($config['blacklist'])) && self::isAnyUrlMatch($config['whitelist'])) { // Blocking everything/nothing and allowing everything is a no-op for all three browsers, so clear both lists $config['blacklist'] = ''; $config['whitelist'] = ''; } $config['blacklist'] = preg_replace('/\r?\n/', "\r\n", $config['blacklist']); $config['whitelist'] = preg_replace('/\r?\n/', "\r\n", $config['whitelist']); } /** * Check if the given pattern would be interpreted as matching any URL. * @param string $url The URL to check */ private static function isAnyUrlMatch(string $url): bool { return $url === '*' || $url === '*://*' || $url === '*://*/' || $url === '*://*/*'; } }