1<?php 2 3/** 4 * Annotations plugin — event registration and AJAX endpoint. 5 * 6 * Responsibilities: 7 * 8 * 1. Register a per-user "annotations_enabled" toggle via the usersettings 9 * plugin's PLUGIN_USERSETTINGS_REGISTER event (BEFORE, so it fires when 10 * the usersettings helper calls getRegisteredToggles()). 11 * 12 * 2. Push the current user's preference and the page's annotation stats 13 * into JSINFO on every normal page view, so script.js can gate itself 14 * and seed the counter without an extra round-trip. 15 * 16 * 3. Serve the AJAX endpoint at: 17 * /lib/exe/ajax.php?call=annotations 18 * POST body (application/json) carries { action, id, ... }. 19 * All state-changing actions require a valid DokuWiki security token. 20 * Every response is JSON: { success:true, ... } or { success:false, error:"..." }. 21 * 22 * Supported actions (all POST): 23 * create — body, anchor (object) 24 * reply — annId, body 25 * edit_annotation — annId, body 26 * edit_reply — annId, replyId, body 27 * delete_annotation — annId 28 * delete_reply — annId, replyId 29 * resolve — annId, status ("open"|"resolved") 30 * clear_resolved — (no extra fields) 31 * clear_orphaned — (no extra fields) 32 * 33 * Permission enforcement is done here; the helper's permission methods are 34 * called with facts gathered from the DokuWiki global state. 35 */ 36 37// must be run within DokuWiki 38if (!defined('DOKU_INC')) die(); 39 40class action_plugin_annotations extends DokuWiki_Action_Plugin 41{ 42 // ------------------------------------------------------------------ 43 // Event registration 44 // ------------------------------------------------------------------ 45 46 /** 47 * @param Doku_Event_Handler $controller 48 */ 49 public function register(Doku_Event_Handler $controller) 50 { 51 // Register our toggle with the usersettings plugin. 52 $controller->register_hook( 53 'PLUGIN_USERSETTINGS_REGISTER', 54 'BEFORE', 55 $this, 56 'handleSettingsRegister' 57 ); 58 59 // Inject annotation stats + user preference into JSINFO. 60 $controller->register_hook( 61 'TPL_METAHEADER_OUTPUT', 62 'BEFORE', 63 $this, 64 'handleMetaHeader' 65 ); 66 67 // Handle the AJAX call. 68 $controller->register_hook( 69 'AJAX_CALL_UNKNOWN', 70 'BEFORE', 71 $this, 72 'handleAjax' 73 ); 74 } 75 76 // ------------------------------------------------------------------ 77 // 1. usersettings toggle registration 78 // ------------------------------------------------------------------ 79 80 /** 81 * Append the annotations_enabled toggle definition to the event data. 82 * 83 * The event data is an array that the usersettings helper fires with 84 * createAndTrigger(); every handler appends its definition(s). 85 * 86 * @param Doku_Event $event PLUGIN_USERSETTINGS_REGISTER 87 * @param mixed $param 88 */ 89 public function handleSettingsRegister(Doku_Event $event, $param) 90 { 91 $event->data[] = [ 92 'key' => 'annotations_enabled', 93 'label' => $this->getLang('toggle_label'), 94 'desc' => $this->getLang('toggle_desc'), 95 'type' => 'checkbox', 96 'default' => true, 97 'plugin' => 'annotations', 98 ]; 99 } 100 101 // ------------------------------------------------------------------ 102 // 2. Inject into JSINFO 103 // ------------------------------------------------------------------ 104 105 /** 106 * Add annotation stats and the user preference to JSINFO so script.js 107 * does not need an extra round-trip on page load. 108 * 109 * IMPORTANT: tpl_metaheaders() calls jsinfo() and then immediately 110 * JSON-encodes $JSINFO into an inline <script> string BEFORE firing 111 * TPL_METAHEADER_OUTPUT. Writing to $JSINFO here is therefore too late. 112 * Instead we locate that inline script block in $event->data and append 113 * a JSINFO.annotations = {...}; statement so it runs in the same scope. 114 * 115 * @param Doku_Event $event TPL_METAHEADER_OUTPUT 116 * @param mixed $param 117 */ 118 public function handleMetaHeader(Doku_Event $event, $param) 119 { 120 global $ID, $ACT; 121 122 // Only inject on normal page-view actions. 123 if (!in_array(act_clean($ACT), ['show', 'export_xhtml'], true)) { 124 return; 125 } 126 127 /** @var helper_plugin_annotations $helper */ 128 $helper = $this->loadHelper('annotations', false); 129 if (!$helper) { 130 return; 131 } 132 133 global $INPUT; 134 135 $enabled = $this->isEnabledForUser(); 136 $stats = $helper->getStats($ID); 137 138 // DokuWiki's jsinfo() does not expose user identity, so we inject it 139 // here. JS uses these to gate the selection tooltip and permission UI. 140 $user = $INPUT->server->str('REMOTE_USER'); 141 $isAdmin = auth_isadmin(); 142 143 $payload = json_encode([ 144 'enabled' => $enabled, 145 'pageId' => $ID, 146 'stats' => $stats, 147 'user' => $user, 148 'isAdmin' => $isAdmin, 149 'token' => getSecurityToken(), // CSRF token for AJAX POSTs 150 ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); 151 152 // The inline script block containing "var JSINFO = ...;" is in 153 // $event->data['script']. Find it and append our assignment so it 154 // runs in the same scope after JSINFO is already declared. 155 if (!empty($event->data['script'])) { 156 foreach ($event->data['script'] as &$scriptTag) { 157 if ( 158 isset($scriptTag['_data']) && 159 strpos($scriptTag['_data'], 'var JSINFO') !== false 160 ) { 161 $scriptTag['_data'] .= 'JSINFO.annotations=' . $payload . ';'; 162 break; 163 } 164 } 165 unset($scriptTag); 166 } 167 } 168 169 // ------------------------------------------------------------------ 170 // 3. AJAX endpoint 171 // ------------------------------------------------------------------ 172 173 /** 174 * Handle AJAX calls for the annotations plugin. 175 * Ignores calls not addressed to us. 176 * 177 * @param Doku_Event $event AJAX_CALL_UNKNOWN 178 * @param mixed $param 179 */ 180 public function handleAjax(Doku_Event $event, $param) 181 { 182 if ($event->data !== 'annotations') { 183 return; 184 } 185 $event->stopPropagation(); 186 $event->preventDefault(); 187 188 header('Content-Type: application/json; charset=utf-8'); 189 190 // Parse JSON body; fall back to POST/GET fields for simple callers. 191 // The 'load' action is a GET request, so we accept query parameters too. 192 $payload = $this->readPayload(); 193 if ($payload === null) { 194 $this->sendError('Invalid request body.'); 195 return; 196 } 197 198 $action = isset($payload['action']) ? (string) $payload['action'] : ''; 199 // For the read-only 'load' action, accept GET requests without a token. 200 // All state-changing actions require a valid DokuWiki security token. 201 // checkSecurityToken() reads from $_REQUEST (form fields), so when the 202 // request body is JSON we must inject the token from the parsed payload 203 // into $_POST / $_REQUEST before calling it. 204 if ($action !== 'load') { 205 // checkSecurityToken() accepts the token directly, so we hand it the 206 // value from the JSON body rather than poking it into $_REQUEST. 207 $jsonToken = isset($payload['sectok']) ? (string) $payload['sectok'] : ''; 208 if (!checkSecurityToken($jsonToken)) { 209 $this->sendError('Invalid security token.'); 210 return; 211 } 212 } 213 $id = isset($payload['id']) ? cleanID((string) $payload['id']) : ''; 214 215 if ($action === '' || $id === '') { 216 $this->sendError('Missing action or page id.'); 217 return; 218 } 219 220 /** @var helper_plugin_annotations $helper */ 221 $helper = $this->loadHelper('annotations', false); 222 if (!$helper) { 223 $this->sendError('Annotations helper unavailable.'); 224 return; 225 } 226 227 // Gather facts once; pass them to the helper's permission methods. 228 global $INPUT; 229 $user = $INPUT->server->str('REMOTE_USER'); 230 $isAdmin = auth_isadmin(); 231 $aclLevel = auth_quickaclcheck($id); 232 233 // Route to the correct handler method. 234 switch ($action) { 235 case 'load': 236 $this->actionLoad($helper, $id, $aclLevel); 237 break; 238 case 'create': 239 $this->actionCreate($helper, $id, $payload, $user, $aclLevel); 240 break; 241 case 'reply': 242 $this->actionReply($helper, $id, $payload, $user, $aclLevel); 243 break; 244 case 'edit_annotation': 245 $this->actionEditAnnotation($helper, $id, $payload, $user, $isAdmin); 246 break; 247 case 'edit_reply': 248 $this->actionEditReply($helper, $id, $payload, $user, $isAdmin); 249 break; 250 case 'delete_annotation': 251 $this->actionDeleteAnnotation($helper, $id, $payload, $user, $isAdmin); 252 break; 253 case 'delete_reply': 254 $this->actionDeleteReply($helper, $id, $payload, $user, $isAdmin); 255 break; 256 case 'resolve': 257 $this->actionResolve($helper, $id, $payload, $user, $aclLevel); 258 break; 259 case 'clear_resolved': 260 $this->actionClearResolved($helper, $id, $isAdmin); 261 break; 262 case 'clear_orphaned': 263 $this->actionClearOrphaned($helper, $id, $isAdmin); 264 break; 265 default: 266 $this->sendError('Unknown action: ' . hsc($action)); 267 } 268 } 269 270 // ------------------------------------------------------------------ 271 // Action handlers (one per supported action) 272 // ------------------------------------------------------------------ 273 274 /** 275 * Create a new annotation. 276 * 277 * Payload: { action, id, anchor:{exact,prefix,suffix,start}, body } 278 * 279 * @param helper_plugin_annotations $helper 280 * @param string $id 281 * @param array $payload 282 * @param string $user 283 * @param int $aclLevel 284 */ 285 protected function actionCreate($helper, $id, array $payload, $user, $aclLevel) 286 { 287 if (!$helper->canAnnotate($user, $aclLevel)) { 288 $this->sendError('Permission denied.'); 289 return; 290 } 291 $anchor = isset($payload['anchor']) && is_array($payload['anchor']) 292 ? $payload['anchor'] 293 : []; 294 $body = isset($payload['body']) ? (string) $payload['body'] : ''; 295 296 $result = $helper->createAnnotation($id, $anchor, $user, $body); 297 if ($result === false) { 298 $this->sendError('Invalid annotation data.'); 299 return; 300 } 301 $this->sendSuccess(['annotation' => $result]); 302 } 303 304 /** 305 * Add a reply to an existing annotation. 306 * 307 * Payload: { action, id, annId, 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 actionReply($helper, $id, array $payload, $user, $aclLevel) 316 { 317 if (!$helper->canAnnotate($user, $aclLevel)) { 318 $this->sendError('Permission denied.'); 319 return; 320 } 321 $annId = isset($payload['annId']) ? (string) $payload['annId'] : ''; 322 $body = isset($payload['body']) ? (string) $payload['body'] : ''; 323 $parentId = isset($payload['parentId']) ? (string) $payload['parentId'] : ''; 324 325 if ($annId === '') { 326 $this->sendError('Missing annId.'); 327 return; 328 } 329 $result = $helper->addReply($id, $annId, $user, $body, $parentId); 330 if ($result === false) { 331 $this->sendError('Invalid reply data or annotation not found.'); 332 return; 333 } 334 $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]); 335 } 336 337 /** 338 * Edit an annotation's body text. 339 * 340 * Payload: { action, id, annId, body } 341 * 342 * @param helper_plugin_annotations $helper 343 * @param string $id 344 * @param array $payload 345 * @param string $user 346 * @param bool $isAdmin 347 */ 348 protected function actionEditAnnotation($helper, $id, array $payload, $user, $isAdmin) 349 { 350 $annId = isset($payload['annId']) ? (string) $payload['annId'] : ''; 351 $body = isset($payload['body']) ? (string) $payload['body'] : ''; 352 353 if ($annId === '') { 354 $this->sendError('Missing annId.'); 355 return; 356 } 357 $annotation = $helper->getAnnotation($id, $annId); 358 if ($annotation === null) { 359 $this->sendError('Annotation not found.'); 360 return; 361 } 362 if (!$helper->canEditAnnotation($annotation, $user, $isAdmin)) { 363 $this->sendError('Permission denied.'); 364 return; 365 } 366 $ok = $helper->updateAnnotationBody($id, $annId, $body); 367 if (!$ok) { 368 $this->sendError('Invalid body or annotation not found.'); 369 return; 370 } 371 $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]); 372 } 373 374 /** 375 * Edit a reply's body text. 376 * 377 * Payload: { action, id, annId, replyId, body } 378 * 379 * @param helper_plugin_annotations $helper 380 * @param string $id 381 * @param array $payload 382 * @param string $user 383 * @param bool $isAdmin 384 */ 385 protected function actionEditReply($helper, $id, array $payload, $user, $isAdmin) 386 { 387 $annId = isset($payload['annId']) ? (string) $payload['annId'] : ''; 388 $replyId = isset($payload['replyId']) ? (string) $payload['replyId'] : ''; 389 $body = isset($payload['body']) ? (string) $payload['body'] : ''; 390 391 if ($annId === '' || $replyId === '') { 392 $this->sendError('Missing annId or replyId.'); 393 return; 394 } 395 $annotation = $helper->getAnnotation($id, $annId); 396 if ($annotation === null) { 397 $this->sendError('Annotation not found.'); 398 return; 399 } 400 // Find the reply to permission-check its author. 401 $reply = null; 402 foreach (($annotation['replies'] ?? []) as $r) { 403 if (($r['id'] ?? '') === $replyId) { 404 $reply = $r; 405 break; 406 } 407 } 408 if ($reply === null) { 409 $this->sendError('Reply not found.'); 410 return; 411 } 412 if (!$helper->canEditReply($reply, $user, $isAdmin)) { 413 $this->sendError('Permission denied.'); 414 return; 415 } 416 $ok = $helper->updateReply($id, $annId, $replyId, $body); 417 if (!$ok) { 418 $this->sendError('Invalid body or reply not found.'); 419 return; 420 } 421 $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]); 422 } 423 424 /** 425 * Delete an annotation and all its replies. 426 * 427 * Payload: { action, id, annId } 428 * 429 * @param helper_plugin_annotations $helper 430 * @param string $id 431 * @param array $payload 432 * @param string $user 433 * @param bool $isAdmin 434 */ 435 protected function actionDeleteAnnotation($helper, $id, array $payload, $user, $isAdmin) 436 { 437 $annId = isset($payload['annId']) ? (string) $payload['annId'] : ''; 438 439 if ($annId === '') { 440 $this->sendError('Missing annId.'); 441 return; 442 } 443 $annotation = $helper->getAnnotation($id, $annId); 444 if ($annotation === null) { 445 $this->sendError('Annotation not found.'); 446 return; 447 } 448 if (!$helper->canEditAnnotation($annotation, $user, $isAdmin)) { 449 $this->sendError('Permission denied.'); 450 return; 451 } 452 $ok = $helper->deleteAnnotation($id, $annId); 453 if (!$ok) { 454 $this->sendError('Delete failed.'); 455 return; 456 } 457 $this->sendSuccess(['stats' => $helper->getStats($id)]); 458 } 459 460 /** 461 * Delete a reply. 462 * 463 * Payload: { action, id, annId, replyId } 464 * 465 * @param helper_plugin_annotations $helper 466 * @param string $id 467 * @param array $payload 468 * @param string $user 469 * @param bool $isAdmin 470 */ 471 protected function actionDeleteReply($helper, $id, array $payload, $user, $isAdmin) 472 { 473 $annId = isset($payload['annId']) ? (string) $payload['annId'] : ''; 474 $replyId = isset($payload['replyId']) ? (string) $payload['replyId'] : ''; 475 476 if ($annId === '' || $replyId === '') { 477 $this->sendError('Missing annId or replyId.'); 478 return; 479 } 480 $annotation = $helper->getAnnotation($id, $annId); 481 if ($annotation === null) { 482 $this->sendError('Annotation not found.'); 483 return; 484 } 485 $reply = null; 486 foreach (($annotation['replies'] ?? []) as $r) { 487 if (($r['id'] ?? '') === $replyId) { 488 $reply = $r; 489 break; 490 } 491 } 492 if ($reply === null) { 493 $this->sendError('Reply not found.'); 494 return; 495 } 496 if (!$helper->canEditReply($reply, $user, $isAdmin)) { 497 $this->sendError('Permission denied.'); 498 return; 499 } 500 $ok = $helper->deleteReply($id, $annId, $replyId); 501 if (!$ok) { 502 $this->sendError('Delete failed.'); 503 return; 504 } 505 $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]); 506 } 507 508 /** 509 * Resolve or reopen an annotation. 510 * 511 * Payload: { action, id, annId, status:"open"|"resolved" } 512 * 513 * @param helper_plugin_annotations $helper 514 * @param string $id 515 * @param array $payload 516 * @param string $user 517 * @param int $aclLevel 518 */ 519 protected function actionResolve($helper, $id, array $payload, $user, $aclLevel) 520 { 521 if (!$helper->canAnnotate($user, $aclLevel)) { 522 $this->sendError('Permission denied.'); 523 return; 524 } 525 $annId = isset($payload['annId']) ? (string) $payload['annId'] : ''; 526 $status = isset($payload['status']) ? (string) $payload['status'] : ''; 527 528 if ($annId === '') { 529 $this->sendError('Missing annId.'); 530 return; 531 } 532 $ok = $helper->setStatus($id, $annId, $status, $user); 533 if (!$ok) { 534 $this->sendError('Invalid status or annotation not found.'); 535 return; 536 } 537 $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]); 538 } 539 540 /** 541 * Remove all resolved annotations on the page. Admin only. 542 * 543 * Payload: { action, id } 544 * 545 * @param helper_plugin_annotations $helper 546 * @param string $id 547 * @param bool $isAdmin 548 */ 549 protected function actionClearResolved($helper, $id, $isAdmin) 550 { 551 if (!$helper->canClear($isAdmin)) { 552 $this->sendError('Permission denied.'); 553 return; 554 } 555 $count = $helper->clearResolved($id); 556 if ($count === false) { 557 $this->sendError('Clear failed.'); 558 return; 559 } 560 $this->sendSuccess(['removed' => $count, 'stats' => $helper->getStats($id)]); 561 } 562 563 /** 564 * Remove all orphaned annotations on the page. Admin only. 565 * 566 * Payload: { action, id } 567 * 568 * @param helper_plugin_annotations $helper 569 * @param string $id 570 * @param bool $isAdmin 571 */ 572 protected function actionClearOrphaned($helper, $id, $isAdmin) 573 { 574 if (!$helper->canClear($isAdmin)) { 575 $this->sendError('Permission denied.'); 576 return; 577 } 578 $count = $helper->clearOrphaned($id); 579 if ($count === false) { 580 $this->sendError('Clear failed.'); 581 return; 582 } 583 $this->sendSuccess(['removed' => $count, 'stats' => $helper->getStats($id)]); 584 } 585 586 // ------------------------------------------------------------------ 587 // Utilities 588 // ------------------------------------------------------------------ 589 590 /** 591 * Whether the current user has the annotations_enabled preference on. 592 * 593 * If the usersettings plugin is absent the feature defaults to enabled. 594 * Public so templates and tests can call it directly. 595 * 596 * @return bool 597 */ 598 public function isEnabledForUser() 599 { 600 /** @var helper_plugin_usersettings|null $us */ 601 $us = plugin_load('helper', 'usersettings'); 602 if (!$us) { 603 return true; // usersettings not installed — default on 604 } 605 $value = $us->getPreference('annotations_enabled'); 606 // getPreference returns null when the toggle is not registered yet 607 // (e.g. very first page load before the event has fired). 608 return ($value === null) ? true : (bool) $value; 609 } 610 611 /** 612 * Parse the request body as JSON; also accepts form-encoded POSTs for 613 * simpler test scripts. 614 * 615 * @return array|null 616 */ 617 protected function readPayload() 618 { 619 global $INPUT; 620 $ct = $INPUT->server->str('CONTENT_TYPE'); 621 if (strpos($ct, 'application/json') !== false) { 622 $data = json_decode(file_get_contents('php://input'), true); 623 return is_array($data) ? $data : null; 624 } 625 // The read-only 'load' action is a GET carrying action + id only. 626 if ($INPUT->server->str('REQUEST_METHOD') === 'GET') { 627 return [ 628 'action' => $INPUT->get->str('action'), 629 'id' => $INPUT->get->str('id'), 630 ]; 631 } 632 // Form-encoded POST fallback (handy for simple curl tests). 633 return [ 634 'action' => $INPUT->post->str('action'), 635 'id' => $INPUT->post->str('id'), 636 'sectok' => $INPUT->post->str('sectok'), 637 'annId' => $INPUT->post->str('annId'), 638 'replyId' => $INPUT->post->str('replyId'), 639 'body' => $INPUT->post->str('body'), 640 'status' => $INPUT->post->str('status'), 641 ]; 642 } 643 644 /** 645 * Return all annotations for a page (read-only, no token required). 646 * 647 * The ACL check is still enforced: only users with at least AUTH_READ 648 * on the page can read its annotations. 649 * 650 * @param helper_plugin_annotations $helper 651 * @param string $id 652 * @param int $aclLevel 653 */ 654 protected function actionLoad($helper, $id, $aclLevel) 655 { 656 if ($aclLevel < AUTH_READ) { 657 $this->sendError('Permission denied.'); 658 return; 659 } 660 $annotations = $helper->getAnnotations($id); 661 $this->sendSuccess(['annotations' => $annotations]); 662 } 663 664 /** 665 * Emit a JSON success response. The caller has already prevented the 666 * default AJAX handling, so the request ends after this output. 667 * 668 * @param array $extra additional fields merged into the response 669 */ 670 protected function sendSuccess(array $extra = []) 671 { 672 echo json_encode(array_merge(['success' => true], $extra), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); 673 } 674 675 /** 676 * Emit a JSON error response. 677 * 678 * @param string $message human-readable error 679 */ 680 protected function sendError($message) 681 { 682 echo json_encode(['success' => false, 'error' => $message], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); 683 } 684} 685