xref: /plugin/annotations/action.php (revision ee9dbf1506bc8a2e17701b4e3c1bc1caf77e1561)
1<?php
2
3/**
4 * Annotations plugin — event registration and AJAX endpoint.
5 *
6 * Responsibilities:
7 *
8 *   1. Register a per-user "annotations_enabled" toggle via the usersettings
9 *      plugin's PLUGIN_USERSETTINGS_REGISTER event (BEFORE, so it fires when
10 *      the usersettings helper calls getRegisteredToggles()).
11 *
12 *   2. Push the current user's preference and the page's annotation stats
13 *      into JSINFO on every normal page view, so script.js can gate itself
14 *      and seed the counter without an extra round-trip.
15 *
16 *   3. Serve the AJAX endpoint at:
17 *        /lib/exe/ajax.php?call=annotations
18 *      POST body (application/json) carries { action, id, ... }.
19 *      All state-changing actions require a valid DokuWiki security token.
20 *      Every response is JSON: { success:true, ... } or { success:false, error:"..." }.
21 *
22 * Supported actions (all POST):
23 *   create          — body, anchor (object)
24 *   reply           — annId, body
25 *   edit_annotation — annId, body
26 *   edit_reply      — annId, replyId, body
27 *   delete_annotation — annId
28 *   delete_reply    — annId, replyId
29 *   resolve         — annId, status ("open"|"resolved")
30 *   clear_resolved  — (no extra fields)
31 *   clear_orphaned  — (no extra fields)
32 *
33 * Permission enforcement is done here; the helper's permission methods are
34 * called with facts gathered from the DokuWiki global state.
35 */
36
37// must be run within DokuWiki
38if (!defined('DOKU_INC')) die();
39
40class action_plugin_annotations extends DokuWiki_Action_Plugin
41{
42    // ------------------------------------------------------------------
43    //  Event registration
44    // ------------------------------------------------------------------
45
46    /**
47     * @param Doku_Event_Handler $controller
48     */
49    public function register(Doku_Event_Handler $controller)
50    {
51        // Register our toggle with the usersettings plugin.
52        $controller->register_hook(
53            'PLUGIN_USERSETTINGS_REGISTER',
54            'BEFORE',
55            $this,
56            'handleSettingsRegister'
57        );
58
59        // Inject annotation stats + user preference into JSINFO.
60        $controller->register_hook(
61            'TPL_METAHEADER_OUTPUT',
62            'BEFORE',
63            $this,
64            'handleMetaHeader'
65        );
66
67        // Handle the AJAX call.
68        $controller->register_hook(
69            'AJAX_CALL_UNKNOWN',
70            'BEFORE',
71            $this,
72            'handleAjax'
73        );
74    }
75
76    // ------------------------------------------------------------------
77    //  1. usersettings toggle registration
78    // ------------------------------------------------------------------
79
80    /**
81     * Append the annotations_enabled toggle definition to the event data.
82     *
83     * The event data is an array that the usersettings helper fires with
84     * createAndTrigger(); every handler appends its definition(s).
85     *
86     * @param Doku_Event $event PLUGIN_USERSETTINGS_REGISTER
87     * @param mixed       $param
88     */
89    public function handleSettingsRegister(Doku_Event $event, $param)
90    {
91        $event->data[] = [
92            'key'     => 'annotations_enabled',
93            'label'   => $this->getLang('toggle_label'),
94            'desc'    => $this->getLang('toggle_desc'),
95            'type'    => 'checkbox',
96            'default' => true,
97            'plugin'  => 'annotations',
98        ];
99    }
100
101    // ------------------------------------------------------------------
102    //  2. Inject into JSINFO
103    // ------------------------------------------------------------------
104
105    /**
106     * Add annotation stats and the user preference to JSINFO so script.js
107     * does not need an extra round-trip on page load.
108     *
109     * IMPORTANT: tpl_metaheaders() calls jsinfo() and then immediately
110     * JSON-encodes $JSINFO into an inline <script> string BEFORE firing
111     * TPL_METAHEADER_OUTPUT. Writing to $JSINFO here is therefore too late.
112     * Instead we locate that inline script block in $event->data and append
113     * a JSINFO.annotations = {...}; statement so it runs in the same scope.
114     *
115     * @param Doku_Event $event TPL_METAHEADER_OUTPUT
116     * @param mixed       $param
117     */
118    public function handleMetaHeader(Doku_Event $event, $param)
119    {
120        global $ID, $ACT;
121
122        // Only inject on normal page-view actions.
123        if (!in_array(act_clean($ACT), ['show', 'export_xhtml'], true)) {
124            return;
125        }
126
127        /** @var helper_plugin_annotations $helper */
128        $helper = $this->loadHelper('annotations', false);
129        if (!$helper) {
130            return;
131        }
132
133        global $INPUT;
134
135        $enabled = $this->isEnabledForUser();
136        $stats   = $helper->getStats($ID);
137
138        // DokuWiki's jsinfo() does not expose user identity, so we inject it
139        // here. JS uses these to gate the selection tooltip and permission UI.
140        $user    = $INPUT->server->str('REMOTE_USER');
141        $isAdmin = auth_isadmin();
142
143        $payload = json_encode([
144            'enabled' => $enabled,
145            'pageId'  => $ID,
146            'stats'   => $stats,
147            'user'    => $user,
148            'isAdmin' => $isAdmin,
149            'token'   => getSecurityToken(),  // CSRF token for AJAX POSTs
150        ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
151
152        // The inline script block containing "var JSINFO = ...;" is in
153        // $event->data['script']. Find it and append our assignment so it
154        // runs in the same scope after JSINFO is already declared.
155        if (!empty($event->data['script'])) {
156            foreach ($event->data['script'] as &$scriptTag) {
157                if (
158                    isset($scriptTag['_data']) &&
159                    strpos($scriptTag['_data'], 'var JSINFO') !== false
160                ) {
161                    $scriptTag['_data'] .= 'JSINFO.annotations=' . $payload . ';';
162                    break;
163                }
164            }
165            unset($scriptTag);
166        }
167    }
168
169    // ------------------------------------------------------------------
170    //  3. AJAX endpoint
171    // ------------------------------------------------------------------
172
173    /**
174     * Handle AJAX calls for the annotations plugin.
175     * Ignores calls not addressed to us.
176     *
177     * @param Doku_Event $event AJAX_CALL_UNKNOWN
178     * @param mixed       $param
179     */
180    public function handleAjax(Doku_Event $event, $param)
181    {
182        if ($event->data !== 'annotations') {
183            return;
184        }
185        $event->stopPropagation();
186        $event->preventDefault();
187
188        header('Content-Type: application/json; charset=utf-8');
189
190        // Parse JSON body; fall back to POST/GET fields for simple callers.
191        // The 'load' action is a GET request, so we accept query parameters too.
192        $payload = $this->readPayload();
193        if ($payload === null) {
194            $this->sendError('Invalid request body.');
195            return;
196        }
197
198        $action = isset($payload['action']) ? (string) $payload['action'] : '';
199        // For the read-only 'load' action, accept GET requests without a token.
200        // All state-changing actions require a valid DokuWiki security token.
201        // checkSecurityToken() reads from $_REQUEST (form fields), so when the
202        // request body is JSON we must inject the token from the parsed payload
203        // into $_POST / $_REQUEST before calling it.
204        if ($action !== 'load') {
205            // checkSecurityToken() accepts the token directly, so we hand it the
206            // value from the JSON body rather than poking it into $_REQUEST.
207            $jsonToken = isset($payload['sectok']) ? (string) $payload['sectok'] : '';
208            if (!checkSecurityToken($jsonToken)) {
209                $this->sendError('Invalid security token.');
210                return;
211            }
212        }
213        $id = isset($payload['id']) ? cleanID((string) $payload['id']) : '';
214
215        if ($action === '' || $id === '') {
216            $this->sendError('Missing action or page id.');
217            return;
218        }
219
220        /** @var helper_plugin_annotations $helper */
221        $helper = $this->loadHelper('annotations', false);
222        if (!$helper) {
223            $this->sendError('Annotations helper unavailable.');
224            return;
225        }
226
227        // Gather facts once; pass them to the helper's permission methods.
228        global $INPUT;
229        $user     = $INPUT->server->str('REMOTE_USER');
230        $isAdmin  = auth_isadmin();
231        $aclLevel = auth_quickaclcheck($id);
232
233        // Route to the correct handler method.
234        switch ($action) {
235            case 'load':
236                $this->actionLoad($helper, $id, $aclLevel);
237                break;
238            case 'create':
239                $this->actionCreate($helper, $id, $payload, $user, $aclLevel);
240                break;
241            case 'reply':
242                $this->actionReply($helper, $id, $payload, $user, $aclLevel);
243                break;
244            case 'edit_annotation':
245                $this->actionEditAnnotation($helper, $id, $payload, $user, $isAdmin);
246                break;
247            case 'edit_reply':
248                $this->actionEditReply($helper, $id, $payload, $user, $isAdmin);
249                break;
250            case 'delete_annotation':
251                $this->actionDeleteAnnotation($helper, $id, $payload, $user, $isAdmin);
252                break;
253            case 'delete_reply':
254                $this->actionDeleteReply($helper, $id, $payload, $user, $isAdmin);
255                break;
256            case 'resolve':
257                $this->actionResolve($helper, $id, $payload, $user, $aclLevel);
258                break;
259            case 'clear_resolved':
260                $this->actionClearResolved($helper, $id, $isAdmin);
261                break;
262            case 'clear_orphaned':
263                $this->actionClearOrphaned($helper, $id, $isAdmin);
264                break;
265            default:
266                $this->sendError('Unknown action: ' . hsc($action));
267        }
268    }
269
270    // ------------------------------------------------------------------
271    //  Action handlers (one per supported action)
272    // ------------------------------------------------------------------
273
274    /**
275     * Create a new annotation.
276     *
277     * Payload: { action, id, anchor:{exact,prefix,suffix,start}, body }
278     *
279     * @param helper_plugin_annotations $helper
280     * @param string                    $id
281     * @param array                     $payload
282     * @param string                    $user
283     * @param int                       $aclLevel
284     */
285    protected function actionCreate($helper, $id, array $payload, $user, $aclLevel)
286    {
287        if (!$helper->canAnnotate($user, $aclLevel)) {
288            $this->sendError('Permission denied.');
289            return;
290        }
291        $anchor = isset($payload['anchor']) && is_array($payload['anchor'])
292            ? $payload['anchor']
293            : [];
294        $body = isset($payload['body']) ? (string) $payload['body'] : '';
295
296        $result = $helper->createAnnotation($id, $anchor, $user, $body);
297        if ($result === false) {
298            $this->sendError('Invalid annotation data.');
299            return;
300        }
301        $this->sendSuccess(['annotation' => $result]);
302    }
303
304    /**
305     * Add a reply to an existing annotation.
306     *
307     * Payload: { action, id, annId, body }
308     *
309     * @param helper_plugin_annotations $helper
310     * @param string                    $id
311     * @param array                     $payload
312     * @param string                    $user
313     * @param int                       $aclLevel
314     */
315    protected function actionReply($helper, $id, array $payload, $user, $aclLevel)
316    {
317        if (!$helper->canAnnotate($user, $aclLevel)) {
318            $this->sendError('Permission denied.');
319            return;
320        }
321        $annId    = isset($payload['annId'])    ? (string) $payload['annId']    : '';
322        $body     = isset($payload['body'])     ? (string) $payload['body']     : '';
323        $parentId = isset($payload['parentId']) ? (string) $payload['parentId'] : '';
324
325        if ($annId === '') {
326            $this->sendError('Missing annId.');
327            return;
328        }
329        $result = $helper->addReply($id, $annId, $user, $body, $parentId);
330        if ($result === false) {
331            $this->sendError('Invalid reply data or annotation not found.');
332            return;
333        }
334        $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]);
335    }
336
337    /**
338     * Edit an annotation's body text.
339     *
340     * Payload: { action, id, annId, body }
341     *
342     * @param helper_plugin_annotations $helper
343     * @param string                    $id
344     * @param array                     $payload
345     * @param string                    $user
346     * @param bool                      $isAdmin
347     */
348    protected function actionEditAnnotation($helper, $id, array $payload, $user, $isAdmin)
349    {
350        $annId = isset($payload['annId']) ? (string) $payload['annId'] : '';
351        $body  = isset($payload['body'])  ? (string) $payload['body']  : '';
352
353        if ($annId === '') {
354            $this->sendError('Missing annId.');
355            return;
356        }
357        $annotation = $helper->getAnnotation($id, $annId);
358        if ($annotation === null) {
359            $this->sendError('Annotation not found.');
360            return;
361        }
362        if (!$helper->canEditAnnotation($annotation, $user, $isAdmin)) {
363            $this->sendError('Permission denied.');
364            return;
365        }
366        $ok = $helper->updateAnnotationBody($id, $annId, $body);
367        if (!$ok) {
368            $this->sendError('Invalid body or annotation not found.');
369            return;
370        }
371        $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]);
372    }
373
374    /**
375     * Edit a reply's body text.
376     *
377     * Payload: { action, id, annId, replyId, body }
378     *
379     * @param helper_plugin_annotations $helper
380     * @param string                    $id
381     * @param array                     $payload
382     * @param string                    $user
383     * @param bool                      $isAdmin
384     */
385    protected function actionEditReply($helper, $id, array $payload, $user, $isAdmin)
386    {
387        $annId   = isset($payload['annId'])   ? (string) $payload['annId']   : '';
388        $replyId = isset($payload['replyId']) ? (string) $payload['replyId'] : '';
389        $body    = isset($payload['body'])    ? (string) $payload['body']    : '';
390
391        if ($annId === '' || $replyId === '') {
392            $this->sendError('Missing annId or replyId.');
393            return;
394        }
395        $annotation = $helper->getAnnotation($id, $annId);
396        if ($annotation === null) {
397            $this->sendError('Annotation not found.');
398            return;
399        }
400        // Find the reply to permission-check its author.
401        $reply = null;
402        foreach (($annotation['replies'] ?? []) as $r) {
403            if (($r['id'] ?? '') === $replyId) {
404                $reply = $r;
405                break;
406            }
407        }
408        if ($reply === null) {
409            $this->sendError('Reply not found.');
410            return;
411        }
412        if (!$helper->canEditReply($reply, $user, $isAdmin)) {
413            $this->sendError('Permission denied.');
414            return;
415        }
416        $ok = $helper->updateReply($id, $annId, $replyId, $body);
417        if (!$ok) {
418            $this->sendError('Invalid body or reply not found.');
419            return;
420        }
421        $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]);
422    }
423
424    /**
425     * Delete an annotation and all its replies.
426     *
427     * Payload: { action, id, annId }
428     *
429     * @param helper_plugin_annotations $helper
430     * @param string                    $id
431     * @param array                     $payload
432     * @param string                    $user
433     * @param bool                      $isAdmin
434     */
435    protected function actionDeleteAnnotation($helper, $id, array $payload, $user, $isAdmin)
436    {
437        $annId = isset($payload['annId']) ? (string) $payload['annId'] : '';
438
439        if ($annId === '') {
440            $this->sendError('Missing annId.');
441            return;
442        }
443        $annotation = $helper->getAnnotation($id, $annId);
444        if ($annotation === null) {
445            $this->sendError('Annotation not found.');
446            return;
447        }
448        if (!$helper->canEditAnnotation($annotation, $user, $isAdmin)) {
449            $this->sendError('Permission denied.');
450            return;
451        }
452        $ok = $helper->deleteAnnotation($id, $annId);
453        if (!$ok) {
454            $this->sendError('Delete failed.');
455            return;
456        }
457        $this->sendSuccess(['stats' => $helper->getStats($id)]);
458    }
459
460    /**
461     * Delete a reply.
462     *
463     * Payload: { action, id, annId, replyId }
464     *
465     * @param helper_plugin_annotations $helper
466     * @param string                    $id
467     * @param array                     $payload
468     * @param string                    $user
469     * @param bool                      $isAdmin
470     */
471    protected function actionDeleteReply($helper, $id, array $payload, $user, $isAdmin)
472    {
473        $annId   = isset($payload['annId'])   ? (string) $payload['annId']   : '';
474        $replyId = isset($payload['replyId']) ? (string) $payload['replyId'] : '';
475
476        if ($annId === '' || $replyId === '') {
477            $this->sendError('Missing annId or replyId.');
478            return;
479        }
480        $annotation = $helper->getAnnotation($id, $annId);
481        if ($annotation === null) {
482            $this->sendError('Annotation not found.');
483            return;
484        }
485        $reply = null;
486        foreach (($annotation['replies'] ?? []) as $r) {
487            if (($r['id'] ?? '') === $replyId) {
488                $reply = $r;
489                break;
490            }
491        }
492        if ($reply === null) {
493            $this->sendError('Reply not found.');
494            return;
495        }
496        if (!$helper->canEditReply($reply, $user, $isAdmin)) {
497            $this->sendError('Permission denied.');
498            return;
499        }
500        $ok = $helper->deleteReply($id, $annId, $replyId);
501        if (!$ok) {
502            $this->sendError('Delete failed.');
503            return;
504        }
505        $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]);
506    }
507
508    /**
509     * Resolve or reopen an annotation.
510     *
511     * Payload: { action, id, annId, status:"open"|"resolved" }
512     *
513     * @param helper_plugin_annotations $helper
514     * @param string                    $id
515     * @param array                     $payload
516     * @param string                    $user
517     * @param int                       $aclLevel
518     */
519    protected function actionResolve($helper, $id, array $payload, $user, $aclLevel)
520    {
521        if (!$helper->canAnnotate($user, $aclLevel)) {
522            $this->sendError('Permission denied.');
523            return;
524        }
525        $annId  = isset($payload['annId'])  ? (string) $payload['annId']  : '';
526        $status = isset($payload['status']) ? (string) $payload['status'] : '';
527
528        if ($annId === '') {
529            $this->sendError('Missing annId.');
530            return;
531        }
532        $ok = $helper->setStatus($id, $annId, $status, $user);
533        if (!$ok) {
534            $this->sendError('Invalid status or annotation not found.');
535            return;
536        }
537        $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]);
538    }
539
540    /**
541     * Remove all resolved annotations on the page. Admin only.
542     *
543     * Payload: { action, id }
544     *
545     * @param helper_plugin_annotations $helper
546     * @param string                    $id
547     * @param bool                      $isAdmin
548     */
549    protected function actionClearResolved($helper, $id, $isAdmin)
550    {
551        if (!$helper->canClear($isAdmin)) {
552            $this->sendError('Permission denied.');
553            return;
554        }
555        $count = $helper->clearResolved($id);
556        if ($count === false) {
557            $this->sendError('Clear failed.');
558            return;
559        }
560        $this->sendSuccess(['removed' => $count, 'stats' => $helper->getStats($id)]);
561    }
562
563    /**
564     * Remove all orphaned annotations on the page. Admin only.
565     *
566     * Payload: { action, id }
567     *
568     * @param helper_plugin_annotations $helper
569     * @param string                    $id
570     * @param bool                      $isAdmin
571     */
572    protected function actionClearOrphaned($helper, $id, $isAdmin)
573    {
574        if (!$helper->canClear($isAdmin)) {
575            $this->sendError('Permission denied.');
576            return;
577        }
578        $count = $helper->clearOrphaned($id);
579        if ($count === false) {
580            $this->sendError('Clear failed.');
581            return;
582        }
583        $this->sendSuccess(['removed' => $count, 'stats' => $helper->getStats($id)]);
584    }
585
586    // ------------------------------------------------------------------
587    //  Utilities
588    // ------------------------------------------------------------------
589
590    /**
591     * Whether the current user has the annotations_enabled preference on.
592     *
593     * If the usersettings plugin is absent the feature defaults to enabled.
594     * Public so templates and tests can call it directly.
595     *
596     * @return bool
597     */
598    public function isEnabledForUser()
599    {
600        /** @var helper_plugin_usersettings|null $us */
601        $us = plugin_load('helper', 'usersettings');
602        if (!$us) {
603            return true; // usersettings not installed — default on
604        }
605        $value = $us->getPreference('annotations_enabled');
606        // getPreference returns null when the toggle is not registered yet
607        // (e.g. very first page load before the event has fired).
608        return ($value === null) ? true : (bool) $value;
609    }
610
611    /**
612     * Parse the request body as JSON; also accepts form-encoded POSTs for
613     * simpler test scripts.
614     *
615     * @return array|null
616     */
617    protected function readPayload()
618    {
619        global $INPUT;
620        $ct = $INPUT->server->str('CONTENT_TYPE');
621        if (strpos($ct, 'application/json') !== false) {
622            $data = json_decode(file_get_contents('php://input'), true);
623            return is_array($data) ? $data : null;
624        }
625        // The read-only 'load' action is a GET carrying action + id only.
626        if ($INPUT->server->str('REQUEST_METHOD') === 'GET') {
627            return [
628                'action' => $INPUT->get->str('action'),
629                'id'     => $INPUT->get->str('id'),
630            ];
631        }
632        // Form-encoded POST fallback (handy for simple curl tests).
633        return [
634            'action'  => $INPUT->post->str('action'),
635            'id'      => $INPUT->post->str('id'),
636            'sectok'  => $INPUT->post->str('sectok'),
637            'annId'   => $INPUT->post->str('annId'),
638            'replyId' => $INPUT->post->str('replyId'),
639            'body'    => $INPUT->post->str('body'),
640            'status'  => $INPUT->post->str('status'),
641        ];
642    }
643
644    /**
645     * Return all annotations for a page (read-only, no token required).
646     *
647     * The ACL check is still enforced: only users with at least AUTH_READ
648     * on the page can read its annotations.
649     *
650     * @param helper_plugin_annotations $helper
651     * @param string                    $id
652     * @param int                       $aclLevel
653     */
654    protected function actionLoad($helper, $id, $aclLevel)
655    {
656        if ($aclLevel < AUTH_READ) {
657            $this->sendError('Permission denied.');
658            return;
659        }
660        $annotations = $helper->getAnnotations($id);
661        $this->sendSuccess(['annotations' => $annotations]);
662    }
663
664    /**
665     * Emit a JSON success response. The caller has already prevented the
666     * default AJAX handling, so the request ends after this output.
667     *
668     * @param array $extra additional fields merged into the response
669     */
670    protected function sendSuccess(array $extra = [])
671    {
672        echo json_encode(array_merge(['success' => true], $extra), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
673    }
674
675    /**
676     * Emit a JSON error response.
677     *
678     * @param string $message human-readable error
679     */
680    protected function sendError($message)
681    {
682        echo json_encode(['success' => false, 'error' => $message], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
683    }
684}
685