127.0.0.1:8000 watch-together / master server / watch-together-server.js
master

Tree @master (Download .tar.gz)

watch-together-server.js @masterraw · history · blame

require('dotenv').config();

const utils = require('./utils');
const path = require('path');
global.VIDEO_ROOT = path.join(__dirname, "../videos");
global.TEMP_UPLOAD_DIR = path.join(VIDEO_ROOT, "/temp");

const commandLineArgs = require('command-line-args');
const options = commandLineArgs([{
    name: "no-captcha",
    type: Boolean,
    defaultValue: false
}, {
    name: "no-scan",
    type: Boolean,
    defaultValue: false
}, {
    name: "verbose",
    type: Boolean,
    defaultValue: false
}, {
    name: "save-verbose",
    type: Boolean,
    defaultValue: false
}]);
console.log("CLI OPTIONS:", options);
require('./utils/clamscan').init(options["no-scan"]);
const logger = require('./utils/log');
logger.set_verbose(options);

const express = require("express");
const app = express();
const exphbs = require("express-handlebars");
const rate_limit = require("express-rate-limit");
const cookie_parser = require('cookie-parser')
const session = require('express-session');
const MySQLStore = require('express-mysql-session')(session);
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const bcrypt = require('bcrypt');
const axios = require('axios');
const query_string = require('query-string');
const time = require('./utils/time');
const mailgun = require('./utils/mailgun');
const snippets = require('./utils/snippets');
const cookies = require('./utils/cookies');
const broadcast = require('./websocket/broadcast');
const ws_utils = require('./websocket/utils');
const buildRoutes = require('./buildRoutes')(app);

const PORT = process.env.PORT || 3000;

// Enable if you're behind a reverse proxy (Heroku, Bluemix, AWS ELB, Nginx, etc)
// see https://expressjs.com/en/guide/behind-proxies.html
app.set('trust proxy', true);

app.engine("handlebars", exphbs({
    defaultLayout: "main"
}));
app.set("view engine", "handlebars");

app.use(express.json({
    limit: 1000000
}));
app.use(express.urlencoded({
    extended: true
}));
app.use(cookie_parser());

app.use(express.static("./dist"));
app.use(express.static("./public"));

const passport_log = logger.create_logger("Passport");

// configure passport.js to use the local strategy
passport.use(new LocalStrategy({
    usernameField: 'email',
    passwordField: 'password'
}, function(email, password, done) {
    utils.query("SELECT * FROM users WHERE ?", {
        email: email
    }).catch(utils.handle_err.sql(passport_log)).then(function([user]) {
        if (!user) {
            return utils.resolve([null, false, {
                message: 'Unknown Email'
            }]);
        }
        if (user.email_confirmed) {
            return bcrypt.compare(password, user.password).then(function(result) {
                return result ? utils.resolve([null, user]) : utils.resolve([null, false, {
                    message: 'Incorrect Password'
                }]);
            });
        }
        return utils.query('SELECT * FROM email_confirmations WHERE ?', {
            email: email
        }).catch(utils.handle_err.sql(passport_log)).then(function([confirmation]) {
            if (!confirmation || confirmation.expires < Date.now()) {
                // User was registered but they never got a
                // token generated (registered through CLI)
                // OR they haven't confirmed their email yet
                // and their token has expired.
                return snippets.generate_token(
                    "email_confirmations",
                    "confirmation_id",
                    email
                ).catch(utils.handle_err.sql(passport_log)).then(function(token) {
                    return mailgun.send_confirmation_email(email, {
                        query: query_string.stringify({
                            token: token
                        })
                    }).catch(utils.handle_err.mailgun(passport_log));
                });
            }
        }).then(function() {
            return utils.resolve([null, false, {
                message: 'You need to confirm your email, please check your inbox'
            }]);
        });
    }).then(function(data) {
        done(...data);
    }).catch(function(error) {
        passport_log.general.error(error);
        done(error);
    });
}));

