From f7a6eb6ee1f49f76daaeb8e4ca1d0bd60dc4fe4b Mon Sep 17 00:00:00 2001 From: hypercross Date: Sat, 8 Aug 2026 11:32:57 +0800 Subject: [PATCH] feat: allow loading mods without a Steam API key Add fetchModFromUrl/fetchModFileFromUrl and accept a fileUrl query param on /items routes so the frontend can download saves directly from a search result's file_url, with no key required. --- README.md | 8 +++-- apps/proxy/src/routes/items.test.ts | 47 +++++++++++++++++++++++++++-- apps/proxy/src/routes/items.ts | 30 ++++++++++-------- apps/web/src/api.ts | 14 ++++++--- apps/web/src/pages/ModPage.tsx | 8 +++-- apps/web/src/stores/modStore.ts | 12 +++++--- docs/implementation-plan.md | 22 +++++++++++--- packages/tts/src/index.test.ts | 38 ++++++++++++++++++++++- packages/tts/src/index.ts | 39 ++++++++++++++++-------- 9 files changed, 170 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index f6fbd53..9eb4ff5 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,12 @@ parser is the first place to look. ## Notes -- `STEAM_API_KEY` is only needed for `/items/*` (resolving `file_url`). Search - works without it. +- `STEAM_API_KEY` is optional. Search works without it. The mod page works + without it too when you arrive from a search result: the frontend passes the + item's `file_url` (already present in search results) to `/items/:id` via a + `fileUrl` query param, so the proxy downloads the save directly instead of + calling the Steam API. Without a key *and* without a `fileUrl` (e.g. a + deep-linked mod page), `/items/*` returns 500. - Each request fetches fresh; there is no caching by design (client-only tool). - `packages/extract` has zero runtime dependencies and runs in browser or Node, ready for a future frontend. diff --git a/apps/proxy/src/routes/items.test.ts b/apps/proxy/src/routes/items.test.ts index e072208..3e8a763 100644 --- a/apps/proxy/src/routes/items.test.ts +++ b/apps/proxy/src/routes/items.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { ItemNotFoundError, SteamApiError } from '@tts/tts'; +import { ItemNotFoundError, SteamApiError, TtsError } from '@tts/tts'; import type { TTSMod } from '@tts/shared'; import items from './items.js'; @@ -16,12 +16,34 @@ describe('items route', () => { expect(await res.json()).toEqual({ error: 'Item ID must be a number' }); }); - it('returns 500 when the API key is missing', async () => { + it('returns 500 when the API key is missing and no fileUrl is given', async () => { + vi.spyOn(await import('@tts/tts'), 'fetchMod').mockRejectedValue( + new TtsError('STEAM_API_KEY is not configured', 500), + ); const res = await items.request('/123', {}, { STEAM_API_KEY: '', PORT: 3000 }); expect(res.status).toBe(500); expect(await res.json()).toEqual({ error: 'STEAM_API_KEY is not configured' }); }); + it('loads from a fileUrl without an API key', async () => { + const mod: TTSMod = { + GameMode: 'Tabletop', + Date: '2024-01-01', + ObjectStates: [], + }; + const fetchModFromUrl = vi + .spyOn(await import('@tts/tts'), 'fetchModFromUrl') + .mockResolvedValue(mod); + const res = await items.request( + '/123?fileUrl=https%3A%2F%2Fexample.com%2Fsave.json', + {}, + { STEAM_API_KEY: '', PORT: 3000 }, + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual(mod); + expect(fetchModFromUrl).toHaveBeenCalledWith('https://example.com/save.json'); + }); + it('returns the parsed mod on success', async () => { const mod: TTSMod = { GameMode: 'Tabletop', @@ -75,6 +97,27 @@ describe('items file route', () => { expect(await res.arrayBuffer()).toEqual(new Uint8Array([1, 2, 3]).buffer); }); + it('serves the raw file from a fileUrl without an API key', async () => { + const fetchModFileFromUrl = vi + .spyOn(await import('@tts/tts'), 'fetchModFileFromUrl') + .mockResolvedValue({ + data: new Uint8Array([1, 2, 3]).buffer, + filename: 'mod.json', + }); + const res = await items.request( + '/123/file?fileUrl=https%3A%2F%2Fexample.com%2Fsave.json', + {}, + { STEAM_API_KEY: '', PORT: 3000 }, + ); + expect(res.status).toBe(200); + expect(res.headers.get('content-disposition')).toBe( + 'attachment; filename="mod.json"', + ); + expect(fetchModFileFromUrl).toHaveBeenCalledWith( + 'https://example.com/save.json', + ); + }); + it('maps SteamApiError to 502', async () => { vi.spyOn(await import('@tts/tts'), 'fetchModFile').mockRejectedValue( new SteamApiError('Steam API responded 500'), diff --git a/apps/proxy/src/routes/items.ts b/apps/proxy/src/routes/items.ts index 68c7900..cb2ca93 100644 --- a/apps/proxy/src/routes/items.ts +++ b/apps/proxy/src/routes/items.ts @@ -1,6 +1,12 @@ import { Hono } from 'hono'; import { itemIdSchema, type TTSMod } from '@tts/shared'; -import { fetchMod, fetchModFile, TtsError } from '@tts/tts'; +import { + fetchMod, + fetchModFile, + fetchModFileFromUrl, + fetchModFromUrl, + TtsError, +} from '@tts/tts'; import type { Bindings } from '../env.js'; const app = new Hono<{ Bindings: Bindings }>(); @@ -11,13 +17,13 @@ app.get('/:id', async (c) => { return c.json({ error: 'Item ID must be a number' }, 400); } - const apiKey = c.env.STEAM_API_KEY; - if (!apiKey) { - return c.json({ error: 'STEAM_API_KEY is not configured' }, 500); - } - try { - const mod = await fetchMod(id, apiKey); + const fileUrl = c.req.query('fileUrl'); + // With no Steam API key, the caller must supply the save URL (e.g. from a + // search result). Otherwise resolve it via the Steam API. + const mod: TTSMod = fileUrl + ? await fetchModFromUrl(fileUrl) + : await fetchMod(id, c.env.STEAM_API_KEY ?? ''); return c.json(mod); } catch (err) { if (err instanceof TtsError) { @@ -33,13 +39,11 @@ app.get('/:id/file', async (c) => { return c.json({ error: 'Item ID must be a number' }, 400); } - const apiKey = c.env.STEAM_API_KEY; - if (!apiKey) { - return c.json({ error: 'STEAM_API_KEY is not configured' }, 500); - } - try { - const { data, filename } = await fetchModFile(id, apiKey); + const fileUrl = c.req.query('fileUrl'); + const { data, filename } = fileUrl + ? await fetchModFileFromUrl(fileUrl) + : await fetchModFile(id, c.env.STEAM_API_KEY ?? ''); return new Response(data, { headers: { 'Content-Type': 'application/octet-stream', diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 7963c76..0e93955 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -18,11 +18,17 @@ export function searchWorkshop(q: string, page = 1): Promise { } /** Fetch a full parsed TTS save. */ -export function fetchMod(id: string): Promise { - return getJson(`/items/${id}`); +export function fetchMod(id: string, fileUrl?: string): Promise { + const params = new URLSearchParams(); + if (fileUrl) params.set('fileUrl', fileUrl); + const qs = params.toString(); + return getJson(`/items/${id}${qs ? `?${qs}` : ''}`); } /** Build a URL for the raw save file download. */ -export function modFileUrl(id: string): string { - return `${BASE}/items/${id}/file`; +export function modFileUrl(id: string, fileUrl?: string): string { + const params = new URLSearchParams(); + if (fileUrl) params.set('fileUrl', fileUrl); + const qs = params.toString(); + return `/items/${id}/file${qs ? `?${qs}` : ''}`; } \ No newline at end of file diff --git a/apps/web/src/pages/ModPage.tsx b/apps/web/src/pages/ModPage.tsx index fc87b4d..9e74fd5 100644 --- a/apps/web/src/pages/ModPage.tsx +++ b/apps/web/src/pages/ModPage.tsx @@ -2,15 +2,17 @@ import { useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { collectRefs, flattenObjects } from '@tts/extract'; import { useModStore } from '../stores/modStore'; +import { useSearchStore } from '../stores/searchStore'; import { modFileUrl } from '../api'; export default function ModPage() { const { id } = useParams<{ id: string }>(); const { mod, loading, error, load } = useModStore(); + const item = useSearchStore((s) => s.items.find((i) => i.id === id)); useEffect(() => { - if (id) load(id); - }, [id, load]); + if (id) load(id, item?.fileUrl); + }, [id, item?.fileUrl, load]); if (loading) return

Loading mod…

; if (error) return

{error}

; @@ -28,7 +30,7 @@ export default function ModPage() { asset refs

Download save file diff --git a/apps/web/src/stores/modStore.ts b/apps/web/src/stores/modStore.ts index 1fdabd2..17285ea 100644 --- a/apps/web/src/stores/modStore.ts +++ b/apps/web/src/stores/modStore.ts @@ -4,26 +4,28 @@ import { fetchMod } from '../api'; interface ModState { id: string | null; + fileUrl?: string; mod: TTSMod | null; loading: boolean; error: string | null; - load: (id: string) => Promise; + load: (id: string, fileUrl?: string) => Promise; clear: () => void; } export const useModStore = create((set) => ({ id: null, + fileUrl: undefined, mod: null, loading: false, error: null, - load: async (id) => { - set({ loading: true, error: null, id }); + load: async (id, fileUrl) => { + set({ loading: true, error: null, id, fileUrl }); try { - const mod = await fetchMod(id); + const mod = await fetchMod(id, fileUrl); set({ mod, loading: false }); } catch (err) { set({ loading: false, error: String(err) }); } }, - clear: () => set({ id: null, mod: null, error: null, loading: false }), + clear: () => set({ id: null, fileUrl: undefined, mod: null, error: null, loading: false }), })); \ No newline at end of file diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 00235ad..43dd890 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -124,6 +124,10 @@ Low-level fetcher, extracted from the existing scraper. - `fetchMod(id: string): Promise` — Steam API call to `ISteamRemoteStorage/GetPublishedFileDetails/v1` to get `file_url`, then download + BSON-deserialize into `TTSMod`. + - `fetchModFromUrl(fileUrl)` — download + BSON-deserialize from a direct URL + (no Steam API call). + - `fetchModFile(id, apiKey)` / `fetchModFileFromUrl(fileUrl)` — raw save + bytes + derived filename, with and without the Steam API. - `getFileName(url: string): Promise` — derive filename from the `content-disposition` header. - `errors.ts` @@ -171,8 +175,11 @@ Hono server exposing search + fetch. `cheerio`, extract `{ id, title, author, previewImageUrl }` from the result grid. Supports `pagenum` pagination. No API key required. - `routes/items.ts` - - `GET /items/:id` — full parsed `TTSMod`. - - `GET /items/:id/file` — raw save bytes, filename from `getFileName`. + - `GET /items/:id` — full parsed `TTSMod`. Accepts an optional `fileUrl` + query param to download the save directly (no Steam API key needed); + otherwise resolves via the Steam API. + - `GET /items/:id/file` — raw save bytes, filename from `getFileName`. Also + accepts `fileUrl`. - `routes/health.ts` - `GET /health` — liveness. - `env.ts` — zod validation of `STEAM_API_KEY`, `PORT`. @@ -204,15 +211,20 @@ proxy API and `packages/extract` directly for analysis. | ------ | ------------------- | -------------------------------------------- | ---- | | GET | `/health` | Liveness | — | | GET | `/search?q=&page=` | Scrape Workshop browse, return item list | — | -| GET | `/items/:id` | Full parsed `TTSMod` | key | -| GET | `/items/:id/file` | Raw save bytes, filename from header | key | +| GET | `/items/:id` | Full parsed `TTSMod` (`?fileUrl=` skips key) | key* | +| GET | `/items/:id/file` | Raw save bytes, filename from header | key* | + +\* `STEAM_API_KEY` is optional; `/items/*` works without it when a `fileUrl` +query param is supplied. ## Data flow ``` -/search?q=wingspan → scrape browse page → list of {id, title, ...} +/search?q=wingspan → scrape browse page → list of {id, title, fileUrl, ...} ↓ /items/:id → Steam API (needs key) → file_url + ↓ + └─ /items/:id?fileUrl=... → download directly (no key) ↓ download BSON → parse → TTSMod ↓ diff --git a/packages/tts/src/index.test.ts b/packages/tts/src/index.test.ts index 2b5163e..02fe049 100644 --- a/packages/tts/src/index.test.ts +++ b/packages/tts/src/index.test.ts @@ -1,11 +1,47 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { getFileName } from './index.js'; +import { fetchModFileFromUrl, fetchModFromUrl, getFileName } from './index.js'; import { ItemNotFoundError, NoFileError, SteamApiError, TtsError } from './errors.js'; afterEach(() => { vi.unstubAllGlobals(); }); +// A minimal valid BSON document: { GameMode: 'Tabletop' }. +const bsonBytes = new Uint8Array([ + 28, 0, 0, 0, 2, 71, 97, 109, 101, 77, 111, 100, 101, 0, 9, 0, 0, 0, 84, 97, + 98, 108, 101, 116, 111, 112, 0, 0, +]); + +function bsonResponse(): Response { + return new Response(bsonBytes); +} + +describe('fetchModFromUrl', () => { + it('downloads and deserializes a save from a URL', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(bsonResponse())); + const mod = await fetchModFromUrl('https://example.com/save.json'); + expect(mod.GameMode).toBe('Tabletop'); + }); + + it('throws on a failed download', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 404 }))); + await expect(fetchModFromUrl('https://example.com/save.json')).rejects.toThrow( + 'Failed to download save file (404)', + ); + }); +}); + +describe('fetchModFileFromUrl', () => { + it('returns bytes and a filename derived from the URL', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(bsonResponse())); + const { data, filename } = await fetchModFileFromUrl( + 'https://example.com/files/mod.json', + ); + expect(new Uint8Array(data)).toEqual(bsonBytes); + expect(filename).toBe('mod.json'); + }); +}); + describe('getFileName', () => { it('parses a quoted content-disposition filename', () => { expect( diff --git a/packages/tts/src/index.ts b/packages/tts/src/index.ts index 11b8f4e..fb6b194 100644 --- a/packages/tts/src/index.ts +++ b/packages/tts/src/index.ts @@ -33,16 +33,16 @@ interface SteamResponse { */ export async function fetchMod(id: string, apiKey: string): Promise { const fileUrl = await getFileUrl(id, apiKey); + return fetchModFromUrl(fileUrl); +} - const saveRes = await fetch(fileUrl); - if (!saveRes.ok) { - throw new SteamApiError( - `Failed to download save file (${saveRes.status})`, - 502, - ); - } - - const buffer = await saveRes.arrayBuffer(); +/** + * Download a TTS save from a direct URL and BSON-deserialize it. + * + * @param fileUrl Direct URL to the save file (e.g. from a search result). + */ +export async function fetchModFromUrl(fileUrl: string): Promise { + const buffer = await downloadSave(fileUrl); return deserialize(new Uint8Array(buffer)) as TTSMod; } @@ -70,7 +70,23 @@ export async function fetchModFile( apiKey: string, ): Promise<{ data: ArrayBuffer; filename: string }> { const fileUrl = await getFileUrl(id, apiKey); + return fetchModFileFromUrl(fileUrl); +} +/** + * Fetch the raw save file bytes from a direct URL. + * Returns the bytes plus a derived filename. + */ +export async function fetchModFileFromUrl( + fileUrl: string, +): Promise<{ data: ArrayBuffer; filename: string }> { + const data = await downloadSave(fileUrl); + const filename = getFileName(fileUrl, null); + return { data, filename }; +} + +/** Download a save file's bytes from a URL, throwing on failure. */ +async function downloadSave(fileUrl: string): Promise { const res = await fetch(fileUrl); if (!res.ok) { throw new SteamApiError( @@ -78,10 +94,7 @@ export async function fetchModFile( 502, ); } - - const data = await res.arrayBuffer(); - const filename = getFileName(fileUrl, res.headers.get('content-disposition')); - return { data, filename }; + return res.arrayBuffer(); } /** Resolve the `file_url` for a Workshop item via the Steam API. */