doodle/cmd/doodad/commands/levelpack.go
Noah Petherbridge 82884c79ae Signed Levels and Levelpacks
Add the ability for the free version of the game to allow loading levels that
use embedded custom doodads if those levels are signed.

* Uses the same signing keys as the JWT token for license registrations.
* Levels and Levelpacks can both be signed. So individual levels with embedded
  doodads can work in free versions of the game.
* Levelpacks now support embedded doodads properly: the individual levels in
  the pack don't need to embed a custom doodad, but if the doodad exists in
  the levelpack's doodads/ folder it will load from there instead - for full
  versions of the game OR when the levelpack is signed.

Signatures are computed by getting a listing of embedded assets inside the
zipfile (the assets/ folder in levels, and the doodads/ + levels/ folders
in levelpacks). Thus for individual signed levels, the level geometry and
metadata may be changed without breaking the signature but if custom doodads
are changed the signature will break.

The doodle-admin command adds subcommands to `sign-level` and `verify-level`
to manage signatures on levels and levelpacks.

When using the `doodad levelpack create` command, any custom doodads the
levels mention that are found in your profile directory get embedded into
the zipfile by default (with --doodads custom).
2023-02-18 17:37:54 -08:00

343 lines
8.3 KiB
Go

package commands
import (
"archive/zip"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"sort"
"strings"
"time"
"git.kirsle.net/SketchyMaze/doodle/pkg/doodads"
"git.kirsle.net/SketchyMaze/doodle/pkg/level"
"git.kirsle.net/SketchyMaze/doodle/pkg/levelpack"
"git.kirsle.net/SketchyMaze/doodle/pkg/log"
"git.kirsle.net/SketchyMaze/doodle/pkg/userdir"
"github.com/urfave/cli/v2"
)
// LevelPack creation and management.
var LevelPack *cli.Command
func init() {
LevelPack = &cli.Command{
Name: "levelpack",
Usage: "create and manage .levelpack archives",
ArgsUsage: "-o output.levelpack <list of .level files>",
Subcommands: []*cli.Command{
{
Name: "create",
Usage: "create a new .levelpack file from source files",
ArgsUsage: "<output.levelpack> <input.level> [input.level...]",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "title",
Aliases: []string{"t"},
Usage: "set a title for your levelpack, default will use the first level's title",
},
&cli.StringFlag{
Name: "author",
Aliases: []string{"a"},
Usage: "set an author for your levelpack, default will use the first level's author",
},
&cli.StringFlag{
Name: "description",
Aliases: []string{"d"},
Usage: "set a description for your levelpack",
},
&cli.IntFlag{
Name: "free",
Aliases: []string{"f"},
Usage: "set number of free levels (levels unlocked by default), 0 means all unlocked",
},
&cli.StringFlag{
Name: "doodads",
Aliases: []string{"D"},
Usage: "which doodads to embed: none, custom, all",
Value: "custom",
},
},
Action: levelpackCreate,
},
{
Name: "show",
Usage: "print details about a levelpack file",
ArgsUsage: "<input.levelpack>",
Action: levelpackShow,
},
},
Flags: []cli.Flag{},
}
}
// Subcommand `levelpack show`
func levelpackShow(c *cli.Context) error {
if c.NArg() < 1 {
return cli.Exit(
"Usage: doodad levelpack show <file.levelpack>",
1,
)
}
var filename = c.Args().Slice()[0]
if !strings.HasSuffix(filename, ".levelpack") {
return cli.Exit("file must name a .levelpack", 1)
}
lp, err := levelpack.LoadFile(filename)
if err != nil {
return cli.Exit(err, 1)
}
fmt.Printf("===== Levelpack: %s =====\n", filename)
fmt.Println("Headers:")
fmt.Printf(" Title: %s\n", lp.Title)
fmt.Printf(" Author: %s\n", lp.Author)
fmt.Printf(" Description: %s\n", lp.Description)
fmt.Printf(" Free levels: %d\n", lp.FreeLevels)
// List the levels.
fmt.Println("\nLevels:")
for i, lvl := range lp.Levels {
fmt.Printf("%d. %s: %s\n", i+1, lvl.Filename, lvl.Title)
}
// List the doodads.
dl := lp.ListFiles("doodads/")
if len(dl) > 0 {
fmt.Println("\nDoodads:")
for i, doodad := range dl {
fmt.Printf("%d. %s\n", i, doodad)
}
}
return nil
}
// Subcommand `levelpack create`
func levelpackCreate(c *cli.Context) error {
if c.NArg() < 2 {
return cli.Exit(
"Usage: doodad levelpack create <out.levelpack> <in.level ...>",
1,
)
}
var (
args = c.Args().Slice()
outfile = args[0]
infiles = args[1:]
title = c.String("title")
author = c.String("author")
description = c.String("description")
free = c.Int("free")
embedDoodads = c.String("doodads")
)
// Validate params.
if !strings.HasSuffix(outfile, ".levelpack") {
return cli.Exit("Output file must have a .levelpack extension", 1)
}
if embedDoodads != "none" && embedDoodads != "custom" && embedDoodads != "all" {
return cli.Exit(
"--doodads: must be one of all, custom, none",
1,
)
}
var lp = levelpack.LevelPack{
Title: title,
Author: author,
Description: description,
FreeLevels: free,
Created: time.Now().UTC(),
}
// Create a temp directory to work with.
workdir, err := os.MkdirTemp(userdir.CacheDirectory, "levelpack-*")
if err != nil {
return cli.Exit(
fmt.Sprintf("Couldn't make temp folder: %s", err),
1,
)
}
log.Info("Working directory: %s", workdir)
defer os.RemoveAll(workdir)
// Useful folders inside the working directory.
var (
levelDir = filepath.Join(workdir, "levels")
doodadDir = filepath.Join(workdir, "doodads")
assets = []string{
"index.json",
}
)
os.MkdirAll(levelDir, 0755)
os.MkdirAll(doodadDir, 0755)
// Get the list of the game's builtin doodads.
builtins, err := doodads.ListBuiltin()
if err != nil {
return cli.Exit(err, 1)
}
// Read the input levels.
for i, filename := range infiles {
if !strings.HasSuffix(filename, ".level") {
return cli.Exit(
fmt.Sprintf("input file at position %d (%s) was not a .level file", i, filename),
1,
)
}
lvl, err := level.LoadJSON(filename)
if err != nil {
return cli.Exit(
fmt.Sprintf("%s: %s", filename, err),
1,
)
}
// Fill in defaults for --title, --author
if lp.Title == "" {
lp.Title = lvl.Title
}
if lp.Author == "" {
lp.Author = lvl.Author
}
// Log the level in the index.json list.
lp.Levels = append(lp.Levels, levelpack.Level{
Title: lvl.Title,
Author: lvl.Author,
Filename: filepath.Base(filename),
})
// Grab all the level's doodads to embed in the zip folder.
for _, actor := range lvl.Actors {
// What was the user's embeds request? (--doodads)
if embedDoodads == "none" {
break
} else if embedDoodads == "custom" {
// Custom doodads only.
if isBuiltinDoodad(builtins, actor.Filename) {
log.Warn("Doodad %s is a built-in, skipping embed", actor.Filename)
continue
}
}
if _, err := os.Stat(filepath.Join(doodadDir, actor.Filename)); !os.IsNotExist(err) {
continue
}
log.Info("Adding doodad to zipfile: %s", actor.Filename)
// Get this doodad from the game's built-ins or the user's
// profile directory only. Pulling embedded doodads out of
// the level is NOT supported.
asset, err := doodads.LoadFile(actor.Filename)
if err != nil {
return cli.Exit(
fmt.Sprintf("%s: Doodad file '%s': %s", filename, asset.Filename, err),
1,
)
}
var targetFile = filepath.Join(doodadDir, actor.Filename)
assets = append(assets, targetFile)
log.Debug("Write doodad: %s", targetFile)
err = asset.WriteFile(filepath.Join(doodadDir, actor.Filename))
if err != nil {
return cli.Exit(
fmt.Sprintf("Writing doodad %s: %s", actor.Filename, err),
1,
)
}
}
// Copy the level in.
var targetFile = filepath.Join(levelDir, filepath.Base(filename))
assets = append(assets, targetFile)
log.Info("Write level: %s", filename)
err = copyFile(filename, filepath.Join(levelDir, filepath.Base(filename)))
if err != nil {
return cli.Exit(
fmt.Sprintf("couldn't copy %s to %s: %s", filename, targetFile, err),
1,
)
}
}
log.Info("Writing index.json")
if err := lp.WriteFile(filepath.Join(workdir, "index.json")); err != nil {
return cli.Exit(err, 1)
}
// Zip the levelpack directory.
log.Info("Creating levelpack file: %s", outfile)
zipf, err := os.Create(outfile)
if err != nil {
return cli.Exit(
fmt.Sprintf("failed to create %s: %s", outfile, err),
1,
)
}
zipper := zip.NewWriter(zipf)
defer zipper.Close()
// Embed all the assets.
sort.Strings(assets)
for _, asset := range assets {
asset = strings.TrimPrefix(asset, workdir+"/")
log.Info("Zip: %s", asset)
err := zipFile(zipper, asset, filepath.Join(workdir, asset))
if err != nil {
return cli.Exit(err, 1)
}
}
log.Info("Written: %s", outfile)
return cli.Exit("", 0)
}
// copyFile copies a file on disk to another location.
func copyFile(source, target string) error {
input, err := ioutil.ReadFile(source)
if err != nil {
return err
}
return ioutil.WriteFile(target, input, 0644)
}
// zipFile reads a file on disk to add to a zip file.
// The `key` is the filepath inside the ZIP file, filename is the actual source file on disk.
func zipFile(zf *zip.Writer, key, filename string) error {
input, err := ioutil.ReadFile(filename)
if err != nil {
return err
}
writer, err := zf.Create(key)
if err != nil {
return err
}
_, err = writer.Write(input)
return err
}
// Helper function to test whether a filename is part of the builtin doodads.
func isBuiltinDoodad(doodads []string, filename string) bool {
for _, cmp := range doodads {
if cmp == filename {
return true
}
}
return false
}