xref: /plugin/annotations/action.php (revision 86c3281af18ac3f82557b4466adb5aef413e1639)
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     * Fallback for the largest serialized annotation list (bytes) embedded
44     * inline in the page when the config value is unreadable. Below this, the
45     * list ships with the page so script.js renders without a second
46     * bootstrapped AJAX round-trip; above it, the client falls back to the GET
47     * 'load' endpoint so a heavily-annotated page can't bloat every view. The
48     * live value is the 'embed_max_bytes' config setting.
49     */
50    const DEFAULT_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            'contextLen' => max(0, (int) $this->getConf('context_length')),
168        ];
169
170        // Inject the configurable highlight colours as CSS custom properties so
171        // style.css can derive every opacity variant from one hex per state.
172        $this->injectColourVars($event);
173
174        // Embed the full list only when the feature is on for this user and the
175        // serialized list is small enough; otherwise script.js fetches it via
176        // the GET 'load' endpoint. The inline JSINFO script is regenerated every
177        // request (it is not part of the parser page cache), so this stays fresh.
178        if ($enabled) {
179            $embedMax = (int) $this->getConf('embed_max_bytes');
180            if ($embedMax <= 0) {
181                $embedMax = self::DEFAULT_EMBED_MAX_BYTES;
182            }
183            $listJson = json_encode($annotations, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
184            if ($listJson !== false && strlen($listJson) <= $embedMax) {
185                $data['annotations'] = $annotations;
186            }
187        }
188
189        // JSON_HEX_TAG escapes < and > to < / >. This payload is
190        // appended inside the page's inline <script> (below), so a body
191        // containing "</script>" would otherwise close the script element and
192        // inject arbitrary HTML — a stored XSS reachable by anyone who can
193        // annotate. HEX_TAG neutralises every tag-based breakout.
194        $payload = json_encode($data, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
195
196        // The inline script block containing "var JSINFO = ...;" is in
197        // $event->data['script']. Find it and append our assignment so it
198        // runs in the same scope after JSINFO is already declared.
199        if (!empty($event->data['script'])) {
200            foreach ($event->data['script'] as &$scriptTag) {
201                if (
202                    isset($scriptTag['_data']) &&
203                    strpos($scriptTag['_data'], 'var JSINFO') !== false
204                ) {
205                    $scriptTag['_data'] .= 'JSINFO.annotations=' . $payload . ';';
206                    break;
207                }
208            }
209            unset($scriptTag);
210        }
211    }
212
213    /**
214     * Append a <style> metaheader declaring the two configurable highlight
215     * colours as CSS custom properties (--ann-open-rgb / --ann-resolved-rgb,
216     * each an "r,g,b" channel triplet). style.css consumes them via
217     * rgba(var(--ann-open-rgb), <alpha>) so a single hex per state drives every
218     * fill/border/marker/pill tint. style.css also ships :root fallbacks, so an
219     * unreadable colour just keeps the built-in palette.
220     *
221     * @param Doku_Event $event TPL_METAHEADER_OUTPUT
222     */
223    protected function injectColourVars(Doku_Event $event)
224    {
225        $open     = $this->hexToRgb($this->getConf('color_open'), '245,158,11');
226        $resolved = $this->hexToRgb($this->getConf('color_resolved'), '74,222,128');
227        $css = ':root{--ann-open-rgb:' . $open . ';--ann-resolved-rgb:' . $resolved . ';}';
228        $event->data['style'][] = ['type' => 'text/css', '_data' => $css];
229    }
230
231    /**
232     * Convert a #rrggbb hex colour to an "r,g,b" channel triplet, returning the
233     * supplied fallback for anything that is not a valid 6-digit hex colour.
234     *
235     * @param mixed  $hex
236     * @param string $fallback "r,g,b" used when $hex is invalid
237     * @return string
238     */
239    protected function hexToRgb($hex, $fallback)
240    {
241        if (is_string($hex) && preg_match('/^#([0-9a-fA-F]{6})$/', $hex, $m)) {
242            $int = hexdec($m[1]);
243            return (($int >> 16) & 255) . ',' . (($int >> 8) & 255) . ',' . ($int & 255);
244        }
245        return $fallback;
246    }
247
248    // ------------------------------------------------------------------
249    //  3. AJAX endpoint
250    // ------------------------------------------------------------------
251
252    /**
253     * Handle AJAX calls for the annotations plugin.
254     * Ignores calls not addressed to us.
255     *
256     * @param Doku_Event $event AJAX_CALL_UNKNOWN
257     * @param mixed       $param
258     */
259    public function handleAjax(Doku_Event $event, $param)
260    {
261        if ($event->data !== 'annotations') {
262            return;
263        }
264        $event->stopPropagation();
265        $event->preventDefault();
266
267        header('Content-Type: application/json; charset=utf-8');
268
269        // Parse JSON body; fall back to POST/GET fields for simple callers.
270        // The 'load' action is a GET request, so we accept query parameters too.
271        $payload = $this->readPayload();
272        if ($payload === null) {
273            $this->sendError('Invalid request body.');
274            return;
275        }
276
277        $action = isset($payload['action']) ? (string) $payload['action'] : '';
278        // For the read-only 'load' action, accept GET requests without a token.
279        // All state-changing actions require a valid DokuWiki security token.
280        // checkSecurityToken() reads from $_REQUEST (form fields), so when the
281        // request body is JSON we must inject the token from the parsed payload
282        // into $_POST / $_REQUEST before calling it.
283        if ($action !== 'load') {
284            // checkSecurityToken() accepts the token directly, so we hand it the
285            // value from the JSON body rather than poking it into $_REQUEST.
286            $jsonToken = isset($payload['sectok']) ? (string) $payload['sectok'] : '';
287            if (!checkSecurityToken($jsonToken)) {
288                $this->sendError('Invalid security token.');
289                return;
290            }
291        }
292        $id = isset($payload['id']) ? cleanID((string) $payload['id']) : '';
293
294        if ($action === '' || $id === '') {
295            $this->sendError('Missing action or page id.');
296            return;
297        }
298
299        /** @var helper_plugin_annotations $helper */
300        $helper = $this->loadHelper('annotations', false);
301        if (!$helper) {
302            $this->sendError('Annotations helper unavailable.');
303            return;
304        }
305
306        // Gather facts once; pass them to the helper's permission methods.
307        global $INPUT;
308        $user     = $INPUT->server->str('REMOTE_USER');
309        $isAdmin  = auth_isadmin();
310        $aclLevel = auth_quickaclcheck($id);
311
312        // Route to the correct handler method.
313        switch ($action) {
314            case 'load':
315                $this->actionLoad($helper, $id, $aclLevel);
316                break;
317            case 'create':
318                $this->actionCreate($helper, $id, $payload, $user, $aclLevel);
319                break;
320            case 'reply':
321                $this->actionReply($helper, $id, $payload, $user, $aclLevel);
322                break;
323            case 'edit_annotation':
324                $this->actionEditAnnotation($helper, $id, $payload, $user, $isAdmin);
325                break;
326            case 'edit_reply':
327                $this->actionEditReply($helper, $id, $payload, $user, $isAdmin);
328                break;
329            case 'delete_annotation':
330                $this->actionDeleteAnnotation($helper, $id, $payload, $user, $isAdmin);
331                break;
332            case 'delete_reply':
333                $this->actionDeleteReply($helper, $id, $payload, $user, $isAdmin);
334                break;
335            case 'resolve':
336                $this->actionResolve($helper, $id, $payload, $user, $aclLevel);
337                break;
338            case 'clear_resolved':
339                $this->actionClearResolved($helper, $id, $isAdmin);
340                break;
341            case 'clear_orphaned':
342                $this->actionClearOrphaned($helper, $id, $isAdmin);
343                break;
344            default:
345                $this->sendError('Unknown action: ' . $action);
346        }
347    }
348
349    // ------------------------------------------------------------------
350    //  Action handlers (one per supported action)
351    // ------------------------------------------------------------------
352
353    /**
354     * Create a new annotation.
355     *
356     * Payload: { action, id, anchor:{exact,prefix,suffix,start}, body }
357     *
358     * @param helper_plugin_annotations $helper
359     * @param string                    $id
360     * @param array                     $payload
361     * @param string                    $user
362     * @param int                       $aclLevel
363     */
364    protected function actionCreate($helper, $id, array $payload, $user, $aclLevel)
365    {
366        if (!$helper->canAnnotate($user, $aclLevel)) {
367            $this->sendError('Permission denied.');
368            return;
369        }
370        $anchor = isset($payload['anchor']) && is_array($payload['anchor'])
371            ? $payload['anchor']
372            : [];
373        $body = isset($payload['body']) ? (string) $payload['body'] : '';
374
375        $result = $helper->createAnnotation($id, $anchor, $user, $body);
376        if ($result === false) {
377            $this->sendError('Invalid annotation data.');
378            return;
379        }
380        $this->sendSuccess(['annotation' => $result]);
381    }
382
383    /**
384     * Add a reply to an existing annotation.
385     *
386     * Payload: { action, id, annId, body }
387     *
388     * @param helper_plugin_annotations $helper
389     * @param string                    $id
390     * @param array                     $payload
391     * @param string                    $user
392     * @param int                       $aclLevel
393     */
394    protected function actionReply($helper, $id, array $payload, $user, $aclLevel)
395    {
396        if (!$helper->canAnnotate($user, $aclLevel)) {
397            $this->sendError('Permission denied.');
398            return;
399        }
400        $annId    = isset($payload['annId'])    ? (string) $payload['annId']    : '';
401        $body     = isset($payload['body'])     ? (string) $payload['body']     : '';
402        $parentId = isset($payload['parentId']) ? (string) $payload['parentId'] : '';
403
404        if ($annId === '') {
405            $this->sendError('Missing annId.');
406            return;
407        }
408        $result = $helper->addReply($id, $annId, $user, $body, $parentId);
409        if ($result === false) {
410            $this->sendError('Invalid reply data or annotation not found.');
411            return;
412        }
413        $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]);
414    }
415
416    /**
417     * Edit an annotation's body text.
418     *
419     * Payload: { action, id, annId, body }
420     *
421     * @param helper_plugin_annotations $helper
422     * @param string                    $id
423     * @param array                     $payload
424     * @param string                    $user
425     * @param bool                      $isAdmin
426     */
427    protected function actionEditAnnotation($helper, $id, array $payload, $user, $isAdmin)
428    {
429        $annId = isset($payload['annId']) ? (string) $payload['annId'] : '';
430        $body  = isset($payload['body'])  ? (string) $payload['body']  : '';
431
432        if ($annId === '') {
433            $this->sendError('Missing annId.');
434            return;
435        }
436        $annotation = $helper->getAnnotation($id, $annId);
437        if ($annotation === null) {
438            $this->sendError('Annotation not found.');
439            return;
440        }
441        if (!$helper->canEditAnnotation($annotation, $user, $isAdmin)) {
442            $this->sendError('Permission denied.');
443            return;
444        }
445        $ok = $helper->updateAnnotationBody($id, $annId, $body);
446        if (!$ok) {
447            $this->sendError('Invalid body or annotation not found.');
448            return;
449        }
450        $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]);
451    }
452
453    /**
454     * Edit a reply's body text.
455     *
456     * Payload: { action, id, annId, replyId, body }
457     *
458     * @param helper_plugin_annotations $helper
459     * @param string                    $id
460     * @param array                     $payload
461     * @param string                    $user
462     * @param bool                      $isAdmin
463     */
464    protected function actionEditReply($helper, $id, array $payload, $user, $isAdmin)
465    {
466        $annId   = isset($payload['annId'])   ? (string) $payload['annId']   : '';
467        $replyId = isset($payload['replyId']) ? (string) $payload['replyId'] : '';
468        $body    = isset($payload['body'])    ? (string) $payload['body']    : '';
469
470        if ($annId === '' || $replyId === '') {
471            $this->sendError('Missing annId or replyId.');
472            return;
473        }
474        $annotation = $helper->getAnnotation($id, $annId);
475        if ($annotation === null) {
476            $this->sendError('Annotation not found.');
477            return;
478        }
479        // Find the reply to permission-check its author.
480        $reply = null;
481        foreach (($annotation['replies'] ?? []) as $r) {
482            if (($r['id'] ?? '') === $replyId) {
483                $reply = $r;
484                break;
485            }
486        }
487        if ($reply === null) {
488            $this->sendError('Reply not found.');
489            return;
490        }
491        if (!$helper->canEditReply($reply, $user, $isAdmin)) {
492            $this->sendError('Permission denied.');
493            return;
494        }
495        $ok = $helper->updateReply($id, $annId, $replyId, $body);
496        if (!$ok) {
497            $this->sendError('Invalid body or reply not found.');
498            return;
499        }
500        $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]);
501    }
502
503    /**
504     * Delete an annotation and all its replies.
505     *
506     * Payload: { action, id, annId }
507     *
508     * @param helper_plugin_annotations $helper
509     * @param string                    $id
510     * @param array                     $payload
511     * @param string                    $user
512     * @param bool                      $isAdmin
513     */
514    protected function actionDeleteAnnotation($helper, $id, array $payload, $user, $isAdmin)
515    {
516        $annId = isset($payload['annId']) ? (string) $payload['annId'] : '';
517
518        if ($annId === '') {
519            $this->sendError('Missing annId.');
520            return;
521        }
522        $annotation = $helper->getAnnotation($id, $annId);
523        if ($annotation === null) {
524            $this->sendError('Annotation not found.');
525            return;
526        }
527        if (!$helper->canEditAnnotation($annotation, $user, $isAdmin)) {
528            $this->sendError('Permission denied.');
529            return;
530        }
531        $ok = $helper->deleteAnnotation($id, $annId);
532        if (!$ok) {
533            $this->sendError('Delete failed.');
534            return;
535        }
536        $this->sendSuccess(['stats' => $helper->getStats($id)]);
537    }
538
539    /**
540     * Delete a reply.
541     *
542     * Payload: { action, id, annId, replyId }
543     *
544     * @param helper_plugin_annotations $helper
545     * @param string                    $id
546     * @param array                     $payload
547     * @param string                    $user
548     * @param bool                      $isAdmin
549     */
550    protected function actionDeleteReply($helper, $id, array $payload, $user, $isAdmin)
551    {
552        $annId   = isset($payload['annId'])   ? (string) $payload['annId']   : '';
553        $replyId = isset($payload['replyId']) ? (string) $payload['replyId'] : '';
554
555        if ($annId === '' || $replyId === '') {
556            $this->sendError('Missing annId or replyId.');
557            return;
558        }
559        $annotation = $helper->getAnnotation($id, $annId);
560        if ($annotation === null) {
561            $this->sendError('Annotation not found.');
562            return;
563        }
564        $reply = null;
565        foreach (($annotation['replies'] ?? []) as $r) {
566            if (($r['id'] ?? '') === $replyId) {
567                $reply = $r;
568                break;
569            }
570        }
571        if ($reply === null) {
572            $this->sendError('Reply not found.');
573            return;
574        }
575        if (!$helper->canEditReply($reply, $user, $isAdmin)) {
576            $this->sendError('Permission denied.');
577            return;
578        }
579        $ok = $helper->deleteReply($id, $annId, $replyId);
580        if (!$ok) {
581            $this->sendError('Delete failed.');
582            return;
583        }
584        $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]);
585    }
586
587    /**
588     * Resolve or reopen an annotation.
589     *
590     * Payload: { action, id, annId, status:"open"|"resolved" }
591     *
592     * @param helper_plugin_annotations $helper
593     * @param string                    $id
594     * @param array                     $payload
595     * @param string                    $user
596     * @param int                       $aclLevel
597     */
598    protected function actionResolve($helper, $id, array $payload, $user, $aclLevel)
599    {
600        if (!$helper->canAnnotate($user, $aclLevel)) {
601            $this->sendError('Permission denied.');
602            return;
603        }
604        $annId  = isset($payload['annId'])  ? (string) $payload['annId']  : '';
605        $status = isset($payload['status']) ? (string) $payload['status'] : '';
606
607        if ($annId === '') {
608            $this->sendError('Missing annId.');
609            return;
610        }
611        $ok = $helper->setStatus($id, $annId, $status, $user);
612        if (!$ok) {
613            $this->sendError('Invalid status or annotation not found.');
614            return;
615        }
616        $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]);
617    }
618
619    /**
620     * Remove all resolved annotations on the page. Admin only.
621     *
622     * Payload: { action, id }
623     *
624     * @param helper_plugin_annotations $helper
625     * @param string                    $id
626     * @param bool                      $isAdmin
627     */
628    protected function actionClearResolved($helper, $id, $isAdmin)
629    {
630        if (!$helper->canClear($isAdmin)) {
631            $this->sendError('Permission denied.');
632            return;
633        }
634        $count = $helper->clearResolved($id);
635        if ($count === false) {
636            $this->sendError('Clear failed.');
637            return;
638        }
639        $this->sendSuccess(['removed' => $count, 'stats' => $helper->getStats($id)]);
640    }
641
642    /**
643     * Remove all orphaned annotations on the page. Admin only.
644     *
645     * Payload: { action, id }
646     *
647     * @param helper_plugin_annotations $helper
648     * @param string                    $id
649     * @param bool                      $isAdmin
650     */
651    protected function actionClearOrphaned($helper, $id, $isAdmin)
652    {
653        if (!$helper->canClear($isAdmin)) {
654            $this->sendError('Permission denied.');
655            return;
656        }
657        $count = $helper->clearOrphaned($id);
658        if ($count === false) {
659            $this->sendError('Clear failed.');
660            return;
661        }
662        $this->sendSuccess(['removed' => $count, 'stats' => $helper->getStats($id)]);
663    }
664
665    // ------------------------------------------------------------------
666    //  Utilities
667    // ------------------------------------------------------------------
668
669    /**
670     * Whether the current user has the annotations_enabled preference on.
671     *
672     * If the usersettings plugin is absent the feature defaults to enabled.
673     * Public so templates and tests can call it directly.
674     *
675     * @return bool
676     */
677    public function isEnabledForUser()
678    {
679        /** @var helper_plugin_usersettings|null $us */
680        $us = plugin_load('helper', 'usersettings');
681        if (!$us) {
682            return true; // usersettings not installed — default on
683        }
684        $value = $us->getPreference('annotations_enabled');
685        // getPreference returns null when the toggle is not registered yet
686        // (e.g. very first page load before the event has fired).
687        return ($value === null) ? true : (bool) $value;
688    }
689
690    /**
691     * Parse the request body as JSON; also accepts form-encoded POSTs for
692     * simpler test scripts.
693     *
694     * @return array|null
695     */
696    protected function readPayload()
697    {
698        global $INPUT;
699        $ct = $INPUT->server->str('CONTENT_TYPE');
700        if (strpos($ct, 'application/json') !== false) {
701            $data = json_decode(file_get_contents('php://input'), true);
702            return is_array($data) ? $data : null;
703        }
704        // The read-only 'load' action is a GET carrying action + id only.
705        if ($INPUT->server->str('REQUEST_METHOD') === 'GET') {
706            return [
707                'action' => $INPUT->get->str('action'),
708                'id'     => $INPUT->get->str('id'),
709            ];
710        }
711        // Form-encoded POST fallback (handy for simple curl tests).
712        return [
713            'action'  => $INPUT->post->str('action'),
714            'id'      => $INPUT->post->str('id'),
715            'sectok'  => $INPUT->post->str('sectok'),
716            'annId'   => $INPUT->post->str('annId'),
717            'replyId' => $INPUT->post->str('replyId'),
718            'body'    => $INPUT->post->str('body'),
719            'status'  => $INPUT->post->str('status'),
720        ];
721    }
722
723    /**
724     * Return all annotations for a page (read-only, no token required).
725     *
726     * The ACL check is still enforced: only users with at least AUTH_READ
727     * on the page can read its annotations.
728     *
729     * @param helper_plugin_annotations $helper
730     * @param string                    $id
731     * @param int                       $aclLevel
732     */
733    protected function actionLoad($helper, $id, $aclLevel)
734    {
735        if ($aclLevel < AUTH_READ) {
736            $this->sendError('Permission denied.');
737            return;
738        }
739        $annotations = $helper->getAnnotations($id);
740        $this->sendSuccess(['annotations' => $annotations]);
741    }
742
743    /**
744     * Emit a JSON success response. The caller has already prevented the
745     * default AJAX handling, so the request ends after this output.
746     *
747     * @param array $extra additional fields merged into the response
748     */
749    protected function sendSuccess(array $extra = [])
750    {
751        echo json_encode(array_merge(['success' => true], $extra), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
752    }
753
754    /**
755     * Emit a JSON error response.
756     *
757     * @param string $message human-readable error
758     */
759    protected function sendError($message)
760    {
761        echo json_encode(['success' => false, 'error' => $message], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
762    }
763}
764