mirror of
				https://github.com/packwiz/packwiz.git
				synced 2025-11-04 12:04:31 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			443 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			443 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
package curseforge
 | 
						|
 | 
						|
import (
 | 
						|
	"bytes"
 | 
						|
	"encoding/json"
 | 
						|
	"errors"
 | 
						|
	"fmt"
 | 
						|
	"io"
 | 
						|
	"net/http"
 | 
						|
	"net/url"
 | 
						|
	"strconv"
 | 
						|
	"strings"
 | 
						|
	"time"
 | 
						|
)
 | 
						|
 | 
						|
// addonSlugRequest is sent to the CurseProxy GraphQL api to get the id from a slug
 | 
						|
type addonSlugRequest struct {
 | 
						|
	Query     string `json:"query"`
 | 
						|
	Variables struct {
 | 
						|
		Slug string `json:"slug"`
 | 
						|
	} `json:"variables"`
 | 
						|
	OperationName string `json:"operationName"`
 | 
						|
}
 | 
						|
 | 
						|
// addonSlugResponse is received from the CurseProxy GraphQL api to get the id from a slug
 | 
						|
type addonSlugResponse struct {
 | 
						|
	Data struct {
 | 
						|
		Addons []struct {
 | 
						|
			ID              int `json:"id"`
 | 
						|
			CategorySection struct {
 | 
						|
				ID int `json:"id"`
 | 
						|
			} `json:"categorySection"`
 | 
						|
		} `json:"addons"`
 | 
						|
	} `json:"data"`
 | 
						|
	Exception  string   `json:"exception"`
 | 
						|
	Message    string   `json:"message"`
 | 
						|
	Stacktrace []string `json:"stacktrace"`
 | 
						|
}
 | 
						|
 | 
						|
// Most of this is shamelessly copied from my previous attempt at modpack management:
 | 
						|
// https://github.com/comp500/modpack-editor/blob/master/query.go
 | 
						|
func modIDFromSlug(slug string) (int, error) {
 | 
						|
	request := addonSlugRequest{
 | 
						|
		Query: `
 | 
						|
		query getIDFromSlug($slug: String) {
 | 
						|
			addons(slug: $slug) {
 | 
						|
				id
 | 
						|
				categorySection {
 | 
						|
					id
 | 
						|
				}
 | 
						|
			}
 | 
						|
		}
 | 
						|
		`,
 | 
						|
		OperationName: "getIDFromSlug",
 | 
						|
	}
 | 
						|
	request.Variables.Slug = slug
 | 
						|
 | 
						|
	// Uses the curse.nikky.moe GraphQL api
 | 
						|
	var response addonSlugResponse
 | 
						|
	client := &http.Client{}
 | 
						|
 | 
						|
	requestBytes, err := json.Marshal(request)
 | 
						|
	if err != nil {
 | 
						|
		return 0, err
 | 
						|
	}
 | 
						|
 | 
						|
	req, err := http.NewRequest("POST", "https://curse.nikky.moe/graphql", bytes.NewBuffer(requestBytes))
 | 
						|
	if err != nil {
 | 
						|
		return 0, 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 0, err
 | 
						|
	}
 | 
						|
 | 
						|
	err = json.NewDecoder(resp.Body).Decode(&response)
 | 
						|
	if err != nil && err != io.EOF {
 | 
						|
		return 0, err
 | 
						|
	}
 | 
						|
 | 
						|
	if len(response.Exception) > 0 || len(response.Message) > 0 {
 | 
						|
		return 0, fmt.Errorf("error requesting id for slug: %s", response.Message)
 | 
						|
	}
 | 
						|
 | 
						|
	for _, addonData := range response.Data.Addons {
 | 
						|
		// Only use mods, not resource packs/modpacks
 | 
						|
		if addonData.CategorySection.ID == 8 {
 | 
						|
			return addonData.ID, nil
 | 
						|
		}
 | 
						|
	}
 | 
						|
	return 0, errors.New("addon not found")
 | 
						|
}
 | 
						|
 | 
						|
//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
 | 
						|
)
 | 
						|
 | 
						|
//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"`
 | 
						|
	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"`
 | 
						|
		Modloader   int    `json:"modLoader"`
 | 
						|
	} `json:"gameVersionLatestFiles"`
 | 
						|
	ModLoaders []string `json:"modLoaders"`
 | 
						|
}
 | 
						|
 | 
						|
