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
1 change: 1 addition & 0 deletions .abcd/work/DECISIONS.md

Large diffs are not rendered by default.

17 changes: 10 additions & 7 deletions src/components/CardGrid/CardGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useMechanicContext, useMechanicCardActions } from "@/mechanics";
import { createFieldSortComparator, resolveFieldPath } from "@/utils/fieldPathResolver";
import { shuffle } from "@/utils/shuffle";
import { capFilterOptions } from "@/utils/filterOptions";
import { cardMatchesFilter } from "@/utils/filterMatch";
import { LoadingSkeleton } from "@/components/LoadingSkeleton";
import { springPresets, getItemDelay } from "@/config/animationPresets";
import type { CardDisplayConfig } from "@/types/display";
Expand Down Expand Up @@ -281,12 +282,12 @@ export function CardGrid() {
// Apply active filters
for (const filter of activeFilters) {
if (filter.values.length === 0) continue;
result = result.filter((card) => {
const value = resolveFieldPath(card as unknown as Record<string, unknown>, filter.field);
if (value === null || value === undefined) return false;
const strValue = typeof value === "object" ? JSON.stringify(value) : String(value as string | number | boolean);
return filter.values.includes(strValue);
});
result = result.filter((card) =>
cardMatchesFilter(
card as unknown as Record<string, unknown>,
filter
)
);
}

return result;
Expand Down Expand Up @@ -605,7 +606,9 @@ export function CardGrid() {
const genres = new Set<string>();

for (const card of sourceCards) {
const platformValue = resolveFieldPath(card as unknown as Record<string, unknown>, "platform.shortTitle");
// Collect from the same field the Platform filter matches on
// (FILTER_FIELD_DEFS), so offered options cannot fail to match.
const platformValue = resolveFieldPath(card as unknown as Record<string, unknown>, "categoryShort");
if (platformValue && typeof platformValue === "string") platforms.add(platformValue);

const yearValue = resolveFieldPath(card as unknown as Record<string, unknown>, "year");
Expand Down
30 changes: 17 additions & 13 deletions src/components/SearchBar/FilterChips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import { useState, useCallback, useRef, useEffect } from "react";
import { useSettingsStore } from "@/stores/settingsStore";
import { FILTER_FIELD_DEFS } from "@/utils/filterMatch";
import styles from "./FilterChips.module.css";

interface FilterChipsProps {
Expand Down Expand Up @@ -80,12 +81,10 @@ function FilterChip({
values: string[];
onRemove: () => void;
}) {
// Map field names to display labels
const fieldLabels: Record<string, string> = {
categoryTitle: "Platform",
year: "Year",
"genres[0]": "Genre",
};
// Map field names to display labels (single source of truth: FILTER_FIELD_DEFS)
const fieldLabels: Record<string, string> = Object.fromEntries(
FILTER_FIELD_DEFS.map((d) => [d.field, d.label])
);

const label = fieldLabels[field] ?? field;
const valueText = values.length > 1 ? `${String(values.length)} selected` : values[0];
Expand Down Expand Up @@ -165,11 +164,16 @@ function AddFilterDropdown({

if (!filterOptions) return null;

const filterFields = [
{ field: "categoryTitle", label: "Platform", options: filterOptions.platforms },
{ field: "year", label: "Year", options: filterOptions.years.map(String) },
{ field: "genres[0]", label: "Genre", options: filterOptions.genres },
].filter((f) => f.options.length > 0);
const optionsByKey = {
platforms: filterOptions.platforms,
years: filterOptions.years.map(String),
genres: filterOptions.genres,
};
const filterFields = FILTER_FIELD_DEFS.map((d) => ({
field: d.field,
label: d.label,
options: optionsByKey[d.optionsKey],
})).filter((f) => f.options.length > 0);

if (filterFields.length === 0) return null;

Expand Down Expand Up @@ -198,7 +202,7 @@ function AddFilterDropdown({
// Field selection
<div className={styles.dropdownSection}>
<div className={styles.dropdownHeader}>Filter by</div>
{filterFields.map(({ field, label }) => (
{filterFields.map(({ field, label, options }) => (
<button
key={field}
type="button"
Expand All @@ -207,7 +211,7 @@ function AddFilterDropdown({
>
{label}
<span className={styles.dropdownCount}>
{filterOptions[field === "categoryTitle" ? "platforms" : field === "year" ? "years" : "genres"].length}
{options.length}
</span>
</button>
))}
Expand Down
39 changes: 33 additions & 6 deletions src/hooks/useAvailableFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@ import type { FieldOption } from "@/utils/fieldPathResolver";
const TOP_BADGE_FIELD_NAMES = new Set(["myRank", "myVerdict", "rank", "year"]);
const TOP_BADGE_FIELD_KEYWORDS = ["verdict", "rating", "score"];

/**
* Upper bound on the number of distinct fields discovered for the settings
* dropdowns.
*
* Entity schemas are `.loose()`, so an untrusted collection can carry an
* unbounded number of keys, all of which are copied onto the display card.
* Every discovered field becomes an `<option>` in the settings panel (sort,
* badge and group-by selectors), which renders outside the collection error
* boundary — so an entity with tens of thousands of matching keys freezes or
* OOMs the tab on a single synchronous commit. Cap discovery the way
* MAX_DISPLAYABLE_FIELDS (entityFields.ts) caps the card-detail rows.
*/
const MAX_AVAILABLE_FIELDS = 100;

/**
* Convert camelCase to Title Case.
*/
Expand Down Expand Up @@ -63,13 +77,17 @@ function extractFields(
obj: Record<string, unknown>,
prefix = "",
maxDepth = 2,
currentDepth = 0
currentDepth = 0,
limit = Number.POSITIVE_INFINITY,
fields: string[] = []
): string[] {
if (currentDepth >= maxDepth) return [];

const fields: string[] = [];
if (currentDepth >= maxDepth) return fields;

for (const [key, value] of Object.entries(obj)) {
// Stop once the caller's ceiling is reached so a hostile entity with a huge
// key space cannot make discovery walk (and allocate) without bound.
if (fields.length >= limit) break;

// Skip internal/private fields
if (key.startsWith("_")) continue;

Expand All @@ -79,7 +97,7 @@ function extractFields(
fields.push(fieldPath);
} else if (value !== null && typeof value === "object" && !Array.isArray(value)) {
// Recurse into nested objects
fields.push(...extractFields(value as Record<string, unknown>, fieldPath, maxDepth, currentDepth + 1));
extractFields(value as Record<string, unknown>, fieldPath, maxDepth, currentDepth + 1, limit, fields);
} else if (Array.isArray(value) && value.length > 0) {
// Check first element of arrays
const first: unknown = value[0];
Expand All @@ -106,15 +124,24 @@ export function useAvailableFields() {
const fieldSet = new Set<string>();

for (const card of sampleCards) {
const fields = extractFields(card as Record<string, unknown>);
if (fieldSet.size >= MAX_AVAILABLE_FIELDS) break;
const fields = extractFields(
card as Record<string, unknown>,
"",
2,
0,
MAX_AVAILABLE_FIELDS
);
for (const field of fields) {
fieldSet.add(field);
if (fieldSet.size >= MAX_AVAILABLE_FIELDS) break;
}
}

// Convert to sorted array and create FieldOption objects
const allFields: FieldOption[] = Array.from(fieldSet)
.sort()
.slice(0, MAX_AVAILABLE_FIELDS)
.map((value) => ({
value,
label: generateLabel(value),
Expand Down
23 changes: 21 additions & 2 deletions src/hooks/useCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,18 @@ interface CollectionResult {
*/
const MAX_MEDIA_PER_CARD = 100;

/**
* Upper bound on detail links kept per card.
*
* `card.detailUrls` is assembled from the untrusted, uncapped `detailUrls`
* array plus one link per entry of the uncapped `videos` array. It feeds the
* sources overlay, which renders one anchor (plus several URL parses) per
* entry with no windowing, so a hostile entity carrying a huge `videos`/
* `detailUrls` array would freeze the tab when the card's Sources button is
* opened. Cap it at the same choke point as the media list.
*/
const MAX_DETAIL_LINKS_PER_CARD = 100;

/**
* Coerce an untrusted entity field to a display string.
*
Expand Down Expand Up @@ -453,7 +465,10 @@ async function loadFreshCollection(
urls.push({ url, source: "YouTube" });
}
}
return urls.length > 0 ? urls : undefined;
if (urls.length === 0) {
return undefined;
}
return urls.slice(0, MAX_DETAIL_LINKS_PER_CARD);
})(),
primaryImage,
// Category/platform info for expanded view
Expand Down Expand Up @@ -503,13 +518,17 @@ async function loadFreshCollection(
: typeof platform.title === "number"
? String(platform.title)
: "";
// Cap the untrusted platform detail links at the same choke
// point as the card's own, so the platform overlay (one anchor +
// several URL parses per link) cannot be flooded via
// `platform.detailUrls` instead of the card's `videos`.
let platformDetailUrls = normaliseDetailUrls(
platform.detailUrls as
| string
| { url: string }
| { url: string }[]
| undefined
);
).slice(0, MAX_DETAIL_LINKS_PER_CARD);

// Auto-generate Wikipedia URL if no detailUrls specified
if (platformDetailUrls.length === 0 && platformTitle) {
Expand Down
42 changes: 28 additions & 14 deletions src/loaders/collectionLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,32 @@ function extractEntityIds(data: unknown, pluralType: string): string[] {
* @param entityType - Type of entities to load (singular form, e.g., "advert")
* @returns Array of entities
*/
/**
* Parse a single-file entity array into deduplicated entities.
*
* The index-driven path caps its id list at MAX_ENTITY_IDS, but a collection
* can skip index.json and serve `{type}s.json` as one array. Apply the same
* ceiling here so an untrusted single file cannot exceed the entity count the
* rest of the pipeline (relationship resolution, per-card rendering) is sized
* for.
*/
function parseEntityArray(data: unknown[], urlLabel: string): Entity[] {
let rows = data;
if (rows.length > MAX_ENTITY_IDS) {
console.warn(
`Entity file lists ${String(rows.length)} entities; loading the first ${String(MAX_ENTITY_IDS)}.`
);
rows = rows.slice(0, MAX_ENTITY_IDS);
}
return dedupeEntitiesById(
rows
.map((item, index) =>
parseEntityTolerant(item, `${urlLabel}[${String(index)}]`)
)
.filter((entity): entity is Entity => entity !== null)
);
}

export async function loadEntities(
basePath: string,
entityType: string
Expand Down Expand Up @@ -270,13 +296,7 @@ export async function loadEntities(
const data = (await response.json()) as unknown;

if (Array.isArray(data)) {
return dedupeEntitiesById(
data
.map((item, index) =>
parseEntityTolerant(item, `${pluralFileUrl}[${String(index)}]`)
)
.filter((entity): entity is Entity => entity !== null)
);
return parseEntityArray(data, pluralFileUrl);
}

const single = parseEntityTolerant(data, pluralFileUrl);
Expand All @@ -299,13 +319,7 @@ export async function loadEntities(
const data = (await response.json()) as unknown;

if (Array.isArray(data)) {
return dedupeEntitiesById(
data
.map((item, index) =>
parseEntityTolerant(item, `${singleFileUrl}[${String(index)}]`)
)
.filter((entity): entity is Entity => entity !== null)
);
return parseEntityArray(data, singleFileUrl);
}

// Single entity in file
Expand Down
27 changes: 24 additions & 3 deletions src/loaders/relationshipResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,21 @@ import type {
RelationshipDefinition,
} from "@/types/schema";

/**
* Upper bound on the number of relationship definitions honoured from an
* untrusted collection.json.
*
* The relationship record is `type.field` keyed and unbounded in the schema.
* Grouping keeps the per-entity work proportional to the entries for one type,
* but a record where every key shares the primary type's prefix collapses into
* a single bucket, so both the resolve pass and the per-card rank pass become
* O(relationships x entities) — a synchronous multi-minute main-thread freeze
* on a crafted payload. Cap the record the way entity types and ids are
* already capped (MAX_ENTITY_TYPES, MAX_ENTITY_IDS). Real collections declare
* a handful of relationships, so the ceiling is far above any honest use.
*/
export const MAX_RELATIONSHIPS = 200;

/**
* Context for resolving relationships.
*/
Expand Down Expand Up @@ -65,9 +80,15 @@ export function createResolverContext(
[string, RelationshipDefinition][]
>();

for (const [relKey, relDef] of Object.entries(
definition.relationships ?? {}
)) {
const relationshipEntries = Object.entries(definition.relationships ?? {});
if (relationshipEntries.length > MAX_RELATIONSHIPS) {
console.warn(
`Collection declares ${String(relationshipEntries.length)} relationships; ` +
`only the first ${String(MAX_RELATIONSHIPS)} are resolved.`
);
}

for (const [relKey, relDef] of relationshipEntries.slice(0, MAX_RELATIONSHIPS)) {
const [relType, fieldName] = relKey.split(".");
if (!relType || !fieldName) {
continue;
Expand Down
20 changes: 18 additions & 2 deletions src/loaders/settingsLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ const MAX_VISIBLE_CARDS = 10;
*/
const MAX_SEARCH_FIELDS = 32;

/**
* Upper bound on the length of a forced free-text label such as
* `rankPlaceholderText`.
*
* The value is written into persisted global settings and rendered once per
* card (every unranked card shows the rank placeholder), with no virtualisation
* — so an unbounded string is amplified by the entity count into a huge layout
* pass that freezes or OOMs the tab, and it reproduces on reload before the
* user can reach the settings panel. A short bound covers every real label.
*/
const MAX_LABEL_LENGTH = 120;

/**
* Load collection settings from a collection directory.
*
Expand Down Expand Up @@ -179,9 +191,13 @@ function validateForcedSettings(raw: Record<string, unknown>): ForcedSettings {
forced.showDeviceBadge = raw.showDeviceBadge;
}

// String fields
// String fields. `rankPlaceholderText` is rendered once per unranked card,
// so an unbounded value is amplified by the card count; cap the length.
if (typeof raw.rankPlaceholderText === "string") {
forced.rankPlaceholderText = raw.rankPlaceholderText;
forced.rankPlaceholderText = raw.rankPlaceholderText.slice(
0,
MAX_LABEL_LENGTH
);
}

// Field mapping — validate each value, not just the container. Field paths
Expand Down
Loading
Loading