Added multi-API-key support and API rate-limiting.

This commit is contained in:
2023-09-29 15:01:13 -04:00
parent b2f216dad9
commit f475633e99
7 changed files with 338 additions and 55 deletions

View File

@@ -33,3 +33,13 @@ API_KEY=""
# This is the name that will be pre-filled when you go to upload custom files for a modpack.
# Unset behaviour: You will need to specify the author every time.
AUTHOR_NAME="John Doe"
# (Optional) API Rate-limit Requests
# The number of requests that can be made to the API by one IP address within the set window before it will block them.
# Default: 1000
API_RATE_REQUESTS=1000
# (Optional) API Rate-limit Window
# The amount of time (in milliseconds) that the request limit is applied to.
# Default: 60000
API_RATE_WINDOW=60000

BIN
README.md

Binary file not shown.

4
app.js
View File

@@ -15,7 +15,7 @@ const storage = multer.diskStorage({
cb(null, uniquePrefix + '-' + file.originalname)
}
})
const upload = multer({ storage: storage })
const upload = multer({ storage })
// --- Routers ---
const indexRouter = require('./routes/index')
@@ -49,7 +49,7 @@ app.use(function (err, req, res, next) {
// render the error page
res.status(err.status || 500)
res.render('error', {title: `Error ${err.status}: ${err.message}`})
res.render('error', { title: `Error ${err.status}: ${err.message}` })
})
module.exports.app = app

View File

