Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug janus server",
"cargo": {
"args": [
"build",
"--bin=janus",
"--package=janus"
],
"filter": {
"name": "janus",
"kind": "bin"
}
},
"args": [
"server",
"--config",
"config.toml"
],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug generate-jwt",
"cargo": {
"args": [
"build",
"--bin=janus",
"--package=janus"
],
"filter": {
"name": "janus",
"kind": "bin"
}
},
"args": [
"generate-jwt",
"--config",
"config.toml",
"--subject",
"test_user"
],
"cwd": "${workspaceFolder}"
}
]
}
12 changes: 12 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,8 @@ serde_variant = "0.1.3"
reqwest = { version = "0.12.28", features = ["json", "multipart"] }
rand = "0.8"
jsonwebtoken = "9.3"
sha2 = "0.10"
hmac = "0.12"
base64 = "0.22"
urlencoding = "2.1"
uuid = { version = "1.0", features = ["v4"] }
3 changes: 3 additions & 0 deletions demo.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{"datacontenttype":"application/json;charset=utf-8","aliyunaccountid":"164901546557****","data":{"region":"cn-beijing","eventVersion":"1.0","eventSource":"acs:oss","eventName":"ObjectCreated:PutObject","eventTime":"2021-08-13T06:45:43.000Z","requestParameters":{"sourceIPAddress":"118.31.XX.XX"},"userIdentity":{"principalId":"28815334868278****"},"responseElements":{"requestId":"61161517B258223732BC****"},"oss":{"bucket":{"name":"oss-source-bucket1-cn-beijing","arn":"acs:oss:cn-beijing:164901546557****:oss-source-bucket1-cn-beijing","ownerIdentity":"164901546557****"},"ossSchemaVersion":"1.0","object":{"size":9,"deltaSize":9,"eTag":"F0F18C2C66AE1DD512BDCD4366F7****","key":"objectname"}}},"subject":"acs:oss:cn-beijing:164901546557****:oss-source-bucket1-cn-beijing/1628837143916","aliyunoriginalaccountid":"164901546557****","source":"acs.oss","type":"oss:ObjectCreated:PutObject","aliyunpublishtime":"2021-08-13T06:45:43.986Z","specversion":"1.0","aliyuneventbusname":"default","id":"61161517B258223732BC****","time":"2021-08-13T06:45:43Z","aliyunregionid":"cn-beijing"}
{"datacontenttype":"application/json;charset=utf-8","aliyunaccountid":"164901546557****","data":{"region":"cn-beijing","eventVersion":"1.0","eventSource":"acs:oss","eventName":"ObjectCreated:CompleteMultipartUpload","eventTime":"2021-08-13T06:45:43.000Z","requestParameters":{"sourceIPAddress":"118.31.XX.XX"},"userIdentity":{"principalId":"28815334868278****"},"responseElements":{"requestId":"61161517B258223732BC****"},"oss":{"bucket":{"name":"oss-source-bucket1-cn-beijing","arn":"acs:oss:cn-beijing:164901546557****:oss-source-bucket1-cn-beijing","ownerIdentity":"164901546557****"},"ossSchemaVersion":"1.0","object":{"size":9,"deltaSize":9,"eTag":"F0F18C2C66AE1DD512BDCD4366F7****","key":"objectname"}}},"subject":"acs:oss:cn-beijing:164901546557****:oss-source-bucket1-cn-beijing/1628837143916","aliyunoriginalaccountid":"164901546557****","source":"acs.oss","type":"oss:ObjectCreated:CompleteMultipartUpload","aliyunpublishtime":"2021-08-13T06:45:43.986Z","specversion":"1.0","aliyuneventbusname":"default","id":"61161517B258223732BC****","time":"2021-08-13T06:45:43Z","aliyunregionid":"cn-beijing"}
{"datacontenttype":"application/json;charset=utf-8","aliyunaccountid":"164901546557****","data":{"region":"cn-beijing","eventVersion":"1.0","eventSource":"acs:oss","eventName":"ObjectRemoved:DeleteObject","eventTime":"2021-08-13T06:45:43.000Z","requestParameters":{"sourceIPAddress":"118.31.XX.XX"},"userIdentity":{"principalId":"28815334868278****"},"responseElements":{"requestId":"61161517B258223732BC****"},"oss":{"bucket":{"name":"oss-source-bucket1-cn-beijing","arn":"acs:oss:cn-beijing:164901546557****:oss-source-bucket1-cn-beijing","ownerIdentity":"164901546557****"},"ossSchemaVersion":"1.0","object":{"key":"test/file.txt"}}},"subject":"acs:oss:cn-beijing:164901546557****:oss-source-bucket1-cn-beijing/test/file.txt","aliyunoriginalaccountid":"164901546557****","source":"acs.oss","type":"oss:ObjectRemoved:DeleteObject","aliyunpublishtime":"2021-08-13T06:45:43.986Z","specversion":"1.0","aliyuneventbusname":"default","id":"61161517B258223732BC****","time":"2021-08-13T06:45:43Z","aliyunregionid":"cn-beijing"}
10 changes: 10 additions & 0 deletions example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ public_key = """-----BEGIN PUBLIC KEY-----
YOUR_PUBLIC_KEY_HERE
-----END PUBLIC KEY-----"""

