├── .babelrc ├── .gitignore ├── README.md ├── auth └── passport.js ├── config └── default_example.json ├── cron └── scheduler.js ├── database ├── PostSchema.js ├── Schema.js └── UserSchema.js ├── media └── .gitkeep ├── nodemon.json ├── package-lock.json ├── package.json ├── routes ├── login.js └── scheduler.js ├── server.js ├── src ├── Posts.js ├── Scheduler.js ├── app.js └── app.scss ├── views ├── app.ejs ├── footer.ejs ├── header.ejs ├── login.ejs ├── navbar.ejs └── profile.ejs └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets":[ 3 | "es2015", "react" 4 | ] 5 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Mac OS X 2 | .DS_Store 3 | 4 | # Node.js 5 | node_modules/ 6 | npm-debug.log 7 | 8 | .idea 9 | sessions/ 10 | public/ 11 | certs/ 12 | config/default.json 13 | # exclude everything 14 | media/* 15 | 16 | # exception to the rule 17 | !media/.gitkeep 18 | 19 | ngrok -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node Scheduler for Facebook Pages 2 | 3 | ### Setup Instructions 4 | 5 | ``` 6 | # Clone 7 | $ git clone https://github.com/waleedahmad/NodeScheduler.git 8 | 9 | # Install depdendencies 10 | $ npm install 11 | 12 | # Install nodemon globally 13 | $ npm install -g nodemon 14 | 15 | # Build Frontend code and watch for changes 16 | $ npm run watch 17 | 18 | # App configuration 19 | $ cp ./config/default_example.json ./config/default.json 20 | 21 | # Add configuration to ./config/default.json 22 | # callbackURL must match Facebook app setting 23 | # valid redirect URIs 24 | { 25 | "appId" : "YOUR_APP_KEY", 26 | "appSecret" : "YOUR_APP_SECRET", 27 | "sessionSecret" : "EXPRESS_SESSION_SECRET", 28 | "callbackURL" : "https://127.0.0.1:8888/auth/facebook/callback", 29 | "scheduler_timezone" : "Asia/Karachi" 30 | } 31 | 32 | # Run web server 33 | $ npm run dev-server 34 | ``` 35 | 36 | ![alt text](https://i.imgur.com/kPIHYCZ.png) 37 | 38 | ![alt text](https://i.imgur.com/zeCQXFb.png) 39 | 40 | ![alt text](https://i.imgur.com/dYHuo7t.png) 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /auth/passport.js: -------------------------------------------------------------------------------- 1 | const config = require('config'); 2 | const passport = require('passport'); 3 | const Strategy = require('passport-facebook').Strategy; 4 | const User = require('../database/Schema').User; 5 | passport.use(new Strategy({ 6 | clientID: config.get('appId'), 7 | clientSecret: config.get('appSecret'), 8 | callbackURL: config.get('callbackURL'), 9 | profileFields: ['id', 'displayName', 'link', 'email'] 10 | }, 11 | (accessToken, refreshToken, profile, cb) => { 12 | let query = {facebookID: profile.id}, 13 | update = { 14 | name: profile.displayName, 15 | email: profile.emails[0].value, 16 | accessToken: accessToken 17 | }, 18 | options = {upsert: true, new: true, setDefaultsOnInsert: true}; 19 | User.findOneAndUpdate(query, update, options, function (error, result) { 20 | if (error) return; 21 | return cb(null, profile); 22 | // do something with the document 23 | }); 24 | })); 25 | 26 | 27 | passport.serializeUser(function (user, cb) { 28 | cb(null, user); 29 | }); 30 | 31 | passport.deserializeUser(function (obj, cb) { 32 | cb(null, obj); 33 | }); 34 | 35 | module.exports = passport; -------------------------------------------------------------------------------- /config/default_example.json: -------------------------------------------------------------------------------- 1 | { 2 | "appId" : "YOUR_APP_KEY", 3 | "appSecret" : "YOUR_APP_SECRET", 4 | "sessionSecret" : "EXPRESS_SESSION_SECRET", 5 | "callbackURL" : "https://127.0.0.1:8888/auth/facebook/callback", 6 | "scheduler_timezone" : "Asia/Karachi" 7 | } -------------------------------------------------------------------------------- /cron/scheduler.js: -------------------------------------------------------------------------------- 1 | const CronJob = require('cron').CronJob, 2 | Post = require('../database/Schema').Post, 3 | User = require('../database/Schema').User, 4 | FB = require('fb'), 5 | fs = require('fs'), 6 | path = require('path'), 7 | config = require('config'); 8 | 9 | 10 | module.exports = () => { 11 | new CronJob('* * * * * *', function() { 12 | 13 | let now = Math.floor(new Date().getTime() / 1000); 14 | 15 | let post_query = Post.find({published: false, schedule_time : now}); 16 | 17 | post_query.then(posts => { 18 | publishPosts(posts); 19 | }).catch(err => { 20 | console.log(err); 21 | }); 22 | }, null, true, config.get('scheduler_timezone')); 23 | }; 24 | 25 | const publishPosts = (posts) => { 26 | posts.map(post => { 27 | let user_query = User.findOne({facebookID: post.user_id}); 28 | 29 | user_query.then(user => { 30 | let options = {}; 31 | 32 | if(post.type_of === 'photo'){ 33 | options = Object.assign(options, { 34 | source: fs.createReadStream(path.join(__dirname, '../', post.media)), 35 | caption: post.message, 36 | api_endpoint : post.page_id+'/photos' 37 | }, options); 38 | }else{ 39 | options = Object.assign(options, { 40 | message : post.message, 41 | api_endpoint : post.page_id+'/feed' 42 | }, options); 43 | } 44 | 45 | FB.setAccessToken(user.accessToken); 46 | FB.api('/me/accounts', (pages) => { 47 | 48 | let page = pages.data.filter((page) => { 49 | return page.id === post.page_id 50 | })[0]; 51 | 52 | options = Object.assign(options, { 53 | access_token : page.access_token, 54 | }, options); 55 | publishPost(options, post); 56 | }); 57 | }) 58 | }); 59 | }; 60 | 61 | const publishPost = (options, post) => { 62 | FB.api(options.api_endpoint, 'post', options , (res) => { 63 | if(!res || res.error) { 64 | console.log(!res ? 'error occurred' : res.error); 65 | return; 66 | } 67 | let update = {published : true, publish_id : res.id}, 68 | options = {new: true}; 69 | Post.findByIdAndUpdate(post._id, update, options, (error, result) => { 70 | if (error) return; 71 | console.log(result); 72 | }); 73 | 74 | }); 75 | }; -------------------------------------------------------------------------------- /database/PostSchema.js: -------------------------------------------------------------------------------- 1 | let mongoose = require('mongoose'); 2 | let Schema = mongoose.Schema; 3 | 4 | let PostSchema = new Schema({ 5 | user_id : String, 6 | message : String, 7 | media : String, 8 | schedule_time : String, 9 | page_id : String, 10 | page_name : String, 11 | published : Boolean, 12 | type_of : String, 13 | publish_id : String 14 | }); 15 | 16 | module.exports = PostSchema; -------------------------------------------------------------------------------- /database/Schema.js: -------------------------------------------------------------------------------- 1 | let mongoose = require('mongoose'); 2 | 3 | exports.User = mongoose.model('User', require('./UserSchema')); 4 | exports.Post = mongoose.model('Post', require('./PostSchema')); -------------------------------------------------------------------------------- /database/UserSchema.js: -------------------------------------------------------------------------------- 1 | let mongoose = require('mongoose'); 2 | let Schema = mongoose.Schema; 3 | 4 | let UserSchema = new Schema({ 5 | name: String, 6 | email : String, 7 | facebookID : String, 8 | accessToken : String, 9 | }); 10 | 11 | module.exports = UserSchema; -------------------------------------------------------------------------------- /media/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waleedahmad/NodeScheduler/fe74649be225d354d6de3f2eddad8b9a9f728d12/media/.gitkeep -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": true, 3 | "ignore": ["./src", "./public", "./sessions", "./views", "./media"] 4 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-scheduler", 3 | "version": "0.0.0", 4 | "description": "Facebook post scheduler build with Node.js", 5 | "scripts": { 6 | "dev-server": "nodemon server.js --config nodemon.json", 7 | "watch": "webpack --config webpack.config.js" 8 | }, 9 | "dependencies": { 10 | "axios": "^0.18.0", 11 | "body-parser": "^1.13.2", 12 | "bootstrap": "^4.1.1", 13 | "config": "^1.30.0", 14 | "connect-ensure-login": "^0.1.1", 15 | "cookie-parser": "^1.3.5", 16 | "cron": "^1.3.0", 17 | "ejs": "^2.3.3", 18 | "express": "^4.13.1", 19 | "express-fileupload": "^0.4.0", 20 | "express-session": "^1.11.3", 21 | "fb": "^2.0.0", 22 | "jquery": "^3.3.1", 23 | "moment": "^2.22.2", 24 | "mongoose": "^5.1.4", 25 | "morgan": "^1.6.1", 26 | "passport": "^0.2.2", 27 | "passport-facebook": "^2.0.0", 28 | "path": "^0.12.7", 29 | "popper.js": "^1.14.3", 30 | "random-string": "^0.2.0", 31 | "randomstring": "^1.1.5", 32 | "react": "^16.4.0", 33 | "react-datetime-picker": "^1.3.1", 34 | "react-dom": "^16.4.0", 35 | "toastr": "^2.1.4" 36 | }, 37 | "devDependencies": { 38 | "babel-core": "^6.26.3", 39 | "babel-loader": "^7.1.4", 40 | "babel-preset-es2015": "^6.24.1", 41 | "babel-preset-react": "^6.24.1", 42 | "css-loader": "^0.28.11", 43 | "file-loader": "^1.1.11", 44 | "mini-css-extract-plugin": "^0.4.0", 45 | "node-sass": "^4.9.0", 46 | "sass-loader": "^7.0.3", 47 | "session-file-store": "^1.2.0", 48 | "style-loader": "^0.21.0", 49 | "url-loader": "^1.0.1", 50 | "webpack": "^4.11.1", 51 | "webpack-cli": "^3.0.3" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /routes/login.js: -------------------------------------------------------------------------------- 1 | const express = require('express'), 2 | router = express.Router(), 3 | passport = require('passport'); 4 | 5 | router.get('/', 6 | require('connect-ensure-login').ensureLoggedOut(), 7 | (req, res) => { 8 | res.render('login', {user : null}); 9 | }); 10 | 11 | router.get('/facebook', 12 | require('connect-ensure-login').ensureLoggedOut(), 13 | passport.authenticate('facebook', { 14 | scope: [ 15 | 'email', 16 | 'user_posts', 17 | 'manage_pages', 18 | 'publish_pages' 19 | ] 20 | })); 21 | 22 | router.get('/facebook/callback', 23 | require('connect-ensure-login').ensureLoggedOut(), 24 | passport.authenticate('facebook', {failureRedirect: '/login'}), 25 | (req, res) => { 26 | res.redirect('/'); 27 | }); 28 | 29 | module.exports = router; 30 | 31 | -------------------------------------------------------------------------------- /routes/scheduler.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const router = express.Router(), 3 | randomString = require('random-string'), 4 | User = require('../database/Schema').User, 5 | Post = require('../database/Schema').Post, 6 | FB = require('fb'), 7 | fs = require('fs'), 8 | path = require('path'), 9 | middleware = require('connect-ensure-login'); 10 | 11 | router.get('/pages', 12 | middleware.ensureLoggedIn(), 13 | (req, res) => { 14 | User.findOne({facebookID: req.user.id}, (err, user) => { 15 | if (err) return; 16 | FB.setAccessToken(user.accessToken); 17 | FB.api('/me/accounts', (pages) => { 18 | let data = pages.data.map((page) => { 19 | return { 20 | name : page.name, 21 | id : page.id 22 | } 23 | }); 24 | res.json([...data]); 25 | }); 26 | }); 27 | }); 28 | 29 | router.get('/posts', 30 | middleware.ensureLoggedIn(), 31 | (req, res) => { 32 | console.log(); 33 | Post.find({ 34 | user_id : req.user.id, 35 | published : req.query.published 36 | },null, {sort: {schedule_time: 1}}, (err, posts) => { 37 | res.json(posts); 38 | }); 39 | }); 40 | 41 | router.post('/schedule/publish', 42 | middleware.ensureLoggedIn(), 43 | (req, res) => { 44 | 45 | Post.findById(req.body.id, (err, post) => { 46 | 47 | let options = {}; 48 | 49 | if(post.type_of === 'photo'){ 50 | options = Object.assign(options, { 51 | source: fs.createReadStream(path.join(__dirname, '../', post.media)), 52 | caption: post.message, 53 | api_endpoint : post.page_id+'/photos' 54 | }, options); 55 | }else{ 56 | options = Object.assign(options, { 57 | message : post.message, 58 | api_endpoint : post.page_id+'/feed' 59 | }, options); 60 | } 61 | 62 | User.findOne({facebookID: req.user.id}, (err, user) => { 63 | if (err) return; 64 | FB.setAccessToken(user.accessToken); 65 | FB.api('/me/accounts', (pages) => { 66 | let page = pages.data.filter((page) => { 67 | return page.id === post.page_id 68 | })[0]; 69 | 70 | options = Object.assign(options, { 71 | access_token : page.access_token, 72 | }, options); 73 | publishPost(options, post, res); 74 | }); 75 | }); 76 | 77 | }); 78 | }); 79 | 80 | const publishPost = (options, post, response) => { 81 | FB.api(options.api_endpoint, 'post', options , (res) => { 82 | if(!res || res.error) { 83 | console.log(!res ? 'error occurred' : res.error); 84 | return; 85 | } 86 | let update = {published : true, publish_id : res.id}, 87 | options = {new: true}; 88 | Post.findByIdAndUpdate(post._id, update, options, function (error, result) { 89 | if (error) return; 90 | response.json({ 91 | published : true 92 | }) 93 | }); 94 | 95 | }); 96 | }; 97 | 98 | 99 | router.delete('/schedule', 100 | middleware.ensureLoggedIn(), 101 | (req, res) => { 102 | Post.findByIdAndRemove(req.query.id, (err, post) => { 103 | if(post.type_of === 'photo'){ 104 | fs.unlink(path.join(__dirname, '../', post.media), (err) => { 105 | if(err) return console.log(err); 106 | res.json({done : true}) 107 | }); 108 | }else{ 109 | res.json({done : true}) 110 | } 111 | }); 112 | }); 113 | 114 | 115 | router.post('/schedule', 116 | middleware.ensureLoggedIn(), 117 | (req, res) => { 118 | let file = req.files ? req.files.file : null, 119 | message = req.body.message, 120 | date = req.body.date, 121 | type = !file ? 'text' : 'photo', 122 | page_id = req.body.page_id, 123 | page_name = req.body.page_name, 124 | options = {new:true, safe: true, upsert: true}, 125 | uri = !file ? '' : '/media/'+ randomString({length: 20}) + '.' + file.name.split('.').pop(), 126 | post = { 127 | user_id : req.user.id, 128 | message : message, 129 | media : uri, 130 | schedule_time : date, 131 | published : false, 132 | page_id : page_id, 133 | page_name : page_name, 134 | type_of : type, 135 | }, 136 | query = {facebookID : req.user.id}; 137 | 138 | if(file){ 139 | let storage_path = path.join(__dirname, '../', uri); 140 | file.mv(storage_path, (err) => { 141 | if (err){ 142 | res.json(err); 143 | }else{ 144 | createPost(query, options, post, res); 145 | } 146 | }); 147 | }else{ 148 | createPost(query, options, post, res); 149 | } 150 | }); 151 | 152 | const createPost = (query, options, post, res) => { 153 | let new_post = new Post(post); 154 | new_post.save(function (err, doc) { 155 | if (err) res.json({error : err}); 156 | res.json({ 157 | error : false, 158 | post : doc 159 | }); 160 | }); 161 | }; 162 | 163 | const removeMedia = (id) => { 164 | 165 | }; 166 | 167 | module.exports = router; -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'), 2 | https = require('https'), 3 | express = require('express'), 4 | Session = require('express-session'), 5 | FileStore = require('session-file-store')(Session), 6 | fileUpload = require('express-fileupload'), 7 | bodyParse = require('body-parser'), 8 | passport = require('./auth/passport'), 9 | mongoose = require('mongoose'), 10 | FB = require('fb'), 11 | config = require('config'), 12 | middleware = require('connect-ensure-login'), 13 | scheduler = require('./cron/scheduler'), 14 | options = { 15 | key: fs.readFileSync(__dirname + '/certs/selfsigned.key'), 16 | cert: fs.readFileSync(__dirname + '/certs/selfsigned.crt'), 17 | }, 18 | port = 8888; 19 | 20 | FB.options({ 21 | appId: config.get('appId'), 22 | appSecret: config.get('appSecret') 23 | }); 24 | 25 | const app = express(); 26 | mongoose.connect('mongodb://127.0.0.1/nodeFB'); 27 | 28 | app.set('views', __dirname + '/views'); 29 | app.set('view engine', 'ejs'); 30 | app.use('/src', express.static(__dirname + '/public')); 31 | app.use('/media', express.static(__dirname + '/media')); 32 | 33 | app.use(fileUpload()); 34 | 35 | app.use(require('morgan')('combined')); 36 | app.use(require('cookie-parser')()); 37 | app.use(bodyParse.urlencoded({extended: true})); 38 | app.use(bodyParse.json()); 39 | app.use(Session({ 40 | store: new FileStore(), 41 | secret: config.get('sessionSecret'), 42 | resave: true, 43 | saveUninitialized: true 44 | })); 45 | 46 | app.use(passport.initialize()); 47 | app.use(passport.session()); 48 | 49 | app.use('/', require('./routes/scheduler')); 50 | app.use('/login', require('./routes/login')); 51 | 52 | app.get('/', 53 | function (req, res) { 54 | res.render('app', {user: req.user}); 55 | }); 56 | 57 | app.get('/logout', 58 | middleware.ensureLoggedIn(), 59 | (req, res) => { 60 | req.session.destroy(); 61 | res.redirect('/'); 62 | }); 63 | 64 | app.get('*', 65 | middleware.ensureLoggedIn(), 66 | function (req, res) { 67 | res.render('app', {user: req.user}); 68 | }); 69 | 70 | scheduler(); 71 | 72 | https.createServer(options, app).listen(port, function(){ 73 | console.log("Express server listening on port " + port); 74 | }); 75 | 76 | -------------------------------------------------------------------------------- /src/Posts.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import moment from 'moment'; 3 | 4 | class Posts extends React.Component{ 5 | constructor(props){ 6 | super(props); 7 | this.state = { 8 | posts : props.posts 9 | } 10 | } 11 | 12 | static getDerivedStateFromProps(props, state){ 13 | return { 14 | posts : props.posts 15 | } 16 | } 17 | 18 | render(){ 19 | return ( 20 |
21 | 22 |

