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