github: fixes and improvements

- "file" -> "asset"
- "version" -> "tag" or "release" (where appropriate)
- fix updater.go for upstream changes
- make printed log messages more similar to those of other modules
- move http request function(s) to separate file "request.go"
- remove the concept of a "Mod"; we're using "Repo"s (GitHub repositories) instead
- remove unnecessary fields in structs
- use sha256 instead of sha1 for asset checksums

Signed-off-by: unilock <unilock@fennet.rentals>
This commit is contained in:
unilock 2023-05-30 21:01:29 -04:00
parent 837b4db760
commit 6116393310
4 changed files with 112 additions and 169 deletions

View File

@ -4,9 +4,8 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io/ioutil" "io"
"net/http" "net/http"
"time"
"github.com/mitchellh/mapstructure" "github.com/mitchellh/mapstructure"
"github.com/packwiz/packwiz/cmd" "github.com/packwiz/packwiz/cmd"
@ -27,15 +26,15 @@ func init() {
func fetchRepo(slug string) (Repo, error) { func fetchRepo(slug string) (Repo, error) {
var repo Repo var repo Repo
res, err := http.Get(githubApiUrl + "repos/" + slug)
res, err := http.Get(githubApiUrl + "repos/" + slug)
if err != nil { if err != nil {
return repo, err return repo, err
} }
defer res.Body.Close() defer res.Body.Close()
repoBody, err := ioutil.ReadAll(res.Body) repoBody, err := io.ReadAll(res.Body)
if err != nil { if err != nil {
return repo, err return repo, err
} }
@ -44,49 +43,24 @@ func fetchRepo(slug string) (Repo, error) {
if err != nil { if err != nil {
return repo, err return repo, err
} }
if repo.FullName == "" {
return repo, errors.New("invalid json while fetching mod: " + slug)
}
return repo, nil return repo, nil
} }
func fetchMod(slug string) (Mod, error) { type Repo struct {
ID int `json:"id"`
var mod Mod 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"
repo, err := fetchRepo(slug) FullName string `json:"full_name"` // "owner/hello_world"
if err != nil {
return mod, err
} }
release, err := getLatestVersion(slug, "") type Release struct {
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 ModReleases struct {
URL string `json:"url"` 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"` TagName string `json:"tag_name"`
TargetCommitish string `json:"target_commitish"` // The branch of the release TargetCommitish string `json:"target_commitish"` // The branch of the release
Name string `json:"name"` Name string `json:"name"`
@ -96,29 +70,8 @@ type ModReleases struct {
type Asset struct { type Asset struct {
URL string `json:"url"` URL string `json:"url"`
Name string `json:"name"`
UpdatedAt time.Time `json:"updated_at"`
BrowserDownloadURL string `json:"browser_download_url"` BrowserDownloadURL string `json:"browser_download_url"`
}
type Repo struct {
ID int `json:"id"`
Name string `json:"name"` 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"`
} }
func (u ghUpdateData) ToMap() (map[string]interface{}, error) { func (u ghUpdateData) ToMap() (map[string]interface{}, error) {
@ -127,58 +80,28 @@ func (u ghUpdateData) ToMap() (map[string]interface{}, error) {
return newMap, err return newMap, err
} }
type License struct { func (u Asset) getSha256() (string, error) {
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) {
// TODO potentionally cache downloads to speed things up and avoid getting ratelimited by github! // 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) resp, err := http.Get(u.BrowserDownloadURL)
if err != nil { if err != nil {
return "", err return "", err
} }
if resp.StatusCode == 404 {
return "", fmt.Errorf("Asset not found")
}
if resp.StatusCode != 200 { 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() defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
mainHasher.Write(body) mainHasher.Write(body)
hash := mainHasher.Sum(nil) hash := mainHasher.Sum(nil)

View File

@ -4,8 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io/ioutil" "io"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
@ -16,48 +15,46 @@ import (
"github.com/spf13/viper" "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 // installCmd represents the install command
var installCmd = &cobra.Command{ var installCmd = &cobra.Command{
Use: "install [mod]", Use: "add [URL]",
Short: "Install mods from github releases", Short: "Add a project from a GitHub repository URL",
Aliases: []string{"add", "get"}, Aliases: []string{"install", "get"},
Args: cobra.ArbitraryArgs, Args: cobra.ArbitraryArgs,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
pack, err := core.LoadPack() pack, err := core.LoadPack()
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)
} }
if len(args) == 0 || len(args[0]) == 0 { 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) os.Exit(1)
} }
//Try interpreting the arg as a modId or slug. // Try interpreting the argument as a slug, or GitHub repository URL.
//Modrinth transparently handles slugs/mod ids in their api; we don't have to detect which one it is.
var slug string var slug string
//Try to see if it's a site, if extract the id/slug from the url. // Check if the argument is a valid GitHub repository URL; if so, extract the slug from the URL.
//Otherwise, interpret the arg as a id/slug straight up // Otherwise, interpret the argument as a slug directly.
matches := GithubRegex.FindStringSubmatch(args[0]) matches := GithubRegex.FindStringSubmatch(args[0])
if matches != nil && len(matches) == 2 { if len(matches) == 2 {
slug = matches[1] slug = matches[1]
} else { } else {
slug = args[0] slug = args[0]
} }
mod, err := fetchMod(slug) repo, err := fetchRepo(slug)
if err != nil { if err != nil {
fmt.Println("Failed to get the mod ", err) fmt.Println("Failed to get the mod ", err)
os.Exit(1) os.Exit(1)
} }
installMod(mod, pack) installMod(repo, pack)
}, },
} }
@ -67,10 +64,8 @@ func init() {
const githubApiUrl = "https://api.github.com/" const githubApiUrl = "https://api.github.com/"
func installMod(mod Mod, pack core.Pack) error { func installMod(repo Repo, pack core.Pack) error {
fmt.Printf("Found repo %s: '%s'.\n", mod.Slug, mod.Description) latestVersion, err := getLatestVersion(repo.FullName, "")
latestVersion, err := getLatestVersion(mod.Slug, "")
if err != nil { if err != nil {
return fmt.Errorf("failed to get latest version: %v", err) 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 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) { func getLatestVersion(slug string, branch string) (Release, error) {
var modReleases []ModReleases var modReleases []Release
var release ModReleases var release Release
resp, err := http.Get(githubApiUrl + "repos/" + slug + "/releases") resp, err := ghDefaultClient.makeGet(slug)
if err != nil { if err != nil {
return release, err 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() defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return release, err return release, err
} }
@ -116,23 +103,23 @@ func getLatestVersion(slug string, branch string) (ModReleases, error) {
return modReleases[0], nil return modReleases[0], nil
} }
func installVersion(mod Mod, version ModReleases, pack core.Pack) error { func installVersion(repo Repo, release Release, pack core.Pack) error {
var files = version.Assets var files = release.Assets
if len(files) == 0 { 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? // TODO: add some way to allow users to pick which file to install?
var file = files[0] var file = files[0]
for _, v := range version.Assets { for _, v := range release.Assets {
if strings.HasSuffix(v.Name, ".jar") { if strings.HasSuffix(v.Name, ".jar") {
file = v file = v
} }
} }
//Install the file //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() index, err := pack.LoadIndex()
if err != nil { if err != nil {
return err return err
@ -141,26 +128,26 @@ func installVersion(mod Mod, version ModReleases, pack core.Pack) error {
updateMap := make(map[string]map[string]interface{}) updateMap := make(map[string]map[string]interface{})
updateMap["github"], err = ghUpdateData{ updateMap["github"], err = ghUpdateData{
Slug: mod.Slug, Slug: repo.FullName,
InstalledVersion: version.TagName, Tag: release.TagName,
Branch: version.TargetCommitish, Branch: release.TargetCommitish,
}.ToMap() }.ToMap()
if err != nil { if err != nil {
return err return err
} }
hash, error := file.getSha1() hash, err := file.getSha256()
if error != nil || hash == "" { if err != nil {
return errors.New("file doesn't have a hash") return err
} }
modMeta := core.Mod{ modMeta := core.Mod{
Name: mod.Title, Name: repo.Name,
FileName: file.Name, FileName: file.Name,
Side: "unknown", Side: core.UniversalSide,
Download: core.ModDownload{ Download: core.ModDownload{
URL: file.BrowserDownloadURL, URL: file.BrowserDownloadURL,
HashFormat: "sha1", HashFormat: "sha256",
Hash: hash, Hash: hash,
}, },
Update: updateMap, Update: updateMap,
@ -170,7 +157,7 @@ func installVersion(mod Mod, version ModReleases, pack core.Pack) error {
if folder == "" { if folder == "" {
folder = "mods" 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!!! // If the file already exists, this will overwrite it!!!
// TODO: Should this be improved? // TODO: Should this be improved?

33
github/request.go Normal file
View File

@ -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
}

View File

@ -11,7 +11,7 @@ import (
type ghUpdateData struct { type ghUpdateData struct {
Slug string `mapstructure:"slug"` Slug string `mapstructure:"slug"`
InstalledVersion string `mapstructure:"version"` Tag string `mapstructure:"tag"`
Branch string `mapstructure:"branch"` Branch string `mapstructure:"branch"`
} }
@ -25,16 +25,16 @@ func (u ghUpdater) ParseUpdate(updateUnparsed map[string]interface{}) (interface
type cachedStateStore struct { type cachedStateStore struct {
ModID string 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)) results := make([]core.UpdateCheck, len(mods))
for i, mod := range mods { for i, mod := range mods {
rawData, ok := mod.GetParsedUpdateData("github") rawData, ok := mod.GetParsedUpdateData("github")
if !ok { 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 continue
} }
@ -46,13 +46,13 @@ func (u ghUpdater) CheckUpdate(mods []core.Mod, mcVersion string, pack core.Pack
continue 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} results[i] = core.UpdateCheck{UpdateAvailable: false}
continue continue
} }
if len(newVersion.Assets) == 0 { 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 continue
} }
@ -80,18 +80,18 @@ func (u ghUpdater) DoUpdate(mods []*core.Mod, cachedState []interface{}) error {
} }
} }
hash, error := file.getSha1() hash, err := file.getSha256()
if error != nil || hash == "" { if err != nil {
return errors.New("file doesn't have a hash") return err
} }
mod.FileName = file.Name mod.FileName = file.Name
mod.Download = core.ModDownload{ mod.Download = core.ModDownload{
URL: file.BrowserDownloadURL, URL: file.BrowserDownloadURL,
HashFormat: "sha1", HashFormat: "sha256",
Hash: hash, Hash: hash,
} }
mod.Update["github"]["version"] = version.TagName mod.Update["github"]["tag"] = version.TagName
} }
return nil return nil