refactor(tts): derive save filename from URL path only
This commit is contained in:
@@ -38,7 +38,7 @@ To run the frontend alongside the proxy, open a second terminal and run
|
|||||||
| GET | `/health` | Liveness |
|
| GET | `/health` | Liveness |
|
||||||
| GET | `/search?q=&page=`| Search the Workshop (scrapes browse page) |
|
| GET | `/search?q=&page=`| Search the Workshop (scrapes browse page) |
|
||||||
| GET | `/items/:id` | Full parsed `TTSMod` (BSON save) |
|
| 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 | `/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 |
|
| GET | `/trace?url=&mode=&format=&offset=` | Trace an image into a vector shape (BSON); `offset` insets/outsets in pixels |
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ apps/web ──► apps/proxy ──► packages/tts ──► packages/shared
|
|||||||
- **`apps/web` → `packages/mesh`** — extrudes 2D shapes into 3D geometry
|
- **`apps/web` → `packages/mesh`** — extrudes 2D shapes into 3D geometry
|
||||||
(`{ front, back, walls }`) for the tile, token, and card viewers.
|
(`{ front, back, walls }`) for the tile, token, and card viewers.
|
||||||
- **`apps/proxy` → `packages/tts`** — calls `fetchMod` / `getFileName` to serve
|
- **`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
|
- **`apps/proxy` → `packages/shared`** — uses shared types and zod schemas for
|
||||||
request/response validation.
|
request/response validation.
|
||||||
- **`packages/tts` → `packages/shared`** — consumes `TTSMod` / `TTSObject`
|
- **`packages/tts` → `packages/shared`** — consumes `TTSMod` / `TTSObject`
|
||||||
|
|||||||
@@ -155,8 +155,8 @@ Low-level fetcher, extracted from the existing scraper.
|
|||||||
(no Steam API call).
|
(no Steam API call).
|
||||||
- `fetchModFile(id, apiKey)` / `fetchModFileFromUrl(fileUrl)` — raw save
|
- `fetchModFile(id, apiKey)` / `fetchModFileFromUrl(fileUrl)` — raw save
|
||||||
bytes + derived filename, with and without the Steam API.
|
bytes + derived filename, with and without the Steam API.
|
||||||
- `getFileName(url: string): Promise<string>` — derive filename from the
|
- `getFileName(url: string): string` — derive a filename from the save URL
|
||||||
`content-disposition` header.
|
path (the upstream `content-disposition` header is ignored).
|
||||||
- `errors.ts`
|
- `errors.ts`
|
||||||
- Typed errors: missing `file_url`, Steam API failure, rate limit, invalid key.
|
- Typed errors: missing `file_url`, Steam API failure, rate limit, invalid key.
|
||||||
- Notes
|
- Notes
|
||||||
@@ -237,8 +237,8 @@ Hono server exposing search + fetch.
|
|||||||
- `GET /items/:id` — full parsed `TTSMod`. Accepts an optional `fileUrl`
|
- `GET /items/:id` — full parsed `TTSMod`. Accepts an optional `fileUrl`
|
||||||
query param to download the save directly (no Steam API key needed);
|
query param to download the save directly (no Steam API key needed);
|
||||||
otherwise resolves via the Steam API.
|
otherwise resolves via the Steam API.
|
||||||
- `GET /items/:id/file` — raw save bytes, filename from `getFileName`. Also
|
- `GET /items/:id/file` — raw save bytes, filename from `getFileName` (the
|
||||||
accepts `fileUrl`.
|
URL path). Also accepts `fileUrl`.
|
||||||
- `routes/asset.ts`
|
- `routes/asset.ts`
|
||||||
- `GET /asset?url=...` — fetch an external asset (texture, model) and stream
|
- `GET /asset?url=...` — fetch an external asset (texture, model) and stream
|
||||||
it back with a `Content-Type` header. Workshop hosts often omit CORS
|
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 | `/health` | Liveness | — |
|
||||||
| GET | `/search?q=&page=` | Scrape Workshop browse, return item list | — |
|
| GET | `/search?q=&page=` | Scrape Workshop browse, return item list | — |
|
||||||
| GET | `/items/:id` | Full parsed `TTSMod` (`?fileUrl=` skips key) | key* |
|
| 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 | `/asset?url=` | CORS-safe proxy for external assets | — |
|
||||||
| GET | `/trace?url=&mode=&format=&offset=` | Trace an image into a vector shape (BSON) | — |
|
| GET | `/trace?url=&mode=&format=&offset=` | Trace an image into a vector shape (BSON) | — |
|
||||||
|
|
||||||
|
|||||||
@@ -43,26 +43,12 @@ describe('fetchModFileFromUrl', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('getFileName', () => {
|
describe('getFileName', () => {
|
||||||
it('parses a quoted content-disposition filename', () => {
|
it('derives the filename from the URL path', () => {
|
||||||
expect(
|
expect(getFileName('https://example.com/files/mod.json')).toBe('mod.json');
|
||||||
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('falls back to a default when the URL has no path', () => {
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -47,17 +47,12 @@ export async function fetchModFromUrl(fileUrl: string): Promise<TTSMod> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derive a filename from a `content-disposition` header.
|
* Derive a filename for a downloaded save from its URL path, falling back to
|
||||||
* Parses `filename="..."`; falls back to the URL path, then a default.
|
* 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 {
|
export function getFileName(url: string): string {
|
||||||
if (disposition) {
|
|
||||||
const match = disposition.match(/filename\*?=(?:"([^"]*)"|([^;\s]*))/i);
|
|
||||||
const name = match?.[1] ?? match?.[2];
|
|
||||||
if (name) {
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new URL(url).pathname.split('/').pop() || 'save.json';
|
return new URL(url).pathname.split('/').pop() || 'save.json';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +76,7 @@ export async function fetchModFileFromUrl(
|
|||||||
fileUrl: string,
|
fileUrl: string,
|
||||||
): Promise<{ data: ArrayBuffer; filename: string }> {
|
): Promise<{ data: ArrayBuffer; filename: string }> {
|
||||||
const data = await downloadSave(fileUrl);
|
const data = await downloadSave(fileUrl);
|
||||||
const filename = getFileName(fileUrl, null);
|
const filename = getFileName(fileUrl);
|
||||||
return { data, filename };
|
return { data, filename };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user