Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Database Congiguration
DB_USERNAME=godot-asset-library
DB_PASSWORD=root
DB_HOST=mongo
DB_PORT=27017
DB_NAME=godot-asset-library

Expand All @@ -19,3 +20,16 @@ IMGPROXY_SALT=
IMGPROXY_HOST=https://img.godotassetlibrary.com
IMGPROXY_ENABLED=true
PROJECT_BASE_URL=https://godotassetlibrary.com

# Mongo pool / concurrency tuning (optional)
# The driver always pools connections, but the pool is lazy: connections are
# created on demand (minPoolSize 0), capped at MONGO_MAX_POOL, and idle ones
# are pruned after MONGO_MAX_IDLE_MS.
# MONGO_MAX_POOL=1000 # max connections per client (default 1000; high ceiling = "spin up as needed")
# MONGO_MIN_POOL=0 # pre-warmed connections kept ready (default 0 = none; pure lazy)
# MONGO_MAX_IDLE_MS=300000 # close idle connections after this many ms (default 5 min)
# MAX_CONCURRENT_REQUESTS=100
# ARGON2_MAX_CONCURRENCY=2

# Import window (comma separated Godot versions the mirror imports)
# IMPORT_GODOT_VERSIONS=2.2,3.9,4.9
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
node_modules
# don't lint build output (make sure it's set to your correct build folder name)
dist
dist-test
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
node_modules/
dist/*
dist-test/
.env
35 changes: 25 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,35 @@ npm run devel

For linting:
```
npm run lint
npm run lint:check
```

### Indexes
Searching the catalog of assets relies on MongoDBs `text` search, so we need to create a text asset on the `asset` collection. To do so is pretty easy, just log into the MongoDB shell and run:
### Verification
```
db.assets.createIndex({
description: "text",
quick_description: "text",
title: "text",
author: "text",
})
npm run typecheck # TypeScript validation (use this, not `build`, for types)
npm run lint:check # ESLint enforcement (npm run lint exits 0 by design)
npm test # Compiles and runs the node:test suite (host Node 18+)
npm run build # Webpack bundle + per-page Sass
```

### Indexes & migrations
Search relies on MongoDB text search and several derived fields. Migrations
are applied with a single command (they record completion in the `migrations`
collection, so they are idempotent):
```
npm run migrate
```
Migrations create/verify:
* `0001` — the weighted text index (`title` 10, `quick_description` 7, `author` 7, `description` 1). MongoDB only allows one text index per collection, so a conflicting legacy index must be dropped manually first.
* `0002` — backfills the confidence-adjusted `rating_score` (95% Wilson lower bound) used by "Highest rated" sorting.
* `0003` — backfills the normalized `modify_date_at` used by "Recently updated" sorting.
* `0004` — deduplicates reviews by `(user_id, asset_id)` and creates the unique index.

Operational maintenance (run against a snapshot first):
```
npm run reconcile:ratings # Recompute vote counters + rating_score from reviews
npm run audit:catalog # Read-only catalog health audit
```
In future versions we may do this automatically if it detects the index doesn't already exist, but for now its a manual process.

## Folder Structure
```
Expand Down
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@
"build": "mkdir -p dist/ && node src/BuildTaskRunner.js --production --build-only",
"start": "node dist/bundle.js",
"lint": "eslint . --ext .js,.ts ; exit 0",
"lint:check": "eslint . --ext .js,.ts",
"typecheck": "tsc --noEmit",
"audit:catalog": "ts-node --transpile-only -r tsconfig-paths/register src/core/maintenance/runCatalogAudit.ts",
"reconcile:ratings": "ts-node --transpile-only -r tsconfig-paths/register src/core/maintenance/runReconcileRatings.ts",
"migrate": "ts-node --transpile-only -r tsconfig-paths/register src/core/migrations/runMigrations.ts",
"test:build": "tsc -p tsconfig.test.json",
"test": "npm run test:build && node --test --require ./tests/bootstrap.cjs \"dist-test/tests/*.test.js\"",
"prod": "npm run build && npm start"
},
"eslintConfig": {
Expand Down
3 changes: 2 additions & 1 deletion src/app/code/asset/controllers/AssetController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Controller, Get, Middleware, Post, Patch } from '@overnightjs/core'
import { Request, Response } from 'express'
import rateLimit from 'express-rate-limit'
import { CheckIfUserExistAndSendError } from 'core/modules/authentication/middleware/CheckIfUserExistAndSendError'
import { rateLimitHandler } from 'core/utils/rateLimitHandler'
import { AssetService } from '../services/AssetService'

const reviewAssetRateLimit = rateLimit({
Expand All @@ -25,7 +26,7 @@ const reportReviewRateLimit = rateLimit({
const renderAssetRateLimit = rateLimit({
windowMs: 1000 * 60 * 15, // 15 minutes
max: 60, // start blocking after x requests
message: JSON.stringify({ error: 'You\'re doing that too often, please try again later' })
handler: rateLimitHandler('You\'re doing that too often, please try again later')
})

@Controller('asset')
Expand Down
10 changes: 3 additions & 7 deletions src/app/code/asset/models/GET/GetAssetDisplayInformation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ interface ReturnedAsset extends WithId<Document>, assetSchema {}
* Get asset information for display page
*
* @param {string} assetId
* @returns {ReturnedAsset}
* @returns {Promise<ReturnedAsset | null>} the asset, or null when it does not exist
*/
export async function GetAssetDisplayInformation (assetId: String): Promise<ReturnedAsset> {
export async function GetAssetDisplayInformation (assetId: String): Promise<ReturnedAsset | null> {
const mongo = MongoHelper.getDatabase()
const operationObject = await mongo.collection('assets').findOne({ asset_id: assetId }, {
projection: {
Expand All @@ -22,11 +22,7 @@ export async function GetAssetDisplayInformation (assetId: String): Promise<Retu
legacy_asset_id: 0,
version: 0
}
}) as ReturnedAsset

if (operationObject === null || operationObject === undefined) {
throw new Error('No assets found')
}
}) as ReturnedAsset | null

