1<?php 2if(!defined('DOKU_INC')) die(); 3if(!defined('DOKU_MEDIAMANAGER_URL_BASE')) define('DOKU_MEDIAMANAGER_URL_BASE', DOKU_BASE . 'lib/exe/mediamanager.php'); 4 5class action_plugin_pagesicon extends DokuWiki_Action_Plugin { 6 public function register(Doku_Event_Handler $controller) { 7 $controller->register_hook('TPL_CONTENT_DISPLAY', 'BEFORE', $this, 'displayPageIcon'); 8 $controller->register_hook('RENDERER_CONTENT_POSTPROCESS', 'AFTER', $this, 'injectLinkIcons'); 9 $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'setPageFavicon'); 10 $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'addUploadFormScript'); 11 $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'addFaviconRuntimeScript'); 12 $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'handleAction'); 13 $controller->register_hook('TPL_ACT_RENDER', 'BEFORE', $this, 'renderAction'); 14 $controller->register_hook('MENU_ITEMS_ASSEMBLY', 'AFTER', $this, 'addPageAction'); 15 } 16 17 public function addPageAction(Doku_Event $event): void { 18 global $ID; 19 20 if (($event->data['view'] ?? '') !== 'page') return; 21 if ($this->isActionDisabled('pagesicon')) return; 22 if (auth_quickaclcheck((string)$ID) < AUTH_UPLOAD) return; 23 24 foreach (($event->data['items'] ?? []) as $item) { 25 if ($item instanceof \dokuwiki\Menu\Item\AbstractItem && $item->getType() === 'pagesicon') { 26 return; 27 } 28 } 29 30 $label = (string)$this->getLang('page_action'); 31 if ($label === '') $label = 'Gerer l\'icone'; 32 $title = (string)$this->getLang('page_action_title'); 33 if ($title === '') $title = $label; 34 $targetPage = cleanID((string)$ID); 35 36 $event->data['items'][] = new class($targetPage, $label, $title) extends \dokuwiki\Menu\Item\AbstractItem { 37 public function __construct(string $targetPage, string $label, string $title) { 38 parent::__construct(); 39 $this->type = 'pagesicon'; 40 $this->id = $targetPage; 41 $this->params = [ 42 'do' => 'pagesicon', 43 ]; 44 $this->label = $label; 45 $this->title = $title; 46 $this->svg = DOKU_INC . 'lib/images/menu/folder-multiple-image.svg'; 47 } 48 }; 49 } 50 51 private function getIconSize(): int { 52 return (int)$this->getConf('icon_size'); 53 } 54 55 private function isActionDisabled(string $actionName): bool { 56 global $conf; 57 58 $disabled = explode(',', (string)($conf['disableactions'] ?? '')); 59 $disabled = array_map(static function ($value) { 60 return strtolower(trim((string)$value)); 61 }, $disabled); 62 $actionName = strtolower(trim($actionName)); 63 if ($actionName === '') return false; 64 65 return in_array($actionName, $disabled, true); 66 } 67 68 private function isLayoutIncludePage(): bool { 69 global $ID, $INFO; 70 // DokuWiki populates $INFO['id'] once for the originally requested page, but 71 // temporarily changes $ID while rendering layout includes (sidebar, footer, …) 72 // via tpl_include_page(). Comparing them detects any layout include without 73 // having to hardcode page names. 74 return isset($INFO['id']) && (string)$ID !== (string)$INFO['id']; 75 } 76 77 public function setPageFavicon(Doku_Event $event): void { 78 global $ACT, $ID; 79 80 if (!(bool)$this->getConf('show_as_favicon')) return; 81 if ($ACT !== 'show') return; 82 83 if ($this->isLayoutIncludePage()) return; 84 85 $helper = plugin_load('helper', 'pagesicon'); 86 if (!$helper) return; 87 88 $namespace = getNS((string)$ID); 89 $pageID = noNS((string)$ID); 90 $size = $this->getIconSize(); 91 $sizeMode = $size > 35 ? 'bigorsmall' : 'smallorbig'; 92 $favicon = $helper->getPageIconUrl($namespace, $pageID, $sizeMode, ['w' => $size]); 93 if (!$favicon) return; 94 $favicon = html_entity_decode((string)$favicon, ENT_QUOTES | ENT_HTML5, 'UTF-8'); 95 96 if (!isset($event->data['link']) || !is_array($event->data['link'])) { 97 $event->data['link'] = []; 98 } 99 100 $links = []; 101 foreach ($event->data['link'] as $link) { 102 if (!is_array($link)) { 103 $links[] = $link; 104 continue; 105 } 106 107 $rels = $link['rel'] ?? ''; 108 if (!is_array($rels)) { 109 $rels = preg_split('/\s+/', strtolower(trim((string)$rels))) ?: []; 110 } 111 $rels = array_filter(array_map('strtolower', (array)$rels)); 112 if (in_array('icon', $rels, true)) { 113 continue; 114 } 115 $links[] = $link; 116 } 117 118 $links[] = ['rel' => 'icon', 'href' => $favicon]; 119 $links[] = ['rel' => 'shortcut icon', 'href' => $favicon]; // Kept for legacy browser compatibility. 120 $event->data['link'] = $links; 121 122 if (!isset($event->data['meta']) || !is_array($event->data['meta'])) { 123 $event->data['meta'] = []; 124 } 125 $event->data['meta'][] = ['name' => 'pagesicon-favicon', 'content' => $favicon]; 126 } 127 128 public function addFaviconRuntimeScript(Doku_Event $event): void { 129 global $ACT; 130 131 if (!(bool)$this->getConf('show_as_favicon')) return; 132 if ($ACT !== 'show') return; 133 134 if (!isset($event->data['script']) || !is_array($event->data['script'])) { 135 $event->data['script'] = []; 136 } 137 138 $event->data['script'][] = [ 139 'type' => 'text/javascript', 140 'src' => DOKU_BASE . 'lib/plugins/pagesicon/script/favicon-runtime.js', 141 '_data' => 'pagesicon-favicon-runtime', 142 ]; 143 } 144 145 public function addUploadFormScript(Doku_Event $event): void { 146 global $ACT; 147 148 if ($ACT !== 'pagesicon') return; 149 150 if (!isset($event->data['script']) || !is_array($event->data['script'])) { 151 $event->data['script'] = []; 152 } 153 154 $event->data['script'][] = [ 155 'type' => 'text/javascript', 156 'src' => DOKU_BASE . 'lib/plugins/pagesicon/script/upload-form.js', 157 '_data' => 'pagesicon-upload-form', 158 ]; 159 } 160 161 private function hasIconAlready(string $html): bool { 162 return strpos($html, 'class="pagesicon-injected"') !== false; 163 } 164 165 private function canUploadToTarget(string $targetPage): bool { 166 if ($targetPage === '') return false; 167 return auth_quickaclcheck($targetPage) >= AUTH_UPLOAD; 168 } 169 170 private function getDefaultTarget(): string { 171 global $ID; 172 return cleanID((string)$ID); 173 } 174 175 private function getDefaultVariant(): string { 176 global $INPUT; 177 $defaultVariant = strtolower($INPUT->str('icon_variant')); 178 if (!in_array($defaultVariant, ['big', 'small'], true)) { 179 $defaultVariant = 'big'; 180 } 181 return $defaultVariant; 182 } 183 184 private function getPostedBaseName(array $choices): string { 185 global $INPUT; 186 /** @var helper_plugin_pagesicon|null $helper */ 187 $helper = plugin_load('helper', 'pagesicon'); 188 $selected = $helper ? $helper->normalizeIconBaseName($INPUT->post->str('icon_filename')) : ''; 189 if ($selected !== '' && isset($choices[$selected])) return $selected; 190 return (string)array_key_first($choices); 191 } 192 193 private function getMediaManagerUrl(string $targetPage): string { 194 $namespace = getNS($targetPage); 195 return DOKU_MEDIAMANAGER_URL_BASE . '?ns=' . rawurlencode($namespace); 196 } 197 198 private function renderCurrentIconPreview(string $mediaID, string $defaultTarget, string $actionPage, int $previewSize): void { 199 echo '<a href="' . hsc($this->getMediaManagerUrl($defaultTarget)) . '" target="_blank" title="' . hsc($this->getLang('open_media_manager')) . '">'; 200 echo '<img src="' . ml($mediaID, ['w' => $previewSize]) . '" alt="" width="' . $previewSize . '" style="display:block;margin:6px 0;" />'; 201 echo '</a>'; 202 echo '<small>' . hsc(noNS($mediaID)) . '</small>'; 203 echo '<form action="' . wl($actionPage) . '" method="post" style="margin-top:6px;">'; 204 formSecurityToken(); 205 echo '<input type="hidden" name="do" value="pagesicon" />'; 206 echo '<input type="hidden" name="media_id" value="' . hsc($mediaID) . '" />'; 207 echo '<input type="hidden" name="pagesicon_delete_submit" value="1" />'; 208 echo '<button type="submit" class="button">' . hsc($this->getLang('delete_icon')) . '</button>'; 209 echo '</form>'; 210 } 211 212 private function handleDeletePost(): void { 213 global $INPUT, $ID; 214 215 if (!$INPUT->post->has('pagesicon_delete_submit')) return; 216 if (!checkSecurityToken()) return; 217 218 $targetPage = cleanID((string)$ID); 219 $mediaID = cleanID($INPUT->post->str('media_id')); 220 221 if ($targetPage === '' || $mediaID === '') { 222 msg($this->getLang('error_delete_invalid'), -1); 223 return; 224 } 225 if (!$this->canUploadToTarget($targetPage)) { 226 msg($this->getLang('error_no_upload_permission'), -1); 227 return; 228 } 229 $namespace = getNS($targetPage); 230 $pageID = noNS($targetPage); 231 $helper = plugin_load('helper', 'pagesicon'); 232 $currentBig = ($helper && method_exists($helper, 'getPageIconId')) ? (string)$helper->getPageIconId($namespace, $pageID, 'big') : ''; 233 $currentSmall = ($helper && method_exists($helper, 'getPageIconId')) ? (string)$helper->getPageIconId($namespace, $pageID, 'small') : ''; 234 $allowed = array_values(array_filter(array_unique([$currentBig, $currentSmall]))); 235 if (!$allowed || !in_array($mediaID, $allowed, true)) { 236 msg($this->getLang('error_delete_invalid'), -1); 237 return; 238 } 239 240 $file = mediaFN($mediaID); 241 if (!@file_exists($file)) { 242 msg($this->getLang('error_delete_not_found'), -1); 243 return; 244 } 245 if (!@unlink($file)) { 246 msg($this->getLang('error_delete_failed'), -1); 247 return; 248 } 249 250 if ($helper) { 251 $helper->notifyIconUpdated($targetPage, 'delete', $mediaID); 252 } 253 msg(sprintf($this->getLang('delete_success'), hsc($mediaID)), 1); 254 } 255 256 private function handleUploadPost(): void { 257 global $INPUT, $ID, $conf; 258 259 if (!$INPUT->post->has('pagesicon_upload_submit')) return; 260 if (!checkSecurityToken()) return; 261 262 $targetPage = cleanID((string)$ID); 263 if (!$this->canUploadToTarget($targetPage)) { 264 msg($this->getLang('error_no_upload_permission'), -1); 265 return; 266 } 267 268 $variant = strtolower($INPUT->post->str('icon_variant')); 269 if (!in_array($variant, ['big', 'small'], true)) { 270 $variant = 'big'; 271 } 272 273 if (!isset($_FILES['pagesicon_file']) || !is_array($_FILES['pagesicon_file'])) { 274 msg($this->getLang('error_missing_file'), -1); 275 return; 276 } 277 278 $upload = $_FILES['pagesicon_file']; 279 if (($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { 280 msg($this->getLang('error_upload_failed') . ' (' . (int)($upload['error'] ?? -1) . ')', -1); 281 return; 282 } 283 284 $originalName = (string)($upload['name'] ?? ''); 285 $tmpName = (string)($upload['tmp_name'] ?? ''); 286 if ($tmpName === '' || !is_uploaded_file($tmpName)) { 287 msg($this->getLang('error_upload_failed'), -1); 288 return; 289 } 290 291 $ext = strtolower((string)pathinfo($originalName, PATHINFO_EXTENSION)); 292 if ($ext === '') { 293 msg($this->getLang('error_extension_missing'), -1); 294 return; 295 } 296 297 $helper = plugin_load('helper', 'pagesicon'); 298 $allowed = ($helper && method_exists($helper, 'getConfiguredExtensions')) 299 ? $helper->getConfiguredExtensions() 300 : []; 301 if (!in_array($ext, $allowed, true)) { 302 msg(sprintf($this->getLang('error_extension_not_allowed'), hsc($ext), hsc(implode(', ', $allowed))), -1); 303 return; 304 } 305 306 $choices = ($helper && method_exists($helper, 'getUploadNameChoices')) 307 ? $helper->getUploadNameChoices($targetPage, $variant) 308 : []; 309 $base = $this->getPostedBaseName($choices); 310 $namespace = getNS($targetPage); 311 $mediaBase = $namespace !== '' ? ($namespace . ':' . $base) : $base; 312 $mediaID = cleanID($mediaBase . '.' . $ext); 313 $targetFile = mediaFN($mediaID); 314 315 io_makeFileDir($targetFile); 316 if (!@is_dir(dirname($targetFile))) { 317 msg($this->getLang('error_write_dir'), -1); 318 return; 319 } 320 321 $moved = @move_uploaded_file($tmpName, $targetFile); 322 if (!$moved) { 323 $moved = @copy($tmpName, $targetFile); 324 } 325 if (!$moved) { 326 msg($this->getLang('error_write_file'), -1); 327 return; 328 } 329 330 @chmod($targetFile, $conf['fmode']); 331 if ($helper) { 332 $helper->notifyIconUpdated($targetPage, 'upload', $mediaID); 333 } 334 msg(sprintf($this->getLang('upload_success'), hsc($mediaID)), 1); 335 } 336 337 private function renderUploadForm(): void { 338 global $ID, $INPUT; 339 340 $defaultTarget = $this->getDefaultTarget(); 341 $defaultVariant = $this->getDefaultVariant(); 342 $helper = plugin_load('helper', 'pagesicon'); 343 $allowed = ($helper && method_exists($helper, 'getConfiguredExtensions')) 344 ? implode(', ', $helper->getConfiguredExtensions()) 345 : ''; 346 $currentChoices = ($helper && method_exists($helper, 'getUploadNameChoices')) 347 ? $helper->getUploadNameChoices($defaultTarget, $defaultVariant) 348 : []; 349 $selectedBase = $helper ? $helper->normalizeIconBaseName($INPUT->str('icon_filename')) : ''; 350 if (!isset($currentChoices[$selectedBase])) { 351 $selectedBase = (string)array_key_first($currentChoices); 352 } 353 $filenameHelp = hsc($this->getLang('icon_filename_help')); 354 $actionPage = $defaultTarget !== '' ? $defaultTarget : cleanID((string)$ID); 355 $namespace = getNS($defaultTarget); 356 $pageID = noNS($defaultTarget); 357 $previewSize = $this->getIconSize(); 358 $currentBig = ($helper && method_exists($helper, 'getPageIconId')) ? $helper->getPageIconId($namespace, $pageID, 'big') : false; 359 $currentSmall = ($helper && method_exists($helper, 'getPageIconId')) ? $helper->getPageIconId($namespace, $pageID, 'small') : false; 360 361 echo '<h1>' . hsc($this->getLang('menu')) . '</h1>'; 362 echo '<p>' . hsc($this->getLang('intro')) . '</p>'; 363 echo '<p><small>' . hsc(sprintf($this->getLang('allowed_extensions'), $allowed)) . '</small></p>'; 364 echo '<div class="pagesicon-current-preview" style="display:flex;gap:24px;align-items:flex-start;flex-wrap:wrap;margin:10px 0 16px;">'; 365 echo '<div class="pagesicon-current-item">'; 366 echo '<strong>' . hsc($this->getLang('current_big_icon')) . '</strong><br />'; 367 if ($currentBig) { 368 $this->renderCurrentIconPreview($currentBig, $defaultTarget, $actionPage, $previewSize); 369 } else { 370 echo '<small>' . hsc($this->getLang('current_icon_none')) . '</small>'; 371 } 372 echo '</div>'; 373 echo '<div class="pagesicon-current-item">'; 374 echo '<strong>' . hsc($this->getLang('current_small_icon')) . '</strong><br />'; 375 if ($currentSmall) { 376 $this->renderCurrentIconPreview($currentSmall, $defaultTarget, $actionPage, $previewSize); 377 } else { 378 echo '<small>' . hsc($this->getLang('current_icon_none')) . '</small>'; 379 } 380 echo '</div>'; 381 echo '</div>'; 382 383 echo '<form action="' . wl($actionPage) . '" method="post" enctype="multipart/form-data"' 384 . ' class="pagesicon-upload-form"' 385 . ' data-page-name="' . hsc(noNS($defaultTarget)) . '"' 386 . ' data-big-templates="' . hsc(json_encode($helper ? $helper->getVariantTemplates('big') : [])) . '"' 387 . ' data-small-templates="' . hsc(json_encode($helper ? $helper->getVariantTemplates('small') : [])) . '">'; 388 formSecurityToken(); 389 echo '<input type="hidden" name="do" value="pagesicon" />'; 390 echo '<input type="hidden" name="pagesicon_upload_submit" value="1" />'; 391 392 echo '<div class="table"><table class="inline">'; 393 echo '<tr>'; 394 echo '<td class="label"><label for="pagesicon_icon_variant">' . hsc($this->getLang('icon_variant')) . '</label></td>'; 395 echo '<td>'; 396 echo '<select id="pagesicon_icon_variant" name="icon_variant" class="edit">'; 397 echo '<option value="big"' . ($defaultVariant === 'big' ? ' selected="selected"' : '') . '>' . hsc($this->getLang('icon_variant_big')) . '</option>'; 398 echo '<option value="small"' . ($defaultVariant === 'small' ? ' selected="selected"' : '') . '>' . hsc($this->getLang('icon_variant_small')) . '</option>'; 399 echo '</select>'; 400 echo '</td>'; 401 echo '</tr>'; 402 403 echo '<tr>'; 404 echo '<td class="label"><label for="pagesicon_file">' . hsc($this->getLang('file')) . '</label></td>'; 405 echo '<td><input type="file" id="pagesicon_file" name="pagesicon_file" class="edit" required /></td>'; 406 echo '</tr>'; 407 408 echo '<tr>'; 409 echo '<td class="label"><label for="pagesicon_icon_filename">' . hsc($this->getLang('icon_filename')) . '</label></td>'; 410 echo '<td>'; 411 if ($currentChoices) { 412 echo '<select id="pagesicon_icon_filename" name="icon_filename" class="edit">'; 413 foreach ($currentChoices as $value => $label) { 414 $selected = $value === $selectedBase ? ' selected="selected"' : ''; 415 echo '<option value="' . hsc($value) . '"' . $selected . '>' . hsc($label) . '</option>'; 416 } 417 echo '</select>'; 418 echo '<br /><small>' . $filenameHelp . '</small>'; 419 } else { 420 echo '<span class="error">' . hsc($this->getLang('error_no_filename_choices')) . '</span>'; 421 } 422 echo '</td>'; 423 echo '</tr>'; 424 echo '</table></div>'; 425 426 echo '<p><button type="submit" class="button">' . hsc($this->getLang('upload_button')) . '</button></p>'; 427 echo '</form>'; 428 } 429 430 public function displayPageIcon(Doku_Event &$event, $param): void { 431 global $ACT, $ID; 432 433 if($ACT !== 'show') return; 434 if(!(bool)$this->getConf('show_on_top')) return; 435 436 if($this->isLayoutIncludePage()) return; 437 438 $namespace = getNS($ID); 439 $pageID = noNS((string)$ID); 440 /** @var helper_plugin_pagesicon|null $helper */ 441 $helper = plugin_load('helper', 'pagesicon'); 442 if(!$helper) return; 443 $sizeMode = $this->getIconSize() > 35 ? 'bigorsmall' : 'smallorbig'; 444 $logoMediaID = $helper->getPageIconId($namespace, $pageID, $sizeMode); 445 if(!$logoMediaID) return; 446 if($this->hasIconAlready($event->data)) return; 447 448 $size = $this->getIconSize(); 449 $src = $helper->getPageIconUrl($namespace, $pageID, $sizeMode, ['w' => $size]); 450 if(!$src) return; 451 $iconHtml = '<img src="' . $src . '" class="media pagesicon-image" loading="lazy" alt="" width="' . $size . '" />'; 452 453 $inlineIcon = '<span class="pagesicon-injected pagesicon-injected-inline">' . $iconHtml . '</span> '; 454 $updated = preg_replace('/<h1\b([^>]*)>/i', '<h1$1>' . $inlineIcon, $event->data, 1, $count); 455 if ($count > 0 && $updated !== null) { 456 $event->data = $updated; 457 return; 458 } 459 460 // Fallback: no H1 found, keep old behavior 461 $event->data = '<div class="pagesicon-injected">' . $iconHtml . '</div>' . "\n" . $event->data; 462 } 463 464 private static array $linkIconCache = []; 465 466 private function getLinkIconUrl(object $helper, string $pageId): ?string { 467 if (!array_key_exists($pageId, self::$linkIconCache)) { 468 $url = $helper->getPageIconUrl(getNS($pageId), noNS($pageId), 'smallorbig', ['w' => 16]); 469 self::$linkIconCache[$pageId] = $url ?: null; 470 } 471 return self::$linkIconCache[$pageId]; 472 } 473 474 public function injectLinkIcons(Doku_Event $event): void { 475 if ($event->data[0] !== 'xhtml') return; 476 477 $conf = $this->getConf('link_icons'); 478 if ($conf === 'none') return; 479 480 $helper = plugin_load('helper', 'pagesicon'); 481 if (!$helper) return; 482 483 $event->data[1] = preg_replace_callback( 484 '~(<a\b[^>]*\bclass="[^"]*\bwikilink([12])[^"]*"[^>]*\btitle="([^"]+)"[^>]*>)~', 485 function ($m) use ($conf, $helper) { 486 if ($m[2] === '2' && $conf !== 'all') return $m[1]; 487 $pageId = html_entity_decode($m[3], ENT_QUOTES | ENT_HTML5, 'UTF-8'); 488 $iconUrl = $this->getLinkIconUrl($helper, $pageId); 489 if (!$iconUrl) return $m[1]; 490 return $m[1] . '<img src="' . $iconUrl . '" class="pagesicon-link" alt="" width="16" height="16" loading="lazy">'; 491 }, 492 (string)$event->data[1] 493 ); 494 } 495 496 public function handleAction(Doku_Event $event): void { 497 if ($event->data !== 'pagesicon') return; 498 $event->preventDefault(); 499 } 500 501 public function renderAction(Doku_Event $event): void { 502 global $ACT; 503 if ($ACT !== 'pagesicon') return; 504 505 $this->handleDeletePost(); 506 $this->handleUploadPost(); 507 $this->renderUploadForm(); 508 509 $event->preventDefault(); 510 $event->stopPropagation(); 511 } 512} 513