mirror of https://github.com/dirtbags/moth.git
Merge branch 'v4' of https://github.com/dirtbags/moth into v4
This commit is contained in:
commit
3f1c5bf059
|
@ -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 .
|
|
|
@ -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
|
||||||
|
|
|
@ -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 ./...
|
|
@ -4,8 +4,5 @@
|
||||||
*.o
|
*.o
|
||||||
.idea
|
.idea
|
||||||
./bin/
|
./bin/
|
||||||
build/
|
|
||||||
cache/
|
|
||||||
target/
|
|
||||||
puzzles
|
puzzles
|
||||||
__debug_bin
|
__debug_bin
|
||||||
|
|
21
CHANGELOG.md
21
CHANGELOG.md
|
@ -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
|
||||||
|
|
||||||
|
|
19
Dockerfile
19
Dockerfile
|
@ -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" ]
|
|
|
@ -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
|
||||||
|
|
14
build.sh
14
build.sh
|
@ -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
|
|
|
@ -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" ]
|
|
@ -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
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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) {
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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])
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -13,7 +13,7 @@ pre:
|
||||||
- Buster
|
- Buster
|
||||||
- DW
|
- DW
|
||||||
attachments:
|
attachments:
|
||||||
- filename: moo.txt
|
- moo.txt
|
||||||
---
|
---
|
||||||
YAML body
|
YAML body
|
||||||
`)
|
`)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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."
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in New Issue