diff --git a/docs/2026-05-14-landing-security-steamdb-design.md b/docs/2026-05-14-landing-security-steamdb-design.md new file mode 100644 index 0000000..c6e4793 --- /dev/null +++ b/docs/2026-05-14-landing-security-steamdb-design.md @@ -0,0 +1,390 @@ +# Architectural Spec: Landing Page, SteamDB Integration, API Security, Changelog + +**Date:** 2026-05-14 +**Target Version:** 2026.0.101 +**Status:** Draft — pending user review + +--- + +## 1. Objective Overview + +Four independent workstreams bundled into one release: + +| # | Stream | Scope | +|---|--------|-------| +| A | **[Submit] SteamDB Version Auto-Fetch** | Auto-fetch latest game version from SteamDB and surface it in the submit wizard version selector | +| B | **[Feature] Landing Page** | Add trending/hot/new/tested/reported sections below the hero, with a "peek" effect | +| C | **[Infra] API Security & Rate Limiting** | Abuse prevention on submissions, anti-spam for comments, general hardening | +| D | **[CHANGELOG]** | Bump version, write update news, update CHANGELOG.md | + +--- + +## 2. Stream A — SteamDB Version Auto-Fetch + +### 2.1 Current State + +- `gameVersions` table: `id`, `gameId`, `buildId`, `versionString`, `isLatest`, `createdAt` +- Submit wizard (Setup step) shows a ``: + ``` + ⬇ Latest from SteamDB: v1.2.3 (Build 12345678) — recommended + ``` +3. Selecting this option triggers version creation with the SteamDB data pre-filled, or (if the build ID already matches an existing version) selects that existing version. +4. The option should be visually distinct (e.g., with a small SteamDB badge/icon) to distinguish it from user-entered versions. + +#### 2.2.5 Constraints + +- **No SteamDB scraping if `steamAppId` is null** (manual/GOG/Epic games) +- **No automatic version creation** — the user must explicitly choose the SteamDB suggestion +- **Respect SteamDB's rate limits** — do not fetch on every keystroke; fetch only on wizard mount or explicit refresh +- **The scraping is best-effort** — SteamDB may change their HTML structure; graceful degradation is required + +### 2.3 New Files + +| File | Purpose | +|------|---------| +| `lib/api/steamdb-version.ts` | New Elysia route for the endpoint | +| `lib/steamdb/scrape.ts` | HTML scraping logic (fetch + parse) | +| `lib/steamdb/cache.ts` | In-memory cache with TTL | + +### 2.4 Modified Files + +| File | Change | +|------|--------| +| `lib/api/index.ts` | Export new `steamdbVersionRoutes` | +| `lib/api/app.ts` | Register `steamdbVersionRoutes` | +| `components/wizard/steps/setup-step.tsx` | Add SteamDB version fetch + special option | +| `app/game/[id]/submit/page.tsx` | No changes needed (data fetched client-side) | + +--- + +## 3. Stream B — Landing Page + +### 3.1 Current State + +- `app/page.tsx` renders a full-height hero section with animated tagline and search bar +- No game cards, no data sections +- Server-route `/api/dashboard/trending`, `/api/dashboard/best-new-releases`, `/api/dashboard/most-tested`, `/api/dashboard/most-reported` already exist and return data + +### 3.2 Design + +#### 3.2.1 Layout & "Peek" Effect + +``` +┌─────────────────────────────────┐ +│ │ +│ HERO SECTION │ height: calc(100svh - 10svh) +│ (tagline, search, etc.) │ +│ │ +├─────────────────────────────────┤ ← 10svh of next section peeks above fold +│ ┌───────────────────────────┐ │ +│ │ Trending This Week │ │ +│ │ ┌─────┐ ┌─────┐ ┌─────┐ │ │ +│ │ │Game │ │Game │ │Game │ │ │ +│ │ └─────┘ └─────┘ └─────┘ │ │ +│ └───────────────────────────┘ │ +│ ┌───────────────────────────┐ │ +│ │ Best New Releases │ │ +│ ... │ +``` + +**Implementation:** +- Hero section gets `min-h-[calc(100svh-10svh)]` (formerly `h-[calc(100vh-3.6rem)]`) +- The sections below start immediately, so ~10svh of the first card row is visible without scrolling +- This REQUIRES the hero to not use `overflow: hidden` — let content naturally overflow + +#### 3.2.2 Section Order (top to bottom) + +1. **Hero** (existing — keep as-is with height adjustment) +2. **Trending This Week** — from `/api/dashboard/trending` +3. **Best Performing New Releases** — from `/api/dashboard/best-new-releases` +4. **Most Tested Games** — from `/api/dashboard/most-tested` +5. **Most Reported Games** — from `/api/dashboard/most-reported` + +#### 3.2.3 Card Design + +Each section renders a horizontal scrollable row of compact game cards. Card design follows the **search result card** pattern from `app/search/page.tsx`: + +- **Cover image** (aspect 2:3, `w-20 sm:w-24 md:w-28`, rounded-lg) +- **Title** (truncated, semibold) +- **Playability badge** (from search card) +- **Relevant stat** (varies by section): + - Trending: activity score or benchmark count + - Best New Releases: avg FPS + - Most Tested: benchmark count + - Most Reported: report count +- **Hover/tap:** scales to 1.02, redirects to `/game/{id}?sync=1` + +#### 3.2.4 Data Fetching Strategy + +The landing page remains a **client-side component** (consistent with the current `"use client"` pattern and the search page): + +- Hero section keeps its animations and client state (search bar, rotating tagline) +- Data sections fetch from the existing public dashboard endpoints on mount via `useEffect` + `fetch`: + - `GET /api/dashboard/trending` + - `GET /api/dashboard/best-new-releases` + - `GET /api/dashboard/most-tested` + - `GET /api/dashboard/most-reported` +- Each section shows a **skeleton loader** (pulsing placeholder cards) while fetching +- All 4 fetches fire in parallel via `Promise.all` +- The page does NOT need to be a Server Component — the dashboard queries are fast, public, and the hero already requires client-side interactivity + +#### 3.2.5 Empty States + +- If a section returns 0 results, hide the section entirely (don't show "No trending games") +- This avoids a dead page for new/quiet periods + +#### 3.2.6 Styling Constraints + +- Follow existing Tailwind theme variables (`--color-background`, `--color-primary`, `--color-text`, `--color-border`, etc.) +- Section headers: `text-sm font-semibold text-text/80` with a subtle left border accent (`border-l-2 border-primary pl-3`) +- Horizontal scroll: use `overflow-x-auto` with `scrollbar-hide` or custom thin scrollbar +- Cards: replicate the `bg-text/3 border border-border rounded-xl` pattern from search cards +- Use `motion` (framer-motion fork) for stagger animations on card reveal +- Maintain 44px touch targets for interactive elements + +### 3.3 New/Modified Files + +| File | Change | +|------|--------| +| `app/page.tsx` | Major refactor — add data sections below hero, adjust hero height | +| `components/landing/` | New directory with section components (or inline in page.tsx for MVP) | + +--- + +## 4. Stream C — API Security & Rate Limiting + +### 4.1 Current State + +- **Rate limiter:** In-memory, global 60s window, 100 requests per IP+path (`lib/auth/rate-limit.ts`) +- **Auth guards:** Role-based (`requireAuth`, `requireRole`, `requireAdmin`, `requireContributorOrAdmin`) +- **Comments:** Auth required, soft-delete, no duplicate/spam detection +- **Submissions:** Auth required, no per-user rate limits +- **Reports:** One per user per entry (DB unique constraint) +- **Community suggestions:** One per user per game per field (DB unique constraint) + +### 4.2 Identified Gaps + +| Gap | Risk | Severity | +|-----|------|----------| +| No per-route rate limits | A logged-in user can POST comments as fast as they can send requests | Medium | +| No duplicate comment detection | Identical content can be posted repeatedly | Low-Medium | +| No submission flood protection | A user can submit many benchmarks in quick succession | Medium | +| Global rate limit is coarse | All routes share one 100 req/min bucket; legitimate burst traffic may be blocked | Low | +| No content length validation on comments | Extremely long comments could be submitted | Low | +| No input sanitization beyond Elysia validation | Tiptap JSON is stored as-is; XSS risk if renderer is flawed | Low | +| No CSRF protection on state-changing endpoints | Token-based auth mitigates this partially | Low | +| In-memory rate limiter doesn't scale | Multi-instance deployments would have separate counters | Low (current deploy is single-instance) | + +### 4.3 Design + +#### 4.3.1 Tiered Rate Limiting + +Replace the single global rate limiter with **route-category limits**: + +| Category | Window | Max Requests | Applies To | +|----------|--------|-------------|------------| +| `default` | 60s | 100 | All unlisted routes | +| `auth` | 60s | 20 | Login, OTP, passkey endpoints | +| `read` | 60s | 300 | GET endpoints (search, listing, game details) | +| `write` | 60s | 10 | POST comment, POST submission, POST report | +| `strict` | 60s | 5 | POST contact form, community suggestion | + +**Implementation:** +- Extend `rateLimit` to accept an optional `category` parameter +- Apply different limits per route group in `app.ts` +- Maintain the existing in-memory store pattern (no Redis dependency for now) + +#### 4.3.2 Comment Anti-Spam + +1. **Duplicate detection (exact match):** + - Before inserting a comment, check if the user has posted a comment with identical `content` (JSON stringified) to the same game in the last 5 minutes + - If yes, return `409 Conflict` with `{ error: "Duplicate comment detected" }` + +2. **Content length cap:** + - Validate that `JSON.stringify(body.content).length <= 50000` (50KB) + - Return `413 Payload Too Large` if exceeded + +3. **Rate limit on comment creation:** + - Covered by the `write` tier (10 req/min per IP) + - Additional per-user limit: max 30 comments per hour across all games (DB query check) + +4. **Ghost-ban pattern (future consideration):** + - Add `isShadowBanned` boolean to users table (admin-only) + - Shadow-banned users' comments appear to themselves but are hidden from others + - **Scope:** Out of scope for this release; note as future enhancement + +#### 4.3.3 Submission Abuse Prevention + +1. **Per-user submission cooldown:** + - Before inserting a performance entry, check when the user last submitted ANY entry + - If < 60 seconds ago, return `429 Too Many Requests` with `{ error: "Please wait before submitting another benchmark", retryAfter: N }` + - This is a lightweight DB query: `SELECT MAX(createdAt) FROM performance_entries WHERE userId = ?` + +2. **Duplicate entry detection:** + - Check for existing entry with same `userId`, `versionId`, `hardwareSlug`, `upscalerType`, `frameGenMethod` within last 10 minutes + - If found, warn but don't block (user may be re-submitting with corrections) + +3. **Validation hardening:** + - `fpsAvg`: must be between 1 and 500 + - `fpsLow`/`fpsHigh`/`fpsOnePercentLow`: if provided, must be between 0 and 500 + - `tdpWatts`: if provided, must be between 1 and 100 + - `settingsJson`: maximum 20 categories, each with maximum 50 settings + - `userNotes`: maximum 5000 characters + +#### 4.3.4 Additional Hardening + +1. **Input sanitization on comment content:** + - Strip `