smbcloud_cli/account/
lib.rs

1use anyhow::{anyhow, Result};
2use console::style;
3use log::debug;
4use regex::Regex;
5use reqwest::{Client, Response, StatusCode};
6use smbcloud_model::account::SmbAuthorization;
7use smbcloud_networking::{
8    constants::{
9        GH_OAUTH_CLIENT_ID, GH_OAUTH_REDIRECT_HOST, GH_OAUTH_REDIRECT_PORT, PATH_AUTHORIZE,
10    },
11    environment::Environment,
12    smb_base_url_builder, smb_token_file_path,
13};
14use spinners::Spinner;
15use std::{
16    fs::{create_dir_all, OpenOptions},
17    io::{BufRead, BufReader, Write},
18    net::{TcpListener, TcpStream},
19    sync::mpsc::{self, Receiver, Sender},
20};
21use url_builder::URLBuilder;
22
23use crate::ui::fail_message;
24
25pub async fn authorize_github(env: &Environment) -> Result<SmbAuthorization> {
26    // Spin up a simple localhost server to listen for the GitHub OAuth callback
27    // setup_oauth_callback_server();
28    // Open the GitHub OAuth URL in the user's browser
29    let mut spinner = Spinner::new(
30        spinners::Spinners::BouncingBall,
31        style("🚀 Getting your GitHub information...")
32            .green()
33            .bold()
34            .to_string(),
35    );
36
37    let rx = match open::that(build_github_oauth_url()) {
38        Ok(_) => {
39            let (tx, rx): (Sender<String>, Receiver<String>) = mpsc::channel();
40            debug!(
41                "Setting up OAuth callback server... (tx: {:#?}, rx: {:#?})",
42                &tx, &rx
43            );
44            tokio::spawn(async move {
45                setup_oauth_callback_server(tx);
46            });
47            rx
48        }
49        Err(_) => {
50            let error = anyhow!("Failed to open a browser.");
51            return Err(error);
52        }
53    };
54
55    spinner.stop_and_persist("⌛", "Waiting for the authorization.".into());
56
57    debug!("Waiting for code from channel...");
58
59    match rx.recv() {
60        Ok(code) => {
61            debug!("Got code from channel: {:#?}", &code);
62            //Err(anyhow!("Failed to get code from channel."))
63            process_connect_github(*env, code).await
64        }
65        Err(e) => {
66            let error = anyhow!("Failed to get code from channel: {e}");
67            Err(error)
68        }
69    }
70}
71
72fn setup_oauth_callback_server(tx: Sender<String>) {
73    let listener = TcpListener::bind(format!("127.0.0.1:{}", GH_OAUTH_REDIRECT_PORT)).unwrap();
74    for stream in listener.incoming() {
75        let stream = stream.unwrap();
76        handle_connection(stream, tx.clone());
77    }
78}
79
80fn handle_connection(mut stream: TcpStream, tx: Sender<String>) {
81    let buf_reader = BufReader::new(&stream);
82    let request_line = &buf_reader.lines().next().unwrap().unwrap();
83
84    debug!("Request: {:#?}", request_line);
85
86    let code_regex = Regex::new(r"code=([^&]*)").unwrap();
87
88    let (status_line, contents) = match code_regex.captures(request_line) {
89        Some(group) => {
90            let code = group.get(1).unwrap().as_str();
91            debug!("Code: {:#?}", code);
92            debug!("Sending code to channel...");
93            debug!("Channel: {:#?}", &tx);
94            match tx.send(code.to_string()) {
95                Ok(_) => {
96                    debug!("Code sent to channel.");
97                }
98                Err(e) => {
99                    debug!("Failed to send code to channel: {e}");
100                }
101            }
102            (
103                "HTTP/1.1 200 OK",
104                "<!DOCTYPE html>
105
106                <head>
107                    <meta charset='utf-8'>
108                    <title>Hello!</title>
109                </head>
110                
111                <body>
112                    <h1>Authenticated!</h1>
113                    <p>Back to the terminal console to finish your registration.</p>
114                </body>",
115            )
116        }
117        None => {
118            debug!("Code not found.");
119            (
120                "HTTP/1.1 404 NOT FOUND",
121                "<!DOCTYPE html>
122                <html lang='en'>
123                
124                <head>
125                    <meta charset='utf-8'>
126                    <title>404 Not found</title>
127                </head>
128                
129                <body>
130                    <h1>Oops!</h1>
131                    <p>Sorry, I don't know what you're asking for.</p>
132                </body>
133                
134                </html>",
135            )
136        }
137    };
138
139    debug!("Contents: {:#?}", &contents);
140    let response = format!("{status_line}\r\n\r\n{contents}");
141    stream.write_all(response.as_bytes()).unwrap();
142    stream.flush().unwrap();
143}
144
145// Get access token
146pub async fn process_connect_github(env: Environment, code: String) -> Result<SmbAuthorization> {
147    let response = Client::new()
148        .post(build_authorize_smb_url(env))
149        .body(format!("gh_code={}", code))
150        .header("Accept", "application/json")
151        .header("Content-Type", "application/x-www-form-urlencoded")
152        .send()
153        .await?;
154    let mut spinner = Spinner::new(
155        spinners::Spinners::BouncingBall,
156        style("🚀 Authorizing your account...")
157            .green()
158            .bold()
159            .to_string(),
160    );
161    // println!("Response: {:#?}", &response);
162    match response.status() {
163        StatusCode::OK => {
164            // Account authorized and token received
165            spinner.stop_and_persist("✅", "You are logged in with your GitHub account!".into());
166            save_token(env, &response).await?;
167            let result = response.json().await?;
168            // println!("Result: {:#?}", &result);
169            Ok(result)
170        }
171        StatusCode::NOT_FOUND => {
172            // Account not found and we show signup option
173            spinner.stop_and_persist("🥲", "Account not found. Please signup!".into());
174            let result = response.json().await?;
175            // println!("Result: {:#?}", &result);
176            Ok(result)
177        }
178        StatusCode::UNPROCESSABLE_ENTITY => {
179            // Account found but email not verified
180            spinner.stop_and_persist("🥹", "Unverified email!".into());
181            let result = response.json().await?;
182            // println!("Result: {:#?}", &result);
183            Ok(result)
184        }
185        _ => {
186            // Other errors
187            let error = anyhow!("Error while authorizing with GitHub.");
188            Err(error)
189        }
190    }
191}
192
193fn build_authorize_smb_url(env: Environment) -> String {
194    let mut url_builder = smb_base_url_builder(env);
195    url_builder.add_route(PATH_AUTHORIZE);
196    url_builder.build()
197}
198
199fn build_github_oauth_url() -> String {
200    let mut url_builder = github_base_url_builder();
201    url_builder
202        .add_route("login/oauth/authorize")
203        .add_param("scope", "user")
204        .add_param("state", "smbcloud");
205    url_builder.build()
206}
207
208fn github_base_url_builder() -> URLBuilder {
209    let redirect_url = format!("{}:{}", GH_OAUTH_REDIRECT_HOST, GH_OAUTH_REDIRECT_PORT);
210
211    let mut url_builder = URLBuilder::new();
212    url_builder
213        .set_protocol("https")
214        .set_host("github.com")
215        .add_param("client_id", GH_OAUTH_CLIENT_ID)
216        .add_param("redirect_uri", &redirect_url);
217    url_builder
218}
219
220pub async fn save_token(env: Environment, response: &Response) -> Result<()> {
221    let headers = response.headers();
222    // println!("Headers: {:#?}", &headers);
223    match headers.get("Authorization") {
224        Some(token) => {
225            debug!("{}", token.to_str()?);
226            match home::home_dir() {
227                Some(path) => {
228                    debug!("{}", path.to_str().unwrap());
229                    create_dir_all(path.join(env.smb_dir()))?;
230                    let mut file = OpenOptions::new()
231                        .create(true)
232                        .truncate(true)
233                        .write(true)
234                        .open([path.to_str().unwrap(), "/", &env.smb_dir(), "/token"].join(""))?;
235                    file.write_all(token.to_str()?.as_bytes())?;
236                    Ok(())
237                }
238                None => Err(anyhow!("Failed to get home directory.")),
239            }
240        }
241        None => Err(anyhow!("Failed to get token. Probably a backend issue.")),
242    }
243}
244
245pub async fn protected_request(env: Environment) -> Result<()> {
246    // Check if token file exists
247    if smb_token_file_path(env).is_none() {
248        return Err(anyhow!(fail_message(
249            "Please authorize your account first with `smb account login` command."
250        )));
251    }
252    Ok(())
253}