1 <?php
2 
3 namespace dokuwiki;
4 
5 use dokuwiki\Extension\Event;
6 use dokuwiki\Action\AbstractAction;
7 use dokuwiki\Action\Exception\ActionDisabledException;
8 use dokuwiki\Action\Exception\ActionException;
9 use dokuwiki\Action\Exception\FatalException;
10 use dokuwiki\Action\Exception\NoActionException;
11 use dokuwiki\Action\Plugin;
12 
13 /**
14  * Class ActionRouter
15  * @package dokuwiki
16  */
17 class 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