diff --git a/core/download.go b/core/download.go index 066d956..4fe6558 100644 --- a/core/download.go +++ b/core/download.go @@ -4,12 +4,13 @@ import ( "encoding/json" "errors" "fmt" - "golang.org/x/exp/slices" "io" "net/http" "os" "path/filepath" "strings" + + "golang.org/x/exp/slices" ) const UserAgent = "packwiz/packwiz" diff --git a/github/github.go b/github/github.go new file mode 100644 index 0000000..b63ccdb --- /dev/null +++ b/github/github.go @@ -0,0 +1,70 @@ +package github + +import ( + "github.com/packwiz/packwiz/cmd" + "github.com/packwiz/packwiz/core" + "github.com/spf13/cobra" +) + +var githubCmd = &cobra.Command{ + Use: "github", + Aliases: []string{"gh"}, + Short: "Manage github-based mods", +} + +func init() { + cmd.Add(githubCmd) + core.Updaters["github"] = ghUpdater{} +} + +type ModReleases struct { + URL string `json:"url"` + AssetsURL string `json:"assets_url"` + UploadURL string `json:"upload_url"` + HTMLURL string `json:"html_url"` + ID int `json:"id"` + Author 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:"author"` + NodeID string `json:"node_id"` + TagName string `json:"tag_name"` + TargetCommitish string `json:"target_commitish"` + Name string `json:"name"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + CreatedAt string `json:"created_at"` + PublishedAt string `json:"published_at"` + Assets []Asset `json:"assets"` + TarballURL string `json:"tarball_url"` + ZipballURL string `json:"zipball_url"` + Body string `json:"body"` + Reactions struct { + URL string `json:"url"` + TotalCount int `json:"total_count"` + Num1 int `json:"+1"` + Num10 int `json:"-1"` + Laugh int `json:"laugh"` + Hooray int `json:"hooray"` + Confused int `json:"confused"` + Heart int `json:"heart"` + Rocket int `json:"rocket"` + Eyes int `json:"eyes"` + } `json:"reactions"` +} diff --git a/github/install.go b/github/install.go new file mode 100644 index 0000000..a39941c --- /dev/null +++ b/github/install.go @@ -0,0 +1,504 @@ +package github + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "log" + "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) + + 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 + } + + // side := mod.getSide() + // if side == "" { + // return errors.New("version doesn't have a side that's supported. Server: " + mod.ServerSide + " Client: " + mod.ClientSide) + // } + + 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" + } + if mod.Slug != "" { + path = modMeta.SetMetaPath(filepath.Join(viper.GetString("meta-folder-base"), folder, mod.Slug+core.MetaExtension)) + } else { + 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! + file, err := os.CreateTemp("", "download") + + if err != nil { + fmt.Println(err) + os.Exit(1) + } + 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) + + file.Write(body) + + h := sha256.New() + if _, err := io.Copy(h, file); err != nil { + log.Fatal(err) + } + + hash := h.Sum(nil) + + defer os.Remove(file.Name()) + + 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"` +} diff --git a/github/updater.go b/github/updater.go new file mode 100644 index 0000000..647a2ba --- /dev/null +++ b/github/updater.go @@ -0,0 +1,102 @@ +package github + +import ( + "errors" + "fmt" + "strings" + + "github.com/mitchellh/mapstructure" + "github.com/packwiz/packwiz/core" +) + +type ghUpdateData struct { + ModID string `mapstructure:"mod-id"` // The slug of the repo but named modId for consistency reasons + InstalledVersion string `mapstructure:"version"` +} + +type ghUpdater struct{} + +func (u ghUpdater) ParseUpdate(updateUnparsed map[string]interface{}) (interface{}, error) { + var updateData ghUpdateData + err := mapstructure.Decode(updateUnparsed, &updateData) + return updateData, err +} + +type cachedStateStore struct { + ModID string + Version ModReleases +} + +func (u ghUpdater) CheckUpdate(mods []core.Mod, mcVersion string, pack core.Pack) ([]core.UpdateCheck, error) { + results := make([]core.UpdateCheck, len(mods)) + + for i, mod := range mods { + rawData, ok := mod.GetParsedUpdateData("modrinth") + if !ok { + results[i] = core.UpdateCheck{Error: errors.New("couldn't parse mod data")} + continue + } + + data := rawData.(ghUpdateData) + + newVersion, err := getLatestVersion(data.ModID, pack) + if err != nil { + results[i] = core.UpdateCheck{Error: fmt.Errorf("failed to get latest version: %v", err)} + continue + } + + // if newVersion.ID == "" { //There is no version available for this minecraft version or loader. + // results[i] = core.UpdateCheck{UpdateAvailable: false} + // continue + // } + + if newVersion.TagName == data.InstalledVersion { //The latest version from the site is the same as the installed one + results[i] = core.UpdateCheck{UpdateAvailable: false} + continue + } + + if len(newVersion.Assets) == 0 { + results[i] = core.UpdateCheck{Error: errors.New("new version doesn't have any files")} + continue + } + + newFilename := newVersion.Assets[0].Name + + results[i] = core.UpdateCheck{ + UpdateAvailable: true, + UpdateString: mod.FileName + " -> " + newFilename, + CachedState: cachedStateStore{data.ModID, newVersion}, + } + } + + return results, nil +} + +func (u ghUpdater) DoUpdate(mods []*core.Mod, cachedState []interface{}) error { + for i, mod := range mods { + modState := cachedState[i].(cachedStateStore) + var version = modState.Version + + var file = version.Assets[0] + for _, v := range version.Assets { + if strings.HasSuffix(v.Name, ".jar") { + file = v + } + } + + hash, error := file.getSha256() + if error != nil || hash == "" { + return errors.New("file doesn't have a hash") + } + + mod.FileName = file.Name + mod.Download = core.ModDownload{ + URL: file.BrowserDownloadURL, + HashFormat: "sha256", + Hash: hash, + } + mod.Update["modrinth"]["version"] = version.ID + } + + return nil +} diff --git a/main.go b/main.go index 82e35b8..d5c3a42 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( // Modules of packwiz "github.com/packwiz/packwiz/cmd" _ "github.com/packwiz/packwiz/curseforge" + _ "github.com/packwiz/packwiz/github" _ "github.com/packwiz/packwiz/migrate" _ "github.com/packwiz/packwiz/modrinth" _ "github.com/packwiz/packwiz/settings"