Skip to content
Snippets Groups Projects
Forked from Stud.IP / Stud.IP
3732 commits behind the upstream repository.
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
studip_controller.php 25.48 KiB
<?php
/*
 * studip_controller.php - studip controller base class
 * Copyright (c) 2009  Elmar Ludwig
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License as
 * published by the Free Software Foundation; either version 2 of
 * the License, or (at your option) any later version.
 */

require_once __DIR__ . '/studip_response.php';

abstract class StudipController extends Trails_Controller
{
    protected $with_session = false; //do we need to have a session for this controller
    protected $allow_nobody = true; //should 'nobody' allowed for this controller or redirected to login?
    protected $_autobind = false;

    public function before_filter(&$action, &$args)
    {
        $this->current_action = $action;
        // allow only "word" characters in arguments
        $this->validate_args($args);

        parent::before_filter($action, $args);

        if ($this->with_session) {
            # open session
            page_open([
                'sess' => 'Seminar_Session',
                'auth' => $this->allow_nobody ? 'Seminar_Default_Auth' : 'Seminar_Auth',
                'perm' => 'Seminar_Perm',
                'user' => 'Seminar_User'
            ]);

            // show login-screen, if authentication is "nobody"
            $GLOBALS['auth']->login_if((Request::get('again') || !$this->allow_nobody) && $GLOBALS['user']->id == 'nobody');

            // Setup flash instance
            $this->flash = Trails_Flash::instance();

            // set up user session
            include 'lib/seminar_open.php';
        }

        // Set generic attribute that indicates whether the request was sent
        // via ajax or not
        $this->via_ajax = Request::isXhr();

        # Set base layout
        #
        # If your controller needs another layout, overwrite your controller's
        # before filter:
        #
        #   class YourController extends AuthenticatedController {
        #     function before_filter(&$action, &$args) {
        #       parent::before_filter($action, $args);
        #       $this->set_layout("your_layout");
        #     }
        #   }
        #
        # or unset layout by sending:
        #
        #   $this->set_layout(NULL)
        #
        $layout_file = Request::isXhr()
                     ? 'layouts/dialog.php'
                     : 'layouts/base.php';
        $layout = $GLOBALS['template_factory']->open($layout_file);
        $this->set_layout($layout);

        $this->set_content_type('text/html;charset=utf-8');
    }

    /**
     * Extended method to inject extended response object.
     */
    public function erase_response()
    {
        parent::erase_response();

        $this->response = new StudipResponse();
    }

    /**
     * Hooked perform method in order to inject body element id creation.
     *
     * In order to avoid clashes, these body element id will be joined
     * with a minus sign. Otherwise the controller "x" with action
     * "y_z" would be given the same id as the controller "x/y" with
     * the action "z", namely "x_y_z". With the minus sign this will
     * result in the ids "x-y_z" and "x_y-z".
     *
     * Plugins will always have a leading 'plugin-' and the decamelized
     * plugin name in front of the id.
     *
     * @param String $unconsumed_path Path segment containing action and
     *                                optionally arguments or format
     * @return Trails_Response from parent controller
     */
    public function perform($unconsumed_path)
    {
        // Set body element id if it has not already been set
        if (!PageLayout::hasBodyElementId()) {
            $body_id = $this->getBodyElementIdForControllerAndAction($unconsumed_path);
            PageLayout::setBodyElementId($body_id);
        }

        return parent::perform($unconsumed_path);
    }

    /**
     * Callback function being called after an action is executed.
     *
     * @param string Name of the action to perform.
     * @param array  An array of arguments to the action.
     *
     * @return void
     */
    public function after_filter($action, $args)
    {
        parent::after_filter($action, $args);

        if (Request::isXhr() && !isset($this->response->headers['X-Title']) && PageLayout::hasTitle()) {
            $this->response->add_header('X-Title', rawurlencode(PageLayout::getTitle()));
        }

        if ($this->with_session) {
            page_close();
        }
    }

