@smbcloud / cli / commits / 0e5a48c

Add github connect

Seto Elkahfi committed Apr 4, 2023 at 23:19 UTC 0e5a48c9c595e0237cf8e24641490115f0566a8a
9 files changed +410 -53
.env.local new
+3
@@ -0,0 +1,3 @@
1 +GITHUB_OAUTH_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
2 +GITHUB_OAUTH_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
3 +GITHUB_OAUTH_REDIRECT_URI=http://localhost:8808/
\ No newline at end of file
.gitignore
+2 -1
@@ -1,2 +1,3 @@
1 /target
2 -*.log
\ No newline at end of file
2 +*.log
3 +.env
\ No newline at end of file
Cargo.lock
+30
@@ -168,6 +168,12 @@ dependencies = [
168 "winapi",
169 ]
170
171 +[[package]]
172 +name = "dotenv"
173 +version = "0.15.0"
174 +source = "registry+https://github.com/rust-lang/crates.io-index"
175 +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
176 +
177 [[package]]
178 name = "encode_unicode"
179 version = "0.3.6"
@@ -621,6 +627,15 @@ version = "1.17.1"
627 source = "registry+https://github.com/rust-lang/crates.io-index"
628 checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3"
629
630 +[[package]]
631 +name = "open"
632 +version = "4.0.1"
633 +source = "registry+https://github.com/rust-lang/crates.io-index"
634 +checksum = "075c5203b3a2b698bc72c6c10b1f6263182135751d5013ea66e8a4b3d0562a43"
635 +dependencies = [
636 + "pathdiff",
637 +]
638 +
639 [[package]]
640 name = "openssl"
641 version = "0.10.45"
@@ -701,6 +716,12 @@ dependencies = [
716 "windows-sys 0.45.0",
717 ]
718
719 +[[package]]
720 +name = "pathdiff"
721 +version = "0.2.1"
722 +source = "registry+https://github.com/rust-lang/crates.io-index"
723 +checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd"
724 +
725 [[package]]
726 name = "percent-encoding"
727 version = "2.2.0"
@@ -1014,7 +1035,9 @@ dependencies = [
1035 "console",
1036 "dialoguer",
1037 "dirs",
1038 + "dotenv",
1039 "home",
1040 + "open",
1041 "regex",
1042 "reqwest",
1043 "serde",
@@ -1024,6 +1047,7 @@ dependencies = [
1047 "tracing",
1048 "tracing-bunyan-formatter",
1049 "tracing-subscriber",
1050 + "url-builder",
1051 ]
1052
1053 [[package]]
@@ -1367,6 +1391,12 @@ dependencies = [
1391 "percent-encoding",
1392 ]
1393
1394 +[[package]]
1395 +name = "url-builder"
1396 +version = "0.1.1"
1397 +source = "registry+https://github.com/rust-lang/crates.io-index"
1398 +checksum = "6c560d19163937c53680c515b82a9311c5a796691c67890b8ad579423123e194"
1399 +
1400 [[package]]
1401 name = "valuable"
1402 version = "0.1.0"
Cargo.toml
+4 -1
@@ -21,6 +21,9 @@ console = "0.15.0"
21 dialoguer = { git = "https://github.com/qt2/dialoguer.git", branch ="password_validation", package = "dialoguer" }
22 home = "0.5.4"
23 regex = "1.3.1"
24 +open = "4.0.1"
25 +dotenv = "0.15.0"
26 +url-builder = "0.1.1"
27 reqwest = { version = "0.11", features = ["json"] }
28 serde = { version = "1", features = ["derive"] }
29 serde_json = "1.0.82"
@@ -28,4 +31,4 @@ spinners = "4.1.0"
31 tokio = { version = "1.14.1", features = ["full"] }
32 tracing = { version = "0.1.35", features = ["log"] }
33 tracing-bunyan-formatter = "0.3.3"
31 -tracing-subscriber = { version = "0.3.14", features = ["registry", "env-filter"] }
34 +tracing-subscriber = { version = "0.3.14", features = ["registry", "env-filter"] }
\ No newline at end of file
src/account/404.html new
+14
@@ -0,0 +1,14 @@
1 +<!DOCTYPE html>
2 +<html lang="en">
3 +
4 +<head>
5 + <meta charset="utf-8">
6 + <title>404 Not found</title>
7 +</head>
8 +
9 +<body>
10 + <h1>Oops!</h1>
11 + <p>Sorry, I don't know what you're asking for.</p>
12 +</body>
13 +
14 +</html>
\ No newline at end of file
src/account/hello.html new
+11
@@ -0,0 +1,11 @@
1 +<!DOCTYPE html>
2 +
3 +<head>
4 + <meta charset="utf-8">
5 + <title>Hello!</title>
6 +</head>
7 +
8 +<body>
9 + <h1>Authenticated!</h1>
10 + <p>Back to the terminal console to finish your registration.</p>
11 +</body>
\ No newline at end of file
src/account/signup/mod.rs
+17
@@ -1,2 +1,19 @@
1 pub mod process;
2 +use std::fmt::Display;
3 +
4 pub use process::*;
5 +
6 +#[derive(Debug, Clone, Copy)]
7 +pub enum SignupMethod {
8 + Email,
9 + GitHub,
10 +}
11 +
12 +impl Display for SignupMethod {
13 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
14 + match self {
15 + Self::Email => write!(f, "Email and password"),
16 + Self::GitHub => write!(f, "GitHub"),
17 + }
18 + }
19 +}
src/account/signup/process.rs
+310 -4
@@ -1,10 +1,25 @@
1 +use std::{
2 + fmt::{Display, Formatter},
3 + fs,
4 + io::{BufRead, BufReader, Write},
5 + net::{TcpListener, TcpStream},
6 + sync::mpsc::{self, Receiver, Sender},
7 +};
8 +
9 use anyhow::{anyhow, Result};
2 -use reqwest::Client;
10 +use console::{style, Term};
11 +use dialoguer::{theme::ColorfulTheme, Input, Password, Select};
12 +use regex::Regex;
13 +use reqwest::{Client, StatusCode};
14 use serde::{Deserialize, Serialize};
15 +use spinners::Spinner;
16 +use url_builder::URLBuilder;
17
18 use crate::{
19 account::model::{Data, Status, User},
20 constants::BASE_URL,
21 + debug,
22 + util::CommandResult,
23 };
24 pub struct SignupArgs {
25 pub username: String,
@@ -21,7 +36,298 @@ struct SignupResult {
36 data: Option<Data>,
37 }
38
24 -pub async fn process_signup(args: SignupArgs) -> Result<()> {
39 +pub async fn signup_with_email(email: Option<String>) -> Result<CommandResult> {
40 + let email = if let Some(email) = email {
41 + email
42 + } else {
43 + Input::<String>::with_theme(&ColorfulTheme::default())
44 + .with_prompt("Username")
45 + .validate_with(|input: &String| -> Result<(), &str> {
46 + let email_regex = Regex::new(r"^([a-z0-9_+]([a-z0-9_+.]*[a-z0-9_+])?)@([a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,6})").unwrap();
47 +
48 + if email_regex.is_match(input) {
49 + Ok(())
50 + } else {
51 + Err("Username must be an email address")
52 + }
53 + })
54 + .interact()
55 + .unwrap()
56 + };
57 +
58 + let password = Password::with_theme(&ColorfulTheme::default())
59 + .with_prompt("Password")
60 + .validate_with(|input: &String| -> Result<(), &str> {
61 + if input.len() >= 6 {
62 + Ok(())
63 + } else {
64 + Err("Password must be at least 6 characters")
65 + }
66 + })
67 + .with_confirmation("Confirm password", "Passwords do not match")
68 + .interact()
69 + .unwrap();
70 +
71 + let spinner = Spinner::new(
72 + spinners::Spinners::BouncingBall,
73 + style("Signing up...").green().bold().to_string(),
74 + );
75 +
76 + match process_signup(SignupArgs {
77 + username: email,
78 + password,
79 + })
80 + .await
81 + {
82 + Ok(_) => Ok(CommandResult {
83 + spinner,
84 + symbol: style("✔".to_string()).for_stderr().green().to_string(),
85 + msg: "You are signed up! Check your email to confirm your account.".to_owned(),
86 + }),
87 + Err(e) => Ok(CommandResult {
88 + spinner,
89 + symbol: style("✘".to_string()).for_stderr().red().to_string(),
90 + msg: format!("{e}"),
91 + }),
92 + }
93 +}
94 +
95 +pub async fn signup_with_github() -> Result<CommandResult> {
96 + // Spin up a simple localhost server to listen for the GitHub OAuth callback
97 + // setup_oauth_callback_server();
98 + // Open the GitHub OAuth URL in the user's browser
99 + spinner_newline("Getting your GitHub information...".to_owned());
100 +
101 + let rx = match open::that(build_github_oauth_url()) {
102 + Ok(_) => {
103 + let (tx, rx): (Sender<String>, Receiver<String>) = mpsc::channel();
104 + debug!(
105 + "Setting up OAuth callback server... (tx: {:#?}, rx: {:#?})",
106 + &tx, &rx
107 + );
108 + tokio::spawn(async move {
109 + setup_oauth_callback_server(tx);
110 + });
111 + rx
112 + }
113 + Err(_) => {
114 + let error = anyhow!("Failed to open a browser.");
115 + return Err(error);
116 + }
117 + };
118 +
119 + debug!("Waiting for code from channel...");
120 +
121 + match rx.recv() {
122 + Ok(code) => {
123 + debug!("Got code from channel: {:#?}", &code);
124 + process_signup_github(code).await
125 + }
126 + Err(e) => {
127 + let error = anyhow!("Failed to get code from channel: {e}");
128 + return Err(error);
129 + }
130 + }
131 +}
132 +
133 +fn setup_oauth_callback_server(tx: Sender<String>) {
134 + let listener = TcpListener::bind("127.0.0.1:8808").unwrap();
135 + for stream in listener.incoming() {
136 + let stream = stream.unwrap();
137 + handle_connection(stream, tx.clone());
138 + }
139 +}
140 +
141 +fn handle_connection(mut stream: TcpStream, tx: Sender<String>) {
142 + let buf_reader = BufReader::new(&stream);
143 + let request_line = &buf_reader.lines().next().unwrap().unwrap();
144 +
145 + debug!("Request: {:#?}", request_line);
146 +
147 + let code_regex = Regex::new(r"code=([^&]*)").unwrap();
148 +
149 + let (status_line, filename) = match code_regex.captures(&request_line) {
150 + Some(group) => {
151 + let code = group.get(1).unwrap().as_str();
152 + debug!("Code: {:#?}", code);
153 + debug!("Sending code to channel...");
154 + debug!("Channel: {:#?}", &tx);
155 + match tx.send(code.to_string()) {
156 + Ok(_) => {
157 + debug!("Code sent to channel.");
158 + }
159 + Err(e) => {
160 + debug!("Failed to send code to channel: {e}", e);
161 + }
162 + }
163 + ("HTTP/1.1 200 OK", "./src/account/hello.html")
164 + }
165 + None => {
166 + debug!("Code not found.");
167 + ("HTTP/1.1 404 NOT FOUND", "./src/account/404.html")
168 + }
169 + };
170 +
171 + let contents = fs::read_to_string(filename).unwrap();
172 + let length = contents.len();
173 + let response = format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
174 + stream.write_all(response.as_bytes()).unwrap();
175 + stream.flush().unwrap();
176 +}
177 +
178 +#[derive(Debug, Serialize, Deserialize)]
179 +struct GithubToken {
180 + access_token: String,
181 + scope: String,
182 + token_type: String,
183 +}
184 +#[derive(Debug, Serialize, Deserialize)]
185 +struct GithubUser {
186 + email: Option<String>,
187 + name: String,
188 +}
189 +
190 +#[derive(Debug, Serialize, Deserialize, Clone)]
191 +struct GithubEmail {
192 + email: String,
193 + primary: bool,
194 + verified: bool,
195 + visibility: Option<String>,
196 +}
197 +
198 +impl Display for GithubEmail {
199 + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
200 + write!(f, "{}", self.email)
201 + }
202 +}
203 +
204 +// Get access token
205 +async fn process_signup_github(code: String) -> Result<CommandResult> {
206 + let response = Client::new()
207 + .post(build_github_access_token_url(code))
208 + .header("Accept", "application/json")
209 + .send()
210 + .await?;
211 + let mut spinner = Spinner::new(
212 + spinners::Spinners::BouncingBall,
213 + style("Requesting GitHub token...")
214 + .green()
215 + .bold()
216 + .to_string(),
217 + );
218 + match response.status() {
219 + StatusCode::OK => {
220 + spinner.stop_and_persist("✔", "Finished requesting GitHub token!".into());
221 + debug!("Response: {:#?}", &response);
222 + let result: GithubToken = response.json().await?;
223 + debug!("Result: {:#?}", &result);
224 + get_github_data(result.access_token).await
225 + }
226 + _ => {
227 + let error = anyhow!("Error while requesting GitHub token.");
228 + Err(error)
229 + }
230 + }
231 +}
232 +
233 +// Request user data
234 +async fn get_github_data(token: String) -> Result<CommandResult> {
235 + let mut spinner = Spinner::new(
236 + spinners::Spinners::BouncingBall,
237 + style("Requesting GitHub data...")
238 + .green()
239 + .bold()
240 + .to_string(),
241 + );
242 + let response = Client::new()
243 + .get(build_github_access_data_url())
244 + .header("Accept", "application/json")
245 + .header("User-Agent", "Rust")
246 + .bearer_auth(token)
247 + .send()
248 + .await?;
249 +
250 + debug!("Response: {:#?}", &response);
251 +
252 + match response.status() {
253 + StatusCode::OK => {
254 + spinner.stop_and_persist("✔", "Finished requesting GitHub token!".into());
255 + debug!("Response: {:#?}", &response);
256 + let emails: Vec<GithubEmail> = response.json().await?;
257 + debug!(&emails);
258 + let email = select_github_emails(emails)?;
259 + signup_with_email(Some(email.email)).await
260 + }
261 + status_code => {
262 + let error = anyhow!("Error while requesting GitHub data: {status_code}");
263 + Err(error)
264 + }
265 + }
266 +}
267 +
268 +fn select_github_emails(github_emails: Vec<GithubEmail>) -> Result<GithubEmail> {
269 + let selection = Select::with_theme(&ColorfulTheme::default())
270 + .with_prompt("Select your email")
271 + .items(&github_emails)
272 + .default(0)
273 + .interact_on_opt(&Term::stderr())
274 + .map(|i| &github_emails[i.unwrap()])
275 + .unwrap()
276 + .to_owned();
277 + Ok(selection)
278 +}
279 +
280 +fn spinner_newline(message: String) {
281 + Spinner::new(
282 + spinners::Spinners::BouncingBall,
283 + style(message).green().bold().to_string(),
284 + )
285 + .stop_with_newline();
286 +}
287 +
288 +fn github_url_builder() -> URLBuilder {
289 + let client_id = dotenv::var("GITHUB_OAUTH_CLIENT_ID").unwrap_or("development".to_owned());
290 + let redirect_uri =
291 + dotenv::var("GITHUB_OAUTH_REDIRECT_URI").unwrap_or("http://localhost:8808/".to_owned());
292 + let mut url_builder = URLBuilder::new();
293 + url_builder
294 + .set_protocol("https")
295 + .set_host("github.com")
296 + .add_param("client_id", &client_id)
297 + .add_param("redirect_uri", &redirect_uri);
298 + url_builder
299 +}
300 +
301 +fn build_github_oauth_url() -> String {
302 + let mut url_builder = github_url_builder();
303 + url_builder
304 + .add_route("login/oauth/authorize")
305 + .add_param("scope", "user")
306 + .add_param("state", "smbpndk");
307 + url_builder.build()
308 +}
309 +
310 +fn build_github_access_token_url(code: String) -> String {
311 + let client_secret =
312 + dotenv::var("GITHUB_OAUTH_CLIENT_SECRET").unwrap_or("development".to_owned());
313 + let mut url_builder = github_url_builder();
314 + url_builder
315 + .add_route("login/oauth/access_token")
316 + .add_param("client_secret", &client_secret)
317 + .add_param("code", &code);
318 + url_builder.build()
319 +}
320 +
321 +fn build_github_access_data_url() -> String {
322 + let mut url_builder = URLBuilder::new();
323 + url_builder
324 + .set_protocol("https")
325 + .set_host("api.github.com")
326 + .add_route("user/emails");
327 + url_builder.build()
328 +}
329 +
330 +async fn process_signup(args: SignupArgs) -> Result<()> {
331 let signup_params = SignupParams {
332 user: User {
333 email: args.username,
@@ -36,8 +342,8 @@ pub async fn process_signup(args: SignupArgs) -> Result<()> {
342 .await?;
343
344 match response.status() {
39 - reqwest::StatusCode::OK => {}
40 - reqwest::StatusCode::UNPROCESSABLE_ENTITY => {
345 + StatusCode::OK => {}
346 + StatusCode::UNPROCESSABLE_ENTITY => {
347 let result: SignupResult = response.json().await?;
348 let error = anyhow!("Failed to signup: {}", result.status.message);
349 return Err(error);
src/main.rs
+19 -47
@@ -2,16 +2,15 @@ use std::{fs::OpenOptions, path::PathBuf, str::FromStr};
2
3 use anyhow::{anyhow, Result};
4 use clap::Parser;
5 -use console::style;
6 -use dialoguer::{theme::ColorfulTheme, Input, Password};
7 -use regex::Regex;
5 +use console::{style, Term};
6 +use dialoguer::{theme::ColorfulTheme, Input, Password, Select};
7 +
8 use smbpndk_cli::{
9 account::{
10 login::{process_login, LoginArgs},
11 - signup::{process_signup, SignupArgs},
11 + signup::{signup_with_email, signup_with_github, SignupMethod},
12 },
13 cli::{Cli, Commands},
14 - constants::ERROR_EMOJI,
14 projects,
15 util::CommandResult,
16 };
@@ -55,9 +54,14 @@ async fn main() {
54 Ok(result) => {
55 let mut spinner = result.spinner;
56 spinner.stop_and_persist(&result.symbol, result.msg);
57 + std::process::exit(1);
58 }
59 Err(e) => {
60 - println!("\n{} {}", ERROR_EMOJI, style(e).red());
60 + println!(
61 + "\n{} {}",
62 + style("✘".to_string()).for_stderr().red(),
63 + style(e).red()
64 + );
65 std::process::exit(1);
66 }
67 }
@@ -112,49 +116,17 @@ async fn run() -> Result<CommandResult> {
116 }
117 }
118 Commands::Signup {} => {
115 - println!("Use your email address as your username.");
116 - let username = Input::<String>::with_theme(&ColorfulTheme::default())
117 - .with_prompt("Username")
118 - .validate_with(|input: &String| -> Result<(), &str> {
119 - let email_regex = Regex::new(r"^([a-z0-9_+]([a-z0-9_+.]*[a-z0-9_+])?)@([a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,6})").unwrap();
120 -
121 - if email_regex.is_match(input) {
122 - Ok(())
123 - } else {
124 - Err("Username must be an email address")
125 - }
126 - })
127 - .interact()
128 - .unwrap();
129 - let password = Password::with_theme(&ColorfulTheme::default())
130 - .with_prompt("Password")
131 - .validate_with(|input: &String| -> Result<(), &str> {
132 - if input.len() >= 6 {
133 - Ok(())
134 - } else {
135 - Err("Password must be at least 6 characters")
136 - }
137 - })
138 - .with_confirmation("Confirm password", "Passwords do not match")
139 - .interact()
119 + let signup_methods = vec![SignupMethod::Email, SignupMethod::GitHub];
120 + let selection = Select::with_theme(&ColorfulTheme::default())
121 + .items(&signup_methods)
122 + .default(0)
123 + .interact_on_opt(&Term::stderr())
124 + .map(|i| signup_methods[i.unwrap()])
125 .unwrap();
126
142 - let spinner = Spinner::new(
143 - spinners::Spinners::BouncingBall,
144 - style("Signing up...").green().bold().to_string(),
145 - );
146 -
147 - match process_signup(SignupArgs { username, password }).await {
148 - Ok(_) => Ok(CommandResult {
149 - spinner,
150 - symbol: "✅".to_owned(),
151 - msg: "You are signed up! Check your email to confirm your account.".to_owned(),
152 - }),
153 - Err(e) => Ok(CommandResult {
154 - spinner,
155 - symbol: "❌".to_owned(),
156 - msg: format!("Failed to signup: {e}"),
157 - }),
127 + match selection {
128 + SignupMethod::Email => signup_with_email(None).await,
129 + SignupMethod::GitHub => signup_with_github().await,
130 }
131 }
132 Commands::Projects { command } => projects::process(command).await,