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