xref: /plugin/annotations/action.php (revision 7d2714c77fd8ba61fdbfa0765e160acc24014017)
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 $INFO;
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    = (string) ($_SERVER['REMOTE_USER'] ?? '');
141        $isAdmin = !empty($INFO['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        if ($action !== 'load' && !checkSecurityToken()) {
202            $this->sendError('Invalid security token.');
203            return;
204        }
205        $id = isset($payload['id']) ? cleanID((string) $payload['id']) : '';
206
207        if ($action === '' || $id === '') {
208            $this->sendError('Missing action or page id.');
209            return;
210        }
211
212        /** @var helper_plugin_annotations $helper */
213        $helper = $this->loadHelper('annotations', false);
214        if (!$helper) {
215            $this->sendError('Annotations helper unavailable.');
216            return;
217        }
218
219        // Gather facts once; pass them to the helper's permission methods.
220        global $USERINFO;
221        $user    = (string) ($_SERVER['REMOTE_USER'] ?? '');
222        $isAdmin = (bool) ($USERINFO['grps'] ?? false)
223            ? in_array('admin', (array) ($USERINFO['grps'] ?? []), true)
224            : false;
225        // also honour DokuWiki's own admin flag
226        if (!$isAdmin) {
227            global $INFO;
228            $isAdmin = !empty($INFO['isadmin']);
229        }
230        $aclLevel = auth_quickaclcheck($id);
231
232        // Route to the correct handler method.
233        switch ($action) {
234            case 'load':
235                $this->actionLoad($helper, $id, $aclLevel);
236                break;
237            case 'create':
238                $this->actionCreate($helper, $id, $payload, $user, $aclLevel);
239                break;
240            case 'reply':
241                $this->actionReply($helper, $id, $payload, $user, $aclLevel);
242                break;
243            case 'edit_annotation':
244                $this->actionEditAnnotation($helper, $id, $payload, $user, $isAdmin);
245                break;
246            case 'edit_reply':
247                $this->actionEditReply($helper, $id, $payload, $user, $isAdmin);
248                break;
249            case 'delete_annotation':
250                $this->actionDeleteAnnotation($helper, $id, $payload, $user, $isAdmin);
251                break;
252            case 'delete_reply':
253                $this->actionDeleteReply($helper, $id, $payload, $user, $isAdmin);
254                break;
255            case 'resolve':
256                $this->actionResolve($helper, $id, $payload, $user, $aclLevel);
257                break;
258            case 'clear_resolved':
259                $this->actionClearResolved($helper, $id, $isAdmin);
260                break;
261            case 'clear_orphaned':
262                $this->actionClearOrphaned($helper, $id, $isAdmin);
263                break;
264            default:
265                $this->sendError('Unknown action: ' . hsc($action));
266        }
267    }
268
269    // ------------------------------------------------------------------
270    //  Action handlers (one per supported action)
271    // ------------------------------------------------------------------
272
273    /**
274     * Create a new annotation.
275     *
276     * Payload: { action, id, anchor:{exact,prefix,suffix,start}, body }
277     *
278     * @param helper_plugin_annotations $helper
279     * @param string                    $id
280     * @param array                     $payload
281     * @param string                    $user
282     * @param int                       $aclLevel
283     */
284    protected function actionCreate($helper, $id, array $payload, $user, $aclLevel)
285    {
286        if (!$helper->canAnnotate($user, $aclLevel)) {
287            $this->sendError('Permission denied.');
288            return;
289        }
290        $anchor = isset($payload['anchor']) && is_array($payload['anchor'])
291            ? $payload['anchor']
292            : [];
293        $body = isset($payload['body']) ? (string) $payload['body'] : '';
294
295        $result = $helper->createAnnotation($id, $anchor, $user, $body);
296        if ($result === false) {
297            $this->sendError('Invalid annotation data.');
298            return;
299        }
300        $this->sendSuccess(['annotation' => $result]);
301    }
302
303    /**
304     * Add a reply to an existing annotation.
305     *
306     * Payload: { action, id, annId, body }
307     *
308     * @param helper_plugin_annotations $helper
309     * @param string                    $id
310     * @param array                     $payload
311     * @param string                    $user
312     * @param int                       $aclLevel
313     */
314    protected function actionReply($helper, $id, array $payload, $user, $aclLevel)
315    {
316        if (!$helper->canAnnotate($user, $aclLevel)) {
317            $this->sendError('Permission denied.');
318            return;
319        }
320        $annId = isset($payload['annId']) ? (string) $payload['annId'] : '';
321        $body  = isset($payload['body'])  ? (string) $payload['body']  : '';
322
323        if ($annId === '') {
324            $this->sendError('Missing annId.');
325            return;
326        }
327        $result = $helper->addReply($id, $annId, $user, $body);
328        if ($result === false) {
329            $this->sendError('Invalid reply data or annotation not found.');
330            return;
331        }
332        $this->sendSuccess(['reply' => $result]);
333    }
334
335    /**
336     * Edit an annotation's body text.
337     *
338     * Payload: { action, id, annId, body }
339     *
340     * @param helper_plugin_annotations $helper
341     * @param string                    $id
342     * @param array                     $payload
343     * @param string                    $user
344     * @param bool                      $isAdmin
345     */
346    protected function actionEditAnnotation($helper, $id, array $payload, $user, $isAdmin)
347    {
348        $annId = isset($payload['annId']) ? (string) $payload['annId'] : '';
349        $body  = isset($payload['body'])  ? (string) $payload['body']  : '';
350
351        if ($annId === '') {
352            $this->sendError('Missing annId.');
353            return;
354        }
355        $annotation = $helper->getAnnotation($id, $annId);
356        if ($annotation === null) {
357            $this->sendError('Annotation not found.');
358            return;
359        }
360        if (!$helper->canEditAnnotation($annotation, $user, $isAdmin)) {
361            $this->sendError('Permission denied.');
362            return;
363        }
364        $ok = $helper->updateAnnotationBody($id, $annId, $body);
365        if (!$ok) {
366            $this->sendError('Invalid body or annotation not found.');
367            return;
368        }
369        $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]);
370    }
371
372    /**
373     * Edit a reply's body text.
374     *
375     * Payload: { action, id, annId, replyId, body }
376     *
377     * @param helper_plugin_annotations $helper
378     * @param string                    $id
379     * @param array                     $payload
380     * @param string                    $user
381     * @param bool                      $isAdmin
382     */
383    protected function actionEditReply($helper, $id, array $payload, $user, $isAdmin)
384    {
385        $annId   = isset($payload['annId'])   ? (string) $payload['annId']   : '';
386        $replyId = isset($payload['replyId']) ? (string) $payload['replyId'] : '';
387        $body    = isset($payload['body'])    ? (string) $payload['body']    : '';
388
389        if ($annId === '' || $replyId === '') {
390            $this->sendError('Missing annId or replyId.');
391            return;
392        }
393        $annotation = $helper->getAnnotation($id, $annId);
394        if ($annotation === null) {
395            $this->sendError('Annotation not found.');
396            return;
397        }
398        // Find the reply to permission-check its author.
399        $reply = null;
400        foreach (($annotation['replies'] ?? []) as $r) {
401            if (($r['id'] ?? '') === $replyId) {
402                $reply = $r;
403                break;
404            }
405        }
406        if ($reply === null) {
407            $this->sendError('Reply not found.');
408            return;
409        }
410        if (!$helper->canEditReply($reply, $user, $isAdmin)) {
411            $this->sendError('Permission denied.');
412            return;
413        }
414        $ok = $helper->updateReply($id, $annId, $replyId, $body);
415        if (!$ok) {
416            $this->sendError('Invalid body or reply not found.');
417            return;
418        }
419        $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]);
420    }
421
422    /**
423     * Delete an annotation and all its replies.
424     *
425     * Payload: { action, id, annId }
426     *
427     * @param helper_plugin_annotations $helper
428     * @param string                    $id
429     * @param array                     $payload
430     * @param string                    $user
431     * @param bool                      $isAdmin
432     */
433    protected function actionDeleteAnnotation($helper, $id, array $payload, $user, $isAdmin)
434    {
435        $annId = isset($payload['annId']) ? (string) $payload['annId'] : '';
436
437        if ($annId === '') {
438            $this->sendError('Missing annId.');
439            return;
440        }
441        $annotation = $helper->getAnnotation($id, $annId);
442        if ($annotation === null) {
443            $this->sendError('Annotation not found.');
444            return;
445        }
446        if (!$helper->canEditAnnotation($annotation, $user, $isAdmin)) {
447            $this->sendError('Permission denied.');
448            return;
449        }
450        $ok = $helper->deleteAnnotation($id, $annId);
451        if (!$ok) {
452            $this->sendError('Delete failed.');
453            return;
454        }
455        $this->sendSuccess(['stats' => $helper->getStats($id)]);
456    }
457
458    /**
459     * Delete a reply.
460     *
461     * Payload: { action, id, annId, replyId }
462     *
463     * @param helper_plugin_annotations $helper
464     * @param string                    $id
465     * @param array                     $payload
466     * @param string                    $user
467     * @param bool                      $isAdmin
468     */
469    protected function actionDeleteReply($helper, $id, array $payload, $user, $isAdmin)
470    {
471        $annId   = isset($payload['annId'])   ? (string) $payload['annId']   : '';
472        $replyId = isset($payload['replyId']) ? (string) $payload['replyId'] : '';
473
474        if ($annId === '' || $replyId === '') {
475            $this->sendError('Missing annId or replyId.');
476            return;
477        }
478        $annotation = $helper->getAnnotation($id, $annId);
479        if ($annotation === null) {
480            $this->sendError('Annotation not found.');
481            return;
482        }
483        $reply = null;
484        foreach (($annotation['replies'] ?? []) as $r) {
485            if (($r['id'] ?? '') === $replyId) {
486                $reply = $r;
487                break;
488            }
489        }
490        if ($reply === null) {
491            $this->sendError('Reply not found.');
492            return;
493        }
494        if (!$helper->canEditReply($reply, $user, $isAdmin)) {
495            $this->sendError('Permission denied.');
496            return;
497        }
498        $ok = $helper->deleteReply($id, $annId, $replyId);
499        if (!$ok) {
500            $this->sendError('Delete failed.');
501            return;
502        }
503        $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]);
504    }
505
506    /**
507     * Resolve or reopen an annotation.
508     *
509     * Payload: { action, id, annId, status:"open"|"resolved" }
510     *
511     * @param helper_plugin_annotations $helper
512     * @param string                    $id
513     * @param array                     $payload
514     * @param string                    $user
515     * @param int                       $aclLevel
516     */
517    protected function actionResolve($helper, $id, array $payload, $user, $aclLevel)
518    {
519        if (!$helper->canAnnotate($user, $aclLevel)) {
520            $this->sendError('Permission denied.');
521            return;
522        }
523        $annId  = isset($payload['annId'])  ? (string) $payload['annId']  : '';
524        $status = isset($payload['status']) ? (string) $payload['status'] : '';
525
526        if ($annId === '') {
527            $this->sendError('Missing annId.');
528            return;
529        }
530        $ok = $helper->setStatus($id, $annId, $status, $user);
531        if (!$ok) {
532            $this->sendError('Invalid status or annotation not found.');
533            return;
534        }
535        $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]);
536    }
537
538    /**
539     * Remove all resolved annotations on the page. Admin only.
540     *
541     * Payload: { action, id }
542     *
543     * @param helper_plugin_annotations $helper
544     * @param string                    $id
545     * @param bool                      $isAdmin
546     */
547    protected function actionClearResolved($helper, $id, $isAdmin)
548    {
549        if (!$helper->canClear($isAdmin)) {
550            $this->sendError('Permission denied.');
551            return;
552        }
553        $count = $helper->clearResolved($id);
554        if ($count === false) {
555            $this->sendError('Clear failed.');
556            return;
557        }
558        $this->sendSuccess(['removed' => $count, 'stats' => $helper->getStats($id)]);
559    }
560
561    /**
562     * Remove all orphaned annotations on the page. Admin only.
563     *
564     * Payload: { action, id }
565     *
566     * @param helper_plugin_annotations $helper
567     * @param string                    $id
568     * @param bool                      $isAdmin
569     */
570    protected function actionClearOrphaned($helper, $id, $isAdmin)
571    {
572        if (!$helper->canClear($isAdmin)) {
573            $this->sendError('Permission denied.');
574            return;
575        }
576        $count = $helper->clearOrphaned($id);
577        if ($count === false) {
578            $this->sendError('Clear failed.');
579            return;
580        }
581        $this->sendSuccess(['removed' => $count, 'stats' => $helper->getStats($id)]);
582    }
583
584    // ------------------------------------------------------------------
585    //  Utilities
586    // ------------------------------------------------------------------
587
588    /**
589     * Whether the current user has the annotations_enabled preference on.
590     *
591     * If the usersettings plugin is absent the feature defaults to enabled.
592     * Public so templates and tests can call it directly.
593     *
594     * @return bool
595     */
596    public function isEnabledForUser()
597    {
598        /** @var helper_plugin_usersettings|null $us */
599        $us = plugin_load('helper', 'usersettings');
600        if (!$us) {
601            return true; // usersettings not installed — default on
602        }
603        $value = $us->getPreference('annotations_enabled');
604        // getPreference returns null when the toggle is not registered yet
605        // (e.g. very first page load before the event has fired).
606        return ($value === null) ? true : (bool) $value;
607    }
608
609    /**
610     * Parse the request body as JSON; also accepts form-encoded POSTs for
611     * simpler test scripts.
612     *
613     * @return array|null
614     */
615    protected function readPayload()
616    {
617        $ct = $_SERVER['CONTENT_TYPE'] ?? '';
618        if (strpos($ct, 'application/json') !== false) {
619            $raw  = file_get_contents('php://input');
620            $data = json_decode($raw, true);
621            return is_array($data) ? $data : null;
622        }
623        // For GET requests (load action), read from query string.
624        if ($_SERVER['REQUEST_METHOD'] === 'GET') {
625            return $_GET ? (array) $_GET : [];
626        }
627        // Fall back to form-encoded POST (useful for simple curl tests).
628        return $_POST ? (array) $_POST : [];
629    }
630
631    /**
632     * Return all annotations for a page (read-only, no token required).
633     *
634     * The ACL check is still enforced: only users with at least AUTH_READ
635     * on the page can read its annotations.
636     *
637     * @param helper_plugin_annotations $helper
638     * @param string                    $id
639     * @param int                       $aclLevel
640     */
641    protected function actionLoad($helper, $id, $aclLevel)
642    {
643        if ($aclLevel < AUTH_READ) {
644            $this->sendError('Permission denied.');
645            return;
646        }
647        $annotations = $helper->getAnnotations($id);
648        $this->sendSuccess(['annotations' => $annotations]);
649    }
650
651        /**
652     * Emit a JSON success response and exit.
653     *
654     * @param array $extra additional fields merged into the response
655     */
656    protected function sendSuccess(array $extra = [])
657    {
658        echo json_encode(array_merge(['success' => true], $extra), JSON_PRETTY_PRINT);
659    }
660
661    /**
662     * Emit a JSON error response and exit.
663     *
664     * @param string $message human-readable error
665     */
666    protected function sendError($message)
667    {
668        echo json_encode(['success' => false, 'error' => $message], JSON_PRETTY_PRINT);
669    }
670}
671