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