use crate::console::ConsoleState;
use axum::{
Router,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
};
use serde::Serialize;
use std::sync::Arc;
pub fn urls() -> Router<Arc<ConsoleState>> {
#[cfg(feature = "commerce")]
return commerce::urls();
#[cfg(not(feature = "commerce"))]
Router::new().route("/licenses", get(index))
}
pub async fn index(State(state): State<Arc<ConsoleState>>) -> impl IntoResponse {
let bp = state.base_path().to_string();
#[cfg(not(feature = "commerce"))]
return axum::response::Redirect::to(&format!("{bp}/addons"));
#[cfg(feature = "commerce")]
axum::response::Redirect::to(&format!("{bp}/addons#licenses"))
}
#[cfg(feature = "commerce")]
pub async fn verify_license_key(_state: Arc<ConsoleState>, license_key: String) -> Response {
use super::addons::{
get_config_path, json_error, load_document, parse_config_from_str, persist_document,
};
use toml_edit::{Item, Table};
let license_key = license_key.trim().to_string();
if license_key.is_empty() {
return json_error(StatusCode::BAD_REQUEST, "License key cannot be empty.").into_response();
}
let info = match crate::license::verify_license(&license_key).await {
Ok(i) => i,
Err(e) => {
return json_error(
StatusCode::UNPROCESSABLE_ENTITY,
format!("License verification failed: {e}"),
)
.into_response();
}
};
if !info.valid {
let reason_msg = match info.reject_reason.as_deref() {
Some("ip_limit_exceeded") => {
"This license key has reached its maximum IP usage limit. Please contact support to increase the limit or free up an IP."
}
Some("email_mismatch") => {
"This key is restricted to a specific authorized email. Make sure [licenses] email in your config matches the key's authorized email."
}
Some("email_required") => {
"This key requires an authorized email. Set [licenses] email in your config.toml."
}
Some("license_revoked") => "This license key has been revoked.",
Some("license_expired") => "This license key has expired.",
Some("key_not_found") => {
"License key not found. Please double-check the key and try again."
}
_ => "License key is not valid.",
};
return json_error(StatusCode::UNPROCESSABLE_ENTITY, reason_msg).into_response();
}
if crate::license::is_expired(&info) {
return json_error(StatusCode::UNPROCESSABLE_ENTITY, "License key has expired.")
.into_response();
}
let config_path = match get_config_path(&_state) {
Ok(p) => p,
Err(resp) => return resp,
};
let mut doc = match load_document(&config_path) {
Ok(d) => d,
Err(resp) => return resp,
};
let (target_addon_ids, key_name) = match &info.scope {
None => {
let ids = if let Some(app_state) = _state.app_state() {
app_state
.addon_registry
.list_addons(app_state.clone())
.into_iter()
.filter(|a| a.category == crate::addons::AddonCategory::Commercial)
.map(|a| a.id.clone())
.collect::<Vec<_>>()
} else {
vec![]
};
(ids, "global".to_string())
}
Some(scopes) => {
let name = scopes
.first()
.cloned()
.unwrap_or_else(|| "license".to_string());
(scopes.clone(), name)
}
};
if !doc.contains_key("licenses") || !doc["licenses"].is_table() {
doc["licenses"] = Item::Table(Table::new());
}
{
let licenses_table = doc["licenses"]
.as_table_mut()
.expect("[licenses] is a table");
if !licenses_table.contains_key("addons") || !licenses_table["addons"].is_table() {
licenses_table["addons"] = Item::Table(Table::new());
}
if let Some(t) = licenses_table["addons"].as_table_mut() {
for addon_id in &target_addon_ids {
t[addon_id.as_str()] = toml_edit::value(key_name.clone());
}
}
if !licenses_table.contains_key("keys") || !licenses_table["keys"].is_table() {
licenses_table["keys"] = Item::Table(Table::new());
}
if let Some(t) = licenses_table["keys"].as_table_mut() {
t[key_name.as_str()] = toml_edit::value(license_key.clone());
}
}
let doc_text = doc.to_string();
if let Err(resp) = parse_config_from_str(&doc_text) {
return resp;
}
if let Err(resp) = persist_document(&config_path, doc_text) {
return resp;
}
let plan = if info.plan.is_empty() {
"unknown".to_string()
} else {
info.plan
};
let expiry = info.expiry.map(|d| d.format("%Y-%m-%d").to_string());
let scope = info.scope;
let new_status = crate::license::LicenseStatus {
key_name: key_name.clone(),
valid: true,
expired: false,
expiry: expiry.clone(),
plan: plan.clone(),
is_trial: false,
scope: scope.clone(),
};
crate::license::update_license_status(&target_addon_ids, new_status);
axum::Json(serde_json::json!({
"success": true,
"message": "License key verified and saved successfully.",
"plan": plan,
"expiry": expiry,
"scope": scope,
}))
.into_response()
}
#[cfg(not(feature = "commerce"))]
pub async fn verify_license_key(_state: Arc<ConsoleState>, _license_key: String) -> Response {
use super::addons::json_error;
json_error(
StatusCode::NOT_FOUND,
"License management is not available in this build.",
)
.into_response()
}
#[cfg(feature = "commerce")]
#[derive(Debug, Serialize)]
pub(crate) struct LicenseRow {
pub key_name: String,
pub addon_ids: Vec<String>,
pub status: String,
pub plan: String,
pub expiry: Option<String>,
pub scope: Option<Vec<String>>,
pub is_trial: bool,
}
#[cfg(not(feature = "commerce"))]
#[derive(Debug, Serialize)]
pub(crate) struct LicenseRow;
#[cfg(feature = "commerce")]
pub(crate) fn build_license_rows(state: &ConsoleState) -> Vec<LicenseRow> {
use std::collections::HashMap;
let app_state = match state.app_state() {
Some(s) => s,
None => return vec![],
};
let config = app_state.config();
let license_cfg = match &config.licenses {
Some(l) => l,
None => return vec![],
};
let mut key_to_addons: HashMap<String, Vec<String>> = HashMap::new();
for (addon_id, key_name) in &license_cfg.addons {
key_to_addons
.entry(key_name.clone())
.or_default()
.push(addon_id.clone());
}
let mut rows: Vec<LicenseRow> = Vec::new();
for (key_name, addon_ids) in &key_to_addons {
let status_opt = addon_ids
.iter()
.find_map(|id| crate::license::get_license_status(id));
let (status, plan, expiry, scope, is_trial) = match status_opt {
Some(s) => {
let label = if s.is_trial {
if s.valid {
format!("Trial ({})", s.plan)
} else {
"Trial Expired".to_string()
}
} else if s.expired {
"Expired".to_string()
} else if s.valid {
"Valid".to_string()
} else {
"Invalid".to_string()
};
(
label,
s.plan.clone(),
s.expiry.clone(),
s.scope.clone(),
s.is_trial,
)
}
None => ("Not Verified".to_string(), String::new(), None, None, false),
};
let mut sorted_addons = addon_ids.clone();
sorted_addons.sort();
rows.push(LicenseRow {
key_name: key_name.clone(),
addon_ids: sorted_addons,
status,
plan,
expiry,
scope,
is_trial,
});
}
rows.sort_by(|a, b| a.key_name.cmp(&b.key_name));
rows
}
#[cfg(feature = "commerce")]
mod commerce {
use super::*;
use axum::{Json, extract::Json as AxJson, routing::post};
use serde::Deserialize;
#[derive(Deserialize)]
struct VerifyLicensePayload {
license_key: String,
}
async fn verify_license(
State(state): State<Arc<ConsoleState>>,
AxJson(payload): AxJson<VerifyLicensePayload>,
) -> Response {
super::verify_license_key(state, payload.license_key).await
}
pub fn urls() -> Router<Arc<ConsoleState>> {
let _ = Json::<()>;
Router::new()
.route("/licenses", get(super::index))
.route("/licenses/verify", post(verify_license))
}
}