Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee7aa9921b | ||
|
|
964415198a | ||
|
|
0adf0bd51f | ||
|
|
39f1c88255 |
@@ -6,10 +6,10 @@ import helmet from 'helmet'
|
|||||||
import cookieParser from 'cookie-parser'
|
import cookieParser from 'cookie-parser'
|
||||||
import mongoSanitize from 'express-mongo-sanitize'
|
import mongoSanitize from 'express-mongo-sanitize'
|
||||||
import xss from 'xss-clean'
|
import xss from 'xss-clean'
|
||||||
import rateLimit from 'express-rate-limit'
|
|
||||||
import hpp from 'hpp'
|
import hpp from 'hpp'
|
||||||
import morgan from 'morgan'
|
import morgan from 'morgan'
|
||||||
import errorHandler from './middleware/error.js'
|
import errorHandler from './middleware/error.js'
|
||||||
|
import { apiLimiter } from './middleware/rateLimit.js'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -36,12 +36,7 @@ const corsOptions = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const limiter = rateLimit({
|
app.use(express.json(), cookieParser(), morgan('dev'), mongoSanitize(), helmet(), xss(), apiLimiter, hpp(), cors())
|
||||||
windowMs: 10 * 60 * 1000, // 10 minutes
|
|
||||||
max: 100
|
|
||||||
})
|
|
||||||
|
|
||||||
app.use(express.json(), cookieParser(), morgan('dev'), mongoSanitize(), helmet(), xss(), limiter, hpp(), cors())
|
|
||||||
|
|
||||||
app.get('/health', (req, res) => res.status(200).json({ status: 'ok' }))
|
app.get('/health', (req, res) => res.status(200).json({ status: 'ok' }))
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Game from '../models/Game.js'
|
|||||||
import steamScraper from '../scripts/scraper.js'
|
import steamScraper from '../scripts/scraper.js'
|
||||||
import asyncHandler from '../middleware/async.js'
|
import asyncHandler from '../middleware/async.js'
|
||||||
import ErrorResponse from '../utils/errorResponse.js'
|
import ErrorResponse from '../utils/errorResponse.js'
|
||||||
|
import normalizeSteamId from '../utils/normalizeSteamId.js'
|
||||||
|
|
||||||
const checkForHexRegExp = new RegExp('^[0-9a-fA-F]{24}$')
|
const checkForHexRegExp = new RegExp('^[0-9a-fA-F]{24}$')
|
||||||
const checkForTwelveRegExp = new RegExp('^[0-9a-fA-F]{12}$')
|
const checkForTwelveRegExp = new RegExp('^[0-9a-fA-F]{12}$')
|
||||||
@@ -41,8 +42,17 @@ export const show = asyncHandler(async (req, res, next) => {
|
|||||||
*/
|
*/
|
||||||
export const create = asyncHandler(async (req, res, next) => {
|
export const create = asyncHandler(async (req, res, next) => {
|
||||||
let oldGame
|
let oldGame
|
||||||
req.body.steamId.length > 0
|
const steamId = normalizeSteamId(req.body.steamId)
|
||||||
? (oldGame = await Game.findOne({ steamId: req.body.steamId }))
|
if (steamId === null)
|
||||||
|
return next(
|
||||||
|
new ErrorResponse(
|
||||||
|
'Enter a Steam app ID or a store.steampowered.com URL.',
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
req.body.steamId = steamId
|
||||||
|
steamId.length > 0
|
||||||
|
? (oldGame = await Game.findOne({ steamId }))
|
||||||
: (oldGame = await Game.findOne({ title: req.body.title }))
|
: (oldGame = await Game.findOne({ title: req.body.title }))
|
||||||
|
|
||||||
if (oldGame)
|
if (oldGame)
|
||||||
@@ -56,6 +66,14 @@ export const create = asyncHandler(async (req, res, next) => {
|
|||||||
const data =
|
const data =
|
||||||
req.body.scrape === true ? await steamScraper(req.body) : req.body
|
req.body.scrape === true ? await steamScraper(req.body) : req.body
|
||||||
|
|
||||||
|
if (
|
||||||
|
req.body.scrape === true &&
|
||||||
|
(!data || typeof data !== 'object' || !data.title || !data.frontImage)
|
||||||
|
)
|
||||||
|
return next(
|
||||||
|
new ErrorResponse('Steam lookup failed for that ID/URL.', 400)
|
||||||
|
)
|
||||||
|
|
||||||
if (req.body.scrape === false) {
|
if (req.body.scrape === false) {
|
||||||
if (req.body.shortDesc && isValid(req.body.shortDesc)) req.body.shortDesc = decode(req.body.shortDesc)
|
if (req.body.shortDesc && isValid(req.body.shortDesc)) req.body.shortDesc = decode(req.body.shortDesc)
|
||||||
if (req.body.reviews && isValid(req.body.reviews)) req.body.reviews = decode(req.body.reviews)
|
if (req.body.reviews && isValid(req.body.reviews)) req.body.reviews = decode(req.body.reviews)
|
||||||
@@ -96,6 +114,17 @@ export const update = asyncHandler(async (req, res, next) => {
|
|||||||
|
|
||||||
|
|
||||||
req.body.lastModifiedBy = req.user.id
|
req.body.lastModifiedBy = req.user.id
|
||||||
|
if (req.body.steamId !== undefined) {
|
||||||
|
const normalizedSteamId = normalizeSteamId(req.body.steamId)
|
||||||
|
if (normalizedSteamId === null)
|
||||||
|
return next(
|
||||||
|
new ErrorResponse(
|
||||||
|
'Enter a Steam app ID or a store.steampowered.com URL.',
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
req.body.steamId = normalizedSteamId
|
||||||
|
}
|
||||||
if (req.body.shortDesc && isValid(req.body.shortDesc)) req.body.shortDesc = decode(req.body.shortDesc)
|
if (req.body.shortDesc && isValid(req.body.shortDesc)) req.body.shortDesc = decode(req.body.shortDesc)
|
||||||
if (req.body.reviews && isValid(req.body.reviews)) req.body.reviews = decode(req.body.reviews)
|
if (req.body.reviews && isValid(req.body.reviews)) req.body.reviews = decode(req.body.reviews)
|
||||||
if (req.body.summary && isValid(req.body.summary)) req.body.summary = decode(req.body.summary)
|
if (req.body.summary && isValid(req.body.summary)) req.body.summary = decode(req.body.summary)
|
||||||
@@ -109,6 +138,14 @@ export const update = asyncHandler(async (req, res, next) => {
|
|||||||
const data =
|
const data =
|
||||||
req.body.scrape === true ? await steamScraper(req.body) : req.body
|
req.body.scrape === true ? await steamScraper(req.body) : req.body
|
||||||
|
|
||||||
|
if (
|
||||||
|
req.body.scrape === true &&
|
||||||
|
(!data || typeof data !== 'object' || !data.title || !data.frontImage)
|
||||||
|
)
|
||||||
|
return next(
|
||||||
|
new ErrorResponse('Steam lookup failed for that ID/URL.', 400)
|
||||||
|
)
|
||||||
|
|
||||||
game = await Game.findOneAndUpdate({ [gameId]: id }, data, {
|
game = await Game.findOneAndUpdate({ [gameId]: id }, data, {
|
||||||
new: true,
|
new: true,
|
||||||
runValidators: true,
|
runValidators: true,
|
||||||
|
|||||||
+18
-4
@@ -4,6 +4,7 @@ import Game from "../models/Game.js";
|
|||||||
import steamScraper from "../scripts/scraper.js";
|
import steamScraper from "../scripts/scraper.js";
|
||||||
import asyncHandler from "../middleware/async.js";
|
import asyncHandler from "../middleware/async.js";
|
||||||
import ErrorResponse from "../utils/errorResponse.js";
|
import ErrorResponse from "../utils/errorResponse.js";
|
||||||
|
import normalizeSteamId from "../utils/normalizeSteamId.js";
|
||||||
import { decode, isValid } from "js-base64";
|
import { decode, isValid } from "js-base64";
|
||||||
|
|
||||||
const checkForHexRegExp = new RegExp("^[0-9a-fA-F]{24}$");
|
const checkForHexRegExp = new RegExp("^[0-9a-fA-F]{24}$");
|
||||||
@@ -77,11 +78,16 @@ export const show = asyncHandler(async (req, res, next) => {
|
|||||||
*/
|
*/
|
||||||
export const create = asyncHandler(async (req, res, next) => {
|
export const create = asyncHandler(async (req, res, next) => {
|
||||||
let oldGame;
|
let oldGame;
|
||||||
let steamId;
|
|
||||||
|
|
||||||
if (req.body.steamId.includes("store.steampowered.com"))
|
const steamId = normalizeSteamId(req.body.steamId);
|
||||||
steamId = req.body.steamId.match(/\d+/)[0];
|
if (steamId === null)
|
||||||
else steamId = req.body.steamId;
|
return next(
|
||||||
|
new ErrorResponse(
|
||||||
|
"Enter a Steam app ID or a store.steampowered.com URL.",
|
||||||
|
400,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
req.body.steamId = steamId;
|
||||||
|
|
||||||
if (req.body.shortDesc && isValid(req.body.shortDesc))
|
if (req.body.shortDesc && isValid(req.body.shortDesc))
|
||||||
req.body.shortDesc = decode(req.body.shortDesc);
|
req.body.shortDesc = decode(req.body.shortDesc);
|
||||||
@@ -166,6 +172,14 @@ export const create = asyncHandler(async (req, res, next) => {
|
|||||||
const data =
|
const data =
|
||||||
req.body.scrape === true ? await steamScraper(req.body) : req.body;
|
req.body.scrape === true ? await steamScraper(req.body) : req.body;
|
||||||
|
|
||||||
|
if (
|
||||||
|
req.body.scrape === true &&
|
||||||
|
(!data || typeof data !== "object" || !data.title || !data.frontImage)
|
||||||
|
)
|
||||||
|
return next(
|
||||||
|
new ErrorResponse("Steam lookup failed for that ID/URL.", 400),
|
||||||
|
);
|
||||||
|
|
||||||
const game = await Game.create(data);
|
const game = await Game.create(data);
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import rateLimit from 'express-rate-limit'
|
||||||
|
|
||||||
|
// Public auth endpoints get their own brute-force budget (see authLimiter)
|
||||||
|
// and must not consume the shared API budget, otherwise a bad-token storm
|
||||||
|
// can lock the owner out of logging back in.
|
||||||
|
const PUBLIC_AUTH_PATHS = [
|
||||||
|
'/api/auth/login',
|
||||||
|
'/api/auth/register',
|
||||||
|
'/api/auth/forgotpassword',
|
||||||
|
'/api/auth/resetpassword',
|
||||||
|
]
|
||||||
|
|
||||||
|
// Shared budget for real API traffic. Counts failures too (cheap 401s, no DB
|
||||||
|
// hit), but skips CORS preflights, the health probe, and the public auth
|
||||||
|
// endpoints above so junk traffic and login attempts can't drain it.
|
||||||
|
export const apiLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 300,
|
||||||
|
skip: (req) =>
|
||||||
|
req.method === 'OPTIONS' ||
|
||||||
|
req.path === '/health' ||
|
||||||
|
PUBLIC_AUTH_PATHS.some((p) => req.path.startsWith(p)),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Strict budget for the login door only. Successful logins are free
|
||||||
|
// (skipSuccessfulRequests), so normal use never notices it — only repeated
|
||||||
|
// failed attempts burn budget. 429 here means "wrong password 20 times in
|
||||||
|
// 15 minutes", never "the API was busy".
|
||||||
|
export const authLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 20,
|
||||||
|
skipSuccessfulRequests: true,
|
||||||
|
message: {
|
||||||
|
success: false,
|
||||||
|
error: 'Too many login attempts, please try again later.',
|
||||||
|
},
|
||||||
|
})
|
||||||
+5
-4
@@ -4,14 +4,15 @@ import express from 'express'
|
|||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
import { register, login, getMe, forgotPassword, resetPassword, updateDetails, updatePassword, logout } from '../controllers/auth.js'
|
import { register, login, getMe, forgotPassword, resetPassword, updateDetails, updatePassword, logout } from '../controllers/auth.js'
|
||||||
import { protect } from '../middleware/auth.js'
|
import { protect } from '../middleware/auth.js'
|
||||||
|
import { authLimiter } from '../middleware/rateLimit.js'
|
||||||
|
|
||||||
router.post('/register', register)
|
router.post('/register', authLimiter, register)
|
||||||
router.post('/login', login)
|
router.post('/login', authLimiter, login)
|
||||||
router.get('/logout', logout)
|
router.get('/logout', logout)
|
||||||
router.get('/me', protect, getMe)
|
router.get('/me', protect, getMe)
|
||||||
router.put('/updatedetails', protect, updateDetails)
|
router.put('/updatedetails', protect, updateDetails)
|
||||||
router.post('/forgotpassword', forgotPassword)
|
router.post('/forgotpassword', authLimiter, forgotPassword)
|
||||||
router.put('/resetpassword/:resettoken', resetPassword)
|
router.put('/resetpassword/:resettoken', authLimiter, resetPassword)
|
||||||
router.put('/updatepassword', protect, updatePassword)
|
router.put('/updatepassword', protect, updatePassword)
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -33,12 +33,8 @@ const createAdmin = async () => {
|
|||||||
gamesToAccess: [],
|
gamesToAccess: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
await user.save((err) => {
|
await user.save()
|
||||||
console.log('Admin user successfully created')
|
console.log('Admin user successfully created')
|
||||||
if (err) {
|
|
||||||
return console.error('Could not save admin user')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return console.error(
|
return console.error(
|
||||||
'Server Error: Could not create admin user' + err.message
|
'Server Error: Could not create admin user' + err.message
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* normalizeSteamId()
|
||||||
|
*
|
||||||
|
* @description Accepts either a plain numeric Steam app ID or a
|
||||||
|
* store.steampowered.com URL and returns the numeric app ID.
|
||||||
|
* Returns "" when the input is empty/missing (manual non-scrape flow
|
||||||
|
* looks games up by title instead) and `null` when the input is
|
||||||
|
* neither digits nor a recognized Steam Store URL.
|
||||||
|
*/
|
||||||
|
const normalizeSteamId = (value) => {
|
||||||
|
const raw = String(value ?? "").trim();
|
||||||
|
if (!raw) return "";
|
||||||
|
|
||||||
|
const urlMatch = raw.match(
|
||||||
|
/store\.steampowered\.com\/(?:agecheck\/)?app\/(\d+)/,
|
||||||
|
);
|
||||||
|
if (urlMatch) return urlMatch[1];
|
||||||
|
|
||||||
|
if (/^\d+$/.test(raw)) return raw;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default normalizeSteamId;
|
||||||
Reference in New Issue
Block a user