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;
16
17const API_KEY_HEADER: &str = "X-API-KEY";
18const JSON_CONTENT_TYPE: &str = "application/json";
19
20#[derive(Debug, Deserialize)]
22pub struct TicketSyncData {
23 pub ticket: TicketSyncPayload,
24}
25
26#[derive(Debug, Deserialize)]
28pub struct TicketSyncPayload {
29 pub memory_id: Uuid,
30 #[serde(alias = "seq_no")]
31 pub sequence: i64,
32 pub issuer: String,
33 pub expires_in: u64,
34 #[serde(default)]
35 pub capacity_bytes: Option<u64>,
36 pub signature: String,
37}
38
39#[derive(Debug, Deserialize)]
40struct ApiEnvelope<T> {
41 status: String,
42 request_id: String,
43 data: Option<T>,
44 error: Option<ApiErrorBody>,
45 #[allow(dead_code)]
46 signature: String,
47}
48
49#[derive(Debug, Deserialize)]
50struct ApiErrorBody {
51 code: String,
52 message: String,
53}
54
55pub struct TicketSyncResponse {
56 pub payload: TicketSyncPayload,
57 pub request_id: String,
58}
59
60#[derive(Debug, Deserialize)]
62#[serde(rename_all = "camelCase")]
63struct DashboardTicketResponse {
64 data: TicketSyncData,
65 request_id: String,
66}
67
68pub fn fetch_ticket(config: &CliConfig, memory_id: &Uuid) -> Result<TicketSyncResponse> {
69 let api_key = require_api_key(config)?;
70 let client = http_client()?;
71
72 let base_url = std::env::var("MEMVID_DASHBOARD_URL")
74 .unwrap_or_else(|_| "https://memvid.com".to_string());
75 let url = format!(
76 "{}/api/memories/{}/tickets/sync",
77 base_url.trim_end_matches('/'),
78 memory_id
79 );
80
81 let response = client
82 .post(&url)
83 .headers(auth_headers(api_key)?)
84 .header(CONTENT_TYPE, JSON_CONTENT_TYPE)
85 .body("{}")
86 .send()
87 .with_context(|| format!("failed to contact ticket sync endpoint at {}", url))?;
88
89 let status = response.status();
90 if !status.is_success() {
91 let error_text = response.text().unwrap_or_else(|_| "unknown error".to_string());
92 if status.as_u16() == 401 {
93 bail!("Invalid API key. Get a valid key at https://memvid.com/dashboard/api-keys");
94 }
95 if status.as_u16() == 404 {
96 bail!("Memory not found. Create it first at https://memvid.com/dashboard");
97 }
98 if status.as_u16() == 403 {
99 bail!("You don't have access to this memory");
100 }
101 bail!("Ticket sync error ({}): {}", status, error_text);
102 }
103
104 let dashboard_response: DashboardTicketResponse = response.json()
105 .context("failed to parse ticket sync response")?;
106
107 Ok(TicketSyncResponse {
108 payload: dashboard_response.data.ticket,
109 request_id: dashboard_response.request_id,
110 })
111}
112
113#[derive(serde::Serialize)]
114pub struct ApplyTicketRequest<'a> {
115 pub issuer: &'a str,
116 pub seq_no: i64,
117 pub expires_in: u64,
118 #[serde(skip_serializing_if = "Option::is_none")]
119 pub capacity_bytes: Option<u64>,
120 pub signature: &'a str,
121}
122
123pub fn apply_ticket(
124 config: &CliConfig,
125 memory_id: &Uuid,
126 request: &ApplyTicketRequest<'_>,
127) -> Result<String> {
128 let api_key = require_api_key(config)?;
129 let client = http_client()?;
130 let url = format!(
131 "{}/memories/{}/tickets/apply",
132 config.api_url.trim_end_matches('/'),
133 memory_id
134 );
135 let response = client
136 .post(url)
137 .headers(auth_headers(api_key)?)
138 .json(request)
139 .send()
140 .with_context(|| "failed to contact ticket apply endpoint")?;
141 let envelope: ApiEnvelope<serde_json::Value> = parse_envelope(response)?;
142 Ok(envelope.request_id)
143}
144
145#[derive(serde::Serialize)]
146pub struct RegisterFileRequest<'a> {
147 pub file_name: &'a str,
148 pub file_path: &'a str,
149 pub file_size: i64,
150 pub machine_id: &'a str,
151}
152
153#[derive(Debug, Deserialize)]
155pub struct RegisterFileResponse {
156 pub id: String,
157 pub memory_id: String,
158 pub file_name: String,
159 pub file_path: String,
160 pub file_size: i64,
161 pub machine_id: String,
162 pub last_synced: String,
163 pub created_at: String,
164}
165
166#[derive(Debug, Deserialize)]
168#[serde(rename_all = "camelCase")]
169struct DashboardFileResponse {
170 data: RegisterFileResponse,
171 request_id: String,
172}
173
174pub fn register_file(
176 _config: &CliConfig,
177 memory_id: &Uuid,
178 request: &RegisterFileRequest<'_>,
179 api_key: &str,
180) -> Result<RegisterFileResponse> {
181 let client = http_client()?;
182
183 let base_url = std::env::var("MEMVID_DASHBOARD_URL")
185 .unwrap_or_else(|_| "https://memvid.com".to_string());
186 let url = format!(
187 "{}/api/memories/{}/files",
188 base_url.trim_end_matches('/'),
189 memory_id
190 );
191
192 let response = client
193 .post(&url)
194 .headers(auth_headers(api_key)?)
195 .json(request)
196 .send()
197 .with_context(|| "failed to contact file registration endpoint")?;
198
199 let status = response.status();
200 if !status.is_success() {
201 let error_text = response.text().unwrap_or_else(|_| "unknown error".to_string());
202 if status.as_u16() == 409 {
203 bail!("This memory is already bound to another file. Each memory can only be bound to one MV2 file.");
204 }
205 bail!("File registration error ({}): {}", status, error_text);
206 }
207
208 let dashboard_response: DashboardFileResponse = response.json()
209 .context("failed to parse file registration response")?;
210
211 Ok(dashboard_response.data)
212}
213
214fn http_client() -> Result<Client> {
215 crate::http::blocking_client(Duration::from_secs(15))
216}
217
218fn auth_headers(api_key: &str) -> Result<HeaderMap> {
219 let mut headers = HeaderMap::new();
220 let value = HeaderValue::from_str(api_key)
221 .map_err(|_| anyhow!("API key contains invalid characters"))?;
222 headers.insert(API_KEY_HEADER, value);
223 Ok(headers)
224}
225
226fn require_api_key(config: &CliConfig) -> Result<&str> {
227 config
228 .api_key
229 .as_deref()
230 .ok_or_else(|| anyhow!("MEMVID_API_KEY is not set"))
231}
232
233fn parse_envelope<T: DeserializeOwned>(response: Response) -> Result<ApiEnvelope<T>> {
234 let status = response.status();
235 let envelope = response.json::<ApiEnvelope<T>>()?;
236 if envelope.status == "ok" {
237 return Ok(envelope);
238 }
239
240 let message = envelope
241 .error
242 .map(|err| format!("{}: {}", err.code, err.message))
243 .unwrap_or_else(|| format!("request failed with status {}", status));
244 bail!(message);
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct OrgTicket {
254 pub version: i32,
255 pub user_id: String,
256 pub org_id: String,
257 pub plan_id: String,
258 pub capacity_bytes: u64,
259 pub features: Vec<String>,
260 pub expires_at: i64,
261 pub signature: String,
262}
263
264impl OrgTicket {
265 pub fn is_expired(&self) -> bool {
267 let now = std::time::SystemTime::now()
268 .duration_since(std::time::UNIX_EPOCH)
269 .map(|d| d.as_secs() as i64)
270 .unwrap_or(0);
271 self.expires_at < now
272 }
273
274 pub fn is_paid(&self) -> bool {
276 matches!(self.plan_id.as_str(), "starter" | "pro" | "enterprise")
277 }
278
279 pub fn expires_in_secs(&self) -> u64 {
281 let now = std::time::SystemTime::now()
282 .duration_since(std::time::UNIX_EPOCH)
283 .map(|d| d.as_secs() as i64)
284 .unwrap_or(0);
285 if self.expires_at > now {
286 (self.expires_at - now) as u64
287 } else {
288 0
289 }
290 }
291}
292
293#[derive(Debug, Clone, Deserialize)]
295pub struct PlanInfo {
296 pub id: String,
297 pub name: String,
298 pub limits: PlanLimits,
299 pub features: Vec<String>,
300}
301
302#[derive(Debug, Clone, Deserialize)]
304#[serde(rename_all = "camelCase")]
305pub struct PlanLimits {
306 pub capacity_bytes: u64,
307 #[serde(default)]
308 pub memory_files: Option<u64>,
309 #[serde(default)]
310 pub max_file_size: Option<u64>,
311}
312
313#[derive(Debug, Clone, Deserialize)]
315#[serde(rename_all = "camelCase")]
316pub struct SubscriptionInfo {
317 pub status: String,
318 pub expires_at: i64,
319}
320
321#[derive(Debug, Clone, Deserialize)]
323#[serde(rename_all = "camelCase")]
324pub struct OrgInfo {
325 pub id: String,
326 pub name: String,
327 pub total_storage_bytes: u64,
328}
329
330#[derive(Debug, Clone, Deserialize)]
332pub struct OrgTicketResponse {
333 pub ticket: OrgTicket,
334 pub plan: PlanInfo,
335 pub subscription: SubscriptionInfo,
336 pub organisation: OrgInfo,
337}
338
339pub fn fetch_org_ticket(config: &CliConfig) -> Result<OrgTicketResponse> {
345 let api_key = require_api_key(config)?;
346 let client = http_client()?;
347
348 let base_url = std::env::var("MEMVID_DASHBOARD_URL")
350 .unwrap_or_else(|_| "https://memvid.com".to_string());
351 let url = format!("{}/api/ticket", base_url.trim_end_matches('/'));
352
353 let response = client
354 .get(&url)
355 .headers(auth_headers(api_key)?)
356 .send()
357 .with_context(|| format!("failed to contact ticket endpoint at {}", url))?;
358
359 let status = response.status();
360 if !status.is_success() {
361 let error_text = response.text().unwrap_or_else(|_| "unknown error".to_string());
362 if status.as_u16() == 401 {
363 bail!(
364 "Invalid API key. Get a valid key at https://memvid.com/dashboard/api-keys"
365 );
366 }
367 bail!("Ticket API error ({}): {}", status, error_text);
368 }
369
370 let wrapper: serde_json::Value = response.json()
371 .context("failed to parse ticket response")?;
372
373 let data = if let Some(data_field) = wrapper.get("data") {
376 data_field.clone()
377 } else if wrapper.get("status").and_then(|s| s.as_str()) == Some("ok") {
378 wrapper.get("data").cloned().unwrap_or(wrapper.clone())
379 } else if wrapper.get("ticket").is_some() {
380 wrapper
382 } else {
383 wrapper
384 };
385
386 let ticket_response: OrgTicketResponse = serde_json::from_value(data)
387 .context("failed to parse ticket data")?;
388
389 Ok(ticket_response)
390}