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']; } }