src/controller/Controller.php
<?php
/**
* Handles all the requests from the user and changes the view accordingly.
*/
class Controller
{
/**
* How long to store retrieved disk configuration for HTTP 304 header
* from git information.
*/
public const GIT_MODIFIED_CONFIG_TTL = 600; // 10 minutes
/**
* The controller has to know the model to access the data stored there.
* @var Model $model contains the Model object.
*/
public $model;
protected $negotiator;
protected $languages;
/**
* Initializes the Model object.
*/
public function __construct($model)
{
$this->model = $model;
$this->negotiator = new \Negotiation\Negotiator();
$domain = 'skosmos';
// Build arrays of language information, with 'locale' and 'name' keys
$this->languages = array();
foreach ($this->model->getConfig()->getLanguages() as $langcode => $locale) {
$this->languages[$langcode] = array('locale' => $locale);
$this->model->setLocale($langcode);
$this->languages[$langcode]['name'] = $this->model->getText('in_this_language');
$this->languages[$langcode]['lemma'] = Punic\Language::getName($langcode, $langcode);
}
}
/**
* Negotiate a MIME type according to the proposed format, the list of valid
* formats, and an optional proposed format.
* As a side effect, set the HTTP Vary header if a choice was made based on
* the Accept header.
* @param array $choices possible MIME types as strings
* @param string $accept HTTP Accept header value
* @param string $format proposed format
* @return string selected format, or null if negotiation failed
*/
protected function negotiateFormat($choices, $accept, $format)
{
if ($format) {
if (!in_array($format, $choices)) {
return null;
}
return $format;
}
// if there was no proposed format, negotiate a suitable format
header('Vary: Accept'); // inform caches that a decision was made based on Accept header
$best = $this->negotiator->getBest($accept, $choices);
return ($best !== null) ? $best->getValue() : null;
}
private function isSecure()
{
if ($protocol = filter_input(INPUT_SERVER, 'HTTP_X_FORWARDED_PROTO', FILTER_SANITIZE_FULL_SPECIAL_CHARS)) {
return \in_array(strtolower($protocol), ['https', 'on', 'ssl', '1'], true);
}
return filter_input(INPUT_SERVER, 'HTTPS', FILTER_SANITIZE_FULL_SPECIAL_CHARS) !== null;
}
private function guessBaseHref()
{
$script_name = filter_input(INPUT_SERVER, 'SCRIPT_NAME', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$script_filename = filter_input(INPUT_SERVER, 'SCRIPT_FILENAME', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$script_filename = realpath($script_filename); // resolve any symlinks (see #274)
$script_filename = str_replace("\\", "/", $script_filename); // fixing windows paths with \ (see #309)
$base_dir = __DIR__; // Absolute path to your installation, ex: /var/www/mywebsite
$base_dir = str_replace("\\", "/", $base_dir); // fixing windows paths with \ (see #309)
$doc_root = preg_replace("!{$script_name}$!", '', $script_filename);
$base_url = preg_replace("!^{$doc_root}!", '', $base_dir);
$base_url = str_replace('/src/controller', '/', $base_url);
$protocol = $this->isSecure() ? 'https' : 'http';
$port = filter_input(INPUT_SERVER, 'SERVER_PORT', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$disp_port = ($port == 80 || $port == 443) ? '' : ":$port";
$domain = filter_input(INPUT_SERVER, 'SERVER_NAME', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
return "$protocol://{$domain}{$disp_port}{$base_url}";
}
public function getBaseHref()
{
return ($this->model->getConfig()->getBaseHref() !== null) ? $this->model->getConfig()->getBaseHref() : $this->guessBaseHref();
}
/**
* Echos an error message when the request can't be fulfilled.
* @param string $code
* @param string $status
* @param string $message
*/
protected function returnError($code, $status, $message)
{
header("HTTP/1.0 $code $status");
header("Content-type: text/plain; charset=utf-8");
echo "$code $status : $message";
}
protected function notModified(Modifiable $modifiable = null)
{
$notModified = false;
if ($modifiable !== null && $modifiable->isUseModifiedDate()) {
$modifiedDate = $this->getModifiedDate($modifiable);
$notModified = $this->sendNotModifiedHeader($modifiedDate);
}
return $notModified;
}
/**
* Return the modified date.
*
* @param Modifiable $modifiable
* @return DateTime|null
*/
protected function getModifiedDate(Modifiable $modifiable = null)
{
$modified = null;
$modifiedDate = $modifiable !== null ? $modifiable->getModifiedDate() : null;
$gitModifiedDate = $this->getGitModifiedDate();
$configModifiedDate = $this->getConfigModifiedDate();
// max with an empty list raises an error and returns bool
if ($modifiedDate || $gitModifiedDate || $configModifiedDate) {
$modified = max($modifiedDate, $gitModifiedDate, $configModifiedDate);
}
return $modified;
}
/**
* Return the datetime of the latest commit, or null if git is not available or if the command failed
* to execute.
*
* @see https://stackoverflow.com/a/33986403
* @return DateTime|null
*/
protected function getGitModifiedDate()
{
$commitDate = null;
$cache = $this->model->getConfig()->getCache();
$cacheKey = "git:modified_date";
$gitCommand = 'git log -1 --date=iso --pretty=format:%cd';
if ($cache->isAvailable()) {
$commitDate = $cache->fetch($cacheKey);
if (!$commitDate) {
$commitDate = $this->executeGitModifiedDateCommand($gitCommand);
if ($commitDate) {
$cache->store($cacheKey, $commitDate, static::GIT_MODIFIED_CONFIG_TTL);
}
}
} else {
$commitDate = $this->executeGitModifiedDateCommand($gitCommand);
}
return $commitDate;
}
/**
* Execute the git command and return a parsed date time, or null if the command failed.
*
* @param string $gitCommand git command line that returns a formatted date time
* @return DateTime|null
*/
protected function executeGitModifiedDateCommand($gitCommand)
{
$commitDate = null;
$commandOutput = @exec($gitCommand);
if ($commandOutput) {
$commitDate = new \DateTime(trim($commandOutput));
$commitDate->setTimezone(new \DateTimeZone('UTC'));
}
return $commitDate;
}
/**
* Return the datetime of the modified time of the config file. This value is read in the GlobalConfig
* for every request, so we simply access that value and if not null, we will return a datetime. Otherwise,
* we return a null value.
*
* @see http://php.net/manual/en/function.filemtime.php
* @return DateTime|null
*/
protected function getConfigModifiedDate()
{
$dateTime = null;
$configModifiedTime = $this->model->getConfig()->getConfigModifiedTime();
if ($configModifiedTime !== null) {
$dateTime = (new DateTime())->setTimestamp($configModifiedTime);
}
return $dateTime;
}
/**
* If the $modifiedDate is a valid DateTime, and if the $_SERVER variable contains the right info, and
* if the $modifiedDate is not more recent than the latest value in $_SERVER, then this function sets the
* HTTP 304 not modified and returns true..
*
* If the $modifiedDate is still valid, then it sets the Last-Modified header, to be used by the browser for
* subsequent requests, and returns false.
*
* Otherwise, it returns false.
*
* @param DateTime|null $modifiedDate the last modified date to be compared against server's modified since information
* @return bool whether it sent the HTTP 304 not modified headers or not (useful for sending the response without
* further actions)
*/
protected function sendNotModifiedHeader($modifiedDate): bool
{
if ($modifiedDate) {
$ifModifiedSince = $this->getIfModifiedSince();
$this->sendHeader("Last-Modified: " . $modifiedDate->format('D, d M Y H:i:s \G\M\T'));
if ($ifModifiedSince !== null && $ifModifiedSince >= $modifiedDate) {
$this->sendHeader("HTTP/1.0 304 Not Modified");
return true;
}
}
return false;
}
/**
* @return DateTime|null a DateTime object if the value exists in the $_SERVER variable, null otherwise
*/
protected function getIfModifiedSince()
{
$ifModifiedSince = null;
$ifModSinceHeader = filter_input(INPUT_SERVER, 'HTTP_IF_MODIFIED_SINCE', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
if ($ifModSinceHeader) {
// example value set by a browser: "Mon, 11 May 2020 10:46:57 GMT"
$ifModifiedSince = new DateTime($ifModSinceHeader);
}
return $ifModifiedSince;
}
/**
* Sends HTTP headers. Simply calls PHP built-in header function. But being
* a function here, it can easily be tested/mocked.
*
* @param $header string header to be sent
*/
protected function sendHeader($header)
{
header($header);
}
}