summaryrefslogblamecommitdiff
path: root/src/routers/tmwa/middlewares/account.js
blob: 1f5ed42db5bfcce1e46d2901fa4ab77d5f027950 (plain) (tree)

























































































                                                                                                                                                                                                  

                                            










                                                                                                                                                                            




                                                                              


                                                       
                                
                                       
               

                                                                                


                   



                                                                                         
             



















                                                                                                    
                                                                                                                 




                                                                                  
 





                                                                                                                           
                                                                                                                                
               
           
                          

       




                                                     
                                                                                                                                                                        

                                                                   

































                                                                                                                         
           


                                                                            

                                                               
                                             




                                                         



               
                                                          
                                             
                                         
                                                          











                                                                              
                                










































































                                                                                                                                               













                                                            
// this whole file would be a lot prettier with typescript interfaces
"use strict";
const uuidv4 = require("uuid/v4");
const execFile = require("child_process").execFile;
const spawn = require("child_process").spawn;
const ripgrep = require("ripgrep-bin");
const nodemailer = require("nodemailer");

const execAsync = (cmd, par) =>
    new Promise((resolve, reject) =>
        execFile(cmd, par, (error, stdout, stderr) =>
            resolve(error ? "" : (stdout ? stdout : stderr))));

const tmwa_account_regex = new RegExp("^(?<id>[0-9]+)\t(?<name>[^\t]+)\t[^\t]+\t(?<time>[^\t]+)\t[MFSN]\t(?<logins>[0-9]+)\t(?<state>[0-9]+)\t(?<email>[^\t]+)\t[^\t]+\t[0-9]+\t(?<ip>[0-9\.]+)");
const tmwa_char_regex = new RegExp("^(?<id>[0-9]+)\t(?<account>[0-9]+),[0-9]+\t(?<name>[^\t]+)\t");

const parseAccountLine = (line) => {
    const { groups: account } = tmwa_account_regex.exec(line);
    return {
        id: +account.id,
        name: account.name,
        last_login: Date.parse(account.time),
        active: !!+account.logins,
        banned: +account.state !== 0,
        email: account.email === "a@a.com" ? null : account.email,
        ip: (account.ip !== "0.0.0.0" && account.ip !== "127.0.0.1") ? account.ip : null,
    };
}

const parseCharLine = (line) => {
    const { groups: char } = tmwa_char_regex.exec(line);
    return {
        id: +char.id,
        account: +char.account,
        name: char.name,
    };
}

const findAccounts = async (regex, max) => {
    const stdout = await execAsync(ripgrep, ["--case-sensitive", `--max-count=${max}`, regex, "account.txt"]);
    const accounts = new Set();
    if (stdout.length)
        stdout.slice(0, -1).split("\n").forEach(line => accounts.add(parseAccountLine(line)));
    return accounts;
};

const findAccountsByID = async (account_id) => await findAccounts(`^${account_id}\t`, 1);
const findAccountsByName = async (name) => await findAccounts(`^[0-9]+\t${name}\t`, 1);
const findAccountsByEmail = async (email, max=20) => await findAccounts(`^[0-9]+\t[^\t]+\t[^\t]+\t[^\t]+\t[MFSN]\t[0-9]+\t[0-9]+\t${email}\t`, max);

const findChars = async (regex, max) => {
    const stdout = await execAsync(ripgrep, ["--case-sensitive", `--max-count=${max}`, regex, "athena.txt"]);
    const chars = new Set();
    if (stdout.length)
        stdout.slice(0, -1).split("\n").forEach(line => chars.add(parseCharLine(line)));
    return chars;
};

const findCharsByID = async (char_id) => await findChars(`^${char_id}\t`, 1);
const findCharsByName = async (name) => await findChars(`^[0-9]+\t[0-9]+,[0-9]+\t${name}\t`, 1);
const findCharsByAccount = async (account_id, max=20) => await findChars(`^[0-9]+\t${account_id},`, max);

const findCharsByAccountName = async (name) => {
    const accounts = await findAccountsByName(name);
    if (accounts.size > 0)
        return await findCharsByAccount(accounts.values().next().value.id);
    else
        return new Set();
};

