Welcome to Analytics
- This is a Twitter analytics application which let you have insights about public profiles
+ This is a Twitter analytics application which let you analyze public profiles.
diff --git a/.gitignore b/.gitignore index aac10d8..a06ee67 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,5 @@ credentials.txt *.log *.sql *.sqlite + +.DS_Store diff --git a/controllers/contact.js b/controllers/contact.js index bb991d6..09e5319 100644 --- a/controllers/contact.js +++ b/controllers/contact.js @@ -1,14 +1,15 @@ const recaptcha = require('../utils/recaptcha') const sendEmail = require('../utils/sendEmail') +// render contact page const getContact = async (req,res,next) =>{ if(!req.session.username || !req.session.email) res.render('pages/contact',{contact:true}) else res.render('pages/contact',{contact:true,logout:true}) - } +// post message to webmaster const postContact = async (req,res,next) =>{ console.log("Contact: new message received."); @@ -39,5 +40,4 @@ const postContact = async (req,res,next) =>{ }) } - module.exports = {getContact,postContact} \ No newline at end of file diff --git a/controllers/dashboard.js b/controllers/dashboard.js index 098b3e8..bc22d8e 100644 --- a/controllers/dashboard.js +++ b/controllers/dashboard.js @@ -1,3 +1,4 @@ +// redirecting to history page const getDashboard = async (req,res,next) =>{ res.redirect('/dashboard/history') } diff --git a/controllers/forgot_psw.js b/controllers/forgot_psw.js index 8cd3bd8..ff48415 100644 --- a/controllers/forgot_psw.js +++ b/controllers/forgot_psw.js @@ -3,12 +3,12 @@ const jwt = require('jsonwebtoken') const sendEmail = require('../utils/sendEmail') const User = require('../models/User') +// token expires in 30 minutes const JWT_EXP = '30m' -// FIXME change the email address - +// sends password reset link const postEmail = async (req,res,next) => { - console.log("psw forgot request recieved"); + console.log("Forgot Psw: request recieved"); if(req.body.email){ Auth.findOne({email:req.body.email},async (err,auth)=>{ @@ -47,7 +47,6 @@ const postEmail = async (req,res,next) => { }else{ return res.sendStatus(500) } - } module.exports = {postEmail} \ No newline at end of file diff --git a/controllers/history.js b/controllers/history.js index 9c2c3d6..ce884fd 100644 --- a/controllers/history.js +++ b/controllers/history.js @@ -2,24 +2,26 @@ const Auth = require('../models/Auth') const User = require('../models/User') const SearchResults = require('../models/SearchResults') +// return search records of given email const getSearchedResults = async (email) => { let user = await Auth.findOne({email:email}) let searched = await User.findOne({_id:user._id},'searched') searched = searched.searched - const search_ids = [] + const results = [] const projections = `_id date name user_img username start_date end_date followings followers total_tweets total.count`; await Promise.all( searched.map( async (id) => { let temp = await SearchResults.findById(id,projections) - search_ids.push(temp) + results.push(temp) }) ) - return search_ids + return results } +// render history page const getHistory = async (req,res,next) => { let results = await getSearchedResults(req.session.email) diff --git a/controllers/login.js b/controllers/login.js index 9a2a31f..8b796bf 100644 --- a/controllers/login.js +++ b/controllers/login.js @@ -1,10 +1,13 @@ const Auth = require('../models/Auth') -const bcrypt = require('bcrypt') - const User = require('../models/User') -const SessionDuration = 1000 * 60 * 60 * 60 + +const bcrypt = require('bcrypt') const md5 = require('md5') +// Remember me session duration +const SessionDuration = 1000 * 60 * 60 * 48 // ms s m h 48hours + +// login process const login = async (req,res,next) => { const {email, password, remember} = req.body console.log("Login: new request received for:",email); diff --git a/controllers/profile.js b/controllers/profile.js index 1faafe3..46fc015 100644 --- a/controllers/profile.js +++ b/controllers/profile.js @@ -4,8 +4,8 @@ const User = require("../models/User") const SearchResults = require('../models/SearchResults') const recaptcha = require('../utils/recaptcha') +// render profile page const getProfile = async (req,res,next) => { - res.render('pages/profile',{ logout:true, username:req.session.username, @@ -16,6 +16,7 @@ const getProfile = async (req,res,next) => { }) } +// update password const updateProfile = async (req,res,next) => { console.log("Profile: update psw request received."); @@ -51,7 +52,6 @@ const deleteProfile = async (req,res,next) => { if(req.session.email){ Auth.findOne({email:req.session.email},async function(err,auth){ - console.log(req.body.password); await bcrypt.compare(req.body.password,auth.password).then(async (check)=>{ if(!check) return res.json({error:"Credentials are not valid"}) @@ -80,7 +80,6 @@ const deleteProfile = async (req,res,next) => { User.findByIdAndRemove(auth._id).exec((err,data)=>{ if(err) { return res.json({error:"Profile delete: User error"}) - } console.log("pass: delete user"); }) diff --git a/controllers/reset_psw.js b/controllers/reset_psw.js index 638c781..1721243 100644 --- a/controllers/reset_psw.js +++ b/controllers/reset_psw.js @@ -5,6 +5,7 @@ const recaptcha = require('../utils/recaptcha') const sendEmail = require('../utils/sendEmail') const JWT_EXP = '30m' +// render reset password page const getReset = async (req,res,next) => { const {email,token} = req.params @@ -27,15 +28,14 @@ const getReset = async (req,res,next) => { } - +// resetting password const putReset = async (req,res,next) =>{ - console.log("reset request recieved"); + console.log("Reset Psw: request recieved"); const {email,password,password_confirm} = req.body let catpcha = await recaptcha(req.body['g-recaptcha-response']) if(!catpcha) return res.json({error:"Invalid captcha!"}) - if(!email || !password || !password_confirm) return res.json({error:"Invalid request"}) if(password !== password_confirm) return res.json({error:"Passwords don't match"}) diff --git a/controllers/results.js b/controllers/results.js index 06b0acd..f357c8e 100644 --- a/controllers/results.js +++ b/controllers/results.js @@ -1,10 +1,11 @@ const mongoose = require('mongoose') + const Auth = require('../models/Auth') const User = require('../models/User') const SearchResults = require('../models/SearchResults') +// returns true if id belongs to this user async function validateID(id,email){ - if(!mongoose.isValidObjectId(id)) return false let user_id = await Auth.findOne({email:email}) @@ -17,7 +18,7 @@ async function validateID(id,email){ return true } - +// sends search record data as json obj const getResults = async (req,res,next) => { let email = req.session.email @@ -30,13 +31,11 @@ const getResults = async (req,res,next) => { return res.json({error:"Invalid id"}) let result = await SearchResults.findById(req.query.id) - return res.json(result) }else{ console.log("Results: new compare request received."); - if(!req.query.id.length) return res.json({error:"Must provide two ids"}) - if(req.query.id.length != 2) return res.json({error:"Must provide two ids"}) + if(!req.query.id.length || req.query.id.length != 2) return res.json({error:"Must provide two ids"}) if(!(await validateID(req.query.id[0],email) && await validateID(req.query.id[1],email))) @@ -50,28 +49,27 @@ const getResults = async (req,res,next) => { } } - +// removes/deletes the given search record from this user const removeResult = async (req,res,next) => { console.log("Results: delete request received."); if(!req.body.id) return res.json({error:"Missing id"}) - if(! await validateID(req.body.id,req.session.email)) return res.json({error:"Invalid id"}) + // Validating ObjectID + if(! await validateID(req.body.id,req.session.email)) + return res.json({error:"Invalid id"}) + // Removing search record from DB SearchResults.findByIdAndRemove(req.body.id).exec(function(err,item){ - if(!item)res.json({error:"Search record not found"}) + if(!item) return res.json({error:"Search record not found"}) Auth.findOne({email:req.session.email},async (err,auth)=>{ - if(!auth) return res.json({error:"Invalid request"}) User.updateOne({_id:auth._id},{$pull : {searched:req.body.id}},(err,user)=>{ if(err) return res.json({error:"User search record not found"}) + return res.sendStatus(200) }) - }) - }) - - return res.sendStatus(200) } module.exports = {getResults,removeResult} \ No newline at end of file diff --git a/controllers/search.js b/controllers/search.js index fdea591..5a8227c 100644 --- a/controllers/search.js +++ b/controllers/search.js @@ -1,3 +1,4 @@ +// render search page const getSearch = async (req,res,next) => { res.render('pages/search',{ diff --git a/controllers/signup.js b/controllers/signup.js index 8efd8aa..cf9cd14 100644 --- a/controllers/signup.js +++ b/controllers/signup.js @@ -1,15 +1,17 @@ -const Auth = require('../models/Auth') -const User = require('../models/User') const bcrypt = require('bcrypt') const mongoose = require('mongoose') const recaptcha = require('../utils/recaptcha') const sendEmail = require('../utils/sendEmail') +const Auth = require('../models/Auth') +const User = require('../models/User') +// creating a new user (making a new db record) const createUser = async (req,res,next) => { console.log("Signup: request recieved"); - + // console.log(req.body); if( !req.body.email || !req.body.password || + !req.body.password_confirm || !req.body.name || !req.body.terms) return res.json({error:"Missing fields"}) @@ -49,8 +51,8 @@ const createUser = async (req,res,next) => { html:`
You've successfully created an account!
-Email ${req.body.email}
-Password ${req.body.password}
+Email: ${req.body.email}
+Password: ${req.body.password}
` } @@ -64,6 +66,7 @@ const createUser = async (req,res,next) => { }) } +// render signup page const signupPage = async (req,res) => { if(!req.session.username || !req.session.email) res.render('pages/singup',{signup:true}) diff --git a/controllers/twitter.js b/controllers/twitter.js index 1d364f1..f98282d 100644 --- a/controllers/twitter.js +++ b/controllers/twitter.js @@ -8,8 +8,10 @@ const User = require('../models/User') const Auth = require('../models/Auth') const recaptcha = require('../utils/recaptcha') +const DEBUG = false const filename = './data.json' +// tweet fields const tweet_fields = [ 'attachments', 'author_id', @@ -49,6 +51,7 @@ const tweet_user_fields = [ "withheld" ] +// Twitter API search function const search = async (ID) => { res = await app.search(`from:${ID}`,{ 'tweet.fields':tweet_fields, @@ -58,10 +61,12 @@ const search = async (ID) => { let DATA = await res.fetchLast() if(DEBUG){ - return new Promise( (resolve,reject) => fs.writeFile(filename,JSON.stringify(DATA), (err)=>{ - if(err) reject(err) - resolve(data_process(DATA)) - })) + return new Promise( (resolve,reject) => { + // Writing received data to a file + fs.writeFile(filename,JSON.stringify(DATA), (err)=>{ + resolve(data_process(DATA)) + }) + }) }else{ return new Promise( (resolve,reject) => { resolve(data_process(DATA)) @@ -70,6 +75,7 @@ const search = async (ID) => { } // verify the search limit of an user +// return true if limit is exceeded const searchLimit = async (auth)=>{ let user = await User.findById(auth._id) @@ -81,16 +87,17 @@ const searchLimit = async (auth)=>{ return false } +// handles the twitter search request and sends data as json obj const postTwitter = async(req,res,next) => { + // recaptcha validation let catpcha = await recaptcha(req.body['g-recaptcha-response']) if(!catpcha) return res.json({error:"Invalid captcha!"}) - //user search limit validation - let auth = await Auth.findOne({email:req.session.email}) if(!auth) return res.json({error:"Twitter: Invalid session"}) + //user search limit validation let limit = await searchLimit(auth) console.log("Twitter: limit",limit); @@ -101,6 +108,7 @@ const postTwitter = async(req,res,next) => { const user = await app.userByUsername(req.body.handler,{"user.fields":tweet_user_fields}); if(user?.errors) return res.json({error:`Invalid user`}) + if(DEBUG) console.log("Twitter: request for ->",user.data) @@ -110,6 +118,7 @@ const postTwitter = async(req,res,next) => { if(DEBUG) fs.writeFileSync("./output_metions.json",JSON.stringify(mentions)) + // executing search for given twitter account search(req.body.handler) .then( async (data,err) => { if(err) return res.json({error:"Twitter search: "+err.message}) @@ -129,10 +138,12 @@ const postTwitter = async(req,res,next) => { // Limit display console.log("Twitter Limit:",data.limit.limit,data.limit.remaining,data.limit.reset) + // updating db console.log('Twitter: creating search results') await SearchResults.create(data) await User.findOneAndUpdate({_id:auth._id},{$push : {searched:data._id}}) + // deleting object id from data delete(data._id) if(DEBUG){ diff --git a/db/connect.js b/db/connect.js index a303066..f4b870f 100644 --- a/db/connect.js +++ b/db/connect.js @@ -1,15 +1,9 @@ const mongoose = require('mongoose') mongoose.set('strictQuery', true) +// Connecting to MongoDB const connectDB = (db_connection_url) => { return mongoose.connect(db_connection_url).then(()=>console.log('OK: successfully connected to db')).catch((e)=>console.log(e) ) } - -module.exports = connectDB - -// useNewUrlParser :true, -// useCreateIndex: true, -// useFindAndModify:false, -// useUnifiedTopology: true - \ No newline at end of file +module.exports = connectDB \ No newline at end of file diff --git a/db/db_params.js b/db/db_params.js index 675c331..3d6cda1 100644 --- a/db/db_params.js +++ b/db/db_params.js @@ -5,9 +5,7 @@ const username = process.env.Db_Username const cluster = process.env.Db_Cluster const db_name = process.env.Db_Name -const connectionString = +const connectionString = `mongodb+srv://${username}:${password}@${cluster}/${db_name}?retryWrites=true&w=majority` - - module.exports = connectionString \ No newline at end of file diff --git a/middleware/auth.js b/middleware/auth.js deleted file mode 100644 index 506838d..0000000 --- a/middleware/auth.js +++ /dev/null @@ -1,21 +0,0 @@ -const jwt = require('jsonwebtoken') -const {createError} = require('../errors/customError') - -const auth = async (req,res,next) => { - const authHeader = req.headers?.authorization - - if(!authHeader || !authHeader.startsWith('Bear ')) - return next(createError(400,"No valid token")) - - const token = authHeader && authHeader.split(' ')[1] - - if(token == null) return next(createError(400,'Unauthorized')) - - jwt.verify(token,process.env.Server_Secret,(err,ok)=>{ - //if(err) return next(createError(400,'Credentials are not valid')) - req.user = ok - next() - }) -} - -module.exports = auth \ No newline at end of file diff --git a/middleware/auth_session.js b/middleware/auth_session.js index 340488c..ec359bc 100644 --- a/middleware/auth_session.js +++ b/middleware/auth_session.js @@ -1,6 +1,8 @@ + +// session validation const auth_session = async (req,res,next)=>{ if(req.session.username && req.session.email){ - console.log('Auth user:',req.session.email) + //console.log('Auth user:',req.session.email) next() }else{ req.session.destroy() diff --git a/middleware/error_handler.js b/middleware/error_handler.js index 4be4af3..f6d57f1 100644 --- a/middleware/error_handler.js +++ b/middleware/error_handler.js @@ -1,9 +1,11 @@ const {CustomError} = require('../errors/customError') +// sending errors as json obj +// FIXME verify const error_handler = (err,req,res,next) => { - console.log(err) + console.error("ERROR: ",err) if(err instanceof CustomError) return res.status(err.status_code).send(`${err.message}`) - return res.status(500).json(JSON.stringify({error:`${err.message}`})) + return res.status(500).json({error:`${err.message}`}) } diff --git a/middleware/not_found.js b/middleware/not_found.js index a35f71d..355f8e4 100644 --- a/middleware/not_found.js +++ b/middleware/not_found.js @@ -1,3 +1,4 @@ +// 404 message const not_found = (req,res) => { res.status(404).send("404 - These are uncharted waters...") } diff --git a/models/Auth.js b/models/Auth.js index c91a8c5..27da733 100644 --- a/models/Auth.js +++ b/models/Auth.js @@ -1,5 +1,6 @@ const mongoose = require('mongoose') +// Schema for Auth const AuthSchema = new mongoose.Schema({ _id:{type:mongoose.Schema.Types.ObjectId}, email: { diff --git a/models/SearchResults.js b/models/SearchResults.js index a06ebfa..6b580df 100644 --- a/models/SearchResults.js +++ b/models/SearchResults.js @@ -1,5 +1,6 @@ const mongoose = require('mongoose') +//Schema for search record const SeachResultsSchema = new mongoose.Schema({ _id:{type:mongoose.Schema.Types.ObjectId}, date : {type:Date,required:true}, @@ -28,7 +29,7 @@ const SeachResultsSchema = new mongoose.Schema({ quote_count:{type:Number,default:0}, impression_count:{type:Number,default:0} } - }, // original posts + }, video : { count : {type:Number,default:0}, interval : {type:Number,default:0}, @@ -39,7 +40,7 @@ const SeachResultsSchema = new mongoose.Schema({ quote_count:{type:Number,default:0}, impression_count:{type:Number,default:0} } - }, // original posts + }, photo : { count : {type:Number,default:0}, interval : {type:Number,default:0}, @@ -50,7 +51,7 @@ const SeachResultsSchema = new mongoose.Schema({ quote_count:{type:Number,default:0}, impression_count:{type:Number,default:0} } - }, // original posts + }, link : { count : {type:Number,default:0}, interval : {type:Number,default:0}, @@ -61,7 +62,7 @@ const SeachResultsSchema = new mongoose.Schema({ quote_count:{type:Number,default:0}, impression_count:{type:Number,default:0} } - }, // original posts + }, polls : { count : {type:Number,default:0}, interval : {type:Number,default:0}, @@ -72,7 +73,7 @@ const SeachResultsSchema = new mongoose.Schema({ quote_count:{type:Number,default:0}, impression_count:{type:Number,default:0} } - }, // original posts + }, animated_gif : { count : {type:Number,default:0}, interval : {type:Number,default:0}, @@ -83,7 +84,7 @@ const SeachResultsSchema = new mongoose.Schema({ quote_count:{type:Number,default:0}, impression_count:{type:Number,default:0} } - } // gif tweets count + } }, highlights : { retweet_count : { @@ -97,11 +98,11 @@ const SeachResultsSchema = new mongoose.Schema({ like_count : { id : {type:String,default:""}, count : {type:String}, - }, + },// tweet with most likes quote_count : { id : {type:String,default:""}, count : {type:String}, - }, // tweet with most likes + }, // tweet with most quotes impression_count : { id : {type:String,default:""}, count : {type:String}, @@ -161,6 +162,4 @@ const SeachResultsSchema = new mongoose.Schema({ }) -let counts - module.exports = mongoose.model('SearchResults',SeachResultsSchema) \ No newline at end of file diff --git a/models/User.js b/models/User.js index 7a9ac49..2c191c0 100644 --- a/models/User.js +++ b/models/User.js @@ -1,5 +1,6 @@ const mongoose = require('mongoose') +// Schema for User const UserSchema = new mongoose.Schema({ _id:{ type: mongoose.Schema.Types.ObjectId, ref : 'Auth'}, name: {type:String,required:true}, diff --git a/package-lock.json b/package-lock.json index 0987f4d..52fc6d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,15 @@ { "name": "twitter_analytics", - "version": "0.0.1", + "version": "1.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "twitter_analytics", - "version": "0.0.1", + "version": "0.7.0", "license": "ISC", "dependencies": { "bcrypt": "^5.1.0", - "bootstrap-icons": "^1.10.3", "connect-mongo": "^3.2.0", "cookie-parser": "^1.4.6", "ejs": "^3.1.8", @@ -29,7 +28,7 @@ }, "devDependencies": { "dotenv": "^16.0.3", - "nodemon": "^2.0.20" + "nodemon": "^2.0.22" } }, "node_modules/@aws-crypto/ie11-detection": { @@ -1329,11 +1328,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/bootstrap-icons": { - "version": "1.10.4", - "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.10.4.tgz", - "integrity": "sha512-eI3HyIUmpGKRiRv15FCZccV+2sreGE2NnmH8mtxV/nPOzQVu0sPEj8HhF1MwjJ31IhjF0rgMvtYOX5VqIzcb/A==" - }, "node_modules/bowser": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", @@ -7168,11 +7162,6 @@ "unpipe": "1.0.0" } }, - "bootstrap-icons": { - "version": "1.10.4", - "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.10.4.tgz", - "integrity": "sha512-eI3HyIUmpGKRiRv15FCZccV+2sreGE2NnmH8mtxV/nPOzQVu0sPEj8HhF1MwjJ31IhjF0rgMvtYOX5VqIzcb/A==" - }, "bowser": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", diff --git a/package.json b/package.json index 772cf25..0d99943 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "twitter_analytics", - "version": "0.7.0", + "version": "1.0.0", "description": "pwm-project-2022", "main": "index.js", "scripts": { @@ -45,6 +45,6 @@ }, "devDependencies": { "dotenv": "^16.0.3", - "nodemon": "^2.0.20" + "nodemon": "^2.0.22" } } diff --git a/public/assets/loader.gif b/public/assets/loader.gif deleted file mode 100644 index 1e59b55..0000000 Binary files a/public/assets/loader.gif and /dev/null differ diff --git a/public/assets/working.gif b/public/assets/working.gif deleted file mode 100644 index 5af5a17..0000000 Binary files a/public/assets/working.gif and /dev/null differ diff --git a/public/assets/working_backup.gif b/public/assets/working_backup.gif deleted file mode 100644 index 5487e4b..0000000 Binary files a/public/assets/working_backup.gif and /dev/null differ diff --git a/public/css/style.css b/public/css/style.css index d6e8961..381bd21 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -134,11 +134,9 @@ footer #footer-2{ } footer #footer-2{ - border-right:none; - border-left:none; - border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color); - border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) -} - - + border-right:none; + border-left:none; + border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color); + border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) + } } diff --git a/public/js/scripts.js b/public/js/scripts.js index d18aa24..39b6588 100644 --- a/public/js/scripts.js +++ b/public/js/scripts.js @@ -20,16 +20,29 @@ const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]') const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)) +// Chart colors const colors_1 = ['#6a4c93','#1982c4','#8ac926','#ffca3a','#ff595e','#778da9'] const colors_2 = ['#26547c','#ef476f','#ffd166','#06d6a0','#FAA307','#7d8597'] const colors_3 = ['#33a8c7','#52e3e1','#a0e426','#00a878','#f77976','#d883ff','#d883ff','#147DF5','#9336fd','#BE0AFF'] -// ! chartsjs plugins +// set theme on page load +document.getElementsByTagName('html')[0].setAttribute("data-bs-theme",localStorage.getItem("theme") || 'light') + +if(document.getElementsByTagName('html')[0].getAttribute('data-bs-theme')=='light'){ + document.querySelector('#dark-mode i').classList.add('bi-sun-fill') +}else{ + document.querySelector('#dark-mode i').classList.add('bi-moon-stars-fill') +} + +// ! registring chartsjs plugins Chart.register(ChartDataLabels) +// web worker for sending ajax requests function postViaWorker(data,method,url){ return new Promise((resolve,reject) => { + let worker = new Worker('/js/worker.js') + worker.postMessage({data:data,method:method,url:url}) worker.onmessage = (event) => { resolve(event.data) @@ -37,14 +50,7 @@ function postViaWorker(data,method,url){ }) } -// set theme on page load -document.getElementsByTagName('html')[0].setAttribute("data-bs-theme",localStorage.getItem("theme") || 'light') -if(document.getElementsByTagName('html')[0].getAttribute('data-bs-theme')=='light'){ - document.querySelector('#dark-mode i').classList.add('bi-sun-fill') -}else{ - document.querySelector('#dark-mode i').classList.add('bi-moon-stars-fill') -} - +// email validation with regex const validateEmail = (email) => { return String(email) .toLowerCase() @@ -53,10 +59,11 @@ const validateEmail = (email) => { ); }; - +// sleep/waiting function const sleep = ms => new Promise(r => setTimeout(r,ms)) -async function loadFromCache(url,id){ +// Caching previous search record requests +/* async function loadFromCache(url,id){ let data if(localStorage.getItem("dataCache-"+id)){ data = JSON.parse(localStorage.getItem("dataCache-"+id)) @@ -66,8 +73,9 @@ async function loadFromCache(url,id){ } return data -} +} */ +// g-captcha callback enable function enableBtns(){ let buttons = document.getElementsByClassName('disable-btn') @@ -77,6 +85,7 @@ function enableBtns(){ }) } +// g-captcha callback disable function disableBtns(){ let buttons = document.getElementsByClassName('disable-btn') @@ -87,6 +96,7 @@ function disableBtns(){ $(document).ready(async function(){ + // dark-mode click activate function $('#dark-mode').click(function(){ if($('html').attr('data-bs-theme') == 'light'){ @@ -114,14 +124,20 @@ $(document).ready(async function(){ $('i',this).addClass('bi-sun-fill').removeClass('bi-moon-stars-fill') }) - // forgot psw + // Show Modal $('.show-modal-btn').click(function(){ let modal = $(this).attr('data-modal') $('#'+modal).modal('show') }) - $('.form-submit').click(async function(event){ + // reset checkboxes on page ready + $('.form-check-input:checked').each(function(i,e){ + $(this).click() + }) + // Submit form via web worker + $('.form-submit').click(async function(event){ + event.preventDefault() const form_id = $(this).attr('data-form') const form = $('#'+form_id) if(!form) return errorDisplay({error:"Frontend error: Form must be specified"}) @@ -138,7 +154,8 @@ $(document).ready(async function(){ const email = form.find('input.email') - if(!validateEmail(email[0].value)){ + if(email.length==1 && !validateEmail(email[0].value)){ + console.log("email failed"); $(email[0]).parent().find('.invalid-feedback').show() return } @@ -152,18 +169,15 @@ $(document).ready(async function(){ const input_confirm = psw_confirm[0] if(input.value !== input_confirm.value) { + console.log("psw failed"); input_confirm.setCustomValidity("Passwords don't match") $(input_confirm).parent().find('.invalid-feedback').show() - return } input_confirm.setCustomValidity("") $(input_confirm).parent().find('.invalid-feedback').hide() } - event.preventDefault() - event.stopPropagation() - const method = form.attr('method') if(!method) return errorDisplay({error:"Must specify method in form"}) @@ -176,17 +190,10 @@ $(document).ready(async function(){ if(data.error || data.success) return errorDisplay(data) - - // if(data.redirect) - // return window.location.href = data.redirect - }) - // reset checkboxes on page ready - $('.form-check-input:checked').each(function(i,e){ - $(this).click() - }) + // Display Error/Success message from URL Params function errorParamDisplay(){ const params = new URLSearchParams(document.location.search) @@ -197,10 +204,10 @@ $(document).ready(async function(){ if(params.get('success')){ errorDisplay({success:params.get('success')}) } + var newURL = location.href.split("?")[0]; window.history.pushState('object', document.title, newURL); } - errorParamDisplay() // ! error display function function errorDisplay(data){ @@ -221,9 +228,7 @@ $(document).ready(async function(){ $('#error-modal').modal('show') } - - - // delete a seached result + // removing deleted search record $('.close-icon').click(async function(){ if(!confirm("Are you sure you want to delete this record?")) return @@ -241,7 +246,7 @@ $(document).ready(async function(){ }) - // show detailed clicked result + // show detailed view of clicked search record $('.searched-entry').click(async function(){ let id = $(this).attr('id') let url = '/results?id=' @@ -251,7 +256,7 @@ $(document).ready(async function(){ $('#results-modal').removeClass('d-none').modal('toggle') $('#loader #spinner').removeClass('d-none') - let data = await loadFromCache(url,id) + let data = await postViaWorker(null,'GET',url+id) await sleep(500) $('#loader #spinner').addClass('d-none') @@ -261,7 +266,7 @@ $(document).ready(async function(){ }) - // search history close button icon hovering effect + // search history close button / icon hovering effect $('.close-icon').hover(function(){ $(this).removeClass('bi-x-square') $(this).addClass('bi-x-square-fill') @@ -275,7 +280,7 @@ $(document).ready(async function(){ $('.modal').modal('hide') }) - // show detailed comparing results + // show detailed view of comparing results $('.compare-btn').click(async function(event){ const id_1 = $('.form-check-input:checked').attr('data-id') const id_2 = $(this).parent().find('.form-check-input').attr('data-id') @@ -286,8 +291,8 @@ $(document).ready(async function(){ $('#results-modal').removeClass('d-none').modal('toggle') $('#loader #spinner').removeClass('d-none') - let data_1 = await loadFromCache(url,id_1) - let data_2 = await loadFromCache(url,id_2) + let data_1 = await postViaWorker(null,'GET',url+id_1) + let data_2 = await postViaWorker(null,'GET',url+id_2) await sleep(500) $('#loader #spinner').addClass('d-none') @@ -295,10 +300,8 @@ $(document).ready(async function(){ await genSearchResults([data_1,data_2]) }) - // disable checkboxes of other usernames + // disables checkboxes of other usernames // and re-enable checkboxes if there is no checkboxes checked - - //FIXME to test with more results per username $('.form-check-input').click(function(event){ let username = $(this).attr('data-username') @@ -332,6 +335,7 @@ $(document).ready(async function(){ $('#search-btn').prop("disabled",true) let data = await postViaWorker($('#form-search').serialize(),'POST','/twitter') + // let data = await fetch('../js/output_data_compare.json').then( response => { // return response.json() // }) @@ -343,14 +347,31 @@ $(document).ready(async function(){ $('#search-btn').removeAttr("disabled") + disableBtns() grecaptcha.reset() }) + // resets the search results area progress bar + function resetProgressBar() { + let id = "#loader #progress-bar " + $(id+' .progress-bar').removeClass(function (index, className) { + return (className.match(new RegExp("\\S*w-\\S*", 'g')) || []).join(' ') + }) + } + + // update the search results area progress bar + function updateProgressBar(n){ + let id = "#loader #progress-bar " + $(id+' .progress-bar').addClass('w-'+n) + } + + // resetting/cleaning search results area function cleanResults(){ $('#results #user-info #user-profile img').remove() $('#results .data-clean').each((i,obj) => $(obj).empty()) $('#results .node-clean').each((i,obj) => $(obj).remove()) } + // ! populate with seach results + charts async function genSearchResults(data){ if(data.error) return errorDisplay(data) @@ -373,8 +394,10 @@ $(document).ready(async function(){ } + // building search area async function build(INPUT){ + // set chart colors for according to theme if(localStorage.getItem('theme') == 'dark'){ Chart.defaults.color = "#bbb" Chart.defaults.borderColor = "rgba(256, 255, 255, 0.1)" @@ -605,8 +628,6 @@ $(document).ready(async function(){ } - - const load_media_type_Chart = async () => { let id = "tweets-by-media-type" let labels = ["Text","Video","Photo","Link","Poll","Gif"] @@ -706,8 +727,11 @@ $(document).ready(async function(){ await load_avg_media_type() updateProgressBar(100) + + sleep(200) } + // generate/append subtypes of MEDIA_TYPE or TYPE json objs function load_subTypes(id,data,compare,dn,cn){ for(let type of Object.keys(data)){ let sub_id = id+"-"+type @@ -719,6 +743,7 @@ $(document).ready(async function(){ } } + // generate language chart async function load_langs_chart(id,data,compare){ if(compare){ await pieCharts(data,compare,id,"Tweets by languages",colors_3) @@ -727,6 +752,7 @@ $(document).ready(async function(){ } } + // append new element () to given list function build_ulElement(id,type){ let sub_id = id+'-'+type @@ -742,6 +768,8 @@ $(document).ready(async function(){ $('#'+id).append(a) } + // insert apply round function to data and compare and insert it to given id + // requires: round function : (a) => b function load_element(id,data,compare,title,round){ let rounded = round(data) @@ -753,6 +781,7 @@ $(document).ready(async function(){ buildCompare(id,data,compare,title,round) } + // builds Average area of MEDIA_TYPE or TYPE function load_avg(id,data,compare){ id = '#results #'+id let count_id = id+'-count' @@ -777,17 +806,22 @@ $(document).ready(async function(){ } + // if compare value is present appends an icon and the difference between comparing value, + // the value is rounded by the given round function + // if type is NOT equal to 'interval' it will set data-round and data-real + // requires: round function : (a) => b function buildCompare(id,data,compare,title,round,type = undefined){ if(!compare) return if(data == compare) return let diff = Number((data - compare).toFixed(3)) - if(type === 'interval') - console.log(diff); + // if(type === 'interval') + // console.log(diff); let rounded = round(diff) + appendDiffIcon(id,diff) id += '-compare' @@ -800,6 +834,7 @@ $(document).ready(async function(){ $(id).append(rounded) } + // append comparing icon: if diff is negative red arrow pointing downwards otherwise green upwards function appendDiffIcon(id,diff){ if(diff == 0) return @@ -819,13 +854,15 @@ $(document).ready(async function(){ $(id).after(icon) } + // calcuate the engament of tweet account within the given data + // Formula: (Likes + Retweets + Quotes + Replies) divided by the number of tweets, + // then by the total number of followers, then multiplied by 100. function engagement(input){ if(!input) return undefined let result = 0 let metrics = input.total.metrics - for(let type of Object.keys(metrics)){ if(type == 'impression_count') continue result += metrics[type] @@ -839,18 +876,7 @@ $(document).ready(async function(){ } - function resetProgressBar() { - let id = "#loader #progress-bar " - $(id+' .progress-bar').removeClass(function (index, className) { - return (className.match(new RegExp("\\S*w-\\S*", 'g')) || []).join(' ') - }) - } - - function updateProgressBar(n){ - let id = "#loader #progress-bar " - $(id+' .progress-bar').addClass('w-'+n) - } - + // appends date-time of search record function load_dateTime(id,data){ let date = data.date $(id+" #search-date .date").text(date.slice(0,10)) @@ -872,6 +898,7 @@ $(document).ready(async function(){ $(id+" #sample span").text(data.total.count) } + // extracts and returns languages and counts from given json obj function extractLangs(langs) { let labels = [] let dataset = [] @@ -895,6 +922,8 @@ $(document).ready(async function(){ return {data:dataset,labels:labels} } + + // extract and returns counts of given json obj function extractCounts(data){ let dataset = [] Object.keys(data).forEach( key => { @@ -903,6 +932,7 @@ $(document).ready(async function(){ return dataset } + // draw pie charts async function pieCharts(data_1,data_2,id,title,colors,chartType = 'pie'){ @@ -969,6 +999,7 @@ $(document).ready(async function(){ }) } + // builds dataset element required by chartsjs dataset obj function toDataset(data,label,color,stack){ let struct = {} struct["data"] = data @@ -979,6 +1010,8 @@ $(document).ready(async function(){ return struct } + // extracts media and type and their respective counts + // output is an array of dataset obj of chartsjs function extractMediaAndType(data,colors_1,colors_2){ let labels = Object.keys(data) let count = 0 @@ -1024,6 +1057,7 @@ $(document).ready(async function(){ return output } + // returns total of tweets per day function extractDailyTotal(data){ let labels = Object.keys(data) @@ -1041,6 +1075,7 @@ $(document).ready(async function(){ return output } + // generates daily chart async function dailyChartByType(datasets,labels,id,title){ let canvas = document.createElement("canvas") canvas.id = id+"-chart" @@ -1088,6 +1123,7 @@ $(document).ready(async function(){ } + // gengerates top 10 mentions/hashtags charts async function top10Chart(data,id,title){ let canvas = document.createElement("canvas") @@ -1136,6 +1172,7 @@ $(document).ready(async function(){ }) } + // generates daily line chart async function lineChart(data_1,labels,data_2,id,type,title){ let canvas = document.createElement("canvas") canvas.id = type+"-chart" @@ -1187,6 +1224,9 @@ $(document).ready(async function(){ }) } + // returns the plural of given string *_count + // required string formatted like: something_count + // supported words are: retweet_count, reply_count, impression_count, like_count, quote_count function countToPlural(key){ if(key == 'reply_count') return "Replies" @@ -1194,6 +1234,8 @@ $(document).ready(async function(){ return key.charAt(0).toUpperCase()+key.slice(1,-6)+"s" } + // set hover to elements which have the class name data-hover + // requires: element must have data-real and data-round attibutes function set_data_hover(){ $(document).on("mouseover ",'.data-hover',function(){ $(this).text($(this).attr("data-real")) @@ -1225,10 +1267,16 @@ $(document).ready(async function(){ } + // return string with letter converted to uppercase function toUpperFirstChar(str){ return str.charAt(0).toUpperCase()+str.slice(1) } + // returns the string obtained by converting the given number in days, hours, minuts, seconds + // example: 10D 3H 23m 45s + // example: 3H 23m 45s if H == 0 + // example: 23m 45s if D and H == 0 + // example: 45s if D and H and m == 0 function msToHMS(e){ e = Math.abs(e) let s = e/1000; @@ -1246,6 +1294,11 @@ $(document).ready(async function(){ return Math.floor(d) +"D "+ Math.floor(h)+"H "+Math.floor(m)+"m "+Math.floor(s)+"s" } + + // round the given number if >=million with 2 decimals + // if < million and >= 1000 with 3 decimals + // example: 140_683 -> 140.68K + // example: 140_600_456 -> 140.6M function tweetCount(count){ if (count / 1000000 >= 1 || count / 1000000 <= -1) return Number((count / 1000000).toFixed(2)) + "M" @@ -1256,13 +1309,19 @@ $(document).ready(async function(){ return Number(count) } + // appends the given string to "https://twitter.com/" + // twitter account link function buildTwitterUrl(user){ return "https://twitter.com/"+user } + // appends the given string to "https://twitter.com/status/" + // twitter post link function buildTwitterPostUrl(user,id){ return buildTwitterUrl(user)+"/status/"+id } + // calling functions + errorParamDisplay() set_data_hover() }) \ No newline at end of file diff --git a/public/js/worker.js b/public/js/worker.js index 0991cac..93a6c75 100644 --- a/public/js/worker.js +++ b/public/js/worker.js @@ -1,10 +1,12 @@ + +// worker for sending ajax requests self.onmessage = (event) => { - console.log("Worker received msg",event.data) + // console.log("Worker received msg",event.data) let method = event.data.method let url = event.data.url let data = event.data.data - console.log(data); var xhr = new XMLHttpRequest + xhr.open(method,url) xhr.responseType = 'json' xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); diff --git a/server.js b/server.js index fc1c84e..a895cd7 100644 --- a/server.js +++ b/server.js @@ -1,9 +1,9 @@ require('dotenv').config() -require('express-async-errors') const express = require('express') const app = express() -//const cookie_signature = require('cookie-signature') +// ===================== Middleware +require('express-async-errors') const cookie_parser = require('cookie-parser') const express_session = require('express-session') const genid = require('genid') @@ -24,11 +24,12 @@ const twitter = require('./routes/twitter') const results = require('./routes/results') const forgot_psw = require('./routes/forgot_psw') const reset_psw = require('./routes/reset_psw') +const contact = require('./routes/contact') + +// ========= Custom Middleware const auth_session = require('./middleware/auth_session') const not_found = require('./middleware/not_found') const error_handler = require('./middleware/error_handler') -const contact = require('./routes/contact') - // ===================== Port const PORT = process.env.PORT || 3000 @@ -73,21 +74,6 @@ app.use(helmet({ crossOriginOpenerPolicy: {policy:"same-origin"}, })) - -// app.use((req, res, next) => { -// // res.setHeader('Access-Control-Allow-Origin', '*'); -// // res.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content, Accept, Content-Type, Authorization'); -// // res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS'); -// res.setHeader('Cross-origin-Embedder-Policy', 'same-origin'); -// res.setHeader('Cross-origin-Opener-Policy','same-origin'); - -// if (req.method === 'OPTIONS') { -// res.sendStatus(200) -// } else { -// next() -// } -// }); - app.use(express.static(__dirname + '/public')) app.use(express.json()) app.use(express.urlencoded({ extended: false })) @@ -120,7 +106,8 @@ app.set('view engine','ejs') // ===================== Handling requests app.get('/',function(req,res){ - if(!req.session.username || !req.session.email) return res.render('pages/index') + if(!req.session.username || !req.session.email) + return res.render('pages/index') else return res.redirect('/dashboard') }) @@ -131,7 +118,6 @@ app.get('/about',function(req,res){ res.render('pages/about',{about:true,logout:true}) }) -// app.use(/^\/dashboard.*/,auth_session,dashboard) app.use('/dashboard',auth_session,dashboard) app.use('/twitter',auth_session,twitter) @@ -165,7 +151,8 @@ app.use(error_handler) const start = async (connection_url) => { try{ await db_connect(connection_url) - app.listen(PORT,()=> console.log('OK: Server is up and running at port:3000 ')) + app.listen(PORT, + ()=> console.log('OK: Server is up and running at port',PORT)) }catch(err){ console.log(`Error: ${err}`) } diff --git a/twitter.js b/twitter.js index d89b408..3643a43 100644 --- a/twitter.js +++ b/twitter.js @@ -1,13 +1,5 @@ const {TwitterApi} = require('twitter-api-v2') -const fs = require('fs') require('dotenv').config() -/* -const app = new TwitterApi({ - appKey : process.env.Api_Key, - appSecret : process.env.Api_Secret_Key, - accessToken : process.env.Access_Token, - accessSecret : process.env.Access_Token_Secret -}) -*/ + const twitter = new TwitterApi(process.env.Bearer_Token) module.exports = twitter.readOnly diff --git a/utils/data_process.js b/utils/data_process.js index 5186165..a6d8eb7 100644 --- a/utils/data_process.js +++ b/utils/data_process.js @@ -1,6 +1,7 @@ const fs = require('fs') const pathToJsonObj = "./tweets_data.json" +// Parse data received from Twitter API and returns a JSON obj const collectData = async (DATA) => { let jsonObj = fs.readFileSync(require.resolve(pathToJsonObj),"utf-8") @@ -29,7 +30,6 @@ const collectData = async (DATA) => { temp_date.setDate(temp_end.getDate()-7) temp_end.setDate(temp_end.getDate()) - // console.log(temp_date,temp_end); TWEETS.interval = temp_end.getTime() - temp_date.getTime() while(temp_date<=temp_end){ @@ -60,9 +60,11 @@ const collectData = async (DATA) => { // collect metioned users const mentioned_users = {} + // collect used hashtags const hashtags = {} + // collect used langs const lang = {} data.forEach(e => { @@ -98,21 +100,16 @@ const collectData = async (DATA) => { tweets_per_day[date].media[mediaType]++ tweets_per_day[date].type[type]++ - if(type != "retweeted"){ - //updateInterval(TWEETS.media_type[mediaType],time,interval_start_time) updateMetrics(TWEETS.media_type,mediaType,e) - updateMetrics(TWEETS.type,type,e) updateMetricsTotal(TWEETS,e) - //updateInterval(TWEETS.type[type],time,interval_start_time) } // count total posts and interval between tweets updateInterval(TWEETS.total,time,interval_start_time) - // console.log(e.id,mediaType,type); }); - // console.log(hashtags_count); + TWEETS.tweets_per_day = tweets_per_day TWEETS.mentioned_users = mentioned_users TWEETS.hashtags = hashtags @@ -121,6 +118,7 @@ const collectData = async (DATA) => { return TWEETS } +// returns the Type of the given tweet data function findTweetType(e){ let type = undefined if(e?.referenced_tweets){ @@ -131,6 +129,8 @@ function findTweetType(e){ } return type } + +// returns the Media Type of the given tweet data function findMediaType(media,e){ let mediaType = undefined if(media && e.attachments?.media_keys){ @@ -145,6 +145,7 @@ function findMediaType(media,e){ return mediaType } + // calculate intervals by dividing the accumulated interval by count for each subtype // requires TWEETS data type function avgInterval(data,interval){ @@ -153,14 +154,19 @@ function avgInterval(data,interval){ }) delete data.last } + +// calculate the average interval of total tweets function avgIntervalTotal(data,interval){ data.interval = calcAvg(data.count,interval) delete data.last } + +// returns 0 if count == 0 otherwise returns sum / count function calcAvg(count,sum) { if(count == 0) return 0 return Math.floor(sum/count) } + // accumulate public metrics for each type function updateMetrics(data,type,e){ Object.keys(data[type].metrics).forEach(key => { @@ -168,6 +174,7 @@ function updateMetrics(data,type,e){ }) } +// update metrics for total tweets function updateMetricsTotal(data,e){ let total = data.total.metrics Object.keys(total).forEach(key => { @@ -177,6 +184,7 @@ function updateMetricsTotal(data,e){ } // compare current post with new post for most retweets, replies, likes, impressions +// updates if this tweet metrics >= old metrics update otherwise not function updateHighlights(data,key,e){ if(data.highlights[key].count == 0) data.highlights[key] = {id:e.id,count:e.public_metrics[key]} @@ -184,7 +192,7 @@ function updateHighlights(data,key,e){ data.highlights[key] = {id:e.id,count:e.public_metrics[key]} } -// accumulate interval for the given type +// update interval for the given type function updateInterval(data,time,interval_start_time){ if(data.last == 0){ data.interval = time - interval_start_time @@ -195,14 +203,14 @@ function updateInterval(data,time,interval_start_time){ } // converts from miliseconds to H:m:s -function msToHMS(e) { +/* function msToHMS(e) { let s = e/1000; let m = s/60; s = s%60; let h = m/60; m = m%60; return [Math.floor(h),Math.floor(m),Math.floor(s)] -} +} */ const process_data = (async (DATA) => { @@ -216,16 +224,16 @@ const process_data = (async (DATA) => { return data }) -// async function test(){ -// let FILE = fs.readFileSync('../data.json') -// let data = await collectData(JSON.parse(FILE)).then( data => { -// avgInterval(data.media_type,data.interval) -// avgInterval(data.type,data.interval) -// avgIntervalTotal(data.total,data.interval) -// return data -// }) -// console.log(data); -// } +/* async function test(){ + let FILE = fs.readFileSync('../data.json') + let data = await collectData(JSON.parse(FILE)).then( data => { + avgInterval(data.media_type,data.interval) + avgInterval(data.type,data.interval) + avgIntervalTotal(data.total,data.interval) + return data + }) + console.log(data); +} */ // test() diff --git a/utils/recaptcha.js b/utils/recaptcha.js index f00fa82..52bf0dd 100644 --- a/utils/recaptcha.js +++ b/utils/recaptcha.js @@ -1,6 +1,7 @@ const fetch = require('node-fetch') - +// recaptcher validation +// returns true if captcha string is valid const recaptcha = async(captcha)=>{ if(!captcha || captcha == '') return false diff --git a/utils/required_params.js b/utils/required_params.js deleted file mode 100644 index 49a45a0..0000000 --- a/utils/required_params.js +++ /dev/null @@ -1,10 +0,0 @@ -const requireParams = (params,req) => { - const reqParamList = Object.keys(req.body) - const hasAllRequiredParams = params.every(param => - reqParamList.includes(param) - ) - return hasAllRequiredParams -} - - -module.exports = requireParams \ No newline at end of file diff --git a/utils/sendEmail.js b/utils/sendEmail.js index 8f0a8b9..6555771 100644 --- a/utils/sendEmail.js +++ b/utils/sendEmail.js @@ -1,5 +1,7 @@ const nodemailer = require('nodemailer') +// sends email using nodemailer +// requires: mail_option object module.exports = async function (mail_opt){ return new Promise((resolve,reject)=>{ const transporter = nodemailer.createTransport({ diff --git a/views/pages/about.ejs b/views/pages/about.ejs index a64711e..41227f6 100644 --- a/views/pages/about.ejs +++ b/views/pages/about.ejs @@ -10,16 +10,16 @@Hello welcome to Twitter Analytics App.
This app let you analyze twitter accounts' recent activity and compare them.
Analized data will be stored and let you compare them in a second moment.
-You can keep upto 10 search records
+You can keep upto 10 search records.
Some of the possible metrics are:
Powered by TWITTER REST API v2.
diff --git a/views/pages/contact.ejs b/views/pages/contact.ejs index 06c3be7..aba0769 100644 --- a/views/pages/contact.ejs +++ b/views/pages/contact.ejs @@ -14,7 +14,7 @@
Welcome to Analytics
- This is a Twitter analytics application which let you have insights about public profiles
+ This is a Twitter analytics application which let you analyze public profiles.