xref: /plugin/pagesicon/action.php (revision 437e13a7f4b0e352780dcdc554e067446a3b6187)
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
91		if (!isset($event->data['link']) || !is_array($event->data['link'])) {
92			$event->data['link'] = [];
93		}
94
95		$links = [];
96		foreach ($event->data['link'] as $link) {
97			if (!is_array($link)) {
98				$links[] = $link;
99				continue;
100			}
101
102			$rels = $link['rel'] ?? '';
103			if (!is_array($rels)) {
104				$rels = preg_split('/\s+/', strtolower(trim((string)$rels))) ?: [];
105			}
106			$rels = array_filter(array_map('strtolower', (array)$rels));
107			if (in_array('icon', $rels, true)) {
108				continue;
109			}
110			$links[] = $link;
111		}
112
113		$links[] = ['rel' => 'icon', 'href' => $favicon];
114		$links[] = ['rel' => 'shortcut icon', 'href' => $favicon]; // Kept for legacy browser compatibility.
115		$event->data['link'] = $links;
116	}
117
118	public function addUploadFormScript(Doku_Event $event): void {
119		global $ACT;
120
121		if ($ACT !== 'pagesicon') return;
122
123		if (!isset($event->data['script']) || !is_array($event->data['script'])) {
124			$event->data['script'] = [];
125		}
126
127		$event->data['script'][] = [
128			'type' => 'text/javascript',
129			'src' => DOKU_BASE . 'lib/plugins/pagesicon/script/upload-form.js',
130			'_data' => 'pagesicon-upload-form',
131		];
132	}
133
134	public function addFaviconRuntimeScript(Doku_Event $event): void {
135		global $ACT;
136
137		if (!(bool)$this->getConf('show_as_favicon')) return;
138		if ($ACT !== 'show') return;
139
140		if (!isset($event->data['script']) || !is_array($event->data['script'])) {
141			$event->data['script'] = [];
142		}
143
144		$event->data['script'][] = [
145			'type' => 'text/javascript',
146			'src' => DOKU_BASE . 'lib/plugins/pagesicon/script/favicon-runtime.js',
147			'_data' => 'pagesicon-favicon-runtime',
148		];
149	}
150
151	private function hasIconAlready(string $html, string $mediaID): bool {
152		return strpos($html, 'class="pagesicon-injected"') !== false;
153	}
154
155	private function injectFaviconRuntimeScript(string &$html, string $faviconHref): void {
156		if ($faviconHref === '') return;
157
158		$marker = '<span class="pagesicon-favicon-runtime" data-href="' . hsc($faviconHref) . '" hidden></span>';
159		$html = $marker . $html;
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) {
428        global $ACT, $ID;
429
430		if($ACT !== 'show') return;
431		if(!(bool)$this->getConf('show_on_top')) return;
432
433		$pageID = noNS($ID);
434		if($this->isLayoutIncludePage($pageID)) return;
435
436		$namespace = getNS($ID);
437		$pageID = noNS((string)$ID);
438		/** @var helper_plugin_pagesicon|null $helper */
439		$helper = plugin_load('helper', 'pagesicon');
440		if(!$helper) return;
441		$sizeMode = $this->getIconSize() > 35 ? 'bigorsmall' : 'smallorbig';
442		$logoMediaID = $helper->getPageIconId($namespace, $pageID, $sizeMode);
443		if(!$logoMediaID) return;
444		if($this->hasIconAlready($event->data, $logoMediaID)) return;
445
446		$size = $this->getIconSize();
447		$src = $helper->getPageIconUrl($namespace, $pageID, $sizeMode, ['w' => $size]);
448		if(!$src) return;
449		$iconHtml = '<img src="' . $src . '" class="media pagesicon-image" loading="lazy" alt="" width="' . $size . '" />';
450		if ((bool)$this->getConf('show_as_favicon')) {
451			$favicon = $helper->getPageIconUrl($namespace, $pageID, $sizeMode, ['w' => 32]);
452			$this->injectFaviconRuntimeScript($event->data, $favicon);
453		}
454
455		$inlineIcon = '<span class="pagesicon-injected pagesicon-injected-inline">' . $iconHtml . '</span> ';
456		$updated = preg_replace('/<h1\b([^>]*)>/i', '<h1$1>' . $inlineIcon, $event->data, 1, $count);
457		if ($count > 0 && $updated !== null) {
458			$event->data = $updated;
459			return;
460		}
461
462		// Fallback: no H1 found, keep old behavior
463		$event->data = '<div class="pagesicon-injected">' . $iconHtml . '</div>' . "\n" . $event->data;
464	}
465
466	public function handleAction(Doku_Event $event): void {
467		if ($event->data !== 'pagesicon') return;
468		$event->preventDefault();
469	}
470
471	public function renderAction(Doku_Event $event): void {
472		global $ACT;
473		if ($ACT !== 'pagesicon') return;
474
475		$this->handleDeletePost();
476		$this->handleUploadPost();
477		$this->renderUploadForm();
478
479		$event->preventDefault();
480		$event->stopPropagation();
481	}
482}
483