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, serde::Deserialize)]
29pub struct Tombstone {
30 pub clip_id: String,
31 pub deleted_at: String, }
33
34#[derive(Debug, Default, Clone)]
36pub struct ListClipsFilter {
37 pub limit: u32,
38 pub source: Option<String>,
39 pub exclude_source: Option<String>,
40 pub exclude_image: bool,
41 pub exclude_text: bool,
42 pub clip_ids: Vec<String>,
43}
44
45#[derive(Debug, thiserror::Error)]
46pub enum HttpError {
47 #[error("network: {0}")]
48 Network(String),
49 #[error("auth required (401)")]
50 Unauthorized,
51 #[error("relay error ({status}): {message}")]
52 Relay {
53 status: u16,
54 message: String,
55 fix: String,
56 },
57 #[error("decode response: {0}")]
58 Decode(String),
59 #[error("build request: {0}")]
60 Build(String),
61}
62
63#[derive(Debug, Clone)]
64pub struct RestClient {
65 base_url: String,
66 token: String,
67 client: Client,
68}
69
70impl RestClient {
71 pub fn new(relay_url: impl Into<String>, token: impl Into<String>) -> Result<Self, HttpError> {
73 let base = relay_url.into().trim_end_matches('/').to_string();
74 let client = Client::builder()
75 .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
76 .build()
77 .map_err(|e| HttpError::Build(e.to_string()))?;
78 Ok(Self {
79 base_url: base,
80 token: token.into(),
81 client,
82 })
83 }
84
85 pub async fn push_clip_json(&self, req: &PushRequest) -> Result<PushResponse, HttpError> {
87 let url = format!("{}/clips", self.base_url);
88 let resp = self
89 .send_with_retry(|| {
90 self.client
91 .post(&url)
92 .bearer_auth(&self.token)
93 .json(req)
94 .build()
95 })
96 .await?;
97 decode_push_response(resp).await
98 }
99
100 pub async fn push_clip_binary(
103 &self,
104 data: Vec<u8>,
105 content_type: &str,
106 source: &str,
107 label: Option<&str>,
108 target_device_id: Option<&str>,
109 ) -> Result<PushResponse, HttpError> {
110 let url = format!("{}/clips/binary", self.base_url);
111 let mut last_err: Option<HttpError> = None;
112 for attempt in 0..MAX_ATTEMPTS {
113 if attempt > 0 {
114 tokio::time::sleep(Duration::from_secs(1u64 << attempt)).await;
115 }
116 let mut form = multipart::Form::new()
119 .part(
120 "file",
121 multipart::Part::bytes(data.clone()).file_name("upload"),
122 )
123 .text("content_type", content_type.to_string())
124 .text("source", source.to_string());
125 if let Some(l) = label.filter(|s| !s.is_empty()) {
126 form = form.text("label", l.to_string());
127 }
128 if let Some(d) = target_device_id.filter(|s| !s.is_empty()) {
129 form = form.text("target_device_id", d.to_string());
130 }
131 let resp = self
132 .client
133 .post(&url)
134 .bearer_auth(&self.token)
135 .multipart(form)
136 .send()
137 .await;
138 match resp {
139 Ok(r) => return decode_push_response(r).await,
140 Err(e) => last_err = Some(HttpError::Network(e.to_string())),
141 }
142 }
143 Err(last_err.unwrap_or(HttpError::Network("max retries exceeded".into())))
144 }
145
146 pub async fn pull_clipboard(&self) -> Result<PullResponse, HttpError> {
148 let url = format!("{}/pull", self.base_url);
149 let resp = self
150 .send_with_retry(|| self.client.post(&url).bearer_auth(&self.token).build())
151 .await?;
152 decode_json_response::<PullResponse>(resp).await
153 }
154
155 pub async fn get_latest_clip(&self, source: &str) -> Result<Clip, HttpError> {
157 let url = format!("{}/clips/latest", self.base_url);
158 let resp = self
159 .send_with_retry(|| {
160 self.client
161 .get(&url)
162 .bearer_auth(&self.token)
163 .query(&[("source", source)])
164 .build()
165 })
166 .await?;
167 decode_json_response::<Clip>(resp).await
168 }
169
170 pub async fn get_clip_media(&self, clip_id: &str) -> Result<Vec<u8>, HttpError> {
172 let url = format!("{}/clips/{}/media", self.base_url, clip_id);
173 let resp = self
174 .send_with_retry(|| self.client.get(&url).bearer_auth(&self.token).build())
175 .await?;
176 let status = resp.status();
177 if status == StatusCode::UNAUTHORIZED {
178 return Err(HttpError::Unauthorized);
179 }
180 if !status.is_success() {
181 return Err(HttpError::Relay {
182 status: status.as_u16(),
183 message: format!("Image not found on relay (HTTP {}).", status.as_u16()),
184 fix: String::new(),
185 });
186 }
187 resp.bytes()
188 .await
189 .map(|b| b.to_vec())
190 .map_err(|e| HttpError::Decode(e.to_string()))
191 }
192
193 pub async fn start_device_code(
197 &self,
198 relay_url: &str,
199 hostname: &str,
200 machine_id: &str,
201 user_hint: Option<&str>,
202 ) -> Result<DeviceCodeResponse, HttpError> {
203 let url = format!("{}/auth/device-code", relay_url.trim_end_matches('/'));
204 let req = DeviceCodeRequest {
205 hostname: Some(hostname.to_string()),
206 machine_id: if machine_id.is_empty() {
207 None
208 } else {
209 Some(machine_id.to_string())
210 },
211 user_hint: user_hint.map(|s| s.to_string()),
212 };
213 let resp = self
214 .client
215 .post(&url)
216 .json(&req)
217 .send()
218 .await
219 .map_err(|e| HttpError::Network(e.to_string()))?;
220 decode_json_response::<DeviceCodeResponse>(resp).await
221 }
222
223 pub async fn poll_device_code(
226 &self,
227 relay_url: &str,
228 device_code: &str,
229 ) -> Result<DeviceCodePollResponse, HttpError> {
230 let url = format!("{}/auth/device-code/poll", relay_url.trim_end_matches('/'));
231 let resp = self
232 .client
233 .get(&url)
234 .query(&[("code", device_code)])
235 .send()
236 .await
237 .map_err(|e| HttpError::Network(e.to_string()))?;
238 decode_json_response::<DeviceCodePollResponse>(resp).await
239 }
240
241 pub async fn complete_device_code(&self, user_code: &str) -> Result<(), HttpError> {
244 let url = format!("{}/auth/device-code/complete", self.base_url);
245 let body = DeviceCodeCompleteRequest {
246 user_code: user_code.to_string(),
247 user_id: String::new(),
248 device_id: String::new(),
249 token: String::new(),
250 };
251 let resp = self
252 .client
253 .post(&url)
254 .bearer_auth(&self.token)
255 .json(&body)
256 .send()
257 .await
258 .map_err(|e| HttpError::Network(e.to_string()))?;
259 decode_json_response::<serde_json::Value>(resp)
260 .await
261 .map(|_| ())
262 }
263
264 pub async fn deny_device_code(&self, user_code: &str) -> Result<(), HttpError> {
267 let url = format!("{}/cinch.v1.AuthService/DeviceCodeDeny", self.base_url);
268 let body = DeviceCodeDenyRequest {
269 user_code: user_code.to_string(),
270 };
271 let resp = self
272 .client
273 .post(&url)
274 .bearer_auth(&self.token)
275 .json(&body)
276 .send()
277 .await
278 .map_err(|e| HttpError::Network(e.to_string()))?;
279 decode_json_response::<serde_json::Value>(resp)
280 .await
281 .map(|_| ())
282 }
283
284 pub async fn probe_relay(&self, relay_url: &str) -> Result<(), HttpError> {
288 let url = format!("{}/health", relay_url.trim_end_matches('/'));
289 let resp = self
290 .client
291 .get(&url)
292 .send()
293 .await
294 .map_err(|e| HttpError::Network(e.to_string()))?;
295 if resp.status().is_success() {
296 Ok(())
297 } else {
298 Err(HttpError::Relay {
299 status: resp.status().as_u16(),
300 message: format!("health check failed: HTTP {}", resp.status().as_u16()),
301 fix: String::new(),
302 })
303 }
304 }
305
306 pub async fn post_key_bundle(
313 &self,
314 target_device_id: &str,
315 ephemeral_public_key: &str,
316 encrypted_bundle: &str,
317 ) -> Result<(), HttpError> {
318 let url = format!("{}/auth/key-bundle", self.base_url);
319 let body = KeyBundlePutRequest {
320 device_id: target_device_id.to_string(),
321 ephemeral_public_key: ephemeral_public_key.to_string(),
322 encrypted_bundle: encrypted_bundle.to_string(),
323 };
324 let resp = self
325 .client
326 .post(&url)
327 .bearer_auth(&self.token)
328 .json(&body)
329 .send()
330 .await
331 .map_err(|e| HttpError::Network(e.to_string()))?;
332 let status = resp.status();
333 if status == StatusCode::UNAUTHORIZED {
334 return Err(HttpError::Unauthorized);
335 }
336 if !status.is_success() {
337 return Err(HttpError::Relay {
338 status: status.as_u16(),
339 message: format!("post key bundle failed: HTTP {}", status.as_u16()),
340 fix: String::new(),
341 });
342 }
343 Ok(())
344 }
345
346 pub async fn register_device_public_key(
353 &self,
354 public_key: &str,
355 fingerprint: &str,
356 ) -> Result<(), HttpError> {
357 let url = format!("{}/auth/device/public-key", self.base_url);
358 let body = RegisterDevicePublicKeyRequest {
359 public_key: public_key.to_string(),
360 fingerprint: fingerprint.to_string(),
361 };
362 let resp = self
363 .client
364 .post(&url)
365 .bearer_auth(&self.token)
366 .json(&body)
367 .send()
368 .await
369 .map_err(|e| HttpError::Network(e.to_string()))?;
370 let status = resp.status();
371 if status == StatusCode::UNAUTHORIZED {
372 return Err(HttpError::Unauthorized);
373 }
374 if !status.is_success() {
375 return Err(HttpError::Relay {
376 status: status.as_u16(),
377 message: format!("register public key failed: HTTP {}", status.as_u16()),
378 fix: String::new(),
379 });
380 }
381 Ok(())
382 }
383
384 pub async fn retry_key_bundle(&self) -> Result<(), HttpError> {
389 let url = format!("{}/auth/key-bundle/retry", self.base_url);
390 let resp = self
391 .client
392 .post(&url)
393 .bearer_auth(&self.token)
394 .send()
395 .await
396 .map_err(|e| HttpError::Network(e.to_string()))?;
397 let status = resp.status();
398 if status == StatusCode::UNAUTHORIZED {
399 return Err(HttpError::Unauthorized);
400 }
401 if !status.is_success() {
402 return Err(HttpError::Relay {
403 status: status.as_u16(),
404 message: format!("retry key bundle failed: HTTP {}", status.as_u16()),
405 fix: String::new(),
406 });
407 }
408 Ok(())
409 }
410
411 pub async fn revoke_device(&self, device_id: &str) -> Result<(), HttpError> {
415 let url = format!("{}/auth/device/revoke", self.base_url);
416 let body = DeviceRevokeRequest {
417 device_id: device_id.to_string(),
418 };
419 let resp = self
420 .client
421 .post(&url)
422 .bearer_auth(&self.token)
423 .json(&body)
424 .send()
425 .await
426 .map_err(|e| HttpError::Network(e.to_string()))?;
427 let status = resp.status();
428 if !status.is_success() {
429 return Err(HttpError::Relay {
430 status: status.as_u16(),
431 message: format!("revoke failed: HTTP {}", status.as_u16()),
432 fix: String::new(),
433 });
434 }
435 Ok(())
436 }
437
438 pub async fn set_device_nickname(
443 &self,
444 device_id: &str,
445 nickname: &str,
446 ) -> Result<(), HttpError> {
447 let url = format!("{}/devices/{}/nickname", self.base_url, device_id);
448 #[derive(serde::Serialize)]
449 struct NicknameBody<'a> {
450 nickname: &'a str,
451 }
452 let resp = self
453 .client
454 .put(&url)
455 .bearer_auth(&self.token)
456 .json(&NicknameBody { nickname })
457 .send()
458 .await
459 .map_err(|e| HttpError::Network(e.to_string()))?;
460 let status = resp.status();
461 if !status.is_success() {
462 let body = resp.text().await.unwrap_or_default();
463 return Err(HttpError::Relay {
464 status: status.as_u16(),
465 message: format!("set_device_nickname failed: {}", body),
466 fix: String::new(),
467 });
468 }
469 Ok(())
470 }
471
472 pub async fn set_remote_retention(&self, days: i32) -> Result<(), HttpError> {
476 let url = format!("{}/devices/self/retention", self.base_url);
477 #[derive(serde::Serialize)]
478 struct Body {
479 remote_retention_days: i32,
480 }
481 let resp = self
482 .client
483 .put(&url)
484 .bearer_auth(&self.token)
485 .json(&Body {
486 remote_retention_days: days,
487 })
488 .send()
489 .await
490 .map_err(|e| HttpError::Network(e.to_string()))?;
491 let status = resp.status();
492 if !status.is_success() {
493 let body = resp.text().await.unwrap_or_default();
494 return Err(HttpError::Relay {
495 status: status.as_u16(),
496 message: format!("set_remote_retention failed: {}", body),
497 fix: String::new(),
498 });
499 }
500 Ok(())
501 }
502
503 pub async fn get_key_bundle(&self) -> Result<KeyBundleResponse, HttpError> {
510 let url = format!("{}/auth/key-bundle", self.base_url);
511 let resp = self
512 .client
513 .get(&url)
514 .bearer_auth(&self.token)
515 .send()
516 .await
517 .map_err(|e| HttpError::Network(e.to_string()))?;
518 decode_json_response::<KeyBundleResponse>(resp).await
519 }
520
521 pub async fn list_tombstones(
523 &self,
524 since: Option<chrono::DateTime<chrono::Utc>>,
525 ) -> Result<Vec<Tombstone>, HttpError> {
526 let url = format!("{}/tombstones", self.base_url);
527 let resp = self
528 .send_with_retry(|| {
529 let mut req = self.client.get(&url).bearer_auth(&self.token);
530 if let Some(ts) = since {
531 req = req.query(&[("since", ts.to_rfc3339())]);
532 }
533 req.build()
534 })
535 .await?;
536 decode_json_response::<Vec<Tombstone>>(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}