UNPKG

peanut-stash

Version:

Collaborative command line cloud Stash, Share, Copy & Paste tool.

498 lines (402 loc) 18.5 kB
import { getDatabase, ref, child, get, set, update, onValue, push } from 'firebase/database'; import { getAuth, createUserWithEmailAndPassword, sendEmailVerification, signInWithEmailAndPassword, updatePassword, signInWithCustomToken, onAuthStateChanged } from "firebase/auth"; import color from 'picocolors'; import {read} from 'read'; import fs from 'fs'; import path from 'path'; import os from 'os'; import machinePkg from 'node-machine-id'; const {machineIdSync} = machinePkg; import * as prompts from '@clack/prompts' import crypto from 'crypto'; import { encryptDataSymmetrical, decryptDataSymmetrical, isValidEmail, fetchJsonAPI } from './utilities.js'; // Login the user, create any missling files/db nodes on first login export async function loginUser(email, auth , db) { if (!isValidEmail(email)) { console.log(`${color.red('Error:')} Invalid email`); process.exit(1); } try { var password = await read({prompt: 'Enter your password: ', silent: true, replace: '*'}); if (password.length == 0) { console.log(`${color.yellow("Error: Empty text")}`); process.exit(0); } } catch(error) { if (error == "Error: canceled") console.log(`${color.yellow("Cancelled")}`); else console.log(`${color.yellow(error)}`); process.exit(0); } const s = prompts.spinner(); s.start('Logging in user...'); // spinner signInWithEmailAndPassword(auth, email, password) .then(async (userCredential) => { // Check if user is verified by email first if (!userCredential.user.emailVerified) { console.log(`\n${color.red('Error:')} User email not verified`); process.exit(1); } // Save session data to local disk to avoid re-sign in every time the app is started // Encrypt the email and password using unique machine id const sessionJson = { email: encryptDataSymmetrical(email, machineIdSync().slice(0, 32)), password: encryptDataSymmetrical(password, machineIdSync().slice(0, 32)) } const dataString = JSON.stringify(sessionJson); // Define the path to the hidden folder in the user's home directory const hiddenFolderPath = path.join(os.homedir(), '.peanuts'); // Ensure the hidden folder exists, or create it if it doesn't if (!fs.existsSync(hiddenFolderPath)) { fs.mkdirSync(hiddenFolderPath); } // Define the path to the authentication token file const sessionFilePath = path.join(hiddenFolderPath, 'session.json'); // Save to the authentication token file fs.writeFileSync(sessionFilePath, dataString, 'utf8', { flag: 'w' }); // check if there is an existing in firebase for this user under /profiles/profileId // holding basic user account info // first, make the email compatible with firebase // replacing all . with _ const firebase_email = email.replace(/\./g, '_'); // Get a read once snapshot of the database at that path // I prefer the await async syntax of resolving multiple promises to the .then syntax in this case // as we don't need the code to be fully async, and we gain the legibility of code without nesting const db = getDatabase(); // Get the firebase user reference const userRef = ref(db, 'users/' + firebase_email); const userRefPrivate = ref(db, 'users/' + firebase_email+'/private'); // We are using the user's email as parent node instead of UID because we are not using server code // and we want other client users to be able to find the public information of this user // based on their email (to add them and send peanut texts to them) const snapshot = await get(userRefPrivate); // If it doesn't exist, this is the first time they log in, add the needed data and save them if (!snapshot.exists()) { // Create the user's encryption keys, no passphrase, 2k mod len should be enough // pem export format for easy string read/write const { publicKey, privateKey} = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', // could be pkcs1 format: 'pem', }, privateKeyEncoding: { type: 'pkcs8', // could be pkcs1 format: 'pem', }, }); try { // Set user public user data in user node path (uid, email, public key) await set(child(userRef,'public'), { uid: userCredential.user.uid, email: userCredential.user.email, publicKey: publicKey, }); // Private info, secure with security rules await set(child(userRef,'private'), { uid: userCredential.user.uid, privateKey: privateKey, }); // TODO: Figure how/where to add teams in the future when working on this feature // await set(child(userRef,'private/teams'), { // dummy: 'default', // }); console.log(`${color.green('\nSuccess:')} User logged in. No need to login again for this account.`); console.log('You can now stash, pop, manage terminal commands, ask Ai and share with other users!'); console.log('Run "pnut" to see list of arguments. Main ones are "stash" and "list".\n'); s.stop(); process.exit(0); } catch (error) { console.error(`${color.red('\nError:')} Failed to add user or contact:`, error); s.stop(); process.exit(1); } } else { s.stop(); console.log(`${color.green('Success:')} User logged in`); console.log('You can now stash, pop, manage terminal commands, ask Ai and share with other users!'); console.log('Run "pnut" to see list of arguments. Main ones are "stash" and "list".\n'); process.exit(0); } }) .catch((error) => { const errorMessage = error.message; console.log(`\n${color.red('Login Error:')} ${errorMessage}`); s.stop(); process.exit(1); }); } // Register new user export async function registerUser(email, auth) { // Is email valid if (!isValidEmail(email)) { console.log(`${color.red('Error:')} Invalid email`); process.exit(1); } // Get the password interactively from user input // we dont want to pass the password as a parameter as this could cause a security issue // in the terminal command history while(true) { try { var password0 = await read({ prompt: 'Enter your password: ', silent: true, replace: '*'}); var password = await read({prompt: 'Enter your password again: ', silent: true, replace: '*'}); if (password0.length == 0 || password.length == 0) { console.log(`${color.yellow("Error: Empty text")}`); continue; } } catch(error) { if (error == "Error: canceled") console.log(`${color.yellow("Cancelled")}`); else console.log(`${color.yellow(error)}`); process.exit(1); } if (password0 != password) { console.log(`${color.red('Error:')} Passwords do not match`); continue; } // make sure password is at least 8 characters long and has at least one number, one uppercase letter, and one lowercase letter, and one special character if (password.length < 8) { console.log(`${color.red('Error:')} Password must be at least 8 characters long`); continue; } if (!/[a-z]/.test(password) || !/[A-Z]/.test(password) || !/[0-9]/.test(password) || !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) { console.log(`${color.red('Error:')} Password must contain at least one number, one uppercase letter, one lowercase letter, and one special character`); continue; } break; } const s = prompts.spinner() s.start('Creating user...') // register new user on firebase auth based on provided email and password createUserWithEmailAndPassword(auth, email, password).then(() => { console.log(`\n${color.green('Success:')} User created`); s.start('Sending email...\n') // send verification email const auth = getAuth(); // get auth instance after registration sendEmailVerification(auth.currentUser) .then(() => { console.log(`\n${color.green('Success:')} Verification email sent`); console.log(`\n Login with "pnut login ${email}" after you verify\n`); s.stop() process.exit(0); }); }).catch((error) => { console.error(`\n${color.red('Error creating user: ')} ${error.code}`); process.exit(1); }); } // Sign out user export function logoutUser() { // Delete cached session data, on next app start using will be signed out // Rememeber we currently re-sign in the user on each app invocation so there is // no need to 'sign them off' in the traditional sense, as they get signed off each time // the app terminates and there is no custom sdk token used either for login or logout const hiddenFolderPath = path.join(os.homedir(), '.peanuts'); const authFilePath = path.join(hiddenFolderPath, 'session.json'); // check if cached auth json file exists if (fs.existsSync(authFilePath)) { // delete it fs.unlinkSync(authFilePath); console.log(`\n${color.green('Success:')} User logged out`); process.exit(0); } } // Reset password, requires the user to be already login export async function resetPassword(user) { // Compare new password with old password try { var password_original = await read ({prompt: 'Enter your current password: ', silent: true, replace: '*' }); if (password_original.length == 0) { console.log(`${color.yellow("Error: Empty text")}`); process.exit(0); } } catch(error) { if (error == "Error: canceled") console.log(`${color.yellow("Cancelled")}`); else console.log(`${color.yellow(error)}`); process.exit(0); } const hiddenFolderPath = path.join(os.homedir(), '.peanuts'); const authFilePath = path.join(hiddenFolderPath, 'session.json'); if (fs.existsSync(authFilePath)) { const sessionData = fs.readFileSync(authFilePath, 'utf8'); const sessionJson = JSON.parse(sessionData); const password_saved = decryptDataSymmetrical(sessionJson.password, machineIdSync().slice(0, 32)); if (password_original != password_saved) { console.log(`${color.red('Error:')} Incorrect password`); process.exit(1); } // Get new password interactively twice try { var newPassword0 = await read({ prompt: 'Enter your new password: ', silent: true, replace: '*' }); var newPassword1 = await read({prompt: 'Enter your new password again: ', silent: true, replace: '*' }); if (newPassword1.length == 0 || newPassword0.length == 0) { console.log(`${color.yellow("Error: Empty text")}`); process.exit(0); } } catch(error) { if (error == "Error: canceled") console.log(`${color.yellow("Cancelled")}`); else console.log(`${color.yellow(error)}`); process.exit(0); } if (newPassword0 != newPassword1) { console.log(`${color.red('Error:')} Passwords do not match`); process.exit(1); } //Check password complexity if (!/[a-z]/.test(newPassword0) || !/[A-Z]/.test(newPassword0) || !/[0-9]/.test(newPassword0) || !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(newPassword0)) { console.log(`${color.red('Error:')} Password must contain at least one number, one uppercase letter, one lowercase letter, and one special character`); process.exit(1); } // Update password updatePassword(user, newPassword0).then(() => { console.log(`\n${color.green('Success:')} Password updated`); console.log('\n Login again with your new password'); // delete cached auth json file fs.unlinkSync(authFilePath); process.exit(0); }).catch((error) => { console.error(`\n${color.red('Error updating password: ')} ${error.code}`); process.exit(1); }); } else { console.log(`${color.red('Error:')} User not logged in`); process.exit(1); } } // Manage users (list/add/remove) // only users you add manually can send you shared peanut text export async function manageUsers(user, db) { const userEmail = user.email; const firebase_email = userEmail.replace(/\./g, '_'); while(true) { // for the security rules to apply easily (no child nesting) // the contacts must all be props with the email as key (. replace by _) const contactsRef = ref(db, `users/${firebase_email}/private/contacts`); let snapshot = await get(contactsRef); try { let promptList = []; if (snapshot.exists()) { // Load all the propertie keys under snapshot into an array // Convert snapshot key name into an array of values const propArray = Object.keys(snapshot.val()); propArray.forEach((prop) => { promptList.push({ label: prop.replace(/\_/g, '.'), value: "DAT:" + prop }); }) // sort promptList alphabetically promptList.sort((a, b) => a.label.localeCompare(b.label)); } promptList.push({ label: `${color.cyan('Add')}`, value: "ADD:USER" }); promptList.push({ label: `${color.yellow('Cancel')}`, value: "CNL:USER" }); let answer_user = await prompts.select({ message: 'Select a User', options: promptList }); if (prompts.isCancel(answer_user)) { console.log(color.yellow("Cancelled")); process.exit(0); } if (answer_user == "CNL:USER") { console.log(`\n${color.yellow('Cancelled')}`); process.exit(0); } // add option was selected else if (answer_user == "ADD:USER") { try { var user = await read({ prompt: color.cyan("Add user's email:\n")}); if (user.length == 0) { console.log(`${color.yellow("Error: Empty text")}`); continue; } } catch(error) { if (error == "Error: canceled") console.log(`${color.yellow("Cancelled")}`); else console.log(`${color.yellow(error)}`); process.exit(0); } if (!isValidEmail(user)) { console.log(`${color.red('Error:')} Invalid email`); continue; } // add user as a prop to the contacts ref else { // make the email firebase compatibly user = user.replace(/\./g, '_'); await update(contactsRef, { [user]: "true" }); console.log(`\n${color.green('Success:')} User added. Make sure they create an account and add you to be able to share with you.`); continue; } } // a user was selected else { // remove prefix and get action answer_user = answer_user.slice(4); let answer_action = await prompts.select({ message: 'Action', options: [ {value: 'cancel' , label: color.yellow('Cancel')}, {value: 'delete' , label: color.red('# Delete #')} ] }); if (prompts.isCancel(answer_action)) { console.log(color.yellow("Cancelled")); process.exit(0); } if (answer_action == 'cancel') { console.log(`\n Action Canceled`); continue; } else if (answer_action == 'delete') { // Confirmation prompt for deletion const shouldDelete = await prompts.confirm({ message: 'Are you Sure?', }) if (prompts.isCancel(shouldDelete)) { console.log(color.yellow("Cancelled")); process.exit(0); } if (shouldDelete){ // remove the property from the contacts ref await update(contactsRef, { [answer_user]: null }) } console.log(`\n${color.green('Success:')} User deleted`); continue; } } } catch(error) { console.error(`\n${color.red('Error Loading Contacts:')} ${error.code}`); process.exit(1); } } }