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.

Tech Musings
Tech Musings
Tech Musings
How to Upload Images to Qiniu Cloud with Rust + Actix‑Web and Generate Signed Download URLs

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‑program

Dependencies (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.com

Also enable the “Allow empty Referer” option because some WeChat client versions omit the Referer header when previewing images.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

RustObject StorageImage UploadQiniuActix-WebSigned URLContent-Addressable Storage
Tech Musings
Written by

Tech Musings

Capturing thoughts and reflections while coding.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.