# Aliyun Configuration (optional, for CDN and OSS event handling)
# [aliyun]
# access_key_id = "your_aliyun_access_key_id"
# access_key_secret = "your_aliyun_access_key_secret"
# # Mapping from OSS bucket names to CDN URL templates
# # The {object_key} placeholder will be replaced with the percent-encoded object key
# [aliyun.bucket_url_map]
# "my-oss-bucket" = "https://cdn.example.com/{object_key}"
# "another-bucket" = "https://static.example.com/{object_key}"

# [sentry]
# dsn = ""
# traces_sample_rate = 1.0
112 changes: 112 additions & 0 deletions src/aliyun/cdn.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
use crate::aliyun::signature::AliyunSigner;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;

const CDN_ENDPOINT: &str = "https://cdn.aliyuncs.com";
const API_VERSION: &str = "2018-05-10";

/// Aliyun CDN API client
pub struct AliyunCdnClient {
signer: AliyunSigner,
http_client: reqwest::Client,
}

impl AliyunCdnClient {
/// Create a new CDN client
pub fn new(
access_key_id: String,
access_key_secret: String,
http_client: reqwest::Client,
) -> Self {
Self {
signer: AliyunSigner::new(access_key_id, access_key_secret),
http_client,
}
}

/// Refresh CDN object caches
///
/// API documentation: https://help.aliyun.com/zh/cdn/developer-reference/api-cdn-2018-05-10-refreshobjectcaches
pub async fn refresh_object_caches(
&self,
request: &RefreshObjectCachesRequest,
) -> Result<RefreshObjectCachesResponse> {
let mut params = BTreeMap::new();

// Required parameters
params.insert("Action".to_string(), "RefreshObjectCaches".to_string());
params.insert("Version".to_string(), API_VERSION.to_string());
params.insert("Format".to_string(), "JSON".to_string());
params.insert(
"AccessKeyId".to_string(),
self.signer.access_key_id().to_string(),
);
params.insert("SignatureMethod".to_string(), "HMAC-SHA256".to_string());
params.insert("SignatureVersion".to_string(), "1.0".to_string());
params.insert(
"SignatureNonce".to_string(),
uuid::Uuid::new_v4().to_string(),
);
params.insert(
"Timestamp".to_string(),
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
);
params.insert("ObjectPath".to_string(), request.object_path.clone());

// Optional parameters
if let Some(object_type) = &request.object_type {
params.insert("ObjectType".to_string(), object_type.clone());
}
if let Some(area) = &request.area {
params.insert("Area".to_string(), area.clone());
}

// Generate signature
let (query_string, signature) = self.signer.sign("GET", "/", &params);

// Build final URL (parameters are already percent-encoded in query_string)
let url = format!(
"{}/?{}&Signature={}",
CDN_ENDPOINT,
query_string,
urlencoding::encode(&signature)
);

// Make request
let response = self
.http_client
.get(&url)
.send()
.await
.context("Failed to send request to Aliyun CDN API")?;

let status = response.status();
let body = response
.text()
.await
.context("Failed to read response body")?;

if !status.is_success() {
anyhow::bail!("Aliyun CDN API error ({}): {}", status, body);
}

serde_json::from_str(&body).context("Failed to parse Aliyun CDN API response")
}
}

// Request/Response structures

