Compare commits
15
Commits
d575a4efc5
..
v1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee7aa9921b | ||
|
|
964415198a | ||
|
|
0adf0bd51f | ||
|
|
39f1c88255 | ||
|
|
4fe5aa25f8 | ||
|
|
a5ceb721f3 | ||
|
|
38e6d21585 | ||
|
|
689610b434 | ||
|
|
748616df9d | ||
|
|
a9b9fe1047 | ||
|
|
20c1fd7be9 | ||
|
|
2800d2fb2e | ||
|
|
cce705c4f7 | ||
|
|
ed889d95e6 | ||
|
|
c61d0c40af |
@@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
.dockerignore
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
LICENSE
|
||||
.vscode
|
||||
Makefile
|
||||
helm-charts
|
||||
.env
|
||||
.editorconfig
|
||||
.idea
|
||||
coverage*
|
||||
@@ -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
|
||||
@@ -130,3 +130,5 @@ dist
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# docker-compose.yml
|
||||
docker-compose.yml
|
||||
|
||||
+41
@@ -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" ]
|
||||
@@ -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."
|
||||
@@ -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
@@ -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"
|
||||
@@ -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,
|
||||
|
||||
+162
-88
@@ -1,53 +1,53 @@
|
||||
// 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 normalizeSteamId from "../utils/normalizeSteamId.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 +59,94 @@ 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;
|
||||
|
||||
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) : ''
|
||||
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;
|
||||
|
||||
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 +155,99 @@ 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)
|
||||
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({
|
||||
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 +255,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: {},
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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