1<?php
2
3/**
4 * DokuWiki Plugin struct (Action Component)
5 *
6 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
7 * @author  Andreas Gohr, Michael Große <dokuwiki@cosmocode.de>
8 */
9
10use dokuwiki\Extension\ActionPlugin;
11use dokuwiki\Extension\EventHandler;
12use dokuwiki\Extension\Event;
13use dokuwiki\plugin\struct\meta\AccessTable;
14use dokuwiki\plugin\struct\meta\AccessTablePage;
15use dokuwiki\plugin\struct\meta\Assignments;
16use dokuwiki\plugin\struct\meta\Column;
17use dokuwiki\plugin\struct\meta\StructException;
18use dokuwiki\plugin\struct\meta\ValueValidator;
19
20/**
21 * Class action_plugin_struct_inline
22 *
23 * Handle inline editing
24 */
25class action_plugin_struct_inline extends ActionPlugin
26{
27    /** @var  AccessTablePage */
28    protected $schemadata;
29
30    /** @var  Column */
31    protected $column;
32
33    /** @var String */
34    protected $pid = '';
35
36    /** @var int */
37    protected $rid = 0;
38
39    /**
40     * Registers a callback function for a given event
41     *
42     * @param EventHandler $controller DokuWiki's event controller object
43     * @return void
44     */
45    public function register(EventHandler $controller)
46    {
47        $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'handleAjax');
48    }
49
50    /**
51     * @param Event $event
52     * @param $param
53     */
54    public function handleAjax(Event $event, $param)
55    {
56        $len = strlen('plugin_struct_inline_');
57        if (substr($event->data, 0, $len) != 'plugin_struct_inline_') return;
58        $event->preventDefault();
59        $event->stopPropagation();
60
61        if (substr($event->data, $len) == 'editor') {
62            $this->inlineEditor();
63        }
64
65        if (substr($event->data, $len) == 'save') {
66            try {
67                $this->inlineSave();
68            } catch (StructException $e) {
69                http_status(500);
70                header('Content-Type: text/plain; charset=utf-8');
71                echo $e->getMessage();
72            }
73        }
74
75        if (substr($event->data, $len) == 'cancel') {
76            $this->inlineCancel();
77        }
78    }
79
80    /**
81     * Creates the inline editor
82     */
83    protected function inlineEditor()
84    {
85        // silently fail when editing not possible
86        if (!$this->initFromInput()) return;
87        if (!$this->schemadata->getSchema()->isEditable()) return;
88        // only check page permissions for data with pid, skip for global data
89        if ($this->pid && auth_quickaclcheck($this->pid) < AUTH_EDIT) return;
90        if (checklock($this->pid)) return;
91
92        // lock page
93        lock($this->pid);
94
95        // output the editor
96        $value = $this->schemadata->getDataColumn($this->column);
97        $id = uniqid('struct__', false);
98        echo '<div class="field">';
99        echo '<label data-column="' . hsc($this->column->getFullQualifiedLabel()) . '" for="' . $id . '">';
100        echo '</label>';
101        echo '<span class="input">';
102        echo $value->getValueEditor('entry', $id);
103        echo '</span>';
104        $hint = $this->column->getType()->getTranslatedHint();
105        if ($hint) {
106            echo '<p class="hint">';
107            echo hsc($hint);
108            echo '</p>';
109        }
110        echo '</div>';
111
112        // csrf protection
113        formSecurityToken();
114    }
115
116    /**
117     * Save the data posted by the inline editor
118     */
119    protected function inlineSave()
120    {
121        global $INPUT;
122
123        // check preconditions
124        if (!$this->initFromInput()) {
125            throw new StructException('inline save error: init');
126        }
127        self::checkCSRF();
128        if (!$this->schemadata->getRid()) {
129            $this->checkPage();
130            $assignments = Assignments::getInstance();
131            $tables = $assignments->getPageAssignments($this->pid, true);
132            if (!in_array($this->schemadata->getSchema()->getTable(), $tables)) {
133                throw new StructException('inline save error: schema not assigned to page');
134            }
135        }
136        if (!$this->schemadata->getSchema()->isEditable()) {
137            throw new StructException('inline save error: no permission for schema');
138        }
139
140        // validate
141        $value = $INPUT->param('entry');
142        $validator = new ValueValidator();
143        if (!$validator->validateValue($this->column, $value)) {
144            throw new StructException(implode("\n", $validator->getErrors()));
145        }
146
147        // current data
148        $tosave = $this->schemadata->getDataArray();
149        $tosave[$this->column->getLabel()] = $value;
150
151        // save
152        if ($this->schemadata->getRid()) {
153            $revision = 0;
154        } else {
155            $revision = helper_plugin_struct::createPageRevision($this->pid, 'inline edit');
156            p_get_metadata($this->pid); // reparse the metadata of the page top update the titles/rev/lasteditor table
157        }
158        $this->schemadata->setTimestamp($revision);
159        try {
160            if (!$this->schemadata->saveData($tosave)) {
161                throw new StructException('saving failed');
162            }
163            if (!$this->schemadata->getRid()) {
164                // make sure this schema is assigned
165                /** @noinspection PhpUndefinedVariableInspection */
166                $assignments->assignPageSchema(
167                    $this->pid,
168                    $this->schemadata->getSchema()->getTable()
169                );
170            }
171        } catch (\Exception $e) {
172            // PHP <7 needs a catch block
173            throw $e;
174        } finally {
175            // unlock (unlocking a non-existing file is okay,
176            // so we don't check if it's a lookup here
177            unlock($this->pid);
178        }
179
180        // reinit then render
181        $this->initFromInput($this->schemadata->getTimestamp());
182        $value = $this->schemadata->getDataColumn($this->column);
183        $R = new Doku_Renderer_xhtml();
184        $value->render($R, 'xhtml'); // FIXME use configured default renderer
185        $data = json_encode(['value' => $R->doc, 'rev' => $this->schemadata->getTimestamp()], JSON_THROW_ON_ERROR);
186        echo $data;
187    }
188
189    /**
190     * Unlock a page (on cancel action)
191     */
192    protected function inlineCancel()
193    {
194        global $INPUT;
195        $pid = $INPUT->str('pid');
196        unlock($pid);
197    }
198
199    /**
200     * Initialize internal state based on input variables
201     *
202     * @param int $updatedRev timestamp of currently created revision, might be newer than input variable
203     * @return bool if initialization was successful
204     */
205    protected function initFromInput($updatedRev = 0)
206    {
207        global $INPUT;
208
209        $this->schemadata = null;
210        $this->column = null;
211
212        $pid = $INPUT->str('pid');
213        $rid = $INPUT->int('rid');
214        $rev = $updatedRev ?: $INPUT->int('rev');
215
216        [$table, $field] = explode('.', $INPUT->str('field'), 2);
217        if (blank($pid) && blank($rid)) return false;
218        if (blank($table)) return false;
219        if (blank($field)) return false;
220
221        $this->pid = $pid;
222        try {
223            if (AccessTable::isTypePage($pid, $rev)) {
224                $this->schemadata = AccessTable::getPageAccess($table, $pid);
225            } elseif (AccessTable::isTypeSerial($pid, $rev)) {
226                $this->schemadata = AccessTable::getSerialAccess($table, $pid, $rid);
227            } else {
228                $this->schemadata = AccessTable::getGlobalAccess($table, $rid);
229            }
230        } catch (StructException $ignore) {
231            return false;
232        }
233
234        $this->column = $this->schemadata->getSchema()->findColumn($field);
235        if (!$this->column || !$this->column->isVisibleInEditor()) {
236            $this->schemadata = null;
237            $this->column = null;
238            return false;
239        }
240
241        return true;
242    }
243
244    /**
245     * Checks if a page can be edited
246     *
247     * @throws StructException when check fails
248     */
249    protected function checkPage()
250    {
251        if (!page_exists($this->pid)) {
252            throw new StructException('inline save error: no such page');
253        }
254        if (auth_quickaclcheck($this->pid) < AUTH_EDIT) {
255            throw new StructException('inline save error: acl');
256        }
257        if (checklock($this->pid)) {
258            throw new StructException('inline save error: lock');
259        }
260    }
261
262    /**
263     * Our own implementation of checkSecurityToken because we don't want the msg() call
264     *
265     * @throws StructException when check fails
266     */
267    public static function checkCSRF()
268    {
269        global $INPUT;
270        if (
271            $INPUT->server->str('REMOTE_USER') &&
272            getSecurityToken() != $INPUT->str('sectok')
273        ) {
274            throw new StructException('CSRF check failed');
275        }
276    }
277}
278