127 lines
3.7 KiB
Rust
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)
|
|
}
|