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.
126 lines
3.5 KiB
TypeScript
126 lines
3.5 KiB
TypeScript
import { deserialize } from 'bson';
|
|
import type { TTSMod } from '@tts/shared';
|
|
import { ItemNotFoundError, NoFileError, SteamApiError } from './errors.js';
|
|
|
|
const STEAM_API_URL =
|
|
'https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/';
|
|
|
|
interface SteamPublishedFileDetails {
|
|
publishedfileid: string;
|
|
title: string;
|
|
creator: string;
|
|
preview_url: string;
|
|
file_url?: string;
|
|
description?: string;
|
|
tags?: { tag: string }[];
|
|
time_created?: number;
|
|
time_updated?: number;
|
|
}
|
|
|
|
interface SteamResponse {
|
|
response: {
|
|
result: number;
|
|
resultcount: number;
|
|
publishedfiledetails?: SteamPublishedFileDetails[];
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetch a full TTS save for a Workshop item and BSON-deserialize it.
|
|
*
|
|
* @param id Workshop item ID (digits only).
|
|
* @param apiKey Steam Web API key.
|
|
*/
|
|
export async function fetchMod(id: string, apiKey: string): Promise<TTSMod> {
|
|
const fileUrl = await getFileUrl(id, apiKey);
|
|
return fetchModFromUrl(fileUrl);
|
|
}
|
|
|
|
/**
|
|
* 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<TTSMod> {
|
|
const buffer = await downloadSave(fileUrl);
|
|
return deserialize(new Uint8Array(buffer)) as TTSMod;
|
|
}
|
|
|
|
/**
|
|
* Derive a filename from a `content-disposition` header.
|
|
* Parses `filename="..."`; falls back to the URL path, then a default.
|
|
*/
|
|
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;
|
|
}
|
|
}
|
|
return new URL(url).pathname.split('/').pop() || 'save.json';
|
|
}
|
|
|
|
/**
|
|
* Fetch the raw save file bytes for a Workshop item.
|
|
* Returns the bytes plus a derived filename.
|
|
*/
|
|
export async function fetchModFile(
|
|
id: string,
|
|
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<ArrayBuffer> {
|
|
const res = await fetch(fileUrl);
|
|
if (!res.ok) {
|
|
throw new SteamApiError(
|
|
`Failed to download save file (${res.status})`,
|
|
502,
|
|
);
|
|
}
|
|
return res.arrayBuffer();
|
|
}
|
|
|
|
/** Resolve the `file_url` for a Workshop item via the Steam API. */
|
|
async function getFileUrl(id: string, apiKey: string): Promise<string> {
|
|
const params = new URLSearchParams();
|
|
params.append('key', apiKey);
|
|
params.append('itemcount', '1');
|
|
params.append('publishedfileids[0]', id);
|
|
|
|
const res = await fetch(STEAM_API_URL, {
|
|
method: 'POST',
|
|
body: params,
|
|
});
|
|
if (!res.ok) {
|
|
throw new SteamApiError(`Steam API responded ${res.status}`, 502);
|
|
}
|
|
|
|
const json = (await res.json()) as SteamResponse;
|
|
const details = json.response.publishedfiledetails?.[0];
|
|
if (!details) {
|
|
throw new ItemNotFoundError(id);
|
|
}
|
|
if (!details.file_url) {
|
|
throw new NoFileError(id);
|
|
}
|
|
return details.file_url;
|
|
}
|
|
|
|
export * from './errors.js'; |