diff --git a/github/github.go b/github/github.go index 33881c0..e67c175 100644 --- a/github/github.go +++ b/github/github.go @@ -4,9 +4,8 @@ import ( "encoding/json" "errors" "fmt" - "io/ioutil" + "io" "net/http" - "time" "github.com/mitchellh/mapstructure" "github.com/packwiz/packwiz/cmd" @@ -27,15 +26,15 @@ func init() { func fetchRepo(slug string) (Repo, error) { var repo Repo - res, err := http.Get(githubApiUrl + "repos/" + slug) + res, err := http.Get(githubApiUrl + "repos/" + slug) if err != nil { return repo, err } defer res.Body.Close() - repoBody, err := ioutil.ReadAll(res.Body) + repoBody, err := io.ReadAll(res.Body) if err != nil { return repo, err } @@ -44,49 +43,24 @@ func fetchRepo(slug string) (Repo, error) { if err != nil { return repo, err } + + if repo.FullName == "" { + return repo, errors.New("invalid json while fetching mod: " + slug) + } + return repo, nil } -func fetchMod(slug string) (Mod, error) { - - var mod Mod - - repo, err := fetchRepo(slug) - - if err != nil { - return mod, err - } - - release, err := getLatestVersion(slug, "") - - if err != nil { - return mod, err - } - - mod = Mod{ - ID: repo.Name, - Slug: slug, - Team: repo.Owner.Login, - Title: repo.Name, - Description: repo.Description, - Published: repo.CreatedAt, - Updated: release.CreatedAt, - License: repo.License, - ClientSide: "unknown", - ServerSide: "unknown", - Categories: repo.Topics, - } - if mod.ID == "" { - return mod, errors.New("invalid json whilst fetching mod: " + slug) - } - - return mod, nil - +type Repo struct { + ID int `json:"id"` + NodeID string `json:"node_id"` // TODO: use this with GH API, instead of name (to acct. for repo renames?) + store in mod.pw.toml + Name string `json:"name"` // "hello_world" + FullName string `json:"full_name"` // "owner/hello_world" } -type ModReleases struct { +type Release struct { URL string `json:"url"` - NodeID string `json:"node_id"` + NodeID string `json:"node_id"` // TODO: probably also use this with GH API TagName string `json:"tag_name"` TargetCommitish string `json:"target_commitish"` // The branch of the release Name string `json:"name"` @@ -95,30 +69,9 @@ type ModReleases struct { } type Asset struct { - URL string `json:"url"` - Name string `json:"name"` - UpdatedAt time.Time `json:"updated_at"` - BrowserDownloadURL string `json:"browser_download_url"` -} - -type Repo struct { - ID int `json:"id"` - Name string `json:"name"` - FullName string `json:"full_name"` - Owner struct { - Login string `json:"login"` - } `json:"owner"` - Description string `json:"description"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - 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"` - Topics []string `json:"topics"` + URL string `json:"url"` + BrowserDownloadURL string `json:"browser_download_url"` + Name string `json:"name"` } func (u ghUpdateData) ToMap() (map[string]interface{}, error) { @@ -127,58 +80,28 @@ func (u ghUpdateData) ToMap() (map[string]interface{}, error) { 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) -} - -func (u Asset) getSha1() (string, error) { +func (u Asset) getSha256() (string, error) { // TODO potentionally cache downloads to speed things up and avoid getting ratelimited by github! - mainHasher, err := core.GetHashImpl("sha1") + mainHasher, err := core.GetHashImpl("sha256") + if err != nil { + return "", err + } + 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) + return "", fmt.Errorf("invalid response status: %v", resp.StatusCode) } defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + mainHasher.Write(body) hash := mainHasher.Sum(nil) diff --git a/github/install.go b/github/install.go index 7765557..0326465 100644 --- a/github/install.go +++ b/github/install.go @@ -4,8 +4,7 @@ import ( "encoding/json" "errors" "fmt" - "io/ioutil" - "net/http" + "io" "os" "path/filepath" "regexp" @@ -16,48 +15,46 @@ import ( "github.com/spf13/viper" ) -var GithubRegex = regexp.MustCompile("https?://(?:www\\.)?github\\.com/([^/]+/[^/]+)") +var GithubRegex = regexp.MustCompile(`^https?://(?:www\.)?github\.com/([^/]+/[^/]+)`) // installCmd represents the install command var installCmd = &cobra.Command{ - Use: "install [mod]", - Short: "Install mods from github releases", - Aliases: []string{"add", "get"}, + Use: "add [URL]", + Short: "Add a project from a GitHub repository URL", + Aliases: []string{"install", "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.") + fmt.Println("You must specify a GitHub repository URL.") os.Exit(1) } - //Try interpreting the arg as a modId or slug. - //Modrinth transparently handles slugs/mod ids in their api; we don't have to detect which one it is. + // Try interpreting the argument as a slug, or GitHub repository URL. var slug string - //Try to see if it's a site, if extract the id/slug from the url. - //Otherwise, interpret the arg as a id/slug straight up + // Check if the argument is a valid GitHub repository URL; if so, extract the slug from the URL. + // Otherwise, interpret the argument as a slug directly. matches := GithubRegex.FindStringSubmatch(args[0]) - if matches != nil && len(matches) == 2 { + if len(matches) == 2 { slug = matches[1] } else { slug = args[0] } - mod, err := fetchMod(slug) + repo, err := fetchRepo(slug) if err != nil { fmt.Println("Failed to get the mod ", err) os.Exit(1) } - installMod(mod, pack) + installMod(repo, pack) }, } @@ -67,10 +64,8 @@ func init() { const githubApiUrl = "https://api.github.com/" -func installMod(mod Mod, pack core.Pack) error { - fmt.Printf("Found repo %s: '%s'.\n", mod.Slug, mod.Description) - - latestVersion, err := getLatestVersion(mod.Slug, "") +func installMod(repo Repo, pack core.Pack) error { + latestVersion, err := getLatestVersion(repo.FullName, "") if err != nil { return fmt.Errorf("failed to get latest version: %v", err) } @@ -78,28 +73,20 @@ func installMod(mod Mod, pack core.Pack) error { 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) + return installVersion(repo, latestVersion, pack) } -func getLatestVersion(slug string, branch string) (ModReleases, error) { - var modReleases []ModReleases - var release ModReleases +func getLatestVersion(slug string, branch string) (Release, error) { + var modReleases []Release + var release Release - resp, err := http.Get(githubApiUrl + "repos/" + slug + "/releases") + resp, err := ghDefaultClient.makeGet(slug) 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) + body, err := io.ReadAll(resp.Body) if err != nil { return release, err } @@ -116,23 +103,23 @@ func getLatestVersion(slug string, branch string) (ModReleases, error) { return modReleases[0], nil } -func installVersion(mod Mod, version ModReleases, pack core.Pack) error { - var files = version.Assets +func installVersion(repo Repo, release Release, pack core.Pack) error { + var files = release.Assets if len(files) == 0 { - return errors.New("version doesn't have any files attached") + return errors.New("release 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 { + for _, v := range release.Assets { if strings.HasSuffix(v.Name, ".jar") { file = v } } //Install the file - fmt.Printf("Installing %s from version %s\n", file.URL, version.Name) + fmt.Printf("Installing %s from release %s\n", file.Name, release.TagName) index, err := pack.LoadIndex() if err != nil { return err @@ -141,26 +128,26 @@ func installVersion(mod Mod, version ModReleases, pack core.Pack) error { updateMap := make(map[string]map[string]interface{}) updateMap["github"], err = ghUpdateData{ - Slug: mod.Slug, - InstalledVersion: version.TagName, - Branch: version.TargetCommitish, + Slug: repo.FullName, + Tag: release.TagName, + Branch: release.TargetCommitish, }.ToMap() if err != nil { return err } - hash, error := file.getSha1() - if error != nil || hash == "" { - return errors.New("file doesn't have a hash") + hash, err := file.getSha256() + if err != nil { + return err } modMeta := core.Mod{ - Name: mod.Title, + Name: repo.Name, FileName: file.Name, - Side: "unknown", + Side: core.UniversalSide, Download: core.ModDownload{ URL: file.BrowserDownloadURL, - HashFormat: "sha1", + HashFormat: "sha256", Hash: hash, }, Update: updateMap, @@ -170,7 +157,7 @@ func installVersion(mod Mod, version ModReleases, pack core.Pack) error { if folder == "" { folder = "mods" } - path = modMeta.SetMetaPath(filepath.Join(viper.GetString("meta-folder-base"), folder, mod.Title+core.MetaExtension)) + path = modMeta.SetMetaPath(filepath.Join(viper.GetString("meta-folder-base"), folder, repo.Name+core.MetaExtension)) // If the file already exists, this will overwrite it!!! // TODO: Should this be improved? diff --git a/github/request.go b/github/request.go new file mode 100644 index 0000000..79dd599 --- /dev/null +++ b/github/request.go @@ -0,0 +1,33 @@ +package github + +import ( + "fmt" + "net/http" +) + +const ghApiServer = "api.github.com" + +type ghApiClient struct { + httpClient *http.Client +} + +var ghDefaultClient = ghApiClient{&http.Client{}} + +func (c *ghApiClient) makeGet(slug string) (*http.Response, error) { + req, err := http.NewRequest("GET", "https://" + ghApiServer + "/repos/" + slug + "/releases", nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/vnd.github+json") + + 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 +} diff --git a/github/updater.go b/github/updater.go index a6aee0a..65f99d7 100644 --- a/github/updater.go +++ b/github/updater.go @@ -10,9 +10,9 @@ import ( ) type ghUpdateData struct { - Slug string `mapstructure:"slug"` - InstalledVersion string `mapstructure:"version"` - Branch string `mapstructure:"branch"` + Slug string `mapstructure:"slug"` + Tag string `mapstructure:"tag"` + Branch string `mapstructure:"branch"` } type ghUpdater struct{} @@ -25,16 +25,16 @@ func (u ghUpdater) ParseUpdate(updateUnparsed map[string]interface{}) (interface type cachedStateStore struct { ModID string - Version ModReleases + Version Release } -func (u ghUpdater) CheckUpdate(mods []core.Mod, mcVersion string, pack core.Pack) ([]core.UpdateCheck, error) { +func (u ghUpdater) CheckUpdate(mods []*core.Mod, pack core.Pack) ([]core.UpdateCheck, error) { results := make([]core.UpdateCheck, len(mods)) for i, mod := range mods { rawData, ok := mod.GetParsedUpdateData("github") if !ok { - results[i] = core.UpdateCheck{Error: errors.New("couldn't parse mod data")} + results[i] = core.UpdateCheck{Error: errors.New("failed to parse update metadata")} continue } @@ -46,13 +46,13 @@ func (u ghUpdater) CheckUpdate(mods []core.Mod, mcVersion string, pack core.Pack continue } - if newVersion.TagName == data.InstalledVersion { //The latest version from the site is the same as the installed one + if newVersion.TagName == data.Tag { // 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")} + results[i] = core.UpdateCheck{Error: errors.New("new version doesn't have any assets")} continue } @@ -80,18 +80,18 @@ func (u ghUpdater) DoUpdate(mods []*core.Mod, cachedState []interface{}) error { } } - hash, error := file.getSha1() - if error != nil || hash == "" { - return errors.New("file doesn't have a hash") + hash, err := file.getSha256() + if err != nil { + return err } mod.FileName = file.Name mod.Download = core.ModDownload{ URL: file.BrowserDownloadURL, - HashFormat: "sha1", + HashFormat: "sha256", Hash: hash, } - mod.Update["github"]["version"] = version.TagName + mod.Update["github"]["tag"] = version.TagName } return nil