From 45bf362fbce16365dca75a6b3ebab001ea040360 Mon Sep 17 00:00:00 2001 From: hypercross Date: Mon, 10 Aug 2026 09:12:34 +0800 Subject: [PATCH] refactor(tts): derive save filename from URL path only --- README.md | 2 +- docs/architecture.md | 2 +- docs/implementation-plan.md | 10 +++++----- packages/tts/src/index.test.ts | 20 +++----------------- packages/tts/src/index.ts | 17 ++++++----------- 5 files changed, 16 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 9a42384..a402f61 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ To run the frontend alongside the proxy, open a second terminal and run | GET | `/health` | Liveness | | GET | `/search?q=&page=`| Search the Workshop (scrapes browse page) | | GET | `/items/:id` | Full parsed `TTSMod` (BSON save) | -| GET | `/items/:id/file` | Raw save bytes, filename from header | +| GET | `/items/:id/file` | Raw save bytes, filename from the URL path | | GET | `/asset?url=` | CORS-safe proxy for external assets (textures, models) | | GET | `/trace?url=&mode=&format=&offset=` | Trace an image into a vector shape (BSON); `offset` insets/outsets in pixels | diff --git a/docs/architecture.md b/docs/architecture.md index 13f480d..9e2c7a8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -55,7 +55,7 @@ apps/web ──► apps/proxy ──► packages/tts ──► packages/shared - **`apps/web` → `packages/mesh`** — extrudes 2D shapes into 3D geometry (`{ front, back, walls }`) for the tile, token, and card viewers. - **`apps/proxy` → `packages/tts`** — calls `fetchMod` / `getFileName` to serve - item requests. + item requests (the filename is derived from the save URL path). - **`apps/proxy` → `packages/shared`** — uses shared types and zod schemas for request/response validation. - **`packages/tts` → `packages/shared`** — consumes `TTSMod` / `TTSObject` diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 628ec02..0d8bb2b 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -155,8 +155,8 @@ Low-level fetcher, extracted from the existing scraper. (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. + - `getFileName(url: string): string` — derive a filename from the save URL + path (the upstream `content-disposition` header is ignored). - `errors.ts` - Typed errors: missing `file_url`, Steam API failure, rate limit, invalid key. - Notes @@ -237,8 +237,8 @@ Hono server exposing search + fetch. - `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`. + - `GET /items/:id/file` — raw save bytes, filename from `getFileName` (the + URL path). Also accepts `fileUrl`. - `routes/asset.ts` - `GET /asset?url=...` — fetch an external asset (texture, model) and stream it back with a `Content-Type` header. Workshop hosts often omit CORS @@ -325,7 +325,7 @@ 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` (`?fileUrl=` skips key) | key* | -| GET | `/items/:id/file` | Raw save bytes, filename from header | key* | +| GET | `/items/:id/file` | Raw save bytes, filename from URL path | key* | | GET | `/asset?url=` | CORS-safe proxy for external assets | — | | GET | `/trace?url=&mode=&format=&offset=` | Trace an image into a vector shape (BSON) | — | diff --git a/packages/tts/src/index.test.ts b/packages/tts/src/index.test.ts index 02fe049..b1bcc68 100644 --- a/packages/tts/src/index.test.ts +++ b/packages/tts/src/index.test.ts @@ -43,26 +43,12 @@ describe('fetchModFileFromUrl', () => { }); describe('getFileName', () => { - it('parses a quoted content-disposition filename', () => { - expect( - getFileName('https://example.com/save', 'attachment; filename="mod.json"'), - ).toBe('mod.json'); - }); - - it('parses an unquoted filename', () => { - expect( - getFileName('https://example.com/save', 'attachment; filename=mod.json'), - ).toBe('mod.json'); - }); - - it('falls back to the URL path when there is no disposition', () => { - expect(getFileName('https://example.com/files/mod.json', null)).toBe( - 'mod.json', - ); + it('derives the filename from the URL path', () => { + expect(getFileName('https://example.com/files/mod.json')).toBe('mod.json'); }); it('falls back to a default when the URL has no path', () => { - expect(getFileName('https://example.com', null)).toBe('save.json'); + expect(getFileName('https://example.com')).toBe('save.json'); }); }); diff --git a/packages/tts/src/index.ts b/packages/tts/src/index.ts index fb6b194..3ff3cb5 100644 --- a/packages/tts/src/index.ts +++ b/packages/tts/src/index.ts @@ -47,17 +47,12 @@ export async function fetchModFromUrl(fileUrl: string): Promise { } /** - * Derive a filename from a `content-disposition` header. - * Parses `filename="..."`; falls back to the URL path, then a default. + * Derive a filename for a downloaded save from its URL path, falling back to + * a default when the URL has no path segment. The upstream `content-disposition` + * header is intentionally ignored: Steam save URLs are extension-less and + * rarely carry a useful filename, so the URL path is the reliable source. */ -export function getFileName(url: string, disposition: string | null): string { - if (disposition) { - const match = disposition.match(/filename\*?=(?:"([^"]*)"|([^;\s]*))/i); - const name = match?.[1] ?? match?.[2]; - if (name) { - return name; - } - } +export function getFileName(url: string): string { return new URL(url).pathname.split('/').pop() || 'save.json'; } @@ -81,7 +76,7 @@ export async function fetchModFileFromUrl( fileUrl: string, ): Promise<{ data: ArrayBuffer; filename: string }> { const data = await downloadSave(fileUrl); - const filename = getFileName(fileUrl, null); + const filename = getFileName(fileUrl); return { data, filename }; }