mirror of
https://github.com/avecms/AVE.cms.git
synced 2026-08-01 08:45:44 +00:00
377 lines
11 KiB
PHP
377 lines
11 KiB
PHP
<?php
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------------------
|
|
| AVE.cms
|
|
|--------------------------------------------------------------------------------------
|
|
| @package AVE.cms
|
|
| @file system/App/Frontend/Media/ThumbnailGateway.php
|
|
| @author AVE.cms <support@ave-cms.ru>
|
|
| @copyright 2007-2026 (c) AVE.cms
|
|
| @link https://ave-cms.ru
|
|
| @version 3.3
|
|
*/
|
|
|
|
namespace App\Frontend\Media;
|
|
|
|
defined('BASEPATH') || die('Direct access to this location is not allowed.');
|
|
|
|
use App\Common\OutboundHttpClient;
|
|
use App\Common\Lock;
|
|
use App\Helpers\Dir;
|
|
use App\Helpers\File;
|
|
|
|
/** Serves and lazily generates public thumbnails from deterministic URLs. */
|
|
class ThumbnailGateway
|
|
{
|
|
public static function handle()
|
|
{
|
|
self::loadConfiguration();
|
|
$imageFile = self::requestedFile();
|
|
if ($imageFile === '/inc/thumb.php') {
|
|
self::notFound(false);
|
|
}
|
|
|
|
$descriptor = self::descriptor($imageFile);
|
|
if (!$descriptor) {
|
|
self::notFound();
|
|
}
|
|
|
|
ThumbnailStorage::ensureCurrent($descriptor['source']);
|
|
if (self::isFresh($descriptor)) {
|
|
self::sendFile($descriptor['target']);
|
|
}
|
|
|
|
if (!defined('HOST')) {
|
|
\App\Common\PublicBootstrap::initializeEnvironment();
|
|
}
|
|
|
|
self::generate($descriptor);
|
|
}
|
|
|
|
protected static function loadConfiguration()
|
|
{
|
|
\App\Common\RuntimeConstants::load();
|
|
\App\Common\PublicConfiguration::load();
|
|
}
|
|
|
|
protected static function requestedFile()
|
|
{
|
|
$uri = isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : '/';
|
|
$path = parse_url($uri, PHP_URL_PATH);
|
|
return '/' . ltrim(urldecode((string) $path), '/');
|
|
}
|
|
|
|
protected static function descriptor($imageFile)
|
|
{
|
|
$imageFile = '/' . ltrim(str_replace('\\', '/', (string) $imageFile), '/');
|
|
if (strpos($imageFile, "\0") !== false || strpos($imageFile, '/../') !== false || substr($imageFile, -3) === '/..') {
|
|
return null;
|
|
}
|
|
|
|
$uploadMarker = '/' . trim(UPLOAD_DIR, '/') . '/';
|
|
if (strpos($imageFile, $uploadMarker) !== 0) {
|
|
return null;
|
|
}
|
|
|
|
$relativeThumbDir = trim(substr(dirname($imageFile), strlen($uploadMarker)), '/');
|
|
$thumbDirName = ThumbnailUrl::directoryName();
|
|
if ($thumbDirName !== '' && basename($relativeThumbDir) !== $thumbDirName) {
|
|
return null;
|
|
}
|
|
|
|
$thumbPath = BASEPATH . $uploadMarker . $relativeThumbDir;
|
|
$sourcePath = $thumbDirName !== '' ? dirname($thumbPath) : $thumbPath;
|
|
$thumbName = basename($imageFile);
|
|
$parsed = self::parseName($thumbName);
|
|
if (!$parsed || !self::sizeAllowed($parsed['check'])) {
|
|
return null;
|
|
}
|
|
|
|
return array(
|
|
'target' => $thumbPath . '/' . $thumbName,
|
|
'target_dir' => $thumbPath,
|
|
'target_name' => $thumbName,
|
|
'source' => $sourcePath . '/' . $parsed['source'],
|
|
'source_dir' => $sourcePath,
|
|
'source_name' => $parsed['source'],
|
|
'options' => $parsed,
|
|
);
|
|
}
|
|
|
|
protected static function generate(array $descriptor)
|
|
{
|
|
$generate = function () use ($descriptor) {
|
|
if (self::isFresh($descriptor)) {
|
|
self::sendFile($descriptor['target']);
|
|
}
|
|
|
|
$sourcePath = $descriptor['source_dir'];
|
|
$sourceName = $descriptor['source_name'];
|
|
self::hydrateRemoteSource($descriptor['source']);
|
|
$save = true;
|
|
if (!is_file($descriptor['source'])) {
|
|
self::reportMissing();
|
|
$sourceName = 'noimage.png';
|
|
if (!is_file($sourcePath . '/' . $sourceName)) {
|
|
$sourcePath = rtrim(BASEPATH, '/') . '/' . trim(UPLOAD_DIR, '/') . '/images';
|
|
}
|
|
|
|
if (!is_file($sourcePath . '/' . $sourceName)) {
|
|
exit;
|
|
}
|
|
|
|
$save = false;
|
|
}
|
|
|
|
defined('IMAGE_TOOLBOX_DEFAULT_JPEG_QUALITY') || define('IMAGE_TOOLBOX_DEFAULT_JPEG_QUALITY', self::jpegQuality());
|
|
require_once BASEPATH . '/system/vendor/ImageToolbox/ImageToolbox.php';
|
|
$thumb = new \Image_Toolbox($sourcePath . '/' . $sourceName);
|
|
self::resize($thumb, $descriptor['options']);
|
|
|
|
if ($save) {
|
|
$target = self::save($thumb, $descriptor['target_dir'], $descriptor['target_name']);
|
|
unset($thumb);
|
|
if ($target !== false) {
|
|
self::sendFile($target);
|
|
}
|
|
|
|
self::notFound();
|
|
}
|
|
|
|
$thumb->output();
|
|
exit;
|
|
};
|
|
try {
|
|
Lock::run('thumbnail:' . $descriptor['target'], $generate);
|
|
} catch (\RuntimeException $e) {
|
|
$generate();
|
|
}
|
|
}
|
|
|
|
protected static function parseName($thumbName)
|
|
{
|
|
$parts = explode('.', (string) $thumbName);
|
|
if (count($parts) < 2 || !in_array(strtolower(end($parts)), self::extensions(), true)) {
|
|
return null;
|
|
}
|
|
|
|
$index = count($parts) - 2;
|
|
if (!preg_match('/-(r|c|f|t|s)(\d+)x(\d+)(r)*$/i', $parts[$index], $match)) {
|
|
return null;
|
|
}
|
|
|
|
$suffix = $match[0];
|
|
$parts[$index] = substr($parts[$index], 0, -strlen($suffix));
|
|
return array(
|
|
'source' => implode('.', $parts),
|
|
'method' => strtolower($match[1]),
|
|
'width' => (int) $match[2],
|
|
'height' => (int) $match[3],
|
|
'rotate' => !empty($match[4]),
|
|
'check' => ltrim($suffix, '-'),
|
|
);
|
|
}
|
|
|
|
protected static function extensions()
|
|
{
|
|
$extensions = array('jpg', 'jpeg', 'png', 'gif');
|
|
if (function_exists('imagecreatefromwebp') && function_exists('imagewebp')) {
|
|
$extensions[] = 'webp';
|
|
}
|
|
|
|
return $extensions;
|
|
}
|
|
|
|
protected static function sizeAllowed($check)
|
|
{
|
|
return ThumbnailPresetPolicy::allowed((string) $check);
|
|
}
|
|
|
|
protected static function hydrateRemoteSource($source)
|
|
{
|
|
if (is_file($source) || !is_file($source . '.tmp')) {
|
|
return;
|
|
}
|
|
|
|
$url = trim((string) File::getContent($source . '.tmp'));
|
|
try {
|
|
$response = $url !== '' ? OutboundHttpClient::get($url, array(
|
|
'allowed_schemes' => array('http', 'https'),
|
|
'allowed_ports' => array(80, 443),
|
|
'connect_timeout' => 5,
|
|
'timeout' => 25,
|
|
'max_redirects' => 3,
|
|
'max_bytes' => 26214400,
|
|
'headers' => array('Accept: image/webp,image/png,image/jpeg,image/gif;q=0.9'),
|
|
)) : null;
|
|
$data = is_array($response) ? (string) $response['body'] : '';
|
|
$image = $data !== '' ? @getimagesizefromstring($data) : false;
|
|
$types = array(IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_GIF);
|
|
if (defined('IMAGETYPE_WEBP')) { $types[] = IMAGETYPE_WEBP; }
|
|
if (is_array($image) && !empty($image[0]) && !empty($image[1]) && isset($image[2])
|
|
&& (int) $image[0] <= 12000 && (int) $image[1] <= 12000
|
|
&& (int) $image[0] * (int) $image[1] <= 50000000
|
|
&& in_array((int) $image[2], $types, true)) {
|
|
File::putAtomic($source, $data);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// Invalid or unavailable remote sources are represented by the placeholder.
|
|
}
|
|
|
|
File::delete($source . '.tmp');
|
|
}
|
|
|
|
protected static function resize($thumb, array $options)
|
|
{
|
|
$width = $options['width'];
|
|
$height = $options['height'];
|
|
$rotate = (bool) $options['rotate'];
|
|
switch ($options['method']) {
|
|
case 'r': $thumb->newOutputSize($width, $height, 0, $rotate); break;
|
|
case 'c': $thumb->newOutputSize($width, $height, 1, $rotate); break;
|
|
case 'f': $thumb->newOutputSize($width, $height, 2, false, '#ffffff'); break;
|
|
case 't': $thumb->newOutputSize($width, $height, 3, false); break;
|
|
case 's': $thumb->newOutputSize($width, $height, 4, $rotate); break;
|
|
}
|
|
}
|
|
|
|
protected static function save($thumb, $thumbPath, $thumbName)
|
|
{
|
|
if (!Dir::create($thumbPath)) {
|
|
return false;
|
|
}
|
|
|
|
$target = $thumbPath . '/' . $thumbName;
|
|
$temporary = tempnam($thumbPath, '.thumb-');
|
|
if ($temporary === false || !$thumb->save($temporary)) {
|
|
if ($temporary !== false) {
|
|
File::delete($temporary);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
if (!@rename($temporary, $target)) {
|
|
File::delete($temporary);
|
|
return false;
|
|
}
|
|
|
|
@chmod($target, 0664);
|
|
if (empty($thumb->_img['main']['type']) || (int) $thumb->_img['main']['type'] !== 2 || !THUMBNAIL_IPTC) {
|
|
return $target;
|
|
}
|
|
|
|
self::writeIptc($target);
|
|
return $target;
|
|
}
|
|
|
|
protected static function writeIptc($target)
|
|
{
|
|
$info = array();
|
|
@getimagesize($target, $info);
|
|
if (isset($info['APP13']) || !function_exists('iptcembed')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$siteName = (string) \App\Frontend\PublicSettings::get('site_name', '');
|
|
$encodedName = iconv('UTF-8', 'WINDOWS-1251//TRANSLIT', $siteName);
|
|
$tags = array(
|
|
'120' => $encodedName !== false ? $encodedName : $siteName,
|
|
'116' => defined('HOST') ? (string) HOST : '',
|
|
);
|
|
$data = '';
|
|
foreach ($tags as $tag => $value) {
|
|
$data .= self::iptcTag(2, $tag, $value);
|
|
}
|
|
|
|
$content = @iptcembed($data, $target);
|
|
if (is_string($content) && $content !== '') {
|
|
File::putAtomic($target, $content);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// IPTC is optional; a valid thumbnail must still be served.
|
|
}
|
|
}
|
|
|
|
protected static function iptcTag($record, $dataSet, $value)
|
|
{
|
|
$value = (string) $value;
|
|
$length = strlen($value);
|
|
$tag = chr(0x1C) . chr($record) . chr($dataSet);
|
|
if ($length < 0x8000) {
|
|
return $tag . chr($length >> 8) . chr($length & 0xFF) . $value;
|
|
}
|
|
|
|
return $tag . chr(0x80) . chr(0x04)
|
|
. chr(($length >> 24) & 0xFF) . chr(($length >> 16) & 0xFF)
|
|
. chr(($length >> 8) & 0xFF) . chr($length & 0xFF) . $value;
|
|
}
|
|
|
|
protected static function sendFile($file)
|
|
{
|
|
$info = @getimagesize($file);
|
|
if (function_exists('apache_setenv')) {
|
|
@apache_setenv('THUMBNAIL_RESPONSE', '1');
|
|
}
|
|
|
|
$modified = (int) @filemtime($file);
|
|
$etag = '"' . sha1((string) $modified . ':' . (int) @filesize($file)) . '"';
|
|
$ifNoneMatch = isset($_SERVER['HTTP_IF_NONE_MATCH']) ? trim((string) $_SERVER['HTTP_IF_NONE_MATCH']) : '';
|
|
$ifModifiedSince = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) ? strtotime((string) $_SERVER['HTTP_IF_MODIFIED_SINCE']) : false;
|
|
$lifetime = defined('THUMBNAIL_CACHE_LIFETIME') ? max(0, (int) THUMBNAIL_CACHE_LIFETIME) : 1209600;
|
|
header('Cache-Control: public, max-age=' . $lifetime);
|
|
header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $lifetime) . ' GMT');
|
|
header('ETag: ' . $etag);
|
|
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $modified) . ' GMT');
|
|
if ($ifNoneMatch === $etag || ($ifModifiedSince !== false && $modified <= $ifModifiedSince)) {
|
|
http_response_code(304);
|
|
exit;
|
|
}
|
|
|
|
http_response_code(200);
|
|
header('Content-Type: ' . ($info && isset($info['mime']) ? $info['mime'] : 'application/octet-stream'));
|
|
header('Content-Length: ' . filesize($file));
|
|
readfile($file);
|
|
exit;
|
|
}
|
|
|
|
protected static function isFresh(array $descriptor)
|
|
{
|
|
if (!is_file($descriptor['target'])) {
|
|
return false;
|
|
}
|
|
|
|
$source = $descriptor['source'];
|
|
$marker = $source . '.tmp';
|
|
$sourceChanged = is_file($source) ? (int) filemtime($source) : 0;
|
|
$markerChanged = is_file($marker) ? (int) filemtime($marker) : 0;
|
|
return (int) filemtime($descriptor['target']) >= max($sourceChanged, $markerChanged);
|
|
}
|
|
|
|
protected static function jpegQuality()
|
|
{
|
|
$quality = defined('JPG_QUALITY') ? (int) JPG_QUALITY : 90;
|
|
return max(40, min(100, $quality));
|
|
}
|
|
|
|
protected static function reportMissing()
|
|
{
|
|
http_response_code(404);
|
|
\App\Common\CsvLogWriter::notFound();
|
|
}
|
|
|
|
protected static function notFound($report = true)
|
|
{
|
|
if ($report) {
|
|
self::reportMissing();
|
|
} else {
|
|
http_response_code(404);
|
|
}
|
|
|
|
echo 'No image';
|
|
exit;
|
|
}
|
|
}
|