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