const findCharsByEmail = async (email) => {
    const chars = new Set();
    const accounts = await findAccountsByEmail(email);
    for (const account of accounts) {
        const account_chars = await findCharsByAccount(account.id);
        account_chars.forEach(char =>
            chars.add(char))
    }
    return chars;
};

/// tmwa flatfile searching ^

let transporter = nodemailer.createTransport({
    sendmail: true,
    newline: 'unix',
    path: '/usr/sbin/sendmail'
});

const pending_operations = new Map();

const create_account = (req, res, next) => {
    if (!req.body || !Reflect.has(req.body, "username") ||
        !Reflect.has(req.body, "password") || !Reflect.has(req.body, "email") ||
        !req.body.username.match(/^[a-zA-Z0-9]{4,23}$/) ||
        !req.body.password.match(/^[a-zA-Z0-9]{4,23}$/) ||
        !req.body.email.match(/^$|^(?:[a-zA-Z0-9.$&+=_~-]{1,34}@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,35}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?){0,9})$/) ||
        req.body.email.length >= 40)
    {
        res.status(400).json({
            status: "error",
            error: "malformed request"
        });
        req.app.locals.rate_limiting.add(req.ip);
        setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 300000);
        return;
    }

    findAccountsByName(req.body.username).then(acc => {
        if (acc.size > 0) {
            res.status(409).json({
                status: "error",
                error: "already exists"
            });
            req.app.locals.rate_limiting.add(req.ip);
            setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 2000);
            return;
        }

        const child = execFile(`${req.app.locals.tmwa.home}/.local/bin/tmwa-admin`, [], {
            cwd: `${req.app.locals.tmwa.root}/login/`,
            env: {
                LD_LIBRARY_PATH: `${req.app.locals.tmwa.home}/.local/lib`,
            }
        });

        const email = req.body.email.length >= 3 ? req.body.email : "a@a.com";

        child.stdin.write(`create ${req.body.username} N ${email} ${req.body.password}\n`);
        child.stderr.on("data", data => {
            console.error("TMWA.account: an unexpected tmwa-admin error occured: %s", data);
            return;
        });
        child.stdout.on("data", data => {
            if (!data.includes("successfully")) {
                if (!data.includes("have a connection"))
                    console.error("TMWA.account: an unexpected tmwa-admin error occured: %s", data);
                child.kill();
                return;
            }

            res.status(201).json({
                status: "success"
            });
            req.app.locals.logger.info(`TMWA.account: an account was created: ${req.body.username} [${req.ip}]`);
            req.app.locals.rate_limiting.add(req.ip);
            setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 300000);

            if (email === "a@a.com")
                return;

            transporter.sendMail({
                from: req.app.locals.mailer.from,
                to: email,
                subject: "The Mana World account registration",
                text: `Your account (\"${req.body.username}\") was created successfully.\nHave fun playing The Mana World!`
            }, (err, info) => {
                req.app.locals.logger.info(`TMWA.account: sent account creation email: ${req.body.username} ${info.messageId}`);
            });
        });
        child.stdin.end();
    });
};



