1 use crate::account::model::{Data, Status, User};
2 use anyhow::{anyhow, Result};
3 use console::{style, Term};
4 use dialoguer::{theme::ColorfulTheme, Input, Password, Select};
5 use log::debug;
6 use regex::Regex;
7 use reqwest::{Client, StatusCode};
8 use serde::{Deserialize, Serialize};
9 use smbpndk_model::CommandResult;
10 use smbpndk_networking::constants::BASE_URL;
11 use smbpndk_utils::email_validation;
12 use spinners::Spinner;
13 use std::{
14 fmt::{Display, Formatter},
15 fs,
16 io::{BufRead, BufReader, Write},
17 net::{TcpListener, TcpStream},
18 sync::mpsc::{self, Receiver, Sender},
19 };
20 use url_builder::URLBuilder;
21
22 use super::SignupMethod;
23 pub struct SignupArgs {
24 pub username: String,
25 pub password: String,
26 }
27 #[derive(Debug, Serialize)]
28 pub struct SignupParams {
29 pub user: User,
30 }
31
32 #[derive(Debug, Serialize, Deserialize)]
33 struct SignupResult {
34 status: Status,
35 data: Option<Data>,
36 }
37
38 pub async fn process_signup() -> Result<CommandResult> {
39 let signup_methods = vec![SignupMethod::Email, SignupMethod::GitHub];
40 let selection = Select::with_theme(&ColorfulTheme::default())
41 .items(&signup_methods)
42 .default(0)
43 .interact_on_opt(&Term::stderr())
44 .map(|i| signup_methods[i.unwrap()])
45 .unwrap();
46
47 match selection {
48 SignupMethod::Email => signup_with_email(None).await,
49 SignupMethod::GitHub => signup_with_github().await,
50 }
51 }
52
53 async fn signup_with_email(email: Option<String>) -> Result<CommandResult> {
54 let email = if let Some(email) = email {
55 email
56 } else {
57 Input::<String>::with_theme(&ColorfulTheme::default())
58 .with_prompt("Username")
59 .validate_with(|email: &String| email_validation(email))
60 .interact()
61 .unwrap()
62 };
63
64 let password = Password::with_theme(&ColorfulTheme::default())
65 .with_prompt("Password")
66 .validate_with(|input: &String| -> Result<(), &str> {
67 if input.len() >= 6 {
68 Ok(())
69 } else {
70 Err("Password must be at least 6 characters")
71 }
72 })
73 .with_confirmation("Confirm password", "Passwords do not match")
74 .interact()
75 .unwrap();
76
77 let spinner = Spinner::new(
78 spinners::Spinners::BouncingBall,
79 style("Signing up...").green().bold().to_string(),
80 );
81
82 match do_signup(SignupArgs {
83 username: email,
84 password,
85 })
86 .await
87 {
88 Ok(_) => Ok(CommandResult {
89 spinner,
90 symbol: style("✅".to_string()).for_stderr().green().to_string(),
91 msg: "You are signed up! Check your email to confirm your account.".to_owned(),
92 }),
93 Err(e) => Ok(CommandResult {
94 spinner,
95 symbol: style("✘".to_string()).for_stderr().red().to_string(),
96 msg: format!("{e}"),
97 }),
98 }
99 }
100
101 async fn signup_with_github() -> Result<CommandResult> {
102 // Spin up a simple localhost server to listen for the GitHub OAuth callback
103 // setup_oauth_callback_server();
104 // Open the GitHub OAuth URL in the user's browser
105 let mut spinner = Spinner::new(
106 spinners::Spinners::BouncingBall,
107 style("🚀 Getting your GitHub information...")
108 .green()
109 .bold()
110 .to_string(),
111 );
112
113 let rx = match open::that(build_github_oauth_url()) {
114 Ok(_) => {
115 let (tx, rx): (Sender<String>, Receiver<String>) = mpsc::channel();
116 debug!(
117 "Setting up OAuth callback server... (tx: {:#?}, rx: {:#?})",
118 &tx, &rx
119 );
120 tokio::spawn(async move {
121 setup_oauth_callback_server(tx);
122 });
123 rx
124 }
125 Err(_) => {
126 let error = anyhow!("Failed to open a browser.");
127 return Err(error);
128 }
129 };
130
131 spinner.stop_and_persist("⌛", "Waiting for the authorization.".into());
132
133 debug!("Waiting for code from channel...");
134
135 match rx.recv() {
136 Ok(code) => {
137 debug!("Got code from channel: {:#?}", &code);
138 process_signup_github(code).await
139 }
140 Err(e) => {
141 let error = anyhow!("Failed to get code from channel: {e}");
142 Err(error)
143 }
144 }
145 }
146
147 fn setup_oauth_callback_server(tx: Sender<String>) {
148 let listener = TcpListener::bind("127.0.0.1:8808").unwrap();
149 for stream in listener.incoming() {
150 let stream = stream.unwrap();
151 handle_connection(stream, tx.clone());
152 }
153 }
154
155 fn handle_connection(mut stream: TcpStream, tx: Sender<String>) {
156 let buf_reader = BufReader::new(&stream);
157 let request_line = &buf_reader.lines().next().unwrap().unwrap();
158
159 debug!("Request: {:#?}", request_line);
160
161 let code_regex = Regex::new(r"code=([^&]*)").unwrap();
162
163 let (status_line, filename) = match code_regex.captures(request_line) {
164 Some(group) => {
165 let code = group.get(1).unwrap().as_str();
166 debug!("Code: {:#?}", code);
167 debug!("Sending code to channel...");
168 debug!("Channel: {:#?}", &tx);
169 match tx.send(code.to_string()) {
170 Ok(_) => {
171 debug!("Code sent to channel.");
172 }
173 Err(e) => {
174 debug!("Failed to send code to channel: {e}");
175 }
176 }
177 ("HTTP/1.1 200 OK", "./src/account/hello.html")
178 }
179 None => {
180 debug!("Code not found.");
181 ("HTTP/1.1 404 NOT FOUND", "./src/account/404.html")
182 }
183 };
184
185 let contents = fs::read_to_string(filename).unwrap();
186 let length = contents.len();
187 let response = format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
188 stream.write_all(response.as_bytes()).unwrap();
189 stream.flush().unwrap();
190 }
191
192 #[derive(Debug, Serialize, Deserialize)]
193 struct GithubToken {
194 access_token: String,
195 scope: String,
196 token_type: String,
197 }
198 #[derive(Debug, Serialize, Deserialize)]
199 struct GithubUser {
200 email: Option<String>,
201 name: String,
202 }
203
204 #[derive(Debug, Serialize, Deserialize, Clone)]
205 struct GithubEmail {
206 email: String,
207 primary: bool,
208 verified: bool,
209 visibility: Option<String>,
210 }
211
212 impl Display for GithubEmail {
213 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
214 write!(f, "{}", self.email)
215 }
216 }
217
218 // Get access token
219 async fn process_signup_github(code: String) -> Result<CommandResult> {
220 let response = Client::new()
221 .post(build_github_access_token_url(code))
222 .header("Accept", "application/json")
223 .send()
224 .await?;
225 let mut spinner = Spinner::new(
226 spinners::Spinners::BouncingBall,
227 style("🚀 Requesting GitHub token...")
228 .green()
229 .bold()
230 .to_string(),
231 );
232 match response.status() {
233 StatusCode::OK => {
234 spinner.stop_and_persist("✅", "Finished requesting GitHub token!".into());
235 debug!("Response: {:#?}", &response);
236 let result: GithubToken = response.json().await?;
237 debug!("Result: {:#?}", &result);
238 get_github_data(result.access_token).await
239 }
240 _ => {
241 let error = anyhow!("Error while requesting GitHub token.");
242 Err(error)
243 }
244 }
245 }
246
247 // Request user data
248 async fn get_github_data(token: String) -> Result<CommandResult> {
249 let mut spinner = Spinner::new(
250 spinners::Spinners::BouncingBall,
251 style("🚀 Requesting GitHub data...")
252 .green()
253 .bold()
254 .to_string(),
255 );
256 let response = Client::new()
257 .get(build_github_access_data_url())
258 .header("Accept", "application/json")
259 .header("User-Agent", "Rust")
260 .bearer_auth(token)
261 .send()
262 .await?;
263
264 debug!("Response: {:#?}", &response);
265
266 match response.status() {
267 StatusCode::OK => {
268 spinner.stop_and_persist("✅", "Finished requesting GitHub data!".into());
269 debug!("Response: {:#?}", &response);
270 let mut emails: Vec<GithubEmail> = response.json().await?;
271 debug!("{:#?}", &emails);
272 emails.retain(|e| !e.email.contains("@users.noreply.github.com"));
273
274 let email = select_github_emails(emails)?;
275 signup_with_email(Some(email.email)).await
276 }
277 status_code => {
278 let error = anyhow!("Error while requesting GitHub data: {status_code}");
279 Err(error)
280 }
281 }
282 }
283
284 fn select_github_emails(github_emails: Vec<GithubEmail>) -> Result<GithubEmail> {
285 let selection = Select::with_theme(&ColorfulTheme::default())
286 .with_prompt("Select your email")
287 .items(&github_emails)
288 .default(0)
289 .interact_on_opt(&Term::stderr())
290 .map(|i| &github_emails[i.unwrap()])
291 .unwrap()
292 .to_owned();
293 Ok(selection)
294 }
295
296 fn github_url_builder() -> URLBuilder {
297 let client_id = option_env!("GH_OAUTH_CLIENT_ID").unwrap_or("Please set GH_OAUTH_CLIENT_ID");
298 let redirect_uri =
299 option_env!("GH_OAUTH_REDIRECT_URI").unwrap_or("Please set GH_OAUTH_REDIRECT_URI");
300
301 let mut url_builder = URLBuilder::new();
302 url_builder
303 .set_protocol("https")
304 .set_host("github.com")
305 .add_param("client_id", client_id)
306 .add_param("redirect_uri", redirect_uri);
307 url_builder
308 }
309
310 fn build_github_oauth_url() -> String {
311 let mut url_builder = github_url_builder();
312 url_builder
313 .add_route("login/oauth/authorize")
314 .add_param("scope", "user")
315 .add_param("state", "smbpndk");
316 url_builder.build()
317 }
318
319 fn build_github_access_token_url(code: String) -> String {
320 let client_secret =
321 option_env!("GH_OAUTH_CLIENT_SECRET").unwrap_or("Please set GH_OAUTH_CLIENT_SECRET");
322
323 let mut url_builder = github_url_builder();
324 url_builder
325 .add_route("login/oauth/access_token")
326 .add_param("client_secret", client_secret)
327 .add_param("code", &code);
328 url_builder.build()
329 }
330
331 fn build_github_access_data_url() -> String {
332 let mut url_builder = URLBuilder::new();
333 url_builder
334 .set_protocol("https")
335 .set_host("api.github.com")
336 .add_route("user/emails");
337 url_builder.build()
338 }
339
340 async fn do_signup(args: SignupArgs) -> Result<()> {
341 let signup_params = SignupParams {
342 user: User {
343 email: args.username,
344 password: args.password,
345 },
346 };
347
348 let response = Client::new()
349 .post([BASE_URL, "/v1/users"].join(""))
350 .json(&signup_params)
351 .send()
352 .await?;
353
354 match response.status() {
355 StatusCode::OK => {}
356 StatusCode::UNPROCESSABLE_ENTITY => {
357 let result: SignupResult = response.json().await?;
358 let error = anyhow!("Failed to signup: {}", result.status.message);
359 return Err(error);
360 }
361 _ => {
362 let error = anyhow!("Failed to signup: {}", response.status());
363 return Err(error);
364 }
365 }
366
367 Ok(())
368 }