diff --git a/Output.php b/Output.php index 6ce7cde..3f9351d 100644 --- a/Output.php +++ b/Output.php @@ -54,23 +54,74 @@ class Output return; } + $thumbW = 150; + $thumbH = 150; + + $placeholderUrl = DOKU_BASE . 'lib/images/blank.gif'; + $syntax = plugin_load('syntax', 'luxtools'); + $placeholderId = $syntax ? trim((string)$syntax->getConf('thumb_placeholder')) : ''; + if ($placeholderId !== '' && function_exists('ml')) { + // ml() builds a fetch.php URL for a MediaManager item + $placeholderUrl = ml($placeholderId, ['w' => $thumbW, 'h' => $thumbH], true, '&'); + } + /** @var \Doku_Renderer_xhtml $renderer */ $renderer = $this->renderer; $renderer->doc .= ''; } + /** + * Append query parameters to a URL. + * + * Preserves existing query and fragment and uses RFC3986 encoding. + * + * @param string $url + * @param array $params + * @return string + */ + protected function withQueryParams(string $url, array $params): string + { + if ($params === []) return $url; + + $fragment = ''; + $hashPos = strpos($url, '#'); + if ($hashPos !== false) { + $fragment = substr($url, $hashPos); + $url = substr($url, 0, $hashPos); + } + + $glue = (strpos($url, '?') === false) ? '?' : '&'; + return $url . $glue . http_build_query($params, '', '&', PHP_QUERY_RFC3986) . $fragment; + } + /** * Renders the files as a table, including details if configured that way. * diff --git a/_test/SyntaxTest.php b/_test/SyntaxTest.php index 2481aed..4c387d3 100644 --- a/_test/SyntaxTest.php +++ b/_test/SyntaxTest.php @@ -239,6 +239,9 @@ class plugin_luxtools_test extends DokuWikiTest $this->structureCheck($doc, $structure); $this->assertStringContainsString('exampleimage.png', $xhtml); + $this->assertStringContainsString('data-thumb-src=', $xhtml); + $this->assertStringContainsString('width="150"', $xhtml); + $this->assertStringContainsString('height="150"', $xhtml); } /** diff --git a/conf/default.php b/conf/default.php index feb1322..826614c 100644 --- a/conf/default.php +++ b/conf/default.php @@ -9,6 +9,9 @@ $conf['allow_in_comments'] = 0; $conf['defaults'] = ''; $conf['extensions'] = ''; +// MediaManager ID for gallery thumbnail placeholder +$conf['thumb_placeholder'] = ':wiki:thumb-placeholder.png'; + // Local opener service used by {{open>...}}. $conf['open_service_url'] = 'http://127.0.0.1:8765'; $conf['open_service_token'] = ''; diff --git a/conf/metadata.php b/conf/metadata.php index ec3812c..479d907 100644 --- a/conf/metadata.php +++ b/conf/metadata.php @@ -12,5 +12,7 @@ $meta['allow_in_comments'] = array('onoff'); $meta['defaults'] = array('string'); $meta['extensions'] = array('string'); +$meta['thumb_placeholder'] = array('string'); + $meta['open_service_url'] = array('string'); $meta['open_service_token'] = array('string'); diff --git a/file.php b/file.php index 04b3089..f680c67 100644 --- a/file.php +++ b/file.php @@ -17,6 +17,172 @@ if (!$syntax) die('plugin disabled?'); $pathUtil = new Path($syntax->getConf('paths')); $path = $INPUT->str('root') . $INPUT->str('file'); +/** + * Send a file to the client with basic caching headers. + * + * @param string $path + * @param string $mime + * @param bool $download + * @param string|null $downloadName + * @param string|null $etag + * @param int|null $mtime + * @param int|null $maxAge + * @return void + */ +function luxtools_sendfile($path, $mime, $download = false, $downloadName = null, $etag = null, $mtime = null, $maxAge = null) +{ + header('Content-Type: ' . $mime); + header('X-Content-Type-Options: nosniff'); + + if ($download) { + $downloadName = $downloadName ?: basename($path); + header('Content-Disposition: attachment; filename="' . $downloadName . '"'); + } + + if ($etag !== null) { + header('ETag: "' . $etag . '"'); + } + if ($mtime !== null) { + header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $mtime) . ' GMT'); + } + + if ($maxAge !== null) { + header('Cache-Control: public, max-age=' . (int)$maxAge . ', immutable'); + } + + // Conditional request handling + if ($etag !== null && isset($_SERVER['HTTP_IF_NONE_MATCH'])) { + if (trim($_SERVER['HTTP_IF_NONE_MATCH']) === '"' . $etag . '"') { + http_status(304); + exit; + } + } + if ($mtime !== null && isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) { + $ims = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']); + if ($ims !== false && $ims >= $mtime) { + http_status(304); + exit; + } + } + + http_sendfile($path); + readfile($path); + exit; +} + +/** + * Create a thumbnail file using GD. + * + * @param string $src + * @param string $dst + * @param int $maxW + * @param int $maxH + * @param string $dstFormat 'jpg' or 'png' + * @param int $quality JPEG quality 0-100 + * @return bool + */ +function luxtools_create_thumb_gd($src, $dst, $maxW, $maxH, $dstFormat, $quality) +{ + if (!function_exists('imagecreatetruecolor')) return false; + if (!is_readable($src)) return false; + + $info = @getimagesize($src); + if (!is_array($info) || empty($info[0]) || empty($info[1]) || empty($info['mime'])) return false; + + $srcW = (int)$info[0]; + $srcH = (int)$info[1]; + $srcMime = (string)$info['mime']; + if ($srcW <= 0 || $srcH <= 0) return false; + + $maxW = max(1, (int)$maxW); + $maxH = max(1, (int)$maxH); + + $scale = min($maxW / $srcW, $maxH / $srcH, 1); + $dstW = max(1, (int)floor($srcW * $scale)); + $dstH = max(1, (int)floor($srcH * $scale)); + + switch ($srcMime) { + case 'image/jpeg': + if (!function_exists('imagecreatefromjpeg')) return false; + $srcImg = @imagecreatefromjpeg($src); + break; + case 'image/png': + if (!function_exists('imagecreatefrompng')) return false; + $srcImg = @imagecreatefrompng($src); + break; + case 'image/gif': + if (!function_exists('imagecreatefromgif')) return false; + $srcImg = @imagecreatefromgif($src); + break; + case 'image/webp': + if (!function_exists('imagecreatefromwebp')) return false; + $srcImg = @imagecreatefromwebp($src); + break; + default: + return false; + } + + if (!$srcImg) return false; + + $dstImg = imagecreatetruecolor($dstW, $dstH); + if (!$dstImg) { + imagedestroy($srcImg); + return false; + } + + // Preserve transparency for formats that support it + if ($dstFormat === 'png') { + imagealphablending($dstImg, false); + imagesavealpha($dstImg, true); + $transparent = imagecolorallocatealpha($dstImg, 0, 0, 0, 127); + imagefilledrectangle($dstImg, 0, 0, $dstW, $dstH, $transparent); + } + + $ok = imagecopyresampled($dstImg, $srcImg, 0, 0, 0, 0, $dstW, $dstH, $srcW, $srcH); + imagedestroy($srcImg); + + if (!$ok) { + imagedestroy($dstImg); + return false; + } + + // Write to a temporary file then rename atomically + $tmp = $dst . '.tmp.' . getmypid(); + @io_mkdir_p(dirname($dst)); + + $written = false; + if ($dstFormat === 'png') { + if (!function_exists('imagepng')) { + $written = false; + } else { + // compression: 0 (none) .. 9 (max). Use a reasonable default. + $written = @imagepng($dstImg, $tmp, 6); + } + } else { + if (!function_exists('imagejpeg')) { + $written = false; + } else { + $q = max(0, min(100, (int)$quality)); + $written = @imagejpeg($dstImg, $tmp, $q); + } + } + imagedestroy($dstImg); + + if (!$written || !is_file($tmp)) { + @unlink($tmp); + return false; + } + + // Best-effort atomic move + if (!@rename($tmp, $dst)) { + // fallback copy + $ok = @copy($tmp, $dst); + @unlink($tmp); + if (!$ok) return false; + } + return true; +} + try { $pathInfo = $pathUtil->getPathInfo($path, false); if ($pathUtil::isWikiControlled($pathInfo['path'])) { @@ -30,13 +196,47 @@ try { exit; } [$ext, $mime, $download] = mimetype($pathInfo['path'], false); - $basename = basename($pathInfo['path']); - header('Content-Type: ' . $mime); - if ($download) { - header('Content-Disposition: attachment; filename="' . $basename . '"'); + + // Optional thumbnail mode: ?thumb=1&w=150&h=150 + $thumb = (int)$INPUT->int('thumb'); + $w = (int)$INPUT->int('w'); + $h = (int)$INPUT->int('h'); + $q = (int)$INPUT->int('q'); + if ($q <= 0) $q = 80; + + $isImage = is_string($mime) && str_starts_with($mime, 'image/'); + $wantThumb = $thumb === 1 && $isImage && ($w > 0 || $h > 0); + + if ($wantThumb) { + if ($w <= 0) $w = $h; + if ($h <= 0) $h = $w; + + global $conf; + $srcMtime = @filemtime($pathInfo['path']) ?: time(); + + // Decide output format (prefer PNG when transparency is likely) + $dstFormat = ($mime === 'image/png' || $mime === 'image/gif') ? 'png' : 'jpg'; + $dstMime = ($dstFormat === 'png') ? 'image/png' : 'image/jpeg'; + $hash = sha1($pathInfo['path'] . '|' . $srcMtime . '|w=' . $w . '|h=' . $h . '|q=' . $q . '|f=' . $dstFormat); + $sub = substr($hash, 0, 2); + $cacheDir = rtrim($conf['cachedir'], '/'); + $thumbPath = $cacheDir . '/luxtools/thumbs/' . $sub . '/' . $hash . '.' . $dstFormat; + + if (!is_file($thumbPath)) { + $ok = luxtools_create_thumb_gd($pathInfo['path'], $thumbPath, $w, $h, $dstFormat, $q); + if (!$ok || !is_file($thumbPath)) { + // Fallback: serve original if we cannot thumbnail + luxtools_sendfile($pathInfo['path'], $mime, $download, basename($pathInfo['path']), null, $srcMtime, 3600); + } + } + + // Cached thumbs are immutable because filename includes mtime + luxtools_sendfile($thumbPath, $dstMime, false, null, $hash, @filemtime($thumbPath) ?: $srcMtime, 31536000); } - http_sendfile($pathInfo['path']); - readfile($pathInfo['path']); + + // Default: serve original file + $basename = basename($pathInfo['path']); + luxtools_sendfile($pathInfo['path'], $mime, $download, $basename, null, @filemtime($pathInfo['path']) ?: null, 3600); } catch (Exception $e) { header('Content-Type: text/plain'); http_status(403); diff --git a/lang/de/settings.php b/lang/de/settings.php index 5629d41..c05e006 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -2,5 +2,7 @@ $lang['allow_in_comments'] = 'Files-Syntax in Kommentaren erlauben.'; +$lang['thumb_placeholder'] = 'MediaManager-ID für den Platzhalter der Galerie-Thumbnails'; + $lang['open_service_url'] = 'URL des lokalen Öffner-Dienstes für {{open>...}} (z.B. http://127.0.0.1:8765).'; $lang['open_service_token'] = 'Token für den lokalen Öffner-Dienst (X-Filetools-Token).'; diff --git a/lang/en/settings.php b/lang/en/settings.php index 3c261be..52398d7 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -4,5 +4,7 @@ $lang['allow_in_comments'] = 'Whether to allow the files syntax to be used in co $lang['defaults'] = 'Default options. Use the same syntax as in inline configuration'; $lang['extensions'] = 'Comma-separated list of allowed file extensions to list'; +$lang['thumb_placeholder'] = 'MediaManager ID for the gallery thumbnail placeholder'; + $lang['open_service_url'] = 'Local opener service URL for the {{open>...}} button (e.g. http://127.0.0.1:8765).'; $lang['open_service_token'] = 'Token sent to the local opener service (X-Filetools-Token).'; diff --git a/lang/nl/settings.php b/lang/nl/settings.php index cda7a0a..480c08d 100644 --- a/lang/nl/settings.php +++ b/lang/nl/settings.php @@ -8,5 +8,7 @@ $lang['allow_in_comments'] = 'Of de files syntax toegestaan is voor gebruik in c $lang['defaults'] = 'Default options. Gebruik dezelfde syntax als de inline configuratie.'; $lang['extensions'] = 'Komma-gescheiden lijst van toegestane bestandsextensies voor de lijst.'; +$lang['thumb_placeholder'] = 'MediaManager-ID voor de placeholder van galerij thumbnails'; + $lang['open_service_url'] = 'Lokale opener service-URL voor de {{open>...}} knop (bijv. http://127.0.0.1:8765).'; $lang['open_service_token'] = 'Token dat naar de lokale opener service wordt gestuurd (X-Filetools-Token).'; diff --git a/script.js b/script.js index 6d9310d..ebafb4c 100644 --- a/script.js +++ b/script.js @@ -3,6 +3,46 @@ (function () { 'use strict'; + function initGalleryThumbs() { + var imgs = document.querySelectorAll('div.filetools-gallery img[data-thumb-src]'); + if (!imgs || !imgs.length) return; + + function loadThumb(img) { + var src = img.getAttribute('data-thumb-src') || ''; + if (!src) return; + if (img.getAttribute('data-thumb-loading') === '1') return; + img.setAttribute('data-thumb-loading', '1'); + + var pre = new window.Image(); + pre.onload = function () { + img.src = src; + img.removeAttribute('data-thumb-src'); + img.removeAttribute('data-thumb-loading'); + }; + pre.onerror = function () { + img.removeAttribute('data-thumb-loading'); + }; + pre.src = src; + } + + if ('IntersectionObserver' in window) { + var io = new window.IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (!entry.isIntersecting) return; + loadThumb(entry.target); + io.unobserve(entry.target); + }); + }, { rootMargin: '200px' }); + + imgs.forEach(function (img) { io.observe(img); }); + } else { + // Fallback: load soon after initial render + window.setTimeout(function () { + imgs.forEach(loadThumb); + }, 0); + } + } + function getServiceUrl(el) { var url = el.getAttribute('data-service-url') || ''; url = (url || '').trim(); @@ -120,4 +160,5 @@ } document.addEventListener('click', onClick, false); + document.addEventListener('DOMContentLoaded', initGalleryThumbs, false); })();