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