This commit is contained in:
Neale Pickett 2020-10-12 16:46:28 +00:00
commit 3f1c5bf059
30 changed files with 411 additions and 162 deletions

View File

@ -1,12 +0,0 @@
name: Mothd Docker build
on: [push]
jobs:
build-mothd:
name: Build mothd
runs-on: ubuntu-latest
steps:
- name: Retrieve code
uses: actions/checkout@v1
- name: Build mothd
run: docker build .

View File

@ -1,4 +1,4 @@
name: Publish Container image name: Publish
on: on:
release: release:
types: [published] types: [published]
@ -10,18 +10,45 @@ jobs:
steps: steps:
- name: Retrieve code - name: Retrieve code
uses: actions/checkout@v1 uses: actions/checkout@v1
- name: Push to GitHub Packages
- name: Push moth to GitHub Packages
uses: docker/build-push-action@v2 uses: docker/build-push-action@v2
with: with:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
target: moth
file: build/package/Containerfile
registry: docker.pkg.github.com registry: docker.pkg.github.com
repository: dirtbags/moth/moth repository: dirtbags/moth/moth
tag_with_ref: true tag_with_ref: true
- name: Push to Docker Hub
- name: Push moth-devel to GitHub Packages
uses: docker/build-push-action@v2
with:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
target: moth-devel
file: build/package/Containerfile
registry: docker.pkg.github.com
repository: dirtbags/moth/moth-devel
tag_with_ref: true
- name: Push moth to Docker Hub
uses: docker/build-push-action@v2 uses: docker/build-push-action@v2
with: with:
username: neale username: neale
password: ${{ secrets.DOCKER_TOKEN }} password: ${{ secrets.DOCKER_TOKEN }}
registry: dirtbags/moth target: moth
file: build/packages/Containerfile
repository: dirtbags/moth
tag_with_ref: true
- name: Push moth-devel to Docker Hub
uses: docker/build-push-action@v2
with:
username: neale
password: ${{ secrets.DOCKER_TOKEN }}
target: moth-devel
file: build/packages/Containerfile
repository: dirtbags/moth-devel
tag_with_ref: true tag_with_ref: true

16
.github/workflows/test.yml vendored Normal file
View File

@ -0,0 +1,16 @@
name: Tests
on: [push]
jobs:
test-mothd:
name: Test mothd
runs-on: ubuntu-latest
steps:
- name: Install Go
uses: actions/setup-go@v2
with:
go-version: 1.13
- name: Retrieve code
uses: actions/checkout@v1
- name: Test
run: go test ./...

3
.gitignore vendored
View File

@ -4,8 +4,5 @@
*.o *.o
.idea .idea
./bin/ ./bin/
build/
cache/
target/
puzzles puzzles
__debug_bin __debug_bin

View File

@ -4,20 +4,29 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [4.0.0] - Unreleased ## [v4.0.0] - Unreleased
### Added
- New `transpile` command to replace some functionality of devel server
### Changed ### Changed
- Major rewrite/refactor of `mothd` - Major rewrite/refactor of `mothd`
- There are now providers for State, Puzzles, and Theme. Sqlite, Redis, or S3 should fit in easily. - Clear separation of roles: State, Puzzles, and Theme
- Sqlite, Redis, or S3 should fit in easily now
- Will allow "dynamic" puzzles now, we just need a flag to enable it
- Server no longer provides unlocked content - Server no longer provides unlocked content
- Puzzle URLs are now just `/content/${cat}/${points}/` - Puzzle URLs are now just `/content/${cat}/${points}/`
- Changes to `state` directory
- Most files now have a bit of (English) documentation at the beginning
- `state/until` is now `state/hours` and can specify multiple begin/end hours - `state/until` is now `state/hours` and can specify multiple begin/end hours
- `state/disabled` is now `state/enabled` - `state/disabled` is now `state/enabled`
- Mothball structure has changed substantially - Mothball structure has changed
- Mothballs no longer contain `map.txt` - Mothballs no longer contain `map.txt`
- Mothballs no longer obfuscate content paths
- Clients now expect unlocked puzzles to just be `map[string][]int` - Clients now expect unlocked puzzles to just be `map[string][]int`
- New `/state` API endpoint
- Provides *all* server state: event log, team mapping, messages, configuration
### Added
- New `transpile` CLI command
- Provides `mothball` action to create mothballs
- Lets you test a few development server things, if you want
### Deprecated ### Deprecated

View File

