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,