Skip to main content

tdm_server_rust/utils/
cos_presign.rs

1//! 腾讯云 COS 预签名 URL 生成
2//!
3//! 使用 STS 临时密钥签署预签名下载/上传链接(HMAC-SHA1)。
4//! 对齐 Java `COSClient.generatePresignedUrl` + `signHost` 行为。
5//!
6//! ## 签名流程
7//!
8//! 1. 计算 SignKey: `HMAC-SHA1(secret_key, "{start};{end}")`
9//! 2. 构建 HttpString: `{method}\n{path}\n{params}\n{headers}\n`
10//! 3. 计算 StringToSign: `sha1\n{key_time}\n{SHA1(http_string)}\n`
11//! 4. 计算 Signature: `HMAC-SHA1(sign_key, string_to_sign)`
12//!
13//! ## 使用场景
14//!
15//! - 下载场景:生成有时效的 COS 预签名下载链接
16//! - 上传场景:前端使用预签名 URL 直接 PUT 到 COS
17
18use hmac::{Hmac, Mac};
19use sha1::{Digest, Sha1};
20use std::collections::BTreeMap;
21use std::time::{SystemTime, UNIX_EPOCH};
22
23type HmacSha1 = Hmac<Sha1>;
24
25/// 生成 COS 预签名 URL(STS 临时密钥 + x-cos-security-token)
26///
27/// 使用 HMAC-SHA1 签名算法生成 COS 预签名请求 URL,
28/// 支持 GET(下载)和 PUT(上传)两种方法。
29///
30/// # 参数
31///
32/// - `secret_id`: STS 临时 SecretId
33/// - `secret_key`: STS 临时 SecretKey
34/// - `bucket`: COS 存储桶名
35/// - `region`: COS 区域
36/// - `object_key`: 对象 Key
37/// - `method`: HTTP 方法(`"get"` 或 `"put"`)
38/// - `duration_secs`: 签名有效期(秒)
39/// - `session_token`: STS SessionToken(传入 `x-cos-security-token` 参数)
40///
41/// # 返回值
42///
43/// 返回完整的预签名 URL(含所有鉴权参数)。
44///
45/// # Errors
46///
47/// - `AppError::Oss` — SecretId/SecretKey 为空
48/// - `AppError::Internal` — 系统时间异常
49///
50/// # 签名说明
51///
52/// 签名 host 包含在签名头中,签名路径使用原始 UTF-8 编码(不 URL 编码),
53/// 但请求 URL 中的路径经过 URL 编码以兼容特殊字符。
54pub fn presigned_url(
55    secret_id: &str,
56    secret_key: &str,
57    bucket: &str,
58    region: &str,
59    object_key: &str,
60    method: &str,
61    duration_secs: u64,
62    session_token: &str,
63) -> crate::error::ApiResult<String> {
64    if secret_id.is_empty() || secret_key.is_empty() {
65        return Err(crate::error::AppError::Oss {
66            code: None,
67            msg: "腾讯云 SecretId/SecretKey 未配置".into(),
68        });
69    }
70    let now = SystemTime::now()
71        .duration_since(UNIX_EPOCH)
72        .map_err(|e| crate::error::AppError::Internal(e.to_string()))?
73        .as_secs();
74    let end = now + duration_secs;
75    let key_time = format!("{now};{end}");
76    let sign_key = hmac_sha1_hex(secret_key.as_bytes(), &key_time);
77
78    let host = format!("{bucket}.cos.{region}.myqcloud.com");
79    let uri_path = sign_uri_path(object_key);
80    let request_path = encode_object_path(object_key);
81
82    let mut url_params = BTreeMap::new();
83    if !session_token.is_empty() {
84        url_params.insert("x-cos-security-token".to_string(), session_token.to_string());
85    }
86    let (format_parameters, signed_param_list) = format_parameters(&url_params);
87
88    let mut headers = BTreeMap::new();
89    headers.insert("host".to_string(), host.clone());
90    let (format_headers, signed_header_list) = format_headers(&headers);
91
92    let http_string = format!(
93        "{}\n{}\n{}\n{}\n",
94        method.to_lowercase(),
95        uri_path,
96        format_parameters,
97        format_headers
98    );
99    let string_to_sign = format!("sha1\n{key_time}\n{}\n", sha1_hex(&http_string));
100    let signature = hmac_sha1_hex(sign_key.as_bytes(), &string_to_sign);
101
102    let encoded_path = request_path;
103    let auth_query = format!(
104        "q-sign-algorithm=sha1&q-ak={secret_id}&q-sign-time={key_time}&q-key-time={key_time}\
105         &q-header-list={}&q-url-param-list={}&q-signature={signature}",
106        signed_header_list.join(";"),
107        signed_param_list.join(";")
108    );
109
110    let base = format!("https://{host}{encoded_path}");
111    if url_params.is_empty() {
112        Ok(format!("{base}?{auth_query}"))
113    } else {
114        let token_qs = url_params
115            .iter()
116            .map(|(k, v)| format!("{}={}", safe_url_encode(k), safe_url_encode(v)))
117            .collect::<Vec<_>>()
118            .join("&");
119        Ok(format!("{base}?{token_qs}&{auth_query}"))
120    }
121}
122
123/// COS 签名字符串用的 URI Path(原始 UTF-8,不 URL 编码)
124fn sign_uri_path(object_key: &str) -> String {
125    let trimmed = object_key.trim_start_matches('/');
126    if trimmed.is_empty() {
127        "/".into()
128    } else {
129        format!("/{trimmed}")
130    }
131}
132
133/// COS 请求 URL 用的 URI Path(URL 编码各段,保留 `/`)
134fn encode_uri_path(object_key: &str) -> String {
135    let trimmed = object_key.trim_start_matches('/');
136    if trimmed.is_empty() {
137        return "/".into();
138    }
139    format!(
140        "/{}",
141        trimmed
142            .split('/')
143            .map(|seg| safe_url_encode(seg))
144            .collect::<Vec<_>>()
145            .join("/")
146    )
147}
148
149/// URL 路径编码(调用 [`encode_uri_path`])
150fn encode_object_path(object_key: &str) -> String {
151    encode_uri_path(object_key)
152}
153
154/// 格式化参数列表(排序,小写 key)
155fn format_parameters(params: &BTreeMap<String, String>) -> (String, Vec<String>) {
156    let mut signed_list = Vec::new();
157    let mut pairs = Vec::new();
158    for (k, v) in params {
159        let lk = safe_url_encode(&k.to_lowercase());
160        signed_list.push(lk.clone());
161        pairs.push(format!("{lk}={}", safe_url_encode(v)));
162    }
163    signed_list.sort();
164    (pairs.join("&"), signed_list)
165}
166
167/// 格式化头列表(排序,小写 key)
168fn format_headers(headers: &BTreeMap<String, String>) -> (String, Vec<String>) {
169    let mut signed_list = Vec::new();
170    let mut pairs = Vec::new();
171    for (k, v) in headers {
172        let lk = safe_url_encode(&k.to_lowercase());
173        signed_list.push(lk.clone());
174        pairs.push(format!("{lk}={}", safe_url_encode(v)));
175    }
176    signed_list.sort();
177    (pairs.join("&"), signed_list)
178}
179
180/// 安全 URL 编码(额外编码 `!` `'` `(` `)` `*`)
181fn safe_url_encode(s: &str) -> String {
182    urlencoding::encode(s)
183        .replace('!', "%21")
184        .replace('\'', "%27")
185        .replace('(', "%28")
186        .replace(')', "%29")
187        .replace('*', "%2A")
188}
189
190/// HMAC-SHA1 计算,返回十六进制字符串
191fn hmac_sha1_hex(key: &[u8], data: &str) -> String {
192    let mut mac = HmacSha1::new_from_slice(key).expect("hmac key");
193    mac.update(data.as_bytes());
194    hex::encode(mac.finalize().into_bytes())
195}
196
197/// SHA1 计算,返回十六进制字符串
198fn sha1_hex(data: &str) -> String {
199    let mut hasher = Sha1::new();
200    hasher.update(data.as_bytes());
201    hex::encode(hasher.finalize())
202}