return operationObject
}
6 changes: 6 additions & 0 deletions src/app/code/asset/models/GET/GetAssetReviewCount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { MongoHelper } from 'core/MongoHelper'

export async function GetAssetReviewCount (assetId: string): Promise<number> {
const mongo = MongoHelper.getDatabase()
return await mongo.collection('reviews').countDocuments({ asset_id: assetId })
}
18 changes: 15 additions & 3 deletions src/app/code/asset/models/GET/GetAssetReviewsById.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,26 @@ import { reviews } from 'app/utilities/fetchFromGodot/schema/reviews'
interface ReturnedReviews extends WithId<Document>, reviews {}

/**
* Get all reviews for asset
* Get reviews for asset, newest first, deterministically ordered and bounded
* to a page of results.
*
* @param {string} assetId
* @param {number} limit max reviews to return
* @param {number} skip how many newest reviews to skip
* @returns {ReturnedReviews}
*/
export async function GetAssetReviewsById (assetId: String): Promise<ReturnedReviews[]> {
export async function GetAssetReviewsById (
assetId: String,
limit: number = 10,
skip: number = 0
): Promise<ReturnedReviews[]> {
const mongo = MongoHelper.getDatabase()
const operationObject = await mongo.collection('reviews').find({ asset_id: assetId }).limit(10).toArray() as ReturnedReviews[]
const operationObject = await mongo.collection('reviews')
.find({ asset_id: assetId })
.sort({ date: -1, _id: -1 })
.limit(limit)
.skip(skip)
.toArray() as ReturnedReviews[]

return operationObject
}
78 changes: 78 additions & 0 deletions src/app/code/asset/models/GET/GetRelatedAssets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Document, WithId } from 'mongodb'
import { MongoHelper } from 'core/MongoHelper'
import { assetGridSchema } from 'app/components/partials/catalog-grid/asset-grd-schema'

interface ReturnedAssets extends WithId<Document>, assetGridSchema {}

/**
* Related assets with compatibility-aware fallback tiers. A bounded pool of
* same-category assets is ranked by how close each peer is to the current
* asset: exact Godot version first, then same major version, then same type,
* then by confidence-adjusted rating and recency.
*/
export async function GetRelatedAssets (
category: string,
godotVersion: string | undefined,
assetType: string | undefined,
excludeAssetId: string
): Promise<ReturnedAssets[]> {
const mongo = MongoHelper.getDatabase()

const pool = await mongo.collection('assets').find(
{
asset_id: { $ne: excludeAssetId },
category: category
},
{
limit: 12,
sort: { rating_score: -1, upvotes: -1, asset_id: 1 },
projection: {
category: 1,
godot_version: 1,
author: 1,
title: 1,
quick_description: 1,
icon_url: 1,
upvotes: 1,
downvotes: 1,
rating_score: 1,
featured: 1,
asset_id: 1,
previews: 1,
card_banner: 1,
modify_date: 1,
modify_date_at: 1,
added_date: 1,
version_string: 1,
type: 1,
support_level: 1
}
}
).toArray() as ReturnedAssets[]

const major = godotVersion?.split('.')[0]
const ranked = pool
.map(asset => {
let tier = 3
if (godotVersion !== undefined && godotVersion !== '' && asset.godot_version === godotVersion) {
tier = 0
} else if (major !== undefined && major !== '' && String(asset.godot_version ?? '').split('.')[0] === major) {
tier = 1
} else if (assetType !== undefined && assetType !== '' && asset.type === assetType) {
tier = 2
}
return { asset, tier }
})
.sort((a, b) => {
if (a.tier !== b.tier) return a.tier - b.tier
const aScore = Number(a.asset.rating_score ?? 0)
const bScore = Number(b.asset.rating_score ?? 0)
if (aScore !== bScore) return bScore - aScore
const aUp = Number(a.asset.upvotes ?? 0)
const bUp = Number(b.asset.upvotes ?? 0)
if (aUp !== bUp) return bUp - aUp
return String(a.asset.asset_id).localeCompare(String(b.asset.asset_id))
})

return ranked.slice(0, 4).map(entry => entry.asset)
}
38 changes: 38 additions & 0 deletions src/app/code/asset/models/UPDATE/RefreshAssetRating.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { MongoHelper } from 'core/MongoHelper'
import { wilsonScore } from 'core/utils/ratingScore'

/**
* Recompute an asset's upvotes/downvotes/rating_score from its canonical
* review documents. This is the single source of truth for the counters and
* keeps them consistent with what cards display, even if an earlier write
* failed partway through.
*/
export async function RefreshAssetRating (assetId: string): Promise<void> {
const mongo = MongoHelper.getDatabase()
const reviews = mongo.collection('reviews')

const [row] = await reviews.aggregate([
{ $match: { asset_id: assetId } },
{
$group: {
_id: '$asset_id',
upvotes: { $sum: { $cond: [{ $eq: ['$review_type', 'positive'] }, 1, 0] } },
downvotes: { $sum: { $cond: [{ $eq: ['$review_type', 'negative'] }, 1, 0] } }
}
}
]).maxTimeMS(5000).toArray()

const upvotes = row?.upvotes ?? 0
const downvotes = row?.downvotes ?? 0

await mongo.collection('assets').updateOne(
{ asset_id: assetId },
{
$set: {
upvotes,
downvotes,
rating_score: wilsonScore(upvotes, downvotes)
}
}
)
}
Loading