1use std::time::Duration;
7
8use anyhow::{anyhow, bail, Context, Result};
9use reqwest::blocking::{Client, Response};
10use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
11use serde::de::DeserializeOwned;
12use serde::{Deserialize, Serialize};
13use uuid::Uuid;
14
15use crate::commands::config::PersistentConfig;
16use crate::config::CliConfig;
17
18const API_KEY_HEADER: &str = "X-API-KEY";
19const JSON_CONTENT_TYPE: &str = "application/json";
20const DEFAULT_DASHBOARD_URL: &str = "https://memvid.com";
21
22fn get_dashboard_url() -> String {
24 if let Ok(url) = std::env::var("MEMVID_DASHBOARD_URL") {
26 if !url.trim().is_empty() {
27 return url;
28 }
29 }
30 if let Ok(config) = PersistentConfig::load() {
31 if let Some(url) = config.dashboard_url {
32 return url;
33 }
34 }
35 DEFAULT_DASHBOARD_URL.to_string()
36}
37
38#[derive(Debug, Deserialize)]
40pub struct TicketSyncData {
41 pub ticket: TicketSyncPayload,
42}
43
44#[derive(Debug, Deserialize)]
46pub struct TicketSyncPayload {
47 pub memory_id: Uuid,
48 #[serde(alias = "seq_no")]
49 pub sequence: i64,
50 pub issuer: String,
51 pub expires_in: u64,
52 #[serde(default)]
53 pub capacity_bytes: Option<u64>,
54 pub signature: String,
55}
56
57#[derive(Debug, Deserialize)]
58#[allow(dead_code)]
59struct ApiEnvelope<T> {
60 status: String,
61 request_id: String,
62 data: Option<T>,
63 error: Option<ApiErrorBody>,
64 signature: String,
65}
66
67#[derive(Debug, Deserialize)]
68struct ApiErrorBody {
69 code: String,
70 message: String,
71}
72
73pub struct TicketSyncResponse {
74 pub payload: TicketSyncPayload,
75 pub request_id: String,
76}
77
78#[derive(Debug, Deserialize)]
80#[serde(rename_all = "camelCase")]
81struct DashboardTicketResponse {
82 data: TicketSyncData,
83 request_id: String,
84}
85
86pub fn fetch_ticket(config: &CliConfig, memory_id: &Uuid) -> Result<TicketSyncResponse> {
87 let api_key = require_api_key(config)?;
88 let client = http_client()?;
89
90 let base_url = get_dashboard_url();
92 let base = base_url.trim_end_matches('/').trim_end_matches("/api");
94 let url = format!("{}/api/memories/{}/tickets/sync", base, memory_id);
95
96 let response = client
97 .post(&url)
98 .headers(auth_headers(api_key)?)
99 .header(CONTENT_TYPE, JSON_CONTENT_TYPE)
100 .body("{}")
101 .send()
102 .with_context(|| format!("failed to contact ticket sync endpoint at {}", url))?;
103
104 let status = response.status();
105 if !status.is_success() {
106 let error_text = response
107 .text()
108 .unwrap_or_else(|_| "unknown error".to_string());
109 if status.as_u16() == 401 {
110 bail!("Invalid API key. Get a valid key at https://memvid.com/dashboard/api-keys");
111 }
112 if status.as_u16() == 404 {
113 bail!("Memory not found. Create it first at https://memvid.com/dashboard");
114 }
115 if status.as_u16() == 403 {
116 bail!("You don't have access to this memory");
117 }
118 bail!("Ticket sync error ({}): {}", status, error_text);
119 }
120
121 let dashboard_response: DashboardTicketResponse = response
122 .json()
123 .context("failed to parse ticket sync response")?;
124
125 Ok(TicketSyncResponse {
126 payload: dashboard_response.data.ticket,
127 request_id: dashboard_response.request_id,
128 })
129}
130
131#[derive(serde::Serialize)]
132pub struct ApplyTicketRequest<'a> {
133 pub issuer: &'a str,
134 pub seq_no: i64,
135 pub expires_in: u64,
136 #[serde(skip_serializing_if = "Option::is_none")]
137 pub capacity_bytes: Option<u64>,
138 pub signature: &'a str,
139}
140
141pub fn apply_ticket(
142 config: &CliConfig,
143 memory_id: &Uuid,
144 request: &ApplyTicketRequest<'_>,
145) -> Result<String> {
146 let api_key = require_api_key(config)?;
147 let client = http_client()?;
148 let url = format!(
149 "{}/memories/{}/tickets/apply",
150 config.api_url.trim_end_matches('/'),
151 memory_id
152 );
153 let response = client
154 .post(url)
155 .headers(auth_headers(api_key)?)
156 .json(request)
157 .send()
158 .with_context(|| "failed to contact ticket apply endpoint")?;
159 let envelope: ApiEnvelope<serde_json::Value> = parse_envelope(response)?;
160 Ok(envelope.request_id)
161}
162
163#[derive(Debug, Serialize)]
164pub struct RegisterFileRequest<'a> {
165 pub file_name: &'a str,
166 pub file_path: &'a str,
167 pub file_size: i64,
168 pub machine_id: &'a str,
169}
170
171#[derive(Debug, Deserialize)]
173pub struct RegisterFileResponse {
174 pub id: String,
175 pub memory_id: String,
176 pub file_name: String,
177 pub file_path: String,
178 pub file_size: i64,
179 pub machine_id: String,
180 pub last_synced: String,
181 pub created_at: String,
182}
183
184#[derive(Debug, Deserialize)]
186#[serde(rename_all = "camelCase")]
187#[allow(dead_code)]
188struct DashboardFileResponse {
189 data: RegisterFileResponse,
190 request_id: String,
191}
192
193pub fn register_file(
195 _config: &CliConfig,
196 memory_id: &Uuid,
197 request: &RegisterFileRequest<'_>,
198 api_key: &str,
199) -> Result<RegisterFileResponse> {
200 let client = http_client()?;
201
202 let base_url = get_dashboard_url();
204 let url = format!(
205 "{}/api/memories/{}/files",
206 base_url.trim_end_matches('/'),
207 memory_id
208 );
209
210 let response = client
211 .post(&url)
212 .headers(auth_headers(api_key)?)
213 .json(request)
214 .send()
215 .with_context(|| "failed to contact file registration endpoint")?;
216
217 let status = response.status();
218 if !status.is_success() {
219 let error_text = response
220 .text()
221 .unwrap_or_else(|_| "unknown error".to_string());
222 if status.as_u16() == 409 {
223 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&error_text) {
225 if let Some(msg) = json
226 .get("error")
227 .and_then(|e| e.get("message"))
228 .and_then(|m| m.as_str())
229 {
230 bail!("{}", msg);
231 }
232 }
233 bail!("This memory is already bound to another file. Each memory can only be bound to one MV2 file.");
234 }
235 bail!("File registration error ({}): {}", status, error_text);
236 }
237
238 let dashboard_response: DashboardFileResponse = response
239 .json()
240 .context("failed to parse file registration response")?;
241
242 Ok(dashboard_response.data)
243}
244
245fn http_client() -> Result<Client> {
246 crate::http::blocking_client(Duration::from_secs(15))
247}
248
249fn auth_headers(api_key: &str) -> Result<HeaderMap> {
250 let mut headers = HeaderMap::new();
251 let value = HeaderValue::from_str(api_key)
252 .map_err(|_| anyhow!("API key contains invalid characters"))?;
253 headers.insert(API_KEY_HEADER, value);
254 Ok(headers)
255}
256
257fn require_api_key(config: &CliConfig) -> Result<&str> {
258 config
259 .api_key
260 .as_deref()
261 .ok_or_else(|| anyhow!("MEMVID_API_KEY is not set"))
262}
263
264fn parse_envelope<T: DeserializeOwned>(response: Response) -> Result<ApiEnvelope<T>> {
265 let status = response.status();
266 let envelope = response.json::<ApiEnvelope<T>>()?;
267 if envelope.status == "ok" {
268 return Ok(envelope);
269 }
270
271 let message = envelope
272 .error
273 .map(|err| format!("{}: {}", err.code, err.message))
274 .unwrap_or_else(|| format!("request failed with status {}", status));
275 bail!(message);
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct OrgTicket {
285 pub version: i32,
286 pub user_id: String,
287 pub org_id: String,
288 pub plan_id: String,
289 pub capacity_bytes: u64,
290 pub features: Vec<String>,
291 pub expires_at: i64,
292 pub signature: String,
293}
294
295impl OrgTicket {
296 pub fn is_expired(&self) -> bool {
298 let now = std::time::SystemTime::now()
299 .duration_since(std::time::UNIX_EPOCH)
300 .map(|d| d.as_secs() as i64)
301 .unwrap_or(0);
302 self.expires_at < now
303 }
304
305 pub fn is_paid(&self) -> bool {
307 matches!(self.plan_id.as_str(), "starter" | "pro" | "enterprise")
308 }
309
310 pub fn expires_in_secs(&self) -> u64 {
312 let now = std::time::SystemTime::now()
313 .duration_since(std::time::UNIX_EPOCH)
314 .map(|d| d.as_secs() as i64)
315 .unwrap_or(0);
316 if self.expires_at > now {
317 (self.expires_at - now) as u64
318 } else {
319 0
320 }
321 }
322}
323
324#[derive(Debug, Clone, Deserialize)]
326pub struct PlanInfo {
327 pub id: String,
328 pub name: String,
329 pub limits: PlanLimits,
330 pub features: Vec<String>,
331}
332
333#[derive(Debug, Clone, Deserialize)]
335#[serde(rename_all = "camelCase")]
336pub struct PlanLimits {
337 pub capacity_bytes: u64,
338 #[serde(default)]
339 pub memory_files: Option<u64>,
340 #[serde(default)]
341 pub max_file_size: Option<u64>,
342}
343
344#[derive(Debug, Clone, Deserialize)]
346#[serde(rename_all = "camelCase")]
347pub struct SubscriptionInfo {
348 pub status: String,
349 pub expires_at: i64,
350 #[serde(default)]
352 pub plan_start_date: Option<String>,
353 #[serde(default)]
355 pub current_period_end: Option<String>,
356 #[serde(default)]
358 pub plan_end_date: Option<String>,
359}
360
361#[derive(Debug, Clone, Deserialize)]
363#[serde(rename_all = "camelCase")]
364pub struct OrgInfo {
365 pub id: String,
366 pub name: String,
367 pub total_storage_bytes: u64,
368}
369
370#[derive(Debug, Clone, Deserialize)]
372pub struct OrgTicketResponse {
373 pub ticket: OrgTicket,
374 pub plan: PlanInfo,
375 pub subscription: SubscriptionInfo,
376 pub organisation: OrgInfo,
377}
378
379pub fn fetch_org_ticket(config: &CliConfig) -> Result<OrgTicketResponse> {
385 let api_key = require_api_key(config)?;
386 let client = http_client()?;
387
388 let base_url = get_dashboard_url();
390 let base = base_url.trim_end_matches('/').trim_end_matches("/api");
392 let url = format!("{}/api/ticket", base);
393
394 let response = client
395 .get(&url)
396 .headers(auth_headers(api_key)?)
397 .send()
398 .with_context(|| format!("failed to contact ticket endpoint at {}", url))?;
399
400 let status = response.status();
401 if !status.is_success() {
402 let error_text = response
403 .text()
404 .unwrap_or_else(|_| "unknown error".to_string());
405 if status.as_u16() == 401 {
406 bail!("Invalid API key. Get a valid key at https://memvid.com/dashboard/api-keys");
407 }
408 bail!("Ticket API error ({}): {}", status, error_text);
409 }
410
411 let wrapper: serde_json::Value = response.json().context("failed to parse ticket response")?;
412
413 let data = if let Some(data_field) = wrapper.get("data") {
416 data_field.clone()
417 } else if wrapper.get("status").and_then(|s| s.as_str()) == Some("ok") {
418 wrapper.get("data").cloned().unwrap_or(wrapper.clone())
419 } else if wrapper.get("ticket").is_some() {
420 wrapper
422 } else {
423 wrapper
424 };
425
426 let ticket_response: OrgTicketResponse =
427 serde_json::from_value(data).context("failed to parse ticket data")?;
428
429 Ok(ticket_response)
430}
431
432#[derive(Debug, Clone, Deserialize)]
438#[serde(rename_all = "camelCase")]
439pub struct QueryTrackingResponse {
440 pub success: bool,
441 #[serde(default)]
442 pub queries_used: Option<u64>,
443 #[serde(default)]
444 pub queries_limit: Option<u64>,
445 #[serde(default)]
446 pub queries_remaining: Option<u64>,
447}
448
449pub fn track_query_usage(config: &CliConfig, count: u64) -> Result<()> {
455 let api_key = match require_api_key(config) {
456 Ok(key) => key,
457 Err(_) => {
458 return Ok(());
460 }
461 };
462
463 let client = http_client()?;
464 let base_url = get_dashboard_url();
465 let url = format!("{}/api/v1/query", base_url);
466
467 let body = serde_json::json!({ "count": count });
468
469 let response = match client
470 .post(&url)
471 .header("Content-Type", "application/json")
472 .header("X-API-Key", &*api_key)
473 .timeout(std::time::Duration::from_secs(5))
474 .body(body.to_string())
475 .send()
476 {
477 Ok(resp) => resp,
478 Err(e) => {
479 log::warn!("Query tracking failed: {}", e);
481 return Ok(());
482 }
483 };
484
485 let status = response.status();
486
487 if status.as_u16() == 429 {
488 let body_text = response.text().unwrap_or_default();
490
491 if let Ok(data) = serde_json::from_str::<serde_json::Value>(&body_text) {
493 let message = data
494 .get("message")
495 .and_then(|v| v.as_str())
496 .unwrap_or("Monthly query quota exceeded");
497 let limit = data.get("limit").and_then(|v| v.as_u64());
498 let used = data.get("used").and_then(|v| v.as_u64());
499 let reset_date = data.get("resetDate").and_then(|v| v.as_str());
500
501 let mut error_msg = format!("{}", message);
502 if let (Some(used), Some(limit)) = (used, limit) {
503 error_msg.push_str(&format!("\nUsed: {} / {}", used, limit));
504 }
505 if let Some(reset) = reset_date {
506 error_msg.push_str(&format!("\nResets: {}", reset));
507 }
508 error_msg.push_str(&format!("\nUpgrade at: {}/dashboard/plan", base_url));
509
510 bail!(error_msg);
511 } else {
512 bail!(
513 "Monthly query quota exceeded. Upgrade at: {}/dashboard/plan",
514 base_url
515 );
516 }
517 }
518
519 if !status.is_success() {
520 log::warn!("Query tracking returned status {}", status);
522 }
523
524 Ok(())
525}