mirror of
https://github.com/packwiz/packwiz.git
synced 2025-04-26 16:16:31 +02:00
The mods-folder option is now replaced with two new options: meta-folder and meta-folder-base This allows non-mod files to use the correct directory based on their category; with correct import of resource packs/etc from CurseForge packs, and the ability to override this behaviour. To improve the reliability of packwiz metadata file marking (in the index), new files now use .pw.toml as the extension - any extension can be used, but .pw.toml will now be automatically be marked as a metafile regardless of folder, so you can easily move metadata files around. Existing metadata files will still work (as metafile = true is set in the index); though in the future .pw.toml may be required.
455 lines
12 KiB
Go
455 lines
12 KiB
Go
package curseforge
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"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
|
|
}
|
|
|
|
//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
|
|
)
|
|
|
|
var modloaderNames = [...]string{
|
|
"",
|
|
"Forge",
|
|
"Cauldron",
|
|
"Liteloader",
|
|
"Fabric",
|
|
}
|
|
|
|
//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"`
|
|
}
|
|
|
|
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"`
|
|
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
|
|
}
|