NatLibFi/Skosmos

View on GitHub
src/controller/Controller.php

Summary

Maintainability
A
35 mins
Test Coverage
D
69%
<?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);
    }
}