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(
85            "x-cos-security-token".to_string(),
86            session_token.to_string(),
87        );
88    }
89    let (format_parameters, signed_param_list) = format_parameters(&url_params);
90
91    let mut headers = BTreeMap::new();
92    headers.insert("host".to_string(), host.clone());
93    let (format_headers, signed_header_list) = format_headers(&headers);
94
95    let http_string = format!(
96        "{}\n{}\n{}\n{}\n",
97        method.to_lowercase(),
98        uri_path,
99        format_parameters,
100        format_headers
101    );
102    let string_to_sign = format!("sha1\n{key_time}\n{}\n", sha1_hex(&http_string));
103    let signature = hmac_sha1_hex(sign_key.as_bytes(), &string_to_sign);
104
105    let encoded_path = request_path;
106    let auth_query = format!(
107        "q-sign-algorithm=sha1&q-ak={secret_id}&q-sign-time={key_time}&q-key-time={key_time}\
108         &q-header-list={}&q-url-param-list={}&q-signature={signature}",
109        signed_header_list.join(";"),
110        signed_param_list.join(";")
111    );
112
113    let base = format!("https://{host}{encoded_path}");
114    if url_params.is_empty() {
115        Ok(format!("{base}?{auth_query}"))
116    } else {
117        let token_qs = url_params
118            .iter()
119            .map(|(k, v)| format!("{}={}", safe_url_encode(k), safe_url_encode(v)))
120            .collect::<Vec<_>>()
121            .join("&");
122        Ok(format!("{base}?{token_qs}&{auth_query}"))
123    }
124}
125
126/// COS 签名字符串用的 URI Path(原始 UTF-8,不 URL 编码)
127fn sign_uri_path(object_key: &str) -> String {
128    let trimmed = object_key.trim_start_matches('/');
129    if trimmed.is_empty() {
130        "/".into()
131    } else {
132        format!("/{trimmed}")
133    }
134}
135
136/// COS 请求 URL 用的 URI Path(URL 编码各段,保留 `/`)
137fn encode_uri_path(object_key: &str) -> String {
138    let trimmed = object_key.trim_start_matches('/');
139    if trimmed.is_empty() {
140        return "/".into();
141    }
142    format!(
143        "/{}",
144        trimmed
145            .split('/')
146            .map(|seg| safe_url_encode(seg))
147            .collect::<Vec<_>>()
148            .join("/")
149    )
150}
151
152/// URL 路径编码(调用 [`encode_uri_path`])
153fn encode_object_path(object_key: &str) -> String {
154    encode_uri_path(object_key)
155}
156
157/// 格式化参数列表(排序,小写 key)
158fn format_parameters(params: &BTreeMap<String, String>) -> (String, Vec<String>) {
159    let mut signed_list = Vec::new();
160    let mut pairs = Vec::new();
161    for (k, v) in params {
162        let lk = safe_url_encode(&k.to_lowercase());
163        signed_list.push(lk.clone());
164        pairs.push(format!("{lk}={}", safe_url_encode(v)));
165    }
166    signed_list.sort();
167    (pairs.join("&"), signed_list)
168}
169
170/// 格式化头列表(排序,小写 key)
171fn format_headers(headers: &BTreeMap<String, String>) -> (String, Vec<String>) {
172    let mut signed_list = Vec::new();
173    let mut pairs = Vec::new();
174    for (k, v) in headers {
175        let lk = safe_url_encode(&k.to_lowercase());
176        signed_list.push(lk.clone());
177        pairs.push(format!("{lk}={}", safe_url_encode(v)));
178    }
179    signed_list.sort();
180    (pairs.join("&"), signed_list)
181}
182
183/// 安全 URL 编码(额外编码 `!` `'` `(` `)` `*`)
184fn safe_url_encode(s: &str) -> String {
185    urlencoding::encode(s)
186        .replace('!', "%21")
187        .replace('\'', "%27")
188        .replace('(', "%28")
189        .replace(')', "%29")
190        .replace('*', "%2A")
191}
192
193/// HMAC-SHA1 计算,返回十六进制字符串
194fn hmac_sha1_hex(key: &[u8], data: &str) -> String {
195    let mut mac = HmacSha1::new_from_slice(key).expect("hmac key");
196    mac.update(data.as_bytes());
197    hex::encode(mac.finalize().into_bytes())
198}
199
200/// SHA1 计算,返回十六进制字符串
201fn sha1_hex(data: &str) -> String {
202    let mut hasher = Sha1::new();
203    hasher.update(data.as_bytes());
204    hex::encode(hasher.finalize())
205}