cargo_backup/remote/
github.rs

1use std::{collections::HashMap, thread, time::Duration};
2
3use super::{get_config, save_config, ProviderConfig, RemoteProvider};
4use crate::{url::UrlBuilder, Package};
5use owo_colors::OwoColorize;
6use serde::{Deserialize, Serialize};
7use serde_json::json;
8
9pub struct Github {
10    keyring: keyring::Entry,
11    config: Config,
12}
13
14#[derive(Serialize, Deserialize, Default, Clone)]
15pub struct Config {
16    gist_id: Option<String>,
17}
18
19impl ProviderConfig for Config {
20    fn get_name() -> String {
21        String::from("github")
22    }
23}
24
25impl Github {
26    fn get_auth(&self) -> Option<String> {
27        if let Ok(content) = self.keyring.get_password() {
28            return Some(content);
29        }
30        None
31    }
32}
33
34impl RemoteProvider for Github {
35    fn get_keyring() -> keyring::Entry {
36        keyring::Entry::new("cargo-backup", "github").expect("Could not open keyring")
37    }
38
39    fn new() -> Self {
40        Self {
41            keyring: Self::get_keyring(),
42            config: get_config(),
43        }
44    }
45
46    fn pull(&self) -> Result<Vec<crate::Package>, Box<dyn std::error::Error>> {
47        let auth = self
48            .get_auth()
49            .unwrap_or_else(|| panic!("Please login first with \"cargo sync login\""));
50
51        let gist_id: String = self
52            .config
53            .gist_id
54            .as_ref()
55            .unwrap_or_else(|| panic!("Gist Id not set"))
56            .to_owned();
57
58        let response: Gist = ureq::get(
59            &UrlBuilder::new(&format!("https://api.github.com/gists/{}", gist_id)).build(),
60        )
61        .set("Accept", "application/json")
62        .set("Authorization", &format!("token {}", auth))
63        .set("Content-Type", "application/json")
64        .set(
65            "User-Agent",
66            &format!("CargoBackup/{}", env!("CARGO_PKG_VERSION")),
67        )
68        .set("Authorization", &format!("token {}", auth))
69        .call()?
70        .into_json()?;
71
72        let mut packages: Vec<Package> = Vec::new();
73        for file in response.files.values() {
74            if file.filename == "backup.json" {
75                packages = serde_json::from_str(file.content.as_ref().unwrap())?;
76            }
77        }
78        Ok(packages)
79    }
80
81    fn push(&self, backup: &[crate::Package]) -> Result<(), Box<dyn std::error::Error>> {
82        let auth = self
83            .get_auth()
84            .unwrap_or_else(|| panic!("Please login first with \"cargo sync login\""));
85
86        let gist_id = self.config.gist_id.as_ref();
87
88        let request = match gist_id {
89            Some(id) => ureq::patch(&format!("https://api.github.com/gists/{}", id)),
90            None => ureq::post("https://api.github.com/gists"),
91        };
92
93        let result = request
94            .set("Accept", "application/json")
95            .set("Authorization", &format!("token {}", auth))
96            .set("Content-Type", "application/json")
97            .set(
98                "User-Agent",
99                &format!("CargoBackup/{}", env!("CARGO_PKG_VERSION")),
100            )
101            .send_json(json!({
102                "description": "Cargo Package Backup (Created by cargo-backup https://github.com/Kiramily/cargo-backup)",
103                "public": false,
104                "files": {
105                    "backup.json": {
106                        "content": serde_json::to_string(&backup).unwrap()
107                    }
108                }
109            }))?;
110
111        if result.status() == 200 {
112            println!("Successfully pushed backup");
113        } else {
114            println!("Failed to push backup");
115        }
116        Ok(())
117    }
118
119    fn login(&self, relogin: bool) -> Result<(), Box<dyn std::error::Error>> {
120        if relogin {
121            let _ = self.keyring.delete_password();
122        }
123
124        if self.keyring.get_password().is_ok() {
125            return Ok(());
126        }
127
128        let device_login: Code = ureq::post(
129            &UrlBuilder::new("https://github.com/login/device/code")
130                .add_param("client_id", "65102f4f3d896bfc9c1a")
131                .add_param("scope", "gist")
132                .build(),
133        )
134        .set("Accept", "application/json")
135        .call()?
136        .into_json()?;
137
138        println!("Open the following URL in your browser and enter the code.");
139        println!("{}", device_login.verification_uri.green());
140        println!("{}", device_login.user_code.blue().bold());
141
142        let mut has_token = false;
143
144        while !has_token {
145            thread::sleep(Duration::from_secs(device_login.interval));
146            let poll_request = ureq::post(
147                &UrlBuilder::new("https://github.com/login/oauth/access_token")
148                    .add_param("client_id", "65102f4f3d896bfc9c1a")
149                    .add_param("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
150                    .add_param("device_code", &device_login.device_code)
151                    .build(),
152            )
153            .set("Accept", "application/json")
154            .call()?;
155
156            let poll_request: LoginStatus = poll_request.into_json()?;
157
158            match poll_request.error {
159                LoginError::None => {
160                    self.keyring
161                        .set_password(&poll_request.access_token.unwrap())
162                        .unwrap();
163                    has_token = true;
164                    println!("Successfull Login");
165                }
166                LoginError::AccessDenied => panic!("Access Denied"),
167                LoginError::IncorrectClientCredentials => panic!("Incorrect Client Credentials"),
168                LoginError::ExpiredToken => panic!("Token expired"),
169                LoginError::IncorrectDeviceCode => panic!("Incorrect Device code"),
170                LoginError::SlowDown => todo!("Handle Slow down request"),
171                LoginError::AuthorizationPending => {}
172                LoginError::UnsupportedGrantType => panic!("Unsupported grant type"),
173            }
174        }
175
176        Ok(())
177    }
178
179    fn set_id(&self, id: String) -> Result<(), Box<dyn std::error::Error>> {
180        let mut config: Config = self.config.to_owned();
181        config.gist_id = Some(id);
182        save_config(config);
183        Ok(())
184    }
185}
186
187#[derive(Deserialize, Debug)]
188pub struct Code {
189    pub device_code: String,
190    pub user_code: String,
191    pub verification_uri: String,
192    pub expires_in: u32,
193    pub interval: u64,
194}
195
196#[derive(Deserialize)]
197struct LoginStatus {
198    #[serde(default)]
199    error: LoginError,
200    access_token: Option<String>,
201}
202
203#[derive(Debug, Deserialize, Default)]
204#[serde(rename_all = "snake_case")]
205pub enum LoginError {
206    AuthorizationPending,
207    SlowDown,
208    ExpiredToken,
209    UnsupportedGrantType,
210    IncorrectClientCredentials,
211    IncorrectDeviceCode,
212    AccessDenied,
213    #[default]
214    None,
215}
216
217#[derive(Serialize, Deserialize, Debug)]
218pub struct Gist {
219    #[serde(rename = "id", skip_serializing)]
220    pub id: String,
221
222    #[serde(rename = "files", default)]
223    pub files: HashMap<String, File>,
224
225    #[serde(rename = "public", default)]
226    pub public: bool,
227}
228
229#[derive(Serialize, Deserialize, Debug)]
230pub struct File {
231    #[serde(rename = "filename")]
232    pub filename: String,
233
234    #[serde(rename = "raw_url", skip_serializing)]
235    pub raw_url: Option<String>,
236
237    pub content: Option<String>,
238}