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