1use std::time::Duration;
16
17use reqwest::{header::HeaderMap, multipart, Client, StatusCode};
18
19use crate::protocol::{Clip, DeviceInfo};
20use crate::rest::{
21 DeviceCodeCompleteRequest, DeviceCodeDenyRequest, DeviceCodePollResponse, DeviceCodeRequest,
22 DeviceCodeResponse, DeviceRevokeRequest, ErrorResponse, KeyBundlePutRequest, KeyBundleResponse,
23 PushRequest, PushResponse, RegisterDevicePublicKeyRequest,
24};
25use crate::version::ClientInfo;
26
27const MAX_ATTEMPTS: u32 = 3;
28const REQUEST_TIMEOUT_SECS: u64 = 30;
29
30#[derive(Debug, Default, Clone)]
32pub struct ListClipsFilter {
33 pub limit: u32,
34 pub source: Option<String>,
35 pub exclude_source: Option<String>,
36 pub exclude_image: bool,
37 pub exclude_text: bool,
38 pub clip_ids: Vec<String>,
39}
40
41#[derive(Debug, thiserror::Error)]
42pub enum HttpError {
43 #[error("network: {0}")]
44 Network(String),
45 #[error("auth required (401)")]
46 Unauthorized,
47 #[error("relay error ({status}): {message}")]
48 Relay {
49 status: u16,
50 message: String,
51 fix: String,
52 },
53 #[error("decode response: {0}")]
54 Decode(String),
55 #[error("build request: {0}")]
56 Build(String),
57}
58
59#[derive(Debug, Clone)]
60pub struct RestClient {
61 base_url: String,
62 token: String,
63 client: Client,
64 client_info: ClientInfo,
65}
66
67impl RestClient {
68 pub fn new(
74 relay_url: impl Into<String>,
75 token: impl Into<String>,
76 client_info: ClientInfo,
77 ) -> Result<Self, HttpError> {
78 let base = relay_url.into().trim_end_matches('/').to_string();
79 let mut headers = HeaderMap::new();
80 for (name, value) in client_info.http_headers() {
81 headers.insert(name, value);
82 }
83 let client = Client::builder()
84 .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
85 .default_headers(headers)
86 .build()
87 .map_err(|e| HttpError::Build(e.to_string()))?;
88 Ok(Self {
89 base_url: base,
90 token: token.into(),
91 client,
92 client_info,
93 })
94 }
95
96 pub fn client_info(&self) -> &ClientInfo {
100 &self.client_info
101 }
102
103 pub async fn push_clip_json(&self, req: &PushRequest) -> Result<PushResponse, HttpError> {
105 let url = format!("{}/clips", self.base_url);
106 let resp = self
107 .send_with_retry(|| {
108 self.client
109 .post(&url)
110 .bearer_auth(&self.token)
111 .json(req)
112 .build()
113 })
114 .await?;
115 decode_push_response(resp).await
116 }
117
118 pub async fn push_clip_binary(
121 &self,
122 data: Vec<u8>,
123 content_type: &str,
124 source: &str,
125 label: Option<&str>,
126 target_device_id: Option<&str>,
127 ) -> Result<PushResponse, HttpError> {
128 let url = format!("{}/clips/binary", self.base_url);
129 let mut last_err: Option<HttpError> = None;
130 for attempt in 0..MAX_ATTEMPTS {
131 if attempt > 0 {
132 tokio::time::sleep(Duration::from_secs(1u64 << attempt)).await;
133 }
134 let mut form = multipart::Form::new()
137 .part(
138 "file",
139 multipart::Part::bytes(data.clone()).file_name("upload"),
140 )
141 .text("content_type", content_type.to_string())
142 .text("source", source.to_string());
143 if let Some(l) = label.filter(|s| !s.is_empty()) {
144 form = form.text("label", l.to_string());
145 }
146 if let Some(d) = target_device_id.filter(|s| !s.is_empty()) {
147 form = form.text("target_device_id", d.to_string());
148 }
149 let resp = self
150 .client
151 .post(&url)
152 .bearer_auth(&self.token)
153 .multipart(form)
154 .send()
155 .await;
156 match resp {
157 Ok(r) => return decode_push_response(r).await,
158 Err(e) => last_err = Some(HttpError::Network(e.to_string())),
159 }
160 }
161 Err(last_err.unwrap_or(HttpError::Network("max retries exceeded".into())))
162 }
163
164 pub async fn get_latest_clip(&self, source: &str) -> Result<Clip, HttpError> {
166 let url = format!("{}/clips/latest", self.base_url);
167 let resp = self
168 .send_with_retry(|| {
169 self.client
170 .get(&url)
171 .bearer_auth(&self.token)
172 .query(&[("source", source)])
173 .build()
174 })
175 .await?;
176 decode_json_response::<Clip>(resp).await
177 }
178
179 pub async fn get_latest_clip_any(&self) -> Result<Clip, HttpError> {
181 let url = format!("{}/clips/latest", self.base_url);
182 let resp = self
183 .send_with_retry(|| self.client.get(&url).bearer_auth(&self.token).build())
184 .await?;
185 decode_json_response::<Clip>(resp).await
186 }
187
188 pub async fn get_clip_media(&self, clip_id: &str) -> Result<Vec<u8>, HttpError> {
190 let url = format!("{}/clips/{}/media", self.base_url, clip_id);
191 let resp = self
192 .send_with_retry(|| self.client.get(&url).bearer_auth(&self.token).build())
193 .await?;
194 let status = resp.status();
195 if status == StatusCode::UNAUTHORIZED {
196 return Err(HttpError::Unauthorized);
197 }
198 if !status.is_success() {
199 return Err(HttpError::Relay {
200 status: status.as_u16(),
201 message: format!("Image not found on relay (HTTP {}).", status.as_u16()),
202 fix: String::new(),
203 });
204 }
205 resp.bytes()
206 .await
207 .map(|b| b.to_vec())
208 .map_err(|e| HttpError::Decode(e.to_string()))
209 }
210
211 pub async fn start_device_code(
215 &self,
216 relay_url: &str,
217 hostname: &str,
218 machine_id: &str,
219 user_hint: Option<&str>,
220 ) -> Result<DeviceCodeResponse, HttpError> {
221 let url = format!("{}/auth/device-code", relay_url.trim_end_matches('/'));
222 let req = DeviceCodeRequest {
223 hostname: Some(hostname.to_string()),
224 machine_id: if machine_id.is_empty() {
225 None
226 } else {
227 Some(machine_id.to_string())
228 },
229 user_hint: user_hint.map(|s| s.to_string()),
230 };
231 let resp = self
232 .client
233 .post(&url)
234 .json(&req)
235 .send()
236 .await
237 .map_err(|e| HttpError::Network(e.to_string()))?;
238 decode_json_response::<DeviceCodeResponse>(resp).await
239 }
240
241 pub async fn poll_device_code(
244 &self,
245 relay_url: &str,
246 device_code: &str,
247 ) -> Result<DeviceCodePollResponse, HttpError> {
248 let url = format!("{}/auth/device-code/poll", relay_url.trim_end_matches('/'));
249 let resp = self
250 .client
251 .get(&url)
252 .query(&[("code", device_code)])
253 .send()
254 .await
255 .map_err(|e| HttpError::Network(e.to_string()))?;
256 decode_json_response::<DeviceCodePollResponse>(resp).await
257 }
258
259 pub async fn complete_device_code(&self, user_code: &str) -> Result<(), HttpError> {
262 let url = format!("{}/auth/device-code/complete", self.base_url);
263 let body = DeviceCodeCompleteRequest {
264 user_code: user_code.to_string(),
265 user_id: String::new(),
266 device_id: String::new(),
267 token: String::new(),
268 };
269 let resp = self
270 .client
271 .post(&url)
272 .bearer_auth(&self.token)
273 .json(&body)
274 .send()
275 .await
276 .map_err(|e| HttpError::Network(e.to_string()))?;
277 decode_json_response::<serde_json::Value>(resp)
278 .await
279 .map(|_| ())
280 }
281
282 pub async fn deny_device_code(&self, user_code: &str) -> Result<(), HttpError> {
285 let url = format!("{}/cinch.v1.AuthService/DeviceCodeDeny", self.base_url);
286 let body = DeviceCodeDenyRequest {
287 user_code: user_code.to_string(),
288 };
289 let resp = self
290 .client
291 .post(&url)
292 .bearer_auth(&self.token)
293 .json(&body)
294 .send()
295 .await
296 .map_err(|e| HttpError::Network(e.to_string()))?;
297 decode_json_response::<serde_json::Value>(resp)
298 .await
299 .map(|_| ())
300 }
301
302 pub async fn probe_relay(&self, relay_url: &str) -> Result<(), HttpError> {
306 let url = format!("{}/health", relay_url.trim_end_matches('/'));
307 let resp = self
308 .client
309 .get(&url)
310 .send()
311 .await
312 .map_err(|e| HttpError::Network(e.to_string()))?;
313 if resp.status().is_success() {
314 Ok(())
315 } else {
316 Err(HttpError::Relay {
317 status: resp.status().as_u16(),
318 message: format!("health check failed: HTTP {}", resp.status().as_u16()),
319 fix: String::new(),
320 })
321 }
322 }
323
324 pub async fn post_key_bundle(
331 &self,
332 target_device_id: &str,
333 ephemeral_public_key: &str,
334 encrypted_bundle: &str,
335 ) -> Result<(), HttpError> {
336 let url = format!("{}/auth/key-bundle", self.base_url);
337 let body = KeyBundlePutRequest {
338 device_id: target_device_id.to_string(),
339 ephemeral_public_key: ephemeral_public_key.to_string(),
340 encrypted_bundle: encrypted_bundle.to_string(),
341 };
342 let resp = self
343 .client
344 .post(&url)
345 .bearer_auth(&self.token)
346 .json(&body)
347 .send()
348 .await
349 .map_err(|e| HttpError::Network(e.to_string()))?;
350 let status = resp.status();
351 if status == StatusCode::UNAUTHORIZED {
352 return Err(HttpError::Unauthorized);
353 }
354 if !status.is_success() {
355 return Err(HttpError::Relay {
356 status: status.as_u16(),
357 message: format!("post key bundle failed: HTTP {}", status.as_u16()),
358 fix: String::new(),
359 });
360 }
361 Ok(())
362 }
363
364 pub async fn register_device_public_key(
371 &self,
372 public_key: &str,
373 fingerprint: &str,
374 ) -> Result<(), HttpError> {
375 let url = format!("{}/auth/device/public-key", self.base_url);
376 let body = RegisterDevicePublicKeyRequest {
377 public_key: public_key.to_string(),
378 fingerprint: fingerprint.to_string(),
379 };
380 let resp = self
381 .client
382 .post(&url)
383 .bearer_auth(&self.token)
384 .json(&body)
385 .send()
386 .await
387 .map_err(|e| HttpError::Network(e.to_string()))?;
388 let status = resp.status();
389 if status == StatusCode::UNAUTHORIZED {
390 return Err(HttpError::Unauthorized);
391 }
392 if !status.is_success() {
393 return Err(HttpError::Relay {
394 status: status.as_u16(),
395 message: format!("register public key failed: HTTP {}", status.as_u16()),
396 fix: String::new(),
397 });
398 }
399 Ok(())
400 }
401
402 pub async fn retry_key_bundle(&self) -> Result<(), HttpError> {
407 let url = format!("{}/auth/key-bundle/retry", self.base_url);
408 let resp = self
409 .client
410 .post(&url)
411 .bearer_auth(&self.token)
412 .send()
413 .await
414 .map_err(|e| HttpError::Network(e.to_string()))?;
415 let status = resp.status();
416 if status == StatusCode::UNAUTHORIZED {
417 return Err(HttpError::Unauthorized);
418 }
419 if !status.is_success() {
420 return Err(HttpError::Relay {
421 status: status.as_u16(),
422 message: format!("retry key bundle failed: HTTP {}", status.as_u16()),
423 fix: String::new(),
424 });
425 }
426 Ok(())
427 }
428
429 pub async fn revoke_device(&self, device_id: &str) -> Result<(), HttpError> {
433 let url = format!("{}/auth/device/revoke", self.base_url);
434 let body = DeviceRevokeRequest {
435 device_id: device_id.to_string(),
436 };
437 let resp = self
438 .client
439 .post(&url)
440 .bearer_auth(&self.token)
441 .json(&body)
442 .send()
443 .await
444 .map_err(|e| HttpError::Network(e.to_string()))?;
445 let status = resp.status();
446 if !status.is_success() {
447 return Err(HttpError::Relay {
448 status: status.as_u16(),
449 message: format!("revoke failed: HTTP {}", status.as_u16()),
450 fix: String::new(),
451 });
452 }
453 Ok(())
454 }
455
456 pub async fn set_device_nickname(
461 &self,
462 device_id: &str,
463 nickname: &str,
464 ) -> Result<(), HttpError> {
465 let url = format!("{}/devices/{}/nickname", self.base_url, device_id);
466 #[derive(serde::Serialize)]
467 struct NicknameBody<'a> {
468 nickname: &'a str,
469 }
470 let resp = self
471 .client
472 .put(&url)
473 .bearer_auth(&self.token)
474 .json(&NicknameBody { nickname })
475 .send()
476 .await
477 .map_err(|e| HttpError::Network(e.to_string()))?;
478 let status = resp.status();
479 if !status.is_success() {
480 let body = resp.text().await.unwrap_or_default();
481 return Err(HttpError::Relay {
482 status: status.as_u16(),
483 message: format!("set_device_nickname failed: {}", body),
484 fix: String::new(),
485 });
486 }
487 Ok(())
488 }
489
490 pub async fn set_remote_retention(&self, days: i32) -> Result<(), HttpError> {
494 let url = format!("{}/devices/self/retention", self.base_url);
495 #[derive(serde::Serialize)]
496 struct Body {
497 remote_retention_days: i32,
498 }
499 let resp = self
500 .client
501 .put(&url)
502 .bearer_auth(&self.token)
503 .json(&Body {
504 remote_retention_days: days,
505 })
506 .send()
507 .await
508 .map_err(|e| HttpError::Network(e.to_string()))?;
509 let status = resp.status();
510 if !status.is_success() {
511 let body = resp.text().await.unwrap_or_default();
512 return Err(HttpError::Relay {
513 status: status.as_u16(),
514 message: format!("set_remote_retention failed: {}", body),
515 fix: String::new(),
516 });
517 }
518 Ok(())
519 }
520
521 pub async fn get_key_bundle(&self) -> Result<KeyBundleResponse, HttpError> {
528 let url = format!("{}/auth/key-bundle", self.base_url);
529 let resp = self
530 .client
531 .get(&url)
532 .bearer_auth(&self.token)
533 .send()
534 .await
535 .map_err(|e| HttpError::Network(e.to_string()))?;
536 decode_json_response::<KeyBundleResponse>(resp).await
537 }
538
539 pub async fn list_clips_since(
543 &self,
544 since: Option<chrono::DateTime<chrono::Utc>>,
545 limit: u32,
546 ) -> Result<Vec<Clip>, HttpError> {
547 let url = format!("{}/clips", self.base_url);
548 let resp = self
549 .send_with_retry(|| {
550 let mut req = self.client.get(&url).bearer_auth(&self.token);
551 if let Some(ts) = since {
552 req = req.query(&[("since", ts.to_rfc3339())]);
553 }
554 req = req.query(&[("limit", limit.to_string())]);
555 req.build()
556 })
557 .await?;
558 decode_json_response::<Vec<Clip>>(resp).await
559 }
560
561 pub async fn list_clips(&self, filter: ListClipsFilter) -> Result<Vec<Clip>, HttpError> {
564 let url = format!("{}/clips", self.base_url);
565 let resp = self
566 .send_with_retry(|| {
567 let mut req = self.client.get(&url).bearer_auth(&self.token);
568 let limit = if filter.limit == 0 {
569 50
570 } else {
571 filter.limit.min(200)
572 };
573 req = req.query(&[("limit", limit.to_string())]);
574 if let Some(s) = &filter.source {
575 req = req.query(&[("source", s.as_str())]);
576 }
577 if let Some(s) = &filter.exclude_source {
578 req = req.query(&[("exclude_source", s.as_str())]);
579 }
580 if filter.exclude_image {
581 req = req.query(&[("exclude_image", "true")]);
582 }
583 if filter.exclude_text {
584 req = req.query(&[("exclude_text", "true")]);
585 }
586 for id in &filter.clip_ids {
587 req = req.query(&[("clip_id", id.as_str())]);
588 }
589 req.build()
590 })
591 .await?;
592 decode_json_response::<Vec<Clip>>(resp).await
593 }
594
595 pub async fn get_clip_by_id(&self, clip_id: &str) -> Result<Clip, HttpError> {
597 let clips = self
598 .list_clips(ListClipsFilter {
599 limit: 1,
600 clip_ids: vec![clip_id.to_string()],
601 ..Default::default()
602 })
603 .await?;
604 clips.into_iter().next().ok_or_else(|| HttpError::Relay {
605 status: 404,
606 message: format!("Clip {} not found.", clip_id),
607 fix: String::new(),
608 })
609 }
610
611 pub async fn get_latest_clip_excluding(&self, exclude_source: &str) -> Result<Clip, HttpError> {
613 let url = format!("{}/clips/latest", self.base_url);
614 let resp = self
615 .send_with_retry(|| {
616 self.client
617 .get(&url)
618 .bearer_auth(&self.token)
619 .query(&[("exclude_source", exclude_source)])
620 .build()
621 })
622 .await?;
623 decode_json_response::<Clip>(resp).await
624 }
625
626 pub async fn delete_clip(&self, clip_id: &str) -> Result<(), HttpError> {
628 let url = format!("{}/clips/{}", self.base_url, clip_id);
629 let resp = self
630 .send_with_retry(|| self.client.delete(&url).bearer_auth(&self.token).build())
631 .await?;
632 let status = resp.status();
633 if status == StatusCode::NOT_FOUND || status.is_success() {
634 return Ok(());
635 }
636 if status == StatusCode::UNAUTHORIZED {
637 return Err(HttpError::Unauthorized);
638 }
639 Err(HttpError::Relay {
640 status: status.as_u16(),
641 message: format!("Delete clip failed (HTTP {}).", status.as_u16()),
642 fix: String::new(),
643 })
644 }
645
646 pub async fn set_clip_pin(
648 &self,
649 clip_id: &str,
650 is_pinned: bool,
651 pin_note: Option<&str>,
652 ) -> Result<(), HttpError> {
653 let url = format!("{}/clips/{}/pin", self.base_url, clip_id);
654 #[derive(serde::Serialize)]
655 struct PinBody<'a> {
656 is_pinned: bool,
657 #[serde(skip_serializing_if = "Option::is_none")]
658 pin_note: Option<&'a str>,
659 }
660 let body = PinBody {
661 is_pinned,
662 pin_note,
663 };
664 let resp = self
665 .send_with_retry(|| {
666 self.client
667 .post(&url)
668 .bearer_auth(&self.token)
669 .json(&body)
670 .build()
671 })
672 .await?;
673 let status = resp.status();
674 if status == StatusCode::NOT_FOUND || status.is_success() {
675 return Ok(());
676 }
677 if status == StatusCode::UNAUTHORIZED {
678 return Err(HttpError::Unauthorized);
679 }
680 Err(HttpError::Relay {
681 status: status.as_u16(),
682 message: format!("Set clip pin failed (HTTP {}).", status.as_u16()),
683 fix: String::new(),
684 })
685 }
686
687 pub async fn list_devices(&self) -> Result<Vec<DeviceInfo>, HttpError> {
689 let url = format!("{}/devices", self.base_url);
690 let resp = self
691 .send_with_retry(|| self.client.get(&url).bearer_auth(&self.token).build())
692 .await?;
693 decode_json_response::<Vec<DeviceInfo>>(resp).await
694 }
695
696 async fn send_with_retry<F>(&self, build: F) -> Result<reqwest::Response, HttpError>
697 where
698 F: Fn() -> Result<reqwest::Request, reqwest::Error>,
699 {
700 let mut last_err: Option<HttpError> = None;
701 for attempt in 0..MAX_ATTEMPTS {
702 if attempt > 0 {
703 tokio::time::sleep(Duration::from_secs(1u64 << attempt)).await;
704 }
705 let req = build().map_err(|e| HttpError::Build(e.to_string()))?;
706 match self.client.execute(req).await {
707 Ok(resp) => return Ok(resp),
708 Err(e) => last_err = Some(HttpError::Network(e.to_string())),
709 }
710 }
711 Err(last_err.unwrap_or(HttpError::Network("max retries exceeded".into())))
712 }
713}
714
715async fn decode_push_response(resp: reqwest::Response) -> Result<PushResponse, HttpError> {
716 decode_json_response::<PushResponse>(resp).await
717}
718
719async fn decode_json_response<T: serde::de::DeserializeOwned>(
720 resp: reqwest::Response,
721) -> Result<T, HttpError> {
722 let status = resp.status();
723 if status == StatusCode::UNAUTHORIZED {
724 return Err(HttpError::Unauthorized);
725 }
726 if !status.is_success() {
727 let err: ErrorResponse = resp.json().await.unwrap_or_default();
728 let message = if !err.message.is_empty() {
729 err.message
730 } else {
731 err.error
732 };
733 return Err(HttpError::Relay {
734 status: status.as_u16(),
735 message,
736 fix: err.fix,
737 });
738 }
739 resp.json::<T>()
740 .await
741 .map_err(|e| HttpError::Decode(e.to_string()))
742}
743
744#[cfg(test)]
745mod tests {
746 use crate::proto::cinch::v1::DeviceCodeStartRequest;
747
748 #[test]
749 fn device_code_start_request_includes_user_hint_when_set() {
750 let req = DeviceCodeStartRequest {
751 hostname: Some("dev-box-3".into()),
752 machine_id: Some("m1".into()),
753 user_hint: Some("alice@example.com".into()),
754 };
755 let bytes = serde_json::to_vec(&req).unwrap();
756 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
757 assert_eq!(parsed["user_hint"], "alice@example.com");
758 }
759
760 #[test]
761 fn device_code_start_request_omits_user_hint_when_none() {
762 let req = DeviceCodeStartRequest {
763 hostname: Some("dev-box-3".into()),
764 machine_id: Some("m1".into()),
765 user_hint: None,
766 };
767 let bytes = serde_json::to_vec(&req).unwrap();
768 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
769 assert!(
770 parsed.get("user_hint").is_none(),
771 "user_hint must omit when None"
772 );
773 }
774}