@ -1,19 +0,0 @@
FROM golang:1.13 AS builder
COPY go.* /src/
COPY pkg /src/pkg/
COPY cmd /src/cmd/
COPY theme /target/theme/
COPY example-puzzles /target/puzzles/
COPY LICENSE.md /target/
WORKDIR /src/
RUN CGO_ENABLED=0 GOOS=linux go install -i -a -ldflags '-extldflags "-static"' ./...
RUN mkdir -p /target/bin
RUN cp /go/bin/* /target/bin/
FROM builder AS tester
RUN go test ./...
FROM scratch
COPY --from=builder /target /
ENTRYPOINT [ "/bin/mothd" ]

View File

@ -1,8 +1,8 @@
Dirtbags Monarch Of The Hill Server Dirtbags Monarch Of The Hill Server
===================== =====================
![](https://github.com/dirtbags/moth/workflows/Mothd%20Docker%20build/badge.svg?branch=master) ![Build badge](https://github.com/dirtbags/moth/workflows/Mothd%20Docker%20build/badge.svg?branch=master)
![](https://github.com/dirtbags/moth/workflows/moth-devel%20Docker%20build/badge.svg?branch=master) ![Go report card](https://goreportcard.com/badge/github.com/dirtbags/moth)
Monarch Of The Hill (MOTH) is a puzzle server. Monarch Of The Hill (MOTH) is a puzzle server.
We (the authors) have used it for instructional and contest events called We (the authors) have used it for instructional and contest events called

View File

@ -1 +0,0 @@
3.5.1

View File

@ -1,14 +0,0 @@
#! /bin/sh
set -e
read version < VERSION
cd $(dirname $0)
for img in moth moth-devel; do
echo "==== $img"
sudo docker build --build-arg http_proxy=$http_proxy --build-arg https_proxy=$https_proxy --tag dirtbags/$img --tag dirtbags/$img:$version -f Dockerfile.$img .
[ "$1" = "-push" ] && docker push dirtbags/$img:$version && docker push dirtbags/$img:latest
done
exit 0

View File

@ -0,0 +1,37 @@
FROM golang:1.13 AS builder
COPY go.* /src/
COPY pkg /src/pkg/
COPY cmd /src/cmd/
COPY theme /target/theme/
COPY example-puzzles /target/puzzles/
COPY LICENSE.md /target/
WORKDIR /src/
RUN CGO_ENABLED=0 GOOS=linux go install -i -a -ldflags '-extldflags "-static"' ./...
# I can't put these in /target/bin: doing so would cause the devel server to overwrite Ubuntu's /bin
RUN mkdir -p /target/bin/
RUN cp /go/bin/* /target/
##########
FROM builder AS tester
RUN go test ./...
##########
FROM scratch AS moth
COPY --from=builder /target /
ENTRYPOINT [ "/mothd" ]
##########
FROM ubuntu AS moth-devel
RUN apt-get -y update && apt-get -y install \
build-essential \
bsdgames \
figlet toilet \
python3 \
python3-pil \
lua5.3
COPY --from=builder /bin/* /
CMD [ "/mothd", "-puzzles", "/puzzles" ]

22
build/package/build.sh Executable file
View File

@ -0,0 +1,22 @@
#! /bin/sh
set -e
cd $(dirname $0)/../..
PODMAN=$(command -v podman || echo docker)
VERSION=$(cat CHANGELOG.md | awk -F '[][]' '/^## \[/ {print $2; exit}')
for target in moth moth-devel; do
tag=dirtbags/$target:$VERSION
echo "==== Building $tag"
$PODMAN build \
--build-arg http_proxy --build-arg https_proxy --build-arg no_proxy \
--tag dirtbags/$target \
--tag dirtbags/$target:$VERSION \
--target $target \
-f build/package/Containerfile .
[ "$1" = "-push" ] && docker push dirtbags/$target:$VERSION && docker push dirtbags/$img:latest
done
exit 0

View File

@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/dirtbags/moth/pkg/jsend" "github.com/dirtbags/moth/pkg/jsend"
) )
@ -29,6 +30,10 @@ func NewHTTPServer(base string, server *MothServer) *HTTPServer {
h.HandleMothFunc("/register", h.RegisterHandler) h.HandleMothFunc("/register", h.RegisterHandler)
h.HandleMothFunc("/answer", h.AnswerHandler) h.HandleMothFunc("/answer", h.AnswerHandler)
h.HandleMothFunc("/content/", h.ContentHandler) h.HandleMothFunc("/content/", h.ContentHandler)
if server.Config.Devel {
h.HandleMothFunc("/mothballer/", h.MothballerHandler)
}
return h return h
} }
@ -128,16 +133,16 @@ func (h *HTTPServer) AnswerHandler(mh MothRequestHandler, w http.ResponseWriter,
// ContentHandler returns static content from a given puzzle // ContentHandler returns static content from a given puzzle
func (h *HTTPServer) ContentHandler(mh MothRequestHandler, w http.ResponseWriter, req *http.Request) { func (h *HTTPServer) ContentHandler(mh MothRequestHandler, w http.ResponseWriter, req *http.Request) {
trimLen := len(h.base) + len("/content/") parts := strings.SplitN(req.URL.Path[len(h.base)+1:], "/", 4)
parts := strings.SplitN(req.URL.Path[trimLen:], "/", 3) if len(parts) < 4 {
if len(parts) < 3 { http.NotFound(w, req)
http.Error(w, "Not Found", http.StatusNotFound)
return return
} }
cat := parts[0] // parts[0] == "content"
pointsStr := parts[1] cat := parts[1]
filename := parts[2] pointsStr := parts[2]
filename := parts[3]
if filename == "" { if filename == "" {
filename = "puzzle.json" filename = "puzzle.json"
@ -154,3 +159,23 @@ func (h *HTTPServer) ContentHandler(mh MothRequestHandler, w http.ResponseWriter
http.ServeContent(w, req, filename, mtime, mf) http.ServeContent(w, req, filename, mtime, mf)
} }
// MothballerHandler returns a mothball
func (h *HTTPServer) MothballerHandler(mh MothRequestHandler, w http.ResponseWriter, req *http.Request) {
parts := strings.SplitN(req.URL.Path[len(h.base)+1:], "/", 2)
if len(parts) < 2 {
http.NotFound(w, req)
return
}
// parts[0] == "mothballer"
filename := parts[1]
cat := strings.TrimSuffix(filename, ".mb")
mothball, err := mh.Mothball(cat)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.ServeContent(w, req, filename, time.Now(), mothball)
}

View File

@ -8,6 +8,8 @@ import (
"net/url" "net/url"
"testing" "testing"
"time" "time"
"github.com/spf13/afero"
) )
const TestParticipantID = "shipox" const TestParticipantID = "shipox"
@ -123,3 +125,39 @@ func TestHttpd(t *testing.T) {
t.Error("Unexpected body", r.Body.String()) t.Error("Unexpected body", r.Body.String())
} }
} }
func TestDevelMemHttpd(t *testing.T) {
srv := NewTestServer()
{
hs := NewHTTPServer("/", srv)
if r := hs.TestRequest("/mothballer/pategory.md", nil); r.Result().StatusCode != 404 {
t.Error("Should have gotten a 404 for mothballer in prod mode")
}
}
{
srv.Config.Devel = true
hs := NewHTTPServer("/", srv)
if r := hs.TestRequest("/mothballer/pategory.md", nil); r.Result().StatusCode != 500 {
t.Log(r.Body.String())
t.Log(r.Result())
t.Error("Should have given us an internal server error, since category is a mothball")
}
}
}
func TestDevelFsHttps(t *testing.T) {
fs := afero.NewBasePathFs(afero.NewOsFs(), "testdata")
transpilerProvider := NewTranspilerProvider(fs)
srv := NewMothServer(Configuration{Devel: true}, NewTestTheme(), NewTestState(), transpilerProvider)
hs := NewHTTPServer("/", srv)
if r := hs.TestRequest("/mothballer/cat0.mb", nil); r.Result().StatusCode != 200 {
t.Log(r.Body.String())
t.Log(r.Result())
t.Error("Didn't get a Mothball")
}
}

View File

@ -2,7 +2,9 @@ package main
import ( import (
"flag" "flag"
"fmt"
"mime" "mime"
"os"
"time" "time"
"github.com/spf13/afero" "github.com/spf13/afero"
@ -44,8 +46,22 @@ func main() {
"/", "/",
"Base URL of this instance", "Base URL of this instance",
) )
seed := flag.String(
"seed",
"",
"Random seed to use, overrides $SEED",
)
flag.Parse() flag.Parse()
// Set random seed
if *seed == "" {
*seed = os.Getenv("SEED")
}
if *seed == "" {
*seed = fmt.Sprintf("%d%d", os.Getpid(), time.Now().Unix())
}
os.Setenv("SEED", *seed)
osfs := afero.NewOsFs() osfs := afero.NewOsFs()
theme := NewTheme(afero.NewBasePathFs(osfs, *themePath)) theme := NewTheme(afero.NewBasePathFs(osfs, *themePath))
state := NewState(afero.NewBasePathFs(osfs, *statePath)) state := NewState(afero.NewBasePathFs(osfs, *statePath))

View File

@ -3,6 +3,7 @@ package main
import ( import (
"archive/zip" "archive/zip"
"bufio" "bufio"
"bytes"
"fmt" "fmt"
"io" "io"
"log" "log"
@ -172,6 +173,11 @@ func (m *Mothballs) refresh() {
} }
} }
// Mothball just returns an error
func (m *Mothballs) Mothball(cat string) (*bytes.Reader, error) {
return nil, fmt.Errorf("Can't repackage a compiled mothball")
}
// Maintain performs housekeeping for Mothballs. // Maintain performs housekeeping for Mothballs.
func (m *Mothballs) Maintain(updateInterval time.Duration) { func (m *Mothballs) Maintain(updateInterval time.Duration) {
m.refresh() m.refresh()

View File

@ -4,6 +4,7 @@ package main
import ( import (
"bytes" "bytes"
"context" "context"
"fmt"
"io" "io"
"log" "log"
"os" "os"
@ -122,6 +123,11 @@ func (pc ProviderCommand) CheckAnswer(cat string, points int, answer string) (bo
return true, nil return true, nil
} }
// Mothball just returns an error
func (pc ProviderCommand) Mothball(cat string) (*bytes.Reader, error) {
return nil, fmt.Errorf("Can't package a command-generated category")
}
// Maintain does nothing: a command puzzle ProviderCommand has no housekeeping // Maintain does nothing: a command puzzle ProviderCommand has no housekeeping
func (pc ProviderCommand) Maintain(updateInterval time.Duration) { func (pc ProviderCommand) Maintain(updateInterval time.Duration) {
} }

View File

@ -1,6 +1,7 @@
package main package main
import ( import (
"bytes"
"fmt" "fmt"
"io" "io"
"strconv" "strconv"
@ -41,6 +42,7 @@ type PuzzleProvider interface {
Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error) Open(cat string, points int, path string) (ReadSeekCloser, time.Time, error)
Inventory() []Category Inventory() []Category
CheckAnswer(cat string, points int, answer string) (bool, error) CheckAnswer(cat string, points int, answer string) (bool, error)
Mothball(cat string) (*bytes.Reader, error)
Maintainer Maintainer
} }
@ -229,3 +231,16 @@ func (mh *MothRequestHandler) ExportState() *StateExport {
return &export return &export
} }
// Mothball generates a mothball for the given category.
func (mh *MothRequestHandler) Mothball(cat string) (r *bytes.Reader, err error) {
if !mh.Config.Devel {
return nil, fmt.Errorf("Cannot mothball in production mode")
}
for _, provider := range mh.PuzzleProviders {
if r, err = provider.Mothball(cat); err == nil {
return r, nil
}
}
return nil, err
}

View File

@ -69,6 +69,12 @@ func (p TranspilerProvider) CheckAnswer(cat string, points int, answer string) (
return c.Answer(points, answer), nil return c.Answer(points, answer), nil
} }
// Mothball packages up a category into a mothball.
func (p TranspilerProvider) Mothball(cat string) (*bytes.Reader, error) {
c := transpile.NewFsCategory(p.fs, cat)
return transpile.Mothball(c)
}
// Maintain performs housekeeping. // Maintain performs housekeeping.
func (p TranspilerProvider) Maintain(updateInterval time.Duration) { func (p TranspilerProvider) Maintain(updateInterval time.Duration) {
// Nothing to do here. // Nothing to do here.

View File

@ -32,17 +32,21 @@ func nothing() error {
return nil return nil
} }
func usage(w io.Writer) {
fmt.Fprintln(w, "Usage: transpile COMMAND [flags]")
fmt.Fprintln(w, "")
fmt.Fprintln(w, " mothball: Compile a mothball")
fmt.Fprintln(w, " inventory: Show category inventory")
fmt.Fprintln(w, " open: Open a file for a puzzle")
fmt.Fprintln(w, " answer: Check correctness of an answer")
}
// ParseArgs parses arguments and runs the appropriate action. // ParseArgs parses arguments and runs the appropriate action.
func (t *T) ParseArgs() (Command, error) { func (t *T) ParseArgs() (Command, error) {
var cmd Command var cmd Command
if len(t.Args) == 1 { if len(t.Args) == 1 {
fmt.Fprintln(t.Stderr, "Usage: transpile COMMAND [flags]") usage(t.Stderr)
fmt.Fprintln(t.Stderr, "")
fmt.Fprintln(t.Stderr, " mothball: Compile a mothball")
fmt.Fprintln(t.Stderr, " inventory: Show category inventory")
fmt.Fprintln(t.Stderr, " open: Open a file for a puzzle")
fmt.Fprintln(t.Stderr, " answer: Check correctness of an answer")
return nothing, nil return nothing, nil
} }
@ -60,7 +64,11 @@ func (t *T) ParseArgs() (Command, error) {
case "answer": case "answer":
flags.StringVar(&t.answer, "answer", "", "Answer to check") flags.StringVar(&t.answer, "answer", "", "Answer to check")
cmd = t.CheckAnswer cmd = t.CheckAnswer
case "help":
usage(t.Stderr)
return nothing, nil
default: default:
usage(t.Stderr)
return nothing, fmt.Errorf("%s is not a valid command", t.Args[1]) return nothing, fmt.Errorf("%s is not a valid command", t.Args[1])
} }
@ -69,6 +77,7 @@ func (t *T) ParseArgs() (Command, error) {
return nothing, err return nothing, err
} }
if *directory != "" { if *directory != "" {
log.Println(*directory)
t.fs = afero.NewBasePathFs(t.BaseFs, *directory) t.fs = afero.NewBasePathFs(t.BaseFs, *directory)
} else { } else {
t.fs = t.BaseFs t.fs = t.BaseFs
@ -101,6 +110,7 @@ func (t *T) PrintInventory() error {
} }
// DumpFile writes a file to the writer. // DumpFile writes a file to the writer.
// BUG(neale): The "open" and "answer" actions don't work on categories with an "mkcategory" executable.
func (t *T) DumpFile() error { func (t *T) DumpFile() error {
puzzle := transpile.NewFsPuzzle(t.fs) puzzle := transpile.NewFsPuzzle(t.fs)
@ -160,6 +170,7 @@ func main() {
Stdout: os.Stdout, Stdout: os.Stdout,
Stderr: os.Stderr, Stderr: os.Stderr,
Args: os.Args, Args: os.Args,
BaseFs: afero.NewOsFs(),
} }
cmd, err := t.ParseArgs() cmd, err := t.ParseArgs()
if err != nil { if err != nil {

View File

@ -7,26 +7,12 @@ import random
import shutil import shutil
import sys import sys
parser = argparse.ArgumentParser("Generate a puzzle") random.seed(os.getenv("SEED", ""))
parser.add_argument("--file", dest="file", help="File to provide, instead of puzzle")
parser.add_argument("--answer", dest="answer", help="Answer to check, instead of providing puzzle")
args = parser.parse_args()
seed = hash(os.getenv("SEED"))
random.seed(seed)
words = ["apple", "pear", "peach", "tangerine", "orange", "potato", "carrot", "pea"] words = ["apple", "pear", "peach", "tangerine", "orange", "potato", "carrot", "pea"]
answer = ' '.join(random.sample(words, 4)) answer = ' '.join(random.sample(words, 4))
if args.file: def puzzle():
f = open(args.file, "rb")
shutil.copyfileobj(f, sys.stdout.buffer)
elif args.answer:
if args.answer == answer:
print("correct")
else:
print("incorrect")
else:
number = random.randint(20, 500) number = random.randint(20, 500)
obj = { obj = {
"Pre": { "Pre": {
@ -53,3 +39,22 @@ else:
} }
} }
json.dump(obj, sys.stdout) json.dump(obj, sys.stdout)
def open_file(filename):
f = open(filename, "rb")
shutil.copyfileobj(f, sys.stdout.buffer)
def check_answer(check):
if answer == check:
print("correct")
else:
print("incorrect")
if len(sys.argv) == 1:
puzzle()
elif sys.argv[1] == "file":
open_file(sys.argv[2])
elif sys.argv[1] == "answer":
check_answer(sys.argv[2])
else:
raise RuntimeError("Unknown command: %s" % sys.argv[1])

View File

@ -4,8 +4,10 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"os/exec" "os/exec"
"path"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -120,14 +122,22 @@ type FsCommandCategory struct {
timeout time.Duration timeout time.Duration
} }
// Inventory returns a list of point values for this category. func (c FsCommandCategory) run(command string, args ...string) ([]byte, error) {
func (c FsCommandCategory) Inventory() ([]int, error) {
ctx, cancel := context.WithTimeout(context.Background(), c.timeout) ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
defer cancel() defer cancel()
cmd := exec.CommandContext(ctx, c.command, "inventory") cmdargs := append([]string{command}, args...)
stdout, err := cmd.Output() cmd := exec.CommandContext(ctx, "./"+path.Base(c.command), cmdargs...)
if err != nil { cmd.Dir = path.Dir(c.command)
return cmd.Output()
}
// Inventory returns a list of point values for this category.
func (c FsCommandCategory) Inventory() ([]int, error) {
stdout, err := c.run("inventory")
if exerr, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("inventory: %s: %s", err, string(exerr.Stderr))
} else if err != nil {
return nil, err return nil, err
} }
@ -143,11 +153,7 @@ func (c FsCommandCategory) Inventory() ([]int, error) {
func (c FsCommandCategory) Puzzle(points int) (Puzzle, error) { func (c FsCommandCategory) Puzzle(points int) (Puzzle, error) {
var p Puzzle var p Puzzle
ctx, cancel := context.WithTimeout(context.Background(), c.timeout) stdout, err := c.run("puzzle", strconv.Itoa(points))
defer cancel()
cmd := exec.CommandContext(ctx, c.command, "puzzle", strconv.Itoa(points))
stdout, err := cmd.Output()
if err != nil { if err != nil {
return p, err return p, err
} }
@ -163,21 +169,13 @@ func (c FsCommandCategory) Puzzle(points int) (Puzzle, error) {
// Open returns an io.ReadCloser for the given filename. // Open returns an io.ReadCloser for the given filename.
func (c FsCommandCategory) Open(points int, filename string) (ReadSeekCloser, error) { func (c FsCommandCategory) Open(points int, filename string) (ReadSeekCloser, error) {
ctx, cancel := context.WithTimeout(context.Background(), c.timeout) stdout, err := c.run("file", strconv.Itoa(points), filename)
defer cancel()
cmd := exec.CommandContext(ctx, c.command, "file", strconv.Itoa(points), filename)
stdout, err := cmd.Output()
return nopCloser{bytes.NewReader(stdout)}, err return nopCloser{bytes.NewReader(stdout)}, err
} }
// Answer checks whether an answer is correct. // Answer checks whether an answer is correct.
func (c FsCommandCategory) Answer(points int, answer string) bool { func (c FsCommandCategory) Answer(points int, answer string) bool {
ctx, cancel := context.WithTimeout(context.Background(), c.timeout) stdout, err := c.run("answer", strconv.Itoa(points), answer)
defer cancel()
cmd := exec.CommandContext(ctx, c.command, "answer", strconv.Itoa(points), answer)
stdout, err := cmd.Output()
if err != nil { if err != nil {
log.Printf("ERROR: Answering %d points: %s", points, err) log.Printf("ERROR: Answering %d points: %s", points, err)
return false return false

View File

@ -13,7 +13,7 @@ pre:
- Buster - Buster
- DW - DW
attachments: attachments:
- filename: moo.txt - moo.txt
--- ---
YAML body YAML body
`) `)

View File

@ -3,6 +3,7 @@ package transpile
import ( import (
"log" "log"
"sort" "sort"
"strings"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@ -20,12 +21,16 @@ func FsInventory(fs afero.Fs) (Inventory, error) {
inv := make(Inventory) inv := make(Inventory)
for _, ent := range dirEnts { for _, ent := range dirEnts {
if strings.HasPrefix(ent.Name(), ".") {
continue
}
if ent.IsDir() { if ent.IsDir() {
name := ent.Name() name := ent.Name()
c := NewFsCategory(fs, name) c := NewFsCategory(fs, name)
puzzles, err := c.Inventory() puzzles, err := c.Inventory()
if err != nil { if err != nil {
return nil, err log.Printf("Inventory: %s: %s", name, err)
continue
} }
sort.Ints(puzzles) sort.Ints(puzzles)
inv[name] = puzzles inv[name] = puzzles

View File

@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"os/exec"
) )
// Mothball packages a Category up for a production server run. // Mothball packages a Category up for a production server run.
@ -37,7 +38,7 @@ func Mothball(c Category) (*bytes.Reader, error) {
} }
puzzle, err := c.Puzzle(points) puzzle, err := c.Puzzle(points)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("Puzzle %d: %s", points, err)
} }
// Record answers in answers.txt // Record answers in answers.txt
@ -45,13 +46,16 @@ func Mothball(c Category) (*bytes.Reader, error) {
fmt.Fprintln(answersTxt, points, answer) fmt.Fprintln(answersTxt, points, answer)
} }
// Remove all answers from puzzle object // Remove answers and debugging from puzzle object
puzzle.Answers = []string{} puzzle.Answers = []string{}
puzzle.Debug.Errors = []string{}
puzzle.Debug.Hints = []string{}
puzzle.Debug.Log = []string{}
// Write out Puzzle object // Write out Puzzle object
penc := json.NewEncoder(pw) penc := json.NewEncoder(pw)
if err := penc.Encode(puzzle); err != nil { if err := penc.Encode(puzzle); err != nil {
return nil, err return nil, fmt.Errorf("Puzzle %d: %s", points, err)
} }
// Write out all attachments and scripts // Write out all attachments and scripts
@ -63,11 +67,13 @@ func Mothball(c Category) (*bytes.Reader, error) {
return nil, err return nil, err
} }
ar, err := c.Open(points, att) ar, err := c.Open(points, att)
if err != nil { if exerr, ok := err.(*exec.ExitError); ok {
return nil, err return nil, fmt.Errorf("Puzzle %d: %s: %s: %s", points, att, err, string(exerr.Stderr))
} else if err != nil {
return nil, fmt.Errorf("Puzzle %d: %s: %s", points, att, err)
} }
if _, err := io.Copy(aw, ar); err != nil { if _, err := io.Copy(aw, ar); err != nil {
return nil, err return nil, fmt.Errorf("Puzzle %d: %s: %s", points, att, err)
} }
} }
} }

View File

@ -92,6 +92,26 @@ type StaticAttachment struct {
FilesystemPath string // Filename in backing FS (URL, mothball, or local FS) FilesystemPath string // Filename in backing FS (URL, mothball, or local FS)
} }
// UnmarshalYAML allows a StaticAttachment to be specified as a single string.
// The way the yaml library works is weird.
func (sa *StaticAttachment) UnmarshalYAML(unmarshal func(interface{}) error) error {
if err := unmarshal(&sa.Filename); err == nil {
sa.FilesystemPath = sa.Filename
return nil
}
parts := new(struct {
Filename string
FilesystemPath string
})
if err := unmarshal(parts); err != nil {
return err
}
sa.Filename = parts.Filename
sa.FilesystemPath = parts.FilesystemPath
return nil
}
// ReadSeekCloser provides io.Reader, io.Seeker, and io.Closer. // ReadSeekCloser provides io.Reader, io.Seeker, and io.Closer.
type ReadSeekCloser interface { type ReadSeekCloser interface {
io.Reader io.Reader
@ -307,8 +327,16 @@ func rfc822HeaderParser(r io.Reader) (StaticPuzzle, error) {
p.Debug.Summary = val[0] p.Debug.Summary = val[0]
case "hint": case "hint":
p.Debug.Hints = val p.Debug.Hints = val
case "solution":
p.Debug.Hints = val
case "ksa": case "ksa":
p.Post.KSAs = val p.Post.KSAs = val
case "objective":
p.Post.Objective = val[0]
case "success.acceptable":
p.Post.Success.Acceptable = val[0]
case "success.mastery":
p.Post.Success.Mastery = val[0]
default: default:
return p, fmt.Errorf("Unknown header field: %s", key) return p, fmt.Errorf("Unknown header field: %s", key)
} }
@ -338,18 +366,19 @@ type FsCommandPuzzle struct {
timeout time.Duration timeout time.Duration
} }
func (fp FsCommandPuzzle) run(args ...string) ([]byte, error) { func (fp FsCommandPuzzle) run(command string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), fp.timeout) ctx, cancel := context.WithTimeout(context.Background(), fp.timeout)
defer cancel() defer cancel()
cmd := exec.CommandContext(ctx, "./"+path.Base(fp.command), args...) cmdargs := append([]string{command}, args...)
cmd := exec.CommandContext(ctx, "./"+path.Base(fp.command), cmdargs...)
cmd.Dir = path.Dir(fp.command) cmd.Dir = path.Dir(fp.command)
return cmd.Output() return cmd.Output()
} }
// Puzzle returns a Puzzle struct for the current puzzle. // Puzzle returns a Puzzle struct for the current puzzle.
func (fp FsCommandPuzzle) Puzzle() (Puzzle, error) { func (fp FsCommandPuzzle) Puzzle() (Puzzle, error) {
stdout, err := fp.run() stdout, err := fp.run("puzzle")
if exiterr, ok := err.(*exec.ExitError); ok { if exiterr, ok := err.(*exec.ExitError); ok {
return Puzzle{}, errors.New(string(exiterr.Stderr)) return Puzzle{}, errors.New(string(exiterr.Stderr))
} else if err != nil { } else if err != nil {
@ -379,7 +408,7 @@ func (c nopCloser) Close() error {
// Open returns a newly-opened file. // Open returns a newly-opened file.
// BUG(neale): FsCommandPuzzle.Open() reads everything into memory, and will suck for large files. // BUG(neale): FsCommandPuzzle.Open() reads everything into memory, and will suck for large files.
func (fp FsCommandPuzzle) Open(filename string) (ReadSeekCloser, error) { func (fp FsCommandPuzzle) Open(filename string) (ReadSeekCloser, error) {
stdout, err := fp.run("--file", filename) stdout, err := fp.run("file", filename)
buf := nopCloser{bytes.NewReader(stdout)} buf := nopCloser{bytes.NewReader(stdout)}
if err != nil { if err != nil {
return buf, err return buf, err
@ -390,7 +419,7 @@ func (fp FsCommandPuzzle) Open(filename string) (ReadSeekCloser, error) {
// Answer checks whether the given answer is correct. // Answer checks whether the given answer is correct.
func (fp FsCommandPuzzle) Answer(answer string) bool { func (fp FsCommandPuzzle) Answer(answer string) bool {
stdout, err := fp.run("--answer", answer) stdout, err := fp.run("answer", answer)
if err != nil { if err != nil {
log.Printf("ERROR: checking answer: %s", err) log.Printf("ERROR: checking answer: %s", err)
return false return false

View File

@ -136,3 +136,35 @@ func TestFsPuzzle(t *testing.T) {
t.Error("Error answer marked correct") t.Error("Error answer marked correct")
} }
} }
func TestAttachment(t *testing.T) {
buf := bytes.NewBufferString(`
pre:
attachments:
- simple
- filename: complex
filesystempath: backingfile
`)
p, err := yamlHeaderParser(buf)
if err != nil {
t.Error(err)
return
}
att := p.Pre.Attachments
if len(att) != 2 {
t.Error("Wrong number of attachments", att)
}
if att[0].Filename != "simple" {
t.Error("Attachment 0 wrong")
}
if att[0].Filename != att[0].FilesystemPath {
t.Error("Attachment 0 wrong")
}
if att[1].Filename != "complex" {
t.Error("Attachment 1 wrong")
}
if att[1].FilesystemPath != "backingfile" {
t.Error("Attachment 2 wrong")
}
}

View File

@ -21,8 +21,8 @@ case $1:$2:$3 in
} }
EOT EOT
;; ;;
puzzle:*:) puzzle:*)
fail "No such puzzle" fail "No such puzzle: $2"
;; ;;
file:1:moo.txt) file:1:moo.txt)
echo "Moo." echo "Moo."

View File

@ -1,7 +1,12 @@
#! /bin/sh #! /bin/sh
case $1 in fail () {
"") echo "ERROR: $*" 1>&2
exit 1
}
case $1:$2 in
puzzle:)
cat <<'EOT' cat <<'EOT'
{ {
"Answers": ["answer"], "Answers": ["answer"],
@ -12,34 +17,23 @@ case $1 in
} }
EOT EOT
;; ;;
-file|--file) file:moo.txt)
case $2 in
moo.txt)
echo "Moo." echo "Moo."
;; ;;
*) file:*)
echo "ERROR: no such file: $1" 1>&2 fail "no such file: $1"
exit 1
;; ;;
esac answer:moo)
;;
-answer|--answer)
case $2 in
moo)
echo "correct" echo "correct"
;; ;;
error) answer:error)
echo "error" 1>&2 fail "you requested an error"
exit 1
;; ;;
*) answer:*)
echo "incorrect" echo "incorrect"
;; ;;
esac
;;
*) *)
echo "ERROR: don't know what to do with $1" 1>&2 fail "What is $1"
exit 1
;; ;;
esac esac

View File

@ -93,6 +93,7 @@ input:invalid {
#devel { #devel {
background-color: #eee; background-color: #eee;
color: black; color: black;
overflow: scroll;
} }
#devel .string { #devel .string {
color: #9c27b0; color: #9c27b0;

View File

@ -88,11 +88,6 @@ function submit(e) {
e.preventDefault() e.preventDefault()
let data = new FormData(e.target) let data = new FormData(e.target)
// Kludge for patterned answers
let xAnswer = data.get("xAnswer")
if (xAnswer) {
data.set("answer", xAnswer)
}
window.data = data window.data = data
fetch("answer", { fetch("answer", {
method: "POST", method: "POST",
@ -193,7 +188,6 @@ function answerCheck(e) {
checkAnswer(answer) checkAnswer(answer)
.then (correct => { .then (correct => {
document.querySelector("[name=xAnswer").value = correct || answer
if (correct) { if (correct) {
ok.textContent = "⭕" ok.textContent = "⭕"
ok.title = "Possibly correct" ok.title = "Possibly correct"