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'])) { throw new Exception('Access to wiki files is not allowed'); } if (!is_readable($pathInfo['path'])) { header('Content-Type: text/plain'); http_status(404); echo 'Path not readable: ' . $pathInfo['path']; exit; } [$ext, $mime, $download] = mimetype($pathInfo['path'], false); // 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); } // 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); echo $e->getMessage(); exit; }