    /**
     * Validate arguments based on a list of given types. The types are:
     * 'int', 'float', 'option'. If the list of types is NULL
     * or shorter than the argument list, 'option' is assumed for all
     * remaining arguments. 'option' differs from Request::option() in
     * that it also accepts the charaters '-' and ',' in addition to all
     * word charaters.
     *
     * Since Stud.IP 4.0 it is also possible to directly inject
     * SimpleORMap objects. If types is NULL, the signature of the called
     * action is analyzed and any type hint that matches a sorm class
     * will be used to create an object using the argument as the id
     * that is passed to the object's constructor.
     *
     * If $_autobind is set to true, the created object is also assigned
     * to the controller so that it is available in a view.
     *
     * @param array   an array of arguments to the action
     * @param array   list of argument types (optional)
     */
    public function validate_args(&$args, $types = null)
    {
        $class_infos = [];

        if ($types === null) {
            $types = array_fill(0, count($args), 'option');
        }

        if ($this->has_action($this->current_action)) {
            $reflection = new ReflectionMethod($this, $this->current_action . '_action');
            $parameters = $reflection->getParameters();
            foreach ($parameters as $i => $parameter) {
                $class_type = $parameter->getClass();

                if (!$class_type
                    || !class_exists($class_type->name)
                    || !is_a($class_type->name, SimpleORMap::class, true))
                {
                    continue;
                }

                $types[$i] = 'sorm';
                $class_infos[$i] = [
                    'model'    => $class_type->name,
                    'var'      => $parameter->getName(),
                    'optional' => $parameter->isOptional(),
                ];

                if ($parameter->isOptional() && !isset($args[$i])) {
                    $args[$i] = $parameter->getDefaultValue();
                }
            }
        }

        foreach ($args as $i => &$arg) {
            $type = $types[$i] ?: 'option';
            switch ($type) {
                case 'int':
                    $arg = (int) $arg;
                    break;

                case 'float':
                    $arg = (float) strtr($arg, ',', '.');
                    break;

                case 'option':
                    if (preg_match('/[^\\w,-]/', $arg)) {
                        throw new Trails_Exception(400);
                    }
                    break;

                case 'sorm':
                    $info = $class_infos[$i];

                    $id = null;
                    if ($arg != -1) {
                        $id = $arg;
                    }
                    if (mb_strpos($id, SimpleORMap::ID_SEPARATOR) !== false) {
                        $id = explode(SimpleORMap::ID_SEPARATOR, $id);
                    }

                    $reflection = new ReflectionClass($info['model']);

                    $sorm = $reflection->newInstance($id);
                    if (!$info['optional'] && $sorm->isNew()) {
                        throw new Trails_Exception(
                            404,
                            "Parameter {$info['var']} could not be resolved with value {$arg}"
                        );
                    }

                    $arg = $sorm;
                    if ($this->_autobind) {
                        $this->{$info['var']} =& $arg;
                    }
                    break;

                default:
                    throw new Trails_Exception(500, 'Unknown type "' . $type . '"');
            }
        }

        reset($args);
    }

    /**
     * Returns a URL to a specified route to your Trails application.
     * without first parameter the current action is used
     * if route begins with a / then the current controller ist prepended
     * if second parameter is an array it is passed to URLHeper
     *
     * @param  string   a string containing a controller and optionally an action
     * @param  string[] optional arguments
     *
     * @return string  a URL to this route
     */
    public function url_for($to = ''/* , ... */)
    {
        $args = func_get_args();

        // Create url for a specific action
        // TODO: This seems odd. You kinda specify an absolute path
        //       to receive a relative url. Meh...
        //
        // @deprecated Do not use this, please!
        if ($to[0] === '/') {
            $args[0] = substr($to, 1);
            return $this->action_url(...$args);
        }

        // Check for absolute URL
        if ($this->isURL($to)) {
            throw new InvalidArgumentException(__METHOD__ . ' cannot be used with absolute URLs');
        }

        // Extract parameters (if any)
        $params = [];
        if (is_array(end($args))) {
            $params = array_pop($args);
        }

        // Map any sorm objects to their ids
        $args = array_map(function ($arg) {
            if (is_object($arg) && $arg instanceof SimpleORMap) {
                return $arg->isNew() ? -1 : $arg->id;
            }
            return $arg;
        }, $args);

        // Combine arguments to new $to string
        $to = implode('/', $args);

        //preserve fragment
        [$to, $fragment] = explode('#', $to);

        // Try to create route if none given
        if (!$to) {
            $to  = '/';
            $to .= $this->parent_controller
                 ? $this->parent_controller->current_action
                 : $this->current_action;
        }

        $url = parent::url_for($to);

        if ($fragment) {
            $url .= '#' . $fragment;
        }
        return URLHelper::getURL($url, $params);
    }

