diff --git a/src/lib/curseforge/client.ts b/src/lib/curseforge/client.ts new file mode 100644 index 0000000..0f13fe0 --- /dev/null +++ b/src/lib/curseforge/client.ts @@ -0,0 +1,78 @@ +/** CurseForge API 客户端,封装两套 API 的鉴权与 User-Agent。 */ +export class CurseForgeClient { + /** GET /v1/mods/{modId}/files 等只读接口 */ + private apiBase = "https://api.curseforge.com"; + + /** 上传 / 更新文件、Game Version Types / Versions 等接口 */ + private uploadBase = "https://minecraft.curseforge.com"; + + private token: string; + private userAgent: string; + + constructor( + token: string, + userAgent?: string, + ) { + this.token = token; + this.userAgent = + userAgent ?? + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"; + } + + /** V1 API 请求头(x-api-key)。 */ + private apiHeaders(): Record { + return { "x-api-key": this.token }; + } + + /** Upload API 请求头(X-Api-Token + browser User-Agent)。 */ + private uploadHeaders(): Record { + return { + "X-Api-Token": this.token, + "User-Agent": this.userAgent, + }; + } + + /** 发送 GET 请求到 V1 API(api.curseforge.com)并解析 JSON 响应体。 */ + async get(path: string): Promise { + const res = await fetch(`${this.apiBase}${path}`, { + headers: this.apiHeaders(), + }); + if (!res.ok) { + throw new Error( + `CurseForge API GET ${path} failed: ${res.status} ${res.statusText}`, + ); + } + return res.json() as Promise; + } + + /** 发送 GET 请求到 Upload API(minecraft.curseforge.com)并解析 JSON 响应体。 */ + async uploadGet(path: string): Promise { + const res = await fetch(`${this.uploadBase}${path}`, { + headers: this.uploadHeaders(), + }); + if (!res.ok) { + throw new Error( + `CurseForge Upload GET ${path} failed: ${res.status} ${res.statusText}`, + ); + } + return res.json() as Promise; + } + + /** + * 发送 POST 请求到 Upload API(minecraft.curseforge.com),body 为 FormData。 + * 不手动设置 Content-Type,让 fetch 自动生成含 boundary 的头。 + */ + async uploadPost(path: string, body: FormData): Promise { + const res = await fetch(`${this.uploadBase}${path}`, { + method: "POST", + headers: this.uploadHeaders(), + body, + }); + if (!res.ok) { + throw new Error( + `CurseForge Upload POST ${path} failed: ${res.status} ${res.statusText}`, + ); + } + return res.json() as Promise; + } +} diff --git a/src/lib/curseforge/file.ts b/src/lib/curseforge/file.ts new file mode 100644 index 0000000..0ae3ee1 --- /dev/null +++ b/src/lib/curseforge/file.ts @@ -0,0 +1,85 @@ +import { CurseForgeClient } from "./client"; +import { + GetModFilesResponseSchema, + UploadResponseSchema, + type GetModFilesParams, + type GetModFilesResponse, + type UploadMetadata, + type UpdateMetadata, + type UploadResponse, +} from "@/types"; + +/** + * 获取指定项目的文件列表(分页)。 + * GET /v1/mods/{modId}/files + */ +export async function getFiles( + client: CurseForgeClient, + params: GetModFilesParams, +): Promise { + const { modId, ...query } = params; + const searchParams = new URLSearchParams(); + + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) { + searchParams.set(key, String(value)); + } + } + + const qs = searchParams.toString(); + const path = `/v1/mods/${encodeURIComponent(modId)}/files${qs ? `?${qs}` : ""}`; + const data = await client.get(path); + return GetModFilesResponseSchema.parse(data); +} + +/** + * 上传文件到项目。 + * POST /api/projects/{projectId}/upload-file + * + * @param client CurseForge 客户端实例 + * @param projectId 项目 ID(数字) + * @param metadata 已通过 Zod 校验的文件元数据 + * @param file 要上传的文件 Blob + */ +export async function uploadFile( + client: CurseForgeClient, + projectId: number, + metadata: UploadMetadata, + file: Blob, +): Promise { + const form = new FormData(); + form.append("metadata", JSON.stringify(metadata)); + + const filename = + file instanceof File ? file.name : "upload.jar"; + form.append("file", file, filename); + + const result = await client.uploadPost( + `/api/projects/${encodeURIComponent(projectId)}/upload-file`, + form, + ); + return UploadResponseSchema.parse(result); +} + +/** + * 更新已上传文件的信息(不重新上传文件本身)。 + * POST /api/projects/{projectId}/update-file + * + * @param client CurseForge 客户端实例 + * @param projectId 项目 ID(数字) + * @param metadata 已通过 Zod 校验的更新元数据 + */ +export async function updateFile( + client: CurseForgeClient, + projectId: number, + metadata: UpdateMetadata, +): Promise { + const form = new FormData(); + form.append("metadata", JSON.stringify(metadata)); + + const result = await client.uploadPost( + `/api/projects/${encodeURIComponent(projectId)}/update-file`, + form, + ); + return UploadResponseSchema.parse(result); +} diff --git a/src/lib/curseforge/game.ts b/src/lib/curseforge/game.ts new file mode 100644 index 0000000..8e3405a --- /dev/null +++ b/src/lib/curseforge/game.ts @@ -0,0 +1,35 @@ +import { CurseForgeClient } from "./client"; +import { + GameVersionTypeSchema, + GameVersionSchema, + type GameVersionType, + type GameVersion, +} from "@/types"; + +/** + * 获取 CurseForge 支持的 Game Version Type 列表。 + * GET /api/game/version-types + */ +export async function getVersionTypes( + client: CurseForgeClient, +): Promise { + const data = await client.uploadGet( + "/api/game/version-types", + ); + return GameVersionTypeSchema.array().parse(data); +} + +/** + * 获取 CurseForge 支持的 Game Version 列表。 + * 包含 Minecraft 版本、Mod Loader、Environment、Java 等。 + * 通过 gameVersionTypeID 区分类型。 + * GET /api/game/versions + */ +export async function getGameVersions( + client: CurseForgeClient, +): Promise { + const data = await client.uploadGet( + "/api/game/versions", + ); + return GameVersionSchema.array().parse(data); +} diff --git a/src/lib/curseforge/index.ts b/src/lib/curseforge/index.ts new file mode 100644 index 0000000..cdda887 --- /dev/null +++ b/src/lib/curseforge/index.ts @@ -0,0 +1,3 @@ +export { CurseForgeClient } from "./client"; +export { getVersionTypes, getGameVersions } from "./game"; +export { getFiles, uploadFile, updateFile } from "./file";