From 52c733ece33cee1572658982b49dc984a2fbc4a9 Mon Sep 17 00:00:00 2001 From: Chris Bolton Date: Mon, 10 Aug 2026 20:49:45 -0400 Subject: [PATCH 01/14] Add Node test harness and tooling configuration --- .env.example | 14 +++ .eslintignore | 1 + .gitignore | 1 + package.json | 7 ++ tests/asset-url.test.ts | 31 +++++ tests/bootstrap.cjs | 20 +++ tests/card-view.test.ts | 36 ++++++ tests/media.test.ts | 54 ++++++++ tests/node-test.d.ts | 14 +++ tests/rating-summary.test.ts | 38 ++++++ tests/rating.test.ts | 45 +++++++ tests/safe-url.test.ts | 28 +++++ tests/search.test.ts | 232 +++++++++++++++++++++++++++++++++++ tsconfig.test.json | 17 +++ 14 files changed, 538 insertions(+) create mode 100644 tests/asset-url.test.ts create mode 100644 tests/bootstrap.cjs create mode 100644 tests/card-view.test.ts create mode 100644 tests/media.test.ts create mode 100644 tests/node-test.d.ts create mode 100644 tests/rating-summary.test.ts create mode 100644 tests/rating.test.ts create mode 100644 tests/safe-url.test.ts create mode 100644 tests/search.test.ts create mode 100644 tsconfig.test.json diff --git a/.env.example b/.env.example index abc11cd5..ed7a19df 100644 --- a/.env.example +++ b/.env.example @@ -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 @@ -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 diff --git a/.eslintignore b/.eslintignore index 0051b4b2..d1e30ddd 100644 --- a/.eslintignore +++ b/.eslintignore @@ -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 diff --git a/.gitignore b/.gitignore index 9e8c5b69..2dddf87f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/* +dist-test/ .env diff --git a/package.json b/package.json index 4b6d0fa1..698d33ad 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/tests/asset-url.test.ts b/tests/asset-url.test.ts new file mode 100644 index 00000000..34dc9090 --- /dev/null +++ b/tests/asset-url.test.ts @@ -0,0 +1,31 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { buildAssetUrl, buildAssetUrlWithReturn, buildCardAnchor } from '../src/core/utils/assetUrl' + +describe('assetUrl', () => { + it('builds a canonical asset URL with a slug', () => { + assert.equal(buildAssetUrl('abc123', 'My Cool Shader'), '/asset/abc123/my-cool-shader') + }) + + it('strips non-alphanumeric characters from the slug', () => { + assert.equal(buildAssetUrl('abc123', 'Grass & Trees! (2D)'), '/asset/abc123/grass-trees-2d') + }) + + it('falls back to id-only URL for an empty title', () => { + assert.equal(buildAssetUrl('abc123', ''), '/asset/abc123') + assert.equal(buildAssetUrl('abc123', ' '), '/asset/abc123') + }) + + it('appends an encoded from parameter when a source URL is provided', () => { + const url = buildAssetUrlWithReturn('abc123', 'Shader', '/search/?q=shader#asset-abc123') + assert.equal(url, '/asset/abc123/shader?from=%2Fsearch%2F%3Fq%3Dshader%23asset-abc123') + }) + + it('omits the from parameter when absent', () => { + assert.equal(buildAssetUrlWithReturn('abc123', 'Shader'), '/asset/abc123/shader') + }) + + it('builds stable card anchors', () => { + assert.equal(buildCardAnchor('abc123'), 'asset-abc123') + }) +}) diff --git a/tests/bootstrap.cjs b/tests/bootstrap.cjs new file mode 100644 index 00000000..da7f4d5a --- /dev/null +++ b/tests/bootstrap.cjs @@ -0,0 +1,20 @@ +/** + * Runtime bootstrap for the compiled test suite. + * + * Tests are compiled by `tsc -p tsconfig.test.json` into `dist-test/` with the + * source tree under `dist-test/src`. TypeScript path aliases (`core/*`, + * `app/*`) are not rewritten by the compiler, so this registers them against + * the compiled output before the test files load. + */ +const path = require('path') +const tsConfigPaths = require('tsconfig-paths') + +const baseUrl = path.resolve(__dirname, '..', 'dist-test', 'src') + +tsConfigPaths.register({ + baseUrl, + paths: { + 'core/*': ['core/*'], + 'app/*': ['app/*'] + } +}) diff --git a/tests/card-view.test.ts b/tests/card-view.test.ts new file mode 100644 index 00000000..8980c100 --- /dev/null +++ b/tests/card-view.test.ts @@ -0,0 +1,36 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { attachCardExtras } from '../src/core/utils/cardView' + +describe('attachCardExtras', () => { + it('keeps safe http(s) download URLs', () => { + const assets: Array> = [ + { asset_id: '1', download_url: 'https://github.com/example/a.zip' } + ] + + attachCardExtras(assets) + + assert.equal(assets[0].download_url, 'https://github.com/example/a.zip') + }) + + it('blanks non-http(s) download URLs', () => { + const cases: Array<[string, string]> = [ + ['javascript:alert(1)', ''], + ['ftp://example.com/a.zip', ''], + ['data:text/plain;base64,x', ''], + ['', ''], + ['not a url', ''] + ] + + const assets: Array> = cases.map(([url]) => ({ asset_id: String(Math.random()), download_url: url })) + attachCardExtras(assets) + + assets.forEach((asset, index) => { + assert.equal(asset.download_url, cases[index][1], `expected download_url to be blank for ${cases[index][0]}`) + }) + }) + + it('is a no-op for an empty list', () => { + attachCardExtras([]) + }) +}) diff --git a/tests/media.test.ts b/tests/media.test.ts new file mode 100644 index 00000000..242ad51b --- /dev/null +++ b/tests/media.test.ts @@ -0,0 +1,54 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { normalizePreviews } from '../src/core/utils/mediaHelpers' + +describe('normalizePreviews', () => { + it('classifies YouTube links as embeddable videos', () => { + const items = normalizePreviews([ + { link: 'https://www.youtube.com/watch?v=abc123def45' } + ]) + assert.equal(items.length, 1) + assert.equal(items[0].type, 'video') + assert.ok(items[0].embedUrl?.includes('embed/abc123def45')) + }) + + it('classifies image extensions and thumbnailed links as images', () => { + const items = normalizePreviews([ + { link: 'https://example.com/screenshot.png' }, + { link: 'https://example.com/preview', thumbnail: 'https://example.com/thumb.jpg' } + ]) + assert.equal(items.length, 2) + assert.ok(items.every(item => item.type === 'image')) + }) + + it('classifies non-YouTube video files as external, not broken images', () => { + const items = normalizePreviews([ + { link: 'https://example.com/trailer.mp4' }, + { link: 'https://example.com/clip.webm' } + ]) + assert.equal(items.length, 2) + assert.ok(items.every(item => item.type === 'external')) + }) + + it('classifies unknown unthumbnailed links as external', () => { + const items = normalizePreviews([ + { link: 'https://example.com/some-preview' } + ]) + assert.equal(items[0].type, 'external') + }) + + it('drops non-http(s) and malformed links entirely', () => { + const items = normalizePreviews([ + { link: 'javascript:alert(1)' }, + { link: 'not a url' }, + { link: '' }, + { link: 'data:text/html,hi' } + ]) + assert.equal(items.length, 0) + }) + + it('returns an empty list for non-array input', () => { + assert.deepEqual(normalizePreviews(undefined), []) + assert.deepEqual(normalizePreviews(null), []) + }) +}) diff --git a/tests/node-test.d.ts b/tests/node-test.d.ts new file mode 100644 index 00000000..e0c52dc7 --- /dev/null +++ b/tests/node-test.d.ts @@ -0,0 +1,14 @@ +/** + * Minimal type declarations for Node's built-in test runner. + * + * The project pins @types/node@17, which predates the `node:test` module + * types. This shim is included only by tsconfig.test.json so the compiled + * tests typecheck without upgrading the shared types dependency. The real + * `node:test` module is available at runtime on Node 18+. + */ +declare module 'node:test' { + export type TestFn = () => void | Promise + export function describe (name: string, fn: () => void): void + export function it (name: string, fn: TestFn): void + export const test: typeof it +} diff --git a/tests/rating-summary.test.ts b/tests/rating-summary.test.ts new file mode 100644 index 00000000..23437bcb --- /dev/null +++ b/tests/rating-summary.test.ts @@ -0,0 +1,38 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { buildRatingSummary } from '../src/core/utils/ratingSummary' + +describe('buildRatingSummary', () => { + it('handles assets with no ratings', () => { + const summary = buildRatingSummary(0, 0) + assert.equal(summary.total, 0) + assert.equal(summary.approvalPercent, 0) + assert.equal(summary.hasRatings, false) + assert.equal(summary.label, 'No ratings yet') + assert.equal(summary.score, 0) + }) + + it('computes approval percentage and sample size', () => { + const summary = buildRatingSummary(75, 25) + assert.equal(summary.total, 100) + assert.equal(summary.approvalPercent, 75) + assert.equal(summary.label, '75% approval · 100 ratings') + }) + + it('uses singular rating wording for a single vote', () => { + const summary = buildRatingSummary(1, 0) + assert.equal(summary.label, '100% approval · 1 rating') + }) + + it('reports a confidence-adjusted score', () => { + const clean = buildRatingSummary(20, 0) + const noisy = buildRatingSummary(100, 100) + assert.ok(clean.score > noisy.score, `expected ${clean.score} > ${noisy.score}`) + }) + + it('guards against non-numeric input', () => { + const summary = buildRatingSummary(Number.NaN, Number.NaN) + assert.equal(summary.total, 0) + assert.equal(summary.hasRatings, false) + }) +}) diff --git a/tests/rating.test.ts b/tests/rating.test.ts new file mode 100644 index 00000000..9eedbd98 --- /dev/null +++ b/tests/rating.test.ts @@ -0,0 +1,45 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { wilsonScore } from '../src/core/utils/ratingScore' + +describe('wilsonScore', () => { + it('returns 0 when there are no votes', () => { + assert.equal(wilsonScore(0, 0), 0) + assert.equal(wilsonScore(0, 0), 0) + }) + + it('never exceeds 1', () => { + assert.ok(wilsonScore(1000, 0) <= 1) + }) + + it('is never negative (clamps the lower bound)', () => { + assert.ok(wilsonScore(0, 5) >= 0) + }) + + it('ranks a clean 20/0 above a noisy 100/100', () => { + const clean = wilsonScore(20, 0) + const noisy = wilsonScore(100, 100) + assert.ok(clean > noisy, `expected ${clean} > ${noisy}`) + }) + + it('ranks more votes above fewer votes at the same ratio', () => { + const few = wilsonScore(1, 0) + const many = wilsonScore(20, 0) + assert.ok(many > few, `expected ${many} > ${few}`) + }) + + it('ranks a 10/0 above an even 5/5', () => { + const positive = wilsonScore(10, 0) + const split = wilsonScore(5, 5) + assert.ok(positive > split, `expected ${positive} > ${split}`) + }) + + it('is deterministic', () => { + assert.equal(wilsonScore(30, 10), wilsonScore(30, 10)) + }) + + it('guards against non-finite input', () => { + assert.equal(wilsonScore(Number.NaN, 5), 0) + assert.equal(wilsonScore(Infinity, 5), 0) + }) +}) diff --git a/tests/safe-url.test.ts b/tests/safe-url.test.ts new file mode 100644 index 00000000..c4918973 --- /dev/null +++ b/tests/safe-url.test.ts @@ -0,0 +1,28 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { isSafeHttpUrl } from '../src/core/utils/safeUrl' + +describe('isSafeHttpUrl', () => { + it('accepts http and https URLs', () => { + assert.equal(isSafeHttpUrl('https://example.com/asset.zip'), true) + assert.equal(isSafeHttpUrl('http://example.com/asset.zip'), true) + assert.equal(isSafeHttpUrl(' https://example.com '), true) + }) + + it('rejects non-http schemes', () => { + assert.equal(isSafeHttpUrl('javascript:alert(1)'), false) + assert.equal(isSafeHttpUrl('data:text/html,hi'), false) + assert.equal(isSafeHttpUrl('file:///etc/passwd'), false) + assert.equal(isSafeHttpUrl('ftp://example.com/file'), false) + }) + + it('rejects empty, whitespace, and malformed values', () => { + assert.equal(isSafeHttpUrl(''), false) + assert.equal(isSafeHttpUrl(' '), false) + assert.equal(isSafeHttpUrl('not a url'), false) + assert.equal(isSafeHttpUrl('/relative/path'), false) + assert.equal(isSafeHttpUrl(undefined), false) + assert.equal(isSafeHttpUrl(null), false) + assert.equal(isSafeHttpUrl(123), false) + }) +}) diff --git a/tests/search.test.ts b/tests/search.test.ts new file mode 100644 index 00000000..0152c258 --- /dev/null +++ b/tests/search.test.ts @@ -0,0 +1,232 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { Request } from 'express' +import { + parseSearchRequest, + resolveSort, + ParsedSearchRequest +} from '../src/app/code/search/services/parseSearchRequest' +import { parsePagination } from '../src/core/utils/pagination' +import { + buildSearchUrl, + buildSearchViewModel +} from '../src/app/code/search/services/buildSearchViewModel' +import { SearchFacets } from '../src/app/code/search/models/GET/GetSearchFacets' + +function makeRequest ( + query: Record = {}, + params: Record = {} +): Request { + return { query, params } as unknown as Request +} + +function emptyFacets (): SearchFacets { + return { categories: [], engines: [], types: [], supports: [] } +} + +function makeParsed (overrides: Partial = {}): ParsedSearchRequest { + return { + query: '', + categories: [], + engines: [], + types: [], + supports: [], + featured: false, + requestedSort: '', + sort: 'last_modified', + limit: 12, + page: 0, + skip: 0, + displayPage: 1, + ...overrides + } +} + +describe('parseSearchRequest', () => { + it('parses an empty request with safe defaults', () => { + const parsed = parseSearchRequest(makeRequest()) + assert.equal(parsed.query, '') + assert.deepEqual(parsed.categories, []) + assert.deepEqual(parsed.engines, []) + assert.deepEqual(parsed.types, []) + assert.deepEqual(parsed.supports, []) + assert.equal(parsed.featured, false) + assert.equal(parsed.sort, 'last_modified') + assert.equal(parsed.limit, 12) + assert.equal(parsed.page, 0) + }) + + it('caps the query at 100 characters', () => { + const longQuery = 'a'.repeat(200) + const parsed = parseSearchRequest(makeRequest({ q: longQuery })) + assert.equal(parsed.query.length, 100) + }) + + it('normalizes comma-separated and repeated categories to lowercase', () => { + const parsed = parseSearchRequest(makeRequest({ + category: ['2D Tools,Templates', '2D Tools', 'Audio'] + })) + assert.deepEqual(parsed.categories, ['2d tools', 'templates', 'audio']) + }) + + it('parses type and support filters', () => { + const parsed = parseSearchRequest(makeRequest({ + type: ['Plugin', 'Shader'], + support: ['Community'] + })) + assert.deepEqual(parsed.types, ['Plugin', 'Shader']) + assert.deepEqual(parsed.supports, ['Community']) + }) + + it('parses the featured flag only for the literal true value', () => { + assert.equal(parseSearchRequest(makeRequest({ featured: 'true' })).featured, true) + assert.equal(parseSearchRequest(makeRequest({ featured: '1' })).featured, false) + assert.equal(parseSearchRequest(makeRequest({})).featured, false) + }) + + it('route category overrides query categories and normalizes + to space', () => { + const parsed = parseSearchRequest( + makeRequest({ category: ['Audio'] }, { category: '2D+Tools' }) + ) + assert.deepEqual(parsed.categories, ['2d tools']) + }) + + it('binds pagination to the shared utility', () => { + const parsed = parseSearchRequest(makeRequest({ limit: '999', page: '5000' })) + assert.equal(parsed.limit, 36) + assert.equal(parsed.page, 1000) + assert.equal(parsed.skip, 36000) + }) +}) + +describe('resolveSort', () => { + it('defaults to relevance for a nonempty query', () => { + assert.equal(resolveSort('', 'shader'), 'relevance') + assert.equal(resolveSort('garbage', 'shader'), 'relevance') + }) + + it('defaults to last_modified for an empty query', () => { + assert.equal(resolveSort('', ''), 'last_modified') + assert.equal(resolveSort('relevance', ''), 'last_modified') + }) + + it('keeps valid explicit sorts regardless of query', () => { + assert.equal(resolveSort('asset_rating', 'shader'), 'asset_rating') + assert.equal(resolveSort('newest', ''), 'newest') + assert.equal(resolveSort('last_modified', 'shader'), 'last_modified') + }) +}) + +describe('parsePagination', () => { + it('rejects zero and negative limits', () => { + assert.equal(parsePagination(0, 0).limit, 12) + assert.equal(parsePagination(-5, 0).limit, 12) + }) + + it('caps the limit at 36', () => { + assert.equal(parsePagination(100, 0).limit, 36) + }) + + it('rejects negative and non-integer pages', () => { + assert.equal(parsePagination(12, -1).page, 0) + assert.equal(parsePagination(12, 1.5).page, 0) + }) + + it('computes the skip from limit and page', () => { + assert.deepEqual(parsePagination(12, 3), { limit: 12, page: 3, skip: 36 }) + }) +}) + +describe('buildSearchUrl', () => { + const base = { + query: '', + categories: [] as string[], + engines: [] as string[], + types: [] as string[], + supports: [] as string[], + featured: false, + sort: 'relevance', + limit: 12, + page: 0 + } + + it('renders the bare search path when everything is default', () => { + assert.equal(buildSearchUrl(base), '/search/') + }) + + it('includes the query when present', () => { + assert.equal(buildSearchUrl({ ...base, query: 'shader' }), '/search/?q=shader') + }) + + it('appends repeated filters', () => { + const url = buildSearchUrl({ ...base, categories: ['2d tools', 'audio'], engines: ['4.2'] }) + assert.equal(url, '/search/?category=2d+tools&category=audio&engine=4.2') + }) + + it('includes type, support and featured filters', () => { + const url = buildSearchUrl({ + ...base, + types: ['Plugin'], + supports: ['Community'], + featured: true + }) + assert.equal(url, '/search/?type=Plugin&support=Community&featured=true') + }) + + it('omits default sort, limit and page', () => { + const url = buildSearchUrl({ ...base, sort: 'asset_rating', limit: 24, page: 2 }) + assert.equal(url, '/search/?sort=asset_rating&limit=24&page=2') + }) +}) + +describe('buildSearchViewModel', () => { + it('labels the asset_rating sort honestly as Highest rated', () => { + const parsed = makeParsed({ sort: 'asset_rating' }) + const model = buildSearchViewModel(parsed, 100, emptyFacets()) + assert.equal(model.currentSortLabel, 'Highest rated') + const option = model.sortOptions.find(option => option.value === 'asset_rating') + assert.equal(option?.label, 'Highest rated') + assert.equal(option?.selected, true) + }) + + it('marks facet options checked from the selected canonical values', () => { + const parsed = makeParsed({ + categories: ['2d tools'], + types: ['Plugin'], + supports: ['Community'] + }) + const facets: SearchFacets = { + categories: [{ value: '2d tools', label: '2D Tools', count: 10 }], + engines: [], + types: [{ value: 'Plugin', label: 'Plugin', count: 4 }], + supports: [{ value: 'Community', label: 'Community', count: 7 }] + } + const model = buildSearchViewModel(parsed, 100, facets) + assert.equal(model.categories[0]?.checked, true) + assert.equal(model.types[0]?.checked, true) + assert.equal(model.supports[0]?.checked, true) + }) + + it('counts type, support and featured toward active filters', () => { + const parsed = makeParsed({ types: ['Plugin'], supports: ['Community'], featured: true }) + const model = buildSearchViewModel(parsed, 100, emptyFacets()) + assert.equal(model.activeFilterCount, 3) + assert.equal(model.hasFilters, true) + }) + + it('clamps the displayed page to the last valid page', () => { + const parsed = makeParsed({ page: 50, limit: 12 }) + const model = buildSearchViewModel(parsed, 25, emptyFacets()) + // total=25, limit=12 => 3 pages; page 50 clamps to the last (index 2). + assert.equal(model.pagination.currentPage, 2) + assert.equal(model.pagination.displayPage, 3) + assert.equal(model.pagination.totalPages, 3) + }) + + it('renders an empty range when there are no results', () => { + const parsed = makeParsed({}) + const model = buildSearchViewModel(parsed, 0, emptyFacets()) + assert.equal(model.pagination.rangeStart, 0) + assert.equal(model.pagination.rangeEnd, 0) + }) +}) diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 00000000..f5a03908 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist-test", + "rootDir": ".", + "sourceMap": false + }, + "include": [ + "src/**/*.ts", + "tests/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + "dist-test" + ] +} From aefee52c8253a116fb8f07ef2fae0c8b3474f2ee Mon Sep 17 00:00:00 2001 From: Chris Bolton Date: Mon, 10 Aug 2026 20:49:50 -0400 Subject: [PATCH 02/14] Add shared utilities for asset URLs, ratings, and error handling --- src/core/utils/assetUrl.ts | 30 +++++++++++++++++++++++++ src/core/utils/cardView.ts | 14 ++++++++++++ src/core/utils/escapeHtml.ts | 12 ++++++++++ src/core/utils/httpError.ts | 12 ++++++++++ src/core/utils/rateLimitHandler.ts | 22 +++++++++++++++++++ src/core/utils/ratingScore.ts | 21 ++++++++++++++++++ src/core/utils/ratingSummary.ts | 35 ++++++++++++++++++++++++++++++ src/core/utils/safeUrl.ts | 19 ++++++++++++++++ 8 files changed, 165 insertions(+) create mode 100644 src/core/utils/assetUrl.ts create mode 100644 src/core/utils/cardView.ts create mode 100644 src/core/utils/escapeHtml.ts create mode 100644 src/core/utils/httpError.ts create mode 100644 src/core/utils/rateLimitHandler.ts create mode 100644 src/core/utils/ratingScore.ts create mode 100644 src/core/utils/ratingSummary.ts create mode 100644 src/core/utils/safeUrl.ts diff --git a/src/core/utils/assetUrl.ts b/src/core/utils/assetUrl.ts new file mode 100644 index 00000000..524b54b7 --- /dev/null +++ b/src/core/utils/assetUrl.ts @@ -0,0 +1,30 @@ +/** Slugify a title for the pretty asset URL path (alphanumeric + hyphens). */ +function slugify (title: string): string { + return String(title ?? '') + .replace(/\s+/g, '-') + .replace(/[^a-zA-Z0-9-]/g, '') + .toLocaleLowerCase() + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') +} + +/** Canonical asset page URL, e.g. /asset/abc123/my-cool-shader. */ +export function buildAssetUrl (assetId: string, title: string): string { + const slug = slugify(title) + return `/asset/${assetId}${slug !== '' ? `/${slug}` : ''}` +} + +/** + * Asset page URL that remembers the discovery context so "Back to results" + * restores filters, sorting, page and scroll position (via the card anchor). + */ +export function buildAssetUrlWithReturn (assetId: string, title: string, sourceUrl?: string): string { + const url = buildAssetUrl(assetId, title) + if (sourceUrl === undefined || sourceUrl === '') return url + return `${url}?from=${encodeURIComponent(sourceUrl)}` +} + +/** Stable anchor used on each card for scroll restoration on return. */ +export function buildCardAnchor (assetId: string): string { + return `asset-${assetId}` +} diff --git a/src/core/utils/cardView.ts b/src/core/utils/cardView.ts new file mode 100644 index 00000000..da21ee93 --- /dev/null +++ b/src/core/utils/cardView.ts @@ -0,0 +1,14 @@ +import { isSafeHttpUrl } from './safeUrl' + +/** + * Enrich grid-card asset objects before render: + * - download_url: blanked unless it is a safe http(s) URL (mirrors the asset page) + * + * Call this on every asset array that will be rendered through + * `asset-card.eta` so the template never sees untrusted URLs. + */ +export function attachCardExtras (assets: Array>): void { + for (const asset of assets) { + if (!isSafeHttpUrl(asset.download_url)) asset.download_url = '' + } +} diff --git a/src/core/utils/escapeHtml.ts b/src/core/utils/escapeHtml.ts new file mode 100644 index 00000000..6dc51937 --- /dev/null +++ b/src/core/utils/escapeHtml.ts @@ -0,0 +1,12 @@ +/** + * Escape a string for safe interpolation into HTML. Imported asset/user data + * must never reach unescaped Eta output (`<%~ %>`). + */ +export function escapeHtml (value: unknown): string { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} diff --git a/src/core/utils/httpError.ts b/src/core/utils/httpError.ts new file mode 100644 index 00000000..a839b4e4 --- /dev/null +++ b/src/core/utils/httpError.ts @@ -0,0 +1,12 @@ +/** + * Client-facing error with an explicit HTTP status. The global error handler + * in RouterServer honors `statusCode`; unmarked errors become 500. + */ +export class BadRequestError extends Error { + public readonly statusCode = 400 + + constructor (message: string) { + super(message) + this.name = 'BadRequestError' + } +} diff --git a/src/core/utils/rateLimitHandler.ts b/src/core/utils/rateLimitHandler.ts new file mode 100644 index 00000000..120334d8 --- /dev/null +++ b/src/core/utils/rateLimitHandler.ts @@ -0,0 +1,22 @@ +import { Request, Response } from 'express' +import { StatusCodes } from 'http-status-codes' + +/** + * express-rate-limit handler that renders a navigable 429 page for normal + * browsing requests and keeps structured JSON for API/mutation requests. + */ +export function rateLimitHandler (message: string) { + return (req: Request, res: Response): void => { + const wantsHtml = req.accepts(['html', 'json']) === 'html' && !req.path.startsWith('/api/') + if (wantsHtml) { + res.status(StatusCodes.TOO_MANY_REQUESTS).render('templates/pages/lost/server-error', { + pageBanner: { + title: 'Too many requests', + info: 'You\'re doing that too much, please try again later' + } + }) + return + } + res.status(StatusCodes.TOO_MANY_REQUESTS).send({ error: message }) + } +} diff --git a/src/core/utils/ratingScore.ts b/src/core/utils/ratingScore.ts new file mode 100644 index 00000000..428208ae --- /dev/null +++ b/src/core/utils/ratingScore.ts @@ -0,0 +1,21 @@ +/** + * 95% Wilson lower bound for binary approval (up/down votes). + * + * This is a standard confidence-adjusted score: a 100/0 vote count ranks + * sensibly above a 1/0 count, and a 20/0 count ranks above 100/100, matching + * what cards show as approval. Returns 0 when there are no votes so unrated + * assets never float above genuinely popular ones. + */ +export function wilsonScore (upvotes: number, downvotes: number): number { + const n = upvotes + downvotes + if (!Number.isFinite(n) || n <= 0) return 0 + + const z = 1.96 + const zSquared = z * z + const p = upvotes / n + const center = p + (zSquared / (2 * n)) + const margin = z * Math.sqrt((p * (1 - p) + (zSquared / (4 * n))) / n) + const denominator = 1 + (zSquared / n) + + return Math.max(0, (center - margin) / denominator) +} diff --git a/src/core/utils/ratingSummary.ts b/src/core/utils/ratingSummary.ts new file mode 100644 index 00000000..55a6e4dd --- /dev/null +++ b/src/core/utils/ratingSummary.ts @@ -0,0 +1,35 @@ +import { wilsonScore } from './ratingScore' + +export interface RatingSummary { + total: number + upvotes: number + downvotes: number + /** Rounded percentage of positive votes (0-100). */ + approvalPercent: number + /** Confidence-adjusted approval score (95% Wilson lower bound). */ + score: number + hasRatings: boolean + /** Human-readable summary used on cards, lists and detail pages. */ + label: string +} + +/** + * Build the honest binary-approval summary for an asset. + * + * The site collects up/down votes, not one-to-five star ratings. Presenting + * that as "stars out of 5" is misleading, so every surface uses approval + * percentage plus sample size instead. + */ +export function buildRatingSummary (upvotes: number, downvotes: number): RatingSummary { + const up = Number.isFinite(Number(upvotes)) ? Number(upvotes) : 0 + const down = Number.isFinite(Number(downvotes)) ? Number(downvotes) : 0 + const total = up + down + const approvalPercent = total > 0 ? Math.round((up / total) * 100) : 0 + const score = wilsonScore(up, down) + const hasRatings = total > 0 + const label = hasRatings + ? `${approvalPercent}% approval \u00b7 ${total} rating${total === 1 ? '' : 's'}` + : 'No ratings yet' + + return { total, upvotes: up, downvotes: down, approvalPercent, score, hasRatings, label } +} diff --git a/src/core/utils/safeUrl.ts b/src/core/utils/safeUrl.ts new file mode 100644 index 00000000..6df3e0ec --- /dev/null +++ b/src/core/utils/safeUrl.ts @@ -0,0 +1,19 @@ +/** + * True only for http:// or https:// absolute URLs. + * + * Imported asset data is untrusted. This is the single guard used before any + * external URL (download, repository, issues, media, icon) is rendered as an + * href or src so non-HTTP schemes (javascript:, data:, file:, etc.) can never + * reach the browser from imported content. + */ +export function isSafeHttpUrl (value: unknown): boolean { + if (typeof value !== 'string') return false + const trimmed = value.trim() + if (trimmed === '') return false + try { + const url = new URL(trimmed) + return url.protocol === 'http:' || url.protocol === 'https:' + } catch { + return false + } +} From 084e20b833e2910989f45ffcd8377d41a7e7c33a Mon Sep 17 00:00:00 2001 From: Chris Bolton Date: Mon, 10 Aug 2026 20:49:56 -0400 Subject: [PATCH 03/14] Rebuild search with facets, filters, view model, and pagination --- .../search/controllers/SearchController.ts | 5 +- .../models/GET/GetAssetsCountFromQuery.ts | 8 +- .../search/models/GET/GetAssetsFromQuery.ts | 74 +++++-- .../code/search/models/GET/GetSearchFacets.ts | 109 +++++++--- .../search/models/GET/buildSearchFilter.ts | 40 +++- src/app/code/search/models/GET/sortUtils.ts | 11 + src/app/code/search/services/SearchService.ts | 126 ++++++------ .../search/services/buildSearchViewModel.ts | 193 ++++++++++++++++++ .../search/services/parseSearchRequest.ts | 106 ++++++++++ src/app/code/search/views/styles/styles.scss | 102 ++++++++- .../code/search/views/templates/search.eta | 163 ++++++++++----- .../partials/asset-card-grid/results.eta | 7 + .../partials/catalog-grid/asset-grd-schema.ts | 6 + .../partials/catalog-grid/catalog-grid.eta | 189 +++++++++++++---- .../partials/catalog-grid/styles.scss | 103 ++++++++++ 15 files changed, 1027 insertions(+), 215 deletions(-) create mode 100644 src/app/code/search/models/GET/sortUtils.ts create mode 100644 src/app/code/search/services/buildSearchViewModel.ts create mode 100644 src/app/code/search/services/parseSearchRequest.ts create mode 100644 src/app/components/partials/asset-card-grid/results.eta diff --git a/src/app/code/search/controllers/SearchController.ts b/src/app/code/search/controllers/SearchController.ts index e6ff517d..6f8427ee 100644 --- a/src/app/code/search/controllers/SearchController.ts +++ b/src/app/code/search/controllers/SearchController.ts @@ -1,18 +1,19 @@ import { Controller, Get, Middleware, Post } from '@overnightjs/core' import { Request, Response } from 'express' import rateLimit from 'express-rate-limit' +import { rateLimitHandler } from 'core/utils/rateLimitHandler' import { SearchService } from '../services/SearchService' const searchRedirectRateLimit = rateLimit({ windowMs: 1000 * 60 * 15, // 15 minutes max: 50, // start blocking after x requests - message: JSON.stringify({ error: 'You\'re doing that too much, please try again later' }) + handler: rateLimitHandler('You\'re doing that too much, please try again later') }) const searchRateLimit = rateLimit({ windowMs: 1000 * 60 * 15, // 15 minutes max: 50, // start blocking after x requests - message: JSON.stringify({ error: 'You\'re doing that too much, please try again later' }) + handler: rateLimitHandler('You\'re doing that too much, please try again later') }) @Controller('search') diff --git a/src/app/code/search/models/GET/GetAssetsCountFromQuery.ts b/src/app/code/search/models/GET/GetAssetsCountFromQuery.ts index dc63d4d9..b0748ce1 100644 --- a/src/app/code/search/models/GET/GetAssetsCountFromQuery.ts +++ b/src/app/code/search/models/GET/GetAssetsCountFromQuery.ts @@ -1,8 +1,8 @@ import { MongoHelper } from 'core/MongoHelper' -import { buildSearchFilter } from './buildSearchFilter' +import { buildSearchFilter, SearchFilterOptions } from './buildSearchFilter' -export async function GetAssetsCountFromQuery (query: string, categoryFilters: any[], engineFilters: any[]): Promise { +export async function GetAssetsCountFromQuery (query: string, options: SearchFilterOptions = {}): Promise { const mongo = MongoHelper.getDatabase() - const filter = buildSearchFilter(query, categoryFilters, engineFilters) - return await mongo.collection('assets').countDocuments(filter) + const filter = buildSearchFilter(query, options) + return await mongo.collection('assets').countDocuments(filter, { maxTimeMS: 5000 }) } diff --git a/src/app/code/search/models/GET/GetAssetsFromQuery.ts b/src/app/code/search/models/GET/GetAssetsFromQuery.ts index 5dc00c2e..ac623912 100644 --- a/src/app/code/search/models/GET/GetAssetsFromQuery.ts +++ b/src/app/code/search/models/GET/GetAssetsFromQuery.ts @@ -1,32 +1,66 @@ import { Document, WithId } from 'mongodb' import { MongoHelper } from 'core/MongoHelper' import { assetGridSchema } from 'app/utilities/fetchFromGodot/schema/assets-grid' -import { buildSearchFilter } from './buildSearchFilter' +import { buildSearchFilter, SearchFilterOptions } from './buildSearchFilter' interface ReturnedAssets extends WithId, assetGridSchema {} -export async function GetAssetsFromQuery (query: string, limit: number = 12, skip: number, sort: any = {}, categoryFilters: any[], engineFilters: any[]): Promise { +const SEARCH_FIELDS_PROJECTION: Record = { + 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, + download_url: 1 +} + +/** + * Deterministic ordering for each sort key. "asset_rating" uses the persisted + * confidence-adjusted rating_score (Wilson lower bound) so the ranking matches + * the approval summary shown on cards. "last_modified" uses the normalized + * modify_date_at date; legacy documents are backfilled by migration. + */ +function mongoSortFor (sortKey: string): Record { + if (sortKey === 'relevance') return { score: { $meta: 'textScore' }, asset_id: 1 } + if (sortKey === 'asset_rating') return { rating_score: -1, upvotes: -1, downvotes: -1, asset_id: 1 } + if (sortKey === 'newest') return { added_date: -1, asset_id: 1 } + return { modify_date_at: -1, asset_id: 1 } +} + +export async function GetAssetsFromQuery ( + query: string, + limit: number, + skip: number, + sortKey: string, + options: SearchFilterOptions = {} +): Promise { const mongo = MongoHelper.getDatabase() - const filter = buildSearchFilter(query, categoryFilters, engineFilters) + const filter = buildSearchFilter(query, options) + + const projection: Record = { ...SEARCH_FIELDS_PROJECTION } + if (sortKey === 'relevance' && query !== '') { + projection.score = { $meta: 'textScore' } + } const operationObject = await mongo.collection('assets').find(filter, { - limit: limit, - sort: sort, - projection: { - category: 1, - godot_version: 1, - author: 1, - title: 1, - quick_description: 1, - icon_url: 1, - upvotes: 1, - downvotes: 1, - featured: 1, - asset_id: 1, - previews: 1, - card_banner: 1, - modify_date: 1 - } + limit, + sort: mongoSortFor(sortKey), + projection, + maxTimeMS: 5000 }).skip(skip).toArray() as ReturnedAssets[] if (operationObject === null || operationObject === undefined) { diff --git a/src/app/code/search/models/GET/GetSearchFacets.ts b/src/app/code/search/models/GET/GetSearchFacets.ts index daa0a28f..3d2bc339 100644 --- a/src/app/code/search/models/GET/GetSearchFacets.ts +++ b/src/app/code/search/models/GET/GetSearchFacets.ts @@ -1,45 +1,92 @@ import { MongoHelper } from 'core/MongoHelper' +import { buildSearchFilter, SearchFilterOptions } from './buildSearchFilter' -interface SearchFacets { - categoryFilters: Record - engineFilters: Record +export interface FacetGroup { + /** Canonical key used in URLs, checkbox values, chips and Mongo matching. */ + value: string + /** Human-readable label for display (e.g. "2D Tools"). */ + label: string + count: number } -export async function GetSearchFacets (filter: Record): Promise { +export interface SearchFacets { + categories: FacetGroup[] + engines: FacetGroup[] + types: FacetGroup[] + supports: FacetGroup[] +} + +interface FacetSpec { + key: keyof SearchFacets + groupBy: string + labelBy?: Record + /** Dimension to omit from this facet's own filter so counts stay self-excluding. */ + omit: 'categories' | 'engines' | 'types' | 'supports' +} + +/** + * Disjunctive (self-excluding) facets. + * + * Each facet runs its own aggregation that begins with the applicable $match, + * which keeps a $text $match legal as the first stage on MongoDB 5. Omitting + * only the facet's own dimension means a selected category does not collapse + * the category facet, so users can still add alternate values. + */ +export async function GetSearchFacets ( + query: string, + options: SearchFilterOptions = {} +): Promise { const mongo = MongoHelper.getDatabase() + const assets = mongo.collection('assets') - const [result] = await mongo.collection('assets').aggregate([ - { $match: filter }, + const specs: FacetSpec[] = [ { - $facet: { - categories: [{ $group: { _id: '$category', count: { $sum: 1 } } }], - engines: [{ $group: { _id: '$godot_version', count: { $sum: 1 } } }] - } + key: 'categories', + groupBy: '$category_lowercase', + labelBy: { $first: '$category' }, + omit: 'categories' + }, + { key: 'engines', groupBy: '$godot_version', omit: 'engines' }, + { key: 'types', groupBy: '$type', omit: 'types' }, + { key: 'supports', groupBy: '$support_level', omit: 'supports' } + ] + + const results = await Promise.all(specs.map(async spec => { + // Omit only the facet's own dimension so counts stay self-excluding. + const facetOptions: SearchFilterOptions = { + categories: spec.omit === 'categories' ? undefined : options.categories, + engines: spec.omit === 'engines' ? undefined : options.engines, + types: spec.omit === 'types' ? undefined : options.types, + supports: spec.omit === 'supports' ? undefined : options.supports, + featured: options.featured } - ]).toArray() + const filter = buildSearchFilter(query, facetOptions) - const categoryFilters: Record = {} - let engineFilters: Record = {} + const group: Record = { _id: spec.groupBy, count: { $sum: 1 } } + if (spec.labelBy !== undefined) group.label = spec.labelBy - for (const item of result?.categories ?? []) { - if (item._id != null) { - categoryFilters[item._id as string] = item.count as number - } - } + const pipeline: any[] = [ + { $match: filter }, + { $group: group } + ] - for (const item of result?.engines ?? []) { - if (item._id != null) { - engineFilters[item._id as string] = item.count as number - } - } + return await assets.aggregate(pipeline).maxTimeMS(5000).toArray() + })) - engineFilters = Object.keys(engineFilters).sort().reverse().reduce>( - (obj, key) => { - obj[key] = engineFilters[key] - return obj - }, - {} - ) + const output: SearchFacets = { categories: [], engines: [], types: [], supports: [] } + + specs.forEach((spec, index) => { + for (const item of results[index]) { + if (item._id == null) continue + const value = item._id as string + if (value === '') continue + output[spec.key].push({ + value, + label: (item.label as string | undefined) ?? value, + count: item.count as number + }) + } + }) - return { categoryFilters, engineFilters } + return output } diff --git a/src/app/code/search/models/GET/buildSearchFilter.ts b/src/app/code/search/models/GET/buildSearchFilter.ts index 33a47f12..fc12482f 100644 --- a/src/app/code/search/models/GET/buildSearchFilter.ts +++ b/src/app/code/search/models/GET/buildSearchFilter.ts @@ -1,16 +1,38 @@ -export function buildSearchFilter ( - query: string, - categoryArray: string[], - engineArray: string[] -): Record { +export interface SearchFilterOptions { + categories?: string[] + engines?: string[] + types?: string[] + supports?: string[] + featured?: boolean +} + +/** + * Build the MongoDB filter for discovery. All filter values use canonical + * lowercase keys (category_lowercase) so query parameters, facet values, + * chips, and stored data always agree. + */ +export function buildSearchFilter (query: string, options: SearchFilterOptions = {}): Record { + const { categories, engines, types, supports, featured } = options const filter: Record = {} - if (categoryArray.length > 0) { - filter.category_lowercase = { $in: categoryArray } + if (categories !== undefined && categories.length > 0) { + filter.category_lowercase = { $in: categories } + } + + if (engines !== undefined && engines.length > 0) { + filter.godot_version = { $in: engines } + } + + if (types !== undefined && types.length > 0) { + filter.type = { $in: types } + } + + if (supports !== undefined && supports.length > 0) { + filter.support_level = { $in: supports } } - if (engineArray.length > 0) { - filter.godot_version = { $in: engineArray } + if (featured === true) { + filter.featured = true } if (query !== '') { diff --git a/src/app/code/search/models/GET/sortUtils.ts b/src/app/code/search/models/GET/sortUtils.ts new file mode 100644 index 00000000..d7fcad3f --- /dev/null +++ b/src/app/code/search/models/GET/sortUtils.ts @@ -0,0 +1,11 @@ +/** Numeric-aware comparison for Godot version strings (e.g. "4.10" > "4.9"). */ +export function compareVersions (a: string, b: string): number { + const pa = a.split('.').map(Number) + const pb = b.split('.').map(Number) + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const na = pa[i] ?? 0 + const nb = pb[i] ?? 0 + if (na !== nb) return na - nb + } + return 0 +} diff --git a/src/app/code/search/services/SearchService.ts b/src/app/code/search/services/SearchService.ts index 63f4cb9f..26118f5b 100644 --- a/src/app/code/search/services/SearchService.ts +++ b/src/app/code/search/services/SearchService.ts @@ -5,81 +5,85 @@ import striptags from 'striptags' import { GetAssetsCountFromQuery } from '../models/GET/GetAssetsCountFromQuery' import { GetAssetsFromQuery } from '../models/GET/GetAssetsFromQuery' import { GetSearchFacets } from '../models/GET/GetSearchFacets' -import { buildSearchFilter } from '../models/GET/buildSearchFilter' -import { parsePagination } from 'core/utils/pagination' +import { GetRelatedAssets } from 'app/code/asset/models/GET/GetRelatedAssets' +import { SearchFilterOptions } from '../models/GET/buildSearchFilter' +import { parseSearchRequest } from './parseSearchRequest' +import { buildSearchUrl, buildSearchViewModel } from './buildSearchViewModel' +import { escapeHtml } from 'core/utils/escapeHtml' +import { attachCardExtras } from 'core/utils/cardView' export class SearchService { public async render (req: Request, res: Response): Promise { - const query = striptags(String(req.query.q ?? '').substr(0, 100)) - let categoryParams = striptags(String(req.query.category ?? '')) - let engineParams = striptags(String(req.query.engine ?? '')) - const { limit, skip } = parsePagination(req.query.limit, req.query.page) + const parsed = parseSearchRequest(req) const authToken = striptags(req.cookies['auth-token'] ?? '') - // || so an empty string also falls back to the default - const requestedSort = striptags(String(req.query.sort ?? '')) - const sort = requestedSort === '' ? 'relevance' : requestedSort - let title = `Search results ${query === '' ? '' : 'for: ' + query}` - const plusToSpaceRegex = /\+|+|%2b/ - let inCategory = false - if (req?.params?.category != null) { - const convertedCategory = striptags(req.params.category.toLocaleLowerCase().replace(plusToSpaceRegex, ' ')) - categoryParams = convertedCategory - title = `Assets in category: ${convertedCategory}` - inCategory = true + const filterOptions: SearchFilterOptions = { + categories: parsed.categories, + engines: parsed.engines, + types: parsed.types, + supports: parsed.supports, + featured: parsed.featured } - if (req?.params?.engine != null) { - const convertedEngine = striptags(req.params.engine.toLocaleLowerCase().replace(plusToSpaceRegex, ' ')) - engineParams = convertedEngine - title = `Assets for engine: ${convertedEngine}` - inCategory = true + let title = parsed.query === '' ? 'Browse Godot assets' : `Search results for: ${parsed.query}` + if (parsed.routeCategory !== undefined) { + title = `Assets in category: ${parsed.routeCategory}` + } else if (parsed.routeEngine !== undefined) { + title = `Assets for engine: ${parsed.routeEngine}` } - const sortMap: {[key: string]: any} = { - relevance: { godot_version: -1 }, - asset_rating: { upvotes: -1 }, - newest: { added_date: -1 }, - last_modified: { modify_date: -1 } - } - - // unknown values fall back to relevance rather than returning a 400 - const sortOrder = sortMap[sort] ?? sortMap.relevance + const [assets, totalAssetsForQuery, facets] = await Promise.all([ + GetAssetsFromQuery(parsed.query, parsed.limit, parsed.skip, parsed.sort, filterOptions), + GetAssetsCountFromQuery(parsed.query, filterOptions), + GetSearchFacets(parsed.query, filterOptions) + ]) - let categoryArray: any[] = [] - let engineArray: any[] = [] + attachCardExtras(assets) - if (typeof categoryParams === 'string') { - if (categoryParams === '') { - categoryArray = [] - } else { - categoryArray = categoryParams.split(',') + // Single-result searches surface a "You may also like" row so visitors + // can keep exploring without going back. + let relatedForSearch: any[] = [] + if (parsed.query !== '' && assets.length === 1) { + try { + relatedForSearch = await GetRelatedAssets( + assets[0].category, + assets[0].godot_version, + assets[0].type, + assets[0].asset_id + ) + attachCardExtras(relatedForSearch) + } catch (e) { + // ignore } - } else { - categoryArray = categoryParams as any[] } - if (typeof engineParams === 'string') { - if (engineParams === '') { - engineArray = [] - } else { - engineArray = engineParams.split(',') - } - } else { - engineArray = engineParams as any[] + // A page that points past the end should redirect to the real last page + // rather than rendering an empty grid with contradictory pagination. + if (parsed.page > 0 && assets.length === 0 && totalAssetsForQuery > 0) { + const lastPage = Math.ceil(totalAssetsForQuery / parsed.limit) - 1 + const redirectUrl = buildSearchUrl({ + query: parsed.query, + categories: parsed.categories, + engines: parsed.engines, + types: parsed.types, + supports: parsed.supports, + featured: parsed.featured, + sort: parsed.sort, + limit: parsed.limit, + page: lastPage + }) + return res.redirect(redirectUrl) } - const filter = buildSearchFilter(query, categoryArray, engineArray) - - const [assets, totalAssetsForQuery, { categoryFilters, engineFilters }] = await Promise.all([ - GetAssetsFromQuery(query, limit, skip, sortOrder, categoryArray, engineArray), - GetAssetsCountFromQuery(query, categoryArray, engineArray), - GetSearchFacets(filter) - ]) + const search = buildSearchViewModel(parsed, totalAssetsForQuery, facets) - let info = `Found ${totalAssetsForQuery} assets for query` - if (inCategory) { - info = `Showing ${totalAssetsForQuery} assets in category` + let info = parsed.query !== '' + ? `Found ${totalAssetsForQuery} assets matching “${escapeHtml(parsed.query)}”` + : `Browsing ${totalAssetsForQuery} assets` + if (parsed.routeCategory !== undefined) { + info = `Showing ${totalAssetsForQuery} assets in this category` + } else if (parsed.routeEngine !== undefined) { + info = `Showing ${totalAssetsForQuery} assets for this engine` } const pageBanner = { @@ -103,11 +107,13 @@ export class SearchService { } return res.render('templates/pages/search/search', { - filters: { category: categoryFilters, engine: engineFilters }, + filters: facets, + search: search, grid: assets, params: req.originalUrl, pageBanner: pageBanner, - originalQuery: query + originalQuery: parsed.query, + relatedForSearch: relatedForSearch }) } diff --git a/src/app/code/search/services/buildSearchViewModel.ts b/src/app/code/search/services/buildSearchViewModel.ts new file mode 100644 index 00000000..49386696 --- /dev/null +++ b/src/app/code/search/services/buildSearchViewModel.ts @@ -0,0 +1,193 @@ +import { ParsedSearchRequest } from './parseSearchRequest' +import { compareVersions } from '../models/GET/sortUtils' +import { FacetGroup, SearchFacets } from '../models/GET/GetSearchFacets' + +export interface SortOption { value: string, label: string, url: string, selected: boolean } +export interface LimitOption { value: string, label: string, url: string, selected: boolean } +export interface FacetOption { value: string, label: string, count: number, checked: boolean } +export interface PageItem { label: string, url: string, current: boolean } + +export interface SearchViewModel { + query: string + currentSort: string + currentSortLabel: string + currentLimit: number + displayPage: number + sortOptions: SortOption[] + limitOptions: LimitOption[] + categories: FacetOption[] + engines: FacetOption[] + types: FacetOption[] + supports: FacetOption[] + featured: boolean + clearAllUrl: string + categoryClearUrl: string + engineClearUrl: string + typeClearUrl: string + supportClearUrl: string + hasFilters: boolean + activeFilterCount: number + pagination: { + total: number + limit: number + currentPage: number + displayPage: number + totalPages: number + hasPrev: boolean + hasNext: boolean + prevUrl: string + nextUrl: string + rangeStart: number + rangeEnd: number + pages: PageItem[] + } +} + +interface UrlStateInternal { + query: string + categories: string[] + engines: string[] + types: string[] + supports: string[] + featured: boolean + sort: string + limit: number + page: number +} + +export type UrlState = UrlStateInternal + +const SORT_LABELS: Record = { + relevance: 'Relevance', + asset_rating: 'Highest rated', + newest: 'Newest', + last_modified: 'Recently updated' +} + +const DEFAULT_LIMIT = 12 +const LIMIT_OPTIONS = [12, 24, 36] +const PAGE_WINDOW = 5 + +/** Build a canonical search URL from state, omitting defaults. */ +export function buildSearchUrl (state: UrlState): string { + const params = new URLSearchParams() + if (state.query !== '') params.set('q', state.query) + for (const category of state.categories) params.append('category', category) + for (const engine of state.engines) params.append('engine', engine) + for (const type of state.types) params.append('type', type) + for (const support of state.supports) params.append('support', support) + if (state.featured) params.set('featured', 'true') + if (state.sort !== 'relevance') params.set('sort', state.sort) + if (state.limit !== DEFAULT_LIMIT) params.set('limit', String(state.limit)) + if (state.page !== 0) params.set('page', String(state.page)) + const queryString = params.toString() + return `/search/${queryString !== '' ? `?${queryString}` : ''}` +} + +function toFacetOptions (groups: FacetGroup[], selected: string[]): FacetOption[] { + return groups.map(group => ({ + value: group.value, + label: group.label, + count: group.count, + checked: selected.includes(group.value) + })) +} + +export function buildSearchViewModel ( + parsed: ParsedSearchRequest, + total: number, + facets: SearchFacets +): SearchViewModel { + const { query, categories, engines, types, supports, featured, sort, limit, page } = parsed + const totalPages = Math.max(1, Math.ceil(total / limit)) + const currentPage = Math.min(page, totalPages - 1) + const displayPage = currentPage + 1 + const rangeStart = total === 0 ? 0 : (currentPage * limit) + 1 + const rangeEnd = Math.min(total, (currentPage + 1) * limit) + + const base: UrlState = { query, categories, engines, types, supports, featured, sort, limit, page: currentPage } + + // "Relevance" only means something with a text query, so it is hidden (not + // just relabelled) when browsing with an empty query. + const sortKeys = Object.keys(SORT_LABELS).filter(value => query !== '' || value !== 'relevance') + const sortOptions: SortOption[] = sortKeys.map(value => ({ + value, + label: SORT_LABELS[value], + url: buildSearchUrl({ ...base, sort: value, page: 0 }), + selected: value === sort + })) + + const limitOptions: LimitOption[] = LIMIT_OPTIONS.map(value => ({ + value: String(value), + label: String(value), + url: buildSearchUrl({ ...base, limit: value, page: 0 }), + selected: value === limit + })) + + const categoriesOptions = toFacetOptions(facets.categories, categories) + const enginesOptions = toFacetOptions(facets.engines, engines).sort((a, b) => compareVersions(a.value, b.value)) + const typesOptions = toFacetOptions(facets.types, types) + const supportsOptions = toFacetOptions(facets.supports, supports) + + // Per-group "clear" links (empty just that dimension, keep everything else). + const categoryClearUrl = buildSearchUrl({ ...base, categories: [], page: 0 }) + const engineClearUrl = buildSearchUrl({ ...base, engines: [], page: 0 }) + const typeClearUrl = buildSearchUrl({ ...base, types: [], page: 0 }) + const supportClearUrl = buildSearchUrl({ ...base, supports: [], page: 0 }) + + const hasFilters = categories.length > 0 || engines.length > 0 || types.length > 0 || + supports.length > 0 || featured + const activeFilterCount = categories.length + engines.length + types.length + + supports.length + (featured ? 1 : 0) + + const pages: PageItem[] = [] + const windowStart = Math.max(0, currentPage - Math.floor(PAGE_WINDOW / 2)) + const windowEnd = Math.min(totalPages - 1, windowStart + PAGE_WINDOW - 1) + for (let p = windowStart; p <= windowEnd; p++) { + pages.push({ label: String(p + 1), url: buildSearchUrl({ ...base, page: p }), current: p === currentPage }) + } + + return { + query, + currentSort: sort, + currentSortLabel: SORT_LABELS[sort] ?? SORT_LABELS.last_modified, + currentLimit: limit, + displayPage, + sortOptions, + limitOptions, + categories: categoriesOptions, + engines: enginesOptions, + types: typesOptions, + supports: supportsOptions, + featured, + clearAllUrl: buildSearchUrl({ + ...base, + categories: [], + engines: [], + types: [], + supports: [], + featured: false, + page: 0 + }), + categoryClearUrl, + engineClearUrl, + typeClearUrl, + supportClearUrl, + hasFilters, + activeFilterCount, + pagination: { + total, + limit, + currentPage, + displayPage, + totalPages, + hasPrev: currentPage > 0, + hasNext: currentPage < totalPages - 1, + prevUrl: currentPage > 0 ? buildSearchUrl({ ...base, page: currentPage - 1 }) : '', + nextUrl: currentPage < totalPages - 1 ? buildSearchUrl({ ...base, page: currentPage + 1 }) : '', + rangeStart, + rangeEnd, + pages + } + } +} diff --git a/src/app/code/search/services/parseSearchRequest.ts b/src/app/code/search/services/parseSearchRequest.ts new file mode 100644 index 00000000..f74219e5 --- /dev/null +++ b/src/app/code/search/services/parseSearchRequest.ts @@ -0,0 +1,106 @@ +import { Request } from 'express' +import striptags from 'striptags' +import { parsePagination } from 'core/utils/pagination' + +const QUERY_MAX_LENGTH = 100 +const MAX_FILTERS = 20 +const VALID_SORTS = ['relevance', 'asset_rating', 'newest', 'last_modified'] + +export interface ParsedSearchRequest { + query: string + categories: string[] + engines: string[] + types: string[] + supports: string[] + featured: boolean + requestedSort: string + /** Resolved sort key, context-aware (never "relevance" for an empty query). */ + sort: string + limit: number + /** Zero-based internal page (matches parsePagination). */ + page: number + skip: number + /** One-based page for display. */ + displayPage: number + routeCategory?: string + routeEngine?: string +} + +/** Accept repeated and comma-separated values, then trim/dedupe/cap. */ +function normalizeList (value: unknown): string[] { + const values = Array.isArray(value) ? value : [value] + const seen = new Set() + const result: string[] = [] + + for (const raw of values) { + for (const part of String(raw ?? '').split(',')) { + const item = striptags(part).trim() + if (item === '') continue + if (seen.has(item)) continue + seen.add(item) + result.push(item) + if (result.length >= MAX_FILTERS) break + } + if (result.length >= MAX_FILTERS) break + } + + return result +} + +/** + * "Relevance" only has meaning when there is a text query. For an empty query + * the truthful default ordering is "Recently Updated" (last_modified). + */ +export function resolveSort (requested: string, query: string): string { + const key = VALID_SORTS.includes(requested) + ? requested + : (query !== '' ? 'relevance' : 'last_modified') + if (key === 'relevance' && query === '') return 'last_modified' + return key +} + +export function parseSearchRequest (req: Request): ParsedSearchRequest { + const query = striptags(String(req.query.q ?? '')).trim().slice(0, QUERY_MAX_LENGTH) + + let categories = normalizeList(req.query.category) + let engines = normalizeList(req.query.engine) + const types = normalizeList(req.query.type) + const supports = normalizeList(req.query.support) + const featured = String(req.query.featured ?? '') === 'true' + + const routeCategory = req?.params?.category != null + ? striptags(String(req.params.category).toLocaleLowerCase().replace(/\+|+|%2b/g, ' ')).trim() + : undefined + const routeEngine = req?.params?.engine != null + ? striptags(String(req.params.engine).toLocaleLowerCase().replace(/\+|+|%2b/g, ' ')).trim() + : undefined + + if (routeCategory !== undefined && routeCategory !== '') categories = [routeCategory] + if (routeEngine !== undefined && routeEngine !== '') engines = [routeEngine] + + // Normalize category values to the lowercase canonical key used by the + // database (`category_lowercase`) so checkbox values, chips, and Mongo + // matching all agree regardless of the display-case facet label. + categories = categories.map(category => category.toLocaleLowerCase()) + + const { limit, page, skip } = parsePagination(req.query.limit, req.query.page) + const requestedSort = striptags(String(req.query.sort ?? '')) + const sort = resolveSort(requestedSort, query) + + return { + query, + categories, + engines, + types, + supports, + featured, + requestedSort, + sort, + limit, + page, + skip, + displayPage: page + 1, + routeCategory, + routeEngine + } +} diff --git a/src/app/code/search/views/styles/styles.scss b/src/app/code/search/views/styles/styles.scss index 13f70101..f752b20a 100644 --- a/src/app/code/search/views/styles/styles.scss +++ b/src/app/code/search/views/styles/styles.scss @@ -13,11 +13,40 @@ column-gap: 55px; margin-top: 25px; + .filters-toggle { + display: none; + } + @include mobile-styles { margin-top: 0; display: flex; flex-direction: column; + .filters-toggle { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + background: $color-brand-white; + border: 1px solid $border-color; + border-radius: $border-radius-md; + padding: 12px 16px; + font-family: "Inter", sans-serif; + font-size: 15px; + font-weight: 600; + color: $text-primary; + cursor: pointer; + margin-top: 15px; + } + + .catalog-filters { + display: none; + + &.open { + display: block; + } + } + .catalog-grid { margin-top: 20px; } @@ -52,11 +81,13 @@ &:not(:last-child) { padding-bottom: 15px; - } - - .filter { + } .filter { border: 1px solid #cacaca; border-radius: 3px; + + &:not(:last-child) { + margin-bottom: 25px; + } } .accordion-trigger { @@ -90,6 +121,19 @@ .options { padding: 0px 13px; + + .clear-section { + display: inline-block; + margin-top: 14px; + margin-bottom: 14px; + font-size: 13px; + color: #2081db; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } } ul { @@ -97,10 +141,10 @@ margin-bottom: 0px; li { - margin-top: 7px; + margin-top: 12px; &:not(:last-child) { - margin-bottom: 7px; + margin-bottom: 12px; } } } @@ -140,5 +184,53 @@ text-decoration: none; } } + + .filter-featured { + display: flex; + align-items: center; + margin-top: 20px; + + input { + margin-right: 7px; + } + } + + .apply-filters { + @include form-button(); + width: 100%; + margin-top: 12px; + border-radius: 4px; + min-height: 44px; + } + } + + .related-for-search { + margin-top: $space-8; + + .section-head h2 { + @include inter-like-font(); + font-weight: 700; + font-size: 18px; + margin: 0 0 $space-3; + } + + .related-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + column-gap: 20px; + row-gap: 20px; + + @include small-desktop-styles { + grid-template-columns: repeat(3, 1fr); + } + + @include tablet-styles { + grid-template-columns: 1fr 1fr; + } + + @include mobile-styles { + grid-template-columns: 1fr; + } + } } } diff --git a/src/app/code/search/views/templates/search.eta b/src/app/code/search/views/templates/search.eta index 324a7315..2485a6e7 100644 --- a/src/app/code/search/views/templates/search.eta +++ b/src/app/code/search/views/templates/search.eta @@ -7,75 +7,144 @@ <% layout('templates/components/layouts/promobar-nav-body-footer/promobar-nav-body-footer.eta') %> diff --git a/src/app/components/partials/asset-card-grid/results.eta b/src/app/components/partials/asset-card-grid/results.eta new file mode 100644 index 00000000..2e766985 --- /dev/null +++ b/src/app/components/partials/asset-card-grid/results.eta @@ -0,0 +1,7 @@ +<% it.grid?.forEach(info => { %> + <% if (it?.type && it?.type === 'reports') { %> + <%~ includeFile('templates/components/partials/review-report/review-report.eta', info) %> + <% } else { %> + <%~ includeFile('templates/components/partials/asset-card/asset-card.eta', { info: info, _locals: it?._locals, sourceUrl: it?.sourceUrl }) %> + <% } %> +<% }) %> diff --git a/src/app/components/partials/catalog-grid/asset-grd-schema.ts b/src/app/components/partials/catalog-grid/asset-grd-schema.ts index b728979c..06f94896 100644 --- a/src/app/components/partials/catalog-grid/asset-grd-schema.ts +++ b/src/app/components/partials/catalog-grid/asset-grd-schema.ts @@ -11,4 +11,10 @@ export interface assetGridSchema { asset_id: string previews: any[] modify_date: Date + added_date?: Date + version_string?: string + type?: string + support_level?: string + /** Optional human-readable context line such as "Updated 3 days ago". */ + context?: string } diff --git a/src/app/components/partials/catalog-grid/catalog-grid.eta b/src/app/components/partials/catalog-grid/catalog-grid.eta index c87ef5be..bc478d70 100644 --- a/src/app/components/partials/catalog-grid/catalog-grid.eta +++ b/src/app/components/partials/catalog-grid/catalog-grid.eta @@ -1,13 +1,121 @@
+ <% if (it?.search != null) { %> +
+
+ +
+
+ +
+ <% if (it.search.pagination.hasPrev) { %> + + <% } else { %> + Back + <% } %> + <% if (it.search.pagination.hasNext) { %> + + <% } else { %> + Next + <% } %> +
+
+
+ <% if (it.search.pagination.total > 0) { %> +
+ Showing <%= it.search.pagination.rangeStart %>–<%= it.search.pagination.rangeEnd %> of + <%= it.search.pagination.total %> assets + <% if (it.search.pagination.totalPages > 1) { %> + · page <%= it.search.pagination.displayPage %> of <%= it.search.pagination.totalPages %> + <% } %> +
+ <% } %> +
+ <%~ includeFile('templates/components/partials/asset-card-grid/results.eta', { grid: it.grid, type: it?.type, _locals: it?._locals, sourceUrl: it?.params }) %> +
+ <% if (it.grid?.length > 0) { %> +
+
+
+ <% for (const page of it.search.pagination.pages) { %> + <%= page.label %> + <% } %> +
+
+
+
+ <% if (it.search.pagination.hasPrev) { %> + + <% } else { %> + Back + <% } %> + <% if (it.search.pagination.hasNext) { %> + + <% } else { %> + Next + <% } %> +
+
+
+ <% } else { %> +
+ +

No assets found

+

No assets found for your search.

+
+ <% if (it.search?.query) { %> + Try fewer or different keywords + <% } %> + Browse categories + <% if (it.search?.hasFilters) { %> + Clear all filters + <% } %> + Browse recently updated assets +
+
+ <% } %> + <% } else { %>