Files
CloudHome-Ops/lib/services/cos/cos_client.dart

356 lines
10 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 腾讯云 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<CosCredentialData> Function() fetchCredentials;
/// 缓存的临时凭据。
CosCredentialData? _cachedCredentials;
TemporaryCredentialProvider({required this.fetchCredentials});
/// 获取有效的临时凭据(缓存过期则重新获取)。
Future<CosCredentialData> 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<CosUploadResult> putObject({
required String objectKey,
required List<int> 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 = <String, String>{
'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 = <String, String>{
'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<CosUploadResult> 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<String, String> headers,
required Map<String, String> 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<String, String> 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<String, String> 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 = <String, String>{
'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}';
}
}