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:
comp500 2022-05-07 18:18:57 +01:00
parent 9ace015690
commit 0c5ff0b7bb
5 changed files with 173 additions and 181 deletions

View File

@ -176,11 +176,6 @@ func createModFile(modInfo modInfo, fileInfo modFileInfo, index *core.Index, opt
return err
}
u, err := core.ReencodeURL(fileInfo.DownloadURL)
if err != nil {
return err
}
hash, hashFormat := fileInfo.getBestHash()
var optional *core.ModOption
@ -196,7 +191,6 @@ func createModFile(modInfo modInfo, fileInfo modFileInfo, index *core.Index, opt
FileName: fileInfo.FileName,
Side: core.UniversalSide,
Download: core.ModDownload{
URL: u,
HashFormat: hashFormat,
Hash: hash,
},
@ -335,7 +329,7 @@ func (u cfUpdater) CheckUpdate(mods []core.Mod, mcVersion string, pack core.Pack
modIDs[i] = project.ProjectID
}
modInfosUnsorted, err := getModInfoMultiple(modIDs)
modInfosUnsorted, err := cfDefaultClient.getModInfoMultiple(modIDs)
if err != nil {
return nil, err
}
@ -420,22 +414,16 @@ func (u cfUpdater) DoUpdate(mods []*core.Mod, cachedState []interface{}) error {
fileInfoData := modState.fileInfo
if !modState.hasFileInfo {
var err error
fileInfoData, err = getFileInfo(modState.ID, modState.fileID)
fileInfoData, err = cfDefaultClient.getFileInfo(modState.ID, modState.fileID)
if err != nil {
return err
}
}
u, err := core.ReencodeURL(fileInfoData.DownloadURL)
if err != nil {
return err
}
v.FileName = fileInfoData.FileName
v.Name = modState.Name
hash, hashFormat := fileInfoData.getBestHash()
v.Download = core.ModDownload{
URL: u,
HashFormat: hashFormat,
Hash: hash,
}

View File

@ -61,7 +61,7 @@ var detectCmd = &cobra.Command{
}
fmt.Printf("Found %d files, submitting...\n", len(hashes))
res, err := getFingerprintInfo(hashes)
res, err := cfDefaultClient.getFingerprintInfo(hashes)
if err != nil {
fmt.Println(err)
return
@ -82,7 +82,7 @@ var detectCmd = &cobra.Command{
}
fmt.Println("Installing...")
for _, v := range res.ExactMatches {
modInfoData, err := getModInfo(v.ID)
modInfoData, err := cfDefaultClient.getModInfo(v.ID)
if err != nil {
fmt.Println(err)
os.Exit(1)

View File

@ -192,7 +192,7 @@ var importCmd = &cobra.Command{
fmt.Println("Querying Curse API for mod info...")
modInfos, err := getModInfoMultiple(modIDs)
modInfos, err := cfDefaultClient.getModInfoMultiple(modIDs)
if err != nil {
fmt.Printf("Failed to obtain mod information: %s\n", err)
os.Exit(1)
@ -236,16 +236,14 @@ var importCmd = &cobra.Command{
// 2nd pass: query files that weren't in the previous results
fmt.Println("Querying Curse API for file info...")
modFileInfos, err := getFileInfoMultiple(remainingFileIDs)
modFileInfos, err := cfDefaultClient.getFileInfoMultiple(remainingFileIDs)
if err != nil {
fmt.Printf("Failed to obtain mod file information: %s\n", err)
os.Exit(1)
}
for _, v := range modFileInfos {
for _, file := range v {
modFileInfosMap[file.ID] = file
}
modFileInfosMap[v.ID] = v
}
// 3rd pass: create mod files for every file

View File

@ -99,7 +99,7 @@ var installCmd = &cobra.Command{
}
if !modInfoObtained {
modInfoData, err = getModInfo(modID)
modInfoData, err = cfDefaultClient.getModInfo(modID)
if err != nil {
fmt.Println(err)
os.Exit(1)
@ -169,7 +169,7 @@ var installCmd = &cobra.Command{
}
depIDPendingQueue = depIDPendingQueue[:i]
depInfoData, err := getModInfoMultiple(depIDPendingQueue)
depInfoData, err := cfDefaultClient.getModInfoMultiple(depIDPendingQueue)
if err != nil {
fmt.Printf("Error retrieving dependency data: %s\n", err.Error())
}
@ -276,7 +276,7 @@ func searchCurseforgeInternal(args []string, mcVersion string, packLoaderType in
if len(viper.GetStringSlice("acceptable-game-versions")) > 0 {
filterGameVersion = ""
}
results, err := getSearch(searchTerm, filterGameVersion, packLoaderType)
results, err := cfDefaultClient.getSearch(searchTerm, filterGameVersion, packLoaderType)
if err != nil {
fmt.Println(err)
os.Exit(1)
@ -373,7 +373,7 @@ func getLatestFile(modInfoData modInfo, mcVersion string, fileID int, packLoader
}
}
fileInfoData, err := getFileInfo(modInfoData.ID, fileID)
fileInfoData, err := cfDefaultClient.getFileInfo(modInfoData.ID, fileID)
if err != nil {
return modFileInfo{}, err
}

View File

@ -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,107 +210,68 @@ 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
@ -242,21 +279,22 @@ type modFileInfo struct {
ID int `json:"id"`
FileName string `json:"fileName"`
FriendlyName string `json:"displayName"`
Date cfDateFormat `json:"fileDate"`
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&sectionId=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
}