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.
This commit is contained in:
2026-08-08 11:32:57 +08:00
parent 4ffe858c36
commit f7a6eb6ee1
9 changed files with 170 additions and 48 deletions
+45 -2
View File
@@ -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'),
+17 -13
View File
@@ -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<TTSMod>(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',