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