195 lines
7.4 KiB
PHP
195 lines
7.4 KiB
PHP
<?php
|
||||
|
|
// ============================================
|
|||
|
|
// 文件: lib/storage/qiniu.php
|
|||
|
|
// 说明: 七牛云 Kodo 驱动
|
|||
|
|
// 鉴权:AccessKey / SecretKey 计算上传凭证(HMAC-SHA1 + URL 安全 Base64)
|
|||
|
|
// 注意:七牛不提供云主机元数据鉴权,必须使用密钥;且访问需要绑定域名。
|
|||
|
|
// ============================================
|
|||
|
|
|
|||
|
|
class StorageQiniu {
|
|||
|
|
|
|||
|
|
private $cfg;
|
|||
|
|
|
|||
|
|
public function __construct(array $cfg) {
|
|||
|
|
$this->cfg = $cfg;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public function id() { return 'qiniu'; }
|
|||
|
|
|
|||
|
|
public function label() { return '七牛云 Kodo'; }
|
|||
|
|
|
|||
|
|
// ---------- 工具 ----------
|
|||
|
|
|
|||
|
|
// URL 安全 Base64(去掉填充、+→-、/→_)
|
|||
|
|
private function urlsafeB64($data) {
|
|||
|
|
return str_replace(['+', '/'], ['-', '_'], rtrim(base64_encode($data), '='));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private function uploadHost() {
|
|||
|
|
$map = [
|
|||
|
|
'z0' => 'upload.qiniup.com',
|
|||
|
|
'z1' => 'upload-z1.qiniup.com',
|
|||
|
|
'z2' => 'upload-z2.qiniup.com',
|
|||
|
|
'na0' => 'upload-na0.qiniup.com',
|
|||
|
|
'as0' => 'upload-as0.qiniup.com',
|
|||
|
|
];
|
|||
|
|
$region = (string)($this->cfg['region'] ?? 'z0');
|
|||
|
|
return $map[$region] ?? $map['z0'];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private function scheme() {
|
|||
|
|
return ($this->cfg['https'] ?? '1') === '1' ? 'https' : 'http';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 给对象键加上「对象前缀」(桶内目录)。库中始终存不带前缀的相对键,
|
|||
|
|
// 前缀只在驱动层拼接,因此改前缀不影响数据库里的历史记录。
|
|||
|
|
private function objectKey($key) {
|
|||
|
|
$key = ltrim((string)$key, '/');
|
|||
|
|
$prefix = trim((string)($this->cfg['prefix'] ?? ''), '/');
|
|||
|
|
return $prefix === '' ? $key : $prefix . '/' . $key;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private function credentials() {
|
|||
|
|
$ak = trim((string)($this->cfg['access_key'] ?? ''));
|
|||
|
|
$sk = trim((string)($this->cfg['secret_key'] ?? ''));
|
|||
|
|
if ($ak === '' || $sk === '') {
|
|||
|
|
return ['ok' => false, 'error' => '未填写 AccessKey / SecretKey'];
|
|||
|
|
}
|
|||
|
|
return ['ok' => true, 'ak' => $ak, 'sk' => $sk];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private function validateConfig() {
|
|||
|
|
foreach (['bucket' => 'Bucket 名称'] as $f => $label) {
|
|||
|
|
if (trim((string)($this->cfg[$f] ?? '')) === '') {
|
|||
|
|
return "未填写{$label}";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 生成上传凭证
|
|||
|
|
private function uploadToken($key, $cred) {
|
|||
|
|
$policy = [
|
|||
|
|
'scope' => trim((string)$this->cfg['bucket']) . ':' . ltrim((string)$key, '/'),
|
|||
|
|
'deadline' => time() + 3600,
|
|||
|
|
'insertOnly' => 0,
|
|||
|
|
];
|
|||
|
|
$encoded = $this->urlsafeB64(json_encode($policy, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
|||
|
|
$sign = $this->urlsafeB64(hash_hmac('sha1', $encoded, $cred['sk'], true));
|
|||
|
|
return $cred['ak'] . ':' . $sign . ':' . $encoded;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 管理凭证(用于删除)
|
|||
|
|
private function managementAuth($path, $cred) {
|
|||
|
|
$sign = $this->urlsafeB64(hash_hmac('sha1', $path . "\n", $cred['sk'], true));
|
|||
|
|
return 'QBox ' . $cred['ak'] . ':' . $sign;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- 对外地址 ----------
|
|||
|
|
|
|||
|
|
public function url($key) {
|
|||
|
|
$key = ltrim((string)$key, '/');
|
|||
|
|
$domain = trim((string)($this->cfg['custom_domain'] ?? ''));
|
|||
|
|
if ($domain === '') {
|
|||
|
|
return '/' . $key; // 未绑定域名时退回相对路径,避免生成半截 URL
|
|||
|
|
}
|
|||
|
|
return storageHostOf($domain, $this->scheme()) . '/' . storageEncodeKey($this->objectKey($key));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- 操作 ----------
|
|||
|
|
|
|||
|
|
public function putFile($srcPath, $key, $mime = null) {
|
|||
|
|
if ($err = $this->validateConfig()) return ['ok' => false, 'error' => $err];
|
|||
|
|
$cred = $this->credentials();
|
|||
|
|
if (empty($cred['ok'])) return ['ok' => false, 'error' => $cred['error']];
|
|||
|
|
if (!is_file($srcPath)) return ['ok' => false, 'error' => "源文件不存在:{$srcPath}"];
|
|||
|
|
|
|||
|
|
$key = $this->objectKey($key);
|
|||
|
|
$post = [
|
|||
|
|
'token' => $this->uploadToken($key, $cred),
|
|||
|
|
'key' => $key,
|
|||
|
|
'file' => new CURLFile($srcPath, $mime ?: 'application/octet-stream', basename($key)),
|
|||
|
|
];
|
|||
|
|
$res = storageHttp('POST', $this->scheme() . '://' . $this->uploadHost() . '/',
|
|||
|
|
['Expect:'], $post, 90);
|
|||
|
|
|
|||
|
|
return $this->interpretUpload($res);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public function putContents($binary, $key, $mime = null) {
|
|||
|
|
$tmp = tempnam(sys_get_temp_dir(), 'qn');
|
|||
|
|
if ($tmp === false || file_put_contents($tmp, $binary) === false) {
|
|||
|
|
return ['ok' => false, 'error' => '无法创建临时文件'];
|
|||
|
|
}
|
|||
|
|
$result = $this->putFile($tmp, $key, $mime);
|
|||
|
|
@unlink($tmp);
|
|||
|
|
return $result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public function delete($key) {
|
|||
|
|
$cred = $this->credentials();
|
|||
|
|
if (empty($cred['ok'])) return ['ok' => false, 'error' => $cred['error']];
|
|||
|
|
|
|||
|
|
$entry = $this->urlsafeB64(trim((string)$this->cfg['bucket']) . ':' . $this->objectKey($key));
|
|||
|
|
$path = '/delete/' . $entry;
|
|||
|
|
$res = storageHttp('POST', $this->scheme() . '://rs.qiniup.com' . $path,
|
|||
|
|
['Authorization: ' . $this->managementAuth($path, $cred)], null, 30);
|
|||
|
|
|
|||
|
|
if (!$res['ok'] && $res['status'] !== 612) { // 612 = 资源不存在
|
|||
|
|
return ['ok' => false, 'error' => '删除七牛对象失败:' . $this->describe($res)];
|
|||
|
|
}
|
|||
|
|
return ['ok' => true, 'error' => ''];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public function exists($key) {
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public function test() {
|
|||
|
|
if ($err = $this->validateConfig()) return ['ok' => false, 'error' => $err];
|
|||
|
|
$cred = $this->credentials();
|
|||
|
|
if (empty($cred['ok'])) return ['ok' => false, 'error' => $cred['error']];
|
|||
|
|
if (trim((string)($this->cfg['custom_domain'] ?? '')) === '') {
|
|||
|
|
return ['ok' => false, 'error' => '未填写绑定域名——七牛云必须绑定域名后才能对外访问图片'];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$key = '.movielog-probe/' . date('YmdHis') . '-' . bin2hex(random_bytes(4)) . '.txt';
|
|||
|
|
$tmp = tempnam(sys_get_temp_dir(), 'qn');
|
|||
|
|
file_put_contents($tmp, 'movielog storage probe ' . date('c'));
|
|||
|
|
$up = $this->putFile($tmp, $key, 'text/plain');
|
|||
|
|
@unlink($tmp);
|
|||
|
|
if (empty($up['ok'])) return $up;
|
|||
|
|
|
|||
|
|
$del = $this->delete($key);
|
|||
|
|
$prefix = trim((string)($this->cfg['prefix'] ?? ''), '/');
|
|||
|
|
return [
|
|||
|
|
'ok' => true,
|
|||
|
|
'error' => '',
|
|||
|
|
'message' => "连接成功(AccessKey/SecretKey)。空间 {$this->cfg['bucket']} 可上传,"
|
|||
|
|
. ($prefix !== '' ? "前缀 {$prefix}/," : '')
|
|||
|
|
. '测试对象已清理。' . ($del['ok'] ? '' : '(注意:测试对象删除失败,请检查删除权限)'),
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- 响应解析 ----------
|
|||
|
|
|
|||
|
|
private function interpretUpload($res) {
|
|||
|
|
$json = json_decode((string)$res['body'], true);
|
|||
|
|
if (is_array($json) && !empty($json['key'])) {
|
|||
|
|
return ['ok' => true, 'error' => ''];
|
|||
|
|
}
|
|||
|
|
if (!$res['ok'] || (is_array($json) && isset($json['error']))) {
|
|||
|
|
return ['ok' => false, 'error' => '上传到七牛失败:' . $this->describe($res)];
|
|||
|
|
}
|
|||
|
|
return ['ok' => false, 'error' => '上传到七牛失败:响应异常'];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private function describe($res) {
|
|||
|
|
$json = json_decode((string)$res['body'], true);
|
|||
|
|
if (is_array($json) && !empty($json['error'])) {
|
|||
|
|
return (string)$json['error'];
|
|||
|
|
}
|
|||
|
|
return $res['error'];
|
|||
|
|
}
|
|||
|
|
}
|