diff --git a/src/Storage.php b/src/Storage.php index b4d1585b..ab9462fb 100644 --- a/src/Storage.php +++ b/src/Storage.php @@ -138,6 +138,7 @@ public static function types(): array 'upyun' => lang('又拍云USS存储'), 'txcos' => lang('腾讯云COS存储'), 'alioss' => lang('阿里云OSS存储'), + 'minio' => lang('自建Minio存储'), ]; } diff --git a/src/contract/StorageUsageTrait.php b/src/contract/StorageUsageTrait.php index a19b939d..aa2ea09a 100644 --- a/src/contract/StorageUsageTrait.php +++ b/src/contract/StorageUsageTrait.php @@ -92,6 +92,7 @@ protected function getSuffix(?string $attname = null, ?string $filename = null): 'UpyunStorage' => '!/format/webp', 'TxcosStorage' => '?imageMogr2/format/webp', 'AliossStorage' => '?x-oss-process=image/format,webp', + 'MinioStorage' => '', // Minio默认不支持图片处理,需要配置第三方服务 ]; $extens = strtolower(pathinfo($this->delSuffix($filename), PATHINFO_EXTENSION)); $suffix = in_array($extens, ['png', 'jpg', 'jpeg']) ? ($compress[$class] ?? '') : ''; diff --git a/src/lang/en-us.php b/src/lang/en-us.php index 438717f0..f55fadab 100644 --- a/src/lang/en-us.php +++ b/src/lang/en-us.php @@ -120,6 +120,7 @@ '阿里云OSS存储' => 'Aliyun Cloud OSS storage', '腾讯云COS存储' => 'Tencent Cloud COS Storage', '七牛云对象存储' => 'Qiniu Cloud Object storage', + '自建Minio存储' => 'Self built Minio storage', '未配置又拍云域名' => 'Unconfigured Upyun Cloud domain', '未配置阿里云域名' => 'Unconfigured Aliyun Cloud domain', '未配置七牛云域名' => 'Unconfigured Qiniu Cloud domain', diff --git a/src/lang/zh-tw.php b/src/lang/zh-tw.php index 6c72da55..bfc2e515 100644 --- a/src/lang/zh-tw.php +++ b/src/lang/zh-tw.php @@ -59,6 +59,7 @@ // 存储引擎翻译 '本地服务器存储' => '本地服務器存儲', '自建Alist存储' => '自建Alist存儲', + '自建Minio存储' => '自建Minio存儲', '七牛云对象存储' => '七牛雲對象存儲', '又拍云USS存储' => '又拍雲USS存儲', '阿里云OSS存储' => '阿裏雲OSS存儲', diff --git a/src/storage/MinioStorage.php b/src/storage/MinioStorage.php new file mode 100644 index 00000000..ec38feb9 --- /dev/null +++ b/src/storage/MinioStorage.php @@ -0,0 +1,341 @@ +endpoint = sysconf('storage.minio_http_domain|raw'); + $type = strtolower(sysconf('storage.minio_http_protocol|raw')); + $this->bucket = sysconf('storage.minio_bucket|raw'); + $this->accessKey = sysconf('storage.minio_access_key|raw'); + $this->secretKey = sysconf('storage.minio_secret_key|raw'); + $this->region = sysconf('storage.minio_region|raw'); + + if (!empty($this->endpoint) && $type === 'auto') { + $this->domain = "//{$this->endpoint}"; + } elseif (!empty($this->endpoint) && in_array($type, ['http', 'https'])) { + $this->domain = "{$type}://{$this->endpoint}"; + } else { + throw new Exception(lang('未配置Minio域名')); + } + } + + /** + * 上传文件内容 + * @param string $name 文件名称 + * @param string $file 文件内容 + * @param boolean $safe 安全模式 + * @param ?string $attname 下载名称 + * @return array + * @throws Exception + */ + public function set(string $name, string $file, bool $safe = false, ?string $attname = null): array + { + $token = $this->token($name); + $data = [ + 'key' => $name, + 'policy' => $token['policy'], + 'x-amz-algorithm' => $token['x-amz-algorithm'], + 'x-amz-credential' => $token['x-amz-credential'], + 'x-amz-date' => $token['x-amz-date'], + 'x-amz-signature' => $token['x-amz-signature'], + 'success_action_status' => '200' + ]; + + if (is_string($attname) && strlen($attname) > 0) { + $data['Content-Disposition'] = 'inline;filename=' . urlencode($attname); + } + + $uri = "{$this->domain}/{$this->bucket}"; + $file = ['field' => 'file', 'name' => $name, 'content' => $file]; + if (is_numeric(stripos(HttpExtend::submit($uri, $data, $file), '200 OK'))) { + return ['file' => $this->path($name, $safe), 'url' => $this->url($name, $safe, $attname), 'key' => $name]; + } else { + return []; + } + } + + /** + * 获取上传令牌 + * @param string $name 文件名称 + * @param integer $expires 有效时间 + * @param ?string $attname 下载名称 + * @return array + */ + public function token(string $name, int $expires = 3600, ?string $attname = null): array + { + $date = gmdate('Ymd\THis\Z'); + $shortDate = substr($date, 0, 8); + $region = $this->region; + $service = 's3'; + $algorithm = 'AWS4-HMAC-SHA256'; + + $credentialScope = "$shortDate/$region/$service/aws4_request"; + $policy = [ + 'expiration' => gmdate('Y-m-d\TH:i:s\Z', time() + $expires), + 'conditions' => [ + ['bucket' => $this->bucket], + ['key' => $name], + ['x-amz-algorithm' => $algorithm], + ['x-amz-credential' => "{$this->accessKey}/$credentialScope"], + ['x-amz-date' => $date], + ['content-length-range', 0, 1048576000], + ], + ]; + + $policyBase64 = base64_encode(json_encode($policy)); + $signingKey = $this->_getSignatureKey($shortDate); + $signature = hash_hmac('sha256', $policyBase64, $signingKey); + + return [ + 'policy' => $policyBase64, + 'x-amz-algorithm' => $algorithm, + 'x-amz-credential' => "{$this->accessKey}/$credentialScope", + 'x-amz-date' => $date, + 'x-amz-signature' => $signature, + 'siteurl' => $this->url($name, false, $attname), + ]; + } + + /** + * 获取访问地址 + * @param string $name 文件名称 + * @param boolean $safe 安全模式 + * @param ?string $attname 下载名称 + * @return string + */ + public function url(string $name, bool $safe = false, ?string $attname = null): string + { + return "{$this->domain}/{$this->bucket}/{$this->delSuffix($name)}{$this->getSuffix($attname,$name)}"; + } + + /** + * 获取存储路径 + * @param string $name 文件名称 + * @param boolean $safe 安全模式 + * @return string + */ + public function path(string $name, bool $safe = false): string + { + return $this->url($name, $safe); + } + + /** + * 获取上传地址 + * @return string + */ + public function upload(): string + { + return $this->domain . '/' . $this->bucket; + } + + /** + * 获取签名密钥 + * @param string $shortDate 短日期 + * @return string + */ + private function _getSignatureKey(string $shortDate): string + { + $dateKey = hash_hmac('sha256', $shortDate, "AWS4{$this->secretKey}", true); + $regionKey = hash_hmac('sha256', $this->region, $dateKey, true); + $serviceKey = hash_hmac('sha256', 's3', $regionKey, true); + return hash_hmac('sha256', 'aws4_request', $serviceKey, true); + } + + /** + * 读取文件内容 + * @param string $name 文件名称 + * @param boolean $safe 安全模式 + * @return string + */ + public function get(string $name, bool $safe = false): string + { + return Storage::curlGet($this->url($name, $safe)); + } + + /** + * 删除存储文件 + * @param string $name 文件名称 + * @param boolean $safe 安全模式 + * @return bool + */ + public function del(string $name, bool $safe = false): bool + { + $url = $this->url($name, $safe); + $headers = HttpExtend::request('DELETE', $url, ['returnHeader' => true]); + return strpos($headers, '204 No Content') !== false; + } + + /** + * 请求数据签名 + * @param string $method 请求方式 + * @param string $source 资源名称 + * @return array + */ + private function _sign(string $method, string $source): array + { + $date = gmdate('Ymd\THis\Z'); + $shortDate = substr($date, 0, 8); + + $canonical_uri = "/{$this->bucket}/{$source}"; + $canonical_query_string = ''; + $host = parse_url($this->domain, PHP_URL_HOST); + + $canonical_headers = [ + 'host' => $host, + 'x-amz-content-sha256' => hash('sha256', ''), + 'x-amz-date' => $date + ]; + + ksort($canonical_headers); + $signed_headers = []; + $canonical_headers_str = ''; + + foreach ($canonical_headers as $key => $value) { + $signed_headers[] = $key; + $canonical_headers_str .= "{$key}:{$value}\n"; + } + + $signed_headers_str = implode(';', $signed_headers); + + $canonical_request = implode("\n", [ + $method, + $canonical_uri, + $canonical_query_string, + $canonical_headers_str, + $signed_headers_str, + hash('sha256', '') + ]); + + $scope = implode('/', [ + $shortDate, + 's3', + 'aws4_request' + ]); + + $string_to_sign = implode("\n", [ + 'AWS4-HMAC-SHA256', + $date, + $scope, + hash('sha256', $canonical_request) + ]); + + $signature = $this->_getSignatureKey($shortDate); + $signature = hash_hmac('sha256', $string_to_sign, $signature); + + $authorization = "AWS4-HMAC-SHA256 Credential={$this->accessKey}/{$scope},SignedHeaders={$signed_headers_str},Signature={$signature}"; + + $headers = []; + foreach ($canonical_headers as $key => $value) { + $headers[ucwords($key, '-')] = $value; + } + $headers['Authorization'] = $authorization; + + return array_map(function ($k, $v) { + return "{$k}: {$v}"; + }, array_keys($headers), $headers); + } + + /** + * 判断文件是否存在 + * @param string $name 文件名称 + * @param boolean $safe 安全模式 + * @return bool + */ + public function has(string $name, bool $safe = false): bool + { + $file = $this->delSuffix($name); + $result = HttpExtend::request('HEAD', "{$this->domain}/{$this->bucket}/{$file}", [ + 'returnHeader' => true, + 'headers' => $this->_sign('HEAD', $file) + ]); + + return is_numeric(stripos($result, '200 OK')); + } + + /** + * 获取文件信息 + * @param string $name 文件名称 + * @param boolean $safe 安全模式 + * @param ?string $attname 下载名称 + * @return array + */ + public function info(string $name, bool $safe = false, ?string $attname = null): array + { + return $this->has($name, $safe) ? [ + 'url' => $this->url($name, $safe, $attname), + 'key' => $name, + 'file' => $this->path($name, $safe), + ] : []; + } + + /** + * 获取存储区域 + * @return array + */ + public static function region(): array + { + return []; + } +}