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