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