==> ./monkeytype/README.md <==
[](https://monkeytype.com/)
# About
Monkeytype is a minimalistic and customizable typing test. It features many test modes, an account system to save your typing speed history, and user-configurable features like themes, sounds, a smooth caret, and more.
# Features
- minimalistic design with no ads
- look at what you are typing
- focus mode
- different test modes
- punctuation mode
- themes
- quotes
- live wpm
- smooth caret
- account system
- command line
- and much more
# Discord bot
On the [Monkeytype Discord server](https://www.discord.gg/monkeytype), we added a Discord bot to auto-assign roles on our server. You can find its code over at https://github.com/Miodec/monkey-bot
# Bug report or Feature request
If you encounter a bug or have a feature request, [send me a message on Reddit](https://reddit.com/user/miodec), [create an issue](https://github.com/Miodec/monkeytype/issues), [create a discussion thread](https://github.com/Miodec/monkeytype/discussions), or [join the Discord server](https://www.discord.gg/monkeytype).
# Want to Contribute?
Refer to [CONTRIBUTING.md.](https://github.com/Miodec/monkeytype/blob/master/CONTRIBUTING.md)
# Code of Conduct
Before contributing to this repository, please read the [code of conduct.](https://github.com/Miodec/monkeytype/blob/master/CODE_OF_CONDUCT.md)
# Credits
[Montydrei](https://www.reddit.com/user/montydrei) for the name suggestion.
Everyone who provided valuable feedback on the [original Reddit post](https://www.reddit.com/r/MechanicalKeyboards/comments/gc6wx3/experimenting_with_a_completely_new_type_of/) for the prototype of this website.
All of the [contributors](https://github.com/Miodec/monkeytype/graphs/contributors) that have helped with implementing various features, adding themes, fixing bugs, and more.
# Support
If you wish to support further development and feel extra awesome, you can [donate](https://ko-fi.com/monkeytype), [become a Patron](https://www.patreon.com/monkeytype) or [buy a t-shirt](https://www.monkeytype.store/).
==> ./monkeytype/.npmrc <==
engine-strict=true
==> ./monkeytype/backend/example.env <==
DB_NAME=monkeytype
DB_URI=mongodb://localhost:27017
MODE=dev
# You can also use the format mongodb://username:password@host:port or
# uncomment the following lines if you want to define them separately
# DB_USERNAME=
# DB_PASSWORD=
# DB_AUTH_MECHANISM="SCRAM-SHA-256"
# DB_AUTH_SOURCE=admin
==> ./monkeytype/backend/init/mongodb.js <==
const { MongoClient } = require("mongodb");
let mongoClient;
module.exports = {
async connectDB() {
let options = {
useNewUrlParser: true,
useUnifiedTopology: true,
connectTimeoutMS: 2000,
serverSelectionTimeoutMS: 2000,
};
if (process.env.DB_USERNAME && process.env.DB_PASSWORD) {
options.auth = {
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
};
}
if (process.env.DB_AUTH_MECHANISM) {
options.authMechanism = process.env.DB_AUTH_MECHANISM;
}
if (process.env.DB_AUTH_SOURCE) {
options.authSource = process.env.DB_AUTH_SOURCE;
}
return MongoClient.connect(process.env.DB_URI, options)
.then((client) => {
mongoClient = client;
})
.catch((e) => {
console.error(e.message);
console.error("FAILED TO CONNECT TO DATABASE. EXITING...");
process.exit(1);
});
},
mongoDB() {
return mongoClient.db(process.env.DB_NAME);
},
};
==> ./monkeytype/backend/server.js <==
const express = require("express");
const { config } = require("dotenv");
const path = require("path");
const MonkeyError = require("./handlers/error");
config({ path: path.join(__dirname, ".env") });
const cors = require("cors");
const admin = require("firebase-admin");
const Logger = require("./handlers/logger.js");
const serviceAccount = require("./credentials/serviceAccountKey.json");
const { connectDB, mongoDB } = require("./init/mongodb");
const jobs = require("./jobs");
const addApiRoutes = require("./api/routes");
const PORT = process.env.PORT || 5005;
// MIDDLEWARE & SETUP
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(cors());
app.set("trust proxy", 1);
app.use((req, res, next) => {
if (process.env.MAINTENANCE === "true") {
res.status(503).json({ message: "Server is down for maintenance" });
} else {
next();
}
});
addApiRoutes(app);
//DO NOT REMOVE NEXT, EVERYTHING WILL EXPLODE
app.use(function (e, req, res, next) {
if (/ECONNREFUSED.*27017/i.test(e.message)) {
e.message = "Could not connect to the database. It may have crashed.";
delete e.stack;
}
let monkeyError;
if (e.errorID) {
//its a monkey error
monkeyError = e;
} else {
//its a server error
monkeyError = new MonkeyError(e.status, e.message, e.stack);
}
if (!monkeyError.uid && req.decodedToken) {
monkeyError.uid = req.decodedToken.uid;
}
if (process.env.MODE !== "dev" && monkeyError.status > 400) {
Logger.log(
"system_error",
`${monkeyError.status} ${monkeyError.message}`,
monkeyError.uid
);
mongoDB().collection("errors").insertOne({
_id: monkeyError.errorID,
timestamp: Date.now(),
status: monkeyError.status,
uid: monkeyError.uid,
message: monkeyError.message,
stack: monkeyError.stack,
});
monkeyError.stack = undefined;
} else {
console.error(monkeyError.message);
}
return res.status(monkeyError.status || 500).json(monkeyError);
});
console.log("Starting server...");
app.listen(PORT, async () => {
console.log(`Listening on port ${PORT}`);
console.log("Connecting to database...");
await connectDB();
console.log("Database connected");
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
});
console.log("Starting cron jobs...");
jobs.forEach((job) => job.start());
});
==> ./monkeytype/backend/constants/quoteLanguages.js <==
const SUPPORTED_QUOTE_LANGUAGES = [
"albanian",
"arabic",
"code_c++",
"code_c",
"code_java",
"code_javascript",
"code_python",
"code_rust",
"czech",
"danish",
"dutch",
"english",
"filipino",
"french",
"german",
"hindi",
"icelandic",
"indonesian",
"irish",
"italian",
"lithuanian",
"malagasy",
"polish",
"portuguese",
"russian",
"serbian",
"slovak",
"spanish",
"swedish",
"thai",
"toki_pona",
"turkish",
"vietnamese",
];
module.exports = SUPPORTED_QUOTE_LANGUAGES;
==> ./monkeytype/backend/dao/leaderboards.js <==
const MonkeyError = require("../handlers/error");
const { mongoDB } = require("../init/mongodb");
const { ObjectID } = require("mongodb");
const Logger = require("../handlers/logger");
const { performance } = require("perf_hooks");
class LeaderboardsDAO {
static async get(mode, mode2, language, skip, limit = 50) {
if (limit > 50 || limit <= 0) limit = 50;
if (skip < 0) skip = 0;
const preset = await mongoDB()
.collection(`leaderboards.${language}.${mode}.${mode2}`)
.find()
.sort({ rank: 1 })
.skip(parseInt(skip))
.limit(parseInt(limit))
.toArray();
return preset;
}
static async getRank(mode, mode2, language, uid) {
const res = await mongoDB()
.collection(`leaderboards.${language}.${mode}.${mode2}`)
.findOne({ uid });
if (res)
res.count = await mongoDB()
.collection(`leaderboards.${language}.${mode}.${mode2}`)
.estimatedDocumentCount();
return res;
}
static async update(mode, mode2, language, uid = undefined) {
let str = `lbPersonalBests.${mode}.${mode2}.${language}`;
let start1 = performance.now();
let lb = await mongoDB()
.collection("users")
.aggregate(
[
{
$match: {
[str + ".wpm"]: {
$exists: true,
},
[str + ".acc"]: {
$exists: true,
},
[str + ".timestamp"]: {
$exists: true,
},
banned: { $exists: false },
},
},
{
$set: {
[str + ".uid"]: "$uid",
[str + ".name"]: "$name",
[str + ".discordId"]: "$discordId",
},
},
{
$replaceRoot: {
newRoot: "$" + str,
},
},
{
$sort: {
wpm: -1,
acc: -1,
timestamp: -1,
},
},
],
{ allowDiskUse: true }
)
.toArray();
let end1 = performance.now();
let start2 = performance.now();
let retval = undefined;
lb.forEach((lbEntry, index) => {
lbEntry.rank = index + 1;
if (uid && lbEntry.uid === uid) {
retval = index + 1;
}
});
let end2 = performance.now();
let start3 = performance.now();
try {
await mongoDB()
.collection(`leaderboards.${language}.${mode}.${mode2}`)
.drop();
} catch (e) {}
if (lb && lb.length !== 0)
await mongoDB()
.collection(`leaderboards.${language}.${mode}.${mode2}`)
.insertMany(lb);
let end3 = performance.now();
let start4 = performance.now();
await mongoDB()
.collection(`leaderboards.${language}.${mode}.${mode2}`)
.createIndex({
uid: -1,
});
await mongoDB()
.collection(`leaderboards.${language}.${mode}.${mode2}`)
.createIndex({
rank: 1,
});
let end4 = performance.now();
let timeToRunAggregate = (end1 - start1) / 1000;
let timeToRunLoop = (end2 - start2) / 1000;
let timeToRunInsert = (end3 - start3) / 1000;
let timeToRunIndex = (end4 - start4) / 1000;
Logger.log(
`system_lb_update_${language}_${mode}_${mode2}`,
`Aggregate ${timeToRunAggregate}s, loop ${timeToRunLoop}s, insert ${timeToRunInsert}s, index ${timeToRunIndex}s`,
uid
);
if (retval) {
return {
message: "Successfully updated leaderboard",
rank: retval,
};
} else {
return {
message: "Successfully updated leaderboard",
};
}
}
}
module.exports = LeaderboardsDAO;
==> ./monkeytype/backend/dao/preset.js <==
const MonkeyError = require("../handlers/error");
const { mongoDB } = require("../init/mongodb");
const { ObjectID } = require("mongodb");
class PresetDAO {
static async getPresets(uid) {
const preset = await mongoDB()
.collection("presets")
.find({ uid })
.sort({ timestamp: -1 })
.toArray(); // this needs to be changed to later take patreon into consideration
return preset;
}
static async addPreset(uid, name, config) {
const count = await mongoDB().collection("presets").find({ uid }).count();
if (count >= 10) throw new MonkeyError(409, "Too many presets");
let preset = await mongoDB()
.collection("presets")
.insertOne({ uid, name, config });
return {
insertedId: preset.insertedId,
};
}
static async editPreset(uid, _id, name, config) {
console.log(_id);
const preset = await mongoDB()
.collection("presets")
.findOne({ uid, _id: ObjectID(_id) });
if (!preset) throw new MonkeyError(404, "Preset not found");
if (config) {
return await mongoDB()
.collection("presets")
.updateOne({ uid, _id: ObjectID(_id) }, { $set: { name, config } });
} else {
return await mongoDB()
.collection("presets")
.updateOne({ uid, _id: ObjectID(_id) }, { $set: { name } });
}
}
static async removePreset(uid, _id) {
const preset = await mongoDB()
.collection("presets")
.findOne({ uid, _id: ObjectID(_id) });
if (!preset) throw new MonkeyError(404, "Preset not found");
return await mongoDB()
.collection("presets")
.deleteOne({ uid, _id: ObjectID(_id) });
}
}
module.exports = PresetDAO;
==> ./monkeytype/backend/dao/quote-ratings.js <==
const MonkeyError = require("../handlers/error");
const { mongoDB } = require("../init/mongodb");
class QuoteRatingsDAO {
static async submit(quoteId, language, rating, update) {
if (update) {
await mongoDB()
.collection("quote-rating")
.updateOne(
{ quoteId, language },
{ $inc: { totalRating: rating } },
{ upsert: true }
);
} else {
await mongoDB()
.collection("quote-rating")
.updateOne(
{ quoteId, language },
{ $inc: { ratings: 1, totalRating: rating } },
{ upsert: true }
);
}
let quoteRating = await this.get(quoteId, language);
let average = parseFloat(
(
Math.round((quoteRating.totalRating / quoteRating.ratings) * 10) / 10
).toFixed(1)
);
return await mongoDB()
.collection("quote-rating")
.updateOne({ quoteId, language }, { $set: { average } });
}
static async get(quoteId, language) {
return await mongoDB()
.collection("quote-rating")
.findOne({ quoteId, language });
}
}
module.exports = QuoteRatingsDAO;
==> ./monkeytype/backend/dao/user.js <==
const MonkeyError = require("../handlers/error");
const { mongoDB } = require("../init/mongodb");
const { ObjectID } = require("mongodb");
const { checkAndUpdatePb } = require("../handlers/pb");
const { updateAuthEmail } = require("../handlers/auth");
const { isUsernameValid } = require("../handlers/validation");
class UsersDAO {
static async addUser(name, email, uid) {
const user = await mongoDB().collection("users").findOne({ uid });
if (user)
throw new MonkeyError(400, "User document already exists", "addUser");
return await mongoDB()
.collection("users")
.insertOne({ name, email, uid, addedAt: Date.now() });
}
static async deleteUser(uid) {
return await mongoDB().collection("users").deleteOne({ uid });
}
static async updateName(uid, name) {
const nameDoc = await mongoDB()
.collection("users")
.findOne({ name: { $regex: new RegExp(`^${name}$`, "i") } });
if (nameDoc) throw new MonkeyError(409, "Username already taken");
let user = await mongoDB().collection("users").findOne({ uid });
if (
Date.now() - user.lastNameChange < 2592000000 &&
isUsernameValid(user.name)
) {
throw new MonkeyError(409, "You can change your name once every 30 days");
}
return await mongoDB()
.collection("users")
.updateOne({ uid }, { $set: { name, lastNameChange: Date.now() } });
}
static async clearPb(uid) {
return await mongoDB()
.collection("users")
.updateOne({ uid }, { $set: { personalBests: {}, lbPersonalBests: {} } });
}
static async isNameAvailable(name) {
const nameDoc = await mongoDB().collection("users").findOne({ name });
if (nameDoc) {
return false;
} else {
return true;
}
}
static async updateQuoteRatings(uid, quoteRatings) {
const user = await mongoDB().collection("users").findOne({ uid });
if (!user)
throw new MonkeyError(404, "User not found", "updateQuoteRatings");
await mongoDB()
.collection("users")
.updateOne({ uid }, { $set: { quoteRatings } });
return true;
}
static async updateEmail(uid, email) {
const user = await mongoDB().collection("users").findOne({ uid });
if (!user) throw new MonkeyError(404, "User not found", "update email");
await updateAuthEmail(uid, email);
await mongoDB().collection("users").updateOne({ uid }, { $set: { email } });
return true;
}
static async getUser(uid) {
const user = await mongoDB().collection("users").findOne({ uid });
if (!user) throw new MonkeyError(404, "User not found", "get user");
return user;
}
static async getUserByDiscordId(discordId) {
const user = await mongoDB().collection("users").findOne({ discordId });
if (!user)
throw new MonkeyError(404, "User not found", "get user by discord id");
return user;
}
static async addTag(uid, name) {
let _id = ObjectID();
await mongoDB()
.collection("users")
.updateOne({ uid }, { $push: { tags: { _id, name } } });
return {
_id,
name,
};
}
static async getTags(uid) {
const user = await mongoDB().collection("users").findOne({ uid });
if (!user) throw new MonkeyError(404, "User not found", "get tags");
return user.tags;
}
static async editTag(uid, _id, name) {
const user = await mongoDB().collection("users").findOne({ uid });
if (!user) throw new MonkeyError(404, "User not found", "edit tag");
if (
user.tags === undefined ||
user.tags.filter((t) => t._id == _id).length === 0
)
throw new MonkeyError(404, "Tag not found");
return await mongoDB()
.collection("users")
.updateOne(
{
uid: uid,
"tags._id": ObjectID(_id),
},
{ $set: { "tags.$.name": name } }
);
}
static async removeTag(uid, _id) {
const user = await mongoDB().collection("users").findOne({ uid });
if (!user) throw new MonkeyError(404, "User not found", "remove tag");
if (
user.tags === undefined ||
user.tags.filter((t) => t._id == _id).length === 0
)
throw new MonkeyError(404, "Tag not found");
return await mongoDB()
.collection("users")
.updateOne(
{
uid: uid,
"tags._id": ObjectID(_id),
},
{ $pull: { tags: { _id: ObjectID(_id) } } }
);
}
static async removeTagPb(uid, _id) {
const user = await mongoDB().collection("users").findOne({ uid });
if (!user) throw new MonkeyError(404, "User not found", "remove tag pb");
if (
user.tags === undefined ||
user.tags.filter((t) => t._id == _id).length === 0
)
throw new MonkeyError(404, "Tag not found");
return await mongoDB()
.collection("users")
.updateOne(
{
uid: uid,
"tags._id": ObjectID(_id),
},
{ $set: { "tags.$.personalBests": {} } }
);
}
static async updateLbMemory(uid, mode, mode2, language, rank) {
const user = await mongoDB().collection("users").findOne({ uid });
if (!user) throw new MonkeyError(404, "User not found", "update lb memory");
if (user.lbMemory === undefined) user.lbMemory = {};
if (user.lbMemory[mode] === undefined) user.lbMemory[mode] = {};
if (user.lbMemory[mode][mode2] === undefined)
user.lbMemory[mode][mode2] = {};
user.lbMemory[mode][mode2][language] = rank;
return await mongoDB()
.collection("users")
.updateOne(
{ uid },
{
$set: { lbMemory: user.lbMemory },
}
);
}
static async checkIfPb(uid, result) {
const user = await mongoDB().collection("users").findOne({ uid });
if (!user) throw new MonkeyError(404, "User not found", "check if pb");
const {
mode,
mode2,
acc,
consistency,
difficulty,
lazyMode,
language,
punctuation,
rawWpm,
wpm,
funbox,
} = result;
if (funbox !== "none" && funbox !== "plus_one" && funbox !== "plus_two") {
return false;
}
if (mode === "quote") {
return false;
}
let lbpb = user.lbPersonalBests;
if (!lbpb) lbpb = {};
let pb = checkAndUpdatePb(
user.personalBests,
lbpb,
mode,
mode2,
acc,
consistency,
difficulty,
lazyMode,
language,
punctuation,
rawWpm,
wpm
);
if (pb.isPb) {
await mongoDB()
.collection("users")
.updateOne({ uid }, { $set: { personalBests: pb.obj } });
if (pb.lbObj) {
await mongoDB()
.collection("users")
.updateOne({ uid }, { $set: { lbPersonalBests: pb.lbObj } });
}
return true;
} else {
return false;
}
}
static async checkIfTagPb(uid, result) {
const user = await mongoDB().collection("users").findOne({ uid });
if (!user) throw new MonkeyError(404, "User not found", "check if tag pb");
if (user.tags === undefined || user.tags.length === 0) {
return [];
}
const {
mode,
mode2,
acc,
consistency,
difficulty,
lazyMode,
language,
punctuation,
rawWpm,
wpm,
tags,
funbox,
} = result;
if (funbox !== "none" && funbox !== "plus_one" && funbox !== "plus_two") {
return [];
}
if (mode === "quote") {
return [];
}
let tagsToCheck = [];
user.tags.forEach((tag) => {
tags.forEach((resultTag) => {
if (resultTag == tag._id) {
tagsToCheck.push(tag);
}
});
});
let ret = [];
tagsToCheck.forEach(async (tag) => {
let tagpb = checkAndUpdatePb(
tag.personalBests,
undefined,
mode,
mode2,
acc,
consistency,
difficulty,
lazyMode,
language,
punctuation,
rawWpm,
wpm
);
if (tagpb.isPb) {
ret.push(tag._id);
await mongoDB()
.collection("users")
.updateOne(
{ uid, "tags._id": ObjectID(tag._id) },
{ $set: { "tags.$.personalBests": tagpb.obj } }
);
}
});
return ret;
}
static async resetPb(uid) {
const user = await mongoDB().collection("users").findOne({ uid });
if (!user) throw new MonkeyError(404, "User not found", "reset pb");
return await mongoDB()
.collection("users")
.updateOne({ uid }, { $set: { personalBests: {} } });
}
static async updateTypingStats(uid, restartCount, timeTyping) {
const user = await mongoDB().collection("users").findOne({ uid });
if (!user)
throw new MonkeyError(404, "User not found", "update typing stats");
return await mongoDB()
.collection("users")
.updateOne(
{ uid },
{
$inc: {
startedTests: restartCount + 1,
completedTests: 1,
timeTyping,
},
}
);
}
static async linkDiscord(uid, discordId) {
const user = await mongoDB().collection("users").findOne({ uid });
if (!user) throw new MonkeyError(404, "User not found", "link discord");
return await mongoDB()
.collection("users")
.updateOne({ uid }, { $set: { discordId } });
}
static async unlinkDiscord(uid) {
const user = await mongoDB().collection("users").findOne({ uid });
if (!user) throw new MonkeyError(404, "User not found", "unlink discord");
return await mongoDB()
.collection("users")
.updateOne({ uid }, { $set: { discordId: null } });
}
static async incrementBananas(uid, wpm) {
const user = await mongoDB().collection("users").findOne({ uid });
if (!user)
throw new MonkeyError(404, "User not found", "increment bananas");
let best60;
try {
best60 = Math.max(...user.personalBests.time[60].map((best) => best.wpm));
} catch (e) {
best60 = undefined;
}
if (best60 === undefined || wpm >= best60 - best60 * 0.25) {
//increment when no record found or wpm is within 25% of the record
return await mongoDB()
.collection("users")
.updateOne({ uid }, { $inc: { bananas: 1 } });
} else {
return null;
}
}
}
module.exports = UsersDAO;
==> ./monkeytype/backend/dao/config.js <==
const MonkeyError = require("../handlers/error");
const { mongoDB } = require("../init/mongodb");
class ConfigDAO {
static async saveConfig(uid, config) {
return await mongoDB()
.collection("configs")
.updateOne({ uid }, { $set: { config } }, { upsert: true });
}
static async getConfig(uid) {
let config = await mongoDB().collection("configs").findOne({ uid });
// if (!config) throw new MonkeyError(404, "Config not found");
return config;
}
}
module.exports = ConfigDAO;
==> ./monkeytype/backend/dao/new-quotes.js <==
const MonkeyError = require("../handlers/error");
const { mongoDB } = require("../init/mongodb");
const fs = require("fs");
const simpleGit = require("simple-git");
const path = require("path");
let git;
try {
git = simpleGit(path.join(__dirname, "../../../monkeytype-new-quotes"));
} catch (e) {
git = undefined;
}
const stringSimilarity = require("string-similarity");
const { ObjectID } = require("mongodb");
class NewQuotesDAO {
static async add(text, source, language, uid) {
if (!git) throw new MonkeyError(500, "Git not available.");
let quote = {
text: text,
source: source,
language: language.toLowerCase(),
submittedBy: uid,
timestamp: Date.now(),
approved: false,
};
//check for duplicate first
const fileDir = path.join(
__dirname,
`../../../monkeytype-new-quotes/static/quotes/${language}.json`
);
let duplicateId = -1;
let similarityScore = -1;
if (fs.existsSync(fileDir)) {
// let quoteFile = fs.readFileSync(fileDir);
// quoteFile = JSON.parse(quoteFile.toString());
// quoteFile.quotes.every((old) => {
// if (stringSimilarity.compareTwoStrings(old.text, quote.text) > 0.9) {
// duplicateId = old.id;
// similarityScore = stringSimilarity.compareTwoStrings(
// old.text,
// quote.text
// );
// return false;
// }
// return true;
// });
} else {
return { languageError: 1 };
}
if (duplicateId != -1) {
return { duplicateId, similarityScore };
}
return await mongoDB().collection("new-quotes").insertOne(quote);
}
static async get() {
if (!git) throw new MonkeyError(500, "Git not available.");
return await mongoDB()
.collection("new-quotes")
.find({ approved: false })
.sort({ timestamp: 1 })
.limit(10)
.toArray();
}
static async approve(quoteId, editQuote, editSource) {
if (!git) throw new MonkeyError(500, "Git not available.");
//check mod status
let quote = await mongoDB()
.collection("new-quotes")
.findOne({ _id: ObjectID(quoteId) });
if (!quote) {
throw new MonkeyError(404, "Quote not found");
}
let language = quote.language;
quote = {
text: editQuote ? editQuote : quote.text,
source: editSource ? editSource : quote.source,
length: quote.text.length,
};
let message = "";
const fileDir = path.join(
__dirname,
`../../../monkeytype-new-quotes/static/quotes/${language}.json`
);
await git.pull("upstream", "master");
if (fs.existsSync(fileDir)) {
let quoteFile = fs.readFileSync(fileDir);
quoteFile = JSON.parse(quoteFile.toString());
quoteFile.quotes.every((old) => {
if (stringSimilarity.compareTwoStrings(old.text, quote.text) > 0.8) {
throw new MonkeyError(409, "Duplicate quote");
}
});
let maxid = 0;
quoteFile.quotes.map(function (q) {
if (q.id > maxid) {
maxid = q.id;
}
});
quote.id = maxid + 1;
quoteFile.quotes.push(quote);
fs.writeFileSync(fileDir, JSON.stringify(quoteFile, null, 2));
message = `Added quote to ${language}.json.`;
} else {
//file doesnt exist, create it
quote.id = 1;
fs.writeFileSync(
fileDir,
JSON.stringify({
language: language,
groups: [
[0, 100],
[101, 300],
[301, 600],
[601, 9999],
],
quotes: [quote],
})
);
message = `Created file ${language}.json and added quote.`;
}
await git.add([`static/quotes/${language}.json`]);
await git.commit(`Added quote to ${language}.json`);
await git.push("origin", "master");
await mongoDB()
.collection("new-quotes")
.deleteOne({ _id: ObjectID(quoteId) });
return { quote, message };
}
static async refuse(quoteId) {
if (!git) throw new MonkeyError(500, "Git not available.");
return await mongoDB()
.collection("new-quotes")
.deleteOne({ _id: ObjectID(quoteId) });
}
}
module.exports = NewQuotesDAO;
==> ./monkeytype/backend/dao/public-stats.js <==
// const MonkeyError = require("../handlers/error");
const { mongoDB } = require("../init/mongodb");
const { roundTo2 } = require("../handlers/misc");
class PublicStatsDAO {
//needs to be rewritten, this is public stats not user stats
static async updateStats(restartCount, time) {
time = roundTo2(time);
await mongoDB()
.collection("public")
.updateOne(
{ type: "stats" },
{
$inc: {
testsCompleted: 1,
testsStarted: restartCount + 1,
timeTyping: time,
},
},
{ upsert: true }
);
return true;
}
}
module.exports = PublicStatsDAO;
==> ./monkeytype/backend/dao/bot.js <==
const MonkeyError = require("../handlers/error");
const { mongoDB } = require("../init/mongodb");
async function addCommand(command, arguments) {
return await mongoDB().collection("bot-commands").insertOne({
command,
arguments,
executed: false,
requestTimestamp: Date.now(),
});
}
async function addCommands(commands, arguments) {
if (commands.length === 0 || commands.length !== arguments.length) {
return [];
}
const normalizedCommands = commands.map((command, index) => {
return {
command,
arguments: arguments[index],
executed: false,
requestTimestamp: Date.now(),
};
});
return await mongoDB()
.collection("bot-commands")
.insertMany(normalizedCommands);
}
class BotDAO {
static async updateDiscordRole(discordId, wpm) {
return await addCommand("updateRole", [discordId, wpm]);
}
static async linkDiscord(uid, discordId) {
return await addCommand("linkDiscord", [discordId, uid]);
}
static async unlinkDiscord(uid, discordId) {
return await addCommand("unlinkDiscord", [discordId, uid]);
}
static async awardChallenge(discordId, challengeName) {
return await addCommand("awardChallenge", [discordId, challengeName]);
}
static async announceLbUpdate(newRecords, leaderboardId) {
if (newRecords.length === 0) {
return [];
}
const leaderboardCommands = Array(newRecords.length).fill("sayLbUpdate");
const leaderboardCommandsArguments = newRecords.map((newRecord) => {
return [
newRecord.discordId ?? newRecord.name,
newRecord.rank,
leaderboardId,
newRecord.wpm,
newRecord.raw,
newRecord.acc,
newRecord.consistency,
];
});
return await addCommands(leaderboardCommands, leaderboardCommandsArguments);
}
}
module.exports = BotDAO;
==> ./monkeytype/backend/dao/psa.js <==
const { mongoDB } = require("../init/mongodb");
class PsaDAO {
static async get(uid, config) {
return await mongoDB().collection("psa").find().toArray();
}
}
module.exports = PsaDAO;
==> ./monkeytype/backend/dao/result.js <==
const { ObjectID } = require("mongodb");
const MonkeyError = require("../handlers/error");
const { mongoDB } = require("../init/mongodb");
const UserDAO = require("./user");
class ResultDAO {
static async addResult(uid, result) {
let user;
try {
user = await UserDAO.getUser(uid);
} catch (e) {
user = null;
}
if (!user) throw new MonkeyError(404, "User not found", "add result");
if (result.uid === undefined) result.uid = uid;
// result.ir = true;
let res = await mongoDB().collection("results").insertOne(result);
return {
insertedId: res.insertedId,
};
}
static async deleteAll(uid) {
return await mongoDB().collection("results").deleteMany({ uid });
}
static async updateTags(uid, resultid, tags) {
const result = await mongoDB()
.collection("results")
.findOne({ _id: ObjectID(resultid), uid });
if (!result) throw new MonkeyError(404, "Result not found");
const userTags = await UserDAO.getTags(uid);
const userTagIds = userTags.map((tag) => tag._id.toString());
let validTags = true;
tags.forEach((tagId) => {
if (!userTagIds.includes(tagId)) validTags = false;
});
if (!validTags)
throw new MonkeyError(400, "One of the tag id's is not vaild");
return await mongoDB()
.collection("results")
.updateOne({ _id: ObjectID(resultid), uid }, { $set: { tags } });
}
static async getResult(uid, id) {
const result = await mongoDB()
.collection("results")
.findOne({ _id: ObjectID(id), uid });
if (!result) throw new MonkeyError(404, "Result not found");
return result;
}
static async getLastResult(uid) {
let result = await mongoDB()
.collection("results")
.find({ uid })
.sort({ timestamp: -1 })
.limit(1)
.toArray();
result = result[0];
if (!result) throw new MonkeyError(404, "No results found");
return result;
}
static async getResultByTimestamp(uid, timestamp) {
return await mongoDB().collection("results").findOne({ uid, timestamp });
}
static async getResults(uid, start, end) {
start = start ?? 0;
end = end ?? 1000;
const result = await mongoDB()
.collection("results")
.find({ uid })
.sort({ timestamp: -1 })
.skip(start)
.limit(end)
.toArray(); // this needs to be changed to later take patreon into consideration
if (!result) throw new MonkeyError(404, "Result not found");
return result;
}
}
module.exports = ResultDAO;
==> ./monkeytype/backend/middlewares/auth.js <==
const MonkeyError = require("../handlers/error");
const { verifyIdToken } = require("../handlers/auth");
module.exports = {
async authenticateRequest(req, res, next) {
try {
if (process.env.MODE === "dev" && !req.headers.authorization) {
if (req.body.uid) {
req.decodedToken = {
uid: req.body.uid,
};
console.log("Running authorization in dev mode");
return next();
} else {
throw new MonkeyError(
400,
"Running authorization in dev mode but still no uid was provided"
);
}
}
const { authorization } = req.headers;
if (!authorization)
throw new MonkeyError(
401,
"Unauthorized",
`endpoint: ${req.baseUrl} no authorization header found`
);
const token = authorization.split(" ");
if (token[0].trim() !== "Bearer")
return next(
new MonkeyError(400, "Invalid Token", "Incorrect token type")
);
req.decodedToken = await verifyIdToken(token[1]);
return next();
} catch (e) {
return next(e);
}
},
};
==> ./monkeytype/backend/middlewares/rate-limit.js <==
const rateLimit = require("express-rate-limit");
const getAddress = (req) =>
req.headers["cf-connecting-ip"] ||
req.headers["x-forwarded-for"] ||
req.ip ||
"255.255.255.255";
const message = "Too many requests, please try again later";
const multiplier = process.env.MODE === "dev" ? 100 : 1;
// Config Routing
exports.configUpdate = rateLimit({
windowMs: 60 * 60 * 1000, // 60 min
max: 500 * multiplier,
message,
keyGenerator: getAddress,
});
exports.configGet = rateLimit({
windowMs: 60 * 60 * 1000, // 60 min
max: 120 * multiplier,
message,
keyGenerator: getAddress,
});
// Leaderboards Routing
exports.leaderboardsGet = rateLimit({
windowMs: 60 * 60 * 1000, // 60 min
max: 60 * multiplier,
message,
keyGenerator: getAddress,
});
// New Quotes Routing
exports.newQuotesGet = rateLimit({
windowMs: 60 * 60 * 1000,
max: 500 * multiplier,
message,
keyGenerator: getAddress,
});
exports.newQuotesAdd = rateLimit({
windowMs: 60 * 60 * 1000, // 60 min
max: 60 * multiplier,
message,
keyGenerator: getAddress,
});
exports.newQuotesAction = rateLimit({
windowMs: 60 * 60 * 1000,
max: 500 * multiplier,
message,
keyGenerator: getAddress,
});
// Quote Ratings Routing
exports.quoteRatingsGet = rateLimit({
windowMs: 60 * 60 * 1000,
max: 500 * multiplier,
message,
keyGenerator: getAddress,
});
exports.quoteRatingsSubmit = rateLimit({
windowMs: 60 * 60 * 1000,
max: 500 * multiplier,
message,
keyGenerator: getAddress,
});
// Quote reporting
exports.quoteReportSubmit = rateLimit({
windowMs: 30 * 60 * 1000, // 30 min
max: 50 * multiplier,
message,
keyGenerator: getAddress,
});
// Presets Routing
exports.presetsGet = rateLimit({
windowMs: 60 * 60 * 1000, // 60 min
max: 60 * multiplier,
message,
keyGenerator: getAddress,
});
exports.presetsAdd = rateLimit({
windowMs: 60 * 60 * 1000, // 60 min
max: 60 * multiplier,
message,
keyGenerator: getAddress,
});
exports.presetsRemove = rateLimit({
windowMs: 60 * 60 * 1000, // 60 min
max: 60 * multiplier,
message,
keyGenerator: getAddress,
});
exports.presetsEdit = rateLimit({
windowMs: 60 * 60 * 1000, // 60 min
max: 60 * multiplier,
message,
keyGenerator: getAddress,
});
// PSA (Public Service Announcement) Routing
exports.psaGet = rateLimit({
windowMs: 60 * 1000,
max: 60 * multiplier,
message,
keyGenerator: getAddress,
});
// Results Routing
exports.resultsGet = rateLimit({
windowMs: 60 * 60 * 1000, // 60 min
max: 60 * multiplier,
message,
keyGenerator: getAddress,
});
exports.resultsAdd = rateLimit({
windowMs: 60 * 60 * 1000,
max: 500 * multiplier,
message,
keyGenerator: getAddress,
});
exports.resultsTagsUpdate = rateLimit({
windowMs: 60 * 60 * 1000,
max: 30 * multiplier,
message,
keyGenerator: getAddress,
});
exports.resultsDeleteAll = rateLimit({
windowMs: 60 * 60 * 1000, // 60 min
max: 10 * multiplier,
message,
keyGenerator: getAddress,
});
exports.resultsLeaderboardGet = rateLimit({
windowMs: 60 * 60 * 1000, // 60 min
max: 60 * multiplier,
message,
keyGenerator: getAddress,
});
exports.resultsLeaderboardQualificationGet = rateLimit({
windowMs: 60 * 60 * 1000, // 60 min
max: 60 * multiplier,
message,
keyGenerator: getAddress,
});
// Users Routing
exports.userGet = rateLimit({
windowMs: 60 * 60 * 1000, // 60 min
max: 60 * multiplier,
message,
keyGenerator: getAddress,
});
exports.userSignup = rateLimit({
windowMs: 24 * 60 * 60 * 1000, // 1 day
max: 3 * multiplier,
message,
keyGenerator: getAddress,
});
exports.userDelete = rateLimit({
windowMs: 24 * 60 * 60 * 1000, // 1 day
max: 3 * multiplier,
message,
keyGenerator: getAddress,
});
exports.userCheckName = rateLimit({
windowMs: 60 * 1000,
max: 60 * multiplier,
message,
keyGenerator: getAddress,
});
exports.userUpdateName = rateLimit({
windowMs: 24 * 60 * 60 * 1000, // 1 day
max: 3 * multiplier,
message,
keyGenerator: getAddress,
});
exports.userUpdateLBMemory = rateLimit({
windowMs: 60 * 1000,
max: 60 * multiplier,
message,
keyGenerator: getAddress,
});
exports.userUpdateEmail = rateLimit({
windowMs: 60 * 60 * 1000, // 60 min
max: 60 * multiplier,
message,
keyGenerator: getAddress,
});
exports.userClearPB = rateLimit({
windowMs: 60 * 60 * 1000, // 60 min
max: 60 * multiplier,
message,
keyGenerator: getAddress,
});
exports.userTagsGet = rateLimit({
windowMs: 60 * 60 * 1000, // 60 min
max: 60 * multiplier,
message,
keyGenerator: getAddress,
});
exports.userTagsRemove = rateLimit({
windowMs: 60 * 60 * 1000, // 60 min
max: 30 * multiplier,
message,
keyGenerator: getAddress,
});
exports.userTagsClearPB = rateLimit({
windowMs: 60 * 60 * 1000, // 60 min
max: 60 * multiplier,
message,
keyGenerator: getAddress,
});
exports.userTagsEdit = rateLimit({
windowMs: 60 * 60 * 1000, // 60 min
max: 30 * multiplier,
message,
keyGenerator: getAddress,
});
exports.userTagsAdd = rateLimit({
windowMs: 60 * 60 * 1000, // 60 min
max: 30 * multiplier,
message,
keyGenerator: getAddress,
});
exports.userDiscordLink = exports.usersTagsEdit = rateLimit({
windowMs: 60 * 60 * 1000, // 60 min
max: 15 * multiplier,
message,
keyGenerator: getAddress,
});
exports.userDiscordUnlink = exports.usersTagsEdit = rateLimit({
windowMs: 60 * 60 * 1000, // 60 min
max: 15 * multiplier,
message,
keyGenerator: getAddress,
});
==> ./monkeytype/backend/middlewares/apiUtils.js <==
const joi = require("joi");
const MonkeyError = require("../handlers/error");
function requestValidation(validationSchema) {
return (req, res, next) => {
// In dev environments, as an alternative to token authentication,
// you can pass the authentication middleware by having a user id in the body.
// Inject the user id into the schema so that validation will not fail.
if (process.env.MODE === "dev") {
validationSchema.body = {
uid: joi.any(),
...(validationSchema.body ?? {}),
};
}
Object.keys(validationSchema).forEach((key) => {
const schema = validationSchema[key];
const joiSchema = joi.object().keys(schema);
const { error } = joiSchema.validate(req[key] ?? {});
if (error) {
const errorMessage = error.details[0].message;
throw new MonkeyError(400, `Invalid request: ${errorMessage}`);
}
});
next();
};
}
module.exports = {
requestValidation,
};
==> ./monkeytype/backend/.gitignore <==
lastId.txt
log_success.txt
log_failed.txt
==> ./monkeytype/backend/api/controllers/leaderboards.js <==
const LeaderboardsDAO = require("../../dao/leaderboards");
const ResultDAO = require("../../dao/result");
const UserDAO = require("../../dao/user");
const admin = require("firebase-admin");
const { verifyIdToken } = require("../../handlers/auth");
class LeaderboardsController {
static async get(req, res, next) {
try {
const { language, mode, mode2, skip, limit } = req.query;
let uid;
const { authorization } = req.headers;
if (authorization) {
const token = authorization.split(" ");
if (token[0].trim() == "Bearer")
req.decodedToken = await verifyIdToken(token[1]);
uid = req.decodedToken.uid;
}
if (!language || !mode || !mode2 || !skip) {
return res.status(400).json({
message: "Missing parameters",
});
}
let retval = await LeaderboardsDAO.get(
mode,
mode2,
language,
skip,
limit
);
retval.forEach((item) => {
if (uid && item.uid == uid) {
//
} else {
delete item.discordId;
delete item.uid;
delete item.difficulty;
delete item.language;
}
});
return res.status(200).json(retval);
} catch (e) {
return next(e);
}
}
static async getRank(req, res, next) {
try {
const { language, mode, mode2 } = req.query;
const { uid } = req.decodedToken;
if (!language || !mode || !mode2 || !uid) {
return res.status(400).json({
message: "Missing parameters",
});
}
let retval = await LeaderboardsDAO.getRank(mode, mode2, language, uid);
return res.status(200).json(retval);
} catch (e) {
return next(e);
}
}
static async update(req, res, next) {
try {
return res.status(200).json({
message: "Leaderboards disabled",
lbdisabled: true,
});
if (process.env.LBDISABLED === true) {
return res.status(200).json({
message: "Leaderboards disabled",
lbdisabled: true,
});
}
const { rid } = req.body;
const { uid } = req.decodedToken;
if (!rid) {
return res.status(400).json({
message: "Missing parameters",
});
}
//verify user first
let user = await UserDAO.getUser(uid);
if (!user) {
return res.status(400).json({
message: "User not found",
});
}
if (user.banned === true) {
return res.status(200).json({
message: "User banned",
banned: true,
});
}
let userauth = await admin.auth().getUser(uid);
if (!userauth.emailVerified) {
return res.status(200).json({
message: "User needs to verify email address",
needsToVerifyEmail: true,
});
}
let result = await ResultDAO.getResult(uid, rid);
if (!result.language) result.language = "english";
if (
result.mode == "time" &&
result.isPb &&
(result.mode2 == 15 || result.mode2 == 60) &&
["english"].includes(result.language)
) {
//check if its better than their current lb pb
let lbpb =
user?.lbPersonalBests?.[result.mode]?.[result.mode2]?.[
result.language
]?.wpm;
if (!lbpb) lbpb = 0;
if (result.wpm >= lbpb) {
//run update
let retval = await LeaderboardsDAO.update(
result.mode,
result.mode2,
result.language,
uid
);
if (retval.rank) {
await UserDAO.updateLbMemory(
uid,
result.mode,
result.mode2,
result.language,
retval.rank
);
}
return res.status(200).json(retval);
} else {
let rank = await LeaderboardsDAO.getRank(
result.mode,
result.mode2,
result.language,
uid
);
rank = rank?.rank;
if (!rank) {
return res.status(400).json({
message: "User has a lbPb but was not found on the leaderboard",
});
}
await UserDAO.updateLbMemory(
uid,
result.mode,
result.mode2,
result.language,
rank
);
return res.status(200).json({
message: "Not a new leaderboard personal best",
rank,
});
}
} else {
return res.status(400).json({
message: "This result is not eligible for any leaderboard",
});
}
} catch (e) {
return next(e);
}
}
static async debugUpdate(req, res, next) {
try {
const { language, mode, mode2 } = req.body;
if (!language || !mode || !mode2) {
return res.status(400).json({
message: "Missing parameters",
});
}
let retval = await LeaderboardsDAO.update(mode, mode2, language);
return res.status(200).json(retval);
} catch (e) {
return next(e);
}
}
}
module.exports = LeaderboardsController;
==> ./monkeytype/backend/api/controllers/preset.js <==
const PresetDAO = require("../../dao/preset");
const {
isTagPresetNameValid,
validateConfig,
} = require("../../handlers/validation");
const MonkeyError = require("../../handlers/error");
class PresetController {
static async getPresets(req, res, next) {
try {
const { uid } = req.decodedToken;
let presets = await PresetDAO.getPresets(uid);
return res.status(200).json(presets);
} catch (e) {
return next(e);
}
}
static async addPreset(req, res, next) {
try {
const { name, config } = req.body;
const { uid } = req.decodedToken;
if (!isTagPresetNameValid(name))
throw new MonkeyError(400, "Invalid preset name.");
validateConfig(config);
let preset = await PresetDAO.addPreset(uid, name, config);
return res.status(200).json(preset);
} catch (e) {
return next(e);
}
}
static async editPreset(req, res, next) {
try {
const { _id, name, config } = req.body;
const { uid } = req.decodedToken;
if (!isTagPresetNameValid(name))
throw new MonkeyError(400, "Invalid preset name.");
if (config) validateConfig(config);
await PresetDAO.editPreset(uid, _id, name, config);
return res.sendStatus(200);
} catch (e) {
return next(e);
}
}
static async removePreset(req, res, next) {
try {
const { _id } = req.body;
const { uid } = req.decodedToken;
await PresetDAO.removePreset(uid, _id);
return res.sendStatus(200);
} catch (e) {
return next(e);
}
}
}
module.exports = PresetController;
==> ./monkeytype/backend/api/controllers/core.js <==
class CoreController {
static async handleTestResult() {}
}
==> ./monkeytype/backend/api/controllers/quote-ratings.js <==
const QuoteRatingsDAO = require("../../dao/quote-ratings");
const UserDAO = require("../../dao/user");
const MonkeyError = require("../../handlers/error");
class QuoteRatingsController {
static async getRating(req, res, next) {
try {
const { quoteId, language } = req.query;
let data = await QuoteRatingsDAO.get(parseInt(quoteId), language);
return res.status(200).json(data);
} catch (e) {
return next(e);
}
}
static async submitRating(req, res, next) {
try {
let { uid } = req.decodedToken;
let { quoteId, rating, language } = req.body;
quoteId = parseInt(quoteId);
rating = parseInt(rating);
if (isNaN(quoteId) || isNaN(rating)) {
throw new MonkeyError(
400,
"Bad request. Quote id or rating is not a number."
);
}
if (typeof language !== "string") {
throw new MonkeyError(400, "Bad request. Language is not a string.");
}
if (rating < 1 || rating > 5) {
throw new MonkeyError(
400,
"Bad request. Rating must be between 1 and 5."
);
}
rating = Math.round(rating);
//check if user already submitted a rating
let user = await UserDAO.getUser(uid);
if (!user) {
throw new MonkeyError(401, "User not found.");
}
let quoteRatings = user.quoteRatings;
if (quoteRatings === undefined) quoteRatings = {};
if (quoteRatings[language] === undefined) quoteRatings[language] = {};
if (quoteRatings[language][quoteId] == undefined)
quoteRatings[language][quoteId] = undefined;
let quoteRating = quoteRatings[language][quoteId];
let newRating;
let update;
if (quoteRating) {
//user already voted for this
newRating = rating - quoteRating;
update = true;
} else {
//user has not voted for this
newRating = rating;
update = false;
}
await QuoteRatingsDAO.submit(quoteId, language, newRating, update);
quoteRatings[language][quoteId] = rating;
await UserDAO.updateQuoteRatings(uid, quoteRatings);
return res.sendStatus(200);
} catch (e) {
return next(e);
}
}
}
module.exports = QuoteRatingsController;
==> ./monkeytype/backend/api/controllers/user.js <==
const UsersDAO = require("../../dao/user");
const BotDAO = require("../../dao/bot");
const {
isUsernameValid,
isTagPresetNameValid,
} = require("../../handlers/validation");
const MonkeyError = require("../../handlers/error");
const fetch = require("node-fetch");
const Logger = require("./../../handlers/logger.js");
const uaparser = require("ua-parser-js");
// import UsersDAO from "../../dao/user";
// import BotDAO from "../../dao/bot";
// import { isUsernameValid } from "../../handlers/validation";
class UserController {
static async createNewUser(req, res, next) {
try {
const { name } = req.body;
const { email, uid } = req.decodedToken;
await UsersDAO.addUser(name, email, uid);
Logger.log("user_created", `${name} ${email}`, uid);
return res.sendStatus(200);
} catch (e) {
return next(e);
}
}
static async deleteUser(req, res, next) {
try {
const { uid } = req.decodedToken;
const userInfo = await UsersDAO.getUser(uid);
await UsersDAO.deleteUser(uid);
Logger.log("user_deleted", `${userInfo.email} ${userInfo.name}`, uid);
return res.sendStatus(200);
} catch (e) {
return next(e);
}
}
static async updateName(req, res, next) {
try {
const { uid } = req.decodedToken;
const { name } = req.body;
if (!isUsernameValid(name))
return res.status(400).json({
message:
"Username invalid. Name cannot contain special characters or contain more than 14 characters. Can include _ . and -",
});
let olduser = await UsersDAO.getUser(uid);
await UsersDAO.updateName(uid, name);
Logger.log(
"user_name_updated",
`changed name from ${olduser.name} to ${name}`,
uid
);
return res.sendStatus(200);
} catch (e) {
return next(e);
}
}
static async clearPb(req, res, next) {
try {
const { uid } = req.decodedToken;
await UsersDAO.clearPb(uid);
Logger.log("user_cleared_pbs", "", uid);
return res.sendStatus(200);
} catch (e) {
return next(e);
}
}
static async checkName(req, res, next) {
try {
const { name } = req.body;
if (!isUsernameValid(name))
return next({
status: 400,
message:
"Username invalid. Name cannot contain special characters or contain more than 14 characters. Can include _ . and -",
});
const available = await UsersDAO.isNameAvailable(name);
if (!available)
return res.status(400).json({ message: "Username unavailable" });
return res.sendStatus(200);
} catch (e) {
return next(e);
}
}
static async updateEmail(req, res, next) {
try {
const { uid } = req.decodedToken;
const { newEmail } = req.body;
try {
await UsersDAO.updateEmail(uid, newEmail);
} catch (e) {
throw new MonkeyError(400, e.message, "update email", uid);
}
Logger.log("user_email_updated", `changed email to ${newEmail}`, uid);
return res.sendStatus(200);
} catch (e) {
return next(e);
}
}
static async getUser(req, res, next) {
try {
const { email, uid } = req.decodedToken;
let userInfo;
try {
userInfo = await UsersDAO.getUser(uid);
} catch (e) {
if (email && uid) {
userInfo = await UsersDAO.addUser(undefined, email, uid);
} else {
throw new MonkeyError(
400,
"User not found. Could not recreate user document.",
"Tried to recreate user document but either email or uid is nullish",
uid
);
}
}
let agent = uaparser(req.headers["user-agent"]);
let logobj = {
ip:
req.headers["cf-connecting-ip"] ||
req.headers["x-forwarded-for"] ||
req.ip ||
"255.255.255.255",
agent:
agent.os.name +
" " +
agent.os.version +
" " +
agent.browser.name +
" " +
agent.browser.version,
};
if (agent.device.vendor) {
logobj.device =
agent.device.vendor +
" " +
agent.device.model +
" " +
agent.device.type;
}
Logger.log("user_data_requested", logobj, uid);
return res.status(200).json(userInfo);
} catch (e) {
return next(e);
}
}
static async linkDiscord(req, res, next) {
try {
const { uid } = req.decodedToken;
let requser;
try {
requser = await UsersDAO.getUser(uid);
} catch (e) {
requser = null;
}
if (requser?.banned === true) {
throw new MonkeyError(403, "Banned accounts cannot link with Discord");
}
let discordFetch = await fetch("https://discord.com/api/users/@me", {
headers: {
authorization: `${req.body.data.tokenType} ${req.body.data.accessToken}`,
},
});
discordFetch = await discordFetch.json();
const did = discordFetch.id;
if (!did) {
throw new MonkeyError(
500,
"Could not get Discord account info",
"did is undefined"
);
}
let user;
try {
user = await UsersDAO.getUserByDiscordId(did);
} catch (e) {
user = null;
}
if (user !== null) {
throw new MonkeyError(
400,
"This Discord account is already linked to a different account"
);
}
await UsersDAO.linkDiscord(uid, did);
await BotDAO.linkDiscord(uid, did);
Logger.log("user_discord_link", `linked to ${did}`, uid);
return res.status(200).json({
message: "Discord account linked",
did,
});
} catch (e) {
return next(e);
}
}
static async unlinkDiscord(req, res, next) {
try {
const { uid } = req.decodedToken;
let userInfo;
try {
userInfo = await UsersDAO.getUser(uid);
} catch (e) {
throw new MonkeyError(400, "User not found.");
}
if (!userInfo.discordId) {
throw new MonkeyError(
400,
"User does not have a linked Discord account"
);
}
await BotDAO.unlinkDiscord(uid, userInfo.discordId);
await UsersDAO.unlinkDiscord(uid);
Logger.log("user_discord_unlinked", userInfo.discordId, uid);
return res.status(200).send();
} catch (e) {
return next(e);
}
}
static async addTag(req, res, next) {
try {
const { uid } = req.decodedToken;
const { tagName } = req.body;
if (!isTagPresetNameValid(tagName))
return res.status(400).json({
message:
"Tag name invalid. Name cannot contain special characters or more than 16 characters. Can include _ . and -",
});
let tag = await UsersDAO.addTag(uid, tagName);
return res.status(200).json(tag);
} catch (e) {
return next(e);
}
}
static async clearTagPb(req, res, next) {
try {
const { uid } = req.decodedToken;
const { tagid } = req.body;
await UsersDAO.removeTagPb(uid, tagid);
return res.sendStatus(200);
} catch (e) {
return next(e);
}
}
static async editTag(req, res, next) {
try {
const { uid } = req.decodedToken;
const { tagid, newname } = req.body;
if (!isTagPresetNameValid(newname))
return res.status(400).json({
message:
"Tag name invalid. Name cannot contain special characters or more than 16 characters. Can include _ . and -",
});
await UsersDAO.editTag(uid, tagid, newname);
return res.sendStatus(200);
} catch (e) {
return next(e);
}
}
static async removeTag(req, res, next) {
try {
const { uid } = req.decodedToken;
const { tagid } = req.body;
await UsersDAO.removeTag(uid, tagid);
return res.sendStatus(200);
} catch (e) {
return next(e);
}
}
static async getTags(req, res, next) {
try {
const { uid } = req.decodedToken;
let tags = await UsersDAO.getTags(uid);
if (tags == undefined) tags = [];
return res.status(200).json(tags);
} catch (e) {
return next(e);
}
}
static async updateLbMemory(req, res, next) {
try {
const { uid } = req.decodedToken;
const { mode, mode2, language, rank } = req.body;
await UsersDAO.updateLbMemory(uid, mode, mode2, language, rank);
return res.sendStatus(200);
} catch (e) {
return next(e);
}
}
}
module.exports = UserController;
==> ./monkeytype/backend/api/controllers/config.js <==
const ConfigDAO = require("../../dao/config");
const { validateConfig } = require("../../handlers/validation");
class ConfigController {
static async getConfig(req, res, next) {
try {
const { uid } = req.decodedToken;
let config = await ConfigDAO.getConfig(uid);
return res.status(200).json(config);
} catch (e) {
return next(e);
}
}
static async saveConfig(req, res, next) {
try {
const { config } = req.body;
const { uid } = req.decodedToken;
validateConfig(config);
await ConfigDAO.saveConfig(uid, config);
return res.sendStatus(200);
} catch (e) {
return next(e);
}
}
}
module.exports = ConfigController;
==> ./monkeytype/backend/api/controllers/new-quotes.js <==
const NewQuotesDAO = require("../../dao/new-quotes");
const MonkeyError = require("../../handlers/error");
const UserDAO = require("../../dao/user");
const Logger = require("../../handlers/logger.js");
// const Captcha = require("../../handlers/captcha");
class NewQuotesController {
static async getQuotes(req, res, next) {
try {
const { uid } = req.decodedToken;
const userInfo = await UserDAO.getUser(uid);
if (!userInfo.quoteMod) {
throw new MonkeyError(403, "You don't have permission to do this");
}
let data = await NewQuotesDAO.get();
return res.status(200).json(data);
} catch (e) {
return next(e);
}
}
static async addQuote(req, res, next) {
try {
throw new MonkeyError(
500,
"Quote submission is disabled temporarily. The queue is quite long and we need some time to catch up."
);
// let { uid } = req.decodedToken;
// let { text, source, language, captcha } = req.body;
// if (!text || !source || !language) {
// throw new MonkeyError(400, "Please fill all the fields");
// }
// if (!(await Captcha.verify(captcha))) {
// throw new MonkeyError(400, "Captcha check failed");
// }
// let data = await NewQuotesDAO.add(text, source, language, uid);
// return res.status(200).json(data);
} catch (e) {
return next(e);
}
}
static async approve(req, res, next) {
try {
let { uid } = req.decodedToken;
let { quoteId, editText, editSource } = req.body;
const userInfo = await UserDAO.getUser(uid);
if (!userInfo.quoteMod) {
throw new MonkeyError(403, "You don't have permission to do this");
}
if (editText === "" || editSource === "") {
throw new MonkeyError(400, "Please fill all the fields");
}
let data = await NewQuotesDAO.approve(quoteId, editText, editSource);
Logger.log("system_quote_approved", data, uid);
return res.status(200).json(data);
} catch (e) {
return next(e);
}
}
static async refuse(req, res, next) {
try {
let { uid } = req.decodedToken;
let { quoteId } = req.body;
await NewQuotesDAO.refuse(quoteId, uid);
return res.sendStatus(200);
} catch (e) {
return next(e);
}
}
}
module.exports = NewQuotesController;
==> ./monkeytype/backend/api/controllers/psa.js <==
const PsaDAO = require("../../dao/psa");
class PsaController {
static async get(req, res, next) {
try {
let data = await PsaDAO.get();
return res.status(200).json(data);
} catch (e) {
return next(e);
}
}
}
module.exports = PsaController;
==> ./monkeytype/backend/api/controllers/result.js <==
const ResultDAO = require("../../dao/result");
const UserDAO = require("../../dao/user");
const PublicStatsDAO = require("../../dao/public-stats");
const BotDAO = require("../../dao/bot");
const { validateObjectValues } = require("../../handlers/validation");
const { stdDev, roundTo2 } = require("../../handlers/misc");
const objecthash = require("object-hash");
const Logger = require("../../handlers/logger");
const path = require("path");
const { config } = require("dotenv");
config({ path: path.join(__dirname, ".env") });
let validateResult;
let validateKeys;
try {
let module = require("../../anticheat/anticheat");
validateResult = module.validateResult;
validateKeys = module.validateKeys;
if (!validateResult || !validateKeys) throw new Error("undefined");
} catch (e) {
if (process.env.MODE === "dev") {
console.error(
"No anticheat module found. Continuing in dev mode, results will not be validated."
);
} else {
console.error("No anticheat module found.");
console.error(
"To continue in dev mode, add 'MODE=dev' to the .env file in the backend directory."
);
process.exit(1);
}
}
class ResultController {
static async getResults(req, res, next) {
try {
const { uid } = req.decodedToken;
const results = await ResultDAO.getResults(uid);
return res.status(200).json(results);
} catch (e) {
next(e);
}
}
static async deleteAll(req, res, next) {
try {
const { uid } = req.decodedToken;
await ResultDAO.deleteAll(uid);
Logger.log("user_results_deleted", "", uid);
return res.sendStatus(200);
} catch (e) {
next(e);
}
}
static async updateTags(req, res, next) {
try {
const { uid } = req.decodedToken;
const { tags, resultid } = req.body;
await ResultDAO.updateTags(uid, resultid, tags);
return res.sendStatus(200);
} catch (e) {
next(e);
}
}
static async addResult(req, res, next) {
try {
const { uid } = req.decodedToken;
const { result } = req.body;
result.uid = uid;
if (validateObjectValues(result) > 0)
return res.status(400).json({ message: "Bad input" });
if (
result.wpm <= 0 ||
result.wpm > 350 ||
result.acc < 75 ||
result.acc > 100 ||
result.consistency > 100
) {
return res.status(400).json({ message: "Bad input" });
}
if (result.wpm == result.raw && result.acc != 100) {
return res.status(400).json({ message: "Bad input" });
}
if (
(result.mode === "time" && result.mode2 < 15 && result.mode2 > 0) ||
(result.mode === "time" &&
result.mode2 == 0 &&
result.testDuration < 15) ||
(result.mode === "words" && result.mode2 < 10 && result.mode2 > 0) ||
(result.mode === "words" &&
result.mode2 == 0 &&
result.testDuration < 15) ||
(result.mode === "custom" &&
result.customText !== undefined &&
!result.customText.isWordRandom &&
!result.customText.isTimeRandom &&
result.customText.textLen < 10) ||
(result.mode === "custom" &&
result.customText !== undefined &&
result.customText.isWordRandom &&
!result.customText.isTimeRandom &&
result.customText.word < 10) ||
(result.mode === "custom" &&
result.customText !== undefined &&
!result.customText.isWordRandom &&
result.customText.isTimeRandom &&
result.customText.time < 15)
) {
return res.status(400).json({ message: "Test too short" });
}
let resulthash = result.hash;
delete result.hash;
const serverhash = objecthash(result);
if (serverhash !== resulthash) {
Logger.log(
"incorrect_result_hash",
{
serverhash,
resulthash,
result,
},
uid
);
return res.status(400).json({ message: "Incorrect result hash" });
}
if (validateResult) {
if (!validateResult(result)) {
return res
.status(400)
.json({ message: "Result data doesn't make sense" });
}
} else {
if (process.env.MODE === "dev") {
console.error(
"No anticheat module found. Continuing in dev mode, results will not be validated."
);
} else {
throw new Error("No anticheat module found");
}
}
result.timestamp = Math.round(result.timestamp / 1000) * 1000;
//dont use - result timestamp is unreliable, can be changed by system time and stuff
// if (result.timestamp > Math.round(Date.now() / 1000) * 1000 + 10) {
// Logger.log(
// "time_traveler",
// {
// resultTimestamp: result.timestamp,
// serverTimestamp: Math.round(Date.now() / 1000) * 1000 + 10,
// },
// uid
// );
// return res.status(400).json({ message: "Time traveler detected" });
// this probably wont work if we replace the timestamp with the server time later
// let timestampres = await ResultDAO.getResultByTimestamp(
// uid,
// result.timestamp
// );
// if (timestampres) {
// return res.status(400).json({ message: "Duplicate result" });
// }
//convert result test duration to miliseconds
const testDurationMilis = result.testDuration * 1000;
//get latest result ordered by timestamp
let lastResultTimestamp;
try {
lastResultTimestamp =
(await ResultDAO.getLastResult(uid)).timestamp - 1000;
} catch (e) {
lastResultTimestamp = null;
}
result.timestamp = Math.round(Date.now() / 1000) * 1000;
//check if its greater than server time - milis or result time - milis
if (
lastResultTimestamp &&
(lastResultTimestamp + testDurationMilis > result.timestamp ||
lastResultTimestamp + testDurationMilis >
Math.round(Date.now() / 1000) * 1000)
) {
Logger.log(
"invalid_result_spacing",
{
lastTimestamp: lastResultTimestamp,
resultTime: result.timestamp,
difference:
lastResultTimestamp + testDurationMilis - result.timestamp,
},
uid
);
return res.status(400).json({ message: "Invalid result spacing" });
}
try {
result.keySpacingStats = {
average:
result.keySpacing.reduce(
(previous, current) => (current += previous)
) / result.keySpacing.length,
sd: stdDev(result.keySpacing),
};
} catch (e) {
//
}
try {
result.keyDurationStats = {
average:
result.keyDuration.reduce(
(previous, current) => (current += previous)
) / result.keyDuration.length,
sd: stdDev(result.keyDuration),
};
} catch (e) {
//
}
const user = await UserDAO.getUser(uid);
// result.name = user.name;
//check keyspacing and duration here for bots
if (
result.mode === "time" &&
result.wpm > 130 &&
result.testDuration < 122
) {
if (user.verified === false || user.verified === undefined) {
if (
result.keySpacingStats !== null &&
result.keyDurationStats !== null
) {
if (validateKeys) {
if (!validateKeys(result, uid)) {
return res
.status(400)
.json({ message: "Possible bot detected" });
}
} else {
if (process.env.MODE === "dev") {
console.error(
"No anticheat module found. Continuing in dev mode, results will not be validated."
);
} else {
throw new Error("No anticheat module found");
}
}
} else {
return res.status(400).json({ message: "Missing key data" });
}
}
}
delete result.keySpacing;
delete result.keyDuration;
delete result.smoothConsistency;
delete result.wpmConsistency;
try {
result.keyDurationStats.average = roundTo2(
result.keyDurationStats.average
);
result.keyDurationStats.sd = roundTo2(result.keyDurationStats.sd);
result.keySpacingStats.average = roundTo2(
result.keySpacingStats.average
);
result.keySpacingStats.sd = roundTo2(result.keySpacingStats.sd);
} catch (e) {
//
}
let isPb = false;
let tagPbs = [];
if (!result.bailedOut) {
isPb = await UserDAO.checkIfPb(uid, result);
tagPbs = await UserDAO.checkIfTagPb(uid, result);
}
if (isPb) {
result.isPb = true;
}
if (result.mode === "time" && String(result.mode2) === "60") {
UserDAO.incrementBananas(uid, result.wpm);
if (isPb && user.discordId) {
BotDAO.updateDiscordRole(user.discordId, result.wpm);
}
}
if (result.challenge && user.discordId) {
BotDAO.awardChallenge(user.discordId, result.challenge);
} else {
delete result.challenge;
}
let tt = 0;
let afk = result.afkDuration;
if (afk == undefined) {
afk = 0;
}
tt = result.testDuration + result.incompleteTestSeconds - afk;
await UserDAO.updateTypingStats(uid, result.restartCount, tt);
await PublicStatsDAO.updateStats(result.restartCount, tt);
if (result.bailedOut === false) delete result.bailedOut;
if (result.blindMode === false) delete result.blindMode;
if (result.lazyMode === false) delete result.lazyMode;
if (result.difficulty === "normal") delete result.difficulty;
if (result.funbox === "none") delete result.funbox;
if (result.language === "english") delete result.language;
if (result.numbers === false) delete result.numbers;
if (result.punctuation === false) delete result.punctuation;
if (result.mode !== "custom") delete result.customText;
let addedResult = await ResultDAO.addResult(uid, result);
if (isPb) {
Logger.log(
"user_new_pb",
`${result.mode + " " + result.mode2} ${result.wpm} ${result.acc}% ${
result.rawWpm
} ${result.consistency}% (${addedResult.insertedId})`,
uid
);
}
return res.status(200).json({
message: "Result saved",
isPb,
name: result.name,
tagPbs,
insertedId: addedResult.insertedId,
});
} catch (e) {
next(e);
}
}
static async getLeaderboard(req, res, next) {
try {
// const { type, mode, mode2 } = req.params;
// const results = await ResultDAO.getLeaderboard(type, mode, mode2);
// return res.status(200).json(results);
return res
.status(503)
.json({ message: "Leaderboard temporarily disabled" });
} catch (e) {
next(e);
}
}
static async checkLeaderboardQualification(req, res, next) {
try {
// const { uid } = req.decodedToken;
// const { result } = req.body;
// const data = await ResultDAO.checkLeaderboardQualification(uid, result);
// return res.status(200).json(data);
return res
.status(503)
.json({ message: "Leaderboard temporarily disabled" });
} catch (e) {
next(e);
}
}
}
module.exports = ResultController;
==> ./monkeytype/backend/api/routes/leaderboards.js <==
const { authenticateRequest } = require("../../middlewares/auth");
const LeaderboardsController = require("../controllers/leaderboards");
const RateLimit = require("../../middlewares/rate-limit");
const { Router } = require("express");
const router = Router();
router.get("/", RateLimit.leaderboardsGet, LeaderboardsController.get);
router.get(
"/rank",
RateLimit.leaderboardsGet,
authenticateRequest,
LeaderboardsController.getRank
);
module.exports = router;
==> ./monkeytype/backend/api/routes/preset.js <==
const { authenticateRequest } = require("../../middlewares/auth");
const PresetController = require("../controllers/preset");
const RateLimit = require("../../middlewares/rate-limit");
const { Router } = require("express");
const router = Router();
router.get(
"/",
RateLimit.presetsGet,
authenticateRequest,
PresetController.getPresets
);
router.post(
"/add",
RateLimit.presetsAdd,
authenticateRequest,
PresetController.addPreset
);
router.post(
"/edit",
RateLimit.presetsEdit,
authenticateRequest,
PresetController.editPreset
);
router.post(
"/remove",
RateLimit.presetsRemove,
authenticateRequest,
PresetController.removePreset
);
module.exports = router;
==> ./monkeytype/backend/api/routes/core.js <==
const { authenticateRequest } = require("../../middlewares/auth");
const { Router } = require("express");
const router = Router();
router.post("/test", authenticateRequest);
==> ./monkeytype/backend/api/routes/user.js <==
const { authenticateRequest } = require("../../middlewares/auth");
const { Router } = require("express");
const UserController = require("../controllers/user");
const RateLimit = require("../../middlewares/rate-limit");
const router = Router();
router.get(
"/",
RateLimit.userGet,
authenticateRequest,
UserController.getUser
);
router.post(
"/signup",
RateLimit.userSignup,
authenticateRequest,
UserController.createNewUser
);
router.post("/checkName", RateLimit.userCheckName, UserController.checkName);
router.post(
"/delete",
RateLimit.userDelete,
authenticateRequest,
UserController.deleteUser
);
router.post(
"/updateName",
RateLimit.userUpdateName,
authenticateRequest,
UserController.updateName
);
router.post(
"/updateLbMemory",
RateLimit.userUpdateLBMemory,
authenticateRequest,
UserController.updateLbMemory
);
router.post(
"/updateEmail",
RateLimit.userUpdateEmail,
authenticateRequest,
UserController.updateEmail
);
router.post(
"/clearPb",
RateLimit.userClearPB,
authenticateRequest,
UserController.clearPb
);
router.post(
"/tags/add",
RateLimit.userTagsAdd,
authenticateRequest,
UserController.addTag
);
router.get(
"/tags",
RateLimit.userTagsGet,
authenticateRequest,
UserController.getTags
);
router.post(
"/tags/clearPb",
RateLimit.userTagsClearPB,
authenticateRequest,
UserController.clearTagPb
);
router.post(
"/tags/remove",
RateLimit.userTagsRemove,
authenticateRequest,
UserController.removeTag
);
router.post(
"/tags/edit",
RateLimit.userTagsEdit,
authenticateRequest,
UserController.editTag
);
router.post(
"/discord/link",
RateLimit.userDiscordLink,
authenticateRequest,
UserController.linkDiscord
);
router.post(
"/discord/unlink",
RateLimit.userDiscordUnlink,
authenticateRequest,
UserController.unlinkDiscord
);
module.exports = router;
==> ./monkeytype/backend/api/routes/index.js <==
const pathOverride = process.env.API_PATH_OVERRIDE;
const BASE_ROUTE = pathOverride ? `/${pathOverride}` : "";
const API_ROUTE_MAP = {
"/user": require("./user"),
"/config": require("./config"),
"/results": require("./result"),
"/presets": require("./preset"),
"/psa": require("./psa"),
"/leaderboard": require("./leaderboards"),
"/quotes": require("./quotes"),
};
function addApiRoutes(app) {
app.get("/", (req, res) => {
res.status(200).json({ message: "OK" });
});
Object.keys(API_ROUTE_MAP).forEach((route) => {
const apiRoute = `${BASE_ROUTE}${route}`;
const router = API_ROUTE_MAP[route];
app.use(apiRoute, router);
});
}
module.exports = addApiRoutes;
==> ./monkeytype/backend/api/routes/config.js <==
const { authenticateRequest } = require("../../middlewares/auth");
const { Router } = require("express");
const ConfigController = require("../controllers/config");
const RateLimit = require("../../middlewares/rate-limit");
const router = Router();
router.get(
"/",
RateLimit.configGet,
authenticateRequest,
ConfigController.getConfig
);
router.post(
"/save",
RateLimit.configUpdate,
authenticateRequest,
ConfigController.saveConfig
);
module.exports = router;
==> ./monkeytype/backend/api/routes/quotes.js <==
const joi = require("joi");
const { authenticateRequest } = require("../../middlewares/auth");
const { Router } = require("express");
const NewQuotesController = require("../controllers/new-quotes");
const QuoteRatingsController = require("../controllers/quote-ratings");
const RateLimit = require("../../middlewares/rate-limit");
const { requestValidation } = require("../../middlewares/apiUtils");
const SUPPORTED_QUOTE_LANGUAGES = require("../../constants/quoteLanguages");
const quotesRouter = Router();
quotesRouter.get(
"/",
RateLimit.newQuotesGet,
authenticateRequest,
NewQuotesController.getQuotes
);
quotesRouter.post(
"/",
RateLimit.newQuotesAdd,
authenticateRequest,
NewQuotesController.addQuote
);
quotesRouter.post(
"/approve",
RateLimit.newQuotesAction,
authenticateRequest,
NewQuotesController.approve
);
quotesRouter.post(
"/reject",
RateLimit.newQuotesAction,
authenticateRequest,
NewQuotesController.refuse
);
quotesRouter.get(
"/rating",
RateLimit.quoteRatingsGet,
authenticateRequest,
QuoteRatingsController.getRating
);
quotesRouter.post(
"/rating",
RateLimit.quoteRatingsSubmit,
authenticateRequest,
QuoteRatingsController.submitRating
);
quotesRouter.post(
"/report",
RateLimit.quoteReportSubmit,
authenticateRequest,
requestValidation({
body: {
quoteId: joi.string().required(),
quoteLanguage: joi
.string()
.valid(...SUPPORTED_QUOTE_LANGUAGES)
.required(),
reason: joi
.string()
.valid(
"Grammatical error",
"Inappropriate content",
"Low quality content"
)
.required(),
comment: joi.string().allow("").max(250).required(),
},
}),
(req, res) => {
res.sendStatus(200);
}
);
module.exports = quotesRouter;
==> ./monkeytype/backend/api/routes/psa.js <==
const { authenticateRequest } = require("../../middlewares/auth");
const PsaController = require("../controllers/psa");
const RateLimit = require("../../middlewares/rate-limit");
const { Router } = require("express");
const router = Router();
router.get("/", RateLimit.psaGet, PsaController.get);
module.exports = router;
==> ./monkeytype/backend/api/routes/result.js <==
const { authenticateRequest } = require("../../middlewares/auth");
const { Router } = require("express");
const ResultController = require("../controllers/result");
const RateLimit = require("../../middlewares/rate-limit");
const router = Router();
router.get(
"/",
RateLimit.resultsGet,
authenticateRequest,
ResultController.getResults
);
router.post(
"/add",
RateLimit.resultsAdd,
authenticateRequest,
ResultController.addResult
);
router.post(
"/updateTags",
RateLimit.resultsTagsUpdate,
authenticateRequest,
ResultController.updateTags
);
router.post(
"/deleteAll",
RateLimit.resultsDeleteAll,
authenticateRequest,
ResultController.deleteAll
);
router.get(
"/getLeaderboard/:type/:mode/:mode2",
RateLimit.resultsLeaderboardGet,
ResultController.getLeaderboard
);
router.post(
"/checkLeaderboardQualification",
RateLimit.resultsLeaderboardQualificationGet,
authenticateRequest,
ResultController.checkLeaderboardQualification
);
module.exports = router;
==> ./monkeytype/backend/jobs/deleteOldLogs.js <==
const { CronJob } = require("cron");
const { mongoDB } = require("../init/mongodb");
const CRON_SCHEDULE = "0 0 0 * * *";
const LOG_MAX_AGE_DAYS = 7;
const LOG_MAX_AGE_MILLISECONDS = LOG_MAX_AGE_DAYS * 24 * 60 * 60 * 1000;
async function deleteOldLogs() {
const data = await mongoDB()
.collection("logs")
.deleteMany({ timestamp: { $lt: Date.now() - LOG_MAX_AGE_MILLISECONDS } });
Logger.log(
"system_logs_deleted",
`${data.deletedCount} logs deleted older than ${LOG_MAX_AGE_DAYS} day(s)`,
undefined
);
}
module.exports = new CronJob(CRON_SCHEDULE, deleteOldLogs);
==> ./monkeytype/backend/jobs/index.js <==
const updateLeaderboards = require("./updateLeaderboards");
const deleteOldLogs = require("./deleteOldLogs");
module.exports = [updateLeaderboards, deleteOldLogs];
==> ./monkeytype/backend/jobs/updateLeaderboards.js <==
const { CronJob } = require("cron");
const { mongoDB } = require("../init/mongodb");
const BotDAO = require("../dao/bot");
const LeaderboardsDAO = require("../dao/leaderboards");
const CRON_SCHEDULE = "30 4/5 * * * *";
const RECENT_AGE_MINUTES = 10;
const RECENT_AGE_MILLISECONDS = RECENT_AGE_MINUTES * 60 * 1000;
async function getTop10(leaderboardTime) {
return await LeaderboardsDAO.get("time", leaderboardTime, "english", 0, 10);
}
async function updateLeaderboardAndNotifyChanges(leaderboardTime) {
const top10BeforeUpdate = await getTop10(leaderboardTime);
const previousRecordsMap = Object.fromEntries(
top10BeforeUpdate.map((record) => {
return [record.uid, record];
})
);
await LeaderboardsDAO.update("time", leaderboardTime, "english");
const top10AfterUpdate = await getTop10(leaderboardTime);
const newRecords = top10AfterUpdate.filter((record) => {
const userId = record.uid;
const userImprovedRank =
userId in previousRecordsMap &&
previousRecordsMap[userId].rank > record.rank;
const newUserInTop10 = !(userId in previousRecordsMap);
const isRecentRecord =
record.timestamp > Date.now() - RECENT_AGE_MILLISECONDS;
return (userImprovedRank || newUserInTop10) && isRecentRecord;
});
if (newRecords.length > 0) {
await BotDAO.announceLbUpdate(
newRecords,
`time ${leaderboardTime} english`
);
}
}
async function updateLeaderboards() {
await updateLeaderboardAndNotifyChanges("15");
await updateLeaderboardAndNotifyChanges("60");
}
module.exports = new CronJob(CRON_SCHEDULE, updateLeaderboards);
==> ./monkeytype/backend/handlers/logger.js <==
const { mongoDB } = require("../init/mongodb");
async function log(event, message, uid) {
console.log(new Date(), "t", event, "t", uid, "t", message);
await mongoDB().collection("logs").insertOne({
timestamp: Date.now(),
uid,
event,
message,
});
}
module.exports = {
log,
};
==> ./monkeytype/backend/handlers/misc.js <==
module.exports = {
roundTo2(num) {
return Math.round((num + Number.EPSILON) * 100) / 100;
},
stdDev(array) {
const n = array.length;
const mean = array.reduce((a, b) => a + b) / n;
return Math.sqrt(
array.map((x) => Math.pow(x - mean, 2)).reduce((a, b) => a + b) / n
);
},
mean(array) {
try {
return (
array.reduce((previous, current) => (current += previous)) /
array.length
);
} catch (e) {
return 0;
}
},
};
==> ./monkeytype/backend/handlers/auth.js <==
const admin = require("firebase-admin");
module.exports = {
async verifyIdToken(idToken) {
return await admin.auth().verifyIdToken(idToken);
},
async updateAuthEmail(uid, email) {
return await admin.auth().updateUser(uid, {
email,
emailVerified: false,
});
},
};
==> ./monkeytype/backend/handlers/pb.js <==
/*
obj structure
time: {
10: [ - this is a list because there can be
different personal bests for different difficulties, languages and punctuation
{
acc,
consistency,
difficulty,
language,
punctuation,
raw,
timestamp,
wpm
}
]
},
words: {
10: [
{}
]
},
zen: {
zen: [
{}
]
},
custom: {
custom: {
[]
}
}
*/
module.exports = {
checkAndUpdatePb(
obj,
lbObj,
mode,
mode2,
acc,
consistency,
difficulty,
lazyMode = false,
language,
punctuation,
raw,
wpm
) {
//verify structure first
if (obj === undefined) obj = {};
if (obj[mode] === undefined) obj[mode] = {};
if (obj[mode][mode2] === undefined) obj[mode][mode2] = [];
let isPb = false;
let found = false;
//find a pb
obj[mode][mode2].forEach((pb) => {
//check if we should compare first
if (
(pb.lazyMode === lazyMode ||
(pb.lazyMode === undefined && lazyMode === false)) &&
pb.difficulty === difficulty &&
pb.language === language &&
pb.punctuation === punctuation
) {
found = true;
//compare
if (pb.wpm < wpm) {
//update
isPb = true;
pb.acc = acc;
pb.consistency = consistency;
pb.difficulty = difficulty;
pb.language = language;
pb.punctuation = punctuation;
pb.lazyMode = lazyMode;
pb.raw = raw;
pb.wpm = wpm;
pb.timestamp = Date.now();
}
}
});
//if not found push a new one
if (!found) {
isPb = true;
obj[mode][mode2].push({
acc,
consistency,
difficulty,
lazyMode,
language,
punctuation,
raw,
wpm,
timestamp: Date.now(),
});
}
if (
lbObj &&
mode === "time" &&
(mode2 == "15" || mode2 == "60") &&
!lazyMode
) {
//updating lbpersonalbests object
//verify structure first
if (lbObj[mode] === undefined) lbObj[mode] = {};
if (lbObj[mode][mode2] === undefined || Array.isArray(lbObj[mode][mode2]))
lbObj[mode][mode2] = {};
let bestForEveryLanguage = {};
if (obj?.[mode]?.[mode2]) {
obj[mode][mode2].forEach((pb) => {
if (!bestForEveryLanguage[pb.language]) {
bestForEveryLanguage[pb.language] = pb;
} else {
if (bestForEveryLanguage[pb.language].wpm < pb.wpm) {
bestForEveryLanguage[pb.language] = pb;
}
}
});
Object.keys(bestForEveryLanguage).forEach((key) => {
if (lbObj[mode][mode2][key] === undefined) {
lbObj[mode][mode2][key] = bestForEveryLanguage[key];
} else {
if (lbObj[mode][mode2][key].wpm < bestForEveryLanguage[key].wpm) {
lbObj[mode][mode2][key] = bestForEveryLanguage[key];
}
}
});
bestForEveryLanguage = {};
}
}
return {
isPb,
obj,
lbObj,
};
},
};
==> ./monkeytype/backend/handlers/error.js <==
const uuid = require("uuid");
class MonkeyError {
constructor(status, message, stack = null, uid) {
this.status = status ?? 500;
this.errorID = uuid.v4();
this.stack = stack;
// this.message =
// process.env.MODE === "dev"
// ? stack
// ? String(stack)
// : this.status === 500
// ? String(message)
// : message
// : "Internal Server Error " + this.errorID;
if (process.env.MODE === "dev") {
this.message = stack
? String(message) + "\nStack: " + String(stack)
: String(message);
} else {
if (this.stack && this.status >= 500) {
this.message = "Internal Server Error " + this.errorID;
} else {
this.message = String(message);
}
}
}
}
module.exports = MonkeyError;
==> ./monkeytype/backend/handlers/validation.js <==
const MonkeyError = require("./error");
function isUsernameValid(name) {
if (name === null || name === undefined || name === "") return false;
if (/.*miodec.*/.test(name.toLowerCase())) return false;
//sorry for the bad words
if (
/.*(bitly|fuck|bitch|shit|pussy|nigga|niqqa|niqqer|nigger|ni99a|ni99er|niggas|niga|niger|cunt|faggot|retard).*/.test(
name.toLowerCase()
)
)
return false;
if (name.length > 14) return false;
if (/^\..*/.test(name.toLowerCase())) return false;
return /^[0-9a-zA-Z_.-]+$/.test(name);
}
function isTagPresetNameValid(name) {
if (name === null || name === undefined || name === "") return false;
if (name.length > 16) return false;
return /^[0-9a-zA-Z_.-]+$/.test(name);
}
function isConfigKeyValid(name) {
if (name === null || name === undefined || name === "") return false;
if (name.length > 40) return false;
return /^[0-9a-zA-Z_.\-#+]+$/.test(name);
}
function validateConfig(config) {
Object.keys(config).forEach((key) => {
if (!isConfigKeyValid(key)) {
throw new MonkeyError(500, `Invalid config: ${key} failed regex check`);
}
// if (key === "resultFilters") return;
// if (key === "customBackground") return;
if (key === "customBackground" || key === "customLayoutfluid") {
let val = config[key];
if (/[<>]/.test(val)) {
throw new MonkeyError(
500,
`Invalid config: ${key}:${val} failed regex check`
);
}
} else {
let val = config[key];
if (Array.isArray(val)) {
val.forEach((valarr) => {
if (!isConfigKeyValid(valarr)) {
throw new MonkeyError(
500,
`Invalid config: ${key}:${valarr} failed regex check`
);
}
});
} else {
if (!isConfigKeyValid(val)) {
throw new MonkeyError(
500,
`Invalid config: ${key}:${val} failed regex check`
);
}
}
}
});
return true;
}
function validateObjectValues(val) {
let errCount = 0;
if (val === null || val === undefined) {
//
} else if (Array.isArray(val)) {
//array
val.forEach((val2) => {
errCount += validateObjectValues(val2);
});
} else if (typeof val === "object" && !Array.isArray(val)) {
//object
Object.keys(val).forEach((valkey) => {
errCount += validateObjectValues(val[valkey]);
});
} else {
if (!/^[0-9a-zA-Z._\-+]+$/.test(val)) {
errCount++;
}
}
return errCount;
}
module.exports = {
isUsernameValid,
isTagPresetNameValid,
validateConfig,
validateObjectValues,
};
==> ./monkeytype/backend/handlers/captcha.js <==
const fetch = require("node-fetch");
const path = require("path");
const { config } = require("dotenv");
config({ path: path.join(__dirname, ".env") });
module.exports = {
async verify(captcha) {
if (process.env.MODE === "dev") return true;
let response = await fetch(
`https://www.google.com/recaptcha/api/siteverify`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `secret=${process.env.RECAPTCHA_SECRET}&response=${captcha}`,
}
);
response = await response.json();
return response?.success;
},
};
==> ./monkeytype/backend/handlers/pb_old.js <==
// module.exports = {
// check(result, userdata) {
// let pbs = null;
// if (result.mode == "quote") {
// return false;
// }
// if (result.funbox !== "none") {
// return false;
// }
// pbs = userdata?.personalBests;
// if(pbs === undefined){
// //userdao set personal best
// return true;
// }
// // try {
// // pbs = userdata.personalBests;
// // if (pbs === undefined) {
// // throw new Error("pb is undefined");
// // }
// // } catch (e) {
// // User.findOne({ uid: userdata.uid }, (err, user) => {
// // user.personalBests = {
// // [result.mode]: {
// // [result.mode2]: [
// // {
// // language: result.language,
// // difficulty: result.difficulty,
// // punctuation: result.punctuation,
// // wpm: result.wpm,
// // acc: result.acc,
// // raw: result.rawWpm,
// // timestamp: Date.now(),
// // consistency: result.consistency,
// // },
// // ],
// // },
// // };
// // }).then(() => {
// // return true;
// // });
// // }
// let toUpdate = false;
// let found = false;
// try {
// if (pbs[result.mode][result.mode2] === undefined) {
// pbs[result.mode][result.mode2] = [];
// }
// pbs[result.mode][result.mode2].forEach((pb) => {
// if (
// pb.punctuation === result.punctuation &&
// pb.difficulty === result.difficulty &&
// pb.language === result.language
// ) {
// //entry like this already exists, compare wpm
// found = true;
// if (pb.wpm < result.wpm) {
// //new pb
// pb.wpm = result.wpm;
// pb.acc = result.acc;
// pb.raw = result.rawWpm;
// pb.timestamp = Date.now();
// pb.consistency = result.consistency;
// toUpdate = true;
// } else {
// //no pb
// return false;
// }
// }
// });
// //checked all pbs, nothing found - meaning this is a new pb
// if (!found) {
// pbs[result.mode][result.mode2] = [
// {
// language: result.language,
// difficulty: result.difficulty,
// punctuation: result.punctuation,
// wpm: result.wpm,
// acc: result.acc,
// raw: result.rawWpm,
// timestamp: Date.now(),
// consistency: result.consistency,
// },
// ];
// toUpdate = true;
// }
// } catch (e) {
// // console.log(e);
// pbs[result.mode] = {};
// pbs[result.mode][result.mode2] = [
// {
// language: result.language,
// difficulty: result.difficulty,
// punctuation: result.punctuation,
// wpm: result.wpm,
// acc: result.acc,
// raw: result.rawWpm,
// timestamp: Date.now(),
// consistency: result.consistency,
// },
// ];
// toUpdate = true;
// }
// if (toUpdate) {
// // User.findOne({ uid: userdata.uid }, (err, user) => {
// // user.personalBests = pbs;
// // user.save();
// // });
// //userdao update the whole personalBests parameter with pbs object
// return true;
// } else {
// return false;
// }
// }
// }
==> ./monkeytype/backend/credentials/.gitkeep <==
==> ./monkeytype/.prettierignore <==
*.min.js
*.min.css
layouts.js
quotes/*
chartjs-plugin-*.js
sound/*
node_modules
css/balloon.css
_list.json
==> ./monkeytype/.editorconfig <==
root = true
[*.{html,js,css,scss,json,yml,yaml}]
indent_size = 2
indent_style = space
==> ./monkeytype/.gitignore <==
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
firebase-debug.log*
# Firebase cache
.firebase/
# Firebase config
# Uncomment this if you'd like others to create their own Firebase project.
# For a team working on the same Firebase project(s), it is recommended to leave
# it commented so all members can deploy to the same project(s) in .firebaserc.
# .firebaserc
# Runtime data
pids
*.pid
*.seed
*.pid.lock
#Mac files
.DS_Store
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
#vs code
.vscode
*.code-workspace
.idea
#firebase
.firebaserc
.firebaserc_copy
serviceAccountKey*.json
#generated files
dist/
#cloudflare y
.cloudflareKey.txt
.cloudflareKey_copy.txt
purgeCfCache.sh
static/adtest.html
backend/lastId.txt
backend/log_success.txt
backend/credentials/*.json
backend/.env
static/adtest.html
backend/migrationStats.txt
backend/anticheat
==> ./monkeytype/static/index.html <==
Monkeytype
:(
It seems like the CSS failed to load. Please clear your cache to
redownload the styles. If that doesn't help contact support.
(jack@monkeytype.com or discord.gg/monkeytype)
(ctrl/cmd + shift + r on Chromium browsers)
If the website works for a bit but then this screen comes back, clear
your cache again and then on Monkeytype open the command line (esc)
and search for "Clear SW cache".
Important information about your account. Please click this message.
punctuation
numbers
time
words
quote
zen
custom
15
30
60
120
custom
10
25
50
100
custom
all
short
medium
long
thicc
search
change
words
wpm
accuracy
raw
consistency
difficulty
language
punctuation
lazy mode
date
Practice words
This will start a new test in custom mode. Words that you mistyped
more often or words that you typed much slower will be weighted higher
and appear more often.
Practice missed
Practice slow
Practice both
-
id
-
length
-
source
-
ratings
-
average
-
your rating
import settings
ok
Words filter
or
ok
language
min length
max length
include
exclude
Use the above filters to include and exclude words or characters
(separated by spaces)
"Set" replaces the current custom word list with the filter result,
"Add" appends the filter result to the current custom word list
set
add
Word amount
You can start an infinite test by inputting 0. Then, to stop the test,
use the Bail Out feature (esc or ctrl/cmd + shift + p > Bail Out)
ok
Test duration
You can start an infinite test by inputting 0. Then, to stop the test,
use the Bail Out feature (esc or ctrl/cmd + shift + p > Bail Out)
ok
Quote Search
Submit a quote
Approve quotes
No search results
Submit a Quote
Do not include content that contains any libelous or otherwise
unlawful, abusive or obscene text.
Verify quotes added aren't duplicates of any already present
Please do not add extremely short quotes (less than 60 characters)
Submitting low quality quotes or misusing this form will cause you
to lose access to this feature
-
Submit
Approve Quotes
Refresh list
Leaderboards
Next update in: --:--
English Time 15
Jump to:
#
name
wpm
accuracy
raw
consistency
test
date
English Time 60
Jump to:
#
name
wpm
accuracy
raw
consistency
test
date
v1
010101
test
v2
010101
test
Support Monkeytype
Thank you so much for thinking about supporting this project. It would
not be possible without you and your continued support.
Created with love by Miodec.
Supported
and
expanded
by many awesome people. Launched on 15th of May, 2020.
about
Monkeytype is a minimalistic typing test, featuring many test
modes, an account system to save your typing speed history and
user configurable features like themes, a smooth caret and more.
word set
By default, this website uses the most common 200 words in the
English language to generate its tests. You can change to an
expanded set (1000 most common words) in the options, or change
the language entirely.
keybinds
You can use
tab
and
enter
(or just
tab
if you have quick tab mode enabled) to restart the typing test.
Open the command line by pressing
ctrl/cmd
+
shift
+
p
or
esc
- there you can access all the functionality you need without
touching your mouse
stats
wpm - total amount of characters in the correctly typed words
(including spaces), divided by 5 and normalised to 60 seconds.
raw wpm - calculated just like wpm, but also includes incorrect
words.
acc - percentage of correctly pressed keys.
char - correct characters / incorrect characters. Calculated
after the test has ended.
consistency - based on the variance of your raw wpm. Closer to
100% is better. Calculated using the coefficient of variation of
raw wpm and mapped onto a scale from 0 to 100.
results screen
After completing a test you will be able to see your wpm, raw
wpm, accuracy, character stats, test length, leaderboards info
and test info. (you can hover over some values to get floating
point numbers). You can also see a graph of your wpm and raw
over the duration of the test. Remember that the wpm line is a
global average, while the raw wpm line is a local, momentary
value. (meaning if you stop, the value is 0)
bug report or feature request
If you encounter a bug, or have a feature request - join the
Discord server, send me an email, a direct message on Twitter or
create an issue on GitHub.
support
Thanks to everyone who has supported this project. It would not
be possible without you and your continued support.
When you connect your monkeytype account to your Discord
account, you will be automatically assigned a new role every
time you achieve a new personal best in a 60 second test. If
you pair your accounts before joining the Discord server the
bot
will not
give you a role.
With tags, you can compare how fast you're typing in different
situations. You can see your active tags above the test words.
They will remain active until you deactivate them, or refresh
the page.
staggered
presets
Create settings presets that can be applied with one click.
Remember to edit your preset if you make any changes - they
don't save on their own.
staggered
behavior
test difficulty
Normal is the classic type test experience. Expert fails the
test if you submit (press space) an incorrect word. Master
fails if you press a single incorrect key (meaning you have to
achieve 100% accuracy).
normal
expert
master
quick tab mode
Press
tab
to quickly restart the test, or to quickly jump to the test
page. This function disables tab navigation on the website.
off
on
repeat quotes
This setting changes the restarting behavior when typing in
quote mode. Changing it to 'typing' will repeat the quote if
you restart while typing.
off
typing
blind mode
No errors or incorrect words are highlighted. Helps you to
focus on raw speed. If enabled, quick end is recommended.
off
always show words history
This option will automatically show the words history at the
end of the test. Can cause slight lag with a lot of words.
off
on
single list command line
When enabled, it will show the command line with all commands
in a single list instead of submenu arrangements. Selecting
'manual' will expose all commands only after typing
>
.
manual
on
min wpm
Automatically fails a test if your WPM falls below a
threshold.
off
custom
min accuracy
Automatically fails a test if your accuracy falls below a
threshold.
off
custom
min burst
Automatically fails a test if your raw for a single word falls
below this threshold. Selecting 'flex' allows for this
threshold to automatically decrease for longer words.
off
fixed
flex
british english
When enabled, the website will use the British spelling
instead of American. Note that this might not replace all
words correctly. If you find any issues, please let us know.
off
on
language groups
language
funbox
These are special modes that change the website in some
special way (by altering the word generation, behavior of the
website or the looks). Give each one of them a try!
custom layoutfluid
Select which layouts you want the layoutfluid funbox to cycle
through.
input
freedom mode
Allows you to delete any word, even if it was typed correctly.
off
on
strict space
Pressing space at the beginning of a word will insert a space
character when this mode is enabled.
off
on
opposite shift mode
This mode will force you to use opposite
shift
keys for shifting. Using an incorrect one will count as an
error. This feature ignores keys in locations
B
,
Y
, and
^
because many people use the other hand for those keys. If
you're using external software to emulate your layout
(including QMK), you should use the "keymap" mode - the
standard "on" will not work. This will enforce opposite shift
based on the "keymap layout" setting.
off
on
keymap
stop on error
Letter mode will stop input when pressing any incorrect
letters. Word mode will not allow you to continue to the next
word until you correct all mistakes.
off
word
letter
confidence mode
When enabled, you will not be able to go back to previous
words to fix mistakes. When turned up to the max, you won't be
able to backspace at all.
off
on
max
quick end
This only applies to the words mode - when enabled, the test
will end as soon as the last word has been typed, even if it's
incorrect. When disabled, you need to manually confirm the
last incorrect entry with a space.
off
on
indicate typos
Shows typos underneath the letters.
off
on
hide extra letters
Hides extra letters. This will completely avoid words jumping
lines (due to changing width), but might feel a bit confusing
when you press a key and nothing happens.
off
on
swap esc and tab
Swap the behavior of tab and escape keys.
off
on
lazy mode
Replaces accented letters with their normal equivalents.
off
on
layout emulator
With this setting you can emulate other layouts. This setting
is best kept off, as it can break things like dead keys and
alt layers.
sound
sound volume
Change the volume of the sound effects.
quiet
medium
loud
play sound on click
Plays a short sound when you press a key.
off
click
beep
pop
nk creams
typewriter
osu
hitmarker
play sound on error
Plays a short sound if you press an incorrect key or press
space too early.
off
on
caret
smooth caret
The caret will move smoothly between letters and words.
off
on
caret style
Change the style of the caret during the test.
off
|
_
pace caret
Displays a second caret that moves at constant speed. The
'average' option averages the speed of last 10 results.
off
average
pb
custom
repeated pace
When repeating a test, a pace caret will automatically be
enabled for one test with the speed of your previous test. It
does not override the pace caret if it's already enabled.
off
on
pace caret style
Change the style of the pace caret during the test.
off
|
_
appearance
timer/progress style
Change the style of the timer/progress during a timed test.
bar
text
mini
timer/progress color
Change the color of the timer/progress number/bar and live wpm
number.
black
sub
text
main
timer/progress opacity
Change the opacity of the timer/progress number/bar and live
wpm number.
0.25
0.5
0.75
1
highlight mode
Change what is highlighted during the test.
off
letter
word
smooth line scroll
When enabled, the line transition will be animated.
off
on
show all lines
When enabled, the website will show all lines for word, custom
and quote mode tests - otherwise the lines will be limited to
3, and will automatically scroll. Using this could cause the
timer text and live wpm to not be visible.
off
on
always show decimal places
Always shows decimal places for values on the result page,
without the need to hover over the stats.
off
on
always show cpm
Always shows characters per minute calculation instead of the
default words per minute calculation.
off
on
start graphs at zero
Force graph axis to always start at zero, no matter what the
data is. Turning this off may exaggerate the value changes.
off
on
font size
Change the font size of the test words.
1
1.25
1.5
2
3
4
font family
page width
Control the width of the content.
100%
125%
150%
200%
Max
keymap
Displays your current layout while taking a test. React shows
what you pressed and Next shows what you need to press next.
off
static
react
next
keymap style
staggered
alice
matrix
split
split matrix
keymap legend style
lowercase
uppercase
blank
keymap layout
theme
flip test colors
By default, typed text is brighter than the future text. When
enabled, the colors will be flipped and the future text will
be brighter than the already typed text.
off
on
colorful mode
When enabled, the test words will use the main color, instead
of the text color, making the website more colorful.
off
on
custom background
Set an image url to be a custom background image. Cover fits
the image to cover the screen. Contain fits the image to be
fully visible. Max fits the image corner to corner.
cover
contain
max
custom background filter
Apply various effects to the custom background.
blur
brightness
saturate
opacity
save
randomize theme
After completing a test, the theme will be set to a random
one. The random themes are not saved to your config. If set to
'fav' only favourite themes will be randomized. If set to
'light' or 'dark', only presets with light or dark background
colors will be randomized, respectively.
off
on
favorite
light
dark
theme
preset
custom
colorful mode
load from preset
share
save
hide elements
live wpm
Displays a live WPM speed during the test. Updates once every
second.
hide
show
live accuracy
Displays live accuracy during the test.
hide
show
live burst
Displays live burst during the test of the last word you
typed.
hide
show
timer/progress
Displays a live timer for timed tests and progress for
words/custom tests.
hide
show
key tips
Shows the keybind tips at the bottom of the page.
hide
show
out of focus warning
Shows an out of focus reminder after 1 second of being 'out of
focus' (not being able to type).
hide
show
caps lock warning
Displays a warning when caps lock is on.
hide
show
danger zone
import/export settings
Import or export the settings as JSON.
import
export
enable ads
If you wish to support me without directly donating you can
enable ads that will be visible at the bottom of the screen.
Sellout mode also shows ads on both sides of the screen.
(changes will take effect after a refresh).
off
on
sellout
reset settings
Resets settings to the default (but doesn't touch your tags).
Warning: you can't undo this action!
reset settings
reset personal bests
Resets all your personal bests (but doesn't delete any tests
from your history). Warning: you can't undo this action!
reset personal bests
update account name
Change the name of your account. You can only do this once
every 30 days.
update name
password authentication settings
Add password authentication, update your password or email.
add password authentication
update email
update password
google authentication settings
Add or remove Google authentication.
add google authentication
remove google authentication
delete account
Deletes your account and all data connected to it.
delete account
register
login
Forgot password?
tests started
-
tests completed
-
time typing
-
Account created on -
personal bests
time
wpm
accuracy
raw
consistency
date
15
-
-
-
30
-
-
-
60
-
-
-
120
-
-
-
show all
words
wpm
accuracy
raw
consistency
date
10
-
-
-
25
-
-
-
50
-
-
-
100
-
-
-
show all
filters
all
current settings
advanced
last day
last week
last month
last 3 months
all time
advanced filters
clear filters
difficulty
normal
expert
master
mode
words
time
quote
zen
custom
quote length
short
medium
long
thicc
words
10
25
50
100
custom
time
15
30
60
120
custom
punctuation
on
off
numbers
on
off
tags
language
funbox
No data found. Check your filters.
Toggle Accuracy
Toggle Chart Style
tests started
-
tests completed
-
-
time typing
-
highest wpm
-
average wpm
-
average wpm
(last 10 tests)
-
highest raw wpm
-
average raw wpm
-
average raw wpm
(last 10 tests)
-
avg accuracy
-
avg accuracy
(last 10 tests)
-
avg consistency
-
avg consistency
(last 10 tests)
-
wpm
raw
accuracy
consistency
chars
mode
info
tags
date
load more
tab
and
enter
- Restart Test
ctrl/cmd
+
shift
+
p
or
esc
- Command Line
Thanks for trusting Monkeytype ('Monkeytype', 'we', 'us', 'our') with
your personal information! We take our
responsibility to you very seriously, and so this Privacy Statement
describes how we handle your data.
This Privacy Statement applies to all websites we own and operate and
to all services we provide (collectively, the 'Services'). So...PLEASE
READ THIS PRIVACY STATEMENT CAREFULLY. By using the Services, you are
expressly and voluntarily accepting the terms and conditions of this
Privacy Statement and our Terms of Service, which include allowing us
to process information about you.
Under this Privacy Statement, we are the data controller responsible
for processing your personal information. Our contact information
appears at the end of this Privacy Statement.
How many typing tests you've started and completed
How long you've been typing on the website
How do we collect your data?
You directly provide most of the data we collect. We collect data and
process data when you:
Create an account
Complete a typing test
Change settings on the website
How will we use your data?
Monkeytype collects your data so that we can:
Allow you to view result history of previous tests you completed
Save results from tests you take and show you statistics based on
them
Remember your settings
Display leaderboards
How do we store your data?
Monkeytype securely stores your data using Firebase Firestore.
What are your data protection rights?
Monkeytype would like to make sure you are fully aware of all of your
data protection rights. Every user is entitled to the following:
The right to access – You have the right to request Monkeytype for
copies of your personal data. We may limit the number of times this
request can be made to depending on the size of the request.
The right to rectification – You have the right to request that
Monkeytype correct any information you believe is inaccurate. You
also have the right to request Monkeytype to complete the
information you believe is incomplete.
The right to erasure – You have the right to request that Monkeytype
erase your personal data, under certain conditions.
The right to restrict processing – You have the right to request
that Monkeytype restrict the processing of your personal data, under
certain conditions.
The right to object to processing – You have the right to object to
Monkeytype processing of your personal data, under certain
conditions.
The right to data portability – You have the right to request that
Monkeytype transfer the data that we have collected to another
organization, or directly to you, under certain conditions.
What log data do we collect?
Like most websites, Monkeytype collects information that your browser
sends whenever you visit the website. This data may include internet
protocol (IP) addresses, browser type, Internet Service Provider
(ISP), date and time stamp, referring/exit pages, and time spent on
each page.
THIS DATA DOES NOT CONTAIN ANY PERSONALLY IDENTIFIABLE INFORMATION.
We use this information for analyzing trends, administering the site,
tracking users' movement on the website, and gathering demographic
information.
In our case, this service is provided by Google Analytics.
What are cookies?
Cookies are text files placed on your computer to collect standard
Internet log information and visitor behavior information. When you
visit our websites, we may collect information from you automatically
through cookies or similar technology
Monkeytype uses cookies in a range of ways to improve your experience
on our website, including:
Keeping you signed in
Remembering your active settings
Remembering your active tags
What types of cookies do we use?
There are a number of different types of cookies; however, our website
uses functionality cookies. Monkeytype uses these cookies so we
recognize you on our website and remember your previously selected
settings.
How to manage your cookies
You can set your browser not to accept cookies, and the above website
tells you how to remove cookies from your browser. However, in a few
cases, some of our website features may behave unexpectedly or fail to
function as a result.
Privacy policies of other websites
Monkeytype contains links to other external websites.
Our privacy policy only applies to our website, so if you click on
a link to another website, you should read their privacy policy.
Changes to our privacy policy
Monkeytype keeps its privacy policy under regular review and places
any updates on this web page. The Monkeytype privacy policy may be
subject to change at any given time without notice. This privacy
policy was last updated on 22 April 2021.
How to contact us
If you have any questions about Monkeytype’s privacy policy, the data
we hold on you, or you would like to exercise one of your data
protection rights, please do not hesitate to contact us.
==> ./monkeytype/static/terms-of-service.html <==
Terms of Service | Monkeytype
monkey see
monkeytype
Terms of Service
These terms of service were last updated on September 11, 2021.
Agreement
By accessing this Website, accessible from monkeytype.com, you are
agreeing to be bound by these Website Terms of Service and agree that
you are responsible for the agreement in accordance with any
applicable local laws.
IF YOU DO NOT AGREE TO ALL THE TERMS AND CONDITIONS OF THIS
AGREEMENT, YOU ARE NOT PERMITTED TO ACCESS OR USE OUR SERVICES.
Limitations
You are responsible for your account's security and all activities on
your account. You must not, in the use of this site, violate any
applicable laws, including, without limitation, copyright laws, or any
other laws regarding the security of your personal data, or otherwise
misuse this site.
Monkeytype reserves the right to remove or disable any account or any
other content on this site at any time for any reason, without prior
notice to you, if we believe that you have violated this agreement.
You agree that you will not upload, post, host, or transmit any
content that:
is unlawful or promotes unlawful activities;
is or contains sexually obscene content;
is libelous, defamatory, or fraudulent;
is discriminatory or abusive toward any individual or group;
is degrading to others on the basis of gender, race, class,
ethnicity, national origin, religion, sexual preference,
orientation, or identity, disability, or other classification, or
otherwise represents or condones content that: is hate speech,
discriminating, threatening, or pornographic; incites violence; or
contains nudity or graphic or gratuitous violence;
violates any person's right to privacy or publicity, or otherwise
solicits, collects, or publishes data, including personal
information and login information, about other Users without consent
or for unlawful purposes in violation of any applicable
international, federal, state, or local law, statute, ordinance, or
regulation; or
contains or installs any active malware or exploits/uses our
platform for exploit delivery (such as part of a command or control
system); or infringes on any proprietary right of any party,
including patent, trademark, trade secret, copyright, right of
publicity, or other rights.
While using the Services, you agree that you will not:
harass, abuse, threaten, or incite violence towards any individual
or group, including other Users and Monkeytype contributors;
use our servers for any form of excessive automated bulk activity
(e.g., spamming), or rely on any other form of unsolicited
advertising or solicitation through our servers or Services;
attempt to disrupt or tamper with our servers in ways that could a)
harm our Website or Services or b) place undue burden on our
servers;
access the Services in ways that exceed your authorization;
falsely impersonate any person or entity, including any of our
contributors, misrepresent your identity or the site's purpose, or
falsely associate yourself with Monkeytype;
violate the privacy of any third party, such as by posting another
person's personal information without their consent;
access or attempt to access any service on the Services by any means
other than as permitted in this Agreement, or operating the Services
on any computers or accounts which you do not have permission to
operate;
facilitate or encourage any violations of this Agreement or
interfere with the operation, appearance, security, or functionality
of the Services; or
use the Services in any manner that is harmful to minors.
Without limiting the foregoing, you will not transmit or post any
content anywhere on the Services that violates any laws. Monkeytype
absolutely does not tolerate engaging in activity that significantly
harms our Users. We will resolve disputes in favor of protecting our
Users as a whole.
Privacy Policy
If you use our Services, you must abide by our Privacy Policy. You
acknowledge that you have read our
Privacy Policy
and understand that it sets forth how we collect, use, and store your
information. If you do not agree with our Privacy Statement, then you
must stop using the Services immediately. Any person, entity, or service
collecting data from the Services must comply with our Privacy
Statement. Misuse of any User's Personal Information is prohibited. If
you collect any Personal Information from a User, you agree that you
will only use the Personal Information you gather for the purpose for
which the User has authorized it. You agree that you will reasonably
secure any Personal Information you have gathered from the Services, and
you will respond promptly to complaints, removal requests, and 'do not
contact' requests from us or Users.
Limitations on Automated Use
You shouldn't use bots or access our Services in malicious or
un-permitted ways. While accessing or using the Services, you may not:
use bots, hacks, or cheats while using our site;
create manual requests to Monkeytype servers;
tamper with or use non-public areas of the Services, or the computer
or delivery systems of Monkeytype and/or its service providers;
probe, scan, or test any system or network (particularly for
vulnerabilities), or otherwise attempt to breach or circumvent any
security or authentication measures, or search or attempt to access
or search the Services by any means (automated or otherwise) other
than through our currently available, published interfaces that are
provided by Monkeytype (and only pursuant to those terms and
conditions), unless you have been specifically allowed to do so in a
separate agreement with Monkeytype, Inc., or unless specifically
permitted by Monkeytype, Inc.'s robots.txt file or other robot
exclusion mechanisms;
scrape the Services, scrape Content from the Services, or use
automated means, including spiders, robots, crawlers, data mining
tools, or the like to download data from the Services or otherwise
access the Services;
employ misleading email or IP addresses or forged headers or
otherwise manipulated identifiers in order to disguise the origin of
any content transmitted to or through the Services;
use the Services to send altered, deceptive, or false
source-identifying information, including, without limitation, by
forging TCP-IP packet headers or e-mail headers; or
interfere with, or disrupt or attempt to interfere with or disrupt,
the access of any User, host, or network, including, without
limitation, by sending a virus to, spamming, or overloading the
Services, or by scripted use of the Services in such a manner as to
interfere with or create an undue burden on the Services.
Links
Monkeytype is not responsible for the contents of any linked sites. The
use of any linked website is at the user's own risk.
Changes
Monkeytype may revise these Terms of Service for its Website at any time
without prior notice. By using this Website, you are agreeing to be
bound by the current version of these Terms of Service.
Disclaimer
EXCLUDING THE EXPLICITLY STATED WARRANTIES WITHIN THESE TERMS, WE ONLY
OFFER OUR SERVICES ON AN 'AS-IS' BASIS. YOUR ACCESS TO AND USE OF THE
SERVICES OR ANY CONTENT IS AT YOUR OWN RISK. YOU UNDERSTAND AND AGREE
THAT THE SERVICES AND CONTENT ARE PROVIDED TO YOU ON AN 'AS IS,' 'WITH
ALL FAULTS,' AND 'AS AVAILABLE' BASIS. WITHOUT LIMITING THE FOREGOING,
TO THE FULL EXTENT PERMITTED BY LAW, MONKEYTYPE DISCLAIMS ALL
WARRANTIES, EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR
NON-INFRINGEMENT. TO THE EXTENT SUCH DISCLAIMER CONFLICTS WITH
APPLICABLE LAW, THE SCOPE AND DURATION OF ANY APPLICABLE WARRANTY WILL
BE THE MINIMUM PERMITTED UNDER SUCH LAW. MONKEYTYPE MAKES NO
REPRESENTATIONS, WARRANTIES, OR GUARANTEES AS TO THE RELIABILITY,
TIMELINESS, QUALITY, SUITABILITY, AVAILABILITY, ACCURACY, OR
COMPLETENESS OF ANY KIND WITH RESPECT TO THE SERVICES, INCLUDING ANY
REPRESENTATION OR WARRANTY THAT THE USE OF THE SERVICES WILL (A) BE
TIMELY, UNINTERRUPTED, OR ERROR-FREE, OR OPERATE IN COMBINATION WITH
ANY OTHER HARDWARE, SOFTWARE, SYSTEM, OR DATA, (B) MEET YOUR
REQUIREMENTS OR EXPECTATIONS, (C) BE FREE FROM ERRORS OR THAT DEFECTS
WILL BE CORRECTED, OR (D) BE FREE OF VIRUSES OR OTHER HARMFUL
COMPONENTS. MONKEYTYPE ALSO MAKES NO REPRESENTATIONS OR WARRANTIES OF
ANY KIND WITH RESPECT TO CONTENT; USER CONTENT IS PROVIDED BY AND IS
SOLELY THE RESPONSIBILITY OF THE RESPECTIVE USER PROVIDING THAT
CONTENT. NO ADVICE OR INFORMATION, WHETHER ORAL OR WRITTEN, OBTAINED
FROM MONKEYTYPE OR THROUGH THE SERVICES, WILL CREATE ANY WARRANTY NOT
EXPRESSLY MADE HEREIN. MONKEYTYPE DOES NOT WARRANT, ENDORSE,
GUARANTEE, OR ASSUME RESPONSIBILITY FOR ANY USER CONTENT ON THE
SERVICES OR ANY HYPERLINKED WEBSITE OR THIRD-PARTY SERVICE, AND
MONKEYTYPE WILL NOT BE A PARTY TO OR IN ANY WAY BE RESPONSIBLE FOR
TRANSACTIONS BETWEEN YOU AND THIRD PARTIES. IF APPLICABLE LAW DOES NOT
ALLOW THE EXCLUSION OF SOME OR ALL OF THE ABOVE IMPLIED OR STATUTORY
WARRANTIES TO APPLY TO YOU, THE ABOVE EXCLUSIONS WILL APPLY TO YOU TO
THE FULLEST EXTENT PERMITTED BY APPLICABLE LAW.
Contact
If you have any questions about Monkeytype’s privacy policy, the data
we hold on you, or you would like to exercise one of your data
protection rights, please do not hesitate to contact us.
We take the security and integrity of Monkeytype very seriously. If
you have found a vulnerability, please report it
ASAP
so we can quickly remediate the issue.
For vulnerabilities that impact the confidentiality, integrity, and
availability of Monkeytype services, please send your disclosure via
(1)
email
, or (2) ping
Miodec#1512
on the
Monkeytype Discord server in the #development channel
and he can discuss the situation with you further in private. For
non-security related platform bugs, follow the bug submission
guidelines
. Include as much detail as possible to ensure reproducibility. At a
minimum, vulnerability disclosures should include:
Vulnerability Description
Proof of Concept
Impact
Screenshots or Proof
Submission Guidelines
Do not engage in activities that might cause a denial of service
condition, create significant strains on critical resources, or
negatively impact users of the site outside of test accounts.
`
);
}
} catch {}
if (anim) {
$(".pageTest #testModesNotice")
.css("transition", "none")
.css("opacity", 0)
.animate(
{
opacity: 1,
},
125,
() => {
$(".pageTest #testModesNotice").css("transition", ".125s");
}
);
}
}
export function arrangeCharactersRightToLeft() {
$("#words").addClass("rightToLeftTest");
$("#resultWordsHistory .words").addClass("rightToLeftTest");
$("#resultReplay .words").addClass("rightToLeftTest");
}
export function arrangeCharactersLeftToRight() {
$("#words").removeClass("rightToLeftTest");
$("#resultWordsHistory .words").removeClass("rightToLeftTest");
$("#resultReplay .words").removeClass("rightToLeftTest");
}
async function loadWordsHistory() {
$("#resultWordsHistory .words").empty();
let wordsHTML = "";
for (let i = 0; i < TestLogic.input.history.length + 2; i++) {
let input = TestLogic.input.getHistory(i);
let word = TestLogic.words.get(i);
let wordEl = "";
try {
if (input === "") throw new Error("empty input word");
if (
TestLogic.corrected.getHistory(i) !== undefined &&
TestLogic.corrected.getHistory(i) !== ""
) {
wordEl = `
`;
} else {
wordEl = `
`;
}
if (i === TestLogic.input.history.length - 1) {
//last word
let wordstats = {
correct: 0,
incorrect: 0,
missed: 0,
};
let length = Config.mode == "zen" ? input.length : word.length;
for (let c = 0; c < length; c++) {
if (c < input.length) {
//on char that still has a word list pair
if (Config.mode == "zen" || input[c] == word[c]) {
wordstats.correct++;
} else {
wordstats.incorrect++;
}
} else {
//on char that is extra
wordstats.missed++;
}
}
if (wordstats.incorrect !== 0 || Config.mode !== "time") {
if (Config.mode != "zen" && input !== word) {
wordEl = `