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