Skip to main content

entracte_lib/
supporter.rs

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
78/// Single-call answer to "is this install a supporter right now?".
79/// Reads the on-disk record and applies the offline grace window so
80/// callers don't have to thread `now`/grace logic of their own.
81/// Used by gated IPC paths (e.g. `custom_css`) to authorise per-call.
82pub 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
139/// HTTP-layer split for `activate_remote`: takes an explicit base URL so
140/// tests can point it at `mockito::Server` without bringing up Lemon
141/// Squeezy. The production caller hard-codes `LS_API_BASE`.
142pub(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
178/// HTTP-layer split for `validate_remote`. See `activate_remote_at`.
179pub(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        // 45 days since last_validated_at — outside the 30-day offline grace.
351        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    // ----- HTTP-layer tests for activate_remote_at / validate_remote_at -----
364
365    #[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        // Defensive: activated=true with no instance.id is a Lemon Squeezy
401        // response we can't act on. We must error rather than panic.
402        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        // Some Lemon Squeezy paths return `valid: false` with no error
467        // string (e.g. a soft-deactivated instance). The helper must
468        // surface that as `Ok(false)`, not as a network error.
469        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}