18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env
|
||||
|
||||
/.idea
|
||||
|
||||
/server/logs
|
||||
|
||||
/build
|
||||
|
||||
package-lock.json
|
||||
11
package.json
11
package.json
@@ -14,17 +14,23 @@
|
||||
"@testing-library/jest-dom": "^5.16.4",
|
||||
"@testing-library/react": "^13.1.1",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/csurf": "^1.11.5",
|
||||
"@types/jest": "^27.4.1",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/node": "^16.11.27",
|
||||
"@types/react": "^18.0.5",
|
||||
"@types/react-dom": "^18.0.1",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@wbr-project/wbr-interpret": "^0.9.3-marketa.1",
|
||||
"axios": "^0.26.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"buffer": "^6.0.3",
|
||||
"bullmq": "^5.12.15",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"cross-fetch": "^4.0.0",
|
||||
"csurf": "^1.11.0",
|
||||
"dotenv": "^16.0.0",
|
||||
"express": "^4.17.2",
|
||||
"fortawesome": "^0.0.1-security",
|
||||
@@ -32,10 +38,12 @@
|
||||
"googleapis": "^144.0.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"joi": "^17.6.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"loglevel": "^1.8.0",
|
||||
"loglevel-plugin-remote": "^0.6.8",
|
||||
"moment-timezone": "^0.5.45",
|
||||
"node-cron": "^3.0.3",
|
||||
"pg": "^8.13.0",
|
||||
"playwright": "^1.20.1",
|
||||
"playwright-extra": "^4.3.6",
|
||||
"prismjs": "^1.28.0",
|
||||
@@ -47,6 +55,8 @@
|
||||
"react-scripts": "5.0.1",
|
||||
"react-simple-code-editor": "^0.11.2",
|
||||
"react-transition-group": "^4.4.2",
|
||||
"sequelize": "^6.37.3",
|
||||
"sequelize-typescript": "^2.1.6",
|
||||
"socket.io": "^4.4.1",
|
||||
"socket.io-client": "^4.4.1",
|
||||
"styled-components": "^5.3.3",
|
||||
@@ -85,6 +95,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/loglevel": "^1.6.3",
|
||||
"@types/node": "^17.0.15",
|
||||
|
||||
36
server/src/db/config.ts
Normal file
36
server/src/db/config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Sequelize } from 'sequelize';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const sequelize = new Sequelize(
|
||||
process.env.DB_NAME as string,
|
||||
process.env.DB_USER as string,
|
||||
process.env.DB_PASSWORD as string,
|
||||
{
|
||||
host: process.env.DB_HOST,
|
||||
dialect: 'postgres',
|
||||
logging: false,
|
||||
}
|
||||
);
|
||||
|
||||
export const connectDB = async () => {
|
||||
try {
|
||||
await sequelize.authenticate();
|
||||
console.log('Database connected successfully');
|
||||
} catch (error) {
|
||||
console.error('Unable to connect to the database:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const syncDB = async () => {
|
||||
try {
|
||||
await sequelize.sync({ force: false }); // force: true will drop and recreate tables on every run
|
||||
console.log('Database synced successfully!');
|
||||
} catch (error) {
|
||||
console.error('Failed to sync database:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export default sequelize;
|
||||
29
server/src/middlewares/auth.ts
Normal file
29
server/src/middlewares/auth.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Request, Response } from "express";
|
||||
import { verify } from "jsonwebtoken";
|
||||
|
||||
declare module "express-serve-static-core" {
|
||||
interface Request {
|
||||
user?: any;
|
||||
}
|
||||
}
|
||||
|
||||
export const requireSignIn = (req: Request, res: Response, next: any) => {
|
||||
const token = req.cookies && req.cookies.token ? req.cookies.token : null;
|
||||
|
||||
if (token === null) return res.sendStatus(401);
|
||||
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) {
|
||||
return res.sendStatus(500); // Internal Server Error if secret is not defined
|
||||
}
|
||||
|
||||
verify(token, secret, (err: any, user: any) => {
|
||||
console.log(err)
|
||||
|
||||
if (err) return res.sendStatus(403)
|
||||
|
||||
req.user = user;
|
||||
|
||||
next()
|
||||
})
|
||||
};
|
||||
57
server/src/models/User.ts
Normal file
57
server/src/models/User.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import sequelize from '../db/config';
|
||||
import { hashPassword, comparePassword } from '../utils/auth';
|
||||
|
||||
interface UserAttributes {
|
||||
id: number;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// Optional fields for creating a new user
|
||||
interface UserCreationAttributes extends Optional<UserAttributes, 'id'> { }
|
||||
|
||||
class User extends Model<UserAttributes, UserCreationAttributes> implements UserAttributes {
|
||||
public id!: number;
|
||||
public email!: string;
|
||||
public password!: string;
|
||||
|
||||
public async isValidPassword(password: string): Promise<boolean> {
|
||||
return comparePassword(password, this.password);
|
||||
}
|
||||
}
|
||||
|
||||
User.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER.UNSIGNED,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
},
|
||||
email: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
validate: {
|
||||
isEmail: true,
|
||||
},
|
||||
},
|
||||
password: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'user',
|
||||
hooks: {
|
||||
beforeCreate: async (user: User) => {
|
||||
if (user.password) {
|
||||
user.password = await hashPassword(user.password) as string;
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export default User;
|
||||
@@ -1,92 +1,79 @@
|
||||
import { Router } from 'express';;
|
||||
import { google, sheets_v4 } from "googleapis";
|
||||
import { OAuth2Client } from 'google-auth-library'
|
||||
import { Router, Request, Response } from 'express';
|
||||
import User from '../models/User';
|
||||
import jwt from 'jsonwebtoken';
|
||||
export const router = Router();
|
||||
|
||||
export const router = Router()
|
||||
interface AuthenticatedRequest extends Request {
|
||||
user?: { id: string };
|
||||
}
|
||||
|
||||
const oauth2Client = new OAuth2Client(
|
||||
'_CLIENT_ID',
|
||||
'_CLIENT_SECRET',
|
||||
'_REDIRECT_URI'
|
||||
);
|
||||
|
||||
// initialize Google OAuth 2.0 flow
|
||||
router.get('/auth/google', (req, res) => {
|
||||
const url = oauth2Client.generateAuthUrl({
|
||||
access_type: 'offline',
|
||||
scope: [
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/spreadsheets'
|
||||
]
|
||||
});
|
||||
res.redirect(url);
|
||||
});
|
||||
|
||||
// Callback route for Google OAuth 2.0
|
||||
router.get('/auth/google/callback', async (req, res) => {
|
||||
const code = req.query.code;
|
||||
if (typeof code !== 'string') {
|
||||
res.status(400).send('Invalid authorization code');
|
||||
return;
|
||||
}
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { tokens } = await oauth2Client.getToken(code);
|
||||
oauth2Client.setCredentials(tokens);
|
||||
// TODO: Store tokens securely (e.g., in a database)
|
||||
res.send('Authentication successful');
|
||||
} catch (error) {
|
||||
console.error('Error during authentication:', error);
|
||||
res.status(500).send('Authentication failed');
|
||||
}
|
||||
});
|
||||
const { email, password } = req.body
|
||||
|
||||
router.get('/sheets', async (req, res) => {
|
||||
if (!email) return res.status(400).send('Email is required')
|
||||
if (!password || password.length < 6) return res.status(400).send('Password is required and must be at least 6 characters')
|
||||
|
||||
let userExist = await User.findOne({ where: { email } });
|
||||
if (userExist) return res.status(400).send('User already exists')
|
||||
|
||||
const user = await User.create({ email, password });
|
||||
|
||||
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET as string, { expiresIn: '1h' });
|
||||
user.password = undefined as unknown as string
|
||||
res.cookie('token', token, {
|
||||
httpOnly: true
|
||||
})
|
||||
res.json(user)
|
||||
} catch (error: any) {
|
||||
res.status(500).send(`Could not register user - ${error.message}`)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const drive = google.drive({ version: 'v3', auth: oauth2Client });
|
||||
const response = await drive.files.list({
|
||||
q: "mimeType='application/vnd.google-apps.spreadsheet'",
|
||||
fields: 'files(id, name)'
|
||||
const { email, password } = req.body;
|
||||
if (!email || !password) return res.status(400).send('Email and password are required')
|
||||
if (password.length < 6) return res.status(400).send('Password must be at least 6 characters')
|
||||
|
||||
let user = await User.findOne({ where: { email } });
|
||||
const match = await user?.isValidPassword(password);
|
||||
if (!match) return res.status(400).send('Invalid email or password')
|
||||
|
||||
const token = jwt.sign({ id: user?.id }, process.env.JWT_SECRET as string, { expiresIn: '1h' });
|
||||
|
||||
// return user and token to client, exclude hashed password
|
||||
if (user) {
|
||||
user.password = undefined as unknown as string;
|
||||
}
|
||||
res.cookie('token', token, {
|
||||
httpOnly: true
|
||||
})
|
||||
res.json(user)
|
||||
} catch (error: any) {
|
||||
res.status(400).send(`Could not login user - ${error.message}`)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/logout', async (req, res) => {
|
||||
try {
|
||||
res.clearCookie('token')
|
||||
return res.json({ message: 'Logout successful' })
|
||||
} catch (error: any) {
|
||||
res.status(500).send(`Could not logout user - ${error.message}`)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/current-user', async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return res.status(401).send('Unauthorized');
|
||||
}
|
||||
const user = await User.findByPk(req.user.id, {
|
||||
attributes: { exclude: ['password'] },
|
||||
});
|
||||
res.json(response.data.files);
|
||||
} catch (error) {
|
||||
console.error('Error listing sheets:', error);
|
||||
res.status(500).send('Failed to list sheets');
|
||||
return res.status(200).json({ ok: true });
|
||||
} catch (error: any) {
|
||||
return res.status(500).send(`Could not fetch current user : ${error.message}.`);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/sheets/:sheetId', async (req, res) => {
|
||||
try {
|
||||
const sheets = google.sheets({ version: 'v4', auth: oauth2Client });
|
||||
const response = await sheets.spreadsheets.values.get({
|
||||
spreadsheetId: req.params.sheetId,
|
||||
range: 'Sheet1', // Adjust range as needed
|
||||
});
|
||||
res.json(response.data.values);
|
||||
} catch (error) {
|
||||
console.error('Error reading sheet:', error);
|
||||
res.status(500).send('Failed to read sheet');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/sheets/:sheetId', async (req, res) => {
|
||||
try {
|
||||
const sheets = google.sheets({ version: 'v4', auth: oauth2Client });
|
||||
|
||||
const request: sheets_v4.Params$Resource$Spreadsheets$Values$Append = {
|
||||
spreadsheetId: req.params.sheetId,
|
||||
range: 'Sheet1', // Adjust range as needed
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
requestBody: {
|
||||
values: [req.body.values], // Expect an array of values in the request body
|
||||
},
|
||||
};
|
||||
|
||||
const response = await sheets.spreadsheets.values.append(request);
|
||||
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error writing to sheet:', error);
|
||||
res.status(500).send('Failed to write to sheet');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,15 @@ import cors from 'cors';
|
||||
import 'dotenv/config';
|
||||
import { record, workflow, storage, auth, integration } from './routes';
|
||||
import { BrowserPool } from "./browser-management/classes/BrowserPool";
|
||||
import logger from './logger'
|
||||
import logger from './logger';
|
||||
import { connectDB, syncDB } from './db/config';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import csrf from 'csurf';
|
||||
import { SERVER_PORT } from "./constants/config";
|
||||
import { Server } from "socket.io";
|
||||
|
||||
const csrfProtection = csrf({ cookie: true })
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
@@ -25,6 +30,10 @@ export const io = new Server(server);
|
||||
*/
|
||||
export const browserPool = new BrowserPool();
|
||||
|
||||
// parse cookies - "cookie" is true in csrfProtection
|
||||
app.use(cookieParser())
|
||||
app.use(csrfProtection)
|
||||
|
||||
app.use('/record', record);
|
||||
app.use('/workflow', workflow);
|
||||
app.use('/storage', storage);
|
||||
@@ -35,4 +44,12 @@ app.get('/', function (req, res) {
|
||||
return res.send('Maxun server started 🚀');
|
||||
});
|
||||
|
||||
server.listen(SERVER_PORT, () => logger.log('info', `Server listening on port ${SERVER_PORT}`));
|
||||
app.get('/csrf-token', (req, res) => {
|
||||
res.json({ csrfToken: req.csrfToken() })
|
||||
})
|
||||
|
||||
server.listen(SERVER_PORT, async () => {
|
||||
await connectDB();
|
||||
await syncDB();
|
||||
logger.log('info', `Server listening on port ${SERVER_PORT}`);
|
||||
});
|
||||
|
||||
22
server/src/utils/auth.ts
Normal file
22
server/src/utils/auth.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import bcrypt from "bcrypt";
|
||||
|
||||
export const hashPassword = (password: any) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
bcrypt.genSalt(12, (err, salt) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
}
|
||||
bcrypt.hash(password, salt, (err, hash) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
}
|
||||
resolve(hash)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// password from frontend and hash from database
|
||||
export const comparePassword = (password: any, hash: any) => {
|
||||
return bcrypt.compare(password, hash)
|
||||
}
|
||||
103
src/context/auth.tsx
Normal file
103
src/context/auth.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useReducer, createContext, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface ProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface ActionType {
|
||||
type: 'LOGIN' | 'LOGOUT';
|
||||
payload?: any;
|
||||
}
|
||||
|
||||
type InitialStateType = {
|
||||
user: any;
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
user: null,
|
||||
};
|
||||
|
||||
const Context = createContext<{
|
||||
state: InitialStateType;
|
||||
dispatch: any;
|
||||
}>({
|
||||
state: initialState,
|
||||
dispatch: () => null,
|
||||
});
|
||||
|
||||
const reducer = (state: InitialStateType, action: ActionType) => {
|
||||
switch (action.type) {
|
||||
case 'LOGIN':
|
||||
return {
|
||||
...state,
|
||||
user: action.payload,
|
||||
};
|
||||
case 'LOGOUT':
|
||||
return {
|
||||
...state,
|
||||
user: null,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const Provider = ({ children }: ProviderProps) => {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// get user info from local storage
|
||||
useEffect(() => {
|
||||
dispatch({
|
||||
type: 'LOGIN',
|
||||
payload: JSON.parse(window.localStorage.getItem('user') || 'null'),
|
||||
});
|
||||
}, []);
|
||||
|
||||
axios.interceptors.response.use(
|
||||
function (response) {
|
||||
// any status code that lies within the range of 2XX causes this function to trigger
|
||||
return response;
|
||||
},
|
||||
function (error) {
|
||||
// any status codes that fall outside the range of 2XX cause this function to trigger
|
||||
let res = error.response;
|
||||
if (res.status === 401 && res.config && !res.config.__isRetryRequest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
axios
|
||||
.get('http://localhost:8080/auth/logout')
|
||||
.then((data) => {
|
||||
console.log('/401 error > logout');
|
||||
dispatch({ type: 'LOGOUT' });
|
||||
window.localStorage.removeItem('user');
|
||||
navigate('/login'); // Replace router.push with navigate
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('AXIOS INTERCEPTORS ERROR:', err);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// csrf - include tokens in the axios header every time a request is made
|
||||
useEffect(() => {
|
||||
const getCsrfToken = async () => {
|
||||
const { data } = await axios.get('http://localhost:8080/csrf-token');
|
||||
console.log('CSRFFFFF =>>>>', data);
|
||||
(axios.defaults.headers as any)['X-CSRF-TOKEN'] = data.getCsrfToken;
|
||||
};
|
||||
getCsrfToken();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Context.Provider value={{ state, dispatch }}>{children}</Context.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export { Context, Provider };
|
||||
Reference in New Issue
Block a user