143d2073cStracker-user<?php 243d2073cStracker-user 343d2073cStracker-user/** 443d2073cStracker-user * Annotations plugin — event registration and AJAX endpoint. 543d2073cStracker-user * 643d2073cStracker-user * Responsibilities: 743d2073cStracker-user * 843d2073cStracker-user * 1. Register a per-user "annotations_enabled" toggle via the usersettings 943d2073cStracker-user * plugin's PLUGIN_USERSETTINGS_REGISTER event (BEFORE, so it fires when 1043d2073cStracker-user * the usersettings helper calls getRegisteredToggles()). 1143d2073cStracker-user * 1243d2073cStracker-user * 2. Push the current user's preference and the page's annotation stats 1343d2073cStracker-user * into JSINFO on every normal page view, so script.js can gate itself 1443d2073cStracker-user * and seed the counter without an extra round-trip. 1543d2073cStracker-user * 1643d2073cStracker-user * 3. Serve the AJAX endpoint at: 1743d2073cStracker-user * /lib/exe/ajax.php?call=annotations 1843d2073cStracker-user * POST body (application/json) carries { action, id, ... }. 1943d2073cStracker-user * All state-changing actions require a valid DokuWiki security token. 2043d2073cStracker-user * Every response is JSON: { success:true, ... } or { success:false, error:"..." }. 2143d2073cStracker-user * 2243d2073cStracker-user * Supported actions (all POST): 2343d2073cStracker-user * create — body, anchor (object) 2443d2073cStracker-user * reply — annId, body 2543d2073cStracker-user * edit_annotation — annId, body 2643d2073cStracker-user * edit_reply — annId, replyId, body 2743d2073cStracker-user * delete_annotation — annId 2843d2073cStracker-user * delete_reply — annId, replyId 2943d2073cStracker-user * resolve — annId, status ("open"|"resolved") 3043d2073cStracker-user * clear_resolved — (no extra fields) 3143d2073cStracker-user * clear_orphaned — (no extra fields) 3243d2073cStracker-user * 3343d2073cStracker-user * Permission enforcement is done here; the helper's permission methods are 3443d2073cStracker-user * called with facts gathered from the DokuWiki global state. 3543d2073cStracker-user */ 3643d2073cStracker-user 3743d2073cStracker-user// must be run within DokuWiki 3843d2073cStracker-userif (!defined('DOKU_INC')) die(); 3943d2073cStracker-user 4043d2073cStracker-userclass action_plugin_annotations extends DokuWiki_Action_Plugin 4143d2073cStracker-user{ 42108f92bdStracker-user /** 4386c7806dStracker-user * Fallback for the largest serialized annotation list (bytes) embedded 4486c7806dStracker-user * inline in the page when the config value is unreadable. Below this, the 4586c7806dStracker-user * list ships with the page so script.js renders without a second 4686c7806dStracker-user * bootstrapped AJAX round-trip; above it, the client falls back to the GET 4786c7806dStracker-user * 'load' endpoint so a heavily-annotated page can't bloat every view. The 4886c7806dStracker-user * live value is the 'embed_max_bytes' config setting. 49108f92bdStracker-user */ 5086c7806dStracker-user const DEFAULT_EMBED_MAX_BYTES = 131072; 51108f92bdStracker-user 5243d2073cStracker-user // ------------------------------------------------------------------ 5343d2073cStracker-user // Event registration 5443d2073cStracker-user // ------------------------------------------------------------------ 5543d2073cStracker-user 5643d2073cStracker-user /** 5743d2073cStracker-user * @param Doku_Event_Handler $controller 5843d2073cStracker-user */ 5943d2073cStracker-user public function register(Doku_Event_Handler $controller) 6043d2073cStracker-user { 6143d2073cStracker-user // Register our toggle with the usersettings plugin. 6243d2073cStracker-user $controller->register_hook( 6343d2073cStracker-user 'PLUGIN_USERSETTINGS_REGISTER', 6443d2073cStracker-user 'BEFORE', 6543d2073cStracker-user $this, 6643d2073cStracker-user 'handleSettingsRegister' 6743d2073cStracker-user ); 6843d2073cStracker-user 6943d2073cStracker-user // Inject annotation stats + user preference into JSINFO. 7043d2073cStracker-user $controller->register_hook( 7143d2073cStracker-user 'TPL_METAHEADER_OUTPUT', 7243d2073cStracker-user 'BEFORE', 7343d2073cStracker-user $this, 7443d2073cStracker-user 'handleMetaHeader' 7543d2073cStracker-user ); 7643d2073cStracker-user 7743d2073cStracker-user // Handle the AJAX call. 7843d2073cStracker-user $controller->register_hook( 7943d2073cStracker-user 'AJAX_CALL_UNKNOWN', 8043d2073cStracker-user 'BEFORE', 8143d2073cStracker-user $this, 8243d2073cStracker-user 'handleAjax' 8343d2073cStracker-user ); 8443d2073cStracker-user } 8543d2073cStracker-user 8643d2073cStracker-user // ------------------------------------------------------------------ 8743d2073cStracker-user // 1. usersettings toggle registration 8843d2073cStracker-user // ------------------------------------------------------------------ 8943d2073cStracker-user 9043d2073cStracker-user /** 9143d2073cStracker-user * Append the annotations_enabled toggle definition to the event data. 9243d2073cStracker-user * 9343d2073cStracker-user * The event data is an array that the usersettings helper fires with 9443d2073cStracker-user * createAndTrigger(); every handler appends its definition(s). 9543d2073cStracker-user * 9643d2073cStracker-user * @param Doku_Event $event PLUGIN_USERSETTINGS_REGISTER 9743d2073cStracker-user * @param mixed $param 9843d2073cStracker-user */ 9943d2073cStracker-user public function handleSettingsRegister(Doku_Event $event, $param) 10043d2073cStracker-user { 10143d2073cStracker-user $event->data[] = [ 10243d2073cStracker-user 'key' => 'annotations_enabled', 10343d2073cStracker-user 'label' => $this->getLang('toggle_label'), 10443d2073cStracker-user 'desc' => $this->getLang('toggle_desc'), 10543d2073cStracker-user 'type' => 'checkbox', 10643d2073cStracker-user 'default' => true, 10743d2073cStracker-user 'plugin' => 'annotations', 10843d2073cStracker-user ]; 10943d2073cStracker-user } 11043d2073cStracker-user 11143d2073cStracker-user // ------------------------------------------------------------------ 11243d2073cStracker-user // 2. Inject into JSINFO 11343d2073cStracker-user // ------------------------------------------------------------------ 11443d2073cStracker-user 11543d2073cStracker-user /** 116b8076f00Stracker-user * Add annotation stats and the user preference to JSINFO so script.js 11743d2073cStracker-user * does not need an extra round-trip on page load. 11843d2073cStracker-user * 119b8076f00Stracker-user * IMPORTANT: tpl_metaheaders() calls jsinfo() and then immediately 120b8076f00Stracker-user * JSON-encodes $JSINFO into an inline <script> string BEFORE firing 121b8076f00Stracker-user * TPL_METAHEADER_OUTPUT. Writing to $JSINFO here is therefore too late. 122b8076f00Stracker-user * Instead we locate that inline script block in $event->data and append 123b8076f00Stracker-user * a JSINFO.annotations = {...}; statement so it runs in the same scope. 124b8076f00Stracker-user * 12543d2073cStracker-user * @param Doku_Event $event TPL_METAHEADER_OUTPUT 12643d2073cStracker-user * @param mixed $param 12743d2073cStracker-user */ 12843d2073cStracker-user public function handleMetaHeader(Doku_Event $event, $param) 12943d2073cStracker-user { 130b8076f00Stracker-user global $ID, $ACT; 131b8076f00Stracker-user 132b8076f00Stracker-user // Only inject on normal page-view actions. 133b8076f00Stracker-user if (!in_array(act_clean($ACT), ['show', 'export_xhtml'], true)) { 134b8076f00Stracker-user return; 135b8076f00Stracker-user } 13643d2073cStracker-user 13743d2073cStracker-user /** @var helper_plugin_annotations $helper */ 13843d2073cStracker-user $helper = $this->loadHelper('annotations', false); 13943d2073cStracker-user if (!$helper) { 14043d2073cStracker-user return; 14143d2073cStracker-user } 14243d2073cStracker-user 143da56206cStracker-user global $INPUT; 1447d2714c7Stracker-user 14543d2073cStracker-user $enabled = $this->isEnabledForUser(); 146108f92bdStracker-user 147108f92bdStracker-user // Read the annotation list once here and ship it inline with the page 148108f92bdStracker-user // (see EMBED_MAX_BYTES). script.js then renders immediately instead of 149108f92bdStracker-user // firing a second AJAX request that re-boots DokuWiki (~300 ms) just to 150108f92bdStracker-user // re-read this same file. Stats are derived from the loaded list rather 151108f92bdStracker-user // than calling getStats(), which would read the file a second time. 152108f92bdStracker-user $annotations = $helper->getAnnotations($ID); 153108f92bdStracker-user $stats = $helper->statsFor($annotations); 15443d2073cStracker-user 1557d2714c7Stracker-user // DokuWiki's jsinfo() does not expose user identity, so we inject it 1567d2714c7Stracker-user // here. JS uses these to gate the selection tooltip and permission UI. 157da56206cStracker-user $user = $INPUT->server->str('REMOTE_USER'); 158da56206cStracker-user $isAdmin = auth_isadmin(); 1597d2714c7Stracker-user 160108f92bdStracker-user $data = [ 16143d2073cStracker-user 'enabled' => $enabled, 16243d2073cStracker-user 'pageId' => $ID, 16343d2073cStracker-user 'stats' => $stats, 1647d2714c7Stracker-user 'user' => $user, 1657d2714c7Stracker-user 'isAdmin' => $isAdmin, 1667d2714c7Stracker-user 'token' => getSecurityToken(), // CSRF token for AJAX POSTs 16786c7806dStracker-user 'contextLen' => max(0, (int) $this->getConf('context_length')), 168108f92bdStracker-user ]; 169108f92bdStracker-user 17086c7806dStracker-user // Inject the configurable highlight colours as CSS custom properties so 17186c7806dStracker-user // style.css can derive every opacity variant from one hex per state. 17286c7806dStracker-user $this->injectColourVars($event); 17386c7806dStracker-user 174108f92bdStracker-user // Embed the full list only when the feature is on for this user and the 175108f92bdStracker-user // serialized list is small enough; otherwise script.js fetches it via 176108f92bdStracker-user // the GET 'load' endpoint. The inline JSINFO script is regenerated every 177108f92bdStracker-user // request (it is not part of the parser page cache), so this stays fresh. 178108f92bdStracker-user if ($enabled) { 17986c7806dStracker-user $embedMax = (int) $this->getConf('embed_max_bytes'); 18086c7806dStracker-user if ($embedMax <= 0) { 18186c7806dStracker-user $embedMax = self::DEFAULT_EMBED_MAX_BYTES; 18286c7806dStracker-user } 183*49d7ec0aStracker-user $listJson = json_encode($annotations, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); 18486c7806dStracker-user if ($listJson !== false && strlen($listJson) <= $embedMax) { 185108f92bdStracker-user $data['annotations'] = $annotations; 186108f92bdStracker-user } 187108f92bdStracker-user } 188108f92bdStracker-user 189*49d7ec0aStracker-user // JSON_HEX_TAG escapes < and > to < / >. This payload is 190*49d7ec0aStracker-user // appended inside the page's inline <script> (below), so a body 191*49d7ec0aStracker-user // containing "</script>" would otherwise close the script element and 192*49d7ec0aStracker-user // inject arbitrary HTML — a stored XSS reachable by anyone who can 193*49d7ec0aStracker-user // annotate. HEX_TAG neutralises every tag-based breakout. 194*49d7ec0aStracker-user $payload = json_encode($data, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); 195b8076f00Stracker-user 196b8076f00Stracker-user // The inline script block containing "var JSINFO = ...;" is in 197b8076f00Stracker-user // $event->data['script']. Find it and append our assignment so it 198b8076f00Stracker-user // runs in the same scope after JSINFO is already declared. 199b8076f00Stracker-user if (!empty($event->data['script'])) { 200b8076f00Stracker-user foreach ($event->data['script'] as &$scriptTag) { 201b8076f00Stracker-user if ( 202b8076f00Stracker-user isset($scriptTag['_data']) && 203b8076f00Stracker-user strpos($scriptTag['_data'], 'var JSINFO') !== false 204b8076f00Stracker-user ) { 205b8076f00Stracker-user $scriptTag['_data'] .= 'JSINFO.annotations=' . $payload . ';'; 206b8076f00Stracker-user break; 207b8076f00Stracker-user } 208b8076f00Stracker-user } 209b8076f00Stracker-user unset($scriptTag); 210b8076f00Stracker-user } 21143d2073cStracker-user } 21243d2073cStracker-user 21386c7806dStracker-user /** 21486c7806dStracker-user * Append a <style> metaheader declaring the two configurable highlight 21586c7806dStracker-user * colours as CSS custom properties (--ann-open-rgb / --ann-resolved-rgb, 21686c7806dStracker-user * each an "r,g,b" channel triplet). style.css consumes them via 21786c7806dStracker-user * rgba(var(--ann-open-rgb), <alpha>) so a single hex per state drives every 21886c7806dStracker-user * fill/border/marker/pill tint. style.css also ships :root fallbacks, so an 21986c7806dStracker-user * unreadable colour just keeps the built-in palette. 22086c7806dStracker-user * 22186c7806dStracker-user * @param Doku_Event $event TPL_METAHEADER_OUTPUT 22286c7806dStracker-user */ 22386c7806dStracker-user protected function injectColourVars(Doku_Event $event) 22486c7806dStracker-user { 22586c7806dStracker-user $open = $this->hexToRgb($this->getConf('color_open'), '245,158,11'); 22686c7806dStracker-user $resolved = $this->hexToRgb($this->getConf('color_resolved'), '74,222,128'); 22786c7806dStracker-user $css = ':root{--ann-open-rgb:' . $open . ';--ann-resolved-rgb:' . $resolved . ';}'; 22886c7806dStracker-user $event->data['style'][] = ['type' => 'text/css', '_data' => $css]; 22986c7806dStracker-user } 23086c7806dStracker-user 23186c7806dStracker-user /** 23286c7806dStracker-user * Convert a #rrggbb hex colour to an "r,g,b" channel triplet, returning the 23386c7806dStracker-user * supplied fallback for anything that is not a valid 6-digit hex colour. 23486c7806dStracker-user * 23586c7806dStracker-user * @param mixed $hex 23686c7806dStracker-user * @param string $fallback "r,g,b" used when $hex is invalid 23786c7806dStracker-user * @return string 23886c7806dStracker-user */ 23986c7806dStracker-user protected function hexToRgb($hex, $fallback) 24086c7806dStracker-user { 24186c7806dStracker-user if (is_string($hex) && preg_match('/^#([0-9a-fA-F]{6})$/', $hex, $m)) { 24286c7806dStracker-user $int = hexdec($m[1]); 24386c7806dStracker-user return (($int >> 16) & 255) . ',' . (($int >> 8) & 255) . ',' . ($int & 255); 24486c7806dStracker-user } 24586c7806dStracker-user return $fallback; 24686c7806dStracker-user } 24786c7806dStracker-user 24843d2073cStracker-user // ------------------------------------------------------------------ 24943d2073cStracker-user // 3. AJAX endpoint 25043d2073cStracker-user // ------------------------------------------------------------------ 25143d2073cStracker-user 25243d2073cStracker-user /** 25343d2073cStracker-user * Handle AJAX calls for the annotations plugin. 25443d2073cStracker-user * Ignores calls not addressed to us. 25543d2073cStracker-user * 25643d2073cStracker-user * @param Doku_Event $event AJAX_CALL_UNKNOWN 25743d2073cStracker-user * @param mixed $param 25843d2073cStracker-user */ 25943d2073cStracker-user public function handleAjax(Doku_Event $event, $param) 26043d2073cStracker-user { 26143d2073cStracker-user if ($event->data !== 'annotations') { 26243d2073cStracker-user return; 26343d2073cStracker-user } 26443d2073cStracker-user $event->stopPropagation(); 26543d2073cStracker-user $event->preventDefault(); 26643d2073cStracker-user 26743d2073cStracker-user header('Content-Type: application/json; charset=utf-8'); 26843d2073cStracker-user 26943d2073cStracker-user // Parse JSON body; fall back to POST/GET fields for simple callers. 27043d2073cStracker-user // The 'load' action is a GET request, so we accept query parameters too. 27143d2073cStracker-user $payload = $this->readPayload(); 27243d2073cStracker-user if ($payload === null) { 27343d2073cStracker-user $this->sendError('Invalid request body.'); 27443d2073cStracker-user return; 27543d2073cStracker-user } 27643d2073cStracker-user 27743d2073cStracker-user $action = isset($payload['action']) ? (string) $payload['action'] : ''; 27843d2073cStracker-user // For the read-only 'load' action, accept GET requests without a token. 27943d2073cStracker-user // All state-changing actions require a valid DokuWiki security token. 280f58805fbStracker-user // checkSecurityToken() reads from $_REQUEST (form fields), so when the 281f58805fbStracker-user // request body is JSON we must inject the token from the parsed payload 282f58805fbStracker-user // into $_POST / $_REQUEST before calling it. 283f58805fbStracker-user if ($action !== 'load') { 284da56206cStracker-user // checkSecurityToken() accepts the token directly, so we hand it the 285da56206cStracker-user // value from the JSON body rather than poking it into $_REQUEST. 286f58805fbStracker-user $jsonToken = isset($payload['sectok']) ? (string) $payload['sectok'] : ''; 287da56206cStracker-user if (!checkSecurityToken($jsonToken)) { 28843d2073cStracker-user $this->sendError('Invalid security token.'); 28943d2073cStracker-user return; 29043d2073cStracker-user } 291f58805fbStracker-user } 29243d2073cStracker-user $id = isset($payload['id']) ? cleanID((string) $payload['id']) : ''; 29343d2073cStracker-user 29443d2073cStracker-user if ($action === '' || $id === '') { 29543d2073cStracker-user $this->sendError('Missing action or page id.'); 29643d2073cStracker-user return; 29743d2073cStracker-user } 29843d2073cStracker-user 29943d2073cStracker-user /** @var helper_plugin_annotations $helper */ 30043d2073cStracker-user $helper = $this->loadHelper('annotations', false); 30143d2073cStracker-user if (!$helper) { 30243d2073cStracker-user $this->sendError('Annotations helper unavailable.'); 30343d2073cStracker-user return; 30443d2073cStracker-user } 30543d2073cStracker-user 30643d2073cStracker-user // Gather facts once; pass them to the helper's permission methods. 307da56206cStracker-user global $INPUT; 308da56206cStracker-user $user = $INPUT->server->str('REMOTE_USER'); 309da56206cStracker-user $isAdmin = auth_isadmin(); 31043d2073cStracker-user $aclLevel = auth_quickaclcheck($id); 31143d2073cStracker-user 31243d2073cStracker-user // Route to the correct handler method. 31343d2073cStracker-user switch ($action) { 31443d2073cStracker-user case 'load': 31543d2073cStracker-user $this->actionLoad($helper, $id, $aclLevel); 31643d2073cStracker-user break; 31743d2073cStracker-user case 'create': 31843d2073cStracker-user $this->actionCreate($helper, $id, $payload, $user, $aclLevel); 31943d2073cStracker-user break; 32043d2073cStracker-user case 'reply': 32143d2073cStracker-user $this->actionReply($helper, $id, $payload, $user, $aclLevel); 32243d2073cStracker-user break; 32343d2073cStracker-user case 'edit_annotation': 32443d2073cStracker-user $this->actionEditAnnotation($helper, $id, $payload, $user, $isAdmin); 32543d2073cStracker-user break; 32643d2073cStracker-user case 'edit_reply': 32743d2073cStracker-user $this->actionEditReply($helper, $id, $payload, $user, $isAdmin); 32843d2073cStracker-user break; 32943d2073cStracker-user case 'delete_annotation': 33043d2073cStracker-user $this->actionDeleteAnnotation($helper, $id, $payload, $user, $isAdmin); 33143d2073cStracker-user break; 33243d2073cStracker-user case 'delete_reply': 33343d2073cStracker-user $this->actionDeleteReply($helper, $id, $payload, $user, $isAdmin); 33443d2073cStracker-user break; 33543d2073cStracker-user case 'resolve': 33643d2073cStracker-user $this->actionResolve($helper, $id, $payload, $user, $aclLevel); 33743d2073cStracker-user break; 33843d2073cStracker-user case 'clear_resolved': 33943d2073cStracker-user $this->actionClearResolved($helper, $id, $isAdmin); 34043d2073cStracker-user break; 34143d2073cStracker-user case 'clear_orphaned': 34243d2073cStracker-user $this->actionClearOrphaned($helper, $id, $isAdmin); 34343d2073cStracker-user break; 34443d2073cStracker-user default: 345*49d7ec0aStracker-user $this->sendError('Unknown action: ' . $action); 34643d2073cStracker-user } 34743d2073cStracker-user } 34843d2073cStracker-user 34943d2073cStracker-user // ------------------------------------------------------------------ 35043d2073cStracker-user // Action handlers (one per supported action) 35143d2073cStracker-user // ------------------------------------------------------------------ 35243d2073cStracker-user 35343d2073cStracker-user /** 35443d2073cStracker-user * Create a new annotation. 35543d2073cStracker-user * 35643d2073cStracker-user * Payload: { action, id, anchor:{exact,prefix,suffix,start}, body } 35743d2073cStracker-user * 35843d2073cStracker-user * @param helper_plugin_annotations $helper 35943d2073cStracker-user * @param string $id 36043d2073cStracker-user * @param array $payload 36143d2073cStracker-user * @param string $user 36243d2073cStracker-user * @param int $aclLevel 36343d2073cStracker-user */ 36443d2073cStracker-user protected function actionCreate($helper, $id, array $payload, $user, $aclLevel) 36543d2073cStracker-user { 36643d2073cStracker-user if (!$helper->canAnnotate($user, $aclLevel)) { 36743d2073cStracker-user $this->sendError('Permission denied.'); 36843d2073cStracker-user return; 36943d2073cStracker-user } 37043d2073cStracker-user $anchor = isset($payload['anchor']) && is_array($payload['anchor']) 37143d2073cStracker-user ? $payload['anchor'] 37243d2073cStracker-user : []; 37343d2073cStracker-user $body = isset($payload['body']) ? (string) $payload['body'] : ''; 37443d2073cStracker-user 37543d2073cStracker-user $result = $helper->createAnnotation($id, $anchor, $user, $body); 37643d2073cStracker-user if ($result === false) { 37743d2073cStracker-user $this->sendError('Invalid annotation data.'); 37843d2073cStracker-user return; 37943d2073cStracker-user } 38043d2073cStracker-user $this->sendSuccess(['annotation' => $result]); 38143d2073cStracker-user } 38243d2073cStracker-user 38343d2073cStracker-user /** 38443d2073cStracker-user * Add a reply to an existing annotation. 38543d2073cStracker-user * 38643d2073cStracker-user * Payload: { action, id, annId, body } 38743d2073cStracker-user * 38843d2073cStracker-user * @param helper_plugin_annotations $helper 38943d2073cStracker-user * @param string $id 39043d2073cStracker-user * @param array $payload 39143d2073cStracker-user * @param string $user 39243d2073cStracker-user * @param int $aclLevel 39343d2073cStracker-user */ 39443d2073cStracker-user protected function actionReply($helper, $id, array $payload, $user, $aclLevel) 39543d2073cStracker-user { 39643d2073cStracker-user if (!$helper->canAnnotate($user, $aclLevel)) { 39743d2073cStracker-user $this->sendError('Permission denied.'); 39843d2073cStracker-user return; 39943d2073cStracker-user } 40043d2073cStracker-user $annId = isset($payload['annId']) ? (string) $payload['annId'] : ''; 40143d2073cStracker-user $body = isset($payload['body']) ? (string) $payload['body'] : ''; 402ee9dbf15Stracker-user $parentId = isset($payload['parentId']) ? (string) $payload['parentId'] : ''; 40343d2073cStracker-user 40443d2073cStracker-user if ($annId === '') { 40543d2073cStracker-user $this->sendError('Missing annId.'); 40643d2073cStracker-user return; 40743d2073cStracker-user } 408ee9dbf15Stracker-user $result = $helper->addReply($id, $annId, $user, $body, $parentId); 40943d2073cStracker-user if ($result === false) { 41043d2073cStracker-user $this->sendError('Invalid reply data or annotation not found.'); 41143d2073cStracker-user return; 41243d2073cStracker-user } 413ee9dbf15Stracker-user $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]); 41443d2073cStracker-user } 41543d2073cStracker-user 41643d2073cStracker-user /** 41743d2073cStracker-user * Edit an annotation's body text. 41843d2073cStracker-user * 41943d2073cStracker-user * Payload: { action, id, annId, body } 42043d2073cStracker-user * 42143d2073cStracker-user * @param helper_plugin_annotations $helper 42243d2073cStracker-user * @param string $id 42343d2073cStracker-user * @param array $payload 42443d2073cStracker-user * @param string $user 42543d2073cStracker-user * @param bool $isAdmin 42643d2073cStracker-user */ 42743d2073cStracker-user protected function actionEditAnnotation($helper, $id, array $payload, $user, $isAdmin) 42843d2073cStracker-user { 42943d2073cStracker-user $annId = isset($payload['annId']) ? (string) $payload['annId'] : ''; 43043d2073cStracker-user $body = isset($payload['body']) ? (string) $payload['body'] : ''; 43143d2073cStracker-user 43243d2073cStracker-user if ($annId === '') { 43343d2073cStracker-user $this->sendError('Missing annId.'); 43443d2073cStracker-user return; 43543d2073cStracker-user } 43643d2073cStracker-user $annotation = $helper->getAnnotation($id, $annId); 43743d2073cStracker-user if ($annotation === null) { 43843d2073cStracker-user $this->sendError('Annotation not found.'); 43943d2073cStracker-user return; 44043d2073cStracker-user } 44143d2073cStracker-user if (!$helper->canEditAnnotation($annotation, $user, $isAdmin)) { 44243d2073cStracker-user $this->sendError('Permission denied.'); 44343d2073cStracker-user return; 44443d2073cStracker-user } 44543d2073cStracker-user $ok = $helper->updateAnnotationBody($id, $annId, $body); 44643d2073cStracker-user if (!$ok) { 44743d2073cStracker-user $this->sendError('Invalid body or annotation not found.'); 44843d2073cStracker-user return; 44943d2073cStracker-user } 45043d2073cStracker-user $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]); 45143d2073cStracker-user } 45243d2073cStracker-user 45343d2073cStracker-user /** 45443d2073cStracker-user * Edit a reply's body text. 45543d2073cStracker-user * 45643d2073cStracker-user * Payload: { action, id, annId, replyId, body } 45743d2073cStracker-user * 45843d2073cStracker-user * @param helper_plugin_annotations $helper 45943d2073cStracker-user * @param string $id 46043d2073cStracker-user * @param array $payload 46143d2073cStracker-user * @param string $user 46243d2073cStracker-user * @param bool $isAdmin 46343d2073cStracker-user */ 46443d2073cStracker-user protected function actionEditReply($helper, $id, array $payload, $user, $isAdmin) 46543d2073cStracker-user { 46643d2073cStracker-user $annId = isset($payload['annId']) ? (string) $payload['annId'] : ''; 46743d2073cStracker-user $replyId = isset($payload['replyId']) ? (string) $payload['replyId'] : ''; 46843d2073cStracker-user $body = isset($payload['body']) ? (string) $payload['body'] : ''; 46943d2073cStracker-user 47043d2073cStracker-user if ($annId === '' || $replyId === '') { 47143d2073cStracker-user $this->sendError('Missing annId or replyId.'); 47243d2073cStracker-user return; 47343d2073cStracker-user } 47443d2073cStracker-user $annotation = $helper->getAnnotation($id, $annId); 47543d2073cStracker-user if ($annotation === null) { 47643d2073cStracker-user $this->sendError('Annotation not found.'); 47743d2073cStracker-user return; 47843d2073cStracker-user } 47943d2073cStracker-user // Find the reply to permission-check its author. 48043d2073cStracker-user $reply = null; 48143d2073cStracker-user foreach (($annotation['replies'] ?? []) as $r) { 48243d2073cStracker-user if (($r['id'] ?? '') === $replyId) { 48343d2073cStracker-user $reply = $r; 48443d2073cStracker-user break; 48543d2073cStracker-user } 48643d2073cStracker-user } 48743d2073cStracker-user if ($reply === null) { 48843d2073cStracker-user $this->sendError('Reply not found.'); 48943d2073cStracker-user return; 49043d2073cStracker-user } 49143d2073cStracker-user if (!$helper->canEditReply($reply, $user, $isAdmin)) { 49243d2073cStracker-user $this->sendError('Permission denied.'); 49343d2073cStracker-user return; 49443d2073cStracker-user } 49543d2073cStracker-user $ok = $helper->updateReply($id, $annId, $replyId, $body); 49643d2073cStracker-user if (!$ok) { 49743d2073cStracker-user $this->sendError('Invalid body or reply not found.'); 49843d2073cStracker-user return; 49943d2073cStracker-user } 50043d2073cStracker-user $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]); 50143d2073cStracker-user } 50243d2073cStracker-user 50343d2073cStracker-user /** 50443d2073cStracker-user * Delete an annotation and all its replies. 50543d2073cStracker-user * 50643d2073cStracker-user * Payload: { action, id, annId } 50743d2073cStracker-user * 50843d2073cStracker-user * @param helper_plugin_annotations $helper 50943d2073cStracker-user * @param string $id 51043d2073cStracker-user * @param array $payload 51143d2073cStracker-user * @param string $user 51243d2073cStracker-user * @param bool $isAdmin 51343d2073cStracker-user */ 51443d2073cStracker-user protected function actionDeleteAnnotation($helper, $id, array $payload, $user, $isAdmin) 51543d2073cStracker-user { 51643d2073cStracker-user $annId = isset($payload['annId']) ? (string) $payload['annId'] : ''; 51743d2073cStracker-user 51843d2073cStracker-user if ($annId === '') { 51943d2073cStracker-user $this->sendError('Missing annId.'); 52043d2073cStracker-user return; 52143d2073cStracker-user } 52243d2073cStracker-user $annotation = $helper->getAnnotation($id, $annId); 52343d2073cStracker-user if ($annotation === null) { 52443d2073cStracker-user $this->sendError('Annotation not found.'); 52543d2073cStracker-user return; 52643d2073cStracker-user } 52743d2073cStracker-user if (!$helper->canEditAnnotation($annotation, $user, $isAdmin)) { 52843d2073cStracker-user $this->sendError('Permission denied.'); 52943d2073cStracker-user return; 53043d2073cStracker-user } 53143d2073cStracker-user $ok = $helper->deleteAnnotation($id, $annId); 53243d2073cStracker-user if (!$ok) { 53343d2073cStracker-user $this->sendError('Delete failed.'); 53443d2073cStracker-user return; 53543d2073cStracker-user } 53643d2073cStracker-user $this->sendSuccess(['stats' => $helper->getStats($id)]); 53743d2073cStracker-user } 53843d2073cStracker-user 53943d2073cStracker-user /** 54043d2073cStracker-user * Delete a reply. 54143d2073cStracker-user * 54243d2073cStracker-user * Payload: { action, id, annId, replyId } 54343d2073cStracker-user * 54443d2073cStracker-user * @param helper_plugin_annotations $helper 54543d2073cStracker-user * @param string $id 54643d2073cStracker-user * @param array $payload 54743d2073cStracker-user * @param string $user 54843d2073cStracker-user * @param bool $isAdmin 54943d2073cStracker-user */ 55043d2073cStracker-user protected function actionDeleteReply($helper, $id, array $payload, $user, $isAdmin) 55143d2073cStracker-user { 55243d2073cStracker-user $annId = isset($payload['annId']) ? (string) $payload['annId'] : ''; 55343d2073cStracker-user $replyId = isset($payload['replyId']) ? (string) $payload['replyId'] : ''; 55443d2073cStracker-user 55543d2073cStracker-user if ($annId === '' || $replyId === '') { 55643d2073cStracker-user $this->sendError('Missing annId or replyId.'); 55743d2073cStracker-user return; 55843d2073cStracker-user } 55943d2073cStracker-user $annotation = $helper->getAnnotation($id, $annId); 56043d2073cStracker-user if ($annotation === null) { 56143d2073cStracker-user $this->sendError('Annotation not found.'); 56243d2073cStracker-user return; 56343d2073cStracker-user } 56443d2073cStracker-user $reply = null; 56543d2073cStracker-user foreach (($annotation['replies'] ?? []) as $r) { 56643d2073cStracker-user if (($r['id'] ?? '') === $replyId) { 56743d2073cStracker-user $reply = $r; 56843d2073cStracker-user break; 56943d2073cStracker-user } 57043d2073cStracker-user } 57143d2073cStracker-user if ($reply === null) { 57243d2073cStracker-user $this->sendError('Reply not found.'); 57343d2073cStracker-user return; 57443d2073cStracker-user } 57543d2073cStracker-user if (!$helper->canEditReply($reply, $user, $isAdmin)) { 57643d2073cStracker-user $this->sendError('Permission denied.'); 57743d2073cStracker-user return; 57843d2073cStracker-user } 57943d2073cStracker-user $ok = $helper->deleteReply($id, $annId, $replyId); 58043d2073cStracker-user if (!$ok) { 58143d2073cStracker-user $this->sendError('Delete failed.'); 58243d2073cStracker-user return; 58343d2073cStracker-user } 58443d2073cStracker-user $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]); 58543d2073cStracker-user } 58643d2073cStracker-user 58743d2073cStracker-user /** 58843d2073cStracker-user * Resolve or reopen an annotation. 58943d2073cStracker-user * 59043d2073cStracker-user * Payload: { action, id, annId, status:"open"|"resolved" } 59143d2073cStracker-user * 59243d2073cStracker-user * @param helper_plugin_annotations $helper 59343d2073cStracker-user * @param string $id 59443d2073cStracker-user * @param array $payload 59543d2073cStracker-user * @param string $user 59643d2073cStracker-user * @param int $aclLevel 59743d2073cStracker-user */ 59843d2073cStracker-user protected function actionResolve($helper, $id, array $payload, $user, $aclLevel) 59943d2073cStracker-user { 60043d2073cStracker-user if (!$helper->canAnnotate($user, $aclLevel)) { 60143d2073cStracker-user $this->sendError('Permission denied.'); 60243d2073cStracker-user return; 60343d2073cStracker-user } 60443d2073cStracker-user $annId = isset($payload['annId']) ? (string) $payload['annId'] : ''; 60543d2073cStracker-user $status = isset($payload['status']) ? (string) $payload['status'] : ''; 60643d2073cStracker-user 60743d2073cStracker-user if ($annId === '') { 60843d2073cStracker-user $this->sendError('Missing annId.'); 60943d2073cStracker-user return; 61043d2073cStracker-user } 61143d2073cStracker-user $ok = $helper->setStatus($id, $annId, $status, $user); 61243d2073cStracker-user if (!$ok) { 61343d2073cStracker-user $this->sendError('Invalid status or annotation not found.'); 61443d2073cStracker-user return; 61543d2073cStracker-user } 61643d2073cStracker-user $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]); 61743d2073cStracker-user } 61843d2073cStracker-user 61943d2073cStracker-user /** 62043d2073cStracker-user * Remove all resolved annotations on the page. Admin only. 62143d2073cStracker-user * 62243d2073cStracker-user * Payload: { action, id } 62343d2073cStracker-user * 62443d2073cStracker-user * @param helper_plugin_annotations $helper 62543d2073cStracker-user * @param string $id 62643d2073cStracker-user * @param bool $isAdmin 62743d2073cStracker-user */ 62843d2073cStracker-user protected function actionClearResolved($helper, $id, $isAdmin) 62943d2073cStracker-user { 63043d2073cStracker-user if (!$helper->canClear($isAdmin)) { 63143d2073cStracker-user $this->sendError('Permission denied.'); 63243d2073cStracker-user return; 63343d2073cStracker-user } 63443d2073cStracker-user $count = $helper->clearResolved($id); 63543d2073cStracker-user if ($count === false) { 63643d2073cStracker-user $this->sendError('Clear failed.'); 63743d2073cStracker-user return; 63843d2073cStracker-user } 63943d2073cStracker-user $this->sendSuccess(['removed' => $count, 'stats' => $helper->getStats($id)]); 64043d2073cStracker-user } 64143d2073cStracker-user 64243d2073cStracker-user /** 64343d2073cStracker-user * Remove all orphaned annotations on the page. Admin only. 64443d2073cStracker-user * 64543d2073cStracker-user * Payload: { action, id } 64643d2073cStracker-user * 64743d2073cStracker-user * @param helper_plugin_annotations $helper 64843d2073cStracker-user * @param string $id 64943d2073cStracker-user * @param bool $isAdmin 65043d2073cStracker-user */ 65143d2073cStracker-user protected function actionClearOrphaned($helper, $id, $isAdmin) 65243d2073cStracker-user { 65343d2073cStracker-user if (!$helper->canClear($isAdmin)) { 65443d2073cStracker-user $this->sendError('Permission denied.'); 65543d2073cStracker-user return; 65643d2073cStracker-user } 65743d2073cStracker-user $count = $helper->clearOrphaned($id); 65843d2073cStracker-user if ($count === false) { 65943d2073cStracker-user $this->sendError('Clear failed.'); 66043d2073cStracker-user return; 66143d2073cStracker-user } 66243d2073cStracker-user $this->sendSuccess(['removed' => $count, 'stats' => $helper->getStats($id)]); 66343d2073cStracker-user } 66443d2073cStracker-user 66543d2073cStracker-user // ------------------------------------------------------------------ 66643d2073cStracker-user // Utilities 66743d2073cStracker-user // ------------------------------------------------------------------ 66843d2073cStracker-user 66943d2073cStracker-user /** 67043d2073cStracker-user * Whether the current user has the annotations_enabled preference on. 67143d2073cStracker-user * 67243d2073cStracker-user * If the usersettings plugin is absent the feature defaults to enabled. 67343d2073cStracker-user * Public so templates and tests can call it directly. 67443d2073cStracker-user * 67543d2073cStracker-user * @return bool 67643d2073cStracker-user */ 67743d2073cStracker-user public function isEnabledForUser() 67843d2073cStracker-user { 67943d2073cStracker-user /** @var helper_plugin_usersettings|null $us */ 68043d2073cStracker-user $us = plugin_load('helper', 'usersettings'); 68143d2073cStracker-user if (!$us) { 68243d2073cStracker-user return true; // usersettings not installed — default on 68343d2073cStracker-user } 68443d2073cStracker-user $value = $us->getPreference('annotations_enabled'); 68543d2073cStracker-user // getPreference returns null when the toggle is not registered yet 68643d2073cStracker-user // (e.g. very first page load before the event has fired). 68743d2073cStracker-user return ($value === null) ? true : (bool) $value; 68843d2073cStracker-user } 68943d2073cStracker-user 69043d2073cStracker-user /** 69143d2073cStracker-user * Parse the request body as JSON; also accepts form-encoded POSTs for 69243d2073cStracker-user * simpler test scripts. 69343d2073cStracker-user * 69443d2073cStracker-user * @return array|null 69543d2073cStracker-user */ 69643d2073cStracker-user protected function readPayload() 69743d2073cStracker-user { 698da56206cStracker-user global $INPUT; 699da56206cStracker-user $ct = $INPUT->server->str('CONTENT_TYPE'); 70043d2073cStracker-user if (strpos($ct, 'application/json') !== false) { 701da56206cStracker-user $data = json_decode(file_get_contents('php://input'), true); 70243d2073cStracker-user return is_array($data) ? $data : null; 70343d2073cStracker-user } 704da56206cStracker-user // The read-only 'load' action is a GET carrying action + id only. 705da56206cStracker-user if ($INPUT->server->str('REQUEST_METHOD') === 'GET') { 706da56206cStracker-user return [ 707da56206cStracker-user 'action' => $INPUT->get->str('action'), 708da56206cStracker-user 'id' => $INPUT->get->str('id'), 709da56206cStracker-user ]; 71043d2073cStracker-user } 711da56206cStracker-user // Form-encoded POST fallback (handy for simple curl tests). 712da56206cStracker-user return [ 713da56206cStracker-user 'action' => $INPUT->post->str('action'), 714da56206cStracker-user 'id' => $INPUT->post->str('id'), 715da56206cStracker-user 'sectok' => $INPUT->post->str('sectok'), 716da56206cStracker-user 'annId' => $INPUT->post->str('annId'), 717da56206cStracker-user 'replyId' => $INPUT->post->str('replyId'), 718da56206cStracker-user 'body' => $INPUT->post->str('body'), 719da56206cStracker-user 'status' => $INPUT->post->str('status'), 720da56206cStracker-user ]; 72143d2073cStracker-user } 72243d2073cStracker-user 72343d2073cStracker-user /** 72443d2073cStracker-user * Return all annotations for a page (read-only, no token required). 72543d2073cStracker-user * 72643d2073cStracker-user * The ACL check is still enforced: only users with at least AUTH_READ 72743d2073cStracker-user * on the page can read its annotations. 72843d2073cStracker-user * 72943d2073cStracker-user * @param helper_plugin_annotations $helper 73043d2073cStracker-user * @param string $id 73143d2073cStracker-user * @param int $aclLevel 73243d2073cStracker-user */ 73343d2073cStracker-user protected function actionLoad($helper, $id, $aclLevel) 73443d2073cStracker-user { 73543d2073cStracker-user if ($aclLevel < AUTH_READ) { 73643d2073cStracker-user $this->sendError('Permission denied.'); 73743d2073cStracker-user return; 73843d2073cStracker-user } 73943d2073cStracker-user $annotations = $helper->getAnnotations($id); 74043d2073cStracker-user $this->sendSuccess(['annotations' => $annotations]); 74143d2073cStracker-user } 74243d2073cStracker-user 74343d2073cStracker-user /** 744ee9dbf15Stracker-user * Emit a JSON success response. The caller has already prevented the 745ee9dbf15Stracker-user * default AJAX handling, so the request ends after this output. 74643d2073cStracker-user * 74743d2073cStracker-user * @param array $extra additional fields merged into the response 74843d2073cStracker-user */ 74943d2073cStracker-user protected function sendSuccess(array $extra = []) 75043d2073cStracker-user { 751da56206cStracker-user echo json_encode(array_merge(['success' => true], $extra), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); 75243d2073cStracker-user } 75343d2073cStracker-user 75443d2073cStracker-user /** 755ee9dbf15Stracker-user * Emit a JSON error response. 75643d2073cStracker-user * 75743d2073cStracker-user * @param string $message human-readable error 75843d2073cStracker-user */ 75943d2073cStracker-user protected function sendError($message) 76043d2073cStracker-user { 761da56206cStracker-user echo json_encode(['success' => false, 'error' => $message], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); 76243d2073cStracker-user } 76343d2073cStracker-user} 764