23 | Scheduled Posts 24 |

25 | 26 |
27 | 28 | {this.state.posts.map((post) => { 29 | return ( 30 |
31 |
32 | 33 | {post.type_of === 'photo' && } 34 | 35 |
36 |
{post.message}
37 | 38 |
39 | Page Name : {post.page_name} 40 |
41 | 42 |
43 | Publish at : {moment.unix(post.schedule_time).format('MMMM Do YYYY, h:mm:ss a')} 44 |
45 | 46 |
47 | Post Type : {post.type_of === 'text' ? 'Text' : 'Media'} 48 |
49 |
50 | 53 | Delete 54 | 55 | 58 | Publish 59 | 60 |
61 |
62 |
63 | 64 |
65 | ) 66 | })} 67 |
68 | ) 69 | } 70 | } 71 | 72 | export default Posts; -------------------------------------------------------------------------------- /src/Scheduler.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import toastr from 'toastr'; 3 | import {post, get} from 'axios'; 4 | import DateTimePicker from 'react-datetime-picker'; 5 | 6 | 7 | class Scheduler extends React.Component { 8 | 9 | constructor(props) { 10 | super(props); 11 | this.state = { 12 | date: new Date(), 13 | file: null, 14 | message: '', 15 | pages : [], 16 | page : {id : '', name : ''} 17 | } 18 | } 19 | 20 | componentDidMount(){ 21 | get('/pages').then((response) => { 22 | this.setState({ 23 | pages : response.data 24 | }); 25 | }); 26 | } 27 | 28 | onDateChange(date) { 29 | this.setState({date}) 30 | } 31 | 32 | onChange(e) { 33 | this.setState({message: e.target.value}); 34 | } 35 | 36 | onFileChange(e) { 37 | if (this.validateFile(e.target.files[0])) { 38 | this.setState({file: e.target.files[0]}) 39 | } 40 | } 41 | 42 | onPageSelect(e){ 43 | if(e.target.value.length){ 44 | this.setState({ 45 | page : this.state.pages.filter(page => { 46 | return page.id === e.target.value 47 | })[0] 48 | }) 49 | }else{ 50 | this.setState({ 51 | page : {id : '', name : ''} 52 | }) 53 | } 54 | 55 | } 56 | 57 | validateFile(file) { 58 | let allowed_types = ['image/jpeg', 'image/png']; 59 | if (!allowed_types.includes(file.type)) { 60 | toastr.error(file.type + ' mime type not supported. We only accept JPEG and PNG images', 'Invalid file type') 61 | return false; 62 | } 63 | return true; 64 | } 65 | 66 | schedulePost(e) { 67 | console.log(this.state); 68 | e.preventDefault(); 69 | let error = false; 70 | if (!this.state.file && !this.state.message.length) { 71 | error = true; 72 | toastr.error( 73 | 'To schedule post, please provide a message or image', 74 | 'Please provide a message or image' 75 | ); 76 | } 77 | 78 | if (!this.validateDate(this.state.date)) { 79 | error = true; 80 | toastr.error( 81 | 'Scheduled date has already been passed, please select a valid date', 82 | 'Invalid Date' 83 | ); 84 | } 85 | 86 | if(!this.state.page.id.length){ 87 | error = true; 88 | toastr.error( 89 | 'Please select a page or profile from dropdown menu', 90 | 'Page or Profile required' 91 | ); 92 | } 93 | if (!error) { 94 | const formData = new FormData(); 95 | formData.append('file', this.state.file); 96 | formData.append('message', this.state.message); 97 | formData.append('date', this.getDateInSeconds(this.state.date)); 98 | formData.append('page_id', this.state.page.id); 99 | formData.append('page_name', this.state.page.name); 100 | const config = { 101 | headers: { 102 | 'content-type': 'multipart/form-data' 103 | } 104 | }; 105 | console.log(formData); 106 | post('/schedule', formData, config).then((response) => { 107 | if(!response.data.error){ 108 | this.setState({ 109 | date: new Date(), 110 | file: null, 111 | message: '', 112 | }); 113 | this.fileInput.value = ''; 114 | toastr.success('Post Scheduled'); 115 | this.props.addPost(response.data.post); 116 | } 117 | }) 118 | } 119 | } 120 | 121 | validateDate(date) { 122 | let selected = (new Date(date).getTime() / 1000) - 10, 123 | current = new Date().getTime() / 1000; 124 | return selected > current; 125 | } 126 | 127 | getDateInSeconds(date){ 128 | return Math.floor(new Date(date).getTime() / 1000); 129 | } 130 | 131 | render() { 132 | return ( 133 |
134 |
135 |
136 | 139 |