13 Commits
Author SHA1 Message Date
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
john-okeefe 4fe5aa25f8 chore(ci): tag-triggered release workflow and helpers
Release / build-and-push (push) Successful in 1m17s
Add .gitea/workflows/release.yml: on v* tag push (or manual
dispatch) build the Docker image and push
git.linuxhg.com/games-database/games-api:<tag> plus :latest,
then create/update the Gitea Release from the annotated tag
message. Main-branch pushes do nothing so WIP never ships.

Add cliff.toml (conventional-commit grouping for release
notes), Makefile release target (cliff notes into annotated
tag, then push), and ./release wrapper (accepts 1.0 or
v1.0). Release flow: ./release v1.0, matching bookhoard.

Requires a REGISTRY_TOKEN repo Actions secret (PAT with
write:package and write:repository); Gitea's auto token
lacks package scope.
2026-09-05 21:05:20 -04:00
john-okeefe a5ceb721f3 chore(docker): portable image and registry run template
Dockerfile: stop baking secrets into the image. Only NODE_ENV
remains; all runtime config (MONGO_URI, SMTP_*, token
secrets) comes from the environment so one image runs
anywhere. Install curl for container healthchecks. App
variable names untouched (singular JWT_EXPIRE as read by
models/User.js).

docker-compose.example.yml: rewrite as a prod run template
for git.linuxhg.com/games-database/games-api (IMAGE_TAG,
default latest) with restart, env_file .env, and a curl
/health healthcheck. Copy to docker-compose.yml next to
the server .env, then pull and up. Full variable list kept
commented out so values can live in the file instead of
.env if preferred.
2026-09-05 20:37:03 -04:00
john-okeefe 38e6d21585 feat(health): add unauthenticated GET /health endpoint
Returns 200 { status: 'ok' } for container healthchecks and
monitoring. Placed before all /api/* routes so it bypasses
auth; intentionally static with no DB dependency so the
container reports healthy whenever the process is serving.
2026-09-05 20:36:57 -04:00
john-okeefe 689610b434 chore(compose): sync example env with codebase
Add NODE_ENV (used by config/db.js and controllers/auth.js
to switch MONGO_URI/MONGO_DEV_URI and secure cookies).

Drop PATH, BUN_RUNTIME_TRANSPILER_CACHE_PATH, and
BUN_INSTALL_BIN leaked from container runtime env; they are
not read by the app.

Drop JWT_EXPIRES duplicate; app reads JWT_EXPIRE
(models/User.js expiresIn), matching .env.

Restore SECURE=false default (used by utils/sendEmail.js
via yn(Bun.env.SECURE)).

Keep list aligned with Dockerfile and .env keys so the
example stays a valid template for server deployments.
2026-09-05 20:01:30 -04:00
john-okeefe 748616df9d feat(games): accept full Steam store URL in create()
Allow callers to pass either a raw Steam app ID or a full store URL (e.g. https://store.steampowered.com/app/730/...) in req.body.steamId.

In create() in controllers/games.js:

- normalize input into a local steamId variable: if the value includes store.steampowered.com, extract the numeric ID via match(/\d+/)[0], otherwise use the value as-is

- use the normalized steamId for both duplicate checks: user-scoped lookup (steamId + accessedBy.user) and global lookup

This lets users paste a copied store link directly without manually stripping the app ID, while keeping existing raw-ID behavior unchanged.
2026-09-05 19:16:14 -04:00
john-okeefe a9b9fe1047 style(games): reformat controllers/games.js with Prettier
Apply Prettier default formatting across the games controller with no logic changes:

- single -> double quotes, add missing semicolons

- 2-space indentation and consistent line wrapping

- expand dense ternary/decode blocks in create() and update() for readability

This isolates the upcoming create() Steam URL feature so its functional diff stays small and reviewable.
2026-09-05 19:16:06 -04:00
john-okeefe 20c1fd7be9 Ensure API Response is in English
added English to the API URL to ensure the returned JSON is in English. I'm leaving the Headers language in place, but it is not as reliable
2025-06-29 11:50:29 -04:00
john-okeefe 2800d2fb2e sorting now default starting with most recently created 2024-10-04 23:35:00 -04:00
john-okeefe cce705c4f7 for now, cors supports all 2024-09-12 19:33:14 -04:00
john-okeefe ed889d95e6 added docker support 2024-09-12 19:32:45 -04:00
john-okeefe c61d0c40af added docker support 2024-09-12 19:32:09 -04:00
15 changed files with 579 additions and 194 deletions
+15
View File
@@ -0,0 +1,15 @@
node_modules
Dockerfile*
docker-compose*
.dockerignore
.git
.gitignore
README.md
LICENSE
.vscode
Makefile
helm-charts
.env
.editorconfig
.idea
coverage*
+112
View File
@@ -0,0 +1,112 @@
name: Release
# Overrides the default run name (the tagged commit's message) so the Actions
# runs list shows "Release v0.1.0" instead.
run-name: "Release ${{ gitea.event.inputs.tag || gitea.ref_name }}"
# Publishes the games-api container image to the Gitea container registry AND
# creates a Gitea Release whose body is the annotated tag's message (generated
# locally by `make release VERSION=...` via git-cliff). Triggered by a version
# tag push, or manually via workflow_dispatch with a tag. Pushing to main does
# nothing, so work-in-progress commits never ship. Each release publishes two
# image tags: the version (e.g. v0.1.0) and "latest".
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Tag to release (e.g. v0.1.0)'
required: true
type: string
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
env:
# Resolve the target tag for both triggers: explicit input on manual
# dispatch, otherwise the pushed tag ref.
TAG: ${{ gitea.event.inputs.tag || gitea.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history ensures the tag annotation (the release notes) is present.
fetch-depth: 0
ref: ${{ gitea.event.inputs.tag || gitea.ref }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.linuxhg.com
username: ${{ gitea.actor }}
# PAT stored as a repo Actions secret (auto GITHUB_TOKEN lacks package scope in Gitea)
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
# Publishes both the exact version (e.g. v0.1.0) and the movable "latest" tag.
# Deployments default to "latest" via ${IMAGE_TAG:-latest} in docker-compose.yml;
# pin or roll back by setting IMAGE_TAG in .env.
tags: |
git.linuxhg.com/games-database/games-api:${{ env.TAG }}
git.linuxhg.com/games-database/games-api:latest
- name: Create Gitea Release
env:
# REGISTRY_TOKEN is reused for release creation because Gitea's auto
# GITHUB_TOKEN cannot create releases on this instance. The PAT must
# carry write:repository scope. Idempotent: re-runs update an existing
# release for this tag instead of failing with 409. On any HTTP error
# the API response body is printed so a 403 names the missing scope.
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REPO: ${{ gitea.repository }}
run: |
set -euo pipefail
: "${TAG:?TAG is required}"
API="https://git.linuxhg.com/api/v1/repos/${REPO}/releases"
AUTH="Authorization: token ${TOKEN}"
# Release body = the annotated tag's message (the git-cliff notes).
BODY="$(git tag -l --format='%(contents)' "${TAG}")"
# Tags containing a '-' (e.g. v0.1.0-rc1) are published as pre-releases.
PRE="false"; case "${TAG}" in *-*) PRE="true";; esac
PAYLOAD=$(jq -n \
--arg t "${TAG}" --arg n "${TAG}" --arg b "${BODY}" --argjson p "${PRE}" \
'{tag_name:$t, name:$n, body:$b, draft:false, prerelease:$p}')
# POST/PATCH the release, surfacing Gitea's error message on failure
# (e.g. "token does not have write scope") instead of failing silently.
api_call() {
local method="$1" url="$2" resp code rbody
resp="$(curl -sS -w '\n%{http_code}' -X "${method}" \
-H "${AUTH}" -H "Content-Type: application/json" \
-d "${PAYLOAD}" "${url}")"
code="$(printf '%s' "${resp}" | tail -n1)"
rbody="$(printf '%s' "${resp}" | sed '$d')"
if [ "${code}" -ge 400 ]; then
echo "::error::Release API ${code} (${method} ${url}): ${rbody}" >&2
return 1
fi
}
EXISTING_ID="$(curl -sS -H "${AUTH}" "${API}/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null || true)"
if [ -n "${EXISTING_ID}" ]; then
api_call PATCH "${API}/${EXISTING_ID}"
echo "Updated existing release id=${EXISTING_ID} for ${TAG}"
else
api_call POST "${API}"
echo "Created new release for ${TAG}"
fi
+2
View File
@@ -130,3 +130,5 @@ dist
.yarn/install-state.gz
.pnp.*
# docker-compose.yml
docker-compose.yml
+41
View File
@@ -0,0 +1,41 @@
# use the official Bun image
# see all versions at https://hub.docker.com/r/oven/bun/tags
FROM oven/bun:1 AS base
WORKDIR /usr/src/app
# curl is needed for container healthchecks (curl -f http://localhost:5000/health)
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
# install dependencies into temp directory
# this will cache them and speed up future builds
FROM base AS install
RUN mkdir -p /temp/dev
COPY package.json bun.lockb /temp/dev/
RUN cd /temp/dev && bun install --frozen-lockfile
# install with --production (exclude devDependencies)
RUN mkdir -p /temp/prod
COPY package.json bun.lockb /temp/prod/
RUN cd /temp/prod && bun install --frozen-lockfile --production
# copy node_modules from temp directory
# then copy all (non-ignored) project files into the image
FROM base AS prerelease
COPY --from=install /temp/dev/node_modules node_modules
COPY . .
# copy production dependencies and source code into final image
FROM base AS release
COPY --from=install /temp/prod/node_modules node_modules
COPY --from=prerelease /usr/src/app/ .
COPY --from=prerelease /usr/src/app/package.json .
# Runtime config comes from the environment (compose env_file: .env),
# not baked in at build time, so one image runs anywhere.
ARG NODE_ENV=production
ENV NODE_ENV=$NODE_ENV
# run the app
USER bun
EXPOSE 5000/tcp
ENTRYPOINT [ "bun", "./bin/www.js" ]
+33
View File
@@ -0,0 +1,33 @@
.PHONY: help release
# Default target
help:
@echo "Available targets:"
@echo ""
@echo "Release:"
@echo " ./release v0.1.0 - Tag, push, and release (notes auto-generated from commits)"
@echo " make release VERSION=v0.1.0 - Same, explicit make form"
# Create an annotated version tag carrying auto-generated release notes (git-cliff)
# and push it. The tag push triggers .gitea/workflows/release.yml, which builds the
# image and publishes a Gitea Release whose body is this tag's message. Notes come
# entirely from Conventional Commits — no hand-written message required.
#
# git-cliff's --latest needs the tag to exist to scope the notes, so we create a
# throwaway lightweight tag, generate the notes, replace it with an annotated tag,
# then push. --cleanup=verbatim keeps the markdown "###" group headers (git's
# default cleanup would strip lines starting with "#").
#
# Requires git-cliff: https://git-cliff.org/install
# Usage: make release VERSION=v0.1.0
release:
@test -n "$(VERSION)" || { echo "Usage: make release VERSION=v0.1.0"; exit 1; }
@command -v git-cliff >/dev/null 2>&1 || { echo "git-cliff not found — install: https://git-cliff.org/install"; exit 1; }
@if git rev-parse "$(VERSION)" >/dev/null 2>&1; then echo "Tag $(VERSION) already exists locally — delete it first: git tag -d $(VERSION)"; exit 1; fi
@echo "Generating release notes for $(VERSION)..."
@git tag "$(VERSION)" HEAD && \
(git cliff --latest --config cliff.toml > .release-notes.tmp && git tag -d "$(VERSION)" >/dev/null) || \
{ git tag -d "$(VERSION)" >/dev/null 2>&1; rm -f .release-notes.tmp; echo "git-cliff failed"; exit 1; }
@git tag -a --cleanup=verbatim -F .release-notes.tmp "$(VERSION)" HEAD && rm -f .release-notes.tmp
@git push origin "$(VERSION)"
@echo "Pushed $(VERSION) — Gitea Actions will build the image and publish the Release."
+3 -6
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,9 @@ const corsOptions = {
},
}
const limiter = rateLimit({
windowMs: 10 * 60 * 1000, // 10 minutes
max: 100
})
app.use(express.json(), cookieParser(), morgan('dev'), mongoSanitize(), helmet(), xss(), apiLimiter, hpp(), cors())
app.use(express.json(), cookieParser(), morgan('dev'), mongoSanitize(), helmet(), xss(), limiter, hpp(), cors(corsOptions))
app.get('/health', (req, res) => res.status(200).json({ status: 'ok' }))
app.use('/api/admin/games', adminGames)
app.use('/api/games', games)
+37
View File
@@ -0,0 +1,37 @@
# git-cliff configuration — generates the body of each Gitea Release from
# Conventional Commits accumulated since the previous tag. Used locally by
# `make release` with --latest so only the current tag's section is emitted
# (no full history, no header — the Gitea Release title is the tag).
# Docs: https://git-cliff.org/docs/configuration
[changelog]
header = ""
body = """
{% for group, commits in commits | group_by(attribute="group") %}\
### {{ group | upper_first }}
{% for commit in commits %}\
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{{ commit.message | upper_first }} ({{ commit.id | truncate(length=7, end="") }})
{% endfor %}\
{% endfor %}\
"""
trim = true
footer = ""
[git]
conventional_commits = true
filter_unconventional = false
require_conventional = false
split_commits = false
commit_parsers = [
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "Refactor" },
{ message = "^docs", group = "Documentation" },
{ message = "^test", group = "Tests" },
{ message = "^chore|^ci", group = "Miscellaneous Tasks" },
{ message = ".*", group = "Other" },
]
filter_commits = false
tag_pattern = "v[0-9].*"
sort_commits = "oldest"
+148 -88
View File
@@ -1,53 +1,52 @@
// noinspection DuplicatedCode
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 Game from "../models/Game.js";
import steamScraper from "../scripts/scraper.js";
import asyncHandler from "../middleware/async.js";
import ErrorResponse from "../utils/errorResponse.js";
import { decode, isValid } from "js-base64";
const checkForHexRegExp = new RegExp('^[0-9a-fA-F]{24}$')
const checkForTwelveRegExp = new RegExp('^[0-9a-fA-F]{12}$')
const checkForHexRegExp = new RegExp("^[0-9a-fA-F]{24}$");
const checkForTwelveRegExp = new RegExp("^[0-9a-fA-F]{12}$");
/**
* games.js
*
* @description :: Server-side logic for managing games.
*/
/**
* gameController.list()
*/
export const list = asyncHandler(async (req, res, next) => {
const data = res.advancedResults.data
const data = res.advancedResults.data;
if (data[0]?.accessedBy) {
for (let i = 0; i < data.length; i++) {
for (let x = 0; x < data[i].accessedBy.length; x++) {
if (data[i].accessedBy[x].user.toString() !== req.user.id)
data[i].accessedBy.splice(x, 1)
data[i].accessedBy.splice(x, 1);
}
}
}
return res.status(200).json(res.advancedResults)
})
return res.status(200).json(res.advancedResults);
});
/**
* gameController.show()
*/
export const show = asyncHandler(async (req, res, next) => {
const {id} = req.params
const { id } = req.params;
const gameId =
id === id.match(checkForTwelveRegExp) || id.match(checkForHexRegExp)
? '_id'
: 'steamId'
? "_id"
: "steamId";
const game = await Game.findOne({[gameId]: id})
const game = await Game.findOne({ [gameId]: id });
if (!game)
return next(
new ErrorResponse(`Game not found with id of ${req.params.id}`, 404),
)
);
if (
!game.accessedBy
@@ -59,54 +58,89 @@ export const show = asyncHandler(async (req, res, next) => {
`You do not have permission to access Game ID ${req.params.id}`,
401,
),
)
);
const userGame = await Game.findOne({ [gameId]: id }).select(
'-createdBy -__v',
)
"-createdBy -__v",
);
for (let i = 0; i < userGame.accessedBy.length; i++) {
if (userGame.accessedBy[i].user.toString() !== req.user.id)
userGame.accessedBy.splice(i, 1)
userGame.accessedBy.splice(i, 1);
}
res.status(200).json({success: true, data: userGame})
})
res.status(200).json({ success: true, data: userGame });
});
/**
* gameController.create()
*/
export const create = asyncHandler(async (req, res, next) => {
let oldGame
let oldGame;
let steamId;
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)
isValid(req.body.systemRequirements?.windows?.minimum) ? req.body.systemRequirements.windows.minimum = decode(req.body.systemRequirements.windows.minimum) : ''
isValid(req.body.systemRequirements?.windows?.recommended) ? req.body.systemRequirements.windows.recommended = decode(req.body.systemRequirements.windows.recommended) : ''
isValid(req.body.systemRequirements?.mac?.minimum) ? req.body.systemRequirements.mac.minimum = decode(req.body.systemRequirements.mac.minimum) : ''
isValid(req.body.systemRequirements?.mac?.recommended) ? req.body.systemRequirements.mac.recommended = decode(req.body.systemRequirements.mac.recommended) : ''
isValid(req.body.systemRequirements?.linux?.minimum) ? req.body.systemRequirements.linux.minimum = decode(req.body.systemRequirements.linux.minimum) : ''
isValid(req.body.systemRequirements?.linux?.recommended) ? req.body.systemRequirements.linux.recommended = decode(req.body.systemRequirements.linux.recommended) : ''
if (req.body.steamId.includes("store.steampowered.com"))
steamId = req.body.steamId.match(/\d+/)[0];
else steamId = req.body.steamId;
req.body.steamId.length > 0
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);
isValid(req.body.systemRequirements?.windows?.minimum)
? (req.body.systemRequirements.windows.minimum = decode(
req.body.systemRequirements.windows.minimum,
))
: "";
isValid(req.body.systemRequirements?.windows?.recommended)
? (req.body.systemRequirements.windows.recommended = decode(
req.body.systemRequirements.windows.recommended,
))
: "";
isValid(req.body.systemRequirements?.mac?.minimum)
? (req.body.systemRequirements.mac.minimum = decode(
req.body.systemRequirements.mac.minimum,
))
: "";
isValid(req.body.systemRequirements?.mac?.recommended)
? (req.body.systemRequirements.mac.recommended = decode(
req.body.systemRequirements.mac.recommended,
))
: "";
isValid(req.body.systemRequirements?.linux?.minimum)
? (req.body.systemRequirements.linux.minimum = decode(
req.body.systemRequirements.linux.minimum,
))
: "";
isValid(req.body.systemRequirements?.linux?.recommended)
? (req.body.systemRequirements.linux.recommended = decode(
req.body.systemRequirements.linux.recommended,
))
: "";
steamId.length > 0
? (oldGame = await Game.findOne({
steamId: req.body.steamId,
'accessedBy.user': req.user.id,
steamId: steamId,
"accessedBy.user": req.user.id,
}))
: (oldGame = await Game.findOne({
title: req.body.title,
'accessedBy.user': req.user.id,
}))
"accessedBy.user": req.user.id,
}));
if (oldGame)
return next(
new ErrorResponse(`The game ${oldGame.title} already exists in users account.`, 400)
)
new ErrorResponse(
`The game ${oldGame.title} already exists in users account.`,
400,
),
);
req.body.steamId.length > 0
? (oldGame = await Game.findOne({steamId: req.body.steamId}))
: (oldGame = await Game.findOne({title: req.body.title}))
steamId.length > 0
? (oldGame = await Game.findOne({ steamId: steamId }))
: (oldGame = await Game.findOne({ title: req.body.title }));
if (oldGame) {
oldGame.accessedBy.push({
@@ -115,64 +149,91 @@ export const create = asyncHandler(async (req, res, next) => {
playStatus: req.body.accessedBy[0].playStatus,
soundtrack: req.body.accessedBy[0].soundtrack,
rating: req.body.accessedBy[0].rating,
})
});
oldGame.lastModifiedBy = req.user.id
oldGame.lastModifiedBy = req.user.id;
await oldGame.save()
await oldGame.save();
return res.status(200).json({
success: true,
data: oldGame,
})
});
}
req.body.createdBy = req.user.id
req.body.lastModifiedBy = req.user.id
req.body.accessedBy[0].user = req.user.id
req.body.createdBy = req.user.id;
req.body.lastModifiedBy = req.user.id;
req.body.accessedBy[0].user = req.user.id;
const data =
req.body.scrape === true ? await steamScraper(req.body) : req.body
req.body.scrape === true ? await steamScraper(req.body) : req.body;
const game = await Game.create(data)
const game = await Game.create(data);
res.status(200).json({
success: true,
data: game,
})
})
});
});
/**
* gameController.update()
*/
export const update = asyncHandler(async (req, res, next) => {
const {id} = req.params
const { id } = req.params;
const gameId =
id === id.match(checkForTwelveRegExp) || id.match(checkForHexRegExp)
? '_id'
: 'steamId'
? "_id"
: "steamId";
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)
isValid(req.body.systemRequirements?.windows?.minimum) ? req.body.systemRequirements.windows.minimum = decode(req.body.systemRequirements.windows.minimum) : ''
isValid(req.body.systemRequirements?.windows?.recommended) ? req.body.systemRequirements.windows.recommended = decode(req.body.systemRequirements.windows.recommended) : ''
isValid(req.body.systemRequirements?.mac?.minimum) ? req.body.systemRequirements.mac.minimum = decode(req.body.systemRequirements.mac.minimum) : ''
isValid(req.body.systemRequirements?.mac?.recommended) ? req.body.systemRequirements.mac.recommended = decode(req.body.systemRequirements.mac.recommended) : ''
isValid(req.body.systemRequirements?.linux?.minimum) ? req.body.systemRequirements.linux.minimum = decode(req.body.systemRequirements.linux.minimum) : ''
isValid(req.body.systemRequirements?.linux?.recommended) ? req.body.systemRequirements.linux.recommended = decode(req.body.systemRequirements.linux.recommended) : ''
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);
isValid(req.body.systemRequirements?.windows?.minimum)
? (req.body.systemRequirements.windows.minimum = decode(
req.body.systemRequirements.windows.minimum,
))
: "";
isValid(req.body.systemRequirements?.windows?.recommended)
? (req.body.systemRequirements.windows.recommended = decode(
req.body.systemRequirements.windows.recommended,
))
: "";
isValid(req.body.systemRequirements?.mac?.minimum)
? (req.body.systemRequirements.mac.minimum = decode(
req.body.systemRequirements.mac.minimum,
))
: "";
isValid(req.body.systemRequirements?.mac?.recommended)
? (req.body.systemRequirements.mac.recommended = decode(
req.body.systemRequirements.mac.recommended,
))
: "";
isValid(req.body.systemRequirements?.linux?.minimum)
? (req.body.systemRequirements.linux.minimum = decode(
req.body.systemRequirements.linux.minimum,
))
: "";
isValid(req.body.systemRequirements?.linux?.recommended)
? (req.body.systemRequirements.linux.recommended = decode(
req.body.systemRequirements.linux.recommended,
))
: "";
let game = await Game.findOne({
[gameId]: id,
'accessedBy.user': req.user.id,
})
"accessedBy.user": req.user.id,
});
if (!game)
return next(
new ErrorResponse(`A game with the id of ${id} does not exist`, 401),
)
);
game = await Game.findOneAndUpdate(
{[gameId]: id, 'accessedBy.user': req.user.id},
{ [gameId]: id, "accessedBy.user": req.user.id },
{
$set: {
series: req.body.series,
@@ -180,59 +241,58 @@ export const update = asyncHandler(async (req, res, next) => {
genre: req.body.genre,
wine: req.body.wine,
lastModifiedBy: req.user.id,
'accessedBy.$.store': req.body.accessedBy[0].store,
'accessedBy.$.playStatus': req.body.accessedBy[0].playStatus,
'accessedBy.$.soundtrack': req.body.accessedBy[0].soundtrack,
'accessedBy.$.rating': req.body.accessedBy[0].rating,
"accessedBy.$.store": req.body.accessedBy[0].store,
"accessedBy.$.playStatus": req.body.accessedBy[0].playStatus,
"accessedBy.$.soundtrack": req.body.accessedBy[0].soundtrack,
"accessedBy.$.rating": req.body.accessedBy[0].rating,
},
},
{ new: true, runValidators: true },
)
);
res.status(200).json({
success: true,
data: game,
})
})
});
});
/**
* gameController.remove()
*/
export const remove = asyncHandler(async (req, res, next) => {
const {id} = req.params
const { id } = req.params;
const gameId =
id === id.match(checkForTwelveRegExp) || id.match(checkForHexRegExp)
? '_id'
: 'steamId'
? "_id"
: "steamId";
let game = await Game.findOne({
[gameId]: id,
'accessedBy.user': req.user.id,
})
"accessedBy.user": req.user.id,
});
if (!game)
return next(
new ErrorResponse(`A game with the id of ${id} does not exist`, 401),
)
);
if (game.accessedBy.length > 1) {
await Game.findOneAndUpdate(
{[gameId]: id, 'accessedBy.user': req.user.id},
{ [gameId]: id, "accessedBy.user": req.user.id },
{
$pull: {
accessedBy: { user: req.user.id },
},
},
{ new: true, runValidators: true },
)
);
} else {
await Game.findOneAndDelete({[gameId]: id})
await Game.findOneAndDelete({ [gameId]: id });
}
res.status(200).json({
success: true,
data: {},
})
})
});
});
+39
View File
@@ -0,0 +1,39 @@
---
# Prod run template. Copy to docker-compose.yml next to the server .env:
# cp docker-compose.example.yml docker-compose.yml
# Then: docker compose pull && docker compose up -d
#
# Required keys in .env (singular JWT_EXPIRE, as read by models/User.js):
# ACCESS_TOKEN_SECRET, REFRESH_TOKEN_SECRET, JWT_EXPIRE,
# MONGO_URI, SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD,
# FROM_EMAIL, FROM_NAME, SECURE, NODE_ENV
# Alternatively, comment out env_file above and uncomment the
# environment list below to keep values directly in this file.
# environment:
# - ACCESS_TOKEN_SECRET=
# - REFRESH_TOKEN_SECRET=
# - JWT_EXPIRE=
# - MONGO_URI=
# - SMTP_HOST=
# - SMTP_PORT=
# - SMTP_USER=
# - SMTP_PASSWORD=
# - FROM_EMAIL=
# - FROM_NAME=
# - SECURE=false
# - NODE_ENV=
services:
games-api:
image: git.linuxhg.com/games-database/games-api:${IMAGE_TAG:-latest}
container_name: games-api
restart: unless-stopped
env_file:
- .env
ports:
- 5000:5000
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:5000/health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
+1 -1
View File
@@ -162,7 +162,7 @@ const advancedResults = (model, populate) => async (req, res, next) => {
const sortBy = req.query.sort.split(',').join(' ')
query = query.sort(sortBy)
} else {
query = query.sort('series title')
query = query.sort('-createDate series title')
}
// Pagination
+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.',
},
})
Executable
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env sh
# Project-attached wrapper around `make release` so you can run:
# ./release v0.1.0 (or) ./release 0.1.0
# instead of:
# make release VERSION=v0.1.0
# Lives in the repo (no machine-specific alias needed).
set -eu
[ "$#" -ge 1 ] || { echo "Usage: ./release v0.1.0" >&2; exit 1; }
# Accept "0.1.0" or "v0.1.0"; ensure the tag starts with 'v' (the workflow
# only triggers on v* tags).
VERSION="v${1#v}"
exec make release "VERSION=${VERSION}"
+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
+1 -5
View File
@@ -33,12 +33,8 @@ const createAdmin = async () => {
gamesToAccess: [],
})
await user.save((err) => {
await user.save()
console.log('Admin user successfully created')
if (err) {
return console.error('Could not save admin user')
}
})
} catch (err) {
return console.error(
'Server Error: Could not create admin user' + err.message
+1 -1
View File
@@ -6,7 +6,7 @@ const steamScraper = async (resData) => {
if (steamId === null || steamId === '') {
return 'Please enter a valid Steam Id'
}
const steamApiUrl = `https://store.steampowered.com/api/appdetails/?appids=${steamId}`
const steamApiUrl = `https://store.steampowered.com/api/appdetails/?appids=${steamId}&l=english`
const steamRatingApiUrl = `https://store.steampowered.com/appreviews/${steamId}?json=1`
const steamWebUrl = `https://store.steampowered.com/app/${steamId}`
try {