refactor(tts): derive save filename from URL path only

This commit is contained in:
2026-08-10 09:12:34 +08:00
parent 82caa9bb9c
commit 45bf362fbc
5 changed files with 16 additions and 35 deletions
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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`
+5 -5
View File
@@ -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<string>` — 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) | — |
+3 -17
View File
@@ -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');
});
});
+6 -11
View File
@@ -47,17 +47,12 @@ export async function fetchModFromUrl(fileUrl: string): Promise<TTSMod> {
}
/**
* 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 };
}