passport.serializeUser(function(user, done) {
    done(null, user.user_id);
});

passport.deserializeUser(function(user_id, done) {
    utils.query("SELECT * FROM users WHERE ?", {
        user_id: user_id
    }).catch(utils.handle_err.sql(passport_log)).then(function([user]) {
        if (user) {
            done(null, user);
        } else {
            // This account got deleted
            done(null, false, {
                message: 'Account Deleted'
            });
        }
    }).catch(function(error) {
        passport_log.general.error(error);
        done(error);
    });
});

const session_middleware = session({
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    store: new MySQLStore(Object.assign({}, utils.mysql_options, {
        connectionLimit: 1,
        clearExpired: true,
        expiration: time.one_day,
        checkExpirationInterval: time.one_day * 0.25,
    })),
    proxy: !process.env.DEBUG,
    cookie: cookies.options()
});

app.use(session_middleware);
app.use(passport.initialize());
app.use(passport.session());

app.use(function(req, res, next) {
    res.locals.ip = req.header("X-Forwarded-For") || "localhost";
    res.locals.log = logger.create_logger(res.locals.ip, req.method, req.url);
    res.locals.log.mark(req);
    next();
});

// Use a map because we want to maintain order of keys
let defaultMiddleware = new Map();
defaultMiddleware.set("rate_limit", {
    default: true,
    func: rate_limit({
        windowMs: 15 * 60 * 1000, // 15 minutes
        max: 100 // limit each IP to 100 requests per windowMs
    })
});
defaultMiddleware.set("harsh_rate_limit", {
    default: false,
    func: rate_limit({
        windowMs: 60 * 60 * 1000, // 1 hour
        max: 100 // limit each IP to 100 requests per windowMs
    })
});
defaultMiddleware.set("auth", {
    default: true,
    func: function(req, res, next) {
        if (!req.isAuthenticated || !req.isAuthenticated()) {
            if (req.session) {
                req.session.returnTo = req.originalUrl || req.url;
            }

            let target = "/login";
            let searchIndex = req.url.indexOf("?");
            if (searchIndex >= 0) {
                target += req.url.substr(req.url.indexOf("?"));
            }

            res.locals.log.general.warning("Client has no session, redirect to", target);
            return res.redirect(target);
        }
        next();
    }
});

function check_captcha_token(req, res, next) {
    if (req.body.captcha_bypass_token) {
        return utils.query("SELECT * FROM captcha_bypass WHERE ?", {
            bypass_id: req.body.captcha_bypass_token
        }).catch(utils.handle_error.sql(res)).then(function(rows) {
            if (rows.length && rows[0].expires > Date.now()) {
                utils.query("DELETE FROM captcha_bypass WHERE ?", {
                    bypass_id: req.body.captcha_bypass_token
                }).catch(utils.handle_err.sql(res, true));
                delete req.body.captcha_bypass_token;
                next();
            } else {
                res.locals.log.general.error("Captcha bypass failed");
                res.send(utils.not_ok(utils.status.error));
            }
        });
    } else {
        return axios({
            method: "POST",
            url: "https://www.google.com/recaptcha/api/siteverify",
            data: query_string.stringify({
                secret: process.env.CAPTCHA_SECRET,
                response: req.body.captcha_token,
                remoteip: res.locals.ip
            }),
            headers: {
                "Content-Type": "application/x-www-form-urlencoded"
            }
        }).then(function(response) {
            /* response.data = {
                "success": true | false, // whether this request was a valid reCAPTCHA token for your site
                "score": number // the score for this request (0.0 - 1.0)
                "action": string // the action name for this request (important to verify)
                "challenge_ts": timestamp, // timestamp of the challenge load (ISO format yyyy-MM-dd'T'HH:mm:ssZZ)
                "hostname": string, // the hostname of the site where the reCAPTCHA was solved
                "error-codes": [...] // optional
            } */
            if (response.data.success &&
                response.data.action === "submit" &&
                response.data.score >= 0.5
            ) {
                res.locals.log.v("Captcha passed with score", response.data.score);
                delete req.body.captcha_token;
                next();
            } else {
                res.locals.log.v("Captcha failed with score", response.data.score);
                res.locals.log.v(response.data);
                return utils.reject("Captcha says you are a bot");
            }
        });
    }
}

