xref: /dokuwiki/inc/ActionRouter.php (revision 8c7c53b0321a3cd3116b8d3b2ad27863a38dece7)
1<?php
2
3namespace dokuwiki;
4
5use dokuwiki\Extension\Event;
6use dokuwiki\Action\AbstractAction;
7use dokuwiki\Action\Exception\ActionDisabledException;
8use dokuwiki\Action\Exception\ActionException;
9use dokuwiki\Action\Exception\FatalException;
10use dokuwiki\Action\Exception\NoActionException;
11use dokuwiki\Action\Plugin;
12
13/**
14 * Class ActionRouter
15 * @package dokuwiki
16 */
17class ActionRouter
18{
19
20    /** @var  AbstractAction */
21    protected $action;
22
23    /** @var  ActionRouter */
24    protected static $instance;
25
26    /** @var int transition counter */
27    protected $transitions = 0;
28
29    /** maximum loop */
30    protected const MAX_TRANSITIONS = 5;
31
32    /** @var string[] the actions disabled in the configuration */
33    protected $disabled;
34
35    /**
36     * ActionRouter constructor. Singleton, thus protected!
37     *
38     * Sets up the correct action based on the $ACT global. Writes back
39     * the selected action to $ACT
40     */
41    protected function __construct() {
42        global $ACT;
43        global $conf;
44
45        $this->disabled = explode(',', $conf['disableactions']);
46        $this->disabled = array_map('trim', $this->disabled);
47
48        $ACT = act_clean($ACT);
49        $this->setupAction($ACT);
50        $ACT = $this->action->getActionName();
51    }
52
53    /**
54     * Get the singleton instance
55     *
56     * @param bool $reinit
57     * @return ActionRouter
58     */
59    public static function getInstance($reinit = false) {
60        if((!self::$instance instanceof \dokuwiki\ActionRouter) || $reinit) {
61            self::$instance = new ActionRouter();
62        }
63        return self::$instance;
64    }
65
66    /**
67     * Setup the given action
68     *
69     * Instantiates the right class, runs permission checks and pre-processing and
70     * sets $action
71     *
72     * @param string $actionname this is passed as a reference to $ACT, for plugin backward compatibility
73     * @triggers ACTION_ACT_PREPROCESS
74     */
75    protected function setupAction(&$actionname) {
76        $presetup = $actionname;
77
78        try {
79            // give plugins an opportunity to process the actionname
80            $evt = new Event('ACTION_ACT_PREPROCESS', $actionname);
81            if ($evt->advise_before()) {
82                $this->action = $this->loadAction($actionname);
83                $this->checkAction($this->action);
84                $this->action->preProcess();
85            } else {
86                // event said the action should be kept, assume action plugin will handle it later
87                $this->action = new Plugin($actionname);
88            }
89            $evt->advise_after();
90
91        } catch(ActionException $e) {
92            // we should have gotten a new action
93            $actionname = $e->getNewAction();
94
95            // this one should trigger a user message
96            if($e instanceof ActionDisabledException) {
97                msg('Action disabled: ' . hsc($presetup), -1);
98            }
99
100            // some actions may request the display of a message
101            if($e->displayToUser()) {
102                msg(hsc($e->getMessage()), -1);
103            }
104
105            // do setup for new action
106            $this->transitionAction($presetup, $actionname);
107
108        } catch(NoActionException $e) {
109            msg('Action unknown: ' . hsc($actionname), -1);
110            $actionname = 'show';
111            $this->transitionAction($presetup, $actionname);
112        } catch(\Exception $e) {
113            $this->handleFatalException($e);
114        }
115    }
116
117    /**
118     * Transitions from one action to another
119     *
120     * Basically just calls setupAction() again but does some checks before.
121     *
122     * @param string $from current action name
123     * @param string $to new action name
124     * @param null|ActionException $e any previous exception that caused the transition
125     */
126    protected function transitionAction($from, $to, $e = null) {
127        $this->transitions++;
128
129        // no infinite recursion
130        if($from == $to) {
131            $this->handleFatalException(new FatalException('Infinite loop in actions', 500, $e));
132        }
133
134        // larger loops will be caught here
135        if($this->transitions >= self::MAX_TRANSITIONS) {
136            $this->handleFatalException(new FatalException('Maximum action transitions reached', 500, $e));
137        }
138
139        // do the recursion
140        $this->setupAction($to);
141    }
142
143    /**
144     * Aborts all processing with a message
145     *
146     * When a FataException instanc is passed, the code is treated as Status code
147     *
148     * @param \Exception|FatalException $e
149     * @throws FatalException during unit testing
150     */
151    protected function handleFatalException(\Throwable $e) {
152        if($e instanceof FatalException) {
153            http_status($e->getCode());
154        } else {
155            http_status(500);
156        }
157        if(defined('DOKU_UNITTEST')) {
158            throw $e;
159        }
160        ErrorHandler::logException($e);
161        $msg = 'Something unforeseen has happened: ' . $e->getMessage();
162        nice_die(hsc($msg));
163    }
164
165    /**
166     * Load the given action
167     *
168     * This translates the given name to a class name by uppercasing the first letter.
169     * Underscores translate to camelcase names. For actions with underscores, the different
170     * parts are removed beginning from the end until a matching class is found. The instatiated
171     * Action will always have the full original action set as Name
172     *
173     * Example: 'export_raw' -> ExportRaw then 'export' -> 'Export'
174     *
175     * @param $actionname
176     * @return AbstractAction
177     * @throws NoActionException
178     */
179    public function loadAction($actionname) {
180        $actionname = strtolower($actionname); // FIXME is this needed here? should we run a cleanup somewhere else?
181        $parts = explode('_', $actionname);
182        while($parts !== []) {
183            $load = implode('_', $parts);
184            $class = 'dokuwiki\\Action\\' . str_replace('_', '', ucwords($load, '_'));
185            if(class_exists($class)) {
186                return new $class($actionname);
187            }
188            array_pop($parts);
189        }
190
191        throw new NoActionException();
192    }
193
194    /**
195     * Execute all the checks to see if this action can be executed
196     *
197     * @param AbstractAction $action
198     * @throws ActionDisabledException
199     * @throws ActionException
200     */
201    public function checkAction(AbstractAction $action) {
202        global $INFO;
203        global $ID;
204
205        if(in_array($action->getActionName(), $this->disabled)) {
206            throw new ActionDisabledException();
207        }
208
209        $action->checkPreconditions();
210
211        if(isset($INFO)) {
212            $perm = $INFO['perm'];
213        } else {
214            $perm = auth_quickaclcheck($ID);
215        }
216
217        if($perm < $action->minimumPermission()) {
218            throw new ActionException('denied');
219        }
220    }
221
222    /**
223     * Returns the action handling the current request
224     *
225     * @return AbstractAction
226     */
227    public function getAction() {
228        return $this->action;
229    }
230}
231