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