    /**
     * Returns an escaped URL to a specified route to your Trails application.
     * without first parameter the current action is used
     * if route begins with a / then the current controller ist prepended
     * if second parameter is an array it is passed to URLHeper
     *
     * @param  string   a string containing a controller and optionally an action
     * @param  strings  optional arguments
     *
     * @return string  a URL to this route
     */
    public function link_for($to = ''/* , ... */)
    {
        return htmlReady($this->url_for(...func_get_args()));
    }

    /**
     * Redirects the user another page. Accepts multiple parameters just like
     * url_for().
     *
     * @param string $to
     * @see StudipController::url_for()
     */
    public function redirect($to)
    {
        $to = $this->adjustToArguments(...func_get_args());

        parent::redirect($to);
    }

    /**
     * Relocate the user to another location. This is a specialized version
     * of redirect that differs in two points:
     *
     * - relocate() will force the browser to leave the current dialog while
     *   redirect would refresh the dialog's contents
     * - relocate() accepts all the parameters that url_for() accepts so it's
     *   no longer neccessary to chain url_for() and redirect()
     *
     * @param String $to Location to redirect to
     */
    public function relocate($to)
    {
        $to = $this->adjustToArguments(...func_get_args());
        if (Request::isDialog()) {
            $this->response->add_header('X-Location', rawurlencode($to));
            $this->render_nothing();
        } else {
            parent::redirect($to);
        }
    }

    /**
     * Returns a URL to a specified route to your Trails application, unless
     * the parameter is already a valid URL (which is returned unchanged).
     *
     * If no absolute url or more than one argument is given, url_for() is
     * used.
     */
    private function adjustToArguments(...$args): string
    {
        if ($this->isURL($args[0]) && count($args) > 1) {
            throw new InvalidArgumentException('Method may not be used with a URL and multiple parameters');
        }

        if (count($args) > 1 || !$this->isURL($args[0])) {
            return $this->url_for(...$args);
        }

        return $args[0];
    }

    /**
     * Returns whether the given parameter is a valid url.
     *
     * @param string $to
     * @return bool
     */
    private function isURL(string $to): bool
    {
        return preg_match('#^(/|\w+://)#', $to);
    }

    /**
     * Exception handler called when the performance of an action raises an
     * exception.
     *
     * @param  object     the thrown exception
     */
    public function rescue($exception)
    {
        throw $exception;
    }

    /**
     * render given data as json, data is converted to utf-8
     *
     * @param mixed $data
     */
    public function render_json($data)
    {
        $this->set_content_type('application/json;charset=utf-8');
        return $this->render_text(json_encode($data));
    }

    /**
     * Render given data as csv, data is assumed to be utf-8.
     * The first row of data may contain column titles.
     *
     * @param array $data       data as two dimensional array
     * @param string $filename  download file name (optional)
     * @param string $delimiter field delimiter char (optional)
     * @param string $enclosure field enclosure char (optional)
     */
    public function render_csv($data, $filename = null, $delimiter = ';', $enclosure = '"')
    {
        $this->set_content_type('text/csv; charset=UTF-8');

        $output = fopen('php://temp', 'rw');
        fputs($output, "\xEF\xBB\xBF");

        foreach ($data as $row) {
            fputcsv($output, $row, $delimiter, $enclosure);
        }

        rewind($output);
        $csv_data = stream_get_contents($output);
        fclose($output);

        if (isset($filename)) {
            $this->response->add_header('Content-Disposition', 'attachment; ' . encode_header_parameter('filename', $filename));
        }

        $this->response->add_header('Content-Length', strlen($csv_data));

        return $this->render_text($csv_data);
    }

    /**
     * Renders a pdf file given by a TCPDF/ExportPDF object.
     *
     * @param TCPDF   $pdf      TCPDF object to render
     * @param string  $filename Filename
     * @param bool    $inline   Should the pdf be displayed inline (default: no)
     */
    protected function render_pdf(TCPDF $pdf, $filename, $inline = false)
    {
        $temp_file = $GLOBALS['TMP_PATH'] . '/' . md5(uniqid('pdf-file', true));
        $pdf->Output($temp_file, 'F');

        $disposition = $inline ? 'inline' : 'attachment';

        $this->render_temporary_file($temp_file, $filename, 'application/pdf', $disposition);
    }

