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