Skip to main content

max / makenotwork

23.3 KB · 741 lines History Blame Raw
1 //! License key management and public activation/validation API.
2 //!
3 //! See also: `/docs/developer/license-keys`
4
5 use axum::{
6 extract::{Path, State},
7 http::{header::HeaderMap, StatusCode},
8 response::{Html, IntoResponse, Response},
9 Form, Json,
10 };
11 use chrono::{DateTime, Datelike, Utc};
12 use serde::{Deserialize, Serialize};
13
14 use crate::{
15 auth::AuthUser,
16 db::{self, ItemId, KeyCode, LicenseKeyId},
17 error::{AppError, Result, ResultExt},
18 helpers::{self, hx_toast, is_htmx_request},
19 templates::{ItemLicenseKeysTemplate, SaveStatusTemplate},
20 types::LicenseKeyRow,
21 types::ListResponse,
22 validation,
23 AppState,
24 };
25 use jsonwebtoken::{encode, EncodingKey, Header};
26
27 use super::verify_item_ownership;
28
29 /// JSON response for key validation/activation.
30 #[derive(Debug, Serialize, utoipa::ToSchema)]
31 pub(crate) struct ValidateKeyResponse {
32 valid: bool,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 activated: Option<bool>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 error: Option<&'static str>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 license: Option<ValidateKeyLicense>,
39 }
40
41 /// License details within a validation response.
42 #[derive(Debug, Serialize, utoipa::ToSchema)]
43 pub(crate) struct ValidateKeyLicense {
44 #[schema(value_type = String)]
45 item_id: ItemId,
46 max_activations: Option<i32>,
47 activation_count: i32,
48 #[schema(value_type = String)]
49 created_at: DateTime<Utc>,
50 }
51
52 /// JSON response for key deactivation.
53 #[derive(Debug, Serialize, utoipa::ToSchema)]
54 pub(crate) struct DeactivateKeyResponse {
55 success: bool,
56 message: &'static str,
57 }
58
59 /// JSON response for key status check.
60 #[derive(Debug, Serialize, utoipa::ToSchema)]
61 pub(crate) struct KeyStatusResponse {
62 valid: bool,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 error: Option<&'static str>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 license: Option<KeyStatusLicense>,
67 }
68
69 /// License details within a status response.
70 #[derive(Debug, Serialize, utoipa::ToSchema)]
71 pub(crate) struct KeyStatusLicense {
72 #[schema(value_type = String)]
73 item_id: ItemId,
74 max_activations: Option<i32>,
75 activation_count: i32,
76 remaining_activations: Option<i32>,
77 #[schema(value_type = String)]
78 created_at: DateTime<Utc>,
79 }
80
81 /// JSON response for key generation and listing.
82 #[derive(Debug, Serialize)]
83 struct GenerateKeyResponse {
84 id: LicenseKeyId,
85 key_code: KeyCode,
86 max_activations: Option<i32>,
87 created_at: DateTime<Utc>,
88 }
89
90 // =============================================================================
91 // License Key API — Public (no auth, rate-limited)
92 // =============================================================================
93
94 /// JSON input for validating/activating a license key.
95 #[derive(Debug, Deserialize, utoipa::ToSchema)]
96 pub(crate) struct ValidateKeyRequest {
97 #[schema(value_type = String)]
98 pub key: KeyCode,
99 pub machine_id: String,
100 pub label: Option<String>,
101 }
102
103 /// Validate a license key and optionally activate it on a machine.
104 #[utoipa::path(
105 post,
106 path = "/api/v1/keys/validate",
107 tag = "License Keys",
108 request_body = ValidateKeyRequest,
109 responses(
110 (status = 200, description = "Validation result", body = ValidateKeyResponse),
111 ),
112 )]
113 #[tracing::instrument(skip_all, name = "license_keys::validate_key")]
114 pub(super) async fn validate_key(
115 State(state): State<AppState>,
116 Json(req): Json<ValidateKeyRequest>,
117 ) -> Result<impl IntoResponse> {
118 // key is auto-validated by KeyCode deserialization
119 validation::validate_machine_id(&req.machine_id)?;
120 if let Some(ref label) = req.label {
121 validation::validate_activation_label(label)?;
122 }
123
124 // Look up key
125 let key = match db::license_keys::get_license_key_by_code(&state.db, &req.key).await? {
126 Some(k) => k,
127 None => return Ok(Json(ValidateKeyResponse {
128 valid: false,
129 activated: None,
130 error: Some("invalid_key"),
131 license: None,
132 })),
133 };
134
135 // Check revoked
136 if key.revoked_at.is_some() {
137 return Ok(Json(ValidateKeyResponse {
138 valid: false,
139 activated: None,
140 error: Some("key_revoked"),
141 license: None,
142 }));
143 }
144
145 // Check if already activated on this machine (fast path, no lock needed)
146 if let Some(activation) = db::license_keys::get_activation(&state.db, key.id, &req.machine_id).await?
147 && activation.is_active
148 {
149 db::license_keys::touch_activation(&state.db, activation.id).await?;
150 return Ok(Json(ValidateKeyResponse {
151 valid: true,
152 activated: None,
153 error: None,
154 license: Some(ValidateKeyLicense {
155 item_id: key.item_id,
156 max_activations: key.max_activations,
157 activation_count: key.activation_count,
158 created_at: key.created_at,
159 }),
160 }));
161 }
162
163 // Atomically check limit and create activation (serialized via FOR UPDATE)
164 let activation = db::license_keys::try_create_activation(
165 &state.db,
166 key.id,
167 &req.machine_id,
168 req.label.as_deref(),
169 key.max_activations,
170 ).await?;
171
172 if activation.is_none() {
173 return Ok(Json(ValidateKeyResponse {
174 valid: false,
175 activated: None,
176 error: Some("activation_limit_reached"),
177 license: None,
178 }));
179 }
180
181 Ok(Json(ValidateKeyResponse {
182 valid: true,
183 activated: Some(true),
184 error: None,
185 license: Some(ValidateKeyLicense {
186 item_id: key.item_id,
187 max_activations: key.max_activations,
188 activation_count: key.activation_count + 1,
189 created_at: key.created_at,
190 }),
191 }))
192 }
193
194 /// JSON input for deactivating a machine.
195 #[derive(Debug, Deserialize, utoipa::ToSchema)]
196 pub(crate) struct DeactivateKeyRequest {
197 #[schema(value_type = String)]
198 pub key: KeyCode,
199 pub machine_id: String,
200 }
201
202 /// Release an activation slot (user uninstalls).
203 #[utoipa::path(
204 post,
205 path = "/api/v1/keys/deactivate",
206 tag = "License Keys",
207 request_body = DeactivateKeyRequest,
208 responses(
209 (status = 200, description = "Deactivation result", body = DeactivateKeyResponse),
210 ),
211 )]
212 #[tracing::instrument(skip_all, name = "license_keys::deactivate_key")]
213 pub(super) async fn deactivate_key(
214 State(state): State<AppState>,
215 Json(req): Json<DeactivateKeyRequest>,
216 ) -> Result<impl IntoResponse> {
217 validation::validate_machine_id(&req.machine_id)?;
218
219 let key = match db::license_keys::get_license_key_by_code(&state.db, &req.key).await? {
220 Some(k) => k,
221 None => return Ok(Json(DeactivateKeyResponse {
222 success: false,
223 message: "Invalid key",
224 })),
225 };
226
227 let deactivated = db::license_keys::deactivate_machine(&state.db, key.id, &req.machine_id).await?;
228
229 Ok(Json(DeactivateKeyResponse {
230 success: deactivated,
231 message: if deactivated { "Machine deactivated" } else { "No active activation found" },
232 }))
233 }
234
235 /// Quick validity check without activating.
236 #[utoipa::path(
237 get,
238 path = "/api/v1/keys/{key_code}/status",
239 tag = "License Keys",
240 params(("key_code" = String, Path, description = "The license key code")),
241 responses(
242 (status = 200, description = "Key status", body = KeyStatusResponse),
243 ),
244 )]
245 #[tracing::instrument(skip_all, name = "license_keys::key_status")]
246 pub(super) async fn key_status(
247 State(state): State<AppState>,
248 Path(key_code): Path<String>,
249 ) -> Result<impl IntoResponse> {
250 let key_code = KeyCode::new(&key_code)?;
251
252 let key = match db::license_keys::get_license_key_by_code(&state.db, &key_code).await? {
253 Some(k) => k,
254 None => return Ok(Json(KeyStatusResponse {
255 valid: false,
256 error: Some("invalid_key"),
257 license: None,
258 })),
259 };
260
261 if key.revoked_at.is_some() {
262 return Ok(Json(KeyStatusResponse {
263 valid: false,
264 error: Some("key_revoked"),
265 license: None,
266 }));
267 }
268
269 let remaining = key.max_activations.map(|max| max - key.activation_count);
270
271 Ok(Json(KeyStatusResponse {
272 valid: true,
273 error: None,
274 license: Some(KeyStatusLicense {
275 item_id: key.item_id,
276 max_activations: key.max_activations,
277 activation_count: key.activation_count,
278 remaining_activations: remaining,
279 created_at: key.created_at,
280 }),
281 }))
282 }
283
284 // =============================================================================
285 // License Key API — Creator management (auth required)
286 // =============================================================================
287
288 /// Form input for updating license key settings on an item.
289 #[derive(Debug, Deserialize)]
290 pub struct UpdateLicenseSettingsForm {
291 pub enable_license_keys: Option<String>,
292 pub default_max_activations: Option<i32>,
293 pub license_preset: Option<String>,
294 pub custom_license_text: Option<String>,
295 }
296
297 /// Toggle license keys and set default activation limit for an item.
298 #[tracing::instrument(skip_all, name = "license_keys::update_license_settings")]
299 pub(super) async fn update_license_settings(
300 State(state): State<AppState>,
301 headers: HeaderMap,
302 AuthUser(user): AuthUser,
303 Path(id): Path<ItemId>,
304 Form(req): Form<UpdateLicenseSettingsForm>,
305 ) -> Result<Response> {
306 user.check_not_suspended()?;
307
308 verify_item_ownership(&state, id, user.id).await?;
309
310 let enable = req.enable_license_keys.is_some();
311 db::items::update_item_license_settings(&state.db, id, user.id, enable, req.default_max_activations).await?;
312
313 // License text preset
314 let preset_str = req.license_preset.as_deref().filter(|s| !s.is_empty());
315 if let Some(preset_key) = preset_str {
316 // Validate preset key
317 use crate::license_templates::LicensePreset;
318 let preset: LicensePreset = preset_key.parse().map_err(|_| {
319 crate::error::AppError::validation("Invalid license preset")
320 })?;
321 let custom_text = if preset == LicensePreset::Custom {
322 let text = req.custom_license_text.as_deref().unwrap_or("").trim();
323 if text.is_empty() {
324 return Err(crate::error::AppError::validation(
325 "Custom license text is required when using Custom preset",
326 ));
327 }
328 Some(text)
329 } else {
330 None
331 };
332 db::items::update_item_license_text(&state.db, id, user.id, Some(preset_key), custom_text).await?;
333 } else {
334 // Clear license
335 db::items::update_item_license_text(&state.db, id, user.id, None, None).await?;
336 }
337
338 if is_htmx_request(&headers) {
339 return Ok(Html(SaveStatusTemplate {
340 success: true,
341 message: "License settings saved".to_string(),
342 }.render_string()).into_response());
343 }
344
345 Ok(StatusCode::NO_CONTENT.into_response())
346 }
347
348 /// Form input for generating a license key.
349 #[derive(Debug, Deserialize)]
350 pub struct GenerateKeyForm {
351 pub max_activations: Option<i32>,
352 }
353
354 /// Generate a new license key for an item (creator dashboard).
355 #[tracing::instrument(skip_all, name = "license_keys::generate_key")]
356 pub(super) async fn generate_key(
357 State(state): State<AppState>,
358 headers: HeaderMap,
359 AuthUser(user): AuthUser,
360 Path(item_id): Path<ItemId>,
361 Form(req): Form<GenerateKeyForm>,
362 ) -> Result<Response> {
363 user.check_not_suspended()?;
364
365 let (item, _project) = verify_item_ownership(&state, item_id, user.id).await?;
366
367 if !item.enable_license_keys {
368 return Err(AppError::BadRequest(
369 "License keys are not enabled for this item".to_string(),
370 ));
371 }
372
373 // Cap at 1000 manually-generated keys per item
374 let existing_count = db::license_keys::count_keys_by_item(&state.db, item_id).await?;
375 if existing_count >= 1000 {
376 return Err(AppError::BadRequest(
377 "Maximum of 1000 license keys per item reached".to_string(),
378 ));
379 }
380
381 let key_code = helpers::generate_key_code();
382 let max_activations = req.max_activations.or(item.default_max_activations);
383
384 let _key = db::license_keys::create_license_key(
385 &state.db,
386 item_id,
387 user.id,
388 None,
389 &key_code,
390 max_activations,
391 )
392 .await?;
393
394 if is_htmx_request(&headers) {
395 let keys = db::license_keys::get_license_keys_by_item(&state.db, item_id).await?;
396 return Ok(ItemLicenseKeysTemplate {
397 license_keys: keys.into_iter().map(LicenseKeyRow::from).collect(),
398 }
399 .into_response());
400 }
401
402 Ok(Json(GenerateKeyResponse {
403 id: _key.id,
404 key_code: _key.key_code,
405 max_activations: _key.max_activations,
406 created_at: _key.created_at,
407 })
408 .into_response())
409 }
410
411 /// List all license keys for an item (creator dashboard).
412 #[tracing::instrument(skip_all, name = "license_keys::list_keys")]
413 pub(super) async fn list_keys(
414 State(state): State<AppState>,
415 headers: HeaderMap,
416 AuthUser(user): AuthUser,
417 Path(item_id): Path<ItemId>,
418 ) -> Result<Response> {
419 verify_item_ownership(&state, item_id, user.id).await?;
420
421 let keys = db::license_keys::get_license_keys_by_item(&state.db, item_id).await?;
422
423 if is_htmx_request(&headers) {
424 return Ok(ItemLicenseKeysTemplate {
425 license_keys: keys.into_iter().map(LicenseKeyRow::from).collect(),
426 }
427 .into_response());
428 }
429
430 let data: Vec<GenerateKeyResponse> = keys.into_iter().map(|k| GenerateKeyResponse {
431 id: k.id,
432 key_code: k.key_code,
433 max_activations: k.max_activations,
434 created_at: k.created_at,
435 }).collect();
436
437 Ok(Json(ListResponse { data }).into_response())
438 }
439
440 /// Revoke a license key (creator dashboard).
441 #[tracing::instrument(skip_all, name = "license_keys::revoke_key")]
442 pub(super) async fn revoke_key(
443 State(state): State<AppState>,
444 headers: HeaderMap,
445 AuthUser(user): AuthUser,
446 Path(key_id): Path<LicenseKeyId>,
447 ) -> Result<Response> {
448 user.check_not_suspended()?;
449
450 let key = db::license_keys::get_license_key_by_id(&state.db, key_id)
451 .await?
452 .ok_or(AppError::NotFound)?;
453
454 verify_item_ownership(&state, key.item_id, user.id).await?;
455
456 db::license_keys::revoke_license_key(&state.db, key_id).await?;
457
458 if is_htmx_request(&headers) {
459 let keys = db::license_keys::get_license_keys_by_item(&state.db, key.item_id).await?;
460 return Ok((
461 [("HX-Trigger", hx_toast("License key revoked", "success"))],
462 ItemLicenseKeysTemplate {
463 license_keys: keys.into_iter().map(LicenseKeyRow::from).collect(),
464 },
465 )
466 .into_response());
467 }
468
469 Ok(StatusCode::NO_CONTENT.into_response())
470 }
471
472 // =============================================================================
473 // License Verification API — phone-home activation binding
474 // =============================================================================
475
476 /// JWT claims for offline license verification.
477 #[derive(Debug, Serialize, Deserialize)]
478 struct LicenseVerifyClaims {
479 /// License key ID
480 sub: String,
481 /// Machine fingerprint
482 machine: String,
483 /// Item ID
484 item: String,
485 /// Issued at (Unix timestamp)
486 iat: i64,
487 /// Expiration (Unix timestamp); 7-day offline grace period
488 exp: i64,
489 }
490
491 /// JSON response for license verification.
492 #[derive(Debug, Serialize, utoipa::ToSchema)]
493 pub(crate) struct LicenseVerifyResponse {
494 valid: bool,
495 #[serde(skip_serializing_if = "Option::is_none")]
496 token: Option<String>,
497 #[serde(skip_serializing_if = "Option::is_none")]
498 expires_in: Option<i64>,
499 #[serde(skip_serializing_if = "Option::is_none")]
500 error: Option<&'static str>,
501 }
502
503 /// JSON input for license verification (phone-home).
504 #[derive(Debug, Deserialize, utoipa::ToSchema)]
505 pub(crate) struct LicenseVerifyRequest {
506 #[schema(value_type = String)]
507 pub key: KeyCode,
508 pub machine_fingerprint: String,
509 }
510
511 /// 7-day offline grace period for signed JWTs.
512 const LICENSE_VERIFY_EXPIRY_SECS: i64 = 7 * 24 * 3600;
513
514 /// Verify a license key and bind it to a machine fingerprint.
515 ///
516 /// If the project has `license_verification_enabled`, validates the key,
517 /// checks/creates an activation (using machine_fingerprint as machine_id),
518 /// and returns a signed JWT for offline verification (valid 7 days).
519 #[utoipa::path(
520 post,
521 path = "/api/v1/license/verify",
522 tag = "License Keys",
523 request_body = LicenseVerifyRequest,
524 responses(
525 (status = 200, description = "Verification result with optional offline JWT", body = LicenseVerifyResponse),
526 ),
527 )]
528 #[tracing::instrument(skip_all, name = "license_keys::license_verify")]
529 pub(super) async fn license_verify(
530 State(state): State<AppState>,
531 Json(req): Json<LicenseVerifyRequest>,
532 ) -> Result<impl IntoResponse> {
533 validation::validate_machine_id(&req.machine_fingerprint)?;
534
535 let key = match db::license_keys::get_license_key_by_code(&state.db, &req.key).await? {
536 Some(k) => k,
537 None => {
538 return Ok(Json(LicenseVerifyResponse {
539 valid: false,
540 token: None,
541 expires_in: None,
542 error: Some("invalid_key"),
543 }));
544 }
545 };
546
547 if key.revoked_at.is_some() {
548 return Ok(Json(LicenseVerifyResponse {
549 valid: false,
550 token: None,
551 expires_in: None,
552 error: Some("key_revoked"),
553 }));
554 }
555
556 // Check if the project has license verification enabled
557 let item = db::items::get_item_by_id(&state.db, key.item_id)
558 .await?
559 .ok_or(AppError::NotFound)?;
560 let project = db::projects::get_project_by_id(&state.db, item.project_id)
561 .await?
562 .ok_or(AppError::NotFound)?;
563
564 if !project.license_verification_enabled {
565 return Ok(Json(LicenseVerifyResponse {
566 valid: false,
567 token: None,
568 expires_in: None,
569 error: Some("verification_not_enabled"),
570 }));
571 }
572
573 // Try to activate (re-activation on same machine is always OK)
574 if let Some(activation) =
575 db::license_keys::get_activation(&state.db, key.id, &req.machine_fingerprint).await?
576 {
577 if activation.is_active {
578 db::license_keys::touch_activation(&state.db, activation.id).await?;
579 } else {
580 // Re-activate a previously deactivated machine
581 let reactivated = db::license_keys::try_create_activation(
582 &state.db,
583 key.id,
584 &req.machine_fingerprint,
585 None,
586 key.max_activations,
587 )
588 .await?;
589 if reactivated.is_none() {
590 return Ok(Json(LicenseVerifyResponse {
591 valid: false,
592 token: None,
593 expires_in: None,
594 error: Some("activation_limit_reached"),
595 }));
596 }
597 }
598 } else {
599 // New activation
600 let activation = db::license_keys::try_create_activation(
601 &state.db,
602 key.id,
603 &req.machine_fingerprint,
604 None,
605 key.max_activations,
606 )
607 .await?;
608 if activation.is_none() {
609 return Ok(Json(LicenseVerifyResponse {
610 valid: false,
611 token: None,
612 expires_in: None,
613 error: Some("activation_limit_reached"),
614 }));
615 }
616 }
617
618 // Issue a signed JWT for offline grace period
619 let now = chrono::Utc::now().timestamp();
620 let claims = LicenseVerifyClaims {
621 sub: key.id.to_string(),
622 machine: req.machine_fingerprint,
623 item: key.item_id.to_string(),
624 iat: now,
625 exp: now + LICENSE_VERIFY_EXPIRY_SECS,
626 };
627
628 let token = encode(
629 &Header::default(),
630 &claims,
631 &EncodingKey::from_secret(state.config.signing_secret.as_bytes()),
632 )
633 .context("jwt encode")?;
634
635 Ok(Json(LicenseVerifyResponse {
636 valid: true,
637 token: Some(token),
638 expires_in: Some(LICENSE_VERIFY_EXPIRY_SECS),
639 error: None,
640 }))
641 }
642
643 /// JSON input for license deactivation (phone-home).
644 #[derive(Debug, Deserialize, utoipa::ToSchema)]
645 pub(crate) struct LicenseDeactivateRequest {
646 #[schema(value_type = String)]
647 pub key: KeyCode,
648 pub machine_fingerprint: String,
649 }
650
651 /// Deactivate a license on a specific machine (free up a slot).
652 #[utoipa::path(
653 post,
654 path = "/api/v1/license/deactivate",
655 tag = "License Keys",
656 request_body = LicenseDeactivateRequest,
657 responses(
658 (status = 200, description = "Deactivation result", body = DeactivateKeyResponse),
659 ),
660 )]
661 #[tracing::instrument(skip_all, name = "license_keys::license_deactivate")]
662 pub(super) async fn license_deactivate(
663 State(state): State<AppState>,
664 Json(req): Json<LicenseDeactivateRequest>,
665 ) -> Result<impl IntoResponse> {
666 validation::validate_machine_id(&req.machine_fingerprint)?;
667
668 let key = match db::license_keys::get_license_key_by_code(&state.db, &req.key).await? {
669 Some(k) => k,
670 None => {
671 return Ok(Json(DeactivateKeyResponse {
672 success: false,
673 message: "Invalid key",
674 }));
675 }
676 };
677
678 let deactivated =
679 db::license_keys::deactivate_machine(&state.db, key.id, &req.machine_fingerprint).await?;
680
681 Ok(Json(DeactivateKeyResponse {
682 success: deactivated,
683 message: if deactivated {
684 "Machine deactivated"
685 } else {
686 "No active activation found"
687 },
688 }))
689 }
690
691 // =============================================================================
692 // License Text — public endpoint
693 // =============================================================================
694
695 /// Serve rendered license text for an item as plain text.
696 #[utoipa::path(
697 get,
698 path = "/api/v1/items/{item_id}/license.txt",
699 tag = "License Keys",
700 params(("item_id" = String, Path, description = "The item ID")),
701 responses(
702 (status = 200, description = "License text", content_type = "text/plain"),
703 (status = 404, description = "Item not found or no license configured"),
704 ),
705 )]
706 #[tracing::instrument(skip_all, name = "license_keys::license_text")]
707 pub(super) async fn license_text(
708 State(state): State<AppState>,
709 Path(item_id): Path<ItemId>,
710 ) -> Result<Response> {
711 use crate::license_templates::{render_license_text, LicensePreset};
712
713 let item = db::items::get_item_by_id(&state.db, item_id)
714 .await?
715 .ok_or(AppError::NotFound)?;
716
717 let preset_str = item.license_preset.as_deref().ok_or(AppError::NotFound)?;
718 let preset: LicensePreset = preset_str
719 .parse()
720 .map_err(|_| AppError::NotFound)?;
721
722 // Get owner display name
723 let project = db::projects::get_project_by_id(&state.db, item.project_id)
724 .await?
725 .ok_or(AppError::NotFound)?;
726 let user = db::users::get_user_by_id(&state.db, project.user_id)
727 .await?
728 .ok_or(AppError::NotFound)?;
729 let owner = user.display_name.as_deref().unwrap_or(&user.username);
730 let year = chrono::Utc::now().year();
731
732 let text = render_license_text(preset, owner, year, item.custom_license_text.as_deref());
733
734 Ok((
735 StatusCode::OK,
736 [(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
737 text,
738 )
739 .into_response())
740 }
741