//! Minimal Aliyun OSS PUT-Object client. //! //! Only the subset required by the Douyin pipeline is implemented: //! - PUT a single local file under `audio/.` //! - Sign with HMAC-SHA1 using AccessKey ID + Secret //! - Return the public https URL //! //! No external `aliyun-oss` crate is used so that the dependency footprint //! stays small. use std::path::Path; use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use chrono::Utc; use hmac::{Hmac, Mac}; use serde::{Deserialize, Serialize}; use sha1::Sha1; use thiserror::Error; #[derive(Debug, Error)] pub enum OssError { #[error("missing field: {0}")] MissingField(&'static str), #[error("io: {0}")] Io(#[from] std::io::Error), #[error("http: {0}")] Http(#[from] reqwest::Error), #[error("oss returned status={status}, body={body}")] BadStatus { status: u16, body: String }, #[error("hmac error")] Hmac, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OssConfig { #[serde(rename = "accessKeyId")] pub access_key_id: String, #[serde(rename = "accessKeySecret")] pub access_key_secret: String, pub bucket: String, pub region: String, #[serde(default)] pub endpoint: Option, } #[derive(Debug, Serialize)] pub struct OssUploadResult { pub success: bool, #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } type HmacSha1 = Hmac; fn rfc1123_now() -> String { Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string() } fn sign(secret: &str, str_to_sign: &str) -> Result { let mut mac = HmacSha1::new_from_slice(secret.as_bytes()).map_err(|_| OssError::Hmac)?; mac.update(str_to_sign.as_bytes()); Ok(B64.encode(mac.finalize().into_bytes())) } fn require<'a>(v: &'a str, name: &'static str) -> Result<&'a str, OssError> { if v.trim().is_empty() { Err(OssError::MissingField(name)) } else { Ok(v) } } pub async fn put_object( cfg: &OssConfig, local_path: &Path, object_key: &str, content_type: &str, ) -> Result { let ak = require(&cfg.access_key_id, "accessKeyId")?; let sk = require(&cfg.access_key_secret, "accessKeySecret")?; let bucket = require(&cfg.bucket, "bucket")?; let region = require(&cfg.region, "region")?; let host = format!("{bucket}.{region}.aliyuncs.com"); let endpoint = cfg .endpoint .clone() .unwrap_or_else(|| format!("https://{host}")); let url = format!("{}/{}", endpoint.trim_end_matches('/'), object_key); let body = tokio::fs::read(local_path).await?; let date = rfc1123_now(); // String-to-sign for OSS V1: // VERB\nContent-MD5\nContent-Type\nDate\nCanonicalizedOSSHeaders+CanonicalizedResource let canonicalized_resource = format!("/{bucket}/{object_key}"); let str_to_sign = format!( "PUT\n\n{ct}\n{date}\n{res}", ct = content_type, date = date, res = canonicalized_resource ); let signature = sign(sk, &str_to_sign)?; let authorization = format!("OSS {ak}:{signature}"); let client = reqwest::Client::builder() .build() .map_err(OssError::Http)?; let resp = client .put(&url) .header("Date", &date) .header("Content-Type", content_type) .header("Authorization", authorization) .body(body) .send() .await?; let status = resp.status().as_u16(); if !(200..300).contains(&status) { let body = resp.text().await.unwrap_or_default(); return Err(OssError::BadStatus { status, body }); } Ok(url) }