4 Commits
Author SHA1 Message Date
john-okeefe ee7aa9921b fix(adminGames): apply Steam URL normalization and scraper guard to create/update
Release / build-and-push (push) Successful in 1m1s
Admin create had no URL handling at all (and crashed on missing steamId
via .length), and admin update passed req.body straight to the scraper.
Reuse normalizeSteamId in both, 400 on unrecognized input, and reject
failed scrapes (non-object / missing title+frontImage) before
Game.create/findOneAndUpdate so failures report as 'Steam lookup failed'
instead of schema validation noise.
2026-09-06 10:11:07 -04:00
john-okeefe 964415198a fix(games): persist normalized Steam ID and guard scraper failures in create()
The store-URL support extracted the app ID into a local but never wrote
it back, so steamScraper still received the full URL, keyed the Steam API
response by the URL string, threw, and its caught Error object flowed into
Game.create() — surfacing as misleading 'Path lastModifiedBy / createdBy /
frontImage / title is required' validation errors.

- add utils/normalizeSteamId: digits pass through, /app/<id>/ (plus
  /agecheck/app/<id>/ and query strings) extracts the ID, empty -> '',
  anything else -> null (no .match()[0] / .includes crash on bad input)
- create(): 400 cleanly on unrecognized input, write the normalized ID
  back to req.body.steamId so dup checks, scraper, and stored doc agree
- guard the scrape result (missing title/frontImage, false, or Error) and
  400 'Steam lookup failed' instead of leaking it into Game.create
2026-09-06 10:11:07 -04:00
john-okeefe 0adf0bd51f fix(admin): await user.save() without callback in createAdmin
Release / build-and-push (push) Successful in 1m14s
Mongoose 7 removed callback support on Model.prototype.save(),
so the seeder threw 'no longer accepts a callback' on every
boot (caught and logged, non-fatal, but noisy and the success
path never ran). Await the promise and log on success.
2026-09-05 22:48:53 -04:00
john-okeefe 39f1c88255 fix(ratelimit): split global and login budgets so storms can't lock out login
One shared 100 req / 10 min limiter counted everything —
401s, CORS preflights, even /health — so a bad-token burst
from a second frontend burned the budget and 429'd POST
/api/auth/login too, leaving no way back in short of
waiting out the window or restarting (MemoryStore reset).

New middleware/rateLimit.js holds two limiters, verified
against express-rate-limit@5.5.1:

- apiLimiter (300 / 15 min) skips OPTIONS, /health, and the
  public auth endpoints, so preflights, probes, and login
  attempts never drain real API budget.
- authLimiter (20 / 15 min, successful logins free) guards
  register/login/forgotpassword/resetpassword against
  brute force. 429 there means 20 wrong passwords, never
  a busy API.

Storm-tested with live server: 320 bad-token hits burn the
global budget yet login still returns 200; 25 bad logins
trip only the login limiter while API traffic is untouched.
2026-09-05 22:48:48 -04:00
7 changed files with 127 additions and 23 deletions
+2 -7
View File
@@ -6,10 +6,10 @@ import helmet from 'helmet'
import cookieParser from 'cookie-parser'
import mongoSanitize from 'express-mongo-sanitize'
import xss from 'xss-clean'
import rateLimit from 'express-rate-limit'
import hpp from 'hpp'
import morgan from 'morgan'
import errorHandler from './middleware/error.js'
import { apiLimiter } from './middleware/rateLimit.js'
@@ -36,12 +36,7 @@ const corsOptions = {
},
}
const limiter = rateLimit({
windowMs: 10 * 60 * 1000, // 10 minutes
max: 100
})
app.use(express.json(), cookieParser(), morgan('dev'), mongoSanitize(), helmet(), xss(), limiter, hpp(), cors())
app.use(express.json(), cookieParser(), morgan('dev'), mongoSanitize(), helmet(), xss(), apiLimiter, hpp(), cors())
app.get('/health', (req, res) => res.status(200).json({ status: 'ok' }))
+39 -2
View File
@@ -2,6 +2,7 @@ import Game from '../models/Game.js'
import steamScraper from '../scripts/scraper.js'
import asyncHandler from '../middleware/async.js'
import ErrorResponse from '../utils/errorResponse.js'
import normalizeSteamId from '../utils/normalizeSteamId.js'
const checkForHexRegExp = new RegExp('^[0-9a-fA-F]{24}$')
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) => {
let oldGame
req.body.steamId.length > 0
? (oldGame = await Game.findOne({ steamId: req.body.steamId }))
const steamId = normalizeSteamId(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 }))
if (oldGame)
@@ -56,6 +66,14 @@ export const create = asyncHandler(async (req, res, next) => {
const data =
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.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)
@@ -96,6 +114,17 @@ export const update = asyncHandler(async (req, res, next) => {
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.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)
@@ -109,6 +138,14 @@ export const update = asyncHandler(async (req, res, next) => {
const data =
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, {
new: true,
runValidators: true,
+18 -4
View File
@@ -4,6 +4,7 @@ import Game from "../models/Game.js";
import steamScraper from "../scripts/scraper.js";
import asyncHandler from "../middleware/async.js";
import ErrorResponse from "../utils/errorResponse.js";
import normalizeSteamId from "../utils/normalizeSteamId.js";
import { decode, isValid } from "js-base64";
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) => {
let oldGame;
let steamId;
if (req.body.steamId.includes("store.steampowered.com"))
steamId = req.body.steamId.match(/\d+/)[0];
else steamId = req.body.steamId;
const steamId = normalizeSteamId(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;
if (req.body.shortDesc && isValid(req.body.shortDesc))
req.body.shortDesc = decode(req.body.shortDesc);
@@ -166,6 +172,14 @@ export const create = asyncHandler(async (req, res, next) => {
const data =
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);
res.status(200).json({
+37
View File
@@ -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
View File
@@ -4,14 +4,15 @@ import express from 'express'
const router = express.Router()
import { register, login, getMe, forgotPassword, resetPassword, updateDetails, updatePassword, logout } from '../controllers/auth.js'
import { protect } from '../middleware/auth.js'
import { authLimiter } from '../middleware/rateLimit.js'
router.post('/register', register)
router.post('/login', login)
router.post('/register', authLimiter, register)
router.post('/login', authLimiter, login)
router.get('/logout', logout)
router.get('/me', protect, getMe)
router.put('/updatedetails', protect, updateDetails)
router.post('/forgotpassword', forgotPassword)
router.put('/resetpassword/:resettoken', resetPassword)
router.post('/forgotpassword', authLimiter, forgotPassword)
router.put('/resetpassword/:resettoken', authLimiter, resetPassword)
router.put('/updatepassword', protect, updatePassword)
export default router
+2 -6
View File
@@ -33,12 +33,8 @@ const createAdmin = async () => {
gamesToAccess: [],
})
await user.save((err) => {
console.log('Admin user successfully created')
if (err) {
return console.error('Could not save admin user')
}
})
await user.save()
console.log('Admin user successfully created')
} catch (err) {
return console.error(
'Server Error: Could not create admin user' + err.message
+24
View File
@@ -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;