356 lines
10 KiB
Dart
356 lines
10 KiB
Dart
|
|
// 腾讯云 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}';
|
|||
|
|
}
|
|||
|
|
}
|