#[derive(Debug, Serialize, Deserialize)]
pub struct RefreshObjectCachesRequest {
pub object_path: String,
pub object_type: Option<String>,
pub area: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct RefreshObjectCachesResponse {
pub request_id: String,
pub refresh_task_id: String,
}
2 changes: 2 additions & 0 deletions src/aliyun/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pub mod cdn;
pub mod signature;
112 changes: 112 additions & 0 deletions src/aliyun/signature.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
use base64::{Engine as _, engine::general_purpose};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::collections::BTreeMap;

type HmacSha256 = Hmac<Sha256>;

/// Aliyun API V3 signature generator
///
/// Implements the signature algorithm for Aliyun API V3 as documented at:
/// https://help.aliyun.com/zh/sdk/product-overview/v3-request-structure-and-signature
pub struct AliyunSigner {
access_key_id: String,
access_key_secret: String,
}

impl AliyunSigner {
/// Create a new AliyunSigner with access credentials
pub fn new(access_key_id: String, access_key_secret: String) -> Self {
Self {
access_key_id,
access_key_secret,
}
}

/// Generate signature for API request
///
/// # Arguments
/// * `method` - HTTP method (e.g., "GET", "POST")
/// * `path` - API path (e.g., "/")
/// * `params` - Query parameters as key-value pairs
///
/// # Returns
/// Returns a tuple of (query_string, signature) where:
/// - query_string: URL-encoded query string with all parameters
/// - signature: Base64-encoded HMAC-SHA256 signature
pub fn sign(
&self,
method: &str,
path: &str,
params: &BTreeMap<String, String>,
) -> (String, String) {
// Build canonical query string from sorted parameters
let canonical_query = self.build_canonical_query(params);

// Build string to sign
let string_to_sign = format!("{}\n{}\n{}", method, path, canonical_query);

// Generate signature
let signature_key = format!("{}&", self.access_key_secret);
let mut mac = HmacSha256::new_from_slice(signature_key.as_bytes())
.expect("HMAC can take key of any size");
mac.update(string_to_sign.as_bytes());
let signature_bytes = mac.finalize().into_bytes();
let signature = general_purpose::STANDARD.encode(signature_bytes);

(canonical_query, signature)
}

/// Build canonical query string from parameters
/// Parameters are sorted by key (BTreeMap maintains order) and percent-encoded per RFC 3986
fn build_canonical_query(&self, params: &BTreeMap<String, String>) -> String {
params
.iter()
.map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v)))
.collect::<Vec<_>>()
.join("&")
}

/// Get access key ID
pub fn access_key_id(&self) -> &str {
&self.access_key_id
}
}

/// Percent-encode a string according to RFC 3986
///
/// This encodes all characters except: A-Z, a-z, 0-9, -, _, ., ~
/// Space is encoded as %20 (not +)
fn percent_encode(s: &str) -> String {
urlencoding::encode(s).to_string()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_percent_encode() {
assert_eq!(percent_encode("hello world"), "hello%20world");
assert_eq!(percent_encode("test@example.com"), "test%40example.com");
assert_eq!(percent_encode("a-b_c.d~e"), "a-b_c.d~e");
}

#[test]
fn test_signature_generation() {
let signer = AliyunSigner::new("test_key_id".to_string(), "test_key_secret".to_string());

let mut params = BTreeMap::new();
params.insert("Action".to_string(), "DescribeRefreshTasks".to_string());
params.insert("Version".to_string(), "2018-05-10".to_string());

let (query, signature) = signer.sign("GET", "/", &params);

// Verify query string is properly formatted
assert!(query.contains("Action=DescribeRefreshTasks"));
assert!(query.contains("Version=2018-05-10"));

// Verify signature is base64 encoded
assert!(general_purpose::STANDARD.decode(&signature).is_ok());
}
}
7 changes: 4 additions & 3 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ use crate::state::AppState;
/// JWT Claims structure using standard registered claims
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
/// Subject (user identifier)
pub sub: String,
/// Subject (user identifier) - optional to support different token types
#[serde(skip_serializing_if = "Option::is_none")]
pub sub: Option<String>,
/// Issued at (as Unix timestamp)
pub iat: u64,
}
Expand All @@ -28,7 +29,7 @@ impl Claims {
.as_secs();

Self {
sub: subject,
sub: Some(subject),
iat: now,
}
}
Expand Down
Loading