From a944a52e264febae95626ff20c8a3ed89e3d6e9c Mon Sep 17 00:00:00 2001 From: gumi Date: Tue, 10 Apr 2018 18:55:43 -0400 Subject: new major version: v1.0.0 this will make it easier to switch to typescript --- package.json | 6 +- server.js | 209 -------------------------------- src/api.js | 127 +++++++++++++++++++ src/routers/tmwa/index.js | 61 ++++++++++ src/routers/tmwa/middlewares/account.js | 65 ++++++++++ src/routers/tmwa/middlewares/server.js | 11 ++ 6 files changed, 267 insertions(+), 212 deletions(-) delete mode 100644 server.js create mode 100644 src/api.js create mode 100644 src/routers/tmwa/index.js create mode 100644 src/routers/tmwa/middlewares/account.js create mode 100644 src/routers/tmwa/middlewares/server.js diff --git a/package.json b/package.json index 4e70b7a..b70c297 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tmw-api", - "version": "0.1.2", + "version": "1.0.0", "description": "TMW RESTful API", "author": "The Mana World", "license": "CC0-1.0", @@ -29,11 +29,11 @@ "bugs": { "url": "https://github.com/themanaworld/api/issues" }, - "main": "server.js", + "main": "src/api.js", "private": true, "scripts": { "test": "node_modules/nsp/bin/nsp check", - "start": "node server.js" + "start": "node ." }, "dependencies": { "express": "^4.16.3", diff --git a/server.js b/server.js deleted file mode 100644 index 0d28cd5..0000000 --- a/server.js +++ /dev/null @@ -1,209 +0,0 @@ -const express = require("express"); -const mysql = require("mysql"); -const https = require("https"); -const fs = require("fs"); -const api = express(); - -const rate_limiting = new Set(); - -const register_db = mysql.createPool({ - connectionLimit: 10, - host : process.env.npm_package_config_sql_host, - user : process.env.npm_package_config_sql_user, - password : process.env.npm_package_config_sql_password, - database : process.env.npm_package_config_sql_database -}); - -const tmwa = { - status: "OfflineTemporarily", - num_online: 0, - timeout: null, - poll: () => { - fs.readFile("./online.txt", "utf8", (err, data) => { - const lines = data.split("\n"); - - if (err || lines.length < 2) { - console.error("encountered an error while retrieving online.txt", err); - tmwa.timeout = setTimeout(tmwa.poll, 30000); // <= it failed, so check again later - return; - } - - const last_online = Date.parse(lines[0].match(/\((.+)\)/)[1] + ` ${process.env.npm_package_config_timezone}`); - - if (Date.now() - last_online < 30000) { - const num = lines[lines.length - 2].match(/([0-9]+) users are online./); - tmwa.status = "Online"; - tmwa.num_online = num ? num[1] : 0; - } else { - tmwa.status = "OfflineTemporarily"; - tmwa.num_online = 0; - } - - tmwa.timeout = setTimeout(tmwa.poll, 2000); - }); - } -}; - -const checkRateLimiting = (req, res, next) => { - if (rate_limiting.has(req.ip)) { - res.status(429).json({ - status: "error", - error: "too many requests" - }); - } else { - next(); - } - return; -}; - -const checkCaptcha = (req, res) => { - const token = String(req.get("X-CAPTCHA-TOKEN") || ""); - - if (!token.match(/^[a-zA-Z0-9-_]{30,60}$/)) { - res.status(403).json({ - status: "error", - error: "no token sent" - }); - console.info("a request with an empty token was received", req.ip); - rate_limiting.add(req.ip); - setTimeout(() => rate_limiting.delete(req.ip), 300000); - return false; - } - - https.get(`https://www.google.com/recaptcha/api/siteverify?secret=${process.env.npm_package_config_recaptcha_secret}&response=${token}`, re => { - re.setEncoding("utf8"); - re.on("data", response => { - const data = JSON.parse(response); - if (!data.success) { - console.error(`recaptcha returned an error: ${response}`); - res.status(403).json({ - status: "error", - error: "captcha validation failed" - }); - console.info("a request failed to validate", req.ip); - rate_limiting.add(req.ip); - setTimeout(() => rate_limiting.delete(req.ip), 300000); - return false; - } - - return true; // challenge passed, so process the request - }); - }).on("error", error => { - console.error(error); - res.status(403).json({ - status: "error", - error: "recaptcha couldn't be reached" - }); - console.warn("reCaptcha couldn't be reached"); - return false; - }) -}; - -api.get("/api/tmwa", (req, res) => { - res.append("Access-Control-Allow-Origin", "*"); // CORS ready - res.status(200).json({ - "@context": "http://schema.org", - "@type": "GameServer", - name: process.env.npm_package_config_tmwa_name, - url: process.env.npm_package_config_tmwa_url, - playersOnline: tmwa.num_online, - serverStatus: tmwa.status, - }); -}); - -api.use(checkRateLimiting); -api.use(express.json()); -api.post("/api/account", (req, res) => { - if (checkCaptcha(req, res) !== true) return; - - 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" - }); - console.info("a malformed request was received", req.ip, req.body); - rate_limiting.add(req.ip); - setTimeout(() => rate_limiting.delete(req.ip), 300000); - return; - } - - const db = mysql.createConnection({ - host : process.env.npm_package_config_sql_host, - user : process.env.npm_package_config_sql_user, - password : process.env.npm_package_config_sql_password, - database : process.env.npm_package_config_sql_database - }); - - register_db.getConnection((err, db) => { - if (err) { - res.status(500).json({ - status: "error", - error: "couldn't reach the database" - }); - console.warn("a connection with the database couldn't be established"); - return; - } - - const query_params = { - "USERNAME": req.body.username, - "PASSWORD": req.body.password, - "EMAIL": req.body.email || "a@a.com", - "GENDER": "N", - }; - - db.query(`INSERT INTO ${process.env.npm_package_config_sql_table} SET ?`, query_params, (err, rows, fields) => { - if (err) { - if (err.code === "ER_DUP_ENTRY") { - res.status(409).json({ - status: "error", - error: "already exists" - }); - console.info("a request to create an already-existent account was received", req.ip, query_params.USERNAME); - rate_limiting.add(req.ip); - setTimeout(() => rate_limiting.delete(req.ip), 2000); - } else { - res.status(500).json({ - status: "error", - error: "couldn't add the user" - }); - console.error("an unexpected sql error occured", err); - } - } else { - res.status(201).json({ - status: "success" - }); - console.info(`an account was created: ${query_params.USERNAME}`); - rate_limiting.add(req.ip); - setTimeout(() => rate_limiting.delete(req.ip), 300000); - } - - db.release(); // return this connection to the pool - }); - }); -}); - - - -api.use((req, res, next) => { - res.status(404).json({ - status: "error", - error: "unknown endpoint" - }); - console.info("a request for an unknown endpoint was received", req.ip, req.originalUrl); -}); - -if (process.env.npm_package_config_port === undefined) { - console.error("Please run this package with `npm start`"); - process.exit(1); -} - -api.set("trust proxy", "loopback"); // only allow localhost to communicate with the API -api.disable("x-powered-by"); // we don't need this header -api.listen(process.env.npm_package_config_port, () => console.info(`Listening on port ${process.env.npm_package_config_port}`)); -tmwa.poll(); diff --git a/src/api.js b/src/api.js new file mode 100644 index 0000000..eb6d726 --- /dev/null +++ b/src/api.js @@ -0,0 +1,127 @@ +const express = require("express"); // from npm registry +const mysql = require("mysql"); // from npm registry +const https = require("https"); // built-in +const api = express(); + +if (process.env.npm_package_config_port === undefined) { + console.error("Please run this package with `npm start`"); + process.exit(1); +} + + + +// config common to all routers: +api.locals = Object.assign({ + rate_limiting: new Set(), // XXX: or do we want routers to each have their own rate limiter? +}, api.locals); + + + +/******************************* + BEGIN MIDDLEWARES +********************************/ + +const checkRateLimiting = (req, res, next) => { + if (req.app.locals.rate_limiting.has(req.ip)) { + res.status(429).json({ + status: "error", + error: "too many requests" + }); + } else { + next(); + } + return; +}; + +const checkCaptcha = (req, res, next) => { + const token = String(req.get("X-CAPTCHA-TOKEN") || ""); + + if (!token.match(/^[a-zA-Z0-9-_]{30,60}$/)) { + res.status(403).json({ + status: "error", + error: "no token sent" + }); + console.info("a request with an empty token was received", req.ip); + req.app.locals.rate_limiting.add(req.ip); + setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 300000); + return false; + } + + https.get(`https://www.google.com/recaptcha/api/siteverify?secret=${process.env.npm_package_config_recaptcha_secret}&response=${token}`, re => { + re.setEncoding("utf8"); + re.on("data", response => { + const data = JSON.parse(response); + if (!data.success) { + console.error(`recaptcha returned an error: ${response}`); + res.status(403).json({ + status: "error", + error: "captcha validation failed" + }); + console.info("a request failed to validate", req.ip); + req.app.locals.rate_limiting.add(req.ip); + setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 300000); + return false; + } + + next(); // challenge passed, so process the request + }); + }).on("error", error => { + console.error(error); + res.status(403).json({ + status: "error", + error: "recaptcha couldn't be reached" + }); + console.warn("reCaptcha couldn't be reached"); + return false; + }) +}; + +/******************************* + END MIDDLEWARES +********************************/ + + + +/******************************* + BEGIN ROUTERS +********************************/ + +const global_router = express.Router(["caseSensitive", "strict"]); + +const tmwa_router = new (require("./routers/tmwa"))({ + timezone: process.env.npm_package_config_timezone, + name: process.env.npm_package_config_tmwa_name, + url: process.env.npm_package_config_tmwa_url, + db_pool: mysql.createPool({ + connectionLimit: 10, + host : process.env.npm_package_config_sql_host, + user : process.env.npm_package_config_sql_user, + password : process.env.npm_package_config_sql_password, + database : process.env.npm_package_config_sql_database + }), + db_tables: { + register: process.env.npm_package_config_sql_table, + }, +}, api, checkCaptcha, checkRateLimiting); + +global_router.use("/tmwa", tmwa_router); +api.use("/api", global_router); + +/******************************* + END ROUTERS +********************************/ + + + +// default endpoint: +api.use((req, res, next) => { + res.status(404).json({ + status: "error", + error: "unknown endpoint" + }); + console.info("a request for an unknown endpoint was received", req.ip, req.originalUrl); +}); + +api.set("trust proxy", "loopback"); // only allow localhost to communicate with the API +api.disable("x-powered-by"); // we don't need this header +api.listen(process.env.npm_package_config_port, () => console.info(`Listening on port ${process.env.npm_package_config_port}`)); diff --git a/src/routers/tmwa/index.js b/src/routers/tmwa/index.js new file mode 100644 index 0000000..52d241f --- /dev/null +++ b/src/routers/tmwa/index.js @@ -0,0 +1,61 @@ +const express = require("express"); // from npm registry +const fs = require("fs"); // built-in +const poll_symbol = Symbol("TMWA.poll"); // private method + +const middlewares = { + account: require("./middlewares/account.js"), + server: require("./middlewares/server.js"), +}; + +module.exports = exports = class TMWA { + constructor(config, api, challenge, rate_limit) { + // XXX: having to pass a reference to `api` is weird, we should instead + // store config in this.config and make the middlewares (somehow) + // access this.config. the problem is that we can't pass arguments + // to middlewares, so we might have to curry them + + this.api = api; + this.api.locals.tmwa = config; + this.api.locals.tmwa.status = "OfflineTemporarily"; // XXX: storing these in the class feels wrong, I don't think it should be exported... maybe we could use some more curry? + this.api.locals.tmwa.num_online = 0; + this.timeout = null; + this.router = express.Router(["caseSensitive", "strict"]); + + this.router.get("/server", middlewares.server); + + this.router.all("/account", rate_limit); // filter out the flood + this.router.all("/account", challenge); // require a captcha + this.router.use("/account", express.json()); // parse the body as json + this.router.post("/account", middlewares.account); + + tmwa_poll(this); // first heartbeat + + console.info("Loaded TMWA router"); + return this.router; + } +}; + +const tmwa_poll = (_this) => { + fs.readFile("./online.txt", "utf8", (err, data) => { + const lines = data.split("\n"); + + if (err || lines.length < 2) { + console.error("encountered an error while retrieving online.txt", err); + _this.timeout = setTimeout(() => tmwa_poll(_this), 30000); // <= it failed, so check again later + return; + } + + const last_online = Date.parse(lines[0].match(/\((.+)\)/)[1] + ` ${_this.api.locals.tmwa.timezone}`); + + if (Date.now() - last_online < 30000) { + const num = lines[lines.length - 2].match(/([0-9]+) users are online./); + _this.api.locals.tmwa.status = "Online"; + _this.api.locals.tmwa.num_online = num ? num[1] : 0; + } else { + _this.api.locals.tmwa.status = "OfflineTemporarily"; + _this.api.locals.tmwa.num_online = 0; + } + + _this.timeout = setTimeout(() => tmwa_poll(_this), 2000); + }); +}; diff --git a/src/routers/tmwa/middlewares/account.js b/src/routers/tmwa/middlewares/account.js new file mode 100644 index 0000000..e29af24 --- /dev/null +++ b/src/routers/tmwa/middlewares/account.js @@ -0,0 +1,65 @@ +module.exports = exports = (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" + }); + console.info("a malformed request was received", req.ip, req.body); + req.app.locals.rate_limiting.add(req.ip); + setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 300000); + return; + } + + req.app.locals.tmwa.db_pool.getConnection((err, db) => { + if (err) { + res.status(500).json({ + status: "error", + error: "couldn't reach the database" + }); + console.warn("a connection with the database couldn't be established"); + return; + } + + const query_params = { + "USERNAME": req.body.username, + "PASSWORD": req.body.password, + "EMAIL": req.body.email || "a@a.com", + "GENDER": "N", + }; + + db.query(`INSERT INTO ${req.app.locals.tmwa.db_tables.register} SET ?`, query_params, (err, rows, fields) => { + if (err) { + if (err.code === "ER_DUP_ENTRY") { + res.status(409).json({ + status: "error", + error: "already exists" + }); + console.info("a request to create an already-existent account was received", req.ip, query_params.USERNAME); + req.app.locals.rate_limiting.add(req.ip); + setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 2000); + } else { + res.status(500).json({ + status: "error", + error: "couldn't add the user" + }); + console.error("an unexpected sql error occured", err); + } + } else { + res.status(201).json({ + status: "success" + }); + console.info(`an account was created: ${query_params.USERNAME}`); + req.app.locals.rate_limiting.add(req.ip); + setTimeout(() => req.app.locals.rate_limiting.delete(req.ip), 300000); + } + + db.release(); // return this connection to the pool + }); + }); +}; diff --git a/src/routers/tmwa/middlewares/server.js b/src/routers/tmwa/middlewares/server.js new file mode 100644 index 0000000..261ecfd --- /dev/null +++ b/src/routers/tmwa/middlewares/server.js @@ -0,0 +1,11 @@ +module.exports = exports = (req, res, next) => { + res.append("Access-Control-Allow-Origin", "*"); // CORS ready + res.status(200).json({ + "@context": "http://schema.org", + "@type": "GameServer", + name: req.app.locals.tmwa.name, + url: req.app.locals.tmwa.url, + playersOnline: req.app.locals.tmwa.num_online, + serverStatus: req.app.locals.tmwa.status, + }); +}; -- cgit v1.2.3-60-g2f50