1
0
mirror of https://github.com/packwiz/packwiz.git synced 2025-04-27 08:36:30 +02:00

468 lines
12 KiB
Go

package curseforge
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
)
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
}
//noinspection GoUnusedConst
const (
fileTypeRelease int = iota + 1
fileTypeBeta
fileTypeAlpha
)
//noinspection GoUnusedConst
const (
dependencyTypeEmbedded int = iota + 1
dependencyTypeOptional
dependencyTypeRequired
dependencyTypeTool
dependencyTypeIncompatible
dependencyTypeInclude
)
//noinspection GoUnusedConst
const (
// modloaderTypeAny should not be passed to the API - it does not work
modloaderTypeAny int = iota
modloaderTypeForge
modloaderTypeCauldron
modloaderTypeLiteloader
modloaderTypeFabric
modloaderTypeQuilt
)
var modloaderNames = [...]string{
"",
"Forge",
"Cauldron",
"Liteloader",
"Fabric",
"Quilt",
}
var modloaderIds = [...]string{
"",
"forge",
"cauldron",
"liteloader",
"fabric",
"quilt",
}
//noinspection GoUnusedConst
const (
hashAlgoSHA1 int = iota + 1
hashAlgoMD5
)
// modInfo is a subset of the deserialised JSON response from the Curse API for mods (addons)
type modInfo struct {
Name string `json:"name"`
Summary string `json:"summary"`
Slug string `json:"slug"`
ID int `json:"id"`
GameID uint32 `json:"gameId"`
PrimaryCategoryID uint32 `json:"primaryCategoryId"`
ClassID uint32 `json:"classId"`
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:"fileId"`
Name string `json:"filename"`
FileType int `json:"releaseType"`
Modloader int `json:"modLoader"`
} `json:"latestFilesIndexes"`
ModLoaders []string `json:"modLoaders"`
Links struct {
WebsiteURL string `json:"websiteUrl"`
} `json:"links"`
}
func (c *cfApiClient) getModInfo(modID int) (modInfo, error) {
var infoRes struct {
Data modInfo `json:"data"`
}
idStr := strconv.Itoa(modID)
resp, err := c.makeGet("/v1/mods/" + idStr)
if err != nil {
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{}, fmt.Errorf("failed to request addon data for ID %d: %w", modID, err)
}
if infoRes.Data.ID != modID {
return modInfo{}, fmt.Errorf("unexpected addon ID in CurseForge response: %d (expected %d)", infoRes.Data.ID, modID)
}
return infoRes.Data, nil
}
func (c *cfApiClient) getModInfoMultiple(modIDs []int) ([]modInfo, error) {
var infoRes struct {
Data []modInfo `json:"data"`
}
modIDsData, err := json.Marshal(struct {
ModIDs []int `json:"modIds"`
}{
ModIDs: modIDs,
})
if err != nil {
return []modInfo{}, err
}
resp, err := c.makePost("/v1/mods", bytes.NewBuffer(modIDsData))
if err != nil {
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{}, fmt.Errorf("failed to request addon data: %w", err)
}
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"`
ModID int `json:"modId"`
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:"gameVersions"`
Fingerprint int `json:"fileFingerprint"`
Dependencies []struct {
ModID int `json:"modId"`
Type int `json:"relationType"`
} `json:"dependencies"`
Hashes []struct {
Value string `json:"value"`
Algorithm int `json:"algo"`
} `json:"hashes"`
}
func (i modFileInfo) getBestHash() (hash string, hashFormat string) {
// TODO: check if the hash is invalid (e.g. 0)
hash = strconv.Itoa(i.Fingerprint)
hashFormat = "murmur2"
hashPreferred := 0
// Prefer SHA1, then MD5 if found:
if i.Hashes != nil {
for _, v := range i.Hashes {
if v.Algorithm == hashAlgoMD5 && hashPreferred < 1 {
hashPreferred = 1
hash = v.Value
hashFormat = "md5"
} else if v.Algorithm == hashAlgoSHA1 && hashPreferred < 2 {
hashPreferred = 2
hash = v.Value
hashFormat = "sha1"
}
}
}
return
}
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)
resp, err := c.makeGet("/v1/mods/" + modIDStr + "/files/" + fileIDStr)
if err != nil {
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{}, fmt.Errorf("failed to request file data for addon ID %d, file ID %d: %w", modID, fileID, err)
}
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.Data, nil
}
func (c *cfApiClient) getFileInfoMultiple(fileIDs []int) ([]modFileInfo, error) {
var infoRes struct {
Data []modFileInfo `json:"data"`
}
fileIDsData, err := json.Marshal(struct {
FileIDs []int `json:"fileIds"`
}{
FileIDs: fileIDs,
})
if err != nil {
return []modFileInfo{}, err
}
resp, err := c.makePost("/v1/mods/files", bytes.NewBuffer(fileIDsData))
if err != nil {
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 []modFileInfo{}, fmt.Errorf("failed to request file data: %w", err)
}
return infoRes.Data, nil
}
func (c *cfApiClient) getSearch(searchTerm string, slug string, gameID int, classID int, categoryID int, gameVersion string, modloaderType int) ([]modInfo, error) {
var infoRes struct {
Data []modInfo `json:"data"`
}
q := url.Values{}
q.Set("gameId", strconv.Itoa(gameID))
q.Set("pageSize", "10")
if classID != 0 {
q.Set("classId", strconv.Itoa(classID))
}
if slug != "" {
q.Set("slug", slug)
}
// If classID and slug are provided, don't bother filtering by anything else (should be unique)
if classID == 0 && slug == "" {
if categoryID != 0 {
q.Set("categoryId", strconv.Itoa(categoryID))
}
if searchTerm != "" {
q.Set("searchFilter", searchTerm)
}
if gameVersion != "" {
q.Set("gameVersion", gameVersion)
}
if modloaderType != modloaderTypeAny {
q.Set("modLoaderType", strconv.Itoa(modloaderType))
}
}
resp, err := c.makeGet("/v1/mods/search?" + q.Encode())
if err != nil {
return []modInfo{}, fmt.Errorf("failed to retrieve search results: %w", err)
}
err = json.NewDecoder(resp.Body).Decode(&infoRes)
if err != nil && err != io.EOF {
return []modInfo{}, fmt.Errorf("failed to parse search results: %w", err)
}
return infoRes.Data, nil
}
//noinspection GoUnusedConst
const (
gameStatusDraft int = iota + 1
gameStatusTest
gameStatusPendingReview
gameStatusRejected
gameStatusApproved
gameStatusLive
)
//noinspection GoUnusedConst
const (
gameApiStatusPrivate int = iota + 1
gameApiStatusPublic
)
type cfGame struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Status int `json:"status"`
APIStatus int `json:"apiStatus"`
}
func (c *cfApiClient) getGames() ([]cfGame, error) {
var infoRes struct {
Data []cfGame `json:"data"`
}
resp, err := c.makeGet("/v1/games")
if err != nil {
return []cfGame{}, fmt.Errorf("failed to retrieve game list: %w", err)
}
err = json.NewDecoder(resp.Body).Decode(&infoRes)
if err != nil && err != io.EOF {
return []cfGame{}, fmt.Errorf("failed to parse game list: %w", err)
}
return infoRes.Data, nil
}
type cfCategory struct {
ID int `json:"id"`
Slug string `json:"slug"`
IsClass bool `json:"isClass"`
ClassID int `json:"classId"`
}
func (c *cfApiClient) getCategories(gameID int) ([]cfCategory, error) {
var infoRes struct {
Data []cfCategory `json:"data"`
}
resp, err := c.makeGet("/v1/categories?gameId=" + strconv.Itoa(gameID))
if err != nil {
return []cfCategory{}, fmt.Errorf("failed to retrieve category list for game %v: %w", gameID, err)
}
err = json.NewDecoder(resp.Body).Decode(&infoRes)
if err != nil && err != io.EOF {
return []cfCategory{}, fmt.Errorf("failed to parse category list for game %v: %w", gameID, err)
}
return infoRes.Data, nil
}
type addonFingerprintResponse struct {
IsCacheBuilt bool `json:"isCacheBuilt"`
ExactMatches []struct {
ID int `json:"id"`
File modFileInfo `json:"file"`
LatestFiles []modFileInfo `json:"latestFiles"`
} `json:"exactMatches"`
ExactFingerprints []int `json:"exactFingerprints"`
PartialMatches []int `json:"partialMatches"`
PartialMatchFingerprints struct{} `json:"partialMatchFingerprints"`
InstalledFingerprints []int `json:"installedFingerprints"`
UnmatchedFingerprints []int `json:"unmatchedFingerprints"`
}
func (c *cfApiClient) getFingerprintInfo(hashes []int) (addonFingerprintResponse, error) {
var infoRes struct {
Data addonFingerprintResponse `json:"data"`
}
hashesData, err := json.Marshal(struct {
Fingerprints []int `json:"fingerprints"`
}{
Fingerprints: hashes,
})
if err != nil {
return addonFingerprintResponse{}, err
}
resp, err := c.makePost("/v1/fingerprints", bytes.NewBuffer(hashesData))
if err != nil {
return addonFingerprintResponse{}, fmt.Errorf("failed to retrieve fingerprint results: %w", err)
}
err = json.NewDecoder(resp.Body).Decode(&infoRes)
if err != nil && err != io.EOF {
return addonFingerprintResponse{}, err
}
return infoRes.Data, nil
}