func getModInfo(modID int) (modInfo, error) {
 | 
						|
	var infoRes modInfo
 | 
						|
	client := &http.Client{}
 | 
						|
 | 
						|
	idStr := strconv.Itoa(modID)
 | 
						|
 | 
						|
	req, err := http.NewRequest("GET", "https://addons-ecs.forgesvc.net/api/v2/addon/"+idStr, nil)
 | 
						|
	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)
 | 
						|
	}
 | 
						|
 | 
						|
	err = json.NewDecoder(resp.Body).Decode(&infoRes)
 | 
						|
	if err != nil && err != io.EOF {
 | 
						|
		return modInfo{}, err
 | 
						|
	}
 | 
						|
 | 
						|
	if infoRes.ID != modID {
 | 
						|
		return modInfo{}, fmt.Errorf("unexpected addon ID in CurseForge response: %d (expected %d)", infoRes.ID, modID)
 | 
						|
	}
 | 
						|
 | 
						|
	return infoRes, nil
 | 
						|
}
 | 
						|
 | 
						|
func getModInfoMultiple(modIDs []int) ([]modInfo, error) {
 | 
						|
	var infoRes []modInfo
 | 
						|
	client := &http.Client{}
 | 
						|
 | 
						|
	modIDsData, err := json.Marshal(modIDs)
 | 
						|
	if err != nil {
 | 
						|
		return []modInfo{}, err
 | 
						|
	}
 | 
						|
 | 
						|
	req, err := http.NewRequest("POST", "https://addons-ecs.forgesvc.net/api/v2/addon/", 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
 | 
						|
	}
 | 
						|
 | 
						|
	err = json.NewDecoder(resp.Body).Decode(&infoRes)
 | 
						|
	if err != nil && err != io.EOF {
 | 
						|
		return []modInfo{}, 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
 | 
						|
}
 | 
						|
 | 
						|
// 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"`
 | 
						|
	// fileStatus? means latest/preferred?
 | 
						|
	DownloadURL  string   `json:"downloadUrl"`
 | 
						|
	GameVersions []string `json:"gameVersion"`
 | 
						|
	Fingerprint  int      `json:"packageFingerprint"`
 | 
						|
	Dependencies []struct {
 | 
						|
		ModID int `json:"addonId"`
 | 
						|
		Type  int `json:"type"`
 | 
						|
	} `json:"dependencies"`
 | 
						|
 | 
						|
	Hashes []struct {
 | 
						|
		Value     string `json:"value"`
 | 
						|
		Algorithm int    `json:"algorithm"`
 | 
						|
	} `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 getFileInfo(modID int, fileID int) (modFileInfo, error) {
 | 
						|
	var infoRes modFileInfo
 | 
						|
	client := &http.Client{}
 | 
						|
 | 
						|
	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)
 | 
						|
	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)
 | 
						|
	}
 | 
						|
 | 
						|
	err = json.NewDecoder(resp.Body).Decode(&infoRes)
 | 
						|
	if err != nil && err != io.EOF {
 | 
						|
		return modFileInfo{}, 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)
 | 
						|
	}
 | 
						|
 | 
						|
	return infoRes, 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
 | 
						|
	}
 | 
						|
 | 
						|
	req, err := http.NewRequest("POST", "https://addons-ecs.forgesvc.net/api/v2/addon/files", bytes.NewBuffer(modIDsData))
 | 
						|
	if err != nil {
 | 
						|
		return make(map[string][]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)
 | 
						|
	if err != nil {
 | 
						|
		return make(map[string][]modFileInfo), err
 | 
						|
	}
 | 
						|
 | 
						|
	err = json.NewDecoder(resp.Body).Decode(&infoRes)
 | 
						|
	if err != nil && err != io.EOF {
 | 
						|
		return make(map[string][]modFileInfo), err
 | 
						|
	}
 | 
						|
 | 
						|
	return infoRes, 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
 | 
						|
	}
 | 
						|
	q := reqURL.Query()
 | 
						|
	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)
 | 
						|
	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
 | 
						|
	}
 | 
						|
 | 
						|
	err = json.NewDecoder(resp.Body).Decode(&infoRes)
 | 
						|
	if err != nil && err != io.EOF {
 | 
						|
		return []modInfo{}, err
 | 
						|
	}
 | 
						|
 | 
						|
	return infoRes, 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 getFingerprintInfo(hashes []int) (addonFingerprintResponse, error) {
 | 
						|
	var infoRes addonFingerprintResponse
 | 
						|
	client := &http.Client{}
 | 
						|
 | 
						|
	hashesData, err := json.Marshal(hashes)
 | 
						|
	if err != nil {
 | 
						|
		return addonFingerprintResponse{}, err
 | 
						|
	}
 | 
						|
 | 
						|
	req, err := http.NewRequest("POST", "https://addons-ecs.forgesvc.net/api/v2/fingerprint", 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
 | 
						|
	}
 | 
						|
 | 
						|
	err = json.NewDecoder(resp.Body).Decode(&infoRes)
 | 
						|
	if err != nil && err != io.EOF {
 | 
						|
		return addonFingerprintResponse{}, err
 | 
						|
	}
 | 
						|
 | 
						|
	return infoRes, nil
 | 
						|
}
 |