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