talos/server/
handlers.rs

1use std::sync::Arc;
2
3use axum::{
4    extract::State,
5    response::{IntoResponse, Response},
6    Json,
7};
8use chrono::Utc;
9use serde::{Deserialize, Serialize};
10use tracing::{info, warn};
11
12#[cfg(feature = "openapi")]
13use utoipa::ToSchema;
14
15use crate::errors::{LicenseError, LicenseResult};
16use crate::server::api_error::ApiError;
17use crate::server::database::{Database, License};
18
19#[cfg(feature = "jwt-auth")]
20use crate::server::auth::AuthState;
21
22/// Shared application state for handlers.
23///
24/// Right now this only wraps the database, but later you can add:
25/// config, key material, metrics handles, etc.
26/// without touching every handler signature.
27#[derive(Clone)]
28pub struct AppState {
29    pub db: Arc<Database>,
30    #[cfg(feature = "jwt-auth")]
31    pub auth: AuthState,
32}
33
34/// Map internal LicenseError into an HTTP response Axum understands.
35///
36/// This lets handlers return:
37///   Result<Json<T>, LicenseError>
38/// and Axum will convert both success and error into HTTP responses.
39///
40/// Uses the standardized `ApiError` format for consistent error responses.
41impl IntoResponse for LicenseError {
42    fn into_response(self) -> Response {
43        let api_error: ApiError = self.into();
44        api_error.into_response()
45    }
46}
47
48/// Request structure for license-related operations.
49#[derive(Debug, Deserialize, Serialize)]
50#[cfg_attr(feature = "openapi", derive(ToSchema))]
51pub struct LicenseRequest {
52    pub license_id: String,
53    pub client_id: String,
54}
55
56/// Response structure for license-related operations.
57#[derive(Debug, Deserialize, Serialize)]
58#[cfg_attr(feature = "openapi", derive(ToSchema))]
59pub struct LicenseResponse {
60    pub success: bool,
61}
62
63/// Request structure for heartbeat operations.
64///
65/// Kept separate in case heartbeat later includes extra metadata.
66#[derive(Debug, Deserialize, Serialize)]
67#[cfg_attr(feature = "openapi", derive(ToSchema))]
68pub struct HeartbeatRequest {
69    pub license_id: String,
70    pub client_id: String,
71}
72
73/// Response structure for heartbeat operations.
74#[derive(Debug, Deserialize, Serialize)]
75#[cfg_attr(feature = "openapi", derive(ToSchema))]
76pub struct HeartbeatResponse {
77    pub success: bool,
78}
79
80/// Handler for activating a license.
81///
82/// Behavior:
83/// - If the license does not exist, it is created as `active`.
84/// - If it exists, it is updated to `active` with the given client_id.
85/// - DB errors bubble up as `LicenseError` (mapped to HTTP 5xx).
86#[cfg_attr(feature = "openapi", utoipa::path(
87    post,
88    path = "/activate",
89    tag = "legacy",
90    request_body = LicenseRequest,
91    responses(
92        (status = 200, description = "License activated", body = LicenseResponse),
93        (status = 500, description = "Server error"),
94    )
95))]
96pub async fn activate_license_handler(
97    State(state): State<AppState>,
98    Json(payload): Json<LicenseRequest>,
99) -> LicenseResult<Json<LicenseResponse>> {
100    info!(
101        "Activating license_id={} for client_id={}",
102        payload.license_id, payload.client_id
103    );
104
105    let now = Utc::now().naive_utc();
106
107    let license = License {
108        license_id: payload.license_id.clone(),
109        client_id: Some(payload.client_id.clone()),
110        status: "active".to_string(),
111        features: None,
112        issued_at: now,
113        expires_at: None,
114        hardware_id: None,
115        signature: None,
116        last_heartbeat: Some(now),
117        // Extended fields (all optional, default to None)
118        org_id: None,
119        org_name: None,
120        license_key: None,
121        tier: None,
122        device_name: None,
123        device_info: None,
124        bound_at: None,
125        last_seen_at: None,
126        suspended_at: None,
127        revoked_at: None,
128        revoke_reason: None,
129        grace_period_ends_at: None,
130        suspension_message: None,
131        is_blacklisted: None,
132        blacklisted_at: None,
133        blacklist_reason: None,
134        metadata: None,
135        bandwidth_used_bytes: None,
136        bandwidth_limit_bytes: None,
137        quota_exceeded: None,
138    };
139
140    state.db.insert_license(license).await?;
141
142    Ok(Json(LicenseResponse { success: true }))
143}
144
145/// Handler for validating a license.
146///
147/// Returns `success: true` only if:
148/// - license exists
149/// - client_id matches
150/// - status == "active"
151///
152/// DB failures bubble as `LicenseError` (HTTP 5xx).
153#[cfg_attr(feature = "openapi", utoipa::path(
154    post,
155    path = "/validate",
156    tag = "legacy",
157    request_body = LicenseRequest,
158    responses(
159        (status = 200, description = "Validation result", body = LicenseResponse),
160        (status = 500, description = "Server error"),
161    )
162))]
163pub async fn validate_license_handler(
164    State(state): State<AppState>,
165    Json(payload): Json<LicenseRequest>,
166) -> LicenseResult<Json<LicenseResponse>> {
167    info!(
168        "Validating license_id={} for client_id={}",
169        payload.license_id, payload.client_id
170    );
171
172    let license_opt = state.db.get_license(&payload.license_id).await?;
173
174    let success = match license_opt {
175        Some(license) => {
176            if license.client_id.as_deref() != Some(payload.client_id.as_str()) {
177                warn!(
178                    "Client ID mismatch for license_id={} (expected={:?}, got={})",
179                    payload.license_id, license.client_id, payload.client_id
180                );
181                false
182            } else if license.status != "active" {
183                warn!(
184                    "License is not active for license_id={} (status={})",
185                    payload.license_id, license.status
186                );
187                false
188            } else {
189                true
190            }
191        }
192        None => {
193            warn!("License not found for license_id={}", payload.license_id);
194            false
195        }
196    };
197
198    Ok(Json(LicenseResponse { success }))
199}
200
201/// Handler for deactivating a license.
202///
203/// Returns `success: true` only if:
204/// - license exists
205/// - client_id matches
206/// - status successfully updated to "inactive"
207///
208/// DB failures bubble as `LicenseError`.
209#[cfg_attr(feature = "openapi", utoipa::path(
210    post,
211    path = "/deactivate",
212    tag = "legacy",
213    request_body = LicenseRequest,
214    responses(
215        (status = 200, description = "Deactivation result", body = LicenseResponse),
216        (status = 500, description = "Server error"),
217    )
218))]
219pub async fn deactivate_license_handler(
220    State(state): State<AppState>,
221    Json(payload): Json<LicenseRequest>,
222) -> LicenseResult<Json<LicenseResponse>> {
223    info!(
224        "Deactivating license_id={} for client_id={}",
225        payload.license_id, payload.client_id
226    );
227
228    let license_opt = state.db.get_license(&payload.license_id).await?;
229
230    let success = if let Some(mut license) = license_opt {
231        if license.client_id.as_deref() == Some(payload.client_id.as_str()) {
232            license.status = "inactive".to_string();
233            state.db.insert_license(license).await?;
234            info!(
235                "License deactivated for license_id={} client_id={}",
236                payload.license_id, payload.client_id
237            );
238            true
239        } else {
240            warn!(
241                "Client ID mismatch during deactivation for license_id={} (expected={:?}, got={})",
242                payload.license_id, license.client_id, payload.client_id
243            );
244            false
245        }
246    } else {
247        warn!(
248            "Deactivation requested for non-existent license_id={}",
249            payload.license_id
250        );
251        false
252    };
253
254    Ok(Json(LicenseResponse { success }))
255}
256
257/// Handler for the heartbeat mechanism.
258///
259/// Updates `last_heartbeat` if a matching license + client exists.
260/// Returns:
261/// - `success: true` if at least one row was updated
262/// - `success: false` otherwise
263///
264/// DB failures bubble as `LicenseError`.
265#[cfg_attr(feature = "openapi", utoipa::path(
266    post,
267    path = "/heartbeat",
268    tag = "legacy",
269    request_body = HeartbeatRequest,
270    responses(
271        (status = 200, description = "Heartbeat result", body = HeartbeatResponse),
272        (status = 500, description = "Server error"),
273    )
274))]
275pub async fn heartbeat_handler(
276    State(state): State<AppState>,
277    Json(payload): Json<HeartbeatRequest>,
278) -> LicenseResult<Json<HeartbeatResponse>> {
279    info!(
280        "Received heartbeat for license_id={} client_id={}",
281        payload.license_id, payload.client_id
282    );
283
284    let updated = state
285        .db
286        .update_last_heartbeat(&payload.license_id, &payload.client_id)
287        .await?;
288
289    if !updated {
290        warn!(
291            "Failed to update heartbeat: no matching license for license_id={} client_id={}",
292            payload.license_id, payload.client_id
293        );
294    }
295
296    Ok(Json(HeartbeatResponse { success: updated }))
297}
298
299/// Health check handler.
300///
301/// Returns the service health status including database connectivity.
302/// This endpoint is useful for load balancers and monitoring systems.
303#[cfg_attr(feature = "openapi", utoipa::path(
304    get,
305    path = "/health",
306    tag = "system",
307    responses(
308        (status = 200, description = "Service health status", body = crate::server::logging::HealthResponse),
309    )
310))]
311pub async fn health_handler(
312    State(state): State<AppState>,
313) -> Json<crate::server::logging::HealthResponse> {
314    // Check database connectivity
315    let db_connected = state.db.health_check().await;
316    let db_type = state.db.db_type();
317
318    Json(crate::server::logging::HealthResponse::healthy(
319        db_connected,
320        db_type,
321    ))
322}