doodle/pkg/levelpack/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

278 lines
6.4 KiB
Go

// Package levelpack handles ZIP archives for level packs.
package levelpack
import (
"archive/zip"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"os"
"runtime"
"sort"
"strings"
"time"
"git.kirsle.net/SketchyMaze/doodle/assets"
"git.kirsle.net/SketchyMaze/doodle/pkg/balance"
"git.kirsle.net/SketchyMaze/doodle/pkg/enum"
"git.kirsle.net/SketchyMaze/doodle/pkg/filesystem"
"git.kirsle.net/SketchyMaze/doodle/pkg/log"
"git.kirsle.net/SketchyMaze/doodle/pkg/userdir"
)
// LevelPack describes the contents of a levelpack file.
type LevelPack struct {
Title string `json:"title"`
Description string `json:"description"`
Author string `json:"author"`
Created time.Time `json:"created"`
// Cached metadata about the (displayed) levels.
Levels []Level `json:"levels"`
// Number of levels unlocked by default.
// 0 = all levels unlocked
FreeLevels int `json:"freeLevels"`
// The loaded zip file for reading an existing levelpack.
Zipfile *zip.Reader `json:"-"`
// A reference to the original filename, not stored in json.
Filename string `json:"-"`
// Signature to allow free versions of the game to load embedded
// custom doodads inside this levelpack for its levels.
Signature []byte `json:"signature,omitempty"`
}
// Level holds metadata about the levels in the levelpack.
type Level struct {
Title string `json:"title"`
Author string `json:"author"`
Filename string `json:"filename"`
}
// LoadFile reads a .levelpack zip file.
func LoadFile(filename string) (*LevelPack, error) {
var (
fh io.ReaderAt
filesize int64
)
// Look in embedded bindata.
if data, err := assets.Asset(filename); err == nil {
filesize = int64(len(data))
fh = bytes.NewReader(data)
}
// Try the filesystem.
if fh == nil {
stat, err := os.Stat(filename)
if err != nil {
return nil, err
}
filesize = stat.Size()
fh, err = os.Open(filename)
if err != nil {
return nil, err
}
}
// No luck?
if fh == nil {
return nil, errors.New("no file found")
}
reader, err := zip.NewReader(fh, filesize)
if err != nil {
return nil, err
}
lp := &LevelPack{
Filename: filename,
Zipfile: reader,
}
// Read the index.json.
lp.GetJSON(lp, "index.json")
return lp, nil
}
// LoadAllAvailable loads every levelpack visible to the game. Returns
// the sorted list of filenames as from ListFiles, plus a deeply loaded
// hash map associating the filenames with their data.
func LoadAllAvailable() ([]string, map[string]*LevelPack, error) {
filenames, err := ListFiles()
if err != nil {
return filenames, nil, err
}
var dictionary = map[string]*LevelPack{}
for _, filename := range filenames {
// Resolve the filename to a definite path on disk.
path, err := filesystem.FindFile(filename)
if err != nil {
log.Error("LoadAllAvailable: FindFile(%s): %s", path, err)
return filenames, nil, err
}
lp, err := LoadFile(path)
if err != nil {
return filenames, nil, fmt.Errorf("LoadAllAvailable: LoadFile(%s): %s", path, err)
}
dictionary[filename] = lp
}
return filenames, dictionary, nil
}
// ListFiles lists all the discoverable levelpack files, starting from
// the game's built-ins all the way to user levelpacks.
func ListFiles() ([]string, error) {
var names []string
// List levelpacks embedded into the binary.
if files, err := assets.AssetDir("assets/levelpacks"); err == nil {
names = append(names, files...)
}
// WASM stops here, no filesystem access.
if runtime.GOOS == "js" {
return names, nil
}
// Read system-level levelpacks.
files, _ := ioutil.ReadDir(filesystem.SystemLevelPacksPath)
for _, file := range files {
name := file.Name()
if strings.HasSuffix(name, enum.LevelPackExt) {
names = append(names, name)
}
}
// Append user levelpacks.
files, _ = ioutil.ReadDir(userdir.LevelPackDirectory)
for _, file := range files {
name := file.Name()
if strings.HasSuffix(name, enum.LevelPackExt) {
names = append(names, name)
}
}
// Deduplicate strings. Can happen e.g. because assets/ is baked
// in to bindata but files also exist there locally.
var (
dedupe []string
seen = map[string]interface{}{}
)
for _, value := range names {
if _, ok := seen[value]; !ok {
seen[value] = nil
dedupe = append(dedupe, value)
}
}
sort.Strings(dedupe)
return dedupe, nil
}
// WriteFile saves the metadata to a .json file on disk.
func (l LevelPack) WriteFile(filename string) error {
out, err := json.Marshal(l)
if err != nil {
return err
}
return ioutil.WriteFile(filename, out, 0655)
}
// WriteZipfile saves a levelpack back into a zip file.
func (l LevelPack) WriteZipfile(filename string) error {
fh, err := os.Create(filename)
if err != nil {
return err
}
defer fh.Close()
// Copy all of the levels and other files from the old zip to new zip.
zf := zip.NewWriter(fh)
defer zf.Close()
// Copy attached doodads and levels.
for _, file := range l.Zipfile.File {
if !strings.HasPrefix(file.Name, "doodads/") &&
!strings.HasPrefix(file.Name, "levels/") {
continue
}
if err := zf.Copy(file); err != nil {
return err
}
}
// Write the index.json metadata.
meta, err := json.Marshal(l)
if err != nil {
return err
}
writer, err := zf.Create("index.json")
if err != nil {
return err
}
_, err = writer.Write(meta)
return err
}
// GetFile returns file data from inside the loaded zipfile of a levelpack.
//
// This also implements the Embeddable interface.
func (l LevelPack) GetFile(filename string) ([]byte, error) {
if l.Zipfile == nil {
return []byte{}, errors.New("zipfile not loaded")
}
// NOTE: levelpacks don't have an "assets/" prefix but the game
// might come looking for "assets/doodads"
if strings.HasPrefix(filename, balance.EmbeddedDoodadsBasePath) {
filename = strings.Replace(filename, balance.EmbeddedDoodadsBasePath, "doodads/", 1)
}
file, err := l.Zipfile.Open(filename)
if err != nil {
return []byte{}, err
}
return ioutil.ReadAll(file)
}
// GetJSON loads a JSON file from the zipfile and marshals it into your struct.
func (l LevelPack) GetJSON(v interface{}, filename string) error {
data, err := l.GetFile(filename)
if err != nil {
return err
}
return json.Unmarshal(data, v)
}
// ListFiles returns the files in the zipfile that match the prefix given.
func (l LevelPack) ListFiles(prefix string) []string {
var result []string
if l.Zipfile != nil {
for _, file := range l.Zipfile.File {
if strings.HasPrefix(file.Name, prefix) {
result = append(result, file.Name)
}
}
}
return result
}