mirror of
https://github.com/packwiz/packwiz.git
synced 2025-11-19 01:24:32 +01:00
Change backend request code to use new CurseForge API (WIP)
See the packwiz Discord for more information, as the changes with the new API Terms and Conditions have some implications for packwiz. This commit isn't fully functional yet; I have more changes to make.
This commit is contained in:
@@ -2,6 +2,7 @@ package curseforge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -9,10 +10,84 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TODO: update everything for no URL and download mode "metadata:curseforge"
|
||||
|
||||
const cfApiServer = "api.curseforge.com"
|
||||
|
||||
// If you fork/derive from packwiz, I request that you obtain your own API key.
|
||||
const cfApiKeyDefault = "JDJhJDEwJHNBWVhqblU1N0EzSmpzcmJYM3JVdk92UWk2NHBLS3BnQ2VpbGc1TUM1UGNKL0RYTmlGWWxh"
|
||||
|
||||
// Exists so you can provide it as a build parameter: -ldflags="-X 'github.com/packwiz/packwiz/curseforge.cfApiKey=key'"
|
||||
var cfApiKey = ""
|
||||
|
||||
func decodeDefaultKey() string {
|
||||
k, err := base64.StdEncoding.DecodeString(cfApiKeyDefault)
|
||||
if err != nil {
|
||||
panic("failed to read API key!")
|
||||
}
|
||||
return string(k)
|
||||
}
|
||||
|
||||
type cfApiClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var cfDefaultClient = cfApiClient{&http.Client{}}
|
||||
|
||||
func (c *cfApiClient) makeGet(endpoint string) (*http.Response, error) {
|
||||
req, err := http.NewRequest("GET", "https://"+cfApiServer+endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO: make this configurable application-wide
|
||||
req.Header.Set("User-Agent", "packwiz/packwiz client")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if cfApiKey == "" {
|
||||
cfApiKey = decodeDefaultKey()
|
||||
}
|
||||
req.Header.Set("X-API-Key", cfApiKey)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("invalid response status: %v", resp.Status)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *cfApiClient) makePost(endpoint string, body io.Reader) (*http.Response, error) {
|
||||
req, err := http.NewRequest("POST", "https://"+cfApiServer+endpoint, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO: make this configurable application-wide
|
||||
req.Header.Set("User-Agent", "packwiz/packwiz client")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if cfApiKey == "" {
|
||||
cfApiKey = decodeDefaultKey()
|
||||
}
|
||||
req.Header.Set("X-API-Key", cfApiKey)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("invalid response status: %v", resp.Status)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// addonSlugRequest is sent to the CurseProxy GraphQL api to get the id from a slug
|
||||
type addonSlugRequest struct {
|
||||
Query string `json:"query"`
|
||||
@@ -64,6 +139,7 @@ func modIDFromSlug(slug string) (int, error) {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// TODO: move to new slug API
|
||||
req, err := http.NewRequest("POST", "https://curse.nikky.moe/graphql", bytes.NewBuffer(requestBytes))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -134,129 +210,91 @@ const (
|
||||
type modInfo struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
WebsiteURL string `json:"websiteUrl"`
|
||||
ID int `json:"id"`
|
||||
LatestFiles []modFileInfo `json:"latestFiles"`
|
||||
GameVersionLatestFiles []struct {
|
||||
// TODO: check how twitch launcher chooses which one to use, when you are on beta/alpha channel?!
|
||||
// or does it not have the concept of release channels?!
|
||||
GameVersion string `json:"gameVersion"`
|
||||
ID int `json:"projectFileId"`
|
||||
Name string `json:"projectFileName"`
|
||||
FileType int `json:"fileType"`
|
||||
ID int `json:"fileId"`
|
||||
Name string `json:"filename"`
|
||||
FileType int `json:"releaseType"`
|
||||
Modloader int `json:"modLoader"`
|
||||
} `json:"gameVersionLatestFiles"`
|
||||
} `json:"latestFilesIndexes"`
|
||||
ModLoaders []string `json:"modLoaders"`
|
||||
}
|
||||
|
||||
func getModInfo(modID int) (modInfo, error) {
|
||||
var infoRes modInfo
|
||||
client := &http.Client{}
|
||||
func (c *cfApiClient) getModInfo(modID int) (modInfo, error) {
|
||||
var infoRes struct {
|
||||
Data modInfo `json:"data"`
|
||||
}
|
||||
|
||||
idStr := strconv.Itoa(modID)
|
||||
|
||||
req, err := http.NewRequest("GET", "https://addons-ecs.forgesvc.net/api/v2/addon/"+idStr, nil)
|
||||
resp, err := c.makeGet("/v1/mods/" + idStr)
|
||||
if err != nil {
|
||||
return modInfo{}, err
|
||||
}
|
||||
|
||||
// TODO: make this configurable application-wide
|
||||
req.Header.Set("User-Agent", "packwiz/packwiz client")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return modInfo{}, err
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return modInfo{}, fmt.Errorf("failed to request addon ID %d: %s", modID, resp.Status)
|
||||
return modInfo{}, fmt.Errorf("failed to request addon data for ID %d: %w", modID, err)
|
||||
}
|
||||
|
||||
err = json.NewDecoder(resp.Body).Decode(&infoRes)
|
||||
if err != nil && err != io.EOF {
|
||||
return modInfo{}, err
|
||||
return modInfo{}, fmt.Errorf("failed to request addon data for ID %d: %w", modID, err)
|
||||
}
|
||||
|
||||
if infoRes.ID != modID {
|
||||
return modInfo{}, fmt.Errorf("unexpected addon ID in CurseForge response: %d (expected %d)", infoRes.ID, modID)
|
||||
if infoRes.Data.ID != modID {
|
||||
return modInfo{}, fmt.Errorf("unexpected addon ID in CurseForge response: %d (expected %d)", infoRes.Data.ID, modID)
|
||||
}
|
||||
|
||||
return infoRes, nil
|
||||
return infoRes.Data, nil
|
||||
}
|
||||
|
||||
func getModInfoMultiple(modIDs []int) ([]modInfo, error) {
|
||||
var infoRes []modInfo
|
||||
client := &http.Client{}
|
||||
func (c *cfApiClient) getModInfoMultiple(modIDs []int) ([]modInfo, error) {
|
||||
var infoRes struct {
|
||||
Data []modInfo `json:"data"`
|
||||
}
|
||||
|
||||
modIDsData, err := json.Marshal(modIDs)
|
||||
modIDsData, err := json.Marshal(struct {
|
||||
ModIDs []int `json:"modIds"`
|
||||
}{
|
||||
ModIDs: modIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return []modInfo{}, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", "https://addons-ecs.forgesvc.net/api/v2/addon/", bytes.NewBuffer(modIDsData))
|
||||
resp, err := c.makePost("/v1/mods", bytes.NewBuffer(modIDsData))
|
||||
if err != nil {
|
||||
return []modInfo{}, err
|
||||
}
|
||||
|
||||
// TODO: make this configurable application-wide
|
||||
req.Header.Set("User-Agent", "packwiz/packwiz client")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return []modInfo{}, err
|
||||
return []modInfo{}, fmt.Errorf("failed to request addon data: %w", err)
|
||||
}
|
||||
|
||||
err = json.NewDecoder(resp.Body).Decode(&infoRes)
|
||||
if err != nil && err != io.EOF {
|
||||
return []modInfo{}, err
|
||||
return []modInfo{}, fmt.Errorf("failed to request addon data: %w", err)
|
||||
}
|
||||
|
||||
return infoRes, nil
|
||||
}
|
||||
|
||||
const cfDateFormatString = "2006-01-02T15:04:05.999"
|
||||
|
||||
type cfDateFormat struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
// Curse switched to proper RFC3339, but previously downloaded metadata still uses the old format :(
|
||||
func (f *cfDateFormat) UnmarshalJSON(input []byte) error {
|
||||
trimmed := strings.Trim(string(input), `"`)
|
||||
timeValue, err := time.Parse(time.RFC3339Nano, trimmed)
|
||||
if err != nil {
|
||||
timeValue, err = time.Parse(cfDateFormatString, trimmed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
f.Time = timeValue
|
||||
return nil
|
||||
return infoRes.Data, nil
|
||||
}
|
||||
|
||||
// modFileInfo is a subset of the deserialised JSON response from the Curse API for mod files
|
||||
type modFileInfo struct {
|
||||
ID int `json:"id"`
|
||||
FileName string `json:"fileName"`
|
||||
FriendlyName string `json:"displayName"`
|
||||
Date cfDateFormat `json:"fileDate"`
|
||||
Length int `json:"fileLength"`
|
||||
FileType int `json:"releaseType"`
|
||||
ID int `json:"id"`
|
||||
FileName string `json:"fileName"`
|
||||
FriendlyName string `json:"displayName"`
|
||||
Date time.Time `json:"fileDate"`
|
||||
Length int `json:"fileLength"`
|
||||
FileType int `json:"releaseType"`
|
||||
// fileStatus? means latest/preferred?
|
||||
// According to the CurseForge API T&Cs, this must not be saved or cached
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
GameVersions []string `json:"gameVersion"`
|
||||
Fingerprint int `json:"packageFingerprint"`
|
||||
GameVersions []string `json:"gameVersions"`
|
||||
Fingerprint int `json:"fileFingerprint"`
|
||||
Dependencies []struct {
|
||||
ModID int `json:"addonId"`
|
||||
Type int `json:"type"`
|
||||
ModID int `json:"modId"`
|
||||
Type int `json:"relationType"`
|
||||
} `json:"dependencies"`
|
||||
|
||||
Hashes []struct {
|
||||
Value string `json:"value"`
|
||||
Algorithm int `json:"algorithm"`
|
||||
Algorithm int `json:"algo"`
|
||||
} `json:"hashes"`
|
||||
}
|
||||
|
||||
@@ -286,105 +324,78 @@ func (i modFileInfo) getBestHash() (hash string, hashFormat string) {
|
||||
return
|
||||
}
|
||||
|
||||
func getFileInfo(modID int, fileID int) (modFileInfo, error) {
|
||||
var infoRes modFileInfo
|
||||
client := &http.Client{}
|
||||
func (c *cfApiClient) getFileInfo(modID int, fileID int) (modFileInfo, error) {
|
||||
var infoRes struct {
|
||||
Data modFileInfo `json:"data"`
|
||||
}
|
||||
|
||||
modIDStr := strconv.Itoa(modID)
|
||||
fileIDStr := strconv.Itoa(fileID)
|
||||
|
||||
req, err := http.NewRequest("GET", "https://addons-ecs.forgesvc.net/api/v2/addon/"+modIDStr+"/file/"+fileIDStr, nil)
|
||||
resp, err := c.makeGet("/v1/mods/" + modIDStr + "/files/" + fileIDStr)
|
||||
if err != nil {
|
||||
return modFileInfo{}, err
|
||||
}
|
||||
|
||||
// TODO: make this configurable application-wide
|
||||
req.Header.Set("User-Agent", "packwiz/packwiz client")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return modFileInfo{}, err
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return modFileInfo{}, fmt.Errorf("failed to request file ID %d for addon %d: %s", fileID, modID, resp.Status)
|
||||
return modFileInfo{}, fmt.Errorf("failed to request file data for addon ID %d, file ID %d: %w", modID, fileID, err)
|
||||
}
|
||||
|
||||
err = json.NewDecoder(resp.Body).Decode(&infoRes)
|
||||
if err != nil && err != io.EOF {
|
||||
return modFileInfo{}, err
|
||||
return modFileInfo{}, fmt.Errorf("failed to request file data for addon ID %d, file ID %d: %w", modID, fileID, err)
|
||||
}
|
||||
|
||||
if infoRes.ID != fileID {
|
||||
return modFileInfo{}, fmt.Errorf("unexpected file ID for addon %d in CurseForge response: %d (expected %d)", modID, infoRes.ID, fileID)
|
||||
if infoRes.Data.ID != fileID {
|
||||
return modFileInfo{}, fmt.Errorf("unexpected file ID for addon %d in CurseForge response: %d (expected %d)", modID, infoRes.Data.ID, fileID)
|
||||
}
|
||||
|
||||
return infoRes, nil
|
||||
return infoRes.Data, nil
|
||||
}
|
||||
|
||||
func getFileInfoMultiple(fileIDs []int) (map[string][]modFileInfo, error) {
|
||||
var infoRes map[string][]modFileInfo
|
||||
client := &http.Client{}
|
||||
|
||||
modIDsData, err := json.Marshal(fileIDs)
|
||||
if err != nil {
|
||||
return make(map[string][]modFileInfo), err
|
||||
func (c *cfApiClient) getFileInfoMultiple(fileIDs []int) ([]modFileInfo, error) {
|
||||
var infoRes struct {
|
||||
Data []modFileInfo `json:"data"`
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", "https://addons-ecs.forgesvc.net/api/v2/addon/files", bytes.NewBuffer(modIDsData))
|
||||
fileIDsData, err := json.Marshal(struct {
|
||||
FileIDs []int `json:"fileIds"`
|
||||
}{
|
||||
FileIDs: fileIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return make(map[string][]modFileInfo), err
|
||||
return []modFileInfo{}, err
|
||||
}
|
||||
|
||||
// TODO: make this configurable application-wide
|
||||
req.Header.Set("User-Agent", "packwiz/packwiz client")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
resp, err := c.makePost("/v1/mods/files", bytes.NewBuffer(fileIDsData))
|
||||
if err != nil {
|
||||
return make(map[string][]modFileInfo), err
|
||||
return []modFileInfo{}, fmt.Errorf("failed to request file data: %w", err)
|
||||
}
|
||||
|
||||
err = json.NewDecoder(resp.Body).Decode(&infoRes)
|
||||
if err != nil && err != io.EOF {
|
||||
return make(map[string][]modFileInfo), err
|
||||
return []modFileInfo{}, fmt.Errorf("failed to request file data: %w", err)
|
||||
}
|
||||
|
||||
return infoRes, nil
|
||||
return infoRes.Data, nil
|
||||
}
|
||||
|
||||
func getSearch(searchText string, gameVersion string, modloaderType int) ([]modInfo, error) {
|
||||
var infoRes []modInfo
|
||||
client := &http.Client{}
|
||||
|
||||
reqURL, err := url.Parse("https://addons-ecs.forgesvc.net/api/v2/addon/search?gameId=432&pageSize=10&categoryId=0§ionId=6")
|
||||
if err != nil {
|
||||
return []modInfo{}, err
|
||||
func (c *cfApiClient) getSearch(searchText string, gameVersion string, modloaderType int) ([]modInfo, error) {
|
||||
var infoRes struct {
|
||||
Data []modInfo `json:"data"`
|
||||
}
|
||||
q := reqURL.Query()
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("gameId", "432") // Minecraft
|
||||
q.Set("pageSize", "10")
|
||||
q.Set("classId", "6") // Mods
|
||||
q.Set("searchFilter", searchText)
|
||||
|
||||
if len(gameVersion) > 0 {
|
||||
q.Set("gameVersion", gameVersion)
|
||||
}
|
||||
if modloaderType != modloaderTypeAny {
|
||||
q.Set("modLoaderType", strconv.Itoa(modloaderType))
|
||||
}
|
||||
reqURL.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL.String(), nil)
|
||||
resp, err := c.makeGet("/v1/mods/search?" + q.Encode())
|
||||
if err != nil {
|
||||
return []modInfo{}, err
|
||||
}
|
||||
|
||||
// TODO: make this configurable application-wide
|
||||
req.Header.Set("User-Agent", "packwiz/packwiz client")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return []modInfo{}, err
|
||||
return []modInfo{}, fmt.Errorf("failed to retrieve search results: %w", err)
|
||||
}
|
||||
|
||||
err = json.NewDecoder(resp.Body).Decode(&infoRes)
|
||||
@@ -392,7 +403,7 @@ func getSearch(searchText string, gameVersion string, modloaderType int) ([]modI
|
||||
return []modInfo{}, err
|
||||
}
|
||||
|
||||
return infoRes, nil
|
||||
return infoRes.Data, nil
|
||||
}
|
||||
|
||||
type addonFingerprintResponse struct {
|
||||
@@ -409,28 +420,23 @@ type addonFingerprintResponse struct {
|
||||
UnmatchedFingerprints []int `json:"unmatchedFingerprints"`
|
||||
}
|
||||
|
||||
func getFingerprintInfo(hashes []int) (addonFingerprintResponse, error) {
|
||||
var infoRes addonFingerprintResponse
|
||||
client := &http.Client{}
|
||||
func (c *cfApiClient) getFingerprintInfo(hashes []int) (addonFingerprintResponse, error) {
|
||||
var infoRes struct {
|
||||
Data addonFingerprintResponse `json:"data"`
|
||||
}
|
||||
|
||||
hashesData, err := json.Marshal(hashes)
|
||||
hashesData, err := json.Marshal(struct {
|
||||
Fingerprints []int `json:"fingerprints"`
|
||||
}{
|
||||
Fingerprints: hashes,
|
||||
})
|
||||
if err != nil {
|
||||
return addonFingerprintResponse{}, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", "https://addons-ecs.forgesvc.net/api/v2/fingerprint", bytes.NewBuffer(hashesData))
|
||||
resp, err := c.makePost("/v1/fingerprints", bytes.NewBuffer(hashesData))
|
||||
if err != nil {
|
||||
return addonFingerprintResponse{}, err
|
||||
}
|
||||
|
||||
// TODO: make this configurable application-wide
|
||||
req.Header.Set("User-Agent", "packwiz/packwiz client")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return addonFingerprintResponse{}, err
|
||||
return addonFingerprintResponse{}, fmt.Errorf("failed to retrieve fingerprint results: %w", err)
|
||||
}
|
||||
|
||||
err = json.NewDecoder(resp.Body).Decode(&infoRes)
|
||||
@@ -438,5 +444,5 @@ func getFingerprintInfo(hashes []int) (addonFingerprintResponse, error) {
|
||||
return addonFingerprintResponse{}, err
|
||||
}
|
||||
|
||||
return infoRes, nil
|
||||
return infoRes.Data, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user