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