mirror of
https://github.com/perfect-panel/ppanel-web.git
synced 2026-08-29 05:52:08 -04:00
Initial
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "sms"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1"
|
||||
anyhow = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
hmac = "0.12"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
md5 = "0.7"
|
||||
chrono = "0.4"
|
||||
@@ -0,0 +1,38 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Unified config struct covering all 4 providers.
|
||||
/// Fields not used by a given provider are simply ignored.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SmsConfig {
|
||||
// ── AlibabaCloud ────────────────────────────────────────────────
|
||||
/// AccessKeyId
|
||||
#[serde(default)]
|
||||
pub access: String,
|
||||
/// AccessKeySecret / MD5Key / AuthToken / Password
|
||||
#[serde(default)]
|
||||
pub secret: String,
|
||||
/// AlibabaCloud: SignName
|
||||
#[serde(default)]
|
||||
pub sign_name: String,
|
||||
/// AlibabaCloud: Endpoint (default: dysmsapi.ap-southeast-1.aliyuncs.com)
|
||||
#[serde(default)]
|
||||
pub endpoint: String,
|
||||
/// AlibabaCloud: TemplateCode
|
||||
#[serde(default)]
|
||||
pub template_code: String,
|
||||
|
||||
// ── Smsbao / Abosend / Twilio ────────────────────────────────────
|
||||
/// Go template string, e.g. "Your code is {{.code}}"
|
||||
#[serde(default)]
|
||||
pub template: String,
|
||||
|
||||
// ── Abosend ─────────────────────────────────────────────────────
|
||||
/// Override API base URL (default: https://smsapi.abosend.com)
|
||||
#[serde(default)]
|
||||
pub api_domain: String,
|
||||
|
||||
// ── Twilio ───────────────────────────────────────────────────────
|
||||
/// Sending phone number (e.g. "+12015551234")
|
||||
#[serde(default)]
|
||||
pub phone_number: String,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use crate::config::SmsConfig;
|
||||
use crate::platform::Platform;
|
||||
use crate::providers::{abosend::AbosendSender, alibabacloud::AlibabaCloudSender, smsbao::SmsbaoSender, twilio::TwilioSender};
|
||||
use crate::sender::Sender;
|
||||
|
||||
pub fn create_sender(platform: Platform, config: SmsConfig) -> Box<dyn Sender> {
|
||||
match platform {
|
||||
Platform::AlibabaCloud => Box::new(AlibabaCloudSender::new(config)),
|
||||
Platform::Smsbao => Box::new(SmsbaoSender::new(config)),
|
||||
Platform::Abosend => Box::new(AbosendSender::new(config)),
|
||||
Platform::Twilio => Box::new(TwilioSender::new(config)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
pub mod config;
|
||||
pub mod factory;
|
||||
pub mod platform;
|
||||
pub mod providers;
|
||||
pub mod sender;
|
||||
|
||||
pub use config::SmsConfig;
|
||||
pub use factory::create_sender;
|
||||
pub use platform::Platform;
|
||||
pub use sender::Sender;
|
||||
@@ -0,0 +1,33 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Platform {
|
||||
AlibabaCloud,
|
||||
Smsbao,
|
||||
Abosend,
|
||||
Twilio,
|
||||
}
|
||||
|
||||
impl Platform {
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"AlibabaCloud" => Some(Platform::AlibabaCloud),
|
||||
"smsbao" => Some(Platform::Smsbao),
|
||||
"abosend" => Some(Platform::Abosend),
|
||||
"twilio" => Some(Platform::Twilio),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Platform {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
Platform::AlibabaCloud => "AlibabaCloud",
|
||||
Platform::Smsbao => "smsbao",
|
||||
Platform::Abosend => "abosend",
|
||||
Platform::Twilio => "twilio",
|
||||
};
|
||||
write!(f, "{}", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use anyhow::Context;
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::config::SmsConfig;
|
||||
use crate::sender::Sender;
|
||||
|
||||
const BASE_URL: &str = "https://smsapi.abosend.com";
|
||||
|
||||
pub struct AbosendSender {
|
||||
config: SmsConfig,
|
||||
client: Client,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl AbosendSender {
|
||||
pub fn new(config: SmsConfig) -> Self {
|
||||
let base_url = if config.api_domain.is_empty() {
|
||||
BASE_URL.to_string()
|
||||
} else {
|
||||
config.api_domain.clone()
|
||||
};
|
||||
Self {
|
||||
config,
|
||||
client: Client::new(),
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AbosendResponse {
|
||||
code: i32,
|
||||
message: String,
|
||||
}
|
||||
|
||||
/// Render a Go-style template string: replace `{{.code}}` with `code`.
|
||||
fn render_template(template: &str, code: &str) -> String {
|
||||
template.replace("{{.code}}", code)
|
||||
}
|
||||
|
||||
/// Compute MD5 hex string (lowercase).
|
||||
fn md5_hex(input: &str) -> String {
|
||||
let digest = md5::compute(input.as_bytes());
|
||||
format!("{:x}", digest)
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Sender for AbosendSender {
|
||||
async fn send(&self, area: &str, phone: &str, code: &str, _expire: u32) -> anyhow::Result<()> {
|
||||
let content = render_template(&self.config.template, code);
|
||||
|
||||
// rand is a 6-digit numeric string; we derive it from current time nanos
|
||||
let rand_num = format!(
|
||||
"{:06}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.subsec_nanos()
|
||||
% 1_000_000
|
||||
);
|
||||
|
||||
// sign = md5(orgCode + content + rand + md5key), uppercase
|
||||
let sign_input = format!(
|
||||
"{}{}{}{}",
|
||||
self.config.access, content, rand_num, self.config.secret
|
||||
);
|
||||
let sign = md5_hex(&sign_input).to_uppercase();
|
||||
|
||||
let body = serde_json::json!({
|
||||
"orgCode": self.config.access,
|
||||
"mobileArea": format!("+{}", area),
|
||||
"mobiles": format!("{}{}", area, phone),
|
||||
"content": content,
|
||||
"rand": rand_num,
|
||||
"sign": sign,
|
||||
});
|
||||
|
||||
let url = format!("{}/v2/api/sendSMS", self.base_url);
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Abosend: HTTP request failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("Abosend: HTTP {}", status);
|
||||
}
|
||||
|
||||
let result: AbosendResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Abosend: failed to parse response")?;
|
||||
|
||||
if result.code != 200 {
|
||||
anyhow::bail!(
|
||||
"Abosend: send failed, code={}, message={}",
|
||||
result.code,
|
||||
result.message
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
use anyhow::Context;
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::config::SmsConfig;
|
||||
use crate::sender::Sender;
|
||||
|
||||
const DEFAULT_ENDPOINT: &str = "dysmsapi.ap-southeast-1.aliyuncs.com";
|
||||
|
||||
pub struct AlibabaCloudSender {
|
||||
config: SmsConfig,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl AlibabaCloudSender {
|
||||
pub fn new(config: SmsConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal response body from Dysmsapi SendSms
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SendSmsResponse {
|
||||
#[serde(rename = "Code")]
|
||||
code: String,
|
||||
#[serde(rename = "Message")]
|
||||
message: String,
|
||||
}
|
||||
|
||||
/// Build the canonical query string and HMAC-SHA1 signature required by
|
||||
/// Alibaba Cloud's Dysmsapi (RPC-style, API version 2017-05-25).
|
||||
///
|
||||
/// Reference:
|
||||
/// https://help.aliyun.com/document_detail/101341.html
|
||||
fn sign_request(
|
||||
access_key_id: &str,
|
||||
access_key_secret: &str,
|
||||
params: &mut Vec<(String, String)>,
|
||||
) -> anyhow::Result<String> {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha1::Sha1;
|
||||
#[allow(unused_imports)]
|
||||
use sha2::Sha256;
|
||||
|
||||
// Common system parameters
|
||||
let nonce = uuid_v4_simple();
|
||||
let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
|
||||
params.push(("AccessKeyId".to_string(), access_key_id.to_string()));
|
||||
params.push(("Format".to_string(), "JSON".to_string()));
|
||||
params.push(("SignatureMethod".to_string(), "HMAC-SHA1".to_string()));
|
||||
params.push(("SignatureNonce".to_string(), nonce));
|
||||
params.push(("SignatureVersion".to_string(), "1.0".to_string()));
|
||||
params.push(("Timestamp".to_string(), timestamp));
|
||||
params.push(("Version".to_string(), "2017-05-25".to_string()));
|
||||
|
||||
// Sort by key
|
||||
params.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
// Percent-encode each key=value pair then join with &
|
||||
let canonical = params
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
format!(
|
||||
"{}={}",
|
||||
percent_encode(k),
|
||||
percent_encode(v)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
|
||||
let string_to_sign = format!(
|
||||
"GET&{}&{}",
|
||||
percent_encode("/"),
|
||||
percent_encode(&canonical)
|
||||
);
|
||||
|
||||
// Sign with HMAC-SHA1 using "<secret>&" as key
|
||||
let signing_key = format!("{}&", access_key_secret);
|
||||
let mut mac = Hmac::<Sha1>::new_from_slice(signing_key.as_bytes())
|
||||
.context("HMAC-SHA1 init failed")?;
|
||||
mac.update(string_to_sign.as_bytes());
|
||||
let signature = STANDARD.encode(mac.finalize().into_bytes());
|
||||
|
||||
params.push(("Signature".to_string(), signature.clone()));
|
||||
|
||||
// Rebuild final query
|
||||
let query = params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
|
||||
Ok(query)
|
||||
}
|
||||
|
||||
fn percent_encode(s: &str) -> String {
|
||||
let mut encoded = String::new();
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
|
||||
| b'-' | b'_' | b'.' | b'~' => {
|
||||
encoded.push(b as char);
|
||||
}
|
||||
_ => {
|
||||
encoded.push_str(&format!("%{:02X}", b));
|
||||
}
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn uuid_v4_simple() -> String {
|
||||
// Generate a UUID-like random string without external dep
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let t = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.subsec_nanos();
|
||||
format!("{:08x}-{:04x}-4{:03x}", t, t >> 16, t & 0xfff)
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Sender for AlibabaCloudSender {
|
||||
async fn send(&self, area: &str, phone: &str, code: &str, _expire: u32) -> anyhow::Result<()> {
|
||||
let endpoint = if self.config.endpoint.is_empty() {
|
||||
DEFAULT_ENDPOINT
|
||||
} else {
|
||||
&self.config.endpoint
|
||||
};
|
||||
|
||||
let template_param = json!({ "code": code }).to_string();
|
||||
let phone_number = format!("{}{}", area, phone);
|
||||
|
||||
let mut params: Vec<(String, String)> = vec![
|
||||
("Action".to_string(), "SendSms".to_string()),
|
||||
("PhoneNumbers".to_string(), phone_number),
|
||||
("SignName".to_string(), self.config.sign_name.clone()),
|
||||
("TemplateCode".to_string(), self.config.template_code.clone()),
|
||||
("TemplateParam".to_string(), template_param),
|
||||
];
|
||||
|
||||
let query = sign_request(&self.config.access, &self.config.secret, &mut params)?;
|
||||
let url = format!("https://{}/{}", endpoint, query);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.context("AlibabaCloud: HTTP request failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.context("AlibabaCloud: failed to read body")?;
|
||||
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("AlibabaCloud: HTTP {} — {}", status, body);
|
||||
}
|
||||
|
||||
let result: SendSmsResponse =
|
||||
serde_json::from_str(&body).context("AlibabaCloud: failed to parse response")?;
|
||||
|
||||
if result.code != "OK" {
|
||||
anyhow::bail!(
|
||||
"AlibabaCloud: SendSms failed, code={}, message={}",
|
||||
result.code,
|
||||
result.message
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod alibabacloud;
|
||||
pub mod abosend;
|
||||
pub mod smsbao;
|
||||
pub mod twilio;
|
||||
@@ -0,0 +1,86 @@
|
||||
use anyhow::Context;
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::config::SmsConfig;
|
||||
use crate::sender::Sender;
|
||||
|
||||
const BASE_URL: &str = "https://api.smsbao.com";
|
||||
|
||||
pub struct SmsbaoSender {
|
||||
config: SmsConfig,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl SmsbaoSender {
|
||||
pub fn new(config: SmsConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a Go-style template: replace `{{.code}}` with `code`.
|
||||
fn render_template(template: &str, code: &str) -> String {
|
||||
template.replace("{{.code}}", code)
|
||||
}
|
||||
|
||||
/// MD5 hex (lowercase).
|
||||
fn md5_hex(input: &str) -> String {
|
||||
let digest = md5::compute(input.as_bytes());
|
||||
format!("{:x}", digest)
|
||||
}
|
||||
|
||||
/// Map SMSBao numeric response body to an error description.
|
||||
fn parse_smsbao_error(body: &str) -> anyhow::Result<()> {
|
||||
match body.trim() {
|
||||
"0" => Ok(()),
|
||||
"30" => anyhow::bail!("SMSBao: password error"),
|
||||
"40" => anyhow::bail!("SMSBao: account not found"),
|
||||
"41" => anyhow::bail!("SMSBao: insufficient balance"),
|
||||
"43" => anyhow::bail!("SMSBao: IP address restrictions"),
|
||||
"50" => anyhow::bail!("SMSBao: content contains sensitive words"),
|
||||
"51" => anyhow::bail!("SMSBao: mobile number is incorrect"),
|
||||
other => anyhow::bail!("SMSBao: unknown error code: {}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Sender for SmsbaoSender {
|
||||
async fn send(&self, area: &str, phone: &str, code: &str, _expire: u32) -> anyhow::Result<()> {
|
||||
let content = render_template(&self.config.template, code);
|
||||
let password_md5 = md5_hex(&self.config.secret);
|
||||
|
||||
// Domestic (China, area == "86") → /sms, just mobile number
|
||||
// International → /wsms, prepend +area to number
|
||||
let (api_path, mobile) = if area == "86" {
|
||||
("/sms", phone.to_string())
|
||||
} else {
|
||||
("/wsms", format!("+{}{}", area, phone))
|
||||
};
|
||||
|
||||
let url = format!("{}{}", BASE_URL, api_path);
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.query(&[
|
||||
("u", self.config.access.as_str()),
|
||||
("p", &password_md5),
|
||||
("m", &mobile),
|
||||
("c", &content),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.context("SMSBao: HTTP request failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.context("SMSBao: failed to read body")?;
|
||||
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("SMSBao: HTTP {} — {}", status, body);
|
||||
}
|
||||
|
||||
parse_smsbao_error(&body)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use anyhow::Context;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::config::SmsConfig;
|
||||
use crate::sender::Sender;
|
||||
|
||||
pub struct TwilioSender {
|
||||
config: SmsConfig,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl TwilioSender {
|
||||
pub fn new(config: SmsConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render Go-style template: replace `{{.code}}` with `code`.
|
||||
fn render_template(template: &str, code: &str) -> String {
|
||||
template.replace("{{.code}}", code)
|
||||
}
|
||||
|
||||
/// Twilio Messages API response (partial — only error fields matter)
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TwilioMessageResponse {
|
||||
error_code: Option<i32>,
|
||||
error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Sender for TwilioSender {
|
||||
async fn send(&self, area: &str, phone: &str, code: &str, _expire: u32) -> anyhow::Result<()> {
|
||||
let to = format!("+{}{}", area, phone);
|
||||
let body_text = render_template(&self.config.template, code);
|
||||
|
||||
// Twilio REST API: POST /2010-04-01/Accounts/{AccountSid}/Messages.json
|
||||
// Auth: HTTP Basic (AccountSid : AuthToken)
|
||||
let account_sid = &self.config.access;
|
||||
let auth_token = &self.config.secret;
|
||||
let url = format!(
|
||||
"https://api.twilio.com/2010-04-01/Accounts/{}/Messages.json",
|
||||
account_sid
|
||||
);
|
||||
|
||||
let credentials = STANDARD.encode(format!("{}:{}", account_sid, auth_token));
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Basic {}", credentials))
|
||||
.form(&[
|
||||
("To", to.as_str()),
|
||||
("From", self.config.phone_number.as_str()),
|
||||
("Body", body_text.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.context("Twilio: HTTP request failed")?;
|
||||
|
||||
let status = resp.status();
|
||||
let raw = resp.text().await.context("Twilio: failed to read body")?;
|
||||
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("Twilio: HTTP {} — {}", status, raw);
|
||||
}
|
||||
|
||||
let result: TwilioMessageResponse =
|
||||
serde_json::from_str(&raw).context("Twilio: failed to parse response")?;
|
||||
|
||||
if let Some(err_code) = result.error_code {
|
||||
let msg = result.error_message.unwrap_or_default();
|
||||
anyhow::bail!("Twilio: send failed, error_code={}, message={}", err_code, msg);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
#[async_trait::async_trait]
|
||||
pub trait Sender: Send + Sync {
|
||||
async fn send(&self, area: &str, phone: &str, code: &str, expire: u32) -> anyhow::Result<()>;
|
||||
}
|
||||
Reference in New Issue
Block a user