//! License key management and public activation/validation API. //! //! See also: `/docs/developer/license-keys` use axum::{ extract::{Path, State}, http::{header::HeaderMap, StatusCode}, response::{Html, IntoResponse, Response}, Form, Json, }; use chrono::{DateTime, Datelike, Utc}; use serde::{Deserialize, Serialize}; use crate::{ auth::AuthUser, db::{self, ItemId, KeyCode, LicenseKeyId}, error::{AppError, Result, ResultExt}, helpers::{self, hx_toast, is_htmx_request}, templates::{ItemLicenseKeysTemplate, SaveStatusTemplate}, types::LicenseKeyRow, types::ListResponse, validation, AppState, }; use jsonwebtoken::{encode, EncodingKey, Header}; use super::verify_item_ownership; /// JSON response for key validation/activation. #[derive(Debug, Serialize, utoipa::ToSchema)] pub(crate) struct ValidateKeyResponse { valid: bool, #[serde(skip_serializing_if = "Option::is_none")] activated: Option, #[serde(skip_serializing_if = "Option::is_none")] error: Option<&'static str>, #[serde(skip_serializing_if = "Option::is_none")] license: Option, } /// License details within a validation response. #[derive(Debug, Serialize, utoipa::ToSchema)] pub(crate) struct ValidateKeyLicense { #[schema(value_type = String)] item_id: ItemId, max_activations: Option, activation_count: i32, #[schema(value_type = String)] created_at: DateTime, } /// JSON response for key deactivation. #[derive(Debug, Serialize, utoipa::ToSchema)] pub(crate) struct DeactivateKeyResponse { success: bool, message: &'static str, } /// JSON response for key status check. #[derive(Debug, Serialize, utoipa::ToSchema)] pub(crate) struct KeyStatusResponse { valid: bool, #[serde(skip_serializing_if = "Option::is_none")] error: Option<&'static str>, #[serde(skip_serializing_if = "Option::is_none")] license: Option, } /// License details within a status response. #[derive(Debug, Serialize, utoipa::ToSchema)] pub(crate) struct KeyStatusLicense { #[schema(value_type = String)] item_id: ItemId, max_activations: Option, activation_count: i32, remaining_activations: Option, #[schema(value_type = String)] created_at: DateTime, } /// JSON response for key generation and listing. #[derive(Debug, Serialize)] struct GenerateKeyResponse { id: LicenseKeyId, key_code: KeyCode, max_activations: Option, created_at: DateTime, } // ============================================================================= // License Key API — Public (no auth, rate-limited) // ============================================================================= /// JSON input for validating/activating a license key. #[derive(Debug, Deserialize, utoipa::ToSchema)] pub(crate) struct ValidateKeyRequest { #[schema(value_type = String)] pub key: KeyCode, pub machine_id: String, pub label: Option, } /// Validate a license key and optionally activate it on a machine. #[utoipa::path( post, path = "/api/v1/keys/validate", tag = "License Keys", request_body = ValidateKeyRequest, responses( (status = 200, description = "Validation result", body = ValidateKeyResponse), ), )] #[tracing::instrument(skip_all, name = "license_keys::validate_key")] pub(super) async fn validate_key( State(state): State, Json(req): Json, ) -> Result { // key is auto-validated by KeyCode deserialization validation::validate_machine_id(&req.machine_id)?; if let Some(ref label) = req.label { validation::validate_activation_label(label)?; } // Look up key let key = match db::license_keys::get_license_key_by_code(&state.db, &req.key).await? { Some(k) => k, None => return Ok(Json(ValidateKeyResponse { valid: false, activated: None, error: Some("invalid_key"), license: None, })), }; // Check revoked if key.revoked_at.is_some() { return Ok(Json(ValidateKeyResponse { valid: false, activated: None, error: Some("key_revoked"), license: None, })); } // Check if already activated on this machine (fast path, no lock needed) if let Some(activation) = db::license_keys::get_activation(&state.db, key.id, &req.machine_id).await? && activation.is_active { db::license_keys::touch_activation(&state.db, activation.id).await?; return Ok(Json(ValidateKeyResponse { valid: true, activated: None, error: None, license: Some(ValidateKeyLicense { item_id: key.item_id, max_activations: key.max_activations, activation_count: key.activation_count, created_at: key.created_at, }), })); } // Atomically check limit and create activation (serialized via FOR UPDATE) let activation = db::license_keys::try_create_activation( &state.db, key.id, &req.machine_id, req.label.as_deref(), key.max_activations, ).await?; if activation.is_none() { return Ok(Json(ValidateKeyResponse { valid: false, activated: None, error: Some("activation_limit_reached"), license: None, })); } Ok(Json(ValidateKeyResponse { valid: true, activated: Some(true), error: None, license: Some(ValidateKeyLicense { item_id: key.item_id, max_activations: key.max_activations, activation_count: key.activation_count + 1, created_at: key.created_at, }), })) } /// JSON input for deactivating a machine. #[derive(Debug, Deserialize, utoipa::ToSchema)] pub(crate) struct DeactivateKeyRequest { #[schema(value_type = String)] pub key: KeyCode, pub machine_id: String, } /// Release an activation slot (user uninstalls). #[utoipa::path( post, path = "/api/v1/keys/deactivate", tag = "License Keys", request_body = DeactivateKeyRequest, responses( (status = 200, description = "Deactivation result", body = DeactivateKeyResponse), ), )] #[tracing::instrument(skip_all, name = "license_keys::deactivate_key")] pub(super) async fn deactivate_key( State(state): State, Json(req): Json, ) -> Result { validation::validate_machine_id(&req.machine_id)?; let key = match db::license_keys::get_license_key_by_code(&state.db, &req.key).await? { Some(k) => k, None => return Ok(Json(DeactivateKeyResponse { success: false, message: "Invalid key", })), }; let deactivated = db::license_keys::deactivate_machine(&state.db, key.id, &req.machine_id).await?; Ok(Json(DeactivateKeyResponse { success: deactivated, message: if deactivated { "Machine deactivated" } else { "No active activation found" }, })) } /// Quick validity check without activating. #[utoipa::path( get, path = "/api/v1/keys/{key_code}/status", tag = "License Keys", params(("key_code" = String, Path, description = "The license key code")), responses( (status = 200, description = "Key status", body = KeyStatusResponse), ), )] #[tracing::instrument(skip_all, name = "license_keys::key_status")] pub(super) async fn key_status( State(state): State, Path(key_code): Path, ) -> Result { let key_code = KeyCode::new(&key_code)?; let key = match db::license_keys::get_license_key_by_code(&state.db, &key_code).await? { Some(k) => k, None => return Ok(Json(KeyStatusResponse { valid: false, error: Some("invalid_key"), license: None, })), }; if key.revoked_at.is_some() { return Ok(Json(KeyStatusResponse { valid: false, error: Some("key_revoked"), license: None, })); } let remaining = key.max_activations.map(|max| max - key.activation_count); Ok(Json(KeyStatusResponse { valid: true, error: None, license: Some(KeyStatusLicense { item_id: key.item_id, max_activations: key.max_activations, activation_count: key.activation_count, remaining_activations: remaining, created_at: key.created_at, }), })) } // ============================================================================= // License Key API — Creator management (auth required) // ============================================================================= /// Form input for updating license key settings on an item. #[derive(Debug, Deserialize)] pub struct UpdateLicenseSettingsForm { pub enable_license_keys: Option, pub default_max_activations: Option, pub license_preset: Option, pub custom_license_text: Option, } /// Toggle license keys and set default activation limit for an item. #[tracing::instrument(skip_all, name = "license_keys::update_license_settings")] pub(super) async fn update_license_settings( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(id): Path, Form(req): Form, ) -> Result { user.check_not_suspended()?; verify_item_ownership(&state, id, user.id).await?; let enable = req.enable_license_keys.is_some(); db::items::update_item_license_settings(&state.db, id, user.id, enable, req.default_max_activations).await?; // License text preset let preset_str = req.license_preset.as_deref().filter(|s| !s.is_empty()); if let Some(preset_key) = preset_str { // Validate preset key use crate::license_templates::LicensePreset; let preset: LicensePreset = preset_key.parse().map_err(|_| { crate::error::AppError::validation("Invalid license preset") })?; let custom_text = if preset == LicensePreset::Custom { let text = req.custom_license_text.as_deref().unwrap_or("").trim(); if text.is_empty() { return Err(crate::error::AppError::validation( "Custom license text is required when using Custom preset", )); } Some(text) } else { None }; db::items::update_item_license_text(&state.db, id, user.id, Some(preset_key), custom_text).await?; } else { // Clear license db::items::update_item_license_text(&state.db, id, user.id, None, None).await?; } if is_htmx_request(&headers) { return Ok(Html(SaveStatusTemplate { success: true, message: "License settings saved".to_string(), }.render_string()).into_response()); } Ok(StatusCode::NO_CONTENT.into_response()) } /// Form input for generating a license key. #[derive(Debug, Deserialize)] pub struct GenerateKeyForm { pub max_activations: Option, } /// Generate a new license key for an item (creator dashboard). #[tracing::instrument(skip_all, name = "license_keys::generate_key")] pub(super) async fn generate_key( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(item_id): Path, Form(req): Form, ) -> Result { user.check_not_suspended()?; let (item, _project) = verify_item_ownership(&state, item_id, user.id).await?; if !item.enable_license_keys { return Err(AppError::BadRequest( "License keys are not enabled for this item".to_string(), )); } // Cap at 1000 manually-generated keys per item let existing_count = db::license_keys::count_keys_by_item(&state.db, item_id).await?; if existing_count >= 1000 { return Err(AppError::BadRequest( "Maximum of 1000 license keys per item reached".to_string(), )); } let key_code = helpers::generate_key_code(); let max_activations = req.max_activations.or(item.default_max_activations); let _key = db::license_keys::create_license_key( &state.db, item_id, user.id, None, &key_code, max_activations, ) .await?; if is_htmx_request(&headers) { let keys = db::license_keys::get_license_keys_by_item(&state.db, item_id).await?; return Ok(ItemLicenseKeysTemplate { license_keys: keys.into_iter().map(LicenseKeyRow::from).collect(), } .into_response()); } Ok(Json(GenerateKeyResponse { id: _key.id, key_code: _key.key_code, max_activations: _key.max_activations, created_at: _key.created_at, }) .into_response()) } /// List all license keys for an item (creator dashboard). #[tracing::instrument(skip_all, name = "license_keys::list_keys")] pub(super) async fn list_keys( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(item_id): Path, ) -> Result { verify_item_ownership(&state, item_id, user.id).await?; let keys = db::license_keys::get_license_keys_by_item(&state.db, item_id).await?; if is_htmx_request(&headers) { return Ok(ItemLicenseKeysTemplate { license_keys: keys.into_iter().map(LicenseKeyRow::from).collect(), } .into_response()); } let data: Vec = keys.into_iter().map(|k| GenerateKeyResponse { id: k.id, key_code: k.key_code, max_activations: k.max_activations, created_at: k.created_at, }).collect(); Ok(Json(ListResponse { data }).into_response()) } /// Revoke a license key (creator dashboard). #[tracing::instrument(skip_all, name = "license_keys::revoke_key")] pub(super) async fn revoke_key( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(key_id): Path, ) -> Result { user.check_not_suspended()?; let key = db::license_keys::get_license_key_by_id(&state.db, key_id) .await? .ok_or(AppError::NotFound)?; verify_item_ownership(&state, key.item_id, user.id).await?; db::license_keys::revoke_license_key(&state.db, key_id).await?; if is_htmx_request(&headers) { let keys = db::license_keys::get_license_keys_by_item(&state.db, key.item_id).await?; return Ok(( [("HX-Trigger", hx_toast("License key revoked", "success"))], ItemLicenseKeysTemplate { license_keys: keys.into_iter().map(LicenseKeyRow::from).collect(), }, ) .into_response()); } Ok(StatusCode::NO_CONTENT.into_response()) } // ============================================================================= // License Verification API — phone-home activation binding // ============================================================================= /// JWT claims for offline license verification. #[derive(Debug, Serialize, Deserialize)] struct LicenseVerifyClaims { /// License key ID sub: String, /// Machine fingerprint machine: String, /// Item ID item: String, /// Issued at (Unix timestamp) iat: i64, /// Expiration (Unix timestamp); 7-day offline grace period exp: i64, } /// JSON response for license verification. #[derive(Debug, Serialize, utoipa::ToSchema)] pub(crate) struct LicenseVerifyResponse { valid: bool, #[serde(skip_serializing_if = "Option::is_none")] token: Option, #[serde(skip_serializing_if = "Option::is_none")] expires_in: Option, #[serde(skip_serializing_if = "Option::is_none")] error: Option<&'static str>, } /// JSON input for license verification (phone-home). #[derive(Debug, Deserialize, utoipa::ToSchema)] pub(crate) struct LicenseVerifyRequest { #[schema(value_type = String)] pub key: KeyCode, pub machine_fingerprint: String, } /// 7-day offline grace period for signed JWTs. const LICENSE_VERIFY_EXPIRY_SECS: i64 = 7 * 24 * 3600; /// Verify a license key and bind it to a machine fingerprint. /// /// If the project has `license_verification_enabled`, validates the key, /// checks/creates an activation (using machine_fingerprint as machine_id), /// and returns a signed JWT for offline verification (valid 7 days). #[utoipa::path( post, path = "/api/v1/license/verify", tag = "License Keys", request_body = LicenseVerifyRequest, responses( (status = 200, description = "Verification result with optional offline JWT", body = LicenseVerifyResponse), ), )] #[tracing::instrument(skip_all, name = "license_keys::license_verify")] pub(super) async fn license_verify( State(state): State, Json(req): Json, ) -> Result { validation::validate_machine_id(&req.machine_fingerprint)?; let key = match db::license_keys::get_license_key_by_code(&state.db, &req.key).await? { Some(k) => k, None => { return Ok(Json(LicenseVerifyResponse { valid: false, token: None, expires_in: None, error: Some("invalid_key"), })); } }; if key.revoked_at.is_some() { return Ok(Json(LicenseVerifyResponse { valid: false, token: None, expires_in: None, error: Some("key_revoked"), })); } // Check if the project has license verification enabled let item = db::items::get_item_by_id(&state.db, key.item_id) .await? .ok_or(AppError::NotFound)?; let project = db::projects::get_project_by_id(&state.db, item.project_id) .await? .ok_or(AppError::NotFound)?; if !project.license_verification_enabled { return Ok(Json(LicenseVerifyResponse { valid: false, token: None, expires_in: None, error: Some("verification_not_enabled"), })); } // Try to activate (re-activation on same machine is always OK) if let Some(activation) = db::license_keys::get_activation(&state.db, key.id, &req.machine_fingerprint).await? { if activation.is_active { db::license_keys::touch_activation(&state.db, activation.id).await?; } else { // Re-activate a previously deactivated machine let reactivated = db::license_keys::try_create_activation( &state.db, key.id, &req.machine_fingerprint, None, key.max_activations, ) .await?; if reactivated.is_none() { return Ok(Json(LicenseVerifyResponse { valid: false, token: None, expires_in: None, error: Some("activation_limit_reached"), })); } } } else { // New activation let activation = db::license_keys::try_create_activation( &state.db, key.id, &req.machine_fingerprint, None, key.max_activations, ) .await?; if activation.is_none() { return Ok(Json(LicenseVerifyResponse { valid: false, token: None, expires_in: None, error: Some("activation_limit_reached"), })); } } // Issue a signed JWT for offline grace period let now = chrono::Utc::now().timestamp(); let claims = LicenseVerifyClaims { sub: key.id.to_string(), machine: req.machine_fingerprint, item: key.item_id.to_string(), iat: now, exp: now + LICENSE_VERIFY_EXPIRY_SECS, }; let token = encode( &Header::default(), &claims, &EncodingKey::from_secret(state.config.signing_secret.as_bytes()), ) .context("jwt encode")?; Ok(Json(LicenseVerifyResponse { valid: true, token: Some(token), expires_in: Some(LICENSE_VERIFY_EXPIRY_SECS), error: None, })) } /// JSON input for license deactivation (phone-home). #[derive(Debug, Deserialize, utoipa::ToSchema)] pub(crate) struct LicenseDeactivateRequest { #[schema(value_type = String)] pub key: KeyCode, pub machine_fingerprint: String, } /// Deactivate a license on a specific machine (free up a slot). #[utoipa::path( post, path = "/api/v1/license/deactivate", tag = "License Keys", request_body = LicenseDeactivateRequest, responses( (status = 200, description = "Deactivation result", body = DeactivateKeyResponse), ), )] #[tracing::instrument(skip_all, name = "license_keys::license_deactivate")] pub(super) async fn license_deactivate( State(state): State, Json(req): Json, ) -> Result { validation::validate_machine_id(&req.machine_fingerprint)?; let key = match db::license_keys::get_license_key_by_code(&state.db, &req.key).await? { Some(k) => k, None => { return Ok(Json(DeactivateKeyResponse { success: false, message: "Invalid key", })); } }; let deactivated = db::license_keys::deactivate_machine(&state.db, key.id, &req.machine_fingerprint).await?; Ok(Json(DeactivateKeyResponse { success: deactivated, message: if deactivated { "Machine deactivated" } else { "No active activation found" }, })) } // ============================================================================= // License Text — public endpoint // ============================================================================= /// Serve rendered license text for an item as plain text. #[utoipa::path( get, path = "/api/v1/items/{item_id}/license.txt", tag = "License Keys", params(("item_id" = String, Path, description = "The item ID")), responses( (status = 200, description = "License text", content_type = "text/plain"), (status = 404, description = "Item not found or no license configured"), ), )] #[tracing::instrument(skip_all, name = "license_keys::license_text")] pub(super) async fn license_text( State(state): State, Path(item_id): Path, ) -> Result { use crate::license_templates::{render_license_text, LicensePreset}; let item = db::items::get_item_by_id(&state.db, item_id) .await? .ok_or(AppError::NotFound)?; let preset_str = item.license_preset.as_deref().ok_or(AppError::NotFound)?; let preset: LicensePreset = preset_str .parse() .map_err(|_| AppError::NotFound)?; // Get owner display name let project = db::projects::get_project_by_id(&state.db, item.project_id) .await? .ok_or(AppError::NotFound)?; let user = db::users::get_user_by_id(&state.db, project.user_id) .await? .ok_or(AppError::NotFound)?; let owner = user.display_name.as_deref().unwrap_or(&user.username); let year = chrono::Utc::now().year(); let text = render_license_text(preset, owner, year, item.custom_license_text.as_deref()); Ok(( StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")], text, ) .into_response()) }