    /**
     * Renders a file
     * @param string  $file                Path of the file to render
     * @param string  $filename            Name of the file displayed to user
     *                                     (will equal $file when missing)
     * @param string  $content_type        Optional content type (will be determined if missing)
     * @param string  $content_disposition Either attachment (default) or inline
     * @param Closure $callback            Optional callback when download has finished
     * @param int     $chunk_size          Optional size of chunks to send (default: 256k)
     */
    public function render_file(
        $file,
        $filename = null,
        $content_type = null,
        $content_disposition = 'attachment',
        Closure $callback = null,
        $chunk_size = 262144
    ) {
        if (!file_exists($file)) {
            throw new Trails_Exception(404);
        }

        if (!is_readable($file)) {
            throw new Trails_Exception(500);
        }

        if ($content_type === null) {
            $finfo = finfo_open(FILEINFO_MIME_TYPE);
            $content_type = finfo_file($finfo, $file);
        }

        $this->set_content_type($content_type);
        $this->response->add_header(
            'Content-Disposition',
            "{$content_disposition}; " . encode_header_parameter(
                'filename',
                FileManager::cleanFileName($filename ?: basename($file))
            )
        );
        $this->response->add_header('Content-Length', filesize($file));
        $this->response->add_header('Content-Transfer-Encoding',  'binary');
        $this->response->add_header('Pragma', 'public');
        $this->render_text(function () use ($file, $chunk_size, $callback) {
            $fp = fopen($file, 'rb');

            while (!feof($fp)) {
                yield fgets($fp, $chunk_size);
            }

            fclose($fp);

            if ($callback) {
                $callback($file);
            }
        });
    }

    /**
     * Renders a temporary file which will be deleted after transmission.
     * This is just a convenience method so you don't have to write the delete
     * callback.
     *
     * @param string  $file                Path of the file to render
     * @param string  $filename            Name of the file displayed to user
     *                                     (will equal $file when missing)
     * @param string  $content_type        Optional content type (will be determined if missing)
     * @param string  $content_disposition Either attachment (default) or inline
     * @param Closure $callback            Optional callback when download has finished
     * @param int     $chunk_size          Optional size of chunks to send (default: 256k)
     */
    public function render_temporary_file(
        $file,
        $filename = null,
        $content_type = null,
        $content_disposition = 'attachment',
        Closure $callback = null,
        $chunk_size = 262144

    ) {
        $delete_callback = function ($file) use ($callback) {
            unlink($file);

            if ($callback) {
                $callback($file);
            }
        };

        $this->render_file(
            $file,
            $filename,
            $content_type,
            $content_disposition,
            $delete_callback,
            $chunk_size
        );
    }

    /**
     * relays current request to another controller and returns the response
     * the other controller is given all assigned properties, additional parameters are passed
     * through
     *
     * @param string $to_uri a trails route
     * @return Trails_Response
     */
    public function relay($to_uri/* , ... */)
    {
        $args = func_get_args();
        $uri = array_shift($args);
        [$controller_path, $unconsumed] = '' === $uri ? $this->dispatcher->default_route() : $this->dispatcher->parse($uri);

        $controller = $this->dispatcher->load_controller($controller_path);
        $assigns = $this->get_assigned_variables();
        unset($assigns['controller']);
        foreach ($assigns as $k => $v) {
            $controller->$k = $v;
        }
        $controller->layout = null;
        $controller->parent_controller = $this;
        array_unshift($args, $unconsumed);
        return $controller->perform_relayed(...$args);
    }

    /**
     * perform a given action/parameter string from an relayed request
     * before_filter and after_filter methods are not called
     *
     * @see perform
     * @param string $unconsumed
     * @return Trails_Response
     */
    public function perform_relayed($unconsumed/* , ... */)
    {
        $args = func_get_args();
        $unconsumed = array_shift($args);

        [$action, $extracted_args, $format] = $this->extract_action_and_args($unconsumed);
        $this->format = isset($format) ? $format : 'html';
        $this->current_action = $action;
        $args = array_merge($extracted_args, $args);
        $callable = $this->map_action($action);

        if (is_callable($callable)) {
            $callable(...$args);
        } else {
            $this->does_not_understand($action, $args);
        }

        if (!$this->performed) {
            $this->render_action($action);
        }
        return $this->response;
    }

