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