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