    /**
     * Renders a given template and returns the resulting string.
     *
     * @param string $template Name of the template file
     * @param mixed  $layout   Optional layout
     * @return string
     */
    public function render_template_as_string($template, $layout = null)
    {
        $template = $this->get_template_factory()->open($template);
        $template->set_layout($layout);
        $template->set_attributes($this->get_assigned_variables());
        return $template->render();
    }
    /**
     * Magic methods that intercepts all unknown method calls.
     * If a method is called that matches an action on this controller,
     * an url to that action is generated.
     *
     * Basically, this:
     *
     *    <code>$controller->url_for('foo/bar/baz/' . $param)</code>
     *
     * is equal to calling this on the Foo_BarController:
     *
     *    <code>$controller->baz($param)</code>
     *
     * @param String $method    Called method name
     * @param array  $argumetns Provided arguments
     * @return url to the requested action
     * @throws Trails_UnknownAction if no action matches the method
     */
    public function __call($method, $arguments)
    {
        $function = 'action_link';
        if (mb_strpos($method, 'Link') === mb_strlen($method) - 4) {
            $method = mb_substr($method, 0, -4);
        } elseif (mb_strpos($method, 'URL') === mb_strlen($method) - 3) {
            $function = 'action_url';
            $method = mb_substr($method, 0, -3);
        }

        if (!$this->has_action($method)) {
            throw new Trails_UnknownAction("Unknown action '{$method}'");
        }

        array_unshift($arguments, $method);
        return call_user_func_array([$this, $function], $arguments);
    }

    /**
     * Returns whether this controller has the specificed action.
     *
     * @param string $action Name of the action
     * @return true if action is defined, false otherwise
     */
    public function has_action($action)
    {
        return method_exists($this, $action . '_action')
            || ($this->parent_controller
                && $this->parent_controller->has_action($action));
    }

    /**
     * Generates the url for an action on this controller without the
     * neccessity to provide the full "path" to the action (since it
     * is implicitely known).
     *
     * Basically, this:
     *
     *    <code>$controller->url_for('foo/bar/baz/' . $param)</code>
     *
     * is equal to calling this on the Foo_BarController:
     *
     *    <code>$controller->action_url('baz/' . $param)</code>
     *
     * @param string $action Name of the action
     * @return string url to the requested action
     */
    public function action_url($action)
    {
        $arguments = func_get_args();
        array_unshift($arguments, $this->controller_path());
        return $this->url_for(...$arguments);
    }

    /**
     * Generates the link for an action on this controller without the
     * neccessity to provide the full "path" to the action (since it
     * is implicitely known).
     *
     * Basically, this:
     *
     *    <code>$controller->link_for('foo/bar/baz/' . $param)</code>
     *
     * is equal to calling this on the Foo_BarController:
     *
     *    <code>$controller->action_link('baz/' . $param)</code>
     *
     * @param string $action Name of the action
     * @return string to the requested action
     */
    public function action_link($action)
    {
        $arguments = func_get_args();
        array_unshift($arguments, $this->controller_path());

        return $this->link_for(...$arguments);
    }

    /**
     * Returns the url path to this controller.
     *
     * @return string url path to this controller
     */
    protected function controller_path()
    {
        $class = get_class($this->parent_controller ?: $this);
        $controller = mb_substr($class, 0, -mb_strlen('Controller'));
        $controller = strtosnakecase($controller);
        return preg_replace('/_{2,}/', '/', $controller);
    }


    /**
     * Validate the datetime according to specific format.
     *
     * @param string $datetime the datetime which should be validate
     * @param string $format the format that the datetime should have by default H:i for time
     *
     * @return bool result of validation
     */
    public function validate_datetime($datetime, $format = 'H:i')
    {
        $dt = DateTime::createFromFormat($format, $datetime);
        return $dt && $dt->format($format) == date('H:i',strtotime($datetime));
    }

    /**
     * Creates the body element id for this controller a given action.
     *
     * @param string $unconsumed_path Unconsumed path to extract action from
     * @return string
     */
    protected function getBodyElementIdForControllerAndAction($unconsumed_path)
    {
        // Extract action from unconsumed path segment
        [$action] = $this->extract_action_and_args($unconsumed_path);

        // Extract controller name from class name
        $controller = preg_replace('/Controller$/', '', get_class($this));
        $controller = Trails_Inflector::underscore($controller);
        // Build main parts of the body element id
        $body_id_parts = explode('/', $controller);
        $body_id_parts[] = $action;

        // Create and set body element id
        $body_id = implode('-', $body_id_parts);

        return $body_id;
    }
}