<?php
/**
* Base class for course query backends
*/
abstract class CourseBackend
{
/*
* Static part for handling interfaces
*/
/**
* @var array list of known backends
*/
private static $backendTypes = false;
/**
* @var boolean|string legacy, do not use
*/
protected $error = false;
/**
* @var array list of errors that occurred, fill using addError()
*/
private $errors;
/**
* @var int as internal serverId
*/
protected $serverId;
/**
* @const int try to fetch this many locations from one backend if less are requested (for backends that benefit from request coalesc.)
*/
const TRY_NUM_LOCATIONS = 6;
/**
* CourseBackend constructor.
*/
public final function __construct()
{
$this->errors = [];
}
protected final function addError(string $message, bool $fatal)
{
$this->errors[] = ['time' => time(), 'message' => $message, 'fatal' => $fatal];
}
/**
* Load all known backend types. This is done
* by including *.inc.php from inc/coursebackend/.
*/
public static function loadDb()
{
if (self::$backendTypes !== false)
return;
self::$backendTypes = array();
foreach (glob(dirname(__FILE__) . '/coursebackend/coursebackend_*.inc.php', GLOB_NOSORT) as $file) {
require_once $file;
preg_match('#coursebackend_([^/.]+)\.inc\.php$#i', $file, $out);
$className = 'CourseBackend_' . $out[1];
if (!class_exists($className)) {
trigger_error("Backend type source unit $file doesn't seem to define class $className", E_USER_ERROR);
}
if (!CONFIG_DEBUG && defined("$className::DEBUG") && constant("$className::DEBUG"))
continue;
self::$backendTypes[$out[1]] = true;
}
}
/**
* Get all known backend types.
*
* @return array list of backends
*/
public static function getList(): array
{
self::loadDb();
return array_keys(self::$backendTypes);
}
public static function exists($backendType): bool
{
self::loadDb();
return isset(self::$backendTypes[$backendType]);
}
/**
* Get fresh instance of CourseBackend subclass for given backend type.
*
* @param string $backendType name of module type
* @return \CourseBackend|false module instance
*/
public static function getInstance(string $backendType)
{
self::loadDb();
if (!isset(self::$backendTypes[$backendType])) {
error_log('Unknown module type: ' . $backendType);
return false;
}
if (!is_object(self::$backendTypes[$backendType])) {
$class = "coursebackend_$backendType";
self::$backendTypes[$backendType] = new $class;
}
return self::$backendTypes[$backendType];
}
/**
* @return string return display name of backend
*/
public abstract function getDisplayName(): string;
/**
* @returns \BackendProperty[] list of properties that need to be set
*/
public abstract function getCredentialDefinitions(): array;
/**
* @return boolean true if the connection works, false otherwise
*/
public abstract function checkConnection(): bool;
/**
* uses json to setCredentials, the json must follow the form given in
* getCredentials
*
* @param array $data assoc array with data required by backend
* @returns bool if the credentials were in the correct format
*/
public abstract function setCredentialsInternal(array $data): bool;
/**
* @return int desired caching time of results, in seconds. 0 = no caching
*/
public abstract function getCacheTime(): int;
/**
* @return int age after which timetables are no longer refreshed should be
* greater then CacheTime
*/
public abstract function getRefreshTime(): int;
/**
* Internal version of fetch, to be overridden by subclasses.
*
* @param $requestedRoomIds array with remote IDs for wanted rooms
* @return array a recursive array that uses the roomID as key
* and has the schedule array as value. A schedule array contains an array in this format:
* ["start"=>'JJJJ-MM-DD"T"HH:MM:SS',"end"=>'JJJJ-MM-DD"T"HH:MM:SS',"title"=>string]
*/
protected abstract function fetchSchedulesInternal(array $requestedRoomIds): array;
/**
* In case you want to sanitize or otherwise mangle a property for your backend,
* override this.
*/
public function mangleProperty(string $prop, $value)
{
return $value;
}
private static function fixTime(string &$start, string &$end): bool
{
if (!preg_match('/^(\d{2}|\d{4})-?\d{2}-?\d{2}-?T\d{1,2}:?\d{2}:?(\d{2})?$/', $start))
return false;
$start = strtotime($start);
$end = strtotime($end);
if ($start === false || $end === false || $start >= $end)
return false;
$start = date('Y-m-d\TH:i:s', $start);
$end = date('Y-m-d\TH:i:s', $end);
return true;
}
/**
* Method for fetching the schedule of the given rooms on a server.
*
* @param array $requestedLocationIds array of room ID to fetch
* @return array array containing the timetables as value and roomid as key as result, or false on error
*/
public final function fetchSchedule(array $requestedLocationIds): array
{
if (empty($requestedLocationIds))
return array();
$requestedLocationIds = array_values($requestedLocationIds);
$NOW = time();
$dbquery1 = Database::simpleQuery("SELECT locationid, calendar, serverlocationid, lastcalendarupdate
FROM locationinfo_locationconfig WHERE locationid IN (:locations)",
array('locations' => $requestedLocationIds));
$returnValue = [];
$remoteIds = [];
foreach ($dbquery1 as $row) {
// Check if in cache - if lastUpdate is null then it is interpreted as 1970
if ($row['lastcalendarupdate'] + $this->getCacheTime() < $NOW) {
$remoteIds[$row['locationid']] = $row['serverlocationid'];
}
// Always add to return value - if updating fails, we better use the stale data than nothing
$returnValue[$row['locationid']] = json_decode($row['calendar'], true);
}
// No need for additional round trips to backend
if (empty($remoteIds)) {
return $returnValue;
}
// Mark requested locations as used
Database::exec("UPDATE locationinfo_locationconfig SET lastuse = :now WHERE locationid IN (:locations)",
['locations' => $requestedLocationIds, 'now' => $NOW]);
// Check if we should refresh other rooms recently requested by front ends
$extraLocs = self::TRY_NUM_LOCATIONS - count($remoteIds);
if ($this->getRefreshTime() > $this->getCacheTime() && $extraLocs > 0) {
$dbquery4 = Database::simpleQuery("SELECT locationid, serverlocationid FROM locationinfo_locationconfig
WHERE serverid = :serverid AND serverlocationid NOT IN (:skiplist)
AND lastcalendarupdate < :minage AND lastuse > :lastuse
LIMIT $extraLocs", array(
'serverid' => $this->serverId,
'skiplist' => array_values($remoteIds),
'lastuse' => $NOW - $this->getRefreshTime(),
'minage' => $NOW - $this->getCacheTime(),
));
foreach ($dbquery4 as $row) {
$remoteIds[$row['locationid']] = $row['serverlocationid'];
}
}
if ($this->getCacheTime() > 0) {
// Update the last update timestamp of the ones we are working on, so they won't be queried in parallel
// if another request comes in while we're in fetchSchedulesInternal. Currently done without locking the
// table. I think it's unlikely enough that we still get a race during those three queries here, and even
// if, nothing bad will happen...
Database::exec("UPDATE locationinfo_locationconfig SET lastcalendarupdate = :time
WHERE lastcalendarupdate < :time AND serverid = :serverid AND serverlocationid IN (:slocs)", [
'time' => $NOW - ($this->getCacheTime() - 60), // Protect for one minute max.
'serverid' => $this->serverId,
'slocs' => array_values($remoteIds),
]);
}
$backendResponse = $this->fetchSchedulesInternal(array_unique($remoteIds));
// Fetching might have taken a while, get current time again
$NOW = time();
foreach ($backendResponse as $serverRoomId => &$calendar) {
$calendar = array_values($calendar);
for ($i = 0; $i < count($calendar); ++$i) {
if (empty($calendar[$i]['title'])) {
$calendar[$i]['title'] = '-';
}
if (!self::fixTime($calendar[$i]['start'], $calendar[$i]['end'])) {
error_log("Ignoring calendar entry '{$calendar[$i]['title']}' with bad time format");
unset($calendar[$i]);
}
}
$calendar = array_values($calendar);
if ($this->getCacheTime() > 0) {
// Caching requested by backend, write to DB
$value = json_encode($calendar);
Database::simpleQuery("UPDATE locationinfo_locationconfig SET calendar = :ttable, lastcalendarupdate = :now
WHERE serverid = :serverid AND serverlocationid = :serverlocationid", array(
'serverid' => $this->serverId,
'serverlocationid' => $serverRoomId,
'ttable' => $value,
'now' => $NOW, // Set real "lastupdate" here
));
}
}
unset($calendar);
// Add rooms that were requested to the final return value
foreach ($remoteIds as $location => $serverRoomId) {
if (isset($backendResponse[$serverRoomId]) && is_array($backendResponse[$serverRoomId])
&& in_array($location, $requestedLocationIds)) {
// Only add if we can map it back to our location id AND it was not an unsolicited coalesced refresh
$returnValue[$location] = $backendResponse[$serverRoomId];
}
}
return $returnValue;
}
public final function setCredentials(int $serverId, array $data): bool
{
foreach ($this->getCredentialDefinitions() as $prop) {
if (!isset($data[$prop->property])) {
$data[$prop->property] = $prop->default;
}
if (in_array($prop->type, ['string', 'bool', 'int'])) {
settype($data[$prop->property], $prop->type);
} else {
settype($data[$prop->property], 'string');
}
}
if ($this->setCredentialsInternal($data)) {
$this->serverId = $serverId;
return true;
}
return false;
}
/**
* @return array list of errors that occurred during processing.
*/
public final function getErrors(): array
{
return $this->errors;
}
/**
* Query path in array-representation of XML document.
* e.g. 'path/syntax/foo/wanteditem'
* This works for intermediate nodes (that have more children)
* and leaf nodes. The result is always an array on success, or
* false if not found.
*/
protected function getArrayPath($array, $path)
{
if (!is_array($path)) {
// Convert 'path/syntax/foo/wanteditem' to array for further processing and recursive calls
$path = explode('/', $path);
}
if (isset($array[0])) {
// The currently handled element of the path exists multiple times on the current level, so it is
// wrapped in a plain array - recurse into each one of them and merge the results
$return = [];
foreach ($array as $item) {
$test = $this->getArrayPath($item, $path);
If (is_array($test)) {
$return = array_merge($return, $test);
}
}
return $return;
}
do {
// Get next element from array, loop to ignore empty elements (so double slashes in the path are allowed)
$element = array_shift($path);
} while (empty($element) && !empty($path));
if (!isset($array[$element])) {
// Current path element does not exist - error
return false;
}
if (empty($path)) {
// Path is now empty which means we're at 'wanteditem' from out example above
if (!is_array($array[$element]) || !isset($array[$element][0])) {
// If it's a leaf node of the array, wrap it in plain array, so the function will
// always return an array on success
return array($array[$element]);
}
// 'wanteditem' is not a unique leaf node, return as is
// This means it's either a plain array, in case there are multiple 'wanteditem' elements on the same level
// or it's an associative array if 'wanteditem' has any sub-nodes
return $array[$element];
}
// Recurse
if (!is_array($array[$element])) {
// We're in the middle of the requested path, but the current element is already a leaf node with no
// children - error
return false;
}
// Non-leaf node - simple recursion
return $this->getArrayPath($array[$element], $path);
}
/**
* @param string $response xml document to convert
* @return bool|array array representation of the xml if possible, false otherwise
*/
protected function xmlStringToArray(string $response, &$error)
{
$cleanresponse = preg_replace('/(<\/?)(\w+):([^>]*>)/', '$1$2$3', $response);
try {
$xml = @new SimpleXMLElement($cleanresponse); // This spams before throwing exception
} catch (Exception $e) {
$error = get_class($e) . ': ' . $e->getMessage();
if (CONFIG_DEBUG) {
error_log($cleanresponse);
}
return false;
}
return json_decode(json_encode((array)$xml), true);
}
}
/**
* Class BackendProperty describes a property a backend requires to define its functionality
*/
class BackendProperty {
public $property;
public $type;
public $default;
public function __construct($property, $type, $default = '')
{
$this->property = $property;
$this->type = $type;
$this->default = $default;
}
/**
* Initialize additional fields of this class that are only required
* for rendering the server configuration dialog.
*
* @param mixed $current current value of this property.
*/
public function initForRender($current = null) {
if ($current === null) {
$current = $this->default;
}
if (is_array($this->type)) {
$this->template = 'dropdown';
$this->select_list = [];
foreach ($this->type as $item) {
$this->select_list[] = [
'option' => $item,
'active' => $item == $current,
];
}
} elseif ($this->type === 'bool') {
$this->template = $this->type;
} else {
$this->template = 'generic';
}
if ($this->type === 'string') {
$this->inputtype = 'text';
} elseif ($this->type === 'int') {
$this->inputtype = 'number';
} elseif ($this->type === 'password') {
$this->inputtype = Property::getPasswordFieldType();
}
$this->currentvalue = $current;
}
public $inputtype;
public $template;
public $title;
public $helptext;
public $currentvalue;
public $select_list;
public $credentialsHtml;
}