Files
yaoayanui/src-tauri/src/oss.rs
fengchuanhn@gmail.com c88b2d0f3d 1
2026-05-16 11:24:18 +08:00

127 lines
3.7 KiB
Rust

//! Minimal Aliyun OSS PUT-Object client.
//!
//! Only the subset required by the Douyin pipeline is implemented:
//! - PUT a single local file under `audio/<uuid>.<ext>`
//! - 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<String>,
}
#[derive(Debug, Serialize)]
pub struct OssUploadResult {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
type HmacSha1 = Hmac<Sha1>;
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<String, OssError> {
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<String, OssError> {
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)
}