~kota/mcoffline

ac52985ad80c6232e955180fb8df76b74ffdf73c — Dakota Walsh 1 year, 5 months ago 68e2e91 v2000.0.0
convert playerdata folder
4 files changed, 177 insertions(+), 25 deletions(-)

M Makefile
M README.md
M doc/mcoffline.1.scd
M main.go
M Makefile => Makefile +1 -1
@@ 3,7 3,7 @@
.POSIX:
.SUFFIXES:

VERSION = 1.0.0
VERSION = 2000.0.0
GO = go
RM = rm
INSTALL = install

M README.md => README.md +14 -1
@@ 6,9 6,22 @@ Convert minecraft whitelist to offline-mode whitelist.
```
mcoffline k0taa
mcoffline whitelist.json
mcoffline < whitelist.json > whitelist-offline.json
```

There are two ways to use `mcoffline`. You can simply run it with a username and
it will print out the corresponding offline-mode UUID for that name.

You can instead run it with a path to a `whitelist.json` file. It will convert
the file, store the new offline-mode version as `whitelist.json.offline` in the
same directory. Then it will look for a `server.properties` file in that same
directory to discover your world name. Finally, it will enter the world
directory and create a copy of the `playerdata` folder named
`playerdata.offline` with each user renamed to the offline-mode version.

Once finished you can manually verify `whitelist.offline.json` and
`playerdata.offline`. If you like them you can backup your old online-mode
versions and rename the offline-mode versions to get rid of the suffix.