const reset_password = async (req, res, next) => {
    if (req.body && Reflect.has(req.body, "email") &&
        req.body.email.match(/^(?:[a-zA-Z0-9.$&+=_~-]{1,34}@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,35}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?){0,9})$/) &&
        req.body.email.length >= 3 && req.body.email.length < 40 &&
        req.body.email !== "a@a.com") {

        const accounts = await findAccountsByEmail(req.body.email);

        if (accounts.size < 1) {
            res.status(404).json({
                status: "error",
                error: "no accounts found"
            });
            req.app.locals.rate_limiting.add(req.ip);
            setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 8000);
            return;
        }

        const uuid = uuidv4();
        transporter.sendMail({
            from: req.app.locals.mailer.from,
            to: email,
            subject: "The Mana World password reset",
            text: "You are receiving this email because someone (you?) has requested a password reset on The Mana World"+
                   "with your email address.\nIf you did not request a password reset please ignore this email.\n\n"+
                   "The following accounts are associated with this email address:\n" + account_names + "\n\n"+
                   "To proceed with the password reset:\n" + `${req.app.locals.tmwa.reset}${uuid}`
        }, (err, info) => {
            pending_operations.set(uuid, {
                type: "reset",
                accounts: accounts,
                initiated: Date.now(),
                ip: req.ip,
                timeout: setTimeout(() => pending_operations.delete(uuid), 3600000), // 60 minutes
            });
            res.status(200).json({
                status: "success"
            });
            req.app.locals.logger.info(`TMWA.account: initiated password reset: ${info.messageId} [${req.ip}]`);
        });

        req.app.locals.rate_limiting.add(req.ip);
        setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 8000);
        return;
    } else if (req.body && Reflect.has(req.body, "username") &&
        !Reflect.has(req.body, "password") &&
        req.body.username.match(/^[a-zA-Z0-9]{4,23}$/)) {
        // recover by username (currently unsupported)
        res.status(501).json({
            status: "error",
            error: "not yet implemented"
        });
        return;
    }

    if (!req.body || !Reflect.has(req.body, "password") ||
        !Reflect.has(req.body, "username") ||
        !Reflect.has(req.body, "code") ||
        !req.body.username.match(/^[a-zA-Z0-9]{4,23}$/) ||
        !req.body.password.match(/^[a-zA-Z0-9]{4,23}$/) ||
        !req.body.code.match(/^[a-zA-Z0-9-_]{6,128}$/))
    {
        res.status(400).json({
            status: "error",
            error: "malformed request"
        });
        req.app.locals.rate_limiting.add(req.ip);
        setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 300000);
        return;
    }

    // actual reset happens here
    if (!pending_operations.has(req.body.code)) {
        res.status(408).json({
            status: "error",
            error: "request expired"
        });
        req.app.locals.rate_limiting.add(req.ip);
        setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 300000);
        return;
    }

    if (pending_operations.get(req.body.code).type !== "reset") {
        res.status(400).json({
            status: "error",
            error: "invalid type"
        });
        req.app.locals.rate_limiting.add(req.ip);
        setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 300000);
        pending_operations.delete(req.body.code);
        req.app.locals.logger.warn(`TMWA.account: attempted reset account with invalid uuid: ${req.body.username} [${req.ip}]`);
        return;
    }

    for (account of pending_operations.get(req.body.code).accounts) {
        if (account.name === req.body.username) {
            pending_operations.delete(req.body.code);
            const child = execFile(`${req.app.locals.tmwa.home}/.local/bin/tmwa-admin`, [], {
                cwd: `${req.app.locals.tmwa.root}/login/`,
                env: {
                    LD_LIBRARY_PATH: `${req.app.locals.tmwa.home}/.local/lib`,
                }
            });

            child.stdin.write(`password ${req.body.username} ${req.body.password}\n`);
            child.stderr.on("data", data => {
                console.error("TMWA.account: an unexpected tmwa-admin error occured: %s", data);
                return;
            });
            child.stdout.on("data", data => {
                if (!data.includes("successfully")) {
                    if (!data.includes("have a connection"))
                        console.error("TMWA.account: an unexpected tmwa-admin error occured: %s", data);
                    child.kill();
                    return;
                }

                res.status(201).json({
                    status: "success"
                });
                req.app.locals.logger.info(`TMWA.account: password has been reset: ${req.body.username} [${req.ip}]`);
                req.app.locals.rate_limiting.add(req.ip);
                setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 300000);

                transporter.sendMail({
                    from: req.app.locals.mailer.from,
                    to: email,
                    subject: "The Mana World password reset",
                    text: `You have successfully reset the password for account \"${req.body.username}\".\nHave fun playing The Mana World!`
                }, (err, info) => {
                    req.app.locals.logger.info(`TMWA.account: sent password reset confirmation email: ${req.body.username} ${info.messageId}`);
                });
            });
            child.stdin.end();
            return;
        }
    }

    res.status(401).json({
        status: "error",
        error: "foreign account"
    });
    req.app.locals.rate_limiting.add(req.ip);
    setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 300000);
    pending_operations.delete(req.body.code);
    req.app.locals.logger.warn(`TMWA.account: attempted reset account not owned by user: ${req.body.username} [${req.ip}]`);
    return;
};



module.exports = exports = (req, res, next) => {
    switch(req.method) {
        case "POST":
            return create_account(req, res, next);
        case "PUT":
            return reset_password(req, res, next);
        default:
            next(); // fallthrough to default endpoint (404)
    }
};