From 60c08b93f37160df3df99418eccad34843ff3d81 Mon Sep 17 00:00:00 2001
From: comp500 <comp500@users.noreply.github.com>
Date: Tue, 28 Dec 2021 22:03:22 +0000
Subject: [PATCH] Implement Modrinth pack exporting (fixes #34)

---
 core/hash.go               |  37 ++++--
 core/index.go              |   9 +-
 core/mod.go                |   9 +-
 core/pack.go               |   5 +-
 curseforge/murmur2/hash.go |  54 +++++++++
 modrinth/export.go         | 243 +++++++++++++++++++++++++++++++++++++
 modrinth/pack.go           |  22 ++++
 7 files changed, 358 insertions(+), 21 deletions(-)
 create mode 100644 curseforge/murmur2/hash.go
 create mode 100644 modrinth/export.go
 create mode 100644 modrinth/pack.go

diff --git a/core/hash.go b/core/hash.go
index 46ce5d9..7e08445 100644
--- a/core/hash.go
+++ b/core/hash.go
@@ -5,23 +5,44 @@ import (
 	"crypto/sha1"
 	"crypto/sha256"
 	"crypto/sha512"
-	"errors"
+	"encoding/binary"
+	"encoding/hex"
+	"fmt"
+	"github.com/packwiz/packwiz/curseforge/murmur2"
 	"hash"
+	"strconv"
 	"strings"
 )
 
 // GetHashImpl gets an implementation of hash.Hash for the given hash type string
-func GetHashImpl(hashType string) (hash.Hash, error) {
+func GetHashImpl(hashType string) (hash.Hash, HashStringer, error) {
 	switch strings.ToLower(hashType) {
 	case "sha1":
-		return sha1.New(), nil
+		return sha1.New(), hexStringer{}, nil
 	case "sha256":
-		return sha256.New(), nil
+		return sha256.New(), hexStringer{}, nil
 	case "sha512":
-		return sha512.New(), nil
+		return sha512.New(), hexStringer{}, nil
 	case "md5":
-		return md5.New(), nil
+		return md5.New(), hexStringer{}, nil
+	case "murmur2":
+		return murmur2.New(), numberStringer{}, nil
 	}
-	// TODO: implement murmur2
-	return nil, errors.New("hash implementation not found")
+	return nil, nil, fmt.Errorf("hash implementation %s not found", hashType)
+}
+
+type HashStringer interface {
+	HashToString([]byte) string
+}
+
+type hexStringer struct{}
+
+func (hexStringer) HashToString(data []byte) string {
+	return hex.EncodeToString(data)
+}
+
+type numberStringer struct{}
+
+func (numberStringer) HashToString(data []byte) string {
+	return strconv.FormatUint(uint64(binary.BigEndian.Uint32(data)), 10)
 }
diff --git a/core/index.go b/core/index.go
index 1b6bafa..1aea382 100644
--- a/core/index.go
+++ b/core/index.go
@@ -1,7 +1,6 @@
 package core
 
 import (
-	"encoding/hex"
 	"errors"
 	"io"
 	"os"
@@ -133,7 +132,7 @@ func (in *Index) updateFile(path string) error {
 		// Hash usage strategy (may change):
 		// Just use SHA256, overwrite existing hash regardless of what it is
 		// May update later to continue using the same hash that was already being used
-		h, err := GetHashImpl("sha256")
+		h, stringer, err := GetHashImpl("sha256")
 		if err != nil {
 			_ = f.Close()
 			return err
@@ -146,7 +145,7 @@ func (in *Index) updateFile(path string) error {
 		if err != nil {
 			return err
 		}
-		hashString = hex.EncodeToString(h.Sum(nil))
+		hashString = stringer.HashToString(h.Sum(nil))
 	}
 
 	mod := false
@@ -348,7 +347,7 @@ func (in Index) SaveFile(f IndexFile, dest io.Writer) error {
 	if err != nil {
 		return err
 	}
-	h, err := GetHashImpl(hashFormat)
+	h, stringer, err := GetHashImpl(hashFormat)
 	if err != nil {
 		return err
 	}
@@ -359,7 +358,7 @@ func (in Index) SaveFile(f IndexFile, dest io.Writer) error {
 		return err
 	}
 
-	calculatedHash := hex.EncodeToString(h.Sum(nil))
+	calculatedHash := stringer.HashToString(h.Sum(nil))
 	if calculatedHash != f.Hash && !viper.GetBool("no-internal-hashes") {
 		return errors.New("hash of saved file is invalid")
 	}
diff --git a/core/mod.go b/core/mod.go
index d3247e7..22d25de 100644
--- a/core/mod.go
+++ b/core/mod.go
@@ -1,7 +1,6 @@
 package core
 
 import (
-	"encoding/hex"
 	"errors"
 	"fmt"
 	"io"
@@ -90,7 +89,7 @@ func (m Mod) Write() (string, string, error) {
 		}
 	}
 
-	h, err := GetHashImpl("sha256")
+	h, stringer, err := GetHashImpl("sha256")
 	if err != nil {
 		_ = f.Close()
 		return "", "", err
@@ -101,7 +100,7 @@ func (m Mod) Write() (string, string, error) {
 	// Disable indentation
 	enc.Indent = ""
 	err = enc.Encode(m)
-	hashString := hex.EncodeToString(h.Sum(nil))
+	hashString := stringer.HashToString(h.Sum(nil))
 	if err != nil {
 		_ = f.Close()
 		return "sha256", hashString, err
@@ -135,7 +134,7 @@ func (m Mod) DownloadFile(dest io.Writer) error {
 		_ = resp.Body.Close()
 		return errors.New("invalid status code " + strconv.Itoa(resp.StatusCode))
 	}
-	h, err := GetHashImpl(m.Download.HashFormat)
+	h, stringer, err := GetHashImpl(m.Download.HashFormat)
 	if err != nil {
 		return err
 	}
@@ -146,7 +145,7 @@ func (m Mod) DownloadFile(dest io.Writer) error {
 		return err
 	}
 
-	calculatedHash := hex.EncodeToString(h.Sum(nil))
+	calculatedHash := stringer.HashToString(h.Sum(nil))
 
 	// Check if the hash of the downloaded file matches the expected hash.
 	if calculatedHash != m.Download.Hash {
diff --git a/core/pack.go b/core/pack.go
index dc868f2..f105222 100644
--- a/core/pack.go
+++ b/core/pack.go
@@ -1,7 +1,6 @@
 package core
 
 import (
-	"encoding/hex"
 	"errors"
 	"fmt"
 	"io"
@@ -116,7 +115,7 @@ func (pack *Pack) UpdateIndexHash() error {
 	// Hash usage strategy (may change):
 	// Just use SHA256, overwrite existing hash regardless of what it is
 	// May update later to continue using the same hash that was already being used
-	h, err := GetHashImpl("sha256")
+	h, stringer, err := GetHashImpl("sha256")
 	if err != nil {
 		_ = f.Close()
 		return err
@@ -125,7 +124,7 @@ func (pack *Pack) UpdateIndexHash() error {
 		_ = f.Close()
 		return err
 	}
-	hashString := hex.EncodeToString(h.Sum(nil))
+	hashString := stringer.HashToString(h.Sum(nil))
 
 	pack.Index.HashFormat = "sha256"
 	pack.Index.Hash = hashString
diff --git a/curseforge/murmur2/hash.go b/curseforge/murmur2/hash.go
new file mode 100644
index 0000000..e4c7c96
--- /dev/null
+++ b/curseforge/murmur2/hash.go
@@ -0,0 +1,54 @@
+package murmur2
+
+import (
+	"encoding/binary"
+	"github.com/aviddiviner/go-murmur"
+	"hash"
+)
+
+func New() hash.Hash32 {
+	return &Murmur2CF{buf: make([]byte, 0)}
+}
+
+type Murmur2CF struct {
+	// Can't be done incrementally, since it is seeded with the length of the input!
+	buf []byte
+}
+
+func (m *Murmur2CF) Write(p []byte) (n int, err error) {
+	for _, b := range p {
+		if !isWhitespaceCharacter(b) {
+			m.buf = append(m.buf, b)
+		}
+	}
+	return len(p), nil
+}
+
+// CF modification: strips whitespace characters
+func isWhitespaceCharacter(b byte) bool {
+	return b == 9 || b == 10 || b == 13 || b == 32
+}
+
+func (m *Murmur2CF) Sum(b []byte) []byte {
+	if b == nil {
+		b = make([]byte, 4)
+	}
+	binary.BigEndian.PutUint32(b, murmur.MurmurHash2(m.buf, 1))
+	return b
+}
+
+func (m *Murmur2CF) Reset() {
+	m.buf = make([]byte, 0)
+}
+
+func (m *Murmur2CF) Size() int {
+	return 4
+}
+
+func (m *Murmur2CF) BlockSize() int {
+	return 4
+}
+
+func (m *Murmur2CF) Sum32() uint32 {
+	return binary.BigEndian.Uint32(m.Sum(nil))
+}
diff --git a/modrinth/export.go b/modrinth/export.go
new file mode 100644
index 0000000..8032c88
--- /dev/null
+++ b/modrinth/export.go
@@ -0,0 +1,243 @@
+package modrinth
+
+import (
+	"archive/zip"
+	"encoding/json"
+	"fmt"
+	"github.com/spf13/viper"
+	"os"
+	"path/filepath"
+
+	"github.com/packwiz/packwiz/core"
+	"github.com/spf13/cobra"
+)
+
+// exportCmd represents the export command
+var exportCmd = &cobra.Command{
+	Use:   "export",
+	Short: "Export the current modpack into a .mrpack for Modrinth",
+	Args:  cobra.NoArgs,
+	Run: func(cmd *cobra.Command, args []string) {
+		fmt.Println("Loading modpack...")
+		pack, err := core.LoadPack()
+		if err != nil {
+			fmt.Println(err)
+			os.Exit(1)
+		}
+		index, err := pack.LoadIndex()
+		if err != nil {
+			fmt.Println(err)
+			os.Exit(1)
+		}
+		// Do a refresh to ensure files are up to date
+		err = index.Refresh()
+		if err != nil {
+			fmt.Println(err)
+			return
+		}
+		err = index.Write()
+		if err != nil {
+			fmt.Println(err)
+			return
+		}
+		err = pack.UpdateIndexHash()
+		if err != nil {
+			fmt.Println(err)
+			return
+		}
+		err = pack.Write()
+		if err != nil {
+			fmt.Println(err)
+			return
+		}
+
+		// TODO: should index just expose indexPath itself, through a function?
+		indexPath := filepath.Join(filepath.Dir(viper.GetString("pack-file")), filepath.FromSlash(pack.Index.File))
+
+		mods := loadMods(index)
+
+		var fileName = pack.GetPackName() + ".mrpack"
+		expFile, err := os.Create(fileName)
+		if err != nil {
+			fmt.Printf("Failed to create zip: %s\n", err.Error())
+			os.Exit(1)
+		}
+		exp := zip.NewWriter(expFile)
+
+		// Add an overrides folder even if there are no files to go in it
+		_, err = exp.Create("overrides/")
+		if err != nil {
+			fmt.Printf("Failed to add overrides folder: %s\n", err.Error())
+			os.Exit(1)
+		}
+
+		// TODO: cache these (ideally with changes to pack format)
+		fmt.Println("Retrieving SHA1 hashes for external mods...")
+		sha1Hashes := make([]string, len(mods))
+		for i, mod := range mods {
+			if mod.Download.HashFormat == "sha1" {
+				sha1Hashes[i] = mod.Download.Hash
+			} else {
+				// Hash format for this mod isn't SHA1 - and the Modrinth pack format requires it; so get it by downloading the file
+				h, stringer, err := core.GetHashImpl("sha1")
+				if err != nil {
+					panic("Failed to get sha1 hash implementation")
+				}
+				err = mod.DownloadFile(h)
+				if err != nil {
+					fmt.Printf("Error downloading mod file %s: %s\n", mod.Download.URL, err.Error())
+					// TODO: exit(1)?
+					continue
+				}
+				sha1Hashes[i] = stringer.HashToString(h.Sum(nil))
+				fmt.Printf("Retrieved SHA1 hash for %s successfully\n", mod.Download.URL)
+			}
+		}
+
+		manifestFile, err := exp.Create("modrinth.index.json")
+		if err != nil {
+			_ = exp.Close()
+			_ = expFile.Close()
+			fmt.Println("Error creating manifest: " + err.Error())
+			os.Exit(1)
+		}
+
+		manifestFiles := make([]PackFile, len(mods))
+		for i, mod := range mods {
+			pathForward, err := filepath.Rel(filepath.Dir(indexPath), mod.GetDestFilePath())
+			if err != nil {
+				fmt.Printf("Error resolving mod file: %s\n", err.Error())
+				// TODO: exit(1)?
+				continue
+			}
+
+			path := filepath.ToSlash(pathForward)
+
+			hashes := make(map[string]string)
+			hashes["sha1"] = sha1Hashes[i]
+
+			// Create env options based on configured optional/side
+			var envInstalled string
+			if mod.Option != nil && mod.Option.Optional {
+				envInstalled = "optional"
+			} else {
+				envInstalled = "required"
+			}
+			var clientEnv, serverEnv string
+			if mod.Side == core.UniversalSide {
+				clientEnv = envInstalled
+				serverEnv = envInstalled
+			} else if mod.Side == core.ClientSide {
+				clientEnv = envInstalled
+				serverEnv = "unsupported"
+			} else if mod.Side == core.ServerSide {
+				clientEnv = "unsupported"
+				serverEnv = envInstalled
+			}
+
+			manifestFiles[i] = PackFile{
+				Path:   path,
+				Hashes: hashes,
+				Env: &struct {
+					Client string `json:"client"`
+					Server string `json:"server"`
+				}{Client: clientEnv, Server: serverEnv},
+				Downloads: []string{mod.Download.URL},
+			}
+		}
+
+		dependencies := make(map[string]string)
+		dependencies["minecraft"], err = pack.GetMCVersion()
+		if err != nil {
+			_ = exp.Close()
+			_ = expFile.Close()
+			fmt.Println("Error creating manifest: " + err.Error())
+			os.Exit(1)
+		}
+		if fabricVersion, ok := pack.Versions["fabric"]; ok {
+			dependencies["fabric-loader"] = fabricVersion
+		} else if forgeVersion, ok := pack.Versions["forge"]; ok {
+			dependencies["forge"] = forgeVersion
+		}
+
+		manifest := Pack{
+			FormatVersion: 1,
+			Game:          "minecraft",
+			VersionID:     pack.Version,
+			Name:          pack.Name,
+			Files:         manifestFiles,
+			Dependencies:  dependencies,
+		}
+
+		w := json.NewEncoder(manifestFile)
+		w.SetIndent("", "    ") // Documentation uses 4 spaces
+		err = w.Encode(manifest)
+		if err != nil {
+			_ = exp.Close()
+			_ = expFile.Close()
+			fmt.Println("Error writing manifest: " + err.Error())
+			os.Exit(1)
+		}
+
+		for _, v := range index.Files {
+			if !v.MetaFile {
+				// Save all non-metadata files into the zip
+				path, err := filepath.Rel(filepath.Dir(indexPath), index.GetFilePath(v))
+				if err != nil {
+					fmt.Printf("Error resolving file: %s\n", err.Error())
+					// TODO: exit(1)?
+					continue
+				}
+				file, err := exp.Create(filepath.ToSlash(filepath.Join("overrides", path)))
+				if err != nil {
+					fmt.Printf("Error creating file: %s\n", err.Error())
+					// TODO: exit(1)?
+					continue
+				}
+				err = index.SaveFile(v, file)
+				if err != nil {
+					fmt.Printf("Error copying file: %s\n", err.Error())
+					// TODO: exit(1)?
+					continue
+				}
+			}
+		}
+
+		err = exp.Close()
+		if err != nil {
+			fmt.Println("Error writing export file: " + err.Error())
+			os.Exit(1)
+		}
+		err = expFile.Close()
+		if err != nil {
+			fmt.Println("Error writing export file: " + err.Error())
+			os.Exit(1)
+		}
+
+		fmt.Println("Modpack exported to " + fileName)
+		fmt.Println("Make sure you remove this file before running packwiz refresh, or add it to .packwizignore")
+	},
+}
+
+func loadMods(index core.Index) []core.Mod {
+	modPaths := index.GetAllMods()
+	mods := make([]core.Mod, len(modPaths))
+	i := 0
+	fmt.Println("Reading mod files...")
+	for _, v := range modPaths {
+		modData, err := core.LoadMod(v)
+		if err != nil {
+			fmt.Printf("Error reading mod file %s: %s\n", v, err.Error())
+			// TODO: exit(1)?
+			continue
+		}
+
+		mods[i] = modData
+		i++
+	}
+	return mods[:i]
+}
+
+func init() {
+	modrinthCmd.AddCommand(exportCmd)
+}
diff --git a/modrinth/pack.go b/modrinth/pack.go
new file mode 100644
index 0000000..dba40c1
--- /dev/null
+++ b/modrinth/pack.go
@@ -0,0 +1,22 @@
+package modrinth
+
+type Pack struct {
+	FormatVersion int    `json:"formatVersion"`
+	Game          string `json:"game"`
+	VersionID     string `json:"versionId"`
+	Name          string `json:"name"`
+	// TODO: implement Summary
+	// Summary       string `json:"summary"`
+	Files        []PackFile        `json:"files"`
+	Dependencies map[string]string `json:"dependencies"`
+}
+
+type PackFile struct {
+	Path   string            `json:"path"`
+	Hashes map[string]string `json:"hashes"`
+	Env    *struct {
+		Client string `json:"client"`
+		Server string `json:"server"`
+	} `json:"env"`
+	Downloads []string `json:"downloads"`
+}