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