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::config::CliConfig;
16use crate::commands::config::PersistentConfig;
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!(
95 "{}/api/memories/{}/tickets/sync",
96 base,
97 memory_id
98 );
99
100 let response = client
101 .post(&url)
102 .headers(auth_headers(api_key)?)
103 .header(CONTENT_TYPE, JSON_CONTENT_TYPE)
104 .body("{}")
105 .send()
106 .with_context(|| format!("failed to contact ticket sync endpoint at {}", url))?;
107
108 let status = response.status();
109 if !status.is_success() {
110 let error_text = response.text().unwrap_or_else(|_| "unknown error".to_string());
111 if status.as_u16() == 401 {
112 bail!("Invalid API key. Get a valid key at https://memvid.com/dashboard/api-keys");
113 }
114 if status.as_u16() == 404 {
115 bail!("Memory not found. Create it first at https://memvid.com/dashboard");
116 }
117 if status.as_u16() == 403 {
118 bail!("You don't have access to this memory");
119 }
120 bail!("Ticket sync error ({}): {}", status, error_text);
121 }
122
123 let dashboard_response: DashboardTicketResponse = response.json()
124 .context("failed to parse ticket sync response")?;
125
126 Ok(TicketSyncResponse {
127 payload: dashboard_response.data.ticket,
128 request_id: dashboard_response.request_id,
129 })
130}
131
132#[derive(serde::Serialize)]
133pub struct ApplyTicketRequest<'a> {
134 pub issuer: &'a str,
135 pub seq_no: i64,
136 pub expires_in: u64,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub capacity_bytes: Option<u64>,
139 pub signature: &'a str,
140}
141
142pub fn apply_ticket(
143 config: &CliConfig,
144 memory_id: &Uuid,
145 request: &ApplyTicketRequest<'_>,
146) -> Result<String> {
147 let api_key = require_api_key(config)?;
148 let client = http_client()?;
149 let url = format!(
150 "{}/memories/{}/tickets/apply",
151 config.api_url.trim_end_matches('/'),
152 memory_id
153 );
154 let response = client
155 .post(url)
156 .headers(auth_headers(api_key)?)
157 .json(request)
158 .send()
159 .with_context(|| "failed to contact ticket apply endpoint")?;
160 let envelope: ApiEnvelope<serde_json::Value> = parse_envelope(response)?;
161 Ok(envelope.request_id)
162}
163
164#[derive(Debug, Serialize)]
165pub struct RegisterFileRequest<'a> {
166 pub file_name: &'a str,
167 pub file_path: &'a str,
168 pub file_size: i64,
169 pub machine_id: &'a str,
170}
171
172#[derive(Debug, Deserialize)]
174pub struct RegisterFileResponse {
175 pub id: String,
176 pub memory_id: String,
177 pub file_name: String,
178 pub file_path: String,
179 pub file_size: i64,
180 pub machine_id: String,
181 pub last_synced: String,
182 pub created_at: String,
183}
184
185#[derive(Debug, Deserialize)]
187#[serde(rename_all = "camelCase")]
188#[allow(dead_code)]
189struct DashboardFileResponse {
190 data: RegisterFileResponse,
191 request_id: String,
192}
193
194pub fn register_file(
196 _config: &CliConfig,
197 memory_id: &Uuid,
198 request: &RegisterFileRequest<'_>,
199 api_key: &str,
200) -> Result<RegisterFileResponse> {
201 let client = http_client()?;
202
203 let base_url = get_dashboard_url();
205 let url = format!(
206 "{}/api/memories/{}/files",
207 base_url.trim_end_matches('/'),
208 memory_id
209 );
210
211 let response = client
212 .post(&url)
213 .headers(auth_headers(api_key)?)
214 .json(request)
215 .send()
216 .with_context(|| "failed to contact file registration endpoint")?;
217
218 let status = response.status();
219 if !status.is_success() {
220 let error_text = response.text().unwrap_or_else(|_| "unknown error".to_string());
221 if status.as_u16() == 409 {
222 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&error_text) {
224 if let Some(msg) = json.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()) {
225 bail!("{}", msg);
226 }
227 }
228 bail!("This memory is already bound to another file. Each memory can only be bound to one MV2 file.");
229 }
230 bail!("File registration error ({}): {}", status, error_text);
231 }
232
233 let dashboard_response: DashboardFileResponse = response.json()
234 .context("failed to parse file registration response")?;
235
236 Ok(dashboard_response.data)
237}
238
239fn http_client() -> Result<Client> {
240 crate::http::blocking_client(Duration::from_secs(15))
241}
242
243fn auth_headers(api_key: &str) -> Result<HeaderMap> {
244 let mut headers = HeaderMap::new();
245 let value = HeaderValue::from_str(api_key)
246 .map_err(|_| anyhow!("API key contains invalid characters"))?;
247 headers.insert(API_KEY_HEADER, value);
248 Ok(headers)
249}
250
251fn require_api_key(config: &CliConfig) -> Result<&str> {
252 config
253 .api_key
254 .as_deref()
255 .ok_or_else(|| anyhow!("MEMVID_API_KEY is not set"))
256}
257
258fn parse_envelope<T: DeserializeOwned>(response: Response) -> Result<ApiEnvelope<T>> {
259 let status = response.status();
260 let envelope = response.json::<ApiEnvelope<T>>()?;
261 if envelope.status == "ok" {
262 return Ok(envelope);
263 }
264
265 let message = envelope
266 .error
267 .map(|err| format!("{}: {}", err.code, err.message))
268 .unwrap_or_else(|| format!("request failed with status {}", status));
269 bail!(message);
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct OrgTicket {
279 pub version: i32,
280 pub user_id: String,
281 pub org_id: String,
282 pub plan_id: String,
283 pub capacity_bytes: u64,
284 pub features: Vec<String>,
285 pub expires_at: i64,
286 pub signature: String,
287}
288
289impl OrgTicket {
290 pub fn is_expired(&self) -> bool {
292 let now = std::time::SystemTime::now()
293 .duration_since(std::time::UNIX_EPOCH)
294 .map(|d| d.as_secs() as i64)
295 .unwrap_or(0);
296 self.expires_at < now
297 }
298
299 pub fn is_paid(&self) -> bool {
301 matches!(self.plan_id.as_str(), "starter" | "pro" | "enterprise")
302 }
303
304 pub fn expires_in_secs(&self) -> u64 {
306 let now = std::time::SystemTime::now()
307 .duration_since(std::time::UNIX_EPOCH)
308 .map(|d| d.as_secs() as i64)
309 .unwrap_or(0);
310 if self.expires_at > now {
311 (self.expires_at - now) as u64
312 } else {
313 0
314 }
315 }
316}
317
318#[derive(Debug, Clone, Deserialize)]
320pub struct PlanInfo {
321 pub id: String,
322 pub name: String,
323 pub limits: PlanLimits,
324 pub features: Vec<String>,
325}
326
327#[derive(Debug, Clone, Deserialize)]
329#[serde(rename_all = "camelCase")]
330pub struct PlanLimits {
331 pub capacity_bytes: u64,
332 #[serde(default)]
333 pub memory_files: Option<u64>,
334 #[serde(default)]
335 pub max_file_size: Option<u64>,
336}
337
338#[derive(Debug, Clone, Deserialize)]
340#[serde(rename_all = "camelCase")]
341pub struct SubscriptionInfo {
342 pub status: String,
343 pub expires_at: i64,
344 #[serde(default)]
346 pub plan_start_date: Option<String>,
347 #[serde(default)]
349 pub current_period_end: Option<String>,
350 #[serde(default)]
352 pub plan_end_date: Option<String>,
353}
354
355#[derive(Debug, Clone, Deserialize)]
357#[serde(rename_all = "camelCase")]
358pub struct OrgInfo {
359 pub id: String,
360 pub name: String,
361 pub total_storage_bytes: u64,
362}
363
364#[derive(Debug, Clone, Deserialize)]
366pub struct OrgTicketResponse {
367 pub ticket: OrgTicket,
368 pub plan: PlanInfo,
369 pub subscription: SubscriptionInfo,
370 pub organisation: OrgInfo,
371}
372
373pub fn fetch_org_ticket(config: &CliConfig) -> Result<OrgTicketResponse> {
379 let api_key = require_api_key(config)?;
380 let client = http_client()?;
381
382 let base_url = get_dashboard_url();
384 let base = base_url.trim_end_matches('/').trim_end_matches("/api");
386 let url = format!("{}/api/ticket", base);
387
388 let response = client
389 .get(&url)
390 .headers(auth_headers(api_key)?)
391 .send()
392 .with_context(|| format!("failed to contact ticket endpoint at {}", url))?;
393
394 let status = response.status();
395 if !status.is_success() {
396 let error_text = response.text().unwrap_or_else(|_| "unknown error".to_string());
397 if status.as_u16() == 401 {
398 bail!(
399 "Invalid API key. Get a valid key at https://memvid.com/dashboard/api-keys"
400 );
401 }
402 bail!("Ticket API error ({}): {}", status, error_text);
403 }
404
405 let wrapper: serde_json::Value = response.json()
406 .context("failed to parse ticket response")?;
407
408 let data = if let Some(data_field) = wrapper.get("data") {
411 data_field.clone()
412 } else if wrapper.get("status").and_then(|s| s.as_str()) == Some("ok") {
413 wrapper.get("data").cloned().unwrap_or(wrapper.clone())
414 } else if wrapper.get("ticket").is_some() {
415 wrapper
417 } else {
418 wrapper
419 };
420
421 let ticket_response: OrgTicketResponse = serde_json::from_value(data)
422 .context("failed to parse ticket data")?;
423
424 Ok(ticket_response)
425}
426
427#[derive(Debug, Clone, Deserialize)]
433#[serde(rename_all = "camelCase")]
434pub struct QueryTrackingResponse {
435 pub success: bool,
436 #[serde(default)]
437 pub queries_used: Option<u64>,
438 #[serde(default)]
439 pub queries_limit: Option<u64>,
440 #[serde(default)]
441 pub queries_remaining: Option<u64>,
442}
443
444pub fn track_query_usage(config: &CliConfig, count: u64) -> Result<()> {
450 let api_key = match require_api_key(config) {
451 Ok(key) => key,
452 Err(_) => {
453 return Ok(());
455 }
456 };
457
458 let client = http_client()?;
459 let base_url = get_dashboard_url();
460 let url = format!("{}/api/v1/query", base_url);
461
462 let body = serde_json::json!({ "count": count });
463
464 let response = match client
465 .post(&url)
466 .header("Content-Type", "application/json")
467 .header("X-API-Key", &*api_key)
468 .timeout(std::time::Duration::from_secs(5))
469 .body(body.to_string())
470 .send()
471 {
472 Ok(resp) => resp,
473 Err(e) => {
474 log::warn!("Query tracking failed: {}", e);
476 return Ok(());
477 }
478 };
479
480 let status = response.status();
481
482 if status.as_u16() == 429 {
483 let body_text = response.text().unwrap_or_default();
485
486 if let Ok(data) = serde_json::from_str::<serde_json::Value>(&body_text) {
488 let message = data.get("message")
489 .and_then(|v| v.as_str())
490 .unwrap_or("Monthly query quota exceeded");
491 let limit = data.get("limit").and_then(|v| v.as_u64());
492 let used = data.get("used").and_then(|v| v.as_u64());
493 let reset_date = data.get("resetDate").and_then(|v| v.as_str());
494
495 let mut error_msg = format!("{}", message);
496 if let (Some(used), Some(limit)) = (used, limit) {
497 error_msg.push_str(&format!("\nUsed: {} / {}", used, limit));
498 }
499 if let Some(reset) = reset_date {
500 error_msg.push_str(&format!("\nResets: {}", reset));
501 }
502 error_msg.push_str(&format!("\nUpgrade at: {}/dashboard/plan", base_url));
503
504 bail!(error_msg);
505 } else {
506 bail!("Monthly query quota exceeded. Upgrade at: {}/dashboard/plan", base_url);
507 }
508 }
509
510 if !status.is_success() {
511 log::warn!("Query tracking returned status {}", status);
513 }
514
515 Ok(())
516}