@@ -16,6 +16,13 @@ const userSchema = new Schema({
login_history: [{ timestamp: Date, userAgent: String }]
})
const apiKeySchema = new Schema({
owner: { type: SchemaTypes.ObjectId, ref: 'technicflux_users' },
key: String,
name: String,
created_at: Date
})
const modpackSchema = new Schema({
name: String,
display_name: String,
@@ -55,6 +62,7 @@ const buildSchema = new Schema({
// --- Models ---
const User = mongoose.model('technicflux_users', userSchema)
const APIKey = mongoose.model('technicflux_keys', apiKeySchema)
const Modpack = mongoose.model('technicflux_modpacks', modpackSchema)
const Mod = mongoose.model('technicflux_mods', modSchema)
const Build = mongoose.model('technicflux_builds', buildSchema)
@@ -119,7 +127,7 @@ exports.getUserById = (uId) => {
}
/*
* Gets the user with the provided email.
* Gets the user with the provided username.
*/
exports.getUserByUsername = (uUsername) => {
// Fetch information about the user with the given username
@@ -155,7 +163,7 @@ exports.authUser = (uUsername, uPassword, logHistory = false, uAgent = 'Unknown'
// Fetch information about the user with the given username
return User.findOne({ username: uUsername }).exec().then((user) => {
// User found. Check password.
return bcrypt.compare(uPassword, user.password).then((isMatch) => {
return bcrypt.compare(uPassword, user.password).then(async (isMatch) => {
// Check if it is a match
if (isMatch) {
// Update the user's login history.
@@ -163,7 +171,7 @@ exports.authUser = (uUsername, uPassword, logHistory = false, uAgent = 'Unknown'
const newLogin = { timestamp: new Date(), userAgent: String(uAgent) }
// Add the new login to the user's login history
return User.updateOne(
return await User.updateOne(
{ username: uUsername },
{ $push: { login_history: newLogin } }
).exec().then(() => {
@@ -229,6 +237,91 @@ exports.updateUser = (uUsername, object) => {
})
}
// --- API-Key-Related Functions
/*
* Creates a new api key with the given information.
*/
exports.createAPIKey = (kOwner, kKey, kName) => {
// Encrypt the password using bcrypt and save user
return bcrypt.genSalt(10).then((Salt) => {
return bcrypt.hash(kKey, Salt).then((hash) => {
// Create an APIKey object
const newKey = new APIKey({
owner: kOwner,
key: hash,
name: kName,
created_at: new Date()
})
// Commit it to the database
return newKey.save().then((key) => {
process.stdout.write(`Successfully added new API key '${kName}' for ${kOwner}\n`)
return key
}).catch((reason) => {
process.stdout.write(`ERROR (when saving new API key): ${reason}\n`)
return false
})
}).catch((err) => {
// Issue creating hashed api key
process.stdout.write(`ERROR (while hashing key): ${err}`)
return false
})
}).catch((err) => {
// Issue creating hashed object salt
process.stdout.write(`ERROR (while generating api key salt): ${err}`)
return false
})
}
/*
* Checks if the corresponding password matches that of the provided username.
*/
exports.authAPIKey = (uKey) => {
// Fetch information about the user with the given username
return APIKey.find({ }).exec().then(async (keys) => {
// Check if any of the keys match.
let valid = [false, undefined]
for (const key of keys) {
await bcrypt.compare(uKey, key.key).then((isMatch) => {
// Check if it is a match
if (isMatch) {
// The key matched.
valid = [true, key]
}
})
}
return valid
}).catch((reason) => {
console.log(reason)
// DB error
debug('ERROR (DB): Could not fetch API keys.')
return [false, undefined]
})
}
exports.getAPIKeysByOwner = (mOwner) => {
return APIKey.find({ owner: mOwner }).populate('owner').exec().then((keys) => {
// API Keys found.
return keys
}).catch((reason) => {
// No API keys found for this owner.
debug(`ERROR (DB): Failed to find any API keys with this owner because of: ${reason}`)
return false
})
}
exports.deleteAPIKey = (kId) => {
return APIKey.deleteOne({ _id: kId }).exec().then(() => {
// Successfully deleted this api key
return true
}).catch((reason) => {
// Failed to delete the api key
debug(`ERROR (DB): Failed to delete API Key '${kId}' because of: ${reason}`)
return false
})
}
// --- Modpack-Related Functions ---
exports.createModpack = (mSlug, mDisplayName, mOwner) => {
// Create a modpack object
@@ -335,7 +428,7 @@ exports.getModBySlug = (mSlug) => {
author: mods[0].author,
description: mods[0].description,
link: mods[0].link,
versions: mods.map((modVersion) => modVersion['version'])
versions: mods.map((modVersion) => modVersion.version)
}
}
}).catch((reason) => {

115
package-lock.json generated
View File

@@ -22,6 +22,7 @@
"mongoose": "^7.5.3",
"morgan": "~1.9.1",
"multer": "^1.4.5-lts.1",
"rate-limit-mongo": "^2.3.2",
"standard": "^17.1.0"
},
"devDependencies": {
@@ -439,6 +440,15 @@
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="
},
"node_modules/bl": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz",
"integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==",
"dependencies": {
"readable-stream": "^2.3.5",
"safe-buffer": "^5.1.1"
}
},
"node_modules/body-parser": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz",
@@ -801,6 +811,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/denque": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz",
"integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
@@ -3077,6 +3095,17 @@
"wrappy": "1"
}
},
"node_modules/optional-require": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/optional-require/-/optional-require-1.1.8.tgz",
"integrity": "sha512-jq83qaUb0wNg9Krv1c5OQ+58EK+vHde6aBPzLvPPqJm89UQWsvSuFy9X/OSNJnFeSOKo7btE0n8Nl2+nE+z5nA==",
"dependencies": {
"require-at": "^1.0.6"
},
"engines": {
"node": ">=4"
}
},
"node_modules/optionator": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz",
@@ -3367,6 +3396,62 @@
"node": ">= 0.6"
}
},
"node_modules/rate-limit-mongo": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/rate-limit-mongo/-/rate-limit-mongo-2.3.2.tgz",
"integrity": "sha512-dLck0j5N/AX9ycVHn5lX9Ti2Wrrwi1LfbXitu/mMBZOo2nC26RgYKJVbcb2mYgb9VMaPI2IwJVzIa2hAQrMaDA==",
"dependencies": {
"mongodb": "^3.6.7",
"twostep": "0.4.2",
"underscore": "1.12.1"
}
},
"node_modules/rate-limit-mongo/node_modules/bson": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/bson/-/bson-1.1.6.tgz",
"integrity": "sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg==",
"engines": {
"node": ">=0.6.19"
}
},
"node_modules/rate-limit-mongo/node_modules/mongodb": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.7.4.tgz",
"integrity": "sha512-K5q8aBqEXMwWdVNh94UQTwZ6BejVbFhh1uB6c5FKtPE9eUMZPUO3sRZdgIEcHSrAWmxzpG/FeODDKL388sqRmw==",
"dependencies": {
"bl": "^2.2.1",
"bson": "^1.1.4",
"denque": "^1.4.1",
"optional-require": "^1.1.8",
"safe-buffer": "^5.1.2"
},
"engines": {
"node": ">=4"
},
"optionalDependencies": {
"saslprep": "^1.0.0"
},
"peerDependenciesMeta": {
"aws4": {
"optional": true
},
"bson-ext": {
"optional": true
},
"kerberos": {
"optional": true
},
"mongodb-client-encryption": {
"optional": true
},
"mongodb-extjson": {
"optional": true
},
"snappy": {
"optional": true
}
}
},
"node_modules/raw-body": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
@@ -3492,6 +3577,14 @@
"url": "https://github.com/sponsors/mysticatea"
}
},
"node_modules/require-at": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/require-at/-/require-at-1.0.6.tgz",
"integrity": "sha512-7i1auJbMUrXEAZCOQ0VNJgmcT2VOKPRl2YGJwgpHpC9CE91Mv4/4UYIUm4chGJaI381ZDq1JUicFii64Hapd8g==",
"engines": {
"node": ">=4"
}
},
"node_modules/resolve": {
"version": "1.22.6",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.6.tgz",
@@ -3609,6 +3702,18 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/saslprep": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz",
"integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==",
"optional": true,
"dependencies": {
"sparse-bitfield": "^3.0.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -4105,6 +4210,11 @@
"strip-bom": "^3.0.0"
}
},
"node_modules/twostep": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/twostep/-/twostep-0.4.2.tgz",
"integrity": "sha512-O/wdPYk9ey04qcCiw8AQN74DbvLFZLAgnryrNTpV7T/sxB4lcGkCMHynx5xCcA6fCh739ZAqp3HcGhy770X1qA=="
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -4231,6 +4341,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/underscore": {
"version": "1.12.1",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz",
"integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw=="
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",

View File

@@ -32,7 +32,9 @@
"version": "0.0.1",
"private": true,
"scripts": {
"start": "set DEBUG=technicflux:* && node ./bin/www"
"start": "set DEBUG=technicflux:* && node ./bin/www",
"lint": "standard",
"lint-and-fix": "standard --fix"
},
"dependencies": {
"@zip.js/zip.js": "^2.7.29",
@@ -48,6 +50,7 @@
"mongoose": "^7.5.3",
"morgan": "~1.9.1",
"multer": "^1.4.5-lts.1",
"rate-limit-mongo": "^2.3.2",
"standard": "^17.1.0"
},
"devDependencies": {

View File

@@ -1,9 +1,44 @@
const express = require('express')
const pckg = require('../package.json')
const branchName = require('current-git-branch')() || "release"
const rateLimit = require('express-rate-limit')
const MongoStore = require('rate-limit-mongo')
const database = require('../database')
const branchName = require('current-git-branch')() || 'release'
const pckg = require('../package.json')
const router = express.Router()
// --- Rate Limiter ---
const maxRequests = process.env.API_RATE_REQUESTS || 1000
const rateWindow = process.env.API_RATE_WINDOW || 60000
const limiter = rateLimit({
store: new MongoStore({
uri: process.env.MONGODB_CONN_STRING,
collectionName: 'technicflux_ratelimit',
expireTimeMs: rateLimit,
errorHandler: console.error.bind(null, 'rate-limit-mongo')
}),
max: maxRequests,
windowMs: rateWindow,
legacyHeaders: true,
message: { error: 'Too many requests from this IP. You are being rate-limited.' }
})
router.use(limiter)
// --- Module Error Functions ---
function error500 (res) {
return res.status(500).json({
error: 'Internal Server Error'
})
}
// --- API Routes ---
/**
* Base API Route : /api/
* Possible Statuses: 200
* Purpose: Displays information about the TechnicFlux Solder API.
*/
router.get('/', (req, res) => {
return res.json({
api: pckg.prettyName,
@@ -12,27 +47,32 @@ router.get('/', (req, res) => {
meta: {
description: "TechnicFlux implementation of Technic's Solder API for retrieval of modpack and mod info.",
license: `https://github.com/Zandercraft/TechnicFlux/blob/${branchName}/LICENSE.txt`,
repo: "https://github.com/Zandercraft/TechnicFlux",
documentation: "https://github.com/Zandercraft/TechnicFlux/wiki",
repo: 'https://github.com/Zandercraft/TechnicFlux',
documentation: 'https://github.com/Zandercraft/TechnicFlux/wiki',
attribution: {
name: "Zandercraft",
github: "https://github.com/Zandercraft",
website: "https://zandercraft.ca",
name: 'Zandercraft',
github: 'https://github.com/Zandercraft',
website: 'https://zandercraft.ca'
}
}
})
})
/**
* Mod API Route : /api/mod/:modname/:modversion?
* Possible Statuses: 200, 404, 500
* Purpose: Displays information about a mod or a specific version of that mod.
*/
router.get('/mod/:modname/:modversion?', (req, res) => {
let modName = req.params.modname
let modVersion = req.params?.modversion
const modName = req.params.modname
const modVersion = req.params?.modversion
return database.getModBySlug(`${modName}`).then((mod) => {
// Check if a mod was found
if (mod === null) {
// Mod Not Found
return res.status(404).json({
"error": "Mod does not exist"
error: 'Mod does not exist'
})
} else if (modVersion === undefined) {
// Request is for the mod's info.
@@ -43,7 +83,7 @@ router.get('/mod/:modname/:modversion?', (req, res) => {
if (version === null) {
// Version Not Found
return res.status(404).json({
"error": "Mod version does not exist"
error: 'Mod version does not exist'
})
} else {
// Found mod version
@@ -56,46 +96,52 @@ router.get('/mod/:modname/:modversion?', (req, res) => {
}
}).catch(() => {
// Issue with db request
return res.status(500).json({
"error": "Internal Server Error"
})
return error500(res)
})
}
}).catch(() => {
// Issue with db request
return res.status(500).json({
"error": "Internal Server Error"
})
return error500(res)
})
})
/**
* Modpack Index API Route : /api/modpack/
* Possible Statuses: 200, 500
* Purpose: Displays a list of all modpacks that are on this TechnicFlux instance.
*/
router.get('/modpack', (req, res) => {
return database.getAllModpacks().then((modpacks) => {
// Modpack Index
return res.json({
modpacks: (modpacks === null) ? {} : modpacks.reduce((obj, item) => {
obj[item['name']] = String(item['display_name'])
return obj
}, {}),
modpacks: (modpacks === null)
? {}
: modpacks.reduce((obj, item) => {
obj[item.name] = String(item.display_name)
return obj
}, {}),
mirror_url: `http://${process.env.HOST}/mods/`
})
}).catch(() => {
// Issue with db request
return res.status(500).json({
"error": "Internal Server Error"
})
return error500(res)
})
})
/**
* Modpack API Route : /api/modpack/:modpack/:build?
* Possible Statuses: 200, 404, 500
* Purpose: Displays information about a modpack or a specific build of that modpack.
*/
router.get('/modpack/:slug/:build?', (req, res) => {
let slug = req.params.slug
let build = req.params?.build
const slug = req.params.slug
const build = req.params?.build
return database.getModpackBySlug(`${slug}`).then((modpack) => {
if (modpack === null) {
// Modpack Not Found
return res.status(404).json({
"error": "Modpack does not exist"
error: 'Modpack does not exist'
})
} else if (build === undefined) {
// Modpack info
@@ -104,7 +150,7 @@ router.get('/modpack/:slug/:build?', (req, res) => {
display_name: modpack.display_name,
recommended: modpack.recommended,
latest: modpack.latest,
builds: modpack.builds.map((build) => build['version'])
builds: modpack.builds.map((build) => build.version)
})
} else {
// Modpack build info
@@ -133,38 +179,54 @@ router.get('/modpack/:slug/:build?', (req, res) => {
}
}).catch(() => {
// Issue with db request
return res.status(500).json({
"error": "Internal Server Error"
})
return error500(res)
})
})
/**
* API Key Verification Route : /api/verify/:key?
* Possible Statuses: 200, 400, 403
* Purpose: Checks if the given API key is valid with this instance of TechnicFlux
*/
router.get('/verify/:key?', (req, res) => {
let api_key = req.params?.key
const apiKey = req.params?.key
if (api_key !== undefined) {
if (api_key === process.env?.API_KEY)
// Key validated
return res.json({
valid: 'Key validated.',
name: 'API KEY',
created_at: 'A long time ago'
})
else {
// Invalid key provided
return res.json({
error: 'Invalid key provided.'
})
}
if (apiKey !== undefined) {
database.authAPIKey(apiKey).then((key) => {
if (apiKey === process.env?.API_KEY) {
// Key validated
return res.json({
valid: 'Key validated.',
name: 'API KEY',
created_at: 'A long time ago'
})
} else if (key[0] === true) {
// Key validated
return res.json({
valid: 'Key validated.',
name: key[1].name,
created_at: key[1].created_at
})
} else {
// Invalid key provided
return res.status(403).json({
error: 'Invalid key provided.'
})
}
})
} else {
// No key provided
return res.json({
return res.status(400).json({
error: 'No API key provided.'
})
}
})
/* Invalid API Routes */
/**
* Invalid API Routes : /api/<invalid route>
* Possible Statuses: 405
* Purpose: Displays an error when an invalid route is called.
*/
router.all('*', (req, res) => {
return res.status(405).json({
code: res.statusCode,