Files
Qiniu/Plugin.php
2026-02-23 19:48:13 +08:00

602 lines
21 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 七牛云存储+CDN+WebP扩展+图片质量+EXIF信息
*
* @package Qiniu
* @author 石头厝
* @version 2.6.0
* @link https://www.shitoucuo.com
* @date 2024-01-04
*/
require __DIR__ . '/vendor/autoload.php';
use Qiniu\Auth;
use Qiniu\Storage\UploadManager;
use Qiniu\Storage\BucketManager;
class Qiniu_Plugin implements Typecho_Plugin_Interface
{
// 使用静态变量确保CSS只加载一次
private static $_cssLoaded = false;
// 激活插件
public static function activate()
{
Typecho_Plugin::factory('Widget_Upload')->uploadHandle = array('Qiniu_Plugin', 'uploadHandle');
Typecho_Plugin::factory('Widget_Upload')->modifyHandle = array('Qiniu_Plugin', 'modifyHandle');
Typecho_Plugin::factory('Widget_Upload')->deleteHandle = array('Qiniu_Plugin', 'deleteHandle');
Typecho_Plugin::factory('Widget_Upload')->attachmentHandle = array('Qiniu_Plugin', 'attachmentHandle');
// 添加文章内容输出时的EXIF信息显示
Typecho_Plugin::factory('Widget_Abstract_Contents')->contentEx = array('Qiniu_Plugin', 'parseContent');
Typecho_Plugin::factory('Widget_Abstract_Contents')->excerptEx = array('Qiniu_Plugin', 'parseContent');
// 使用安全的CSS加载方式
Typecho_Plugin::factory('Widget_Archive')->footer = array('Qiniu_Plugin', 'footer');
return _t('插件已激活,请先配置七牛云信息!');
}
// 禁用插件
public static function deactivate()
{
return _t('插件已禁用');
}
// 插件配置面板
public static function config(Typecho_Widget_Helper_Form $form)
{
$bucket = new Typecho_Widget_Helper_Form_Element_Text('bucket', null, null, _t('空间名称:'), _t('七牛云存储空间名称'));
$bucket->addRule('required', _t('"空间名称"不能为空!'));
$form->addInput($bucket);
$accesskey = new Typecho_Widget_Helper_Form_Element_Text('accesskey', null, null, _t('AccessKey'), _t('从七牛云控制台获取'));
$form->addInput($accesskey->addRule('required', _t('AccessKey不能为空')));
$secretkey = new Typecho_Widget_Helper_Form_Element_Text('secretkey', null, null, _t('SecretKey'), _t('从七牛云控制台获取'));
$form->addInput($secretkey->addRule('required', _t('SecretKey不能为空')));
$domain = new Typecho_Widget_Helper_Form_Element_Text('domain', null, 'https://', _t('绑定域名:'), _t('空间绑定的域名https://cdn.example.com'));
$form->addInput($domain->addRule('required', _t('请填写空间绑定的域名!'))->addRule('url', _t('您输入的域名格式错误!')));
$savepath = new Typecho_Widget_Helper_Form_Element_Text('savepath', null, 'typecho/{year}/{month}/', _t('保存路径前缀'), _t('可使用变量:{year}, {month}, {day}, {random}'));
$form->addInput($savepath);
// WebP转换开关
$webp = new Typecho_Widget_Helper_Form_Element_Radio('webp',
array('0' => _t('关闭'), '1' => _t('开启')),
'0',
_t('WebP自动转换'),
_t('开启后上传的JPEG/PNG图片将自动转换为WebP格式'));
$form->addInput($webp);
// 图片质量设置
$quality = new Typecho_Widget_Helper_Form_Element_Text('quality', null, '85', _t('图片质量:'),
_t('设置图片压缩质量(1-100)仅对JPEG/PNG/WEBP格式有效。85为推荐值'));
$quality->addRule('required', _t('图片质量不能为空!'))
->addRule('isInteger', _t('必须输入数字!'))
->addRule('range', _t('请输入1-100之间的数字'), array(1, 100));
$form->addInput($quality);
// EXIF信息开关
$exif = new Typecho_Widget_Helper_Form_Element_Radio('exif',
array('0' => _t('关闭'), '1' => _t('开启')),
'0',
_t('EXIF信息显示'),
_t('开启后文章图片悬停时会显示拍摄信息(相机型号、光圈、快门等)'));
$form->addInput($exif);
}
// 个人用户配置面板
public static function personalConfig(Typecho_Widget_Helper_Form $form)
{
}
// 获得插件配置信息
public static function getConfig()
{
return Typecho_Widget::widget('Widget_Options')->plugin('Qiniu');
}
// 安全的CSS加载方式
public static function footer()
{
$option = self::getConfig();
if ($option->exif && !self::$_cssLoaded) {
// 只在文章页面加载CSS
$widget = Typecho_Widget::widget('Widget_Archive');
if ($widget->is('single')) {
echo '<script>
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function() {
var style = document.createElement("style");
style.type = "text/css";
style.textContent = `';
self::echoCss();
echo '`;
document.head.appendChild(style);
});
} else {
var style = document.createElement("style");
style.type = "text/css";
style.textContent = `';
self::echoCss();
echo '`;
document.head.appendChild(style);
}
</script>';
self::$_cssLoaded = true;
}
}
}
// 输出CSS内容
private static function echoCss()
{
echo '.qiniu-exif-container {
display: inline-block;
position: relative;
max-width: 100%;
}
.qiniu-exif-info {
position: absolute;
top: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.85);
color: white;
padding: 8px 10px;
font-size: 12px;
line-height: 1.4;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
z-index: 9999;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
pointer-events: none;
}
.qiniu-exif-container:hover .qiniu-exif-info {
opacity: 1;
visibility: visible;
}
.qiniu-exif-info div {
margin: 3px 0;
}
.qiniu-exif-info strong {
font-weight: 600;
color: #fff;
}';
}
/**
* 解析文章内容为图片添加EXIF容器
*/
public static function parseContent($content, $widget, $lastResult)
{
$content = $lastResult ? $lastResult : $content;
$option = self::getConfig();
if (!$option->exif || !$widget->is('single')) {
return $content;
}
$pattern = '/<img\s+[^>]*src=["\']([^"\']+)["\'][^>]*>/i';
return preg_replace_callback($pattern, function($matches) {
$imgTag = $matches[0];
$src = $matches[1];
$option = self::getConfig();
$domain = rtrim($option->domain, '/');
// 检查是否是七牛云的图片
if (strpos($src, $domain) === false) {
return $imgTag;
}
// 从URL中提取文件路径
$path = str_replace($domain, '', $src);
$path = ltrim(parse_url($path, PHP_URL_PATH), '/');
// 去除图片处理参数
if (strpos($path, '?') !== false) {
$path = substr($path, 0, strpos($path, '?'));
}
// 从数据库获取EXIF信息
$db = Typecho_Db::get();
$row = $db->fetchRow($db->select()->from('table.contents')
->where('type = ?', 'attachment')
->where('text LIKE ?', '%"path":"' . $path . '"%')
->limit(1));
if ($row) {
$attachment = unserialize($row['text']);
if (isset($attachment['exif']) && !empty($attachment['exif'])) {
$exifData = $attachment['exif'];
// 生成简化的EXIF信息HTML
$exifHtml = '<div class="qiniu-exif-info">';
if (!empty($exifData['camera'])) {
$exifHtml .= '<div><strong>' . htmlspecialchars($exifData['camera']) . '</strong></div>';
}
$details = array();
if (!empty($exifData['exposure'])) {
$details[] = '快门:' . htmlspecialchars($exifData['exposure']);
}
if (!empty($exifData['aperture'])) {
$details[] = '光圈:f/' . htmlspecialchars($exifData['aperture']);
}
if (!empty($exifData['iso'])) {
$details[] = 'ISO:' . htmlspecialchars($exifData['iso']);
}
if (!empty($exifData['focal_length'])) {
$details[] = '焦距:' . htmlspecialchars($exifData['focal_length']) . 'mm';
}
if (!empty($details)) {
$exifHtml .= '<div>' . implode(' ', $details) . '</div>';
}
$exifHtml .= '</div>';
return '<div class="qiniu-exif-container">' . $imgTag . $exifHtml . '</div>';
}
}
return $imgTag;
}, $content);
}
/**
* 提取EXIF信息
*/
private static function extractExif($filePath)
{
if (!function_exists('exif_read_data')) {
return array();
}
$exif = @exif_read_data($filePath);
if (!$exif) {
return array();
}
$result = array();
// 相机型号
if (!empty($exif['Make']) || !empty($exif['Model'])) {
$make = !empty($exif['Make']) ? trim($exif['Make']) : '';
$model = !empty($exif['Model']) ? trim($exif['Model']) : '';
$result['camera'] = trim($make . ' ' . $model);
}
// 曝光时间
if (!empty($exif['ExposureTime'])) {
$result['exposure'] = $exif['ExposureTime'];
}
// 光圈值
if (!empty($exif['FNumber'])) {
$fnumber = is_array($exif['FNumber']) ? $exif['FNumber'][0] / $exif['FNumber'][1] : $exif['FNumber'];
$result['aperture'] = number_format($fnumber, 1);
}
// ISO
if (!empty($exif['ISOSpeedRatings'])) {
$result['iso'] = $exif['ISOSpeedRatings'];
}
// 焦距
if (!empty($exif['FocalLength'])) {
if (is_array($exif['FocalLength'])) {
$focal = $exif['FocalLength'][0] / $exif['FocalLength'][1];
} else {
$focal = $exif['FocalLength'];
}
$result['focal_length'] = number_format($focal, 1);
}
// 拍摄时间
if (!empty($exif['DateTimeOriginal'])) {
$result['date'] = date('Y-m-d H:i', strtotime($exif['DateTimeOriginal']));
}
return $result;
}
/**
* 检测是否支持WebP转换
*/
private static function canConvertWebp()
{
if (!extension_loaded('gd')) {
return false;
}
$gdInfo = gd_info();
return isset($gdInfo['WebP Support']) && $gdInfo['WebP Support'];
}
/**
* 压缩图片并转换格式
*/
private static function compressAndConvert($sourcePath, $targetExt, $quality)
{
$imageInfo = @getimagesize($sourcePath);
if (!$imageInfo) {
return $sourcePath;
}
$mime = $imageInfo['mime'];
// 创建图像资源
switch ($mime) {
case 'image/jpeg':
$image = imagecreatefromjpeg($sourcePath);
break;
case 'image/png':
$image = imagecreatefrompng($sourcePath);
imagepalettetotruecolor($image);
imagealphablending($image, false);
imagesavealpha($image, true);
break;
case 'image/gif':
$image = imagecreatefromgif($sourcePath);
break;
default:
return $sourcePath;
}
if (!$image) {
return $sourcePath;
}
$tempFile = tempnam(sys_get_temp_dir(), 'img_') . '.' . $targetExt;
$success = false;
switch ($targetExt) {
case 'jpg':
case 'jpeg':
$success = imagejpeg($image, $tempFile, $quality);
break;
case 'png':
$pngQuality = 9 - round(($quality / 100) * 9);
$success = imagepng($image, $tempFile, $pngQuality);
break;
case 'webp':
if (self::canConvertWebp()) {
$success = imagewebp($image, $tempFile, $quality);
}
break;
}
imagedestroy($image);
if ($success && file_exists($tempFile)) {
return $tempFile;
}
return $sourcePath;
}
/**
* 使用七牛基本图片处理
*/
private static function applyQiniuStyle($url, $option)
{
$pathInfo = pathinfo($url);
$currentExt = isset($pathInfo['extension']) ? strtolower($pathInfo['extension']) : '';
$needQuality = $option->quality && $option->quality != 85;
$needWebP = $option->webp && $currentExt != 'webp';
if (!$needQuality && !$needWebP) {
return $url;
}
$params = array();
if ($needQuality) {
$params[] = 'q/' . intval($option->quality);
}
if ($needWebP) {
$params[] = 'format/webp';
}
if (!empty($params)) {
$separator = (strpos($url, '?') === false) ? '?' : '&';
$url .= $separator . 'imageView2/2/' . implode('/', $params);
}
return $url;
}
/**
* 删除文件
*/
public static function deleteFile($filepath)
{
try {
$option = self::getConfig();
$auth = new Auth($option->accesskey, $option->secretkey);
$bucketMgr = new BucketManager($auth);
$err = $bucketMgr->delete($option->bucket, $filepath);
return $err === null;
} catch (Exception $e) {
return false;
}
}
/**
* 上传文件到七牛云 - 修复中文文件名问题
*/
public static function uploadFile($file, $content = null)
{
error_reporting(0);
if (empty($file['name']) || !isset($file['tmp_name'])) {
return array('error' => 1, 'message' => '上传文件无效');
}
// 获取原始文件名
$originalName = basename($file['name']);
// 使用pathinfo正确处理中文字符
$pathInfo = pathinfo($originalName);
$originalExt = isset($pathInfo['extension']) ? strtolower($pathInfo['extension']) : '';
$baseName = isset($pathInfo['filename']) ? $pathInfo['filename'] : '';
// 校验扩展名
if (!Widget_Upload::checkFileType($originalExt)) {
return array('error' => 1, 'message' => '不允许上传此类型文件');
}
$option = self::getConfig();
if (empty($option->bucket) || empty($option->accesskey) || empty($option->secretkey) || empty($option->domain)) {
return array('error' => 1, 'message' => '请先正确配置七牛云插件');
}
$date = new Typecho_Date(Typecho_Widget::widget('Widget_Options')->gmtTime);
// 提取EXIF信息
$exifData = array();
if ($option->exif && in_array($originalExt, array('jpg', 'jpeg'))) {
$exifData = self::extractExif($file['tmp_name']);
}
// 处理图片压缩和转换
$tempFile = null;
$uploadPath = $file['tmp_name'];
$finalExt = $originalExt;
$supportedImages = array('jpg', 'jpeg', 'png', 'gif');
if (in_array($originalExt, $supportedImages)) {
$quality = isset($option->quality) ? intval($option->quality) : 85;
$quality = max(1, min(100, $quality));
$targetExt = $originalExt;
if ($option->webp && self::canConvertWebp() && $originalExt != 'gif') {
$targetExt = 'webp';
}
if ($targetExt != $originalExt || $quality != 85) {
$processedPath = self::compressAndConvert($file['tmp_name'], $targetExt, $quality);
if ($processedPath != $file['tmp_name']) {
$uploadPath = $processedPath;
$finalExt = $targetExt;
$tempFile = $processedPath;
}
}
}
// 生成存储路径 - 保持原始文件名,只替换文件系统不允许的字符
// 处理文件名,保留中文字符,只替换特殊字符
$safeName = $baseName;
// 替换文件系统不允许的字符,但保留中文字符
$safeName = preg_replace('/[<>:"\/\\|?*]/', '_', $safeName);
// 移除首尾空格
$safeName = trim($safeName);
// 如果名称为空,使用时间戳
if (empty($safeName)) {
$safeName = 'image_' . time();
}
if (isset($content)) {
$filePath = $content['attachment']->path;
self::deleteFile($filePath);
} else {
$savepath = preg_replace(array('/\{year\}/', '/\{month\}/', '/\{day\}/', '/\{random\}/'),
array($date->year, $date->month, $date->day, uniqid()),
$option->savepath);
$filePath = rtrim($savepath, '/') . '/' . $safeName . '.' . $finalExt;
}
if (!file_exists($uploadPath)) {
return array('error' => 1, 'message' => '上传文件不存在');
}
try {
$upManager = new UploadManager();
$auth = new Auth($option->accesskey, $option->secretkey);
$token = $auth->uploadToken($option->bucket);
// 七牛云SDK会自动处理文件名编码
list($ret, $error) = $upManager->putFile($token, $filePath, $uploadPath);
if ($tempFile && file_exists($tempFile) && $tempFile != $file['tmp_name']) {
@unlink($tempFile);
}
if ($error == null) {
$rawUrl = Typecho_Common::url($filePath, $option->domain);
$processedUrl = self::applyQiniuStyle($rawUrl, $option);
$fileSize = @filesize($uploadPath);
$result = array(
'name' => $originalName, // 使用原始文件名
'path' => $filePath,
'size' => $fileSize ? (int)$fileSize : 0,
'type' => $finalExt,
'mime' => $finalExt == 'webp' ? 'image/webp' : Typecho_Common::mimeContentType($uploadPath),
'url' => $processedUrl
);
if (!empty($exifData)) {
$result['exif'] = $exifData;
}
return $result;
} else {
return array('error' => 1, 'message' => '七牛云上传失败: ' . $error->message());
}
} catch (Exception $e) {
if ($tempFile && file_exists($tempFile) && $tempFile != $file['tmp_name']) {
@unlink($tempFile);
}
return array('error' => 1, 'message' => '上传异常: ' . $e->getMessage());
}
}
// 上传文件处理函数
public static function uploadHandle($file)
{
return self::uploadFile($file);
}
// 修改文件处理函数
public static function modifyHandle($content, $file)
{
return self::uploadFile($file, $content);
}
// 删除文件处理函数
public static function deleteHandle(array $content)
{
if (isset($content['attachment'])) {
self::deleteFile($content['attachment']->path);
}
}
// 获取实际文件绝对访问路径
public static function attachmentHandle(array $content)
{
$option = self::getConfig();
if (isset($content['attachment'])) {
$rawUrl = Typecho_Common::url($content['attachment']->path, $option->domain);
return self::applyQiniuStyle($rawUrl, $option);
}
return '';
}
}