How to Upload Images to Qiniu Cloud with Rust + Actix‑Web and Generate Signed Download URLs
This article walks through moving image storage from TiDB Cloud to Qiniu Cloud for a WeChat mini‑program, detailing a Rust + Actix‑Web backend that validates image format, deduplicates via SHA‑256 content‑addressable keys, creates upload tokens, performs idempotent multipart uploads, and builds hour‑aligned private download URLs with referer protection.
Overall Business Flow
小程序
│ multipart/form-data POST /api/v1/upload
▼
Actix-Web Handler
├── 1. Parse multipart stream (≤10 MB)
├── 2. Identify image via magic bytes (prevent decode bombs)
├── 3. Compute SHA‑256 of original bytes for deduplication
├── 4. Optimize format (JPEG q85 / PNG·GIF·BMP → WebP)
├── 5. Upload to Qiniu Kodo (idempotent, avoid duplicate uploads)
└── 6. Generate time‑aligned private auth URL and return to mini‑programDependencies (Cargo.toml)
[dependencies]
actix-web = { version = "4", default-features = false, features = ["macros"] }
actix-multipart = "0.7"
reqwest = { version = "0.12", features = ["json", "multipart"] }
tokio = { version = "1", features = ["rt-multi-thread"] }
serde_json = "1"
hmac = "0.12"
sha1 = "0.10"
sha2 = "0.10"
base64 = "0.22"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp", "bmp"] }
webp = "0.3"
futures-util = "0.3"
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "mysql"] }Key Crates
hmac+ sha1: Qiniu auth is HMAC‑SHA1. sha2: SHA‑256 for content‑addressable keys. reqwest: HTTP client for multipart upload to Qiniu.
Qiniu Authentication Mechanism
Before calling any Qiniu API you must obtain the Access Key (AK) and Secret Key (SK) from the Qiniu console; they are used to sign requests.
Signature Helper Functions
use base64::{engine::general_purpose::URL_SAFE, Engine};
use hmac::{Hmac, Mac};
use sha1::Sha1;
type HmacSha1 = Hmac<Sha1>;
fn urlsafe_base64(data: &[u8]) -> String {
URL_SAFE.encode(data)
}
fn hmac_sha1(key: &[u8], data: &[u8]) -> Vec<u8> {
let mut mac = HmacSha1::new_from_slice(key).expect("HMAC key length valid");
mac.update(data);
mac.finalize().into_bytes().to_vec()
}
fn now_epoch() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock sane")
.as_secs()
}Upload Token Generation
The upload token embeds a JSON policy that contains scope (bucket:key) and an expiration deadline. The token format is:
AK:HMAC‑SHA1(SK, Base64(policy)):Base64(policy) pub fn upload_token(ak: &str, sk: &str, policy_json: &str) -> String {
let encoded_policy = urlsafe_base64(policy_json.as_bytes());
let sign = hmac_sha1(sk.as_bytes(), encoded_policy.as_bytes());
let encoded_sign = urlsafe_base64(&sign);
format!("{ak}:{encoded_sign}:{encoded_policy}")
}Private Download URL Generation
After an image is stored, the mini‑program must use a time‑limited private URL. The URL pattern is:
https://{cdn_domain}/{key}?e={deadline}&token={AK}:{HMAC‑SHA1(SK, url?e=deadline)}Fields:
Host : CDN acceleration domain (e.g., mood.xxx.com).
Path : Object key generated from the SHA‑256 hash.
e : Expiration Unix timestamp (seconds).
token : {AK}:{Base64url(HMAC‑SHA1(SK, url?e=...))}, verified by Qiniu.
pub fn private_download_url(
ak: &str,
sk: &str,
cdn_domain: &str,
key: &str,
ttl_secs: u64,
) -> String {
let now = now_epoch();
// Align to the hour to improve CDN cache hit rate
let aligned = (now / 3600) * 3600;
let deadline = aligned + ttl_secs;
let base_url = format!("https://{cdn_domain}/{key}");
let url_to_sign = format!("{base_url}?e={deadline}");
let sign = hmac_sha1(sk.as_bytes(), url_to_sign.as_bytes());
let encoded_sign = urlsafe_base64(&sign);
let token = format!("{ak}:{encoded_sign}");
format!("{url_to_sign}&token={token}")
}QBox Management Token
QBox tokens are used for RS management operations (stat, copy, move, delete). They differ from upload tokens:
Placement : Upload token is sent as the token field in multipart form; QBox token is placed in the Authorization header.
Target Service : Upload token → Kodo upload domain; QBox token → RS management API ( rs.qiniuapi.com).
Signature Content : Upload token signs the Base64‑encoded policy JSON; QBox token signs the request path plus a trailing newline.
Context : Upload token carries scope and deadline; QBox token carries no policy, only proves request legitimacy.
pub fn qbox_token(ak: &str, sk: &str, path_with_query: &str) -> String {
// sign_str = path + query + "
"
let sign_str = format!("{path_with_query}
");
let sign = hmac_sha1(sk.as_bytes(), sign_str.as_bytes());
let encoded_sign = urlsafe_base64(&sign);
format!("QBox {ak}:{encoded_sign}")
}Object Key Design (Content‑Addressable Storage)
Qiniu Kodo uses a flat namespace; we store objects with keys derived from the SHA‑256 hash of the image content, ensuring identical files map to the same key.
pub fn object_key(content_hash: &str, stored_mime: &str) -> String {
let ext = match stored_mime {
"image/jpeg" => "jpg",
"image/png" => "png",
"image/gif" => "gif",
"image/webp" => "webp",
"image/bmp" => "bmp",
_ => "bin",
};
let prefix4 = &content_hash[..4.min(content_hash.len())];
format!("img/{prefix4}/{content_hash}.{ext}")
}Benefits:
Cross‑user deduplication : Different users uploading the same image result in a single stored object, with separate DB references.
Idempotent upload : The scope = bucket:key guarantees that retries overwrite the same object.
Prefix sharding : The first four hex characters distribute objects across 65 536 virtual directories, making console browsing and batch operations easier.
Image Format Detection & Compression
We first verify the true image format by reading magic bytes, ignoring the client‑provided Content‑Type. A static table maps known magic sequences to MIME types.
pub struct FormatEntry {
pub magic: &'static [u8], // file‑header bytes
pub offset: usize, // where to match
pub mime: &'static str,
}
pub static FORMAT_TABLE: &[FormatEntry] = &[
FormatEntry { magic: b"\xFF\xD8\xFF", offset: 0, mime: "image/jpeg" },
FormatEntry { magic: b"\x89PNG
\x1A
", offset: 0, mime: "image/png" },
FormatEntry { magic: b"GIF87a", offset: 0, mime: "image/gif" },
FormatEntry { magic: b"GIF89a", offset: 0, mime: "image/gif" },
FormatEntry { magic: b"RIFF", offset: 0, mime: "image/webp" },
FormatEntry { magic: b"BM", offset: 0, mime: "image/bmp" },
];
pub fn detect_format(data: &[u8]) -> Option<&'static str> {
for entry in FORMAT_TABLE {
let end = entry.offset + entry.magic.len();
if data.len() >= end && &data[entry.offset..end] == entry.magic {
return Some(entry.mime);
}
}
None
}WebP requires an additional check at offset 8 for the string WEBP. If no known magic bytes match, the upload is rejected to prevent disguised files.
Decode‑Bomb Protection
After confirming the format, we read only the image header to obtain dimensions and reject images that exceed safe limits.
const MAX_DIMENSION: u32 = 8000; // max width/height
const MAX_PIXELS: u64 = 4_000_000; // ~4 MP
pub fn check_image_dimensions(data: &[u8], mime: &str) -> Result<(u32, u32), AppError> {
let reader = ImageReader::new(Cursor::new(data))
.with_guessed_format()
.map_err(|e| AppError::BadRequest(format!("图片格式解析失败: {e}")))?;
let (width, height) = reader.into_dimensions()
.map_err(|e| AppError::BadRequest(format!("无法读取尺寸: {e}")))?;
if width > MAX_DIMENSION || height > MAX_DIMENSION {
return Err(AppError::BadRequest(
format!("图片尺寸过大: {width}x{height},最大允许 {MAX_DIMENSION}x{MAX_DIMENSION}")
));
}
if (width as u64) * (height as u64) > MAX_PIXELS {
return Err(AppError::BadRequest(
format!("图片像素数过多: {}MP", width as u64 * height as u64 / 1_000_000)
));
}
Ok((width, height))
}Uploading to Qiniu Kodo
Object Existence Check
Because the key is content‑derived, we can query the RS API to see if the object already exists, avoiding unnecessary uploads.
pub async fn object_exists(
http_client: &reqwest::Client,
config: &QiniuConfig,
key: &str,
) -> Result<bool, AppError> {
let entry = format!("{}:{}", config.bucket, key);
let encoded_entry = URL_SAFE.encode(entry.as_bytes());
let path = format!("/stat/{encoded_entry}");
let qbox = token::qbox_token(&config.access_key, &config.secret_key, &path);
let url = format!("https://rs.qiniuapi.com{path}");
let response = http_client.get(&url)
.header("Authorization", qbox)
.send()
.await
.map_err(|e| AppError::Internal(format!("Qiniu stat failed: {e}")))?;
match response.status().as_u16() {
200 => Ok(true),
612 => Ok(false), // 612 = object not found
_ => Ok(false), // other statuses are treated as non‑existent
}
}Multipart Form Upload
The Kodo form upload endpoint expects three fields: token: the upload token generated earlier. key: the object key; must match the scope in the token. file: binary image data as a multipart part with MIME application/octet-stream (the actual MIME is inferred from the key extension).
pub async fn upload_bytes(
http_client: &reqwest::Client,
config: &QiniuConfig,
key: &str,
data: &[u8],
) -> Result<(), AppError> {
let deadline = now_epoch() + 3600;
let scope = format!("{}:{}", config.bucket, key);
let policy = serde_json::json!({ "scope": scope, "deadline": deadline });
let policy_json = serde_json::to_string(&policy)
.map_err(|e| AppError::Internal(format!("policy JSON: {e}")))?;
let up_token = token::upload_token(&config.access_key, &config.secret_key, &policy_json);
let upload_url = format!("https://upload-{}.qiniup.com", config.upload_region);
let file_part = reqwest::multipart::Part::bytes(data.to_vec())
.file_name("upload.bin")
.mime_str("application/octet-stream")
.map_err(|e| AppError::Internal(format!("mime: {e}")))?;
let form = reqwest::multipart::Form::new()
.text("token", up_token)
.text("key", key.to_string())
.part("file", file_part);
let response = http_client.post(&upload_url)
.multipart(form)
.send()
.await
.map_err(|e| AppError::Internal(format!("Qiniu upload: {e}")))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(AppError::Internal(format!("Qiniu upload HTTP {status}: {body}")));
}
tracing::info!(key = %key, size = data.len(), "七牛上传成功");
Ok(())
}Qiniu CDN Referer Whitelist
Even though the download URL expires, a leaked URL can be abused until it expires. To mitigate hot‑linking, configure a Referer whitelist on the CDN domain so that only trusted origins (the mini‑program domain) can fetch images. The whitelist should include:
servicewechat.com *.servicewechat.comAlso enable the “Allow empty Referer” option because some WeChat client versions omit the Referer header when previewing images.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
