1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::Duration;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8const LS_API_BASE: &str = "https://api.lemonsqueezy.com/v1";
9const VALIDATE_INTERVAL: Duration = Duration::from_secs(60 * 60 * 24);
10const OFFLINE_GRACE: Duration = Duration::from_secs(60 * 60 * 24 * 30);
11const FILE_NAME: &str = "supporter.json";
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14pub struct SupporterRecord {
15 pub license_key: String,
16 pub instance_id: String,
17 pub activated_at: DateTime<Utc>,
18 pub last_validated_at: DateTime<Utc>,
19}
20
21#[derive(Debug, Clone, Serialize)]
22pub struct SupporterStatus {
23 pub is_supporter: bool,
24 pub masked_key: Option<String>,
25 pub last_validated_at: Option<DateTime<Utc>>,
26}
27
28impl SupporterStatus {
29 pub fn from_record(record: Option<&SupporterRecord>, now: DateTime<Utc>) -> Self {
30 match record {
31 Some(r) if is_within_grace(r.last_validated_at, now) => Self {
32 is_supporter: true,
33 masked_key: Some(mask_key(&r.license_key)),
34 last_validated_at: Some(r.last_validated_at),
35 },
36 Some(r) => Self {
37 is_supporter: false,
38 masked_key: Some(mask_key(&r.license_key)),
39 last_validated_at: Some(r.last_validated_at),
40 },
41 None => Self {
42 is_supporter: false,
43 masked_key: None,
44 last_validated_at: None,
45 },
46 }
47 }
48}
49
50pub fn file_path(data_dir: &Path) -> PathBuf {
51 data_dir.join(FILE_NAME)
52}
53
54pub fn load(path: &Path) -> Option<SupporterRecord> {
55 let text = fs::read_to_string(path).ok()?;
56 serde_json::from_str(&text).ok()
57}
58
59pub fn save(path: &Path, record: &SupporterRecord) -> std::io::Result<()> {
60 if let Some(parent) = path.parent() {
61 fs::create_dir_all(parent)?;
62 }
63 let body = serde_json::to_string_pretty(record).map_err(std::io::Error::other)?;
64 let mut tmp = path.as_os_str().to_os_string();
65 tmp.push(".tmp");
66 let tmp_path = PathBuf::from(tmp);
67 fs::write(&tmp_path, body)?;
68 fs::rename(&tmp_path, path)
69}
70
71pub fn delete(path: &Path) -> std::io::Result<()> {
72 if path.exists() {
73 fs::remove_file(path)?;
74 }
75 Ok(())
76}
77
78pub fn is_supporter_now(path: &Path) -> bool {
83 match load(path) {
84 Some(r) => is_within_grace(r.last_validated_at, Utc::now()),
85 None => false,
86 }
87}
88
89pub fn is_within_grace(last_validated_at: DateTime<Utc>, now: DateTime<Utc>) -> bool {
90 let elapsed = now.signed_duration_since(last_validated_at);
91 elapsed >= chrono::Duration::zero()
92 && elapsed <= chrono::Duration::from_std(OFFLINE_GRACE).unwrap()
93}
94
95pub fn needs_revalidation(last_validated_at: DateTime<Utc>, now: DateTime<Utc>) -> bool {
96 let elapsed = now.signed_duration_since(last_validated_at);
97 elapsed >= chrono::Duration::from_std(VALIDATE_INTERVAL).unwrap()
98}
99
100pub fn mask_key(key: &str) -> String {
101 let trimmed = key.trim();
102 let tail: String = trimmed
103 .chars()
104 .rev()
105 .take(4)
106 .collect::<Vec<_>>()
107 .into_iter()
108 .rev()
109 .collect();
110 format!("****-****-****-{tail}")
111}
112
113#[derive(Debug, Deserialize)]
114struct LsActivateResponse {
115 activated: bool,
116 error: Option<String>,
117 instance: Option<LsInstance>,
118}
119
120#[derive(Debug, Deserialize)]
121struct LsValidateResponse {
122 valid: bool,
123 error: Option<String>,
124}
125
126#[derive(Debug, Deserialize)]
127struct LsInstance {
128 id: String,
129}
130
131pub async fn activate_remote(
132 client: &reqwest::Client,
133 key: &str,
134 instance_name: &str,
135) -> Result<String, String> {
136 activate_remote_at(client, LS_API_BASE, key, instance_name).await
137}
138
139pub(crate) async fn activate_remote_at(
143 client: &reqwest::Client,
144 base: &str,
145 key: &str,
146 instance_name: &str,
147) -> Result<String, String> {
148 let resp = client
149 .post(format!("{base}/licenses/activate"))
150 .header("Accept", "application/json")
151 .form(&[("license_key", key), ("instance_name", instance_name)])
152 .send()
153 .await
154 .map_err(|e| format!("network: {e}"))?;
155 let parsed: LsActivateResponse = resp
156 .json()
157 .await
158 .map_err(|e| format!("invalid response from Lemon Squeezy: {e}"))?;
159 if parsed.activated {
160 parsed.instance.map(|i| i.id).ok_or_else(|| {
161 "Lemon Squeezy returned activated=true without an instance id".to_string()
162 })
163 } else {
164 Err(parsed
165 .error
166 .unwrap_or_else(|| "license activation refused".to_string()))
167 }
168}
169
170pub async fn validate_remote(
171 client: &reqwest::Client,
172 key: &str,
173 instance_id: &str,
174) -> Result<bool, String> {
175 validate_remote_at(client, LS_API_BASE, key, instance_id).await
176}
177
178pub(crate) async fn validate_remote_at(
180 client: &reqwest::Client,
181 base: &str,
182 key: &str,
183 instance_id: &str,
184) -> Result<bool, String> {
185 let resp = client
186 .post(format!("{base}/licenses/validate"))
187 .header("Accept", "application/json")
188 .form(&[("license_key", key), ("instance_id", instance_id)])
189 .send()
190 .await
191 .map_err(|e| format!("network: {e}"))?;
192 let parsed: LsValidateResponse = resp
193 .json()
194 .await
195 .map_err(|e| format!("invalid response from Lemon Squeezy: {e}"))?;
196 if let Some(err) = parsed.error {
197 return Err(err);
198 }
199 Ok(parsed.valid)
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 fn epoch(seconds_ago: i64) -> DateTime<Utc> {
207 Utc::now() - chrono::Duration::seconds(seconds_ago)
208 }
209
210 #[test]
211 fn mask_key_keeps_last_four() {
212 assert_eq!(mask_key("ABCDEFGH-1234-5678-2A41"), "****-****-****-2A41");
213 assert_eq!(mask_key("abc"), "****-****-****-abc");
214 }
215
216 #[test]
217 fn within_grace_for_recent_validation() {
218 let now = Utc::now();
219 let recent = now - chrono::Duration::days(3);
220 assert!(is_within_grace(recent, now));
221 }
222
223 #[test]
224 fn outside_grace_after_thirty_days() {
225 let now = Utc::now();
226 let old = now - chrono::Duration::days(31);
227 assert!(!is_within_grace(old, now));
228 }
229
230 #[test]
231 fn within_grace_rejects_future_timestamps() {
232 let now = Utc::now();
233 let future = now + chrono::Duration::days(1);
234 assert!(!is_within_grace(future, now));
235 }
236
237 #[test]
238 fn needs_revalidation_after_one_day() {
239 let now = Utc::now();
240 assert!(needs_revalidation(now - chrono::Duration::hours(25), now));
241 assert!(!needs_revalidation(now - chrono::Duration::hours(2), now));
242 }
243
244 #[test]
245 fn status_from_missing_record_is_not_supporter() {
246 let s = SupporterStatus::from_record(None, Utc::now());
247 assert!(!s.is_supporter);
248 assert!(s.masked_key.is_none());
249 }
250
251 #[test]
252 fn status_from_fresh_record_unlocks() {
253 let rec = SupporterRecord {
254 license_key: "ABCD-1111-2222-3333".to_string(),
255 instance_id: "i-1".to_string(),
256 activated_at: epoch(86_400),
257 last_validated_at: epoch(60),
258 };
259 let s = SupporterStatus::from_record(Some(&rec), Utc::now());
260 assert!(s.is_supporter);
261 assert_eq!(s.masked_key.as_deref(), Some("****-****-****-3333"));
262 }
263
264 #[test]
265 fn status_from_stale_record_locks_but_keeps_masked_key() {
266 let now = Utc::now();
267 let rec = SupporterRecord {
268 license_key: "ZZZZ-9999-8888-7777".to_string(),
269 instance_id: "i-2".to_string(),
270 activated_at: now - chrono::Duration::days(60),
271 last_validated_at: now - chrono::Duration::days(45),
272 };
273 let s = SupporterStatus::from_record(Some(&rec), now);
274 assert!(!s.is_supporter);
275 assert_eq!(s.masked_key.as_deref(), Some("****-****-****-7777"));
276 }
277
278 #[test]
279 fn save_load_round_trip() {
280 let dir = std::env::temp_dir().join(format!(
281 "entracte-supporter-test-{}-{}",
282 std::process::id(),
283 std::time::SystemTime::now()
284 .duration_since(std::time::UNIX_EPOCH)
285 .unwrap()
286 .as_nanos()
287 ));
288 fs::create_dir_all(&dir).unwrap();
289 let p = file_path(&dir);
290 let rec = SupporterRecord {
291 license_key: "ABCDEFGH".to_string(),
292 instance_id: "abc".to_string(),
293 activated_at: Utc::now(),
294 last_validated_at: Utc::now(),
295 };
296 save(&p, &rec).unwrap();
297 let loaded = load(&p).unwrap();
298 assert_eq!(loaded, rec);
299 fs::remove_dir_all(&dir).ok();
300 }
301
302 #[test]
303 fn load_missing_returns_none() {
304 let p = std::env::temp_dir().join("entracte-supporter-does-not-exist.json");
305 let _ = fs::remove_file(&p);
306 assert!(load(&p).is_none());
307 }
308
309 #[test]
310 fn delete_is_idempotent_when_missing() {
311 let p = std::env::temp_dir().join("entracte-supporter-delete-test.json");
312 let _ = fs::remove_file(&p);
313 delete(&p).unwrap();
314 }
315
316 fn unique_temp_path(tag: &str) -> PathBuf {
317 std::env::temp_dir().join(format!(
318 "entracte-supporter-{tag}-{}-{}.json",
319 std::process::id(),
320 std::time::SystemTime::now()
321 .duration_since(std::time::UNIX_EPOCH)
322 .unwrap()
323 .as_nanos()
324 ))
325 }
326
327 #[test]
328 fn is_supporter_now_false_when_no_record_on_disk() {
329 let p = unique_temp_path("isnow-missing");
330 let _ = fs::remove_file(&p);
331 assert!(!is_supporter_now(&p));
332 }
333
334 #[test]
335 fn is_supporter_now_true_for_fresh_record() {
336 let p = unique_temp_path("isnow-fresh");
337 let rec = SupporterRecord {
338 license_key: "ABCD-1111-2222-3333".to_string(),
339 instance_id: "i-fresh".to_string(),
340 activated_at: Utc::now() - chrono::Duration::days(1),
341 last_validated_at: Utc::now() - chrono::Duration::minutes(5),
342 };
343 save(&p, &rec).unwrap();
344 assert!(is_supporter_now(&p));
345 let _ = fs::remove_file(&p);
346 }
347
348 #[test]
349 fn is_supporter_now_false_for_stale_record_past_grace_window() {
350 let p = unique_temp_path("isnow-stale");
352 let rec = SupporterRecord {
353 license_key: "ZZZZ-9999-8888-7777".to_string(),
354 instance_id: "i-stale".to_string(),
355 activated_at: Utc::now() - chrono::Duration::days(60),
356 last_validated_at: Utc::now() - chrono::Duration::days(45),
357 };
358 save(&p, &rec).unwrap();
359 assert!(!is_supporter_now(&p));
360 let _ = fs::remove_file(&p);
361 }
362
363 #[tokio::test]
366 async fn activate_remote_returns_instance_id_on_success() {
367 let mut server = mockito::Server::new_async().await;
368 let _m = server
369 .mock("POST", "/licenses/activate")
370 .with_status(200)
371 .with_header("content-type", "application/json")
372 .with_body(r#"{"activated": true, "instance": {"id": "inst-42"}}"#)
373 .create_async()
374 .await;
375 let client = reqwest::Client::new();
376 let got = activate_remote_at(&client, &server.url(), "KEY", "laptop")
377 .await
378 .unwrap();
379 assert_eq!(got, "inst-42");
380 }
381
382 #[tokio::test]
383 async fn activate_remote_surfaces_server_error_message() {
384 let mut server = mockito::Server::new_async().await;
385 let _m = server
386 .mock("POST", "/licenses/activate")
387 .with_status(200)
388 .with_body(r#"{"activated": false, "error": "key revoked"}"#)
389 .create_async()
390 .await;
391 let client = reqwest::Client::new();
392 let err = activate_remote_at(&client, &server.url(), "KEY", "laptop")
393 .await
394 .unwrap_err();
395 assert!(err.contains("key revoked"));
396 }
397
398 #[tokio::test]
399 async fn activate_remote_errors_when_server_omits_instance() {
400 let mut server = mockito::Server::new_async().await;
403 let _m = server
404 .mock("POST", "/licenses/activate")
405 .with_status(200)
406 .with_body(r#"{"activated": true}"#)
407 .create_async()
408 .await;
409 let client = reqwest::Client::new();
410 let err = activate_remote_at(&client, &server.url(), "KEY", "laptop")
411 .await
412 .unwrap_err();
413 assert!(err.contains("activated=true"));
414 }
415
416 #[tokio::test]
417 async fn activate_remote_errors_on_malformed_json() {
418 let mut server = mockito::Server::new_async().await;
419 let _m = server
420 .mock("POST", "/licenses/activate")
421 .with_status(200)
422 .with_body("not json")
423 .create_async()
424 .await;
425 let client = reqwest::Client::new();
426 let err = activate_remote_at(&client, &server.url(), "KEY", "laptop")
427 .await
428 .unwrap_err();
429 assert!(err.contains("invalid response"));
430 }
431
432 #[tokio::test]
433 async fn validate_remote_returns_valid_flag() {
434 let mut server = mockito::Server::new_async().await;
435 let _m = server
436 .mock("POST", "/licenses/validate")
437 .with_status(200)
438 .with_body(r#"{"valid": true}"#)
439 .create_async()
440 .await;
441 let client = reqwest::Client::new();
442 let got = validate_remote_at(&client, &server.url(), "KEY", "inst-1")
443 .await
444 .unwrap();
445 assert!(got);
446 }
447
448 #[tokio::test]
449 async fn validate_remote_surfaces_error_message() {
450 let mut server = mockito::Server::new_async().await;
451 let _m = server
452 .mock("POST", "/licenses/validate")
453 .with_status(200)
454 .with_body(r#"{"valid": false, "error": "instance not found"}"#)
455 .create_async()
456 .await;
457 let client = reqwest::Client::new();
458 let err = validate_remote_at(&client, &server.url(), "KEY", "inst-1")
459 .await
460 .unwrap_err();
461 assert!(err.contains("instance not found"));
462 }
463
464 #[tokio::test]
465 async fn validate_remote_returns_false_when_valid_false_and_no_error() {
466 let mut server = mockito::Server::new_async().await;
470 let _m = server
471 .mock("POST", "/licenses/validate")
472 .with_status(200)
473 .with_body(r#"{"valid": false}"#)
474 .create_async()
475 .await;
476 let client = reqwest::Client::new();
477 let got = validate_remote_at(&client, &server.url(), "KEY", "inst-1")
478 .await
479 .unwrap();
480 assert!(!got);
481 }
482}