// 腾讯云 COS 直传客户端(纯 Dart 实现,对齐 Web 端 cos-js-sdk-v5 的 STS 临时密钥签名)。 // // 来源:flutter_cos_client (MIT) 的本地化副本。 // 之所以 vendoring 而非直接依赖 pub 包: // - 原 pub 包 (0.0.1) 在生成签名时硬编码 `expires = now + 3600`, // 当后端下发的 STS 临时密钥有效期短于 1 小时时,COS 会以 403 拒绝 // (q-sign-time 末尾超出凭证 ExpiredTime)。此处改为尊重凭证自身的 ExpiredTime。 // - 移除对未维护 0.0.1 包的依赖,便于长期维护。 // - 去掉 mime 依赖,用内置扩展名→Content-Type 映射。 import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:dio/dio.dart'; /// 临时云 API 凭据。 class CosCredential { /// 临时密钥 Id。 final String tmpSecretId; /// 临时密钥 Key。 final String tmpSecretKey; /// 请求时需要用的 token 字符串。 final String sessionToken; CosCredential({ required this.tmpSecretId, required this.tmpSecretKey, required this.sessionToken, }); } /// COS 临时凭据数据。 class CosCredentialData { /// 密钥的起始时间,UNIX 时间戳(秒)。 final int startTime; /// 密钥的失效时间,UNIX 时间戳(秒)。 final int expiredTime; /// 临时云 API 凭据。 final CosCredential credentials; /// 请求 ID。 final String requestId; CosCredentialData({ required this.startTime, required this.expiredTime, required this.credentials, required this.requestId, }); /// 检查凭据是否已过期(提前 5 分钟判断为过期)。 bool get isExpired { final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; return now >= (expiredTime - 300); } } /// 临时秘钥提供者。 class TemporaryCredentialProvider { /// 获取临时凭据的异步函数。 final Future Function() fetchCredentials; /// 缓存的临时凭据。 CosCredentialData? _cachedCredentials; TemporaryCredentialProvider({required this.fetchCredentials}); /// 获取有效的临时凭据(缓存过期则重新获取)。 Future getCredentials() async { if (_cachedCredentials == null || _cachedCredentials!.isExpired) { _cachedCredentials = await fetchCredentials(); } return _cachedCredentials!; } /// 清除缓存的凭据。 void clearCache() { _cachedCredentials = null; } } /// 腾讯云 COS 客户端。 class CosClient { /// 地域,例如:ap-beijing。 final String region; /// 存储桶名称,格式:bucketname-appid。 final String bucket; /// 临时秘钥提供者。 final TemporaryCredentialProvider? temporaryCredentialProvider; late final Dio _dio; /// 构造函数(使用临时密钥提供者)。 CosClient.withTemporaryCredential({ required this.temporaryCredentialProvider, required this.region, required this.bucket, BaseOptions? dioOptions, }) { _dio = Dio(dioOptions ?? BaseOptions()); } /// 获取存储桶的 Host。 String get _host => '$bucket.cos.$region.myqcloud.com'; /// 上传对象(字节数据)。 Future putObject({ required String objectKey, required List fileBytes, String? fileName, String? contentType, void Function(int count, int total)? onProgress, }) async { // 确保 objectKey 以 / 开头。 if (!objectKey.startsWith('/')) { objectKey = '/$objectKey'; } final fileLength = fileBytes.length; // 计算 Content-MD5。 final md5Digest = md5.convert(fileBytes); final contentMd5 = base64.encode(md5Digest.bytes); // 确定 Content-Type。 contentType ??= _getContentType(fileName ?? ''); // 获取密钥(临时)。 String currentSecretId; String currentSecretKey; String? sessionToken; int signExpires; if (temporaryCredentialProvider != null) { final credentials = await temporaryCredentialProvider!.getCredentials(); currentSecretId = credentials.credentials.tmpSecretId; currentSecretKey = credentials.credentials.tmpSecretKey; sessionToken = credentials.credentials.sessionToken; // 关键修复:签名有效期不得超过 STS 凭证自身的 ExpiredTime, // 否则 COS 会以 403 拒绝(q-sign-time 末尾超出凭证有效期)。 signExpires = credentials.expiredTime; } else { throw ArgumentError('CosClient 仅支持临时密钥(STS)方式'); } final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; // 构建请求头(临时密钥需包含 x-cos-security-token)。 final headers = { 'content-length': fileLength.toString(), 'content-md5': contentMd5, 'content-type': contentType, 'host': _host, }; if (sessionToken != null) { headers['x-cos-security-token'] = sessionToken; } // 生成签名。 final authorization = _generateAuthorization( method: 'put', uri: objectKey, headers: headers, queryParams: {}, now: now, expires: signExpires, secretId: currentSecretId, secretKey: currentSecretKey, ); // 构建请求 URL。 final url = 'https://$_host$objectKey'; // 构建实际请求头。 final requestHeaders = { 'Authorization': authorization, 'Content-Type': contentType, 'Content-Length': fileLength.toString(), 'Content-MD5': contentMd5, 'Host': _host, }; if (sessionToken != null) { requestHeaders['x-cos-security-token'] = sessionToken; } try { final response = await _dio.put( url, data: Stream.fromIterable(fileBytes.map((e) => [e])), options: Options( headers: requestHeaders, contentType: contentType, responseType: ResponseType.plain, validateStatus: (status) => status != null && status < 500, ), onSendProgress: onProgress, ); if (response.statusCode == 200) { return CosUploadResult( success: true, statusCode: response.statusCode!, etag: response.headers.value('etag'), message: 'Upload successful', url: url, ); } return CosUploadResult( success: false, statusCode: response.statusCode!, message: 'Upload failed: ${response.statusMessage}, ${response.data}', ); } catch (e) { return CosUploadResult( success: false, statusCode: -1, message: 'Upload error: $e', ); } } /// 从文件上传对象(便捷方法)。 Future putObjectFromFile({ required String objectKey, required File file, String? contentType, void Function(int count, int total)? onProgress, }) async { final fileBytes = await file.readAsBytes(); return putObject( objectKey: objectKey, fileBytes: fileBytes, fileName: file.path, contentType: contentType, onProgress: onProgress, ); } /// 生成 Authorization 签名(腾讯云 q-sign-algorithm=sha1)。 String _generateAuthorization({ required String method, required String uri, required Map headers, required Map queryParams, required int now, required int expires, required String secretId, required String secretKey, }) { final keyTime = '$now;$expires'; final signKey = _hmacSha1(secretKey, keyTime); final urlParamList = _generateParamList(queryParams); final httpParameters = _generateHttpParameters(queryParams); final headerList = _generateParamList(headers); final httpHeaders = _generateHttpParameters(headers); final httpString = '${method.toLowerCase()}\n$uri\n$httpParameters\n$httpHeaders\n'; final httpStringSha1 = sha1.convert(utf8.encode(httpString)).toString(); final stringToSign = 'sha1\n$keyTime\n$httpStringSha1\n'; final signature = _hmacSha1(signKey, stringToSign); return 'q-sign-algorithm=sha1' '&q-ak=$secretId' '&q-sign-time=$keyTime' '&q-key-time=$keyTime' '&q-header-list=$headerList' '&q-url-param-list=$urlParamList' '&q-signature=$signature'; } /// HMAC-SHA1 加密。 String _hmacSha1(String key, String data) { final hmac = Hmac(sha1, utf8.encode(key)); final digest = hmac.convert(utf8.encode(data)); return digest.toString(); } /// 生成参数列表(key 排序后用 ; 分隔)。 String _generateParamList(Map params) { if (params.isEmpty) return ''; final keys = params.keys.toList()..sort(); return keys.map((k) => k.toLowerCase()).join(';'); } /// 生成 HTTP 参数字符串(key=value 格式,用 & 分隔,需 URL 编码)。 String _generateHttpParameters(Map params) { if (params.isEmpty) return ''; final keys = params.keys.toList()..sort(); return keys .map((k) => '${Uri.encodeComponent(k.toLowerCase())}=${Uri.encodeComponent(params[k]!)}') .join('&'); } /// 根据文件扩展名获取 Content-Type(替代 mime 依赖)。 String _getContentType(String filePath) { final lower = filePath.toLowerCase(); final dot = lower.lastIndexOf('.'); final ext = dot >= 0 ? lower.substring(dot + 1) : ''; const map = { 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp', 'bmp': 'image/bmp', 'mp4': 'video/mp4', 'webm': 'video/webm', 'mov': 'video/quicktime', 'm4v': 'video/x-m4v', 'pdf': 'application/pdf', }; return map[ext] ?? 'application/octet-stream'; } } /// COS 上传结果。 class CosUploadResult { /// 是否成功。 final bool success; /// HTTP 状态码。 final int statusCode; /// ETag(文件的唯一标识)。 final String? etag; /// 消息。 final String? message; /// 对象的 COS 访问地址。 final String? url; CosUploadResult({ required this.success, required this.statusCode, this.etag, this.message, this.url, }); @override String toString() { return 'CosUploadResult{success: $success, statusCode: $statusCode, ' 'etag: $etag, message: $message, url: $url}'; } }