229 lines
8.2 KiB
PHP
229 lines
8.2 KiB
PHP
<?php
|
||||
|
|
// ============================================
|
|||
|
|
// 文件: lib/storage/cos.php
|
|||
|
|
// 说明: 腾讯云 COS 驱动
|
|||
|
|
// 签名算法:COS 签名 V5(q-sign-algorithm=sha1)
|
|||
|
|
// 鉴权方式:SecretId/SecretKey 或 CVM CAM 角色(实例元数据,免密钥)
|
|||
|
|
// ============================================
|
|||
|
|
|
|||
|
|
class StorageCos {
|
|||
|
|
|
|||
|
|
private $cfg;
|
|||
|
|
private $cred = null;
|
|||
|
|
|
|||
|
|
public function __construct(array $cfg) {
|
|||
|
|
$this->cfg = $cfg;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public function id() { return 'cos'; }
|
|||
|
|
|
|||
|
|
public function label() { return '腾讯云 COS'; }
|
|||
|
|
|
|||
|
|
// ---------- 凭据 ----------
|
|||
|
|
|
|||
|
|
private function credentials() {
|
|||
|
|
if ($this->cred !== null) return $this->cred;
|
|||
|
|
|
|||
|
|
if (($this->cfg['auth_mode'] ?? 'ak') === 'cvm_role') {
|
|||
|
|
$c = tencentCvmCamCredentials($this->cfg['cam_role_name'] ?? '');
|
|||
|
|
if (empty($c['ok'])) return $this->cred = ['ok' => false, 'error' => $c['error']];
|
|||
|
|
return $this->cred = $c;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (trim((string)($this->cfg['secret_id'] ?? '')) === ''
|
|||
|
|
|| trim((string)($this->cfg['secret_key'] ?? '')) === '') {
|
|||
|
|
return $this->cred = ['ok' => false, 'error' => '未填写 SecretId / SecretKey'];
|
|||
|
|
}
|
|||
|
|
return $this->cred = [
|
|||
|
|
'ok' => true,
|
|||
|
|
'id' => trim($this->cfg['secret_id']),
|
|||
|
|
'secret' => trim($this->cfg['secret_key']),
|
|||
|
|
'token' => '',
|
|||
|
|
'error' => '',
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- 寻址 ----------
|
|||
|
|
|
|||
|
|
private function useHttps() {
|
|||
|
|
return ($this->cfg['https'] ?? '1') === '1';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private function scheme() {
|
|||
|
|
return $this->useHttps() ? 'https' : 'http';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private function host() {
|
|||
|
|
return trim((string)$this->cfg['bucket']) . '.cos.' . trim((string)$this->cfg['region']) . '.myqcloud.com';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 给对象键加上「对象前缀」(桶内目录)。库中始终存不带前缀的相对键,
|
|||
|
|
// 前缀只在驱动层拼接,因此改前缀不影响数据库里的历史记录。
|
|||
|
|
private function objectKey($key) {
|
|||
|
|
$key = ltrim((string)$key, '/');
|
|||
|
|
$prefix = trim((string)($this->cfg['prefix'] ?? ''), '/');
|
|||
|
|
return $prefix === '' ? $key : $prefix . '/' . $key;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private function pathOf($key) {
|
|||
|
|
return '/' . storageEncodeKey(ltrim((string)$key, '/'));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private function objectUrl($key) {
|
|||
|
|
return $this->scheme() . '://' . $this->host() . $this->pathOf($key);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public function url($key) {
|
|||
|
|
$key = $this->objectKey($key);
|
|||
|
|
$custom = trim((string)($this->cfg['custom_domain'] ?? ''));
|
|||
|
|
if ($custom !== '') {
|
|||
|
|
return storageHostOf($custom, $this->useHttps()) . '/' . storageEncodeKey($key);
|
|||
|
|
}
|
|||
|
|
return $this->scheme() . '://' . $this->host() . '/' . storageEncodeKey($key);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- 签名 V5 ----------
|
|||
|
|
|
|||
|
|
private function authorization($method, $path, $cred) {
|
|||
|
|
$signTime = (time() - 60) . ';' . (time() + 3600);
|
|||
|
|
|
|||
|
|
// 参与签名的请求头(键必须小写并按字典序排列)
|
|||
|
|
$headers = ['host' => $this->host()];
|
|||
|
|
if (($cred['token'] ?? '') !== '') {
|
|||
|
|
$headers['x-cos-security-token'] = $cred['token'];
|
|||
|
|
}
|
|||
|
|
ksort($headers);
|
|||
|
|
|
|||
|
|
$headerPairs = [];
|
|||
|
|
$headerNames = [];
|
|||
|
|
foreach ($headers as $k => $v) {
|
|||
|
|
$headerNames[] = $k;
|
|||
|
|
$headerPairs[] = $k . '=' . rawurlencode($v);
|
|||
|
|
}
|
|||
|
|
$httpHeaders = implode('&', $headerPairs);
|
|||
|
|
$headerList = implode(';', $headerNames);
|
|||
|
|
|
|||
|
|
$httpString = strtolower($method) . "\n"
|
|||
|
|
. $path . "\n"
|
|||
|
|
. "\n" // 无 URL 参数
|
|||
|
|
. $httpHeaders . "\n";
|
|||
|
|
|
|||
|
|
$stringToSign = "sha1\n" . $signTime . "\n" . sha1($httpString) . "\n";
|
|||
|
|
$signKey = hash_hmac('sha1', $signTime, $cred['secret']);
|
|||
|
|
$signature = hash_hmac('sha1', $stringToSign, $signKey);
|
|||
|
|
|
|||
|
|
return [
|
|||
|
|
'q-sign-algorithm=sha1',
|
|||
|
|
'q-ak=' . $cred['id'],
|
|||
|
|
'q-sign-time=' . $signTime,
|
|||
|
|
'q-key-time=' . $signTime,
|
|||
|
|
'q-header-list=' . $headerList,
|
|||
|
|
'q-url-param-list=',
|
|||
|
|
'q-signature=' . $signature,
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private function buildHeaders($method, $key, $contentType, $cred) {
|
|||
|
|
$path = $this->pathOf($key);
|
|||
|
|
$parts = $this->authorization($method, $path, $cred);
|
|||
|
|
$headers = ['Authorization: ' . implode('&', $parts)];
|
|||
|
|
if ($contentType !== '') {
|
|||
|
|
$headers[] = 'Content-Type: ' . $contentType;
|
|||
|
|
}
|
|||
|
|
if (($cred['token'] ?? '') !== '') {
|
|||
|
|
$headers[] = 'x-cos-security-token: ' . $cred['token'];
|
|||
|
|
}
|
|||
|
|
return $headers;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private function validateConfig() {
|
|||
|
|
foreach (['bucket' => '存储桶名称(含 APPID)', 'region' => '地域 Region'] as $f => $label) {
|
|||
|
|
if (trim((string)($this->cfg[$f] ?? '')) === '') {
|
|||
|
|
return "未填写{$label}";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- 操作 ----------
|
|||
|
|
|
|||
|
|
public function putFile($srcPath, $key, $mime = null) {
|
|||
|
|
return $this->putContents(file_get_contents($srcPath), $key, $mime ?: 'application/octet-stream');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public function putContents($binary, $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']];
|
|||
|
|
|
|||
|
|
$key = $this->objectKey($key);
|
|||
|
|
$mime = $mime ?: 'application/octet-stream';
|
|||
|
|
$res = storageHttp('PUT', $this->objectUrl($key),
|
|||
|
|
$this->buildHeaders('PUT', $key, $mime, $cred), $binary, 60);
|
|||
|
|
|
|||
|
|
if (!$res['ok']) {
|
|||
|
|
return ['ok' => false, 'error' => '上传到 COS 失败:' . $this->describe($res)];
|
|||
|
|
}
|
|||
|
|
return ['ok' => true, 'error' => ''];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public function delete($key) {
|
|||
|
|
$cred = $this->credentials();
|
|||
|
|
if (empty($cred['ok'])) return ['ok' => false, 'error' => $cred['error']];
|
|||
|
|
|
|||
|
|
$key = $this->objectKey($key);
|
|||
|
|
$res = storageHttp('DELETE', $this->objectUrl($key),
|
|||
|
|
$this->buildHeaders('DELETE', $key, '', $cred), null, 30);
|
|||
|
|
|
|||
|
|
if (!$res['ok'] && $res['status'] !== 404) {
|
|||
|
|
return ['ok' => false, 'error' => '删除 COS 对象失败:' . $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']];
|
|||
|
|
|
|||
|
|
// 探测对象也走前缀,顺便验证前缀目录的写权限
|
|||
|
|
$raw = '.movielog-probe/' . date('YmdHis') . '-' . bin2hex(random_bytes(4)) . '.txt';
|
|||
|
|
$key = $this->objectKey($raw);
|
|||
|
|
$body = 'movielog storage probe ' . date('c');
|
|||
|
|
|
|||
|
|
$res = storageHttp('PUT', $this->objectUrl($key),
|
|||
|
|
$this->buildHeaders('PUT', $key, 'text/plain', $cred), $body, 20);
|
|||
|
|
if (!$res['ok']) {
|
|||
|
|
return ['ok' => false, 'error' => '写入测试失败:' . $this->describe($res)];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$del = $this->delete($raw); // delete() 内部会自行加前缀,故传原始键
|
|||
|
|
$mode = ($this->cfg['auth_mode'] ?? 'ak') === 'cvm_role'
|
|||
|
|
? 'CVM CAM 角色(免密钥)' : 'SecretId/SecretKey';
|
|||
|
|
$prefix = trim((string)($this->cfg['prefix'] ?? ''), '/');
|
|||
|
|
|
|||
|
|
return [
|
|||
|
|
'ok' => true,
|
|||
|
|
'error' => '',
|
|||
|
|
'message' => "连接成功({$mode})。桶 {$this->cfg['bucket']} 可读写,"
|
|||
|
|
. ($prefix !== '' ? "前缀 {$prefix}/," : '')
|
|||
|
|
. '测试对象已清理。' . ($del['ok'] ? '' : '(注意:测试对象删除失败,请检查删除权限)'),
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private function describe($res) {
|
|||
|
|
$body = trim((string)$res['body']);
|
|||
|
|
$json = json_decode($body, true);
|
|||
|
|
if (is_array($json)) {
|
|||
|
|
$code = $json['Code'] ?? '';
|
|||
|
|
$msg = $json['Message'] ?? '';
|
|||
|
|
if ($code || $msg) return trim("{$code} {$msg}");
|
|||
|
|
}
|
|||
|
|
return $res['error'];
|
|||
|
|
}
|
|||
|
|
}
|