xref: /plugin/pagesicon/action.php (revision c8e99a276375ecec8f29dd837c92b2b86eeb3f41)
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