defaultMiddleware.set("captcha", {
    default: false,
    func: options["no-captcha"] ? function(req, res, next) {
        res.locals.log.v("Do not check captcha");
        res.locals.log.v("captcha_token:", req.body.captcha_token);
        res.locals.log.v("captcha_bypass_token:", req.body.captcha_bypass_token);
        if (req.body.captcha_bypass_token || req.body.captcha_token) {
            delete req.body.captcha_bypass_token;
            delete req.body.captcha_token;
            next();
        } else {
            utils.handle_err.res(res)("Debug mode captcha failed - no tokens");
        }
    } : function(req, res, next) {
        check_captcha_token(req, res, next).catch(utils.handle_err.res(res))
    }
});

buildRoutes("/api/", defaultMiddleware, [
    "registration",
    "video",
    "log"
], true);

buildRoutes("/api/account/", defaultMiddleware, [
    "login",
    "update",
    "prefs",
    "password-reset",
    "delete"
], true);

buildRoutes("/", defaultMiddleware, [
    "html"
], false);

// Add fallbacks for unknown requests
app.use(function(error, req, res, next) {
    // res.locals.log.sus.warning();
    res.render('error', {
        title: " - 404"
    });
});

// Make sure we have directories or everything will just fucking crash
utils.fs.mkdir_if_not_exists(logger.LOGS_ROOT).then(function() {
    return logger.process_log_queue();
}).then(function() {
    return utils.fs.mkdir_if_not_exists(VIDEO_ROOT);
}).then(function() {
    return utils.fs.mkdir_if_not_exists(TEMP_UPLOAD_DIR);
}).then(function() {
    // Wrap the express app in an http server that can upgrade
    // websocket connections with authentication
    require('./websocket')(app, function(req, callback) {
        session_middleware(req, {}, function() {
            if (req.session && req.session.passport && req.session.passport.user) {
                callback(req.session.passport.user);
            } else {
                ws_utils.log.general.warning("Could not restore session");
                callback();
            }
        });
    }).listen(PORT, function() {
        console.log("Server running on port " + PORT);
    });
}).catch(console.error);

const cleanup_log = logger.create_logger("Data Cleanup");

utils.setIntervalAndTriggerImmediate(function() {
    let now = Date.now();
    cleanup_log.v("Clean up data on", time.toString());

    utils.query("SELECT video_id, name, created_by FROM videos WHERE expires<=?", [now]).then(function(videos) {
        return Promise.all(videos.map(function(video) {
            return utils.rimraf(path.join(VIDEO_ROOT, video.video_id)).then(function() {
                broadcast({
                    command: "delete-video-response",
                    data: utils.ok({
                        video_id: video.video_id,
                        name: video.name,
                        reason: "expired"
                    })
                }, {
                    [video.created_by]: true
                });
            });
        }));
    }).then(function() {
        return Promise.all([
            "videos",
            "registrations",
            "password_reset",
            "captcha_bypass",
            "delete_account"
        ].map(function(table) {
            return utils.query(`DELETE FROM ${table} WHERE expires<=?`, [now]);
        }));
    }).catch(utils.handle_err.sql(cleanup_log, true));
}, time.one_day * 0.25); // 4 times per day

process.on('SIGINT', function(code) {
    console.log(`About to exit with code: ${code}`);
    logger.process_log_queue().then(function() {
        process.exit(0);
    }).catch(function(error) {
        console.error(error);
        process.exit(1);
    });
});