# Install
```
make all

M doc/mcoffline.1.scd => doc/mcoffline.1.scd +16 -3
@@ 8,13 8,26 @@ mcoffline - Convert minecraft whitelist to offline-mode whitelist.

*mcoffline* [_USER_ | _FILE_]

# EXAMPLES
# DESCRIPTION
```
mcoffline k0taa
mcoffline whitelist.json
mcoffline < whitelist.json > whitelist-offline.json
mcoffline /path/to/whitelist.json
```

There are two ways to use *mcoffline*. You can simply run it with a username and
it will print out the corresponding offline-mode UUID for that name.

You can instead run it with a path to a *whitelist.json* file. It will convert
the file, store the new offline-mode version as *whitelist.offline.json* in the
same directory. Then it will look for a *server.properties* file in that same
directory to discover your world name. Finally, it will enter the world
directory and create a copy of the *playerdata* folder named *playerdata.offline*
with each user renamed to the offline-mode version.

Once finished you can manually verify *whitelist.offline.json*
and *playerdata.offline*. If you like them you can backup your old online-mode
versions and rename the offline-mode versions to get rid of the suffix.

# AUTHORS

Maintained by Dakota Walsh <kota at nilsu.org>.

M main.go => main.go +146 -20
@@ 1,6 1,7 @@
package main

import (
	"bufio"
	"encoding/json"
	"errors"
	"flag"


@@ 8,12 9,19 @@ import (
	"io"
	"log"
	"os"
	"path/filepath"
	"strings"

	"github.com/Tnze/go-mc/offline"
)

// OfflineSuffix is the suffix added to whitelist.json and playerdata.
const OfflineSuffix = ".offline"

// Version is set at build time in the Makefile.
var Version string

// User represents a user in whitelist.json
type User struct {
	UUID string `json:"uuid"`
	Name string `json:"name"`


@@ 23,41 31,59 @@ func main() {
	log.SetPrefix("mcoffline " + Version + ": ")
	log.SetFlags(0)
	flag.Parse()
	if len(flag.Args()) == 0 {
		// Use STDIN and STDOUT.
		if err := convertWhitelist(os.Stdin, os.Stdout); err != nil {
			log.Fatalf("failed parsing whitelist: %v", err)
		}
		return
	}

	arg := flag.Arg(0)
	if arg == "" {
		// Look for whitelist.json in current directory.
		arg = "whitelist.json"
	}

	in, err := os.Open(arg)
	if errors.Is(err, os.ErrNotExist) {
		// Assume the argument was a username if the file does not exist
	if errors.Is(err, os.ErrNotExist) && flag.Arg(0) != "" {
		// Assume the argument was a username if the file does not exist, but an
		// argument was given.
		fmt.Println(arg, offline.NameToUUID(arg))
		return
	} else if err != nil {
		log.Fatalf("failed opening whitelist: %v", err)
		log.Fatalf("failed opening whitelist: %v\n", err)
	}
	defer in.Close()

	// Use second arg as output file if given
	out := os.Stdout
	if flag.Arg(1) != "" {
		f, err := os.OpenFile(flag.Arg(1), os.O_WRONLY|os.O_CREATE, 0644)
		if err != nil {
			log.Fatalf("failed opening output: %v", err)
		}
		defer f.Close()
		out = f
	out, err := os.Create(arg + OfflineSuffix)
	if err != nil {
		log.Fatalf("failed creating offline-mode whitelist file: %v\n", err)
	}
	defer out.Close()

	if err := convertWhitelist(in, out); err != nil {
		log.Fatalf("failed parsing whitelist: %v", err)
		log.Fatalf("failed parsing whitelist: %v\n", err)
	}

	// Reset file seek so we can read it again later to make a map of the UUIDs.
	if _, err := in.Seek(0, io.SeekStart); err != nil {
		log.Fatalf("failed reading whitelist: %v\n", err)
	}

	basePath := filepath.Dir(arg)
	worldName, err := getLevelName(filepath.Join(basePath, "server.properties"))
	if err != nil {
		log.Fatalf("failed reading server.properties to get world name: %v\n", err)
	}
	playerDataPath := filepath.Join(basePath, worldName, "playerdata")

	users, err := mapUsers(in)
	if err != nil {
		log.Fatalln("failed mapping users to UUIDs")
	}

	if err := convertPlayerdata(users, playerDataPath); err != nil {
		log.Fatalf("failed creating offline playerdata: %v\n", err)
	}
}

// convertWhitelist reads an online-mode whitelist.json file from a reader and
// writes an equivalent offline-mode whitelist.json to it's writer. The output
// is indented with 2 spaces to match the format Mojang seems to like.
func convertWhitelist(in io.Reader, out io.Writer) error {
	var users []User
	d := json.NewDecoder(in)


@@ 77,3 103,103 @@ func convertWhitelist(in io.Reader, out io.Writer) error {
	e.SetIndent("", "  ") // MC uses 2 space indented json.
	return e.Encode(&users)
}

// mapUsers reads a whitelist and creates a map of UUIDs to usernames.
func mapUsers(in io.Reader) (map[string]string, error) {
	var users []User
	d := json.NewDecoder(in)
	err := d.Decode(&users)
	if err != nil {
		return nil, err
	}

	m := make(map[string]string)
	for _, user := range users {
		m[user.UUID] = user.Name
	}
	return m, nil
}

// getWorld takes the path to a server.properties file, reads it, and returns
// the string value for the "level-name" key.
func getLevelName(path string) (string, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", err
	}
	defer f.Close()

	scanner := bufio.NewScanner(f)
	for scanner.Scan() {
		line := scanner.Text()
		i := strings.Index(line, "=")
		if i < 1 {
			continue
		}

		key := strings.TrimSpace(line[:i])
		if key == "level-name" {
			var value string
			if len(line) > i {
				value = strings.TrimSpace(line[i+1:])
			}
			return value, nil
		}
	}
	return "", fmt.Errorf("level-name not found in %v", path)
}

// convertPlayerdata takes a map of online-mode UUIDs to player named and the
// path to a playerdata folder. It creates a playerdata.offline directory.
// Then, For each file in the input directory, a hard link is make to the
// offline directory named with the offline-mode UUID instead of it's previous
// online-mode UUID.
func convertPlayerdata(users map[string]string, path string) error {
	files, err := os.ReadDir(path)
	if err != nil {
		return fmt.Errorf("failed reading playerdata folder: %w", err)
	}

	offlineDir := filepath.Clean(path) + OfflineSuffix
	err = os.Mkdir(offlineDir, 0755)
	if err != nil && !errors.Is(err, os.ErrExist) {
		return fmt.Errorf("failed creating %v: %w", offlineDir, err)
	}

	for _, file := range files {
		// Skip non-files... there really shouldn't be any, but who knows.
		if file.IsDir() {
			continue
		}

		onlinePath := filepath.Join(path, file.Name())

		ext := filepath.Ext(file.Name())
		uuid := strings.TrimSuffix(file.Name(), ext)
		name, ok := users[uuid]
		if !ok {
			fmt.Printf("skipping non-whitelisted player: %v\n", onlinePath)
			continue
		}

		offlinePath := filepath.Join(
			offlineDir,
			offline.NameToUUID(name).String()+ext,
		)

		// Using a hardlink to avoid pointless disk writing. Dunno if this works
		// on windows lol. I guess you can always send me an angry email if this
		// doesn't work for you :P
		if err := os.Link(
			onlinePath,
			offlinePath,
		); err != nil && !errors.Is(err, os.ErrExist) {
			return fmt.Errorf("failed creating offline version of %v named %v: %w",
				onlinePath,
				offlinePath,
				err,
			)
		}
	}
	return nil
}