package curseforge import ( "errors" "fmt" "github.com/spf13/viper" "github.com/unascribed/FlexVer/go/flexver" "golang.org/x/exp/slices" "io" "path/filepath" "regexp" "strconv" "strings" "github.com/mitchellh/mapstructure" "github.com/packwiz/packwiz/cmd" "github.com/packwiz/packwiz/core" "github.com/spf13/cobra" ) var curseforgeCmd = &cobra.Command{ Use: "curseforge", Aliases: []string{"cf", "curse"}, Short: "Manage curseforge-based mods", } func init() { cmd.Add(curseforgeCmd) core.Updaters["curseforge"] = cfUpdater{} core.MetaDownloaders["curseforge"] = cfDownloader{} } var snapshotVersionRegex = regexp.MustCompile(`(?:Snapshot )?(\d+)w0?(0|[1-9]\d*)([a-z])`) var snapshotNames = [...]string{"-pre", " Pre-Release ", " Pre-release ", "-rc"} func getCurseforgeVersion(mcVersion string) string { for _, name := range snapshotNames { index := strings.Index(mcVersion, name) if index > -1 { return mcVersion[:index] + "-Snapshot" } } matches := snapshotVersionRegex.FindStringSubmatch(mcVersion) if matches == nil { return mcVersion } year, err := strconv.Atoi(matches[1]) if err != nil { return mcVersion } week, err := strconv.Atoi(matches[2]) if err != nil { return mcVersion } if year >= 22 && week >= 11 { return "1.19-Snapshot" } else if year == 21 && week >= 37 || year >= 22 { return "1.18-Snapshot" } else if year == 20 && week >= 45 || year == 21 && week <= 20 { return "1.17-Snapshot" } else if year == 20 && week >= 6 { return "1.16-Snapshot" } else if year == 19 && week >= 34 { return "1.15-Snapshot" } else if year == 18 && week >= 43 || year == 19 && week <= 14 { return "1.14-Snapshot" } else if year == 18 && week >= 30 && week <= 33 { return "1.13.1-Snapshot" } else if year == 17 && week >= 43 || year == 18 && week <= 22 { return "1.13-Snapshot" } else if year == 17 && week == 31 { return "1.12.1-Snapshot" } else if year == 17 && week >= 6 && week <= 18 { return "1.12-Snapshot" } else if year == 16 && week == 50 { return "1.11.1-Snapshot" } else if year == 16 && week >= 32 && week <= 44 { return "1.11-Snapshot" } else if year == 16 && week >= 20 && week <= 21 { return "1.10-Snapshot" } else if year == 16 && week >= 14 && week <= 15 { return "1.9.3-Snapshot" } else if year == 15 && week >= 31 || year == 16 && week <= 7 { return "1.9-Snapshot" } else if year == 14 && week >= 2 && week <= 34 { return "1.8-Snapshot" } else if year == 13 && week >= 47 && week <= 49 { return "1.7.4-Snapshot" } else if year == 13 && week >= 36 && week <= 43 { return "1.7.2-Snapshot" } else if year == 13 && week >= 16 && week <= 26 { return "1.6-Snapshot" } else if year == 13 && week >= 11 && week <= 12 { return "1.5.1-Snapshot" } else if year == 13 && week >= 1 && week <= 10 { return "1.5-Snapshot" } else if year == 12 && week >= 49 && week <= 50 { return "1.4.6-Snapshot" } else if year == 12 && week >= 32 && week <= 42 { return "1.4.2-Snapshot" } else if year == 12 && week >= 15 && week <= 30 { return "1.3.1-Snapshot" } else if year == 12 && week >= 3 && week <= 8 { return "1.2.1-Snapshot" } else if year == 11 && week >= 47 || year == 12 && week <= 1 { return "1.1-Snapshot" } return mcVersion } func getCurseforgeVersions(mcVersions []string) []string { out := make([]string, len(mcVersions)) for i, v := range mcVersions { out[i] = getCurseforgeVersion(v) } return out } var urlRegexes = [...]*regexp.Regexp{ regexp.MustCompile(`^https?://(?Pminecraft)\.curseforge\.com/projects/(?P[^/]+)(?:/(?:files|download)/(?P\d+))?`), regexp.MustCompile(`^https?://(?:www\.|beta\.|legacy\.)?curseforge\.com/(?P[^/]+)/(?P[^/]+)/(?P[^/]+)(?:/(?:files|download)/(?P\d+))?`), regexp.MustCompile(`^(?P[a-z][\da-z\-_]{0,127})$`), } func parseSlugOrUrl(url string) (game string, category string, slug string, fileID uint32, err error) { for _, r := range urlRegexes { matches := r.FindStringSubmatch(url) if matches != nil { if i := r.SubexpIndex("game"); i >= 0 { game = matches[i] } if i := r.SubexpIndex("category"); i >= 0 { category = matches[i] } if i := r.SubexpIndex("slug"); i >= 0 { slug = matches[i] } if i := r.SubexpIndex("fileID"); i >= 0 { if matches[i] != "" { var f uint64 f, err = strconv.ParseUint(matches[i], 10, 32) fileID = uint32(f) } } return } } return } var defaultFolders = map[uint32]map[uint32]string{ 432: { // Minecraft 5: "plugins", // Bukkit Plugins 12: "resourcepacks", 6: "mods", 17: "saves", }, } func getPathForFile(gameID uint32, classID uint32, categoryID uint32, slug string) string { metaFolder := viper.GetString("meta-folder") if metaFolder == "" { if m, ok := defaultFolders[gameID]; ok { if folder, ok := m[classID]; ok { return filepath.Join(viper.GetString("meta-folder-base"), folder, slug+core.MetaExtension) } else if folder, ok := m[categoryID]; ok { return filepath.Join(viper.GetString("meta-folder-base"), folder, slug+core.MetaExtension) } } metaFolder = "." } return filepath.Join(viper.GetString("meta-folder-base"), metaFolder, slug+core.MetaExtension) } func createModFile(modInfo modInfo, fileInfo modFileInfo, index *core.Index, optionalDisabled bool) error { updateMap := make(map[string]map[string]interface{}) var err error updateMap["curseforge"], err = cfUpdateData{ ProjectID: modInfo.ID, FileID: fileInfo.ID, }.ToMap() if err != nil { return err } hash, hashFormat := fileInfo.getBestHash() var optional *core.ModOption if optionalDisabled { optional = &core.ModOption{ Optional: true, Default: false, } } modMeta := core.Mod{ Name: modInfo.Name, FileName: fileInfo.FileName, Side: core.UniversalSide, Download: core.ModDownload{ HashFormat: hashFormat, Hash: hash, Mode: core.ModeCF, }, Option: optional, Update: updateMap, } path := modMeta.SetMetaPath(getPathForFile(modInfo.GameID, modInfo.ClassID, modInfo.PrimaryCategoryID, modInfo.Slug)) // If the file already exists, this will overwrite it!!! // TODO: Should this be improved? // Current strategy is to go ahead and do stuff without asking, with the assumption that you are using // VCS anyway. format, hash, err := modMeta.Write() if err != nil { return err } return index.RefreshFileWithHash(path, format, hash, true) } func getSearchLoaderType(pack core.Pack) modloaderType { dependencies := pack.Versions _, hasFabric := dependencies["fabric"] _, hasQuilt := dependencies["quilt"] _, hasForge := dependencies["forge"] _, hasNeoForge := dependencies["neoforge"] if hasFabric && !hasQuilt && !hasForge && !hasNeoForge { return modloaderTypeFabric } if hasForge && !hasNeoForge && !hasFabric && !hasQuilt { return modloaderTypeForge } // We can't filter by more than one loader: accept any and filter the response return modloaderTypeAny } // Crude way of preferring Quilt to Fabric / NeoForge to Forge: larger types are preferred // so NeoForge > Quilt > Fabric > Forge > Any func filterLoaderTypeIndex(packLoaders []string, modLoaderType modloaderType) (modloaderType, bool) { if len(packLoaders) == 0 || modLoaderType == modloaderTypeAny { // No loaders are specified: allow all files return modloaderTypeAny, true } else { if int(modLoaderType) < len(modloaderIds) && slices.Contains(packLoaders, modloaderIds[modLoaderType]) { // Pack contains this loader, pass through return modLoaderType, true } else { // Pack does not contain this loader, report unsupported return modloaderTypeAny, false } } } func filterFileInfoLoaderIndex(packLoaders []string, fileInfoData modFileInfo) (modloaderType, bool) { if len(packLoaders) == 0 { // No loaders are specified: allow all files return modloaderTypeAny, true } else { bestLoaderId := -1 for i, name := range modloaderNames { // Check if packLoaders and the file both contain this loader type if slices.Contains(packLoaders, modloaderIds[i]) && slices.Contains(fileInfoData.GameVersions, name) { if i > bestLoaderId { // First loader found, or a loader preferred over the previous one (later IDs are preferred) bestLoaderId = i } } } if bestLoaderId > -1 { // Found a supported loader return modloaderType(bestLoaderId), true } else { // Failed to find a supported version return modloaderTypeAny, false } } } // findLatestFile looks at mod info, and finds the latest file ID (and potentially the file info for it - may be null) func findLatestFile(modInfoData modInfo, mcVersions []string, packLoaders []string) (fileID uint32, fileInfoData *modFileInfo, fileName string) { cfMcVersions := getCurseforgeVersions(mcVersions) bestMcVer := -1 bestLoaderType := modloaderTypeAny // For snapshots, curseforge doesn't put them in GameVersionLatestFiles for _, v := range modInfoData.LatestFiles { mcVerIdx := core.HighestSliceIndex(mcVersions, v.GameVersions) loaderIdx, loaderValid := filterFileInfoLoaderIndex(packLoaders, v) if mcVerIdx < 0 || !loaderValid { continue } // Compare first by Minecraft version (prefer higher indexes of mcVersions) compare := int32(mcVerIdx - bestMcVer) if compare == 0 { // Treat unmarked versions as neutral (i.e. same as others) if bestLoaderType == modloaderTypeAny || loaderIdx == modloaderTypeAny { compare = 0 } else { // Prefer higher loader indexes compare = int32(loaderIdx) - int32(bestLoaderType) } } if compare == 0 { // Other comparisons are equal, compare by ID instead compare = int32(int64(v.ID) - int64(fileID)) } if compare > 0 { fileID = v.ID fileInfoDataCopy := v // Fix for loop variable reference (which gets reassigned on every iteration!) fileInfoData = &fileInfoDataCopy fileName = v.FileName bestMcVer = mcVerIdx bestLoaderType = loaderIdx } } // TODO: manage alpha/beta/release correctly, check update channel? for _, v := range modInfoData.GameVersionLatestFiles { mcVerIdx := slices.Index(cfMcVersions, v.GameVersion) loaderIdx, loaderValid := filterLoaderTypeIndex(packLoaders, v.Modloader) if mcVerIdx < 0 || !loaderValid { continue } // Compare first by Minecraft version (prefer higher indexes of mcVersions) compare := int32(mcVerIdx - bestMcVer) if compare == 0 { // Treat unmarked versions as neutral (i.e. same as others) if bestLoaderType == modloaderTypeAny || loaderIdx == modloaderTypeAny { compare = 0 } else { // Prefer higher loader indexes compare = int32(loaderIdx) - int32(bestLoaderType) } } if compare == 0 { // Other comparisons are equal, compare by ID instead compare = int32(int64(v.ID) - int64(fileID)) } if compare > 0 { fileID = v.ID fileInfoData = nil // (no file info in GameVersionLatestFiles) fileName = v.Name bestMcVer = mcVerIdx bestLoaderType = loaderIdx } } return } type cfUpdateData struct { ProjectID uint32 `mapstructure:"project-id"` FileID uint32 `mapstructure:"file-id"` } func (u cfUpdateData) ToMap() (map[string]interface{}, error) { newMap := make(map[string]interface{}) err := mapstructure.Decode(u, &newMap) return newMap, err } type cfUpdater struct{} func (u cfUpdater) ParseUpdate(updateUnparsed map[string]interface{}) (interface{}, error) { var updateData cfUpdateData err := mapstructure.Decode(updateUnparsed, &updateData) return updateData, err } type cachedStateStore struct { modInfo fileID uint32 fileInfo *modFileInfo } func (u cfUpdater) CheckUpdate(mods []*core.Mod, pack core.Pack) ([]core.UpdateCheck, error) { results := make([]core.UpdateCheck, len(mods)) modIDs := make([]uint32, len(mods)) modInfos := make([]modInfo, len(mods)) mcVersions, err := pack.GetSupportedMCVersions() if err != nil { return nil, err } for i, v := range mods { projectRaw, ok := v.GetParsedUpdateData("curseforge") if !ok { results[i] = core.UpdateCheck{Error: errors.New("failed to parse update metadata")} continue } project := projectRaw.(cfUpdateData) modIDs[i] = project.ProjectID } modInfosUnsorted, err := cfDefaultClient.getModInfoMultiple(modIDs) if err != nil { return nil, err } for _, v := range modInfosUnsorted { for i, id := range modIDs { if id == v.ID { modInfos[i] = v break } } } packLoaders := pack.GetCompatibleLoaders() for i, v := range mods { projectRaw, ok := v.GetParsedUpdateData("curseforge") if !ok { results[i] = core.UpdateCheck{Error: errors.New("failed to parse update metadata")} continue } project := projectRaw.(cfUpdateData) fileID, fileInfoData, fileName := findLatestFile(modInfos[i], mcVersions, packLoaders) if fileID != project.FileID && fileID != 0 { // Update (or downgrade, if changing to an older version) available! results[i] = core.UpdateCheck{ UpdateAvailable: true, UpdateString: v.FileName + " -> " + fileName, CachedState: cachedStateStore{modInfos[i], fileID, fileInfoData}, } } else { // Could not find a file, too old, or up to date: no update available results[i] = core.UpdateCheck{UpdateAvailable: false} continue } } return results, nil } func (u cfUpdater) DoUpdate(mods []*core.Mod, cachedState []interface{}) error { // "Do" isn't really that accurate, more like "Apply", because all the work is done in CheckUpdate! for i, v := range mods { modState := cachedState[i].(cachedStateStore) var fileInfoData modFileInfo if modState.fileInfo != nil { fileInfoData = *modState.fileInfo } else { var err error fileInfoData, err = cfDefaultClient.getFileInfo(modState.ID, modState.fileID) if err != nil { return err } } v.FileName = fileInfoData.FileName v.Name = modState.Name hash, hashFormat := fileInfoData.getBestHash() v.Download = core.ModDownload{ HashFormat: hashFormat, Hash: hash, Mode: core.ModeCF, } v.Update["curseforge"]["project-id"] = modState.ID v.Update["curseforge"]["file-id"] = fileInfoData.ID } return nil } type cfExportData struct { ProjectID uint32 `mapstructure:"project-id"` } func (e cfExportData) ToMap() (map[string]interface{}, error) { newMap := make(map[string]interface{}) err := mapstructure.Decode(e, &newMap) return newMap, err } func parseExportData(from map[string]interface{}) (cfExportData, error) { var exportData cfExportData err := mapstructure.Decode(from, &exportData) return exportData, err } type cfDownloader struct{} func (c cfDownloader) GetFilesMetadata(mods []*core.Mod) ([]core.MetaDownloaderData, error) { if len(mods) == 0 { return []core.MetaDownloaderData{}, nil } downloaderData := make([]core.MetaDownloaderData, len(mods)) // Need array mapping project ID -> mod index, since there may be multiple files with the same project ID indexMap := make(map[uint32][]int) projectMetadata := make([]cfUpdateData, len(mods)) fileIDs := make([]uint32, len(mods)) for i, v := range mods { updateData, ok := v.GetParsedUpdateData("curseforge") if !ok { return nil, fmt.Errorf("failed to read CurseForge update metadata from %s", v.Name) } project := updateData.(cfUpdateData) indexMap[project.ProjectID] = append(indexMap[project.ProjectID], i) projectMetadata[i] = project fileIDs[i] = project.FileID } fileData, err := cfDefaultClient.getFileInfoMultiple(fileIDs) if err != nil { return nil, fmt.Errorf("failed to get CurseForge file metadata: %w", err) } modIDsToLookup := make([]uint32, 0) fileNames := make(map[uint32]string) for _, file := range fileData { if _, ok := indexMap[file.ModID]; !ok { return nil, fmt.Errorf("unknown project ID in response: %v (file %v, name %v)", file.ModID, file.ID, file.FileName) } // Opted-out mods don't provide their download URLs if file.DownloadURL == "" { modIDsToLookup = append(modIDsToLookup, file.ModID) fileNames[file.ModID] = file.FileName } else { for _, v := range indexMap[file.ModID] { downloaderData[v] = &cfDownloadMetadata{ url: file.DownloadURL, } } } } if len(modIDsToLookup) > 0 { modData, err := cfDefaultClient.getModInfoMultiple(modIDsToLookup) if err != nil { return nil, fmt.Errorf("failed to get CurseForge project metadata: %w", err) } for _, mod := range modData { if _, ok := indexMap[mod.ID]; !ok { return nil, fmt.Errorf("unknown project ID in response: %v (for %v)", mod.ID, mod.Name) } for _, v := range indexMap[mod.ID] { downloaderData[v] = &cfDownloadMetadata{ noDistribution: true, // Inverted so the default value is not this (probably doesn't matter) name: mod.Name, websiteUrl: mod.Links.WebsiteURL + "/files/" + strconv.FormatUint(uint64(fileIDs[v]), 10), fileName: fileNames[mod.ID], } } } } // Ensure all files got data for i, v := range downloaderData { if v == nil { return nil, fmt.Errorf("did not get CurseForge metadata for %s", mods[i].Name) } } return downloaderData, nil } type cfDownloadMetadata struct { url string noDistribution bool name string fileName string websiteUrl string } func (m *cfDownloadMetadata) GetManualDownload() (bool, core.ManualDownload) { if !m.noDistribution { return false, core.ManualDownload{} } return true, core.ManualDownload{ Name: m.name, FileName: m.fileName, URL: m.websiteUrl, } } func (m *cfDownloadMetadata) DownloadFile() (io.ReadCloser, error) { resp, err := core.GetWithUA(m.url, "application/octet-stream") if err != nil { return nil, fmt.Errorf("failed to download %s: %w", m.url, err) } if resp.StatusCode != 200 { _ = resp.Body.Close() return nil, fmt.Errorf("failed to download %s: invalid status code %v", m.url, resp.StatusCode) } return resp.Body, nil } // mapDepOverride transforms manual dependency overrides (which will likely be removed when packwiz is able to determine provided mods) func mapDepOverride(depID uint32, isQuilt bool, mcVersion string) uint32 { if isQuilt && depID == 306612 { // Transform FAPI dependencies to QFAPI/QSL dependencies when using Quilt return 634179 } if isQuilt && depID == 308769 { // Transform FLK dependencies to QKL dependencies when using Quilt >=1.19.2 non-snapshot if flexver.Less("1.19.1", mcVersion) && flexver.Less(mcVersion, "2.0.0") { return 720410 } } return depID }