package github import ( "encoding/json" "errors" "fmt" "io/ioutil" "net/http" "os" "path/filepath" "regexp" "strings" "time" "github.com/mitchellh/mapstructure" "github.com/packwiz/packwiz/core" "github.com/spf13/cobra" "github.com/spf13/viper" ) var modSiteRegex = regexp.MustCompile("modrinth\\.com/mod/([^/]+)/?$") var versionSiteRegex = regexp.MustCompile("modrinth\\.com/mod/([^/]+)/version/([^/]+)/?$") // installCmd represents the install command var installCmd = &cobra.Command{ Use: "install [mod]", Short: "Install a mod from a github URL", Aliases: []string{"add", "get"}, Args: cobra.ArbitraryArgs, Run: func(cmd *cobra.Command, args []string) { pack, err := core.LoadPack() if err != nil { fmt.Println(err) os.Exit(1) } if len(args) == 0 || len(args[0]) == 0 { fmt.Println("You must specify a mod.") os.Exit(1) } if strings.HasSuffix(args[0], "/") { fmt.Println("Url cant have a leading slash!") os.Exit(1) } slug := strings.Replace(args[0], "https://github.com/", "", 1) if len(strings.Split(args[0], "/")) == 1 { slug = args[0] } if strings.Contains(slug, "/releases") { slug = strings.Split(slug, "/releases")[0] } mod, err := fetchMod(slug) installMod(mod, pack) }, } func init() { githubCmd.AddCommand(installCmd) } const githubApiUrl = "https://api.github.com/" func fetchMod(slug string) (Mod, error) { var modReleases []ModReleases var mod Mod resp, err := http.Get(githubApiUrl + "repos/" + slug + "/releases") if err != nil { return mod, err } if resp.StatusCode == 404 { return mod, fmt.Errorf("mod not found (for URL %v)", githubApiUrl+"repos/"+slug+"/releases") } if resp.StatusCode != 200 { return mod, fmt.Errorf("invalid response status %v for URL %v", resp.Status, githubApiUrl+"repos/"+slug+"/releases") } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { return mod, err } err = json.Unmarshal(body, &modReleases) if err != nil { return mod, err } var repoData Repo repoResp, err := http.Get(githubApiUrl + "repos/" + slug) defer repoResp.Body.Close() repoBody, err := ioutil.ReadAll(repoResp.Body) if err != nil { return mod, err } err = json.Unmarshal(repoBody, &repoData) if err != nil { return mod, err } release := modReleases[0] mod = Mod{ ID: repoData.Name, Slug: slug, Team: repoData.Owner.Login, Title: repoData.Name, Description: repoData.Description, Published: repoData.CreatedAt, Updated: release.CreatedAt, License: repoData.License, ClientSide: "unknown", ServerSide: "unknown", Categories: repoData.Topics, } if mod.ID == "" { return mod, errors.New("invalid json whilst fetching mod: " + slug) } return mod, nil } func installMod(mod Mod, pack core.Pack) error { fmt.Printf("Found mod %s: '%s'.\n", mod.Title, mod.Description) latestVersion, err := getLatestVersion(mod.Slug, pack) if err != nil { return fmt.Errorf("failed to get latest version: %v", err) } if latestVersion.URL == "" { return errors.New("mod is not available for this Minecraft version (use the acceptable-game-versions option to accept more) or mod loader") } return installVersion(mod, latestVersion, pack) } func getLatestVersion(slug string, pack core.Pack) (ModReleases, error) { var modReleases []ModReleases var release ModReleases resp, err := http.Get(githubApiUrl + "repos/" + slug + "/releases") if err != nil { return release, err } if resp.StatusCode == 404 { return release, fmt.Errorf("mod not found (for URL %v)", githubApiUrl+"repos/"+slug+"/releases") } if resp.StatusCode != 200 { return release, fmt.Errorf("invalid response status %v for URL %v", resp.Status, githubApiUrl+"repos/"+slug+"/releases") } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { return release, err } err = json.Unmarshal(body, &modReleases) if err != nil { return release, err } return modReleases[0], nil } func installVersion(mod Mod, version ModReleases, pack core.Pack) error { var files = version.Assets if len(files) == 0 { return errors.New("version doesn't have any files attached") } // TODO: add some way to allow users to pick which file to install? var file = files[0] for _, v := range version.Assets { if strings.HasSuffix(v.Name, ".jar") { file = v } } //Install the file fmt.Printf("Installing %s from version %s\n", file.URL, version.Name) index, err := pack.LoadIndex() if err != nil { return err } updateMap := make(map[string]map[string]interface{}) updateMap["github"], err = ghUpdateData{ ModID: mod.ID, InstalledVersion: version.TagName, }.ToMap() if err != nil { return err } hash, error := file.getSha256() if error != nil || hash == "" { return errors.New("file doesn't have a hash") } modMeta := core.Mod{ Name: mod.Title, FileName: file.Name, Side: "unknown", Download: core.ModDownload{ URL: file.BrowserDownloadURL, HashFormat: "sha256", Hash: hash, }, Update: updateMap, } var path string folder := viper.GetString("meta-folder") if folder == "" { folder = "mods" } path = modMeta.SetMetaPath(filepath.Join(viper.GetString("meta-folder-base"), folder, mod.Title+core.MetaExtension)) // 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 } err = index.RefreshFileWithHash(path, format, hash, true) if err != nil { return err } err = index.Write() if err != nil { return err } err = pack.UpdateIndexHash() if err != nil { return err } err = pack.Write() if err != nil { return err } return nil } func (u ghUpdateData) ToMap() (map[string]interface{}, error) { newMap := make(map[string]interface{}) err := mapstructure.Decode(u, &newMap) return newMap, err } type License struct { Id string `json:"id"` //The license id of a mod, retrieved from the licenses get route Name string `json:"name"` //The long for name of a license Url string `json:"url"` //The URL to this license } type Mod struct { ID string `json:"id"` //The ID of the mod, encoded as a base62 string Slug string `json:"slug"` //The slug of a mod, used for vanity URLs Team string `json:"team"` //The id of the team that has ownership of this mod Title string `json:"title"` //The title or name of the mod Description string `json:"description"` //A short description of the mod // Body string `json:"body"` //A long form description of the mod. // BodyUrl string `json:"body_url"` //DEPRECATED The link to the long description of the mod (Optional) Published string `json:"published"` //The date at which the mod was first published Updated string `json:"updated"` //The date at which the mod was updated License struct { Key string `json:"key"` Name string `json:"name"` SpdxID string `json:"spdx_id"` URL string `json:"url"` NodeID string `json:"node_id"` } `json:"license"` ClientSide string `json:"client_side"` //The support range for the client mod - required, optional, unsupported, or unknown ServerSide string `json:"server_side"` //The support range for the server mod - required, optional, unsupported, or unknown // Downloads int `json:"downloads"` //The total number of downloads the mod has Categories []string `json:"categories"` //A list of the categories that the mod is in Versions []string `json:"versions"` //A list of ids for versions of the mod IconUrl string `json:"icon_url"` //The URL of the icon of the mod (Optional) IssuesUrl string `json:"issues_url"` //An optional link to where to submit bugs or issues with the mod (Optional) SourceUrl string `json:"source_url"` //An optional link to the source code for the mod (Optional) WikiUrl string `json:"wiki_url"` //An optional link to the mod's wiki page or other relevant information (Optional) DiscordUrl string `json:"discord_url"` //An optional link to the mod's discord (Optional) } type Asset struct { URL string `json:"url"` ID int `json:"id"` NodeID string `json:"node_id"` Name string `json:"name"` Label interface{} `json:"label"` Uploader struct { Login string `json:"login"` ID int `json:"id"` NodeID string `json:"node_id"` AvatarURL string `json:"avatar_url"` GravatarID string `json:"gravatar_id"` URL string `json:"url"` HTMLURL string `json:"html_url"` FollowersURL string `json:"followers_url"` FollowingURL string `json:"following_url"` GistsURL string `json:"gists_url"` StarredURL string `json:"starred_url"` SubscriptionsURL string `json:"subscriptions_url"` OrganizationsURL string `json:"organizations_url"` ReposURL string `json:"repos_url"` EventsURL string `json:"events_url"` ReceivedEventsURL string `json:"received_events_url"` Type string `json:"type"` SiteAdmin bool `json:"site_admin"` } `json:"uploader"` ContentType string `json:"content_type"` State string `json:"state"` Size int `json:"size"` DownloadCount int `json:"download_count"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` BrowserDownloadURL string `json:"browser_download_url"` } func (u Asset) getSha256() (string, error) { // TODO potentionally cache downloads to speed things up and avoid getting ratelimited by github! mainHasher, err := core.GetHashImpl("sha256") resp, err := http.Get(u.BrowserDownloadURL) if err != nil { return "", err } if resp.StatusCode == 404 { return "", fmt.Errorf("Asset not found") } if resp.StatusCode != 200 { return "", fmt.Errorf("Invalid response code: %d", resp.StatusCode) } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) mainHasher.Write(body) hash := mainHasher.Sum(nil) return mainHasher.HashToString(hash), nil } type Repo struct { ID int `json:"id"` NodeID string `json:"node_id"` Name string `json:"name"` FullName string `json:"full_name"` Private bool `json:"private"` Owner struct { Login string `json:"login"` ID int `json:"id"` NodeID string `json:"node_id"` AvatarURL string `json:"avatar_url"` GravatarID string `json:"gravatar_id"` URL string `json:"url"` HTMLURL string `json:"html_url"` FollowersURL string `json:"followers_url"` FollowingURL string `json:"following_url"` GistsURL string `json:"gists_url"` StarredURL string `json:"starred_url"` SubscriptionsURL string `json:"subscriptions_url"` OrganizationsURL string `json:"organizations_url"` ReposURL string `json:"repos_url"` EventsURL string `json:"events_url"` ReceivedEventsURL string `json:"received_events_url"` Type string `json:"type"` SiteAdmin bool `json:"site_admin"` } `json:"owner"` HTMLURL string `json:"html_url"` Description string `json:"description"` Fork bool `json:"fork"` URL string `json:"url"` ForksURL string `json:"forks_url"` KeysURL string `json:"keys_url"` CollaboratorsURL string `json:"collaborators_url"` TeamsURL string `json:"teams_url"` HooksURL string `json:"hooks_url"` IssueEventsURL string `json:"issue_events_url"` EventsURL string `json:"events_url"` AssigneesURL string `json:"assignees_url"` BranchesURL string `json:"branches_url"` TagsURL string `json:"tags_url"` BlobsURL string `json:"blobs_url"` GitTagsURL string `json:"git_tags_url"` GitRefsURL string `json:"git_refs_url"` TreesURL string `json:"trees_url"` StatusesURL string `json:"statuses_url"` LanguagesURL string `json:"languages_url"` StargazersURL string `json:"stargazers_url"` ContributorsURL string `json:"contributors_url"` SubscribersURL string `json:"subscribers_url"` SubscriptionURL string `json:"subscription_url"` CommitsURL string `json:"commits_url"` GitCommitsURL string `json:"git_commits_url"` CommentsURL string `json:"comments_url"` IssueCommentURL string `json:"issue_comment_url"` ContentsURL string `json:"contents_url"` CompareURL string `json:"compare_url"` MergesURL string `json:"merges_url"` ArchiveURL string `json:"archive_url"` DownloadsURL string `json:"downloads_url"` IssuesURL string `json:"issues_url"` PullsURL string `json:"pulls_url"` MilestonesURL string `json:"milestones_url"` NotificationsURL string `json:"notifications_url"` LabelsURL string `json:"labels_url"` ReleasesURL string `json:"releases_url"` DeploymentsURL string `json:"deployments_url"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` PushedAt string `json:"pushed_at"` GitURL string `json:"git_url"` SSHURL string `json:"ssh_url"` CloneURL string `json:"clone_url"` SvnURL string `json:"svn_url"` Homepage string `json:"homepage"` Size int `json:"size"` StargazersCount int `json:"stargazers_count"` WatchersCount int `json:"watchers_count"` Language string `json:"language"` HasIssues bool `json:"has_issues"` HasProjects bool `json:"has_projects"` HasDownloads bool `json:"has_downloads"` HasWiki bool `json:"has_wiki"` HasPages bool `json:"has_pages"` ForksCount int `json:"forks_count"` MirrorURL interface{} `json:"mirror_url"` Archived bool `json:"archived"` Disabled bool `json:"disabled"` OpenIssuesCount int `json:"open_issues_count"` License struct { Key string `json:"key"` Name string `json:"name"` SpdxID string `json:"spdx_id"` URL string `json:"url"` NodeID string `json:"node_id"` } `json:"license"` AllowForking bool `json:"allow_forking"` IsTemplate bool `json:"is_template"` Topics []string `json:"topics"` Visibility string `json:"visibility"` Forks int `json:"forks"` OpenIssues int `json:"open_issues"` Watchers int `json:"watchers"` DefaultBranch string `json:"default_branch"` TempCloneToken interface{} `json:"temp_clone_token"` Organization struct { Login string `json:"login"` ID int `json:"id"` NodeID string `json:"node_id"` AvatarURL string `json:"avatar_url"` GravatarID string `json:"gravatar_id"` URL string `json:"url"` HTMLURL string `json:"html_url"` FollowersURL string `json:"followers_url"` FollowingURL string `json:"following_url"` GistsURL string `json:"gists_url"` StarredURL string `json:"starred_url"` SubscriptionsURL string `json:"subscriptions_url"` OrganizationsURL string `json:"organizations_url"` ReposURL string `json:"repos_url"` EventsURL string `json:"events_url"` ReceivedEventsURL string `json:"received_events_url"` Type string `json:"type"` SiteAdmin bool `json:"site_admin"` } `json:"organization"` NetworkCount int `json:"network_count"` SubscribersCount int `json:"subscribers_count"` }