use chrono::{Duration, Utc};
use crate::cache;
use crate::error::LicenseError;
use crate::fingerprint;
use crate::integrity::ValidationReceipt;
use crate::types::*;
const DEFAULT_API_URL: &str = "https://api.archergate.com";
const USER_AGENT: &str = concat!("archergate-license/", env!("CARGO_PKG_VERSION"));
pub struct LicenseClient {
api_key: String,
plugin_id: String,
api_url: String,
}
impl LicenseClient {
pub fn new(api_key: &str, plugin_id: &str) -> Self {
Self {
api_key: api_key.to_string(),
plugin_id: plugin_id.to_string(),
api_url: DEFAULT_API_URL.to_string(),
}
}
pub fn with_api_url(mut self, url: &str) -> Self {
self.api_url = url.trim_end_matches('/').to_string();
self
}
pub fn validate(&self, license_key: &str) -> Result<(), LicenseError> {
let fp = fingerprint::machine_fingerprint();
if let Some(cached) = cache::load_license(&self.plugin_id) {
if cached.license_key == license_key && cached.machine_fingerprint == fp {
if cache::is_within_grace_period(&cached, Utc::now()) {
return Ok(());
}
} else if cached.license_key == license_key {
return Err(LicenseError::MachineMismatch);
}
}
match self.call_validate(license_key, &fp) {
Ok(resp) => Self::handle_validate_response(resp, license_key, fp, &self.plugin_id),
Err(net_err) => {
if let Some(cached) = cache::load_license(&self.plugin_id) {
if cached.license_key == license_key
&& cached.machine_fingerprint == fp
&& cache::is_within_grace_period(&cached, Utc::now())
{
return Ok(());
}
}
Err(LicenseError::NetworkError(net_err))
}
}
}
pub fn activate(
&self,
license_key: &str,
email: &str,
) -> Result<ActivateResponse, LicenseError> {
let fp = fingerprint::machine_fingerprint();
let url = format!("{}/activate", self.api_url);
let body = ActivateRequest {
license_key: license_key.to_string(),
machine_fingerprint: fp.clone(),
plugin_id: self.plugin_id.clone(),
email: email.to_string(),
};
let resp = ureq::post(&url)
.set("Authorization", &format!("Bearer {}", self.api_key))
.set("User-Agent", USER_AGENT)
.set("Content-Type", "application/json")
.send_json(serde_json::to_value(&body).map_err(|e| LicenseError::NetworkError(e.to_string()))?)
.map_err(|e| {
if let ureq::Error::Status(status, resp) = e {
if let Ok(err_resp) = resp.into_json::<ValidateResponse>() {
return match err_resp.error.as_deref() {
Some("activation_limit") => LicenseError::ActivationLimitReached,
Some("expired") => LicenseError::Expired,
Some("machine_mismatch") => LicenseError::MachineMismatch,
_ => LicenseError::Invalid,
};
}
return LicenseError::NetworkError(format!("HTTP {status}"));
}
LicenseError::NetworkError(e.to_string())
})?;
let activate_resp: ActivateResponse = resp
.into_json()
.map_err(|e| LicenseError::NetworkError(e.to_string()))?;
let cached = CachedLicense {
license_key: license_key.to_string(),
machine_fingerprint: fp,
validated_at: Utc::now(),
expires_at: Utc::now() + Duration::days(365),
offline_token: activate_resp.offline_token.clone(),
};
let _ = cache::save_license(&self.plugin_id, &cached);
Ok(activate_resp)
}
pub fn machine_fingerprint() -> String {
fingerprint::machine_fingerprint()
}
pub fn start_trial(&self) -> Result<TrialLicense, LicenseError> {
if let Some(existing) = cache::load_trial(&self.plugin_id) {
let now = Utc::now();
if now >= existing.expires_at {
return Err(LicenseError::TrialExpired);
}
let remaining = (existing.expires_at - now).num_days().max(0) as u32;
return Ok(TrialLicense {
expires_at: existing.expires_at,
days_remaining: remaining,
});
}
let now = Utc::now();
let expires = now + Duration::days(14);
let trial = CachedTrial {
plugin_id: self.plugin_id.clone(),
started_at: now,
expires_at: expires,
};
cache::save_trial(&self.plugin_id, &trial)
.map_err(|e| LicenseError::NetworkError(format!("failed to save trial: {e}")))?;
let remaining = (expires - now).num_days().max(0) as u32;
Ok(TrialLicense {
expires_at: expires,
days_remaining: remaining,
})
}
pub fn validate_with_receipt(
&self,
license_key: &str,
) -> Result<ValidationReceipt, LicenseError> {
self.validate(license_key)?;
let fp = fingerprint::machine_fingerprint();
Ok(ValidationReceipt::issue(license_key, &fp))
}
pub fn plugin_id(&self) -> &str {
&self.plugin_id
}
fn handle_validate_response(
resp: ValidateResponse,
license_key: &str,
fp: String,
plugin_id: &str,
) -> Result<(), LicenseError> {
if resp.valid {
let expires_at = resp
.expires_at
.as_deref()
.and_then(|s| s.parse().ok())
.unwrap_or_else(|| Utc::now() + Duration::days(365));
let cached = CachedLicense {
license_key: license_key.to_string(),
machine_fingerprint: fp,
validated_at: Utc::now(),
expires_at,
offline_token: String::new(),
};
let _ = cache::save_license(plugin_id, &cached);
Ok(())
} else {
match resp.error.as_deref() {
Some("expired") => Err(LicenseError::Expired),
Some("machine_mismatch") => Err(LicenseError::MachineMismatch),
Some("activation_limit") => Err(LicenseError::ActivationLimitReached),
_ => Err(LicenseError::Invalid),
}
}
}
fn call_validate(&self, license_key: &str, fp: &str) -> Result<ValidateResponse, String> {
let url = format!("{}/validate", self.api_url);
let body = ValidateRequest {
license_key: license_key.to_string(),
machine_fingerprint: fp.to_string(),
plugin_id: self.plugin_id.clone(),
};
let resp = ureq::post(&url)
.set("Authorization", &format!("Bearer {}", self.api_key))
.set("User-Agent", USER_AGENT)
.set("Content-Type", "application/json")
.send_json(serde_json::to_value(&body).map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?;
resp.into_json::<ValidateResponse>()
.map_err(|e| e.to_string())
}
}