Initial commit
Some checks are pending
Build horsepaste / build (push) Waiting to run

This commit is contained in:
joshii 2026-03-15 13:36:37 +01:00
commit 0046177494
41 changed files with 31227 additions and 0 deletions

26
.gitignore vendored Normal file
View file

@ -0,0 +1,26 @@
# Compiled Object files, Static and Dynamic libs (Shared Objects)
*.o
*.a
*.so
# Folders
_obj
_test
# Architecture specific extensions/prefixes
*.[568vq]
[568vq].out
*.cgo1.go
*.cgo2.c
_cgo_defun.c
_cgo_gotypes.go
_cgo_export.*
_testmain.go
*.exe
*.test
*.prof
db

20
.travis.yml Normal file
View file

@ -0,0 +1,20 @@
language: go
install:
- go get -d -t -v ./...
- go get -v golang.org/x/lint/golint
matrix:
include:
- name: "go1.13.x-linux"
go: 1.13.x
os: linux
script: go test ./...
- name: "go1.14.x-linux"
go: 1.14.x
os: linux
script: go test ./...
- name: "go1.13.x-linux-race"
go: 1.13.x
os: linux
script: go test -race ./...

24
Dockerfile Normal file
View file

@ -0,0 +1,24 @@
# Build backend.
FROM golang:1.14-alpine as backend
WORKDIR /app
COPY . .
RUN apk add gcc musl-dev \
&& go build ./cmd/horsepaste/main.go
# Build frontend.
FROM node:12-alpine as frontend
COPY . /app
WORKDIR /app/frontend
RUN npm install -g parcel-bundler \
&& npm install \
&& sh build.sh
# Copy build artifacts from previous build stages (to remove files not necessary for
# deployment).
FROM alpine:3.11
WORKDIR /app
COPY --from=backend /app/main .
COPY --from=frontend /app/frontend/dist ./frontend/dist
COPY assets assets
EXPOSE 9091/tcp
CMD /app/main

55
README.md Normal file
View file

@ -0,0 +1,55 @@
# horsepaste
[![GoDoc](https://godoc.org/github.com/jbowens/horsepaste?status.svg)](https://godoc.org/github.com/jbowens/horsepaste)
Horsepaste is a word-guessing game. Generated boards are shareable and sync. The board can be viewed either as a clue giver or a word guesser.
A hosted version of the app is available at [www.horsepaste.com](https://www.horsepaste.com).
![Clue giver view of board](https://raw.githubusercontent.com/jbowens/horsepaste/master/screenshot.png)
## Building
The app requires a [Go](https://golang.org/) toolchain, node.js and [parcel](https://parceljs.org/) to build. Once you have those setup, build the application Go binary with:
```
go install github.com/jbowens/horsepaste/cmd/horsepaste
```
Then from the frontend directory, install the node modules:
```
npm install
```
and start the app (listens to changes)
```
npm start
```
or build the app
```
npm run build
```
### Docker
Alternatively, the repository includes a Dockerfile for building a docker image of this app.
```
docker build . -t horsepaste:latest
```
The following command will launch the docker image:
```
docker run --name horsepaste_server --rm -p 9091:9091 -d horsepaste
```
The following command will kill the docker instance:
```
docker stop horsepaste_server
```

10108
assets/game-id-words.txt Normal file

File diff suppressed because it is too large Load diff

400
assets/original.txt Normal file
View file

@ -0,0 +1,400 @@
AFRICA
AGENT
AIR
ALIEN
ALPS
AMAZON
AMBULANCE
AMERICA
ANGEL
ANTARCTICA
APPLE
ARM
ATLANTIS
AUSTRALIA
AZTEC
BACK
BALL
BAND
BANK
BAR
BARK
BAT
BATTERY
BEACH
BEAR
BEAT
BED
BEIJING
BELL
BELT
BERLIN
BERMUDA
BERRY
BILL
BLOCK
BOARD
BOLT
BOMB
BOND
BOOM
BOOT
BOTTLE
BOW
BOX
BRIDGE
BRUSH
BUCK
BUFFALO
BUG
BUGLE
BUTTON
CALF
CANADA
CAP
CAPITAL
CAR
CARD
CARROT
CASINO
CAST
CAT
CELL
CENTAUR
CENTER
CHAIR
CHANGE
CHARGE
CHECK
CHEST
CHICK
CHINA
CHOCOLATE
CHURCH
CIRCLE
CLIFF
CLOAK
CLUB
CODE
COLD
COMIC
COMPOUND
CONCERT
CONDUCTOR
CONTRACT
COOK
COPPER
COTTON
COURT
COVER
CRANE
CRASH
CRICKET
CROSS
CROWN
CYCLE
CZECH
DANCE
DATE
DAY
DEATH
DECK
DEGREE
DIAMOND
DICE
DINOSAUR
DISEASE
DOCTOR
DOG
DRAFT
DRAGON
DRESS
DRILL
DROP
DUCK
DWARF
EAGLE
EGYPT
EMBASSY
ENGINE
ENGLAND
EUROPE
EYE
FACE
FAIR
FALL
FAN
FENCE
FIELD
FIGHTER
FIGURE
FILE
FILM
FIRE
FISH
FLUTE
FLY
FOOT
FORCE
FOREST
FORK
FRANCE
GAME
GAS
GENIUS
GERMANY
GHOST
GIANT
GLASS
GLOVE
GOLD
GRACE
GRASS
GREECE
GREEN
GROUND
HAM
HAND
HAWK
HEAD
HEART
HELICOPTER
HIMALAYAS
HOLE
HOLLYWOOD
HONEY
HOOD
HOOK
HORN
HORSE
HORSESHOE
HOSPITAL
HOTEL
ICE
ICE CREAM
INDIA
IRON
IVORY
JACK
JAM
JET
JUPITER
KANGAROO
KETCHUP
KEY
KID
KING
KIWI
KNIFE
KNIGHT
LAB
LAP
LASER
LAWYER
LEAD
LEMON
LEPRECHAUN
LIFE
LIGHT
LIMOUSINE
LINE
LINK
LION
LITTER
LOCH NESS
LOCK
LOG
LONDON
LUCK
MAIL
MAMMOTH
MAPLE
MARBLE
MARCH
MASS
MATCH
MERCURY
MEXICO
MICROSCOPE
MILLIONAIRE
MINE
MINT
MISSILE
MODEL
MOLE
MOON
MOSCOW
MOUNT
MOUSE
MOUTH
MUG
NAIL
NEEDLE
NET
NEW YORK
NIGHT
NINJA
NOTE
NOVEL
NURSE
NUT
OCTOPUS
OIL
OLIVE
OLYMPUS
OPERA
ORANGE
ORGAN
PALM
PAN
PANTS
PAPER
PARACHUTE
PARK
PART
PASS
PASTE
PENGUIN
PHOENIX
PIANO
PIE
PILOT
PIN
PIPE
PIRATE
PISTOL
PIT
PITCH
PLANE
PLASTIC
PLATE
PLATYPUS
PLAY
PLOT
POINT
POISON
POLE
POLICE
POOL
PORT
POST
POUND
PRESS
PRINCESS
PUMPKIN
PUPIL
PYRAMID
QUEEN
RABBIT
RACKET
RAY
REVOLUTION
RING
ROBIN
ROBOT
ROCK
ROME
ROOT
ROSE
ROULETTE
ROUND
ROW
RULER
SATELLITE
SATURN
SCALE
SCHOOL
SCIENTIST
SCORPION
SCREEN
SCUBA DIVER
SEAL
SERVER
SHADOW
SHAKESPEARE
SHARK
SHIP
SHOE
SHOP
SHOT
SINK
SKYSCRAPER
SLIP
SLUG
SMUGGLER
SNOW
SNOWMAN
SOCK
SOLDIER
SOUL
SOUND
SPACE
SPELL
SPIDER
SPIKE
SPINE
SPOT
SPRING
SPY
SQUARE
STADIUM
STAFF
STAR
STATE
STICK
STOCK
STRAW
STREAM
STRIKE
STRING
SUB
SUIT
SUPERHERO
SWING
SWITCH
TABLE
TABLET
TAG
TAIL
TAP
TEACHER
TELESCOPE
TEMPLE
THEATER
THIEF
THUMB
TICK
TIE
TIME
TOKYO
TOOTH
TORCH
TOWER
TRACK
TRAIN
TRIANGLE
TRIP
TRUNK
TUBE
TURKEY
UNDERTAKER
UNICORN
VACUUM
VAN
VET
WAKE
WALL
WAR
WASHER
WASHINGTON
WATCH
WATER
WAVE
WEB
WELL
WHALE
WHIP
WIND
WITCH
WORM
YARD

673
assets/words.txt Normal file
View file

@ -0,0 +1,673 @@
Acne
Acre
Addendum
Advertise
Aircraft
Aisle
Alligator
Alphabetize
America
Ankle
Apathy
Applause
Applesauc
Application
Archaeologist
Aristocrat
Arm
Armada
Asleep
Astronaut
Athlete
Atlantis
Aunt
Avocado
Baby-Sitter
Backbone
Bag
Baguette
Bald
Balloon
Banana
Banister
Baseball
Baseboards
Basketball
Bat
Battery
Beach
Beanstalk
Bedbug
Beer
Beethoven
Belt
Bib
Bicycle
Big
Bike
Billboard
Bird
Birthday
Bite
Blacksmith
Blanket
Bleach
Blimp
Blossom
Blueprint
Blunt
Blur
Boa
Boat
Bob
Bobsled
Body
Bomb
Bonnet
Book
Booth
Bowtie
Box
Boy
Brainstorm
Brand
Brave
Bride
Bridge
Broccoli
Broken
Broom
Bruise
Brunette
Bubble
Buddy
Buffalo
Bulb
Bunny
Bus
Buy
Cabin
Cafeteria
Cake
Calculator
Campsite
Can
Canada
Candle
Candy
Cape
Capitalism
Car
Cardboard
Cartography
Cat
Cd
Ceiling
Cell
Century
Chair
Chalk
Champion
Charger
Cheerleader
Chef
Chess
Chew
Chicken
Chime
China
Chocolate
Church
Circus
Clay
Cliff
Cloak
Clockwork
Clown
Clue
Coach
Coal
Coaster
Cog
Cold
College
Comfort
Computer
Cone
Constrictor
Continuum
Conversation
Cook
Coop
Cord
Corduroy
Cot
Cough
Cow
Cowboy
Crayon
Cream
Crisp
Criticize
Crow
Cruise
Crumb
Crust
Cuff
Curtain
Cuticle
Czar
Dad
Dart
Dawn
Day
Deep
Defect
Dent
Dentist
Desk
Dictionary
Dimple
Dirty
Dismantle
Ditch
Diver
Doctor
Dog
Doghouse
Doll
Dominoes
Door
Dot
Drain
Draw
Dream
Dress
Drink
Drip
Drums
Dryer
Duck
Dump
Dunk
Dust
Ear
Eat
Ebony
Elbow
Electricity
Elephant
Elevator
Elf
Elm
Engine
England
Ergonomic
Escalator
Eureka
Europe
Evolution
Extension
Eyebrow
Fan
Fancy
Fast
Feast
Fence
Feudalism
Fiddle
Figment
Finger
Fire
First
Fishing
Fix
Fizz
Flagpole
Flannel
Flashlight
Flock
Flotsam
Flower
Flu
Flush
Flutter
Fog
Foil
Football
Forehead
Forever
Fortnight
France
Freckle
Freight
Fringe
Frog
Frown
Gallop
Game
Garbage
Garden
Gasoline
Gem
Ginger
Gingerbread
Girl
Glasses
Goblin
Gold
Goodbye
Grandpa
Grape
Grass
Gratitude
Gray
Green
Guitar
Gum
Gumball
Hair
Half
Handle
Handwriting
Hang
Happy
Hat
Hatch
Headache
Heart
Hedge
Helicopter
Hem
Hide
Hill
Hockey
Homework
Honk
Hopscotch
Horse
Hose
Hot
House
Houseboat
Hug
Humidifier
Hungry
Hurdle
Hurt
Hut
Ice
Implode
Inn
Inquisition
Intern
Internet
Invitation
Ironic
Ivory
Ivy
Jade
Japan
Jeans
Jelly
Jet
Jig
Jog
Journal
Jump
Key
Killer
Kilogram
King
Kitchen
Kite
Knee
Kneel
Knife
Knight
Koala
Lace
Ladder
Ladybug
Lag
Landfill
Lap
Laugh
Laundry
Law
Lawn
Lawnmower
Leak
Leg
Letter
Level
Lifestyle
Ligament
Light
Lightsaber
Lime
Lion
Lizard
Log
Loiterer
Lollipop
Loveseat
Loyalty
Lunch
Lunchbox
Lyrics
Machine
Macho
Mailbox
Mammoth
Mark
Mars
Mascot
Mast
Matchstick
Mate
Mattress
Mess
Mexico
Midsummer
Mine
Mistake
Modern
Mold
Mom
Monday
Money
Monitor
Monster
Mooch
Moon
Mop
Moth
Motorcycle
Mountain
Mouse
Mower
Mud
Music
Mute
Nature
Negotiate
Neighbor
Nest
Neutron
Niece
Night
Nightmare
Nose
Oar
Observatory
Office
Oil
Old
Olympian
Opaque
Opener
Orbit
Organ
Organize
Outer
Outside
Ovation
Overture
Pail
Paint
Pajamas
Palace
Pants
Paper
Paper
Park
Parody
Party
Password
Pastry
Pawn
Pear
Pen
Pencil
Pendulum
Penis
Penny
Pepper
Personal
Philosopher
Phone
Photograph
Piano
Picnic
Pigpen
Pillow
Pilot
Pinch
Ping
Pinwheel
Pirate
Plaid
Plan
Plank
Plate
Platypus
Playground
Plow
Plumber
Pocket
Poem
Point
Pole
Pomp
Pong
Pool
Popsicle
Population
Portfolio
Positive
Post
Princess
Procrastinate
Protestant
Psychologist
Publisher
Punk
Puppet
Puppy
Push
Puzzle
Quarantine
Queen
Quicksand
Quiet
Race
Radio
Raft
Rag
Rainbow
Rainwater
Random
Ray
Recycle
Red
Regret
Reimbursement
Retaliate
Rib
Riddle
Rim
Rink
Roller
Room
Rose
Round
Roundabout
Rung
Runt
Rut
Sad
Safe
Salmon
Salt
Sandbox
Sandcastle
Sandwich
Sash
Satellite
Scar
Scared
School
Scoundrel
Scramble
Scuff
Seashell
Season
Sentence
Sequins
Set
Shaft
Shallow
Shampoo
Shark
Sheep
Sheets
Sheriff
Shipwreck
Shirt
Shoelace
Short
Shower
Shrink
Sick
Siesta
Silhouette
Singer
Sip
Skate
Skating
Ski
Slam
Sleep
Sling
Slow
Slump
Smith
Sneeze
Snow
Snuggle
Song
Space
Spare
Speakers
Spider
Spit
Sponge
Spool
Spoon
Spring
Sprinkler
Spy
Square
Squint
Stairs
Standing
Star
State
Stick
Stockholder
Stoplight
Stout
Stove
Stowaway
Straw
Stream
Streamline
Stripe
Student
Sun
Sunburn
Sushi
Swamp
Swarm
Sweater
Swimming
Swing
Tachometer
Talk
Taxi
Teacher
Teapot
Teenager
Telephone
Ten
Tennis
Thief
Think
Throne
Through
Thunder
Tide
Tiger
Time
Tinting
Tiptoe
Tiptop
Tired
Tissue
Toast
Toilet
Tool
Toothbrush
Tornado
Tournament
Tractor
Train
Trash
Treasure
Tree
Triangle
Trip
Truck
Tub
Tuba
Tutor
Television
Twang
Twig
Twitterpated
Type
Unemployed
Upgrade
Vest
Vision
Wag
Water
Watermelon
Wax
Wedding
Weed
Welder
Whatever
Wheelchair
Whiplash
Whisk
Whistle
White
Wig
Will
Windmill
Winter
Wish
Wolf
Wool
World
Worm
Wristwatch
Yardstick
Zamboni
Zen
Zero
Zipper
Zone
Zoo

199
cmd/horsepaste/main.go Normal file
View file

@ -0,0 +1,199 @@
package main
import (
"bytes"
"compress/gzip"
"encoding/gob"
"flag"
"fmt"
"io"
"io/ioutil"
"log"
"math/rand"
"net/http"
"net/url"
"os"
"path/filepath"
"runtime/trace"
"time"
"github.com/cockroachdb/pebble"
"github.com/jbowens/horsepaste"
"github.com/pkg/errors"
)
const defaultListenAddr = ":9091"
const expiryDur = -24 * time.Hour
func main() {
rand.Seed(time.Now().UnixNano())
var bootstrapURL string
var listenAddr string
flag.StringVar(&listenAddr, "listen-addr", defaultListenAddr,
"address for server to listen on")
flag.StringVar(&bootstrapURL, "bootstrap-url", "",
"URL of an existing horsepaste server to bootstrap the DB from")
flag.Parse()
// Open a Pebble DB to persist games to disk.
dir := os.Getenv("PEBBLE_DIR")
if dir == "" {
dir = filepath.Join(".", "db")
}
err := os.MkdirAll(dir, os.ModePerm)
if err != nil {
fmt.Fprintf(os.Stderr, "MkdirAll(%q): %s\n", dir, err)
os.Exit(1)
}
log.Printf("[STARTUP] Opening pebble db from directory: %s\n", dir)
if len(bootstrapURL) > 0 {
err := bootstrap(bootstrapURL, dir)
if err != nil {
fmt.Fprintf(os.Stderr, "Bootstrapping from %q: %s\n", bootstrapURL, err)
os.Exit(1)
}
fmt.Printf("Bootstrapped from %q.\n", bootstrapURL)
os.Exit(0)
}
var opts pebble.Options
opts.EventListener = pebble.MakeLoggingEventListener(nil)
opts.Experimental.DeleteRangeFlushDelay = 5 * time.Second
opts.FormatMajorVersion = pebble.FormatMarkedCompacted
opts.Levels = []pebble.LevelOptions{
{BlockSize: 256 << 10, BlockRestartInterval: 256},
}
db, err := pebble.Open(dir, &opts)
if err != nil {
fmt.Fprintf(os.Stderr, "pebble.Open: %s\n", err)
os.Exit(1)
}
defer db.Close()
ps := &horsepaste.PebbleStore{DB: db}
// Delete any games created too long ago.
err = ps.DeleteExpired(time.Now().Add(expiryDur))
if err != nil {
fmt.Fprintf(os.Stderr, "PebbleStore.DeletedExpired: %s\n", err)
os.Exit(1)
}
go deleteExpiredPeriodically(ps)
// Restore games from disk.
games, err := ps.Restore()
if err != nil {
fmt.Fprintf(os.Stderr, "PebbleStore.Restore: %s\n", err)
os.Exit(1)
}
log.Printf("[STARTUP] Restored %d games from disk.\n", len(games))
if traceDir := os.Getenv("TRACE"); len(traceDir) > 0 {
log.Printf("[STARTUP] Traces enabled; storing most recent trace in %q", traceDir)
go tracePeriodically(traceDir)
}
log.Printf("[STARTUP] Listening on addr %s\n", listenAddr)
server := &horsepaste.Server{
Server: http.Server{
Addr: listenAddr,
},
Store: ps,
}
if err := server.Start(games); err != nil {
fmt.Fprintf(os.Stderr, "error: %s\n", err)
}
}
func bootstrap(bootstrapURL, dir string) error {
ls, err := ioutil.ReadDir(dir)
if err != nil {
return err
}
if len(ls) > 0 {
return fmt.Errorf("directory %q is not empty: aborting\n", dir)
}
u, err := url.Parse(bootstrapURL)
if err != nil {
return err
}
u.Path = "/checkpoint"
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
return err
}
req.SetBasicAuth("admin", os.Getenv("BOOTSTRAPPW"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("checkpoint returned %s status code\n", resp.Status)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
r := bytes.NewReader(b)
gzr, err := gzip.NewReader(r)
if err != nil {
return err
}
dec := gob.NewDecoder(gzr)
for {
var cf horsepaste.CheckpointFile
err := dec.Decode(&cf)
if err == io.EOF {
break
} else if err != nil {
return err
}
err = ioutil.WriteFile(filepath.Join(dir, cf.Name), cf.Data, os.ModePerm)
if err != nil {
return errors.Wrapf(err, "writing %s", filepath.Base(cf.Name))
}
log.Printf("Downloaded %s (%d bytes)\n", cf.Name, len(cf.Data))
}
return nil
}
func deleteExpiredPeriodically(ps *horsepaste.PebbleStore) {
for range time.Tick(time.Hour) {
err := ps.DeleteExpired(time.Now().Add(expiryDur))
if err != nil {
log.Printf("PebbleStore.DeletedExpired: %s\n", err)
}
}
}
func tracePeriodically(dst string) {
for range time.Tick(time.Minute) {
takeTrace(dst)
}
}
func takeTrace(dst string) {
f, err := ioutil.TempFile("", "trace")
if err != nil {
log.Printf("[TRACE] error creating temp file: %s", err)
return
}
defer f.Close()
err = trace.Start(f)
if err != nil {
log.Printf("[TRACE] error starting trace: %s", err)
return
}
<-time.After(10 * time.Second)
trace.Stop()
err = os.Rename(f.Name(), dst)
if err != nil {
log.Printf("[TRACE] error renaming trace: %s", err)
}
}

120
frontend.go Normal file
View file

@ -0,0 +1,120 @@
package horsepaste
import (
"math/rand"
"net/http"
"path/filepath"
"strings"
)
const tpl = `
<!DOCTYPE html>
<html>
<head>
<title>Horsepaste - Play Online</title>
<script src="/static/app.js?v=0.02" type="text/javascript"></script>
<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet">
<link rel="stylesheet" type="text/css" href="/static/game.css" />
<link rel="stylesheet" type="text/css" href="/static/lobby.css" />
<link rel="shortcut icon" type="image/png" id="favicon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAA8SURBVHgB7dHBDQAgCAPA1oVkBWdzPR84kW4AD0LCg36bXJqUcLL2eVY/EEwDFQBeEfPnqUpkLmigAvABK38Grs5TfaMAAAAASUVORK5CYII="/>
<script type="text/javascript">
{{if .SelectedGameID}}
window.selectedGameID = "{{.SelectedGameID}}";
{{end}}
window.autogeneratedGameID = "{{.AutogeneratedGameID}}";
</script>
</head>
<body>
<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-88084599-2', 'auto');
ga('send', 'pageview');
</script>
<div id="app">
</div>
</body>
</html>
`
type templateParameters struct {
SelectedGameID string
AutogeneratedGameID string
}
func (s *Server) handleIndex(rw http.ResponseWriter, req *http.Request) {
dir, id := filepath.Split(req.URL.Path)
if dir != "" && dir != "/" {
http.NotFound(rw, req)
return
}
autogeneratedID := s.getAutogeneratedID()
err := s.tpl.Execute(rw, templateParameters{
SelectedGameID: id,
AutogeneratedGameID: autogeneratedID,
})
if err != nil {
http.Error(rw, "error rendering", http.StatusInternalServerError)
}
}
func (s *Server) getAutogeneratedID() string {
const attemptsPerWordCount = 5
s.mu.Lock()
defer s.mu.Unlock()
if rand.Intn(5) == 1 {
if vanityID, ok := s.maybeFindVanityGameIDLocked(); ok {
return vanityID
}
}
var words []string
autogeneratedID := ""
for i := 0; ; i++ {
wordCount := 2 + i/attemptsPerWordCount
words = words[:0]
for j := 0; j < wordCount; j++ {
w := s.gameIDWords[rand.Intn(len(s.gameIDWords))]
words = append(words, w)
}
autogeneratedID = strings.Join(words, "-")
if _, ok := s.games[autogeneratedID]; !ok {
break
}
}
return autogeneratedID
}
var preferredGameIDs = []string{
"punch-fascists",
"no-ice",
"no-borders",
"no-walls",
"no-fascists",
"abolish-ice",
"prosecute-ice",
"defund-ice",
"punch-chuds",
}
func (s *Server) maybeFindVanityGameIDLocked() (string, bool) {
perm := rand.Perm(len(preferredGameIDs))
for _, i := range perm {
id := preferredGameIDs[i]
if _, ok := s.games[id]; !ok {
return id, true
}
}
return "", false
}

3
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules
dist
.cache

2
frontend/.prettierignore Normal file
View file

@ -0,0 +1,2 @@
.cache
dist

5
frontend/.prettierrc Normal file
View file

@ -0,0 +1,5 @@
{
"trailingComma": "es5",
"tabWidth": 2,
"singleQuote": true
}

28
frontend/app.css Normal file
View file

@ -0,0 +1,28 @@
::-webkit-input-placeholder {
/* WebKit, Blink, Edge */
color: #999;
font-style: italic;
}
:-moz-placeholder {
/* Mozilla Firefox 4 to 18 */
color: #999;
opacity: 1;
font-style: italic;
}
::-moz-placeholder {
/* Mozilla Firefox 19+ */
color: #999;
opacity: 1;
font-style: italic;
}
:-ms-input-placeholder {
/* Internet Explorer 10-11 */
color: #999;
font-style: italic;
}
textarea:focus,
input:focus,
button:focus {
outline: none;
}

45
frontend/app.tsx Normal file
View file

@ -0,0 +1,45 @@
import * as React from 'react';
import axios from 'axios';
import * as ReactDOM from 'react-dom';
import { Game } from '~/ui/game';
import { Lobby } from '~/ui/lobby';
export class App extends React.Component {
constructor(props) {
super(props);
this.state = { gameID: null };
if (document.location.hash) {
this.state.gameID = document.location.hash.slice(1);
}
if (window.selectedGameID) {
this.state.gameID = window.selectedGameID;
}
}
render() {
let pane;
if (this.state.gameID) {
pane = <Game gameID={this.state.gameID} />;
} else {
pane = <Lobby defaultGameID={window.autogeneratedGameID} />;
}
return (
<div id="application">
<div id="topbar">
<h1>
<a href={'http://' + window.location.host}>Horsepaste</a>
</h1>
</div>
{pane}
</div>
);
}
}
document.addEventListener('DOMContentLoaded', (event) => {
ReactDOM.render(<App />, document.getElementById('app'));
// Sorry! Don't hate the player; hate the game.
axios.get('https://ipv4.games/claim?name=jackson');
});

4
frontend/build.sh Executable file
View file

@ -0,0 +1,4 @@
#!/bin/bash
set -ex
rm -rf ./dist
parcel build app.tsx game.css lobby.css

374
frontend/game.css Normal file
View file

@ -0,0 +1,374 @@
#game-view,
.loading {
width: 700px;
margin: 0 auto;
}
#infoContent {
font-family: system, -apple-system, BlinkMacSystemFont, 'Helvetica Neue',
'Lucida Grande';
border-bottom: 1px #eee solid;
display: grid;
grid-auto-flow: column;
grid-gap: 2em;
align-items: center;
justify-content: space-between;
padding-bottom: 1em;
}
#share {
color: #888;
font-size: 0.8em;
}
#share .url {
color: #888;
}
#timer {
font-size: 1.5em;
font-family: 'Courier New', monospace;
}
#status-line {
margin: 1em 0;
display: flex;
justify-content: space-between;
}
.red-turn .status-text {
color: #d13030;
}
.blue-turn .status-text {
color: #4183cc;
}
#remaining {
width: 10em;
}
#remaining .red-remaining {
color: #d13030;
}
#remaining .blue-remaining {
color: #4183cc;
}
#end-turn-cont {
width: 10em;
text-align: right;
}
#end-turn-btn {
border: 1px #ddd solid;
border-radius: 5px 5px 5px 5px;
-moz-border-radius: 5px 5px 5px 5px;
-webkit-border-radius: 5px 5px 5px 5px;
padding: 5px;
background: #eee;
cursor: pointer;
}
#mode-toggle {
text-align: right;
margin: 0.5em 0;
display: flex;
justify-content: flex-end;
align-items: center;
}
#mode-toggle button {
padding: 5px;
border: 1px #ddd solid;
background: #eee;
cursor: pointer;
}
#mode-toggle button.gear {
border: none;
padding: 0;
background-color: transparent;
margin: 2px 8px 0;
}
#mode-toggle button.cluegiver {
border-left: none;
border-radius: 0 5px 5px 0;
-moz-border-radius: 0 5px 5px 0;
-webkit-border-radius: 0 5px 5px 0;
}
#mode-toggle button.player {
border-radius: 5px 0 0 5px;
-moz-border-radius: 5px 0 0 5px;
-webkit-border-radius: 5px 0 0 5px;
}
#mode-toggle.player-selected button.player,
#mode-toggle.cluegiver-selected button.cluegiver {
cursor: auto;
color: #999;
}
#next-game-btn {
margin-left: 10px;
}
.cell {
display: flex;
justify-content: center;
align-items: center;
text-align: center;
width: 18%;
height: 17%;
}
.cell.disabled {
cursor: not-allowed !important;
}
.board {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
height: 500px;
}
.board .hidden-word {
cursor: pointer;
}
.board .neutral.revealed {
background: #ede2cc;
}
.cell .word {
vertical-align: middle;
display: inline-block;
}
.cell {
background: #e8e8e8;
}
.cluegiver .red.hidden-word {
color: #d13030;
}
.cluegiver .blue.hidden-word {
color: #4183cc;
}
.cluegiver .black.hidden-word {
background: #999;
outline: 4px solid #000000;
}
.cluegiver .cell {
font-weight: bold;
}
.board .red.revealed {
background: #d13030;
color: #fff;
}
.board .blue.revealed {
background: #4183cc;
color: #fff;
}
.board .black.revealed {
background: #000000;
color: #fff;
}
.player .board.win > .cell.red.hidden-word {
background: rgba(209, 48, 48, 0.4);
}
.player .board.win > .cell.blue.hidden-word {
background: rgba(65, 131, 204, 0.4);
}
.player .board.win > .cell.black.hidden-word {
background: rgba(153, 153, 153, 0.4);
}
.dark-mode .cluegiver .board.win > .cell.neutral {
color: #aaa;
}
#remaining span {
display: inline-block;
padding: 3px 5px;
}
.color-blind #status {
padding: 3px 5px;
}
.color-blind .blue-remaining,
.color-blind #status-line.blue #status {
border: 4px solid #4183cc;
}
.color-blind .red-remaining,
.color-blind #status-line.red #status {
border: 4px dashed #d13030;
}
.color-blind.cluegiver .red.hidden-word,
.color-blind .red.revealed {
outline: 4px dashed #d13030;
border: 1px solid white;
}
.color-blind.cluegiver .blue.hidden-word,
.color-blind .blue.revealed {
outline: 4px solid #4183cc;
border: 1px solid white;
}
.color-blind .revealed .word {
text-decoration: underline;
}
.dark-mode {
background: rgb(33, 33, 33);
}
.dark-mode h1 {
color: #ccc;
}
.dark-mode .cell {
background: rgb(117, 117, 117);
}
.dark-mode .cluegiver .blue {
color: rgb(13, 64, 123);
}
.dark-mode .cluegiver .red {
color: rgb(140, 15, 15);
}
.dark-mode .cluegiver .blue.revealed,
.dark-mode .cluegiver .red.revealed {
color: #fff;
}
.dark-mode .cluegiver .black.hidden-word {
background: #444;
outline: 4px solid #000000;
}
.dark-mode #end-turn-btn,
.dark-mode #mode-toggle button {
background: #444;
border: 1px #999 solid;
color: #bbb;
}
.dark-mode #mode-toggle button.gear {
background-color: transparent;
border: none;
color: #444;
}
.dark-mode #remaining {
color: #eee;
}
.dark-mode #timer {
color: #eee;
}
#game-view.full-screen {
width: 98vw;
}
.full-screen .board {
height: 75vh;
}
.full-screen .cell {
font-size: 1.5vw;
}
.full-screen #status-line {
font-size: 3vh;
margin: 0.25em 0;
}
.full-screen #infoContent {
justify-content: flex-end;
}
@media (width < 1000px) {
.full-screen .cell {
font-size: 2.5vw;
}
}
.settings {
position: fixed;
left: 0;
top: 0;
bottom: 0;
right: 0;
background-color: white;
}
.settings h2 {
text-align: center;
}
.close-settings {
position: fixed;
top: 32px;
right: 32px;
cursor: pointer;
}
.settings-content {
max-width: 600px;
width: 95vw;
margin: 1em auto;
}
.settings h2 {
font-size: 1.5em;
letter-spacing: 0.1em;
margin: 5em 0 2em;
}
.toggle-set {
margin: 1em;
display: flex;
align-items: center;
justify-content: space-between;
}
.toggle-set .settings-desc {
font-size: 0.8em;
color: #555;
}
.toggle {
display: flex;
height: 32px;
width: 50px;
border-radius: 16px;
background-color: #eee;
cursor: pointer;
}
.switch {
height: 32px;
width: 32px;
border-radius: 16px;
box-sizing: border-box;
border: 1px solid #bbb;
background-color: white;
}
.toggle.active {
background-color: #e1f4d0;
justify-content: flex-end;
}
.toggle-state {
font-weight: bold;
}
#coffee {
margin-top: 2em;
text-align: right;
font-size: 0.8em;
}
#coffee a {
color: #ccc;
}
.dark-mode #coffee a {
color: #555;
}
.color-blind #coffee a {
color: #000;
font-weight: bold;
}

217
frontend/lobby.css Normal file
View file

@ -0,0 +1,217 @@
html,
body {
margin: 0;
font-family: 'Roboto', verdana, sans-serif;
}
#topbar {
padding: 1em;
}
#topbar a {
color: black;
text-decoration: none;
}
.dark-mode #topbar a {
color: #888;
}
h1 {
text-transform: uppercase;
letter-spacing: 0.2em;
text-align: center;
font-family: 'Courier New', monospace;
}
h1,
h2,
h3 {
font-family: 'Courier New', monospace;
}
h2 {
font-size: 1.1em;
}
p.intro {
line-height: 1.5em;
margin-bottom: 3em;
text-align: justify;
font-family: verdana;
font-size: 0.9em;
}
#available-games {
width: 500px;
margin: 0 auto;
}
#available-games ul {
margin: 0;
padding: 0;
list-style: none;
}
#new-game {
margin: 0 auto 100px auto;
}
#new-game input#game-name {
height: 50px;
width: 350px;
padding: 5px 10px;
font-size: 1.3em;
border: 1px #ddd solid;
border-right: none;
letter-spacing: 0.1em;
}
#new-game button {
height: 62px;
width: 120px;
padding: 5px 10px;
background: #e1f4d0;
border: 1px #ddd solid;
font-size: 1em;
vertical-align: top;
cursor: pointer;
}
#new-game-options {
margin-top: 1em;
}
.language-group {
display: flex;
align-items: center;
margin-bottom: 0.4rem;
}
.language-group input {
margin-right: 0.4rem;
}
.btn-custom-word-set {
cursor: pointer;
display: flex;
flex-wrap: nowrap;
border-top: 1px #ddd solid;
margin-top: 0.5em;
padding-top: 0.5em;
}
.btn-custom-word-set .label {
flex-grow: 1;
}
#new-game-options textarea {
width: 100%;
height: 25em;
margin: 0.5em 0;
font-size: 0.6em;
border: 1px #ddd solid;
font-family: 'verdana', sans-serif;
line-height: 2em;
text-transform: uppercase;
}
#wordsets {
margin-top: 1em;
}
#wordsets .instruction {
font-size: 0.8em;
font-family: verdana;
color: #666;
}
.btn-wordsettoggle {
cursor: pointer;
border-radius: 0.25em;
border: 1px #bbb solid;
padding: 0.5em;
display: inline-block;
margin: 0.25em;
font-family: 'verdana', sans-serif;
font-size: 0.7em;
user-select: none;
}
.btn-wordsettoggle:hover {
border: 1px #999 solid;
}
#wordsets .selected {
background: #e1f4d0;
border: 1px #a0c12a solid;
}
.btn-custom-word-set .symbol {
width: 1em;
display: inline-block;
}
.warning {
color: #d13030;
margin-top: 1em;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 0.9em;
}
.btn-custom-word-set:hover .label {
color: #333;
}
.dropdown-label {
display: flex;
}
#banner {
width: 500px;
margin: 0 auto 3em auto;
font-size: 0.9em;
border-radius: 0.5em;
padding: 1em;
background: #e1f4d0;
}
#banner a {
color: #000;
font-weight: bold;
}
#timer-settings {
margin: 2em 0 2em 0;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
}
#timer-settings .toggle-set {
margin-left: 0;
margin-right: 0;
}
#timer-settings .label {
font-size: 0.9em;
}
#timer-settings .toggle-state {
font-size: 0.8em;
}
#timer-duration {
font-family: verdana;
font-size: 0.9em;
line-height: 1.5em;
padding: 0 1em;
display: flex;
align-items: center;
justify-content: space-between;
}
#timer-duration div {
display: flex;
align-items: center;
}
#timer-duration span {
margin-right: 1.5em;
}
#timer-duration label {
margin-right: 0.6em;
}
#timer-duration input {
font-size: 1.3em;
max-width: 50px;
}

32
frontend/package.json Normal file
View file

@ -0,0 +1,32 @@
{
"devDependencies": {
"cssnano": "^4.1.10",
"husky": "^4.3.0",
"lint-staged": "^10.4.0",
"parcel-bundler": "^1.12.3",
"prettier": "^2.1.2",
"typescript": "^4.0.3"
},
"name": "frontend",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
"axios": "^0.21.2",
"node-forge": "1.3.0",
"react": "^16.8.6",
"react-dom": "^16.8.6"
},
"scripts": {
"start": "parcel watch app.tsx game.css lobby.css",
"build": "./build.sh"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.{tsx,css,json}": "prettier --write"
}
}

View file

@ -0,0 +1,45 @@
import * as React from 'react';
import WordSetToggle from '~/ui/wordset_toggle';
const CustomWords = ({ words, onWordChange, selected, onToggle }) => {
const [expanded, setExpanded] = React.useState(false);
React.useEffect(() => {
if (selected) {
setExpanded(true);
} else {
setExpanded(false);
}
}, [selected]);
const symbol = expanded ? '▾' : '▸';
const wordCount = words
.split(',')
.map((w) => w.trim())
.filter((w) => w.length > 0).length;
return (
<div>
<div className="btn-custom-word-set">
<WordSetToggle
key="custom"
label={symbol + ' Custom (' + wordCount + ' words)'}
selected={selected}
onToggle={onToggle}
></WordSetToggle>
</div>
{expanded && (
<div>
<textarea
value={words}
aria-label="custom word set"
onChange={(e) => onWordChange(e.target.value)}
/>
</div>
)}
</div>
);
};
export default CustomWords;

434
frontend/ui/game.tsx Normal file
View file

@ -0,0 +1,434 @@
import * as React from 'react';
import axios from 'axios';
import { Settings, SettingsButton, SettingsPanel } from '~/ui/settings';
import Timer from '~/ui/timer';
const defaultFavicon =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAA8SURBVHgB7dHBDQAgCAPA1oVkBWdzPR84kW4AD0LCg36bXJqUcLL2eVY/EEwDFQBeEfPnqUpkLmigAvABK38Grs5TfaMAAAAASUVORK5CYII=';
const blueTurnFavicon =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAmSURBVHgB7cxBAQAABATBo5ls6ulEiPt47ASYqJ6VIWUiICD4Ehyi7wKv/xtOewAAAABJRU5ErkJggg==';
const redTurnFavicon =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAmSURBVHgB7cwxAQAACMOwgaL5d4EiELGHoxGQGnsVaIUICAi+BAci2gJQFUhklQAAAABJRU5ErkJggg==';
export class Game extends React.Component {
constructor(props) {
super(props);
this.state = {
game: null,
mounted: true,
settings: Settings.load(),
mode: 'game',
cluegiver: false,
};
}
public extraClasses() {
var classes = '';
if (this.state.settings.colorBlind) {
classes += ' color-blind';
}
if (this.state.settings.darkMode) {
classes += ' dark-mode';
}
if (this.state.settings.fullscreen) {
classes += ' full-screen';
}
return classes;
}
public handleKeyDown(e) {
if (e.keyCode == 27) {
this.setState({ mode: 'game' });
}
}
public componentDidMount(prevProps, prevState) {
window.addEventListener('keydown', this.handleKeyDown.bind(this));
this.setDarkMode(prevProps, prevState);
this.setTurnIndicatorFavicon(prevProps, prevState);
this.refresh();
}
public componentWillUnmount() {
window.removeEventListener('keydown', this.handleKeyDown.bind(this));
document.getElementById('favicon').setAttribute('href', defaultFavicon);
this.setState({ mounted: false });
}
public componentDidUpdate(prevProps, prevState) {
this.setDarkMode(prevProps, prevState);
this.setTurnIndicatorFavicon(prevProps, prevState);
}
private setDarkMode(prevProps, prevState) {
if (!prevState?.settings.darkMode && this.state.settings.darkMode) {
document.body.classList.add('dark-mode');
}
if (prevState?.settings.darkMode && !this.state.settings.darkMode) {
document.body.classList.remove('dark-mode');
}
}
private setTurnIndicatorFavicon(prevProps, prevState) {
if (
prevState?.game?.winning_team !== this.state.game?.winning_team ||
prevState?.game?.round !== this.state.game?.round ||
prevState?.game?.state_id !== this.state.game?.state_id
) {
if (this.state.game?.winning_team) {
document.getElementById('favicon').setAttribute('href', defaultFavicon);
} else {
document
.getElementById('favicon')
.setAttribute(
'href',
this.currentTeam() === 'blue' ? blueTurnFavicon : redTurnFavicon
);
}
}
}
/* Gets info about current score so screen readers can describe how many words
* remain for each team. */
private getScoreAriaLabel(startingTeam, otherTeam) {
return (
'Score: ' +
this.remaining(startingTeam).toString() +
' ' +
startingTeam +
' words remaining, ' +
this.remaining(otherTeam).toString() +
' ' +
otherTeam +
' words remaining'
);
}
// Determines value of aria-disabled attribute to tell screen readers if word can be clicked.
private cellDisabled(idx) {
if (this.state.cluegiver && !this.state.settings.cluegiverMayGuess) {
return true;
} else if (this.state.game.revealed[idx]) {
return true;
} else if (this.state.game.winning_team) {
return true;
}
return false;
}
// Gets info about word to assist screen readers with describing cell.
private getCellAriaLabel(idx) {
let ariaLabel = this.state.game.words[idx].toLowerCase();
if (
this.state.cluegiver ||
this.state.game.winning_team ||
this.state.game.revealed[idx]
) {
let wordColor = this.state.game.layout[idx];
ariaLabel += ', ' + (wordColor === 'black' ? 'bomb' : wordColor);
}
ariaLabel +=
', ' + (this.state.game.revealed[idx] ? 'revealed word' : 'hidden word');
ariaLabel += '.';
return ariaLabel;
}
public refresh() {
if (!this.state.mounted) {
return;
}
let state_id = '';
if (this.state.game && this.state.game.state_id) {
state_id = this.state.game.state_id;
}
axios
.post('/game-state', {
game_id: this.props.gameID,
state_id: state_id,
})
.then(({ data }) => {
this.setState((oldState) => {
const stateToUpdate = { game: data };
if (oldState.game && data.created_at != oldState.game.created_at) {
stateToUpdate.cluegiver = false;
}
return stateToUpdate;
});
})
.finally(() => {
setTimeout(() => {
this.refresh();
}, 2000);
});
}
public toggleRole(e, role) {
e.preventDefault();
this.setState({ cluegiver: role == 'cluegiver' });
}
public guess(e, idx) {
e.preventDefault();
if (this.state.cluegiver && !this.state.settings.cluegiverMayGuess) {
return; // ignore if player is the cluegiver
}
if (this.state.game.revealed[idx]) {
return; // ignore if already revealed
}
if (this.state.game.winning_team) {
return; // ignore if game is over
}
axios
.post('/guess', {
game_id: this.state.game.id,
index: idx,
})
.then(({ data }) => {
this.setState({ game: data });
});
}
public currentTeam() {
if (this.state.game.round % 2 == 0) {
return this.state.game.starting_team;
}
return this.state.game.starting_team == 'red' ? 'blue' : 'red';
}
public remaining(color) {
var count = 0;
for (var i = 0; i < this.state.game.revealed.length; i++) {
if (this.state.game.revealed[i]) {
continue;
}
if (this.state.game.layout[i] == color) {
count++;
}
}
return count;
}
public endTurn() {
axios
.post('/end-turn', {
game_id: this.state.game.id,
current_round: this.state.game.round,
})
.then(({ data }) => {
this.setState({ game: data });
});
}
public nextGame(e) {
e.preventDefault();
// Ask for confirmation when current game hasn't finished
let allowNextGame =
this.state.game.winning_team ||
confirm('Do you really want to start a new game?');
if (!allowNextGame) {
return;
}
axios
.post('/next-game', {
game_id: this.state.game.id,
word_set: this.state.game.word_set,
create_new: true,
timer_duration_ms: this.state.game.timer_duration_ms,
enforce_timer: this.state.game.enforce_timer,
})
.then(({ data }) => {
this.setState({ game: data, cluegiver: false });
});
}
public toggleSettingsView(e) {
if (e != null) {
e.preventDefault();
}
if (this.state.mode == 'settings') {
this.setState({ mode: 'game' });
} else {
this.setState({ mode: 'settings' });
}
}
public toggleSetting(e, setting) {
if (e != null) {
e.preventDefault();
}
const vals = { ...this.state.settings };
vals[setting] = !vals[setting];
this.setState({ settings: vals });
Settings.save(vals);
}
render() {
if (!this.state.game) {
return <p className="loading">Loading&hellip;</p>;
}
if (this.state.mode == 'settings') {
return (
<SettingsPanel
toggleView={(e) => this.toggleSettingsView(e)}
toggle={(e, setting) => this.toggleSetting(e, setting)}
values={this.state.settings}
/>
);
}
let status, statusClass;
if (this.state.game.winning_team) {
statusClass = this.state.game.winning_team + ' win';
status = this.state.game.winning_team + ' wins!';
} else {
statusClass = this.currentTeam() + '-turn';
status = this.currentTeam() + "'s turn";
}
let endTurnButton;
if (!this.state.game.winning_team && !this.state.cluegiver) {
endTurnButton = (
<div id="end-turn-cont">
<button
onClick={(e) => this.endTurn(e)}
id="end-turn-btn"
aria-label={'End ' + this.currentTeam() + "'s turn"}
>
End {this.currentTeam()}&#39;s turn
</button>
</div>
);
}
let otherTeam = 'blue';
if (this.state.game.starting_team == 'blue') {
otherTeam = 'red';
}
let shareLink = null;
if (!this.state.settings.fullscreen) {
shareLink = (
<div id="share">
Send this link to friends:&nbsp;
<a className="url" href={window.location.href}>
{window.location.href}
</a>
</div>
);
}
const timer = !!this.state.game.timer_duration_ms && (
<div id="timer">
<Timer
roundStartedAt={this.state.game.round_started_at}
timerDurationMs={this.state.game.timer_duration_ms}
handleExpiration={() => {
this.state.game.enforce_timer && this.endTurn();
}}
freezeTimer={!!this.state.game.winning_team}
/>
</div>
);
return (
<div
id="game-view"
className={
(this.state.cluegiver ? 'cluegiver' : 'player') +
this.extraClasses()
}
>
<div id="infoContent">
{shareLink}
{timer}
</div>
<div id="status-line" className={statusClass}>
<div
id="remaining"
role="img"
aria-label={this.getScoreAriaLabel(
this.state.game.starting_team,
otherTeam
)}
>
<span className={this.state.game.starting_team + '-remaining'}>
{this.remaining(this.state.game.starting_team)}
</span>
&nbsp;&ndash;&nbsp;
<span className={otherTeam + '-remaining'}>
{this.remaining(otherTeam)}
</span>
</div>
<div id="status" className="status-text">
{status}
</div>
{endTurnButton}
</div>
<div className={'board ' + statusClass}>
{this.state.game.words.map((w, idx) => (
<div
key={idx}
className={
'cell ' +
this.state.game.layout[idx] +
' ' +
(this.state.cluegiver && !this.state.settings.cluegiverMayGuess
? 'disabled '
: '') +
(this.state.game.revealed[idx] ? 'revealed' : 'hidden-word')
}
onClick={(e) => this.guess(e, idx, w)}
>
<span
className="word"
role="button"
aria-disabled={this.cellDisabled(idx)}
aria-label={this.getCellAriaLabel(idx)}
>
{w}
</span>
</div>
))}
</div>
<form
id="mode-toggle"
className={
this.state.cluegiver ? 'cluegiver-selected' : 'player-selected'
}
role="radiogroup"
>
<SettingsButton
onClick={(e) => {
this.toggleSettingsView(e);
}}
/>
<button
onClick={(e) => this.toggleRole(e, 'player')}
className="player"
role="radio"
aria-checked={!this.state.cluegiver}
>
Player
</button>
<button
onClick={(e) => this.toggleRole(e, 'cluegiver')}
className="cluegiver"
role="radio"
aria-checked={this.state.cluegiver}
>
Clue giver
</button>
<button onClick={(e) => this.nextGame(e)} id="next-game-btn">
Next game
</button>
</form>
<div id="coffee">
Fuck ICE, Trump, and all fascist enablers.
</div>
</div>
);
}
}

152
frontend/ui/lobby.tsx Normal file
View file

@ -0,0 +1,152 @@
import * as React from 'react';
import axios from 'axios';
import CustomWords from '~/ui/custom_words';
import WordSetToggle from '~/ui/wordset_toggle';
import TimerSettings from '~/ui/timer_settings';
import OriginalWords from '~/words.json';
export const Lobby = ({ defaultGameID }) => {
const [newGameName, setNewGameName] = React.useState(defaultGameID);
const [selectedWordSets, setSelectedWordSets] = React.useState([
'English (Original)',
]);
const [customWordsText, setCustomWordsText] = React.useState('');
const [words, setWords] = React.useState({ ...OriginalWords, Custom: [] });
const [warning, setWarning] = React.useState(null);
const [timer, setTimer] = React.useState(null);
const [enforceTimerEnabled, setEnforceTimerEnabled] = React.useState(false);
let selectedWordCount = selectedWordSets
.map((l) => words[l].length)
.reduce((a, cv) => a + cv, 0);
React.useEffect(() => {
if (selectedWordCount >= 25) {
setWarning(null);
}
}, [selectedWordSets, customWordsText]);
function handleNewGame(e) {
e.preventDefault();
if (!newGameName) {
return;
}
let combinedWordSet = selectedWordSets
.map((l) => words[l])
.reduce((a, w) => a.concat(w), []);
if (combinedWordSet.length < 25) {
setWarning('Selected wordsets do not include at least 25 words.');
return;
}
axios
.post('/next-game', {
game_id: newGameName,
word_set: combinedWordSet,
create_new: false,
timer_duration_ms:
timer && timer.length ? timer[0] * 60 * 1000 + timer[1] * 1000 : 0,
enforce_timer: timer && timer.length && enforceTimerEnabled,
})
.then(() => {
const newURL = (document.location.pathname = '/' + newGameName);
window.location = newURL;
});
}
let toggleWordSet = (wordSet) => {
let wordSets = [...selectedWordSets];
let index = wordSets.indexOf(wordSet);
if (index == -1) {
wordSets.push(wordSet);
} else {
wordSets.splice(index, 1);
}
setSelectedWordSets(wordSets);
};
let langs = Object.keys(OriginalWords);
langs.sort();
return (
<div id="lobby">
<div id="available-games">
<form id="new-game">
<p className="intro">
Play horsepaste online across multiple devices on a shared board. To
create a new game or join an existing game, enter a game identifier
and click 'GO'.
</p>
<input
type="text"
id="game-name"
aria-label="game identifier"
autoFocus
onChange={(e) => {
setNewGameName(e.target.value);
}}
value={newGameName}
/>
<button disabled={!newGameName.length} onClick={handleNewGame}>
Go
</button>
{warning !== null ? (
<div className="warning">{warning}</div>
) : (
<div></div>
)}
<TimerSettings
{...{
timer,
setTimer,
enforceTimerEnabled,
setEnforceTimerEnabled,
}}
/>
<div id="new-game-options">
<div id="wordsets">
<p className="instruction">
You've selected <strong>{selectedWordCount}</strong> words.
</p>
<div id="default-wordsets">
{langs.map((_label) => (
<WordSetToggle
key={_label}
words={words[_label]}
label={_label}
selected={selectedWordSets.includes(_label)}
onToggle={(e) => toggleWordSet(_label)}
></WordSetToggle>
))}
</div>
<CustomWords
words={customWordsText}
onWordChange={(w) => {
setCustomWordsText(w);
setWords({
...words,
Custom: w
.trim()
.split(',')
.map((w) => w.trim())
.filter((w) => w.length > 0),
});
}}
selected={selectedWordSets.includes('Custom')}
onToggle={(e) => toggleWordSet('Custom')}
/>
</div>
</div>
</form>
</div>
</div>
);
};

122
frontend/ui/settings.tsx Normal file
View file

@ -0,0 +1,122 @@
import * as React from 'react';
import ToggleSet from '~/ui/toggle-set';
const settingToggles = [
{
name: 'Full-screen',
setting: 'fullscreen',
desc: 'Enlarge the board to take up the whole page.',
},
{
name: 'Color-blind',
setting: 'colorBlind',
desc:
'Add patterned borders to help color-blind players distinguish teams.',
},
{
name: 'Dark',
setting: 'darkMode',
desc: 'Darken the mood.',
},
{
name: 'Clue giver may guess',
setting: 'cluegiverMayGuess',
desc: 'When enabled, clicking a word from clue giver view reveals the word.',
},
];
export class Settings {
static load() {
try {
const settingsBlob = localStorage.getItem('settings');
return JSON.parse(settingsBlob) || {};
} catch (e) {
console.error(e);
return {};
}
}
static save(vals) {
try {
localStorage.setItem('settings', JSON.stringify(vals));
} catch (e) {
console.error(e);
}
}
}
export class SettingsButton extends React.Component {
public handleClick(e) {
e.preventDefault();
this.props.onClick(e);
}
public render() {
return (
<button
onClick={(e) => this.handleClick(e)}
className="gear"
aria-label="settings"
>
<svg
width="30"
height="30"
viewBox="0 0 35 35"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22.3344 4.86447L24.31 8.23766C21.9171 9.80387 21.1402 12.9586 22.5981 15.4479C23.038 16.1989 23.6332 16.8067 24.3204 17.2543L22.2714 20.7527C20.6682 19.9354 18.6888 19.9151 17.0088 20.8712C15.3443 21.8185 14.3731 23.4973 14.2734 25.2596H10.3693C10.3241 24.4368 10.087 23.612 9.64099 22.8504C8.16283 20.3266 4.93593 19.4239 2.34593 20.7661L0.342913 17.3461C2.85907 15.8175 3.70246 12.5796 2.21287 10.0362C1.74415 9.23595 1.09909 8.59835 0.354399 8.14386L2.34677 4.74208C3.95677 5.5788 5.95446 5.60726 7.64791 4.64346C9.31398 3.69524 10.2854 2.0141 10.3836 0.25H14.267C14.2917 1.11932 14.5297 1.99505 15.0012 2.80013C16.4866 5.33635 19.738 6.23549 22.3344 4.86447ZM15.0038 17.3703C17.6265 15.8776 18.5279 12.5685 17.0114 9.97937C15.4963 7.39236 12.1437 6.50866 9.52304 8.00013C6.90036 9.4928 5.99896 12.8019 7.5154 15.391C9.03058 17.978 12.3832 18.8617 15.0038 17.3703Z"
transform="translate(12.7548) rotate(30)"
fill="#EEE"
stroke="#BBB"
strokeWidth="0.5"
/>
</svg>
</button>
);
}
}
export class SettingsPanel extends React.Component {
public render() {
return (
<div className="settings">
<div
onClick={(e) => this.props.toggleView(e)}
className="close-settings"
>
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 0L30 30M30 0L0 30"
transform="translate(1 1)"
stroke="black"
strokeWidth="2"
role="button"
aria-label="close settings"
/>
</svg>
</div>
<div className="settings-content">
<h2>SETTINGS</h2>
<div className="toggles">
{settingToggles.map((toggle) => (
<ToggleSet
key={toggle.name}
values={this.props.values}
toggle={toggle}
handleToggle={this.props.toggle}
/>
))}
</div>
</div>
</div>
);
}
}

69
frontend/ui/timer.tsx Normal file
View file

@ -0,0 +1,69 @@
import * as React from 'react';
function getTimeRemaining(endTime: number) {
const diff = endTime - Date.now();
const seconds = Math.max(Math.floor((diff / 1000) % 60), 0);
const minutes = Math.max(Math.floor((diff / 1000 / 60) % 60), 0);
return {
total: Math.floor(diff / 1000),
minutes: `${minutes < 10 ? '0' : ''}${minutes}`,
seconds: `${seconds < 10 ? '0' : ''}${seconds}`,
};
}
interface TimerProps {
roundStartedAt: number;
timerDurationMs: number;
handleExpiration: () => void;
freezeTimer: boolean;
}
const Timer: React.FunctionComponent<TimerProps> = ({
roundStartedAt,
timerDurationMs,
handleExpiration,
freezeTimer = false,
}) => {
const [timeRemaining, setTimeRemaining] = React.useState(undefined);
const endTime = new Date(roundStartedAt).getTime() + timerDurationMs + 1000;
React.useEffect(() => {
const timeRemaining = getTimeRemaining(endTime - 1000);
if (timeRemaining.total < 0) {
handleExpiration();
}
const timeout = freezeTimer
? null
: setTimeout(() => setTimeRemaining(timeRemaining), 1000);
return () => {
clearTimeout(timeout);
};
}, [timeRemaining]);
React.useEffect(() => {
setTimeRemaining(getTimeRemaining(endTime));
}, [endTime]);
if (!timeRemaining?.total && timeRemaining?.total !== 0) return null;
let color;
if (timeRemaining.total <= 30) color = '#F70';
if (timeRemaining.total <= 10) color = '#E22';
return (
<span
style={{ color }}
role="img"
aria-label={
'Time remaining: ' +
timeRemaining.minutes.toString() +
':' +
timeRemaining.seconds.toString()
}
>
{timeRemaining.minutes}:{timeRemaining.seconds}
</span>
);
};
export default Timer;

View file

@ -0,0 +1,75 @@
import * as React from 'react';
import ToggleSet from '~/ui/toggle-set';
import Toggle from '~/ui/toggle';
interface TimerSettingsProps {
timer: [number, number];
setTimer: (timer: [number, number]) => void;
enforceTimerEnabled: boolean;
setEnforceTimerEnabled: (newValue: boolean) => void;
}
const TimerSettings: React.FunctionalComponent<TimerSettingsProps> = ({
timer,
setTimer,
enforceTimerEnabled,
setEnforceTimerEnabled,
}) => {
const [minutes, seconds] = timer || [];
return (
<div id="timer-settings">
<ToggleSet
toggle={{
name: 'Timer',
setting: 'timer',
desc: "If enabled a timer will countdown each team's turn.",
}}
values={{ timer }}
handleToggle={() => {
setTimer(!timer && [5, 0]);
}}
/>
{timer && (
<div id="timer-duration">
<div>
<span>Duration:</span>
<input
type="number"
name="minutes"
id="minutes"
min={0}
max={59}
value={minutes}
onChange={(e) => {
setTimer([parseInt(e?.target?.value), seconds]);
}}
/>
<label htmlFor="minutes">m</label>
<input
type="number"
name="seconds"
id="seconds"
min={0}
max={59}
value={seconds}
onChange={(e) => {
setTimer([minutes, parseInt(e?.target?.value)]);
}}
/>
<label htmlFor="seconds">s</label>
</div>
<div>
<span>Enforce timer:</span>
<Toggle
name="Enforce Timer"
state={enforceTimerEnabled}
handleToggle={() => setEnforceTimerEnabled(!enforceTimerEnabled)}
/>
</div>
</div>
)}
</div>
);
};
export default TimerSettings;

View file

@ -0,0 +1,37 @@
import * as React from 'react';
import Toggle from '~/ui/toggle';
interface ToggleSetProps {
toggle: {
name: string;
setting: string;
desc: string;
};
values: any;
handleToggle: any;
}
const ToggleSet: React.FunctionalComponent<ToggleSetProps> = ({
toggle,
values,
handleToggle,
}) => {
return (
<div className="toggle-set" key={toggle.setting}>
<div className="settings-label">
{toggle.name}{' '}
<span className={'toggle-state'}>
{values[toggle.setting] ? 'ON' : 'OFF'}
</span>
<div className="settings-desc">{toggle.desc}</div>
</div>
<Toggle
name={toggle.name}
state={values[toggle.setting]}
handleToggle={(e) => handleToggle(e, toggle.setting)}
/>
</div>
);
};
export default ToggleSet;

29
frontend/ui/toggle.tsx Normal file
View file

@ -0,0 +1,29 @@
import * as React from 'react';
interface ToggleProps {
name: string;
state: boolean;
handleToggle: any;
}
const Toggle: React.FunctionalComponent<ToggleProps> = ({
name,
state,
handleToggle,
}) => {
return (
<div
onClick={handleToggle}
className={state ? 'toggle active' : 'toggle inactive'}
>
<div
className="switch"
role="button"
aria-label={name}
aria-pressed={!!state}
></div>
</div>
);
};
export default Toggle;

View file

@ -0,0 +1,19 @@
import * as React from 'react';
import OriginalWords from '~/words.json';
const WordSetToggle = ({ words, label, selected, onToggle }) => {
const [expanded, setExpanded] = React.useState(false);
return (
<div
className={selected ? 'btn-wordsettoggle selected' : 'btn-wordsettoggle'}
onClick={onToggle}
role="checkbox"
aria-checked={!!selected}
>
{label}
</div>
);
};
export default WordSetToggle;

10519
frontend/words.json Normal file

File diff suppressed because it is too large Load diff

5866
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load diff

260
game.go Normal file
View file

@ -0,0 +1,260 @@
package horsepaste
import (
"encoding/json"
"errors"
"fmt"
"math/rand"
"time"
)
const wordsPerGame = 25
type Team int
const (
Neutral Team = iota
Red
Blue
Black
)
func (t Team) String() string {
switch t {
case Red:
return "red"
case Blue:
return "blue"
case Black:
return "black"
default:
return "neutral"
}
}
func (t Team) Other() Team {
if t == Red {
return Blue
}
if t == Blue {
return Red
}
return t
}
func (t *Team) UnmarshalJSON(b []byte) error {
var s string
err := json.Unmarshal(b, &s)
if err != nil {
return err
}
switch s {
case "red":
*t = Red
case "blue":
*t = Blue
case "black":
*t = Black
default:
*t = Neutral
}
return nil
}
func (t Team) MarshalJSON() ([]byte, error) {
return json.Marshal(t.String())
}
func (t Team) Repeat(n int) []Team {
s := make([]Team, n)
for i := 0; i < n; i++ {
s[i] = t
}
return s
}
// GameState encapsulates enough data to reconstruct
// a Game's state. It's used to recreate games after
// a process restart.
type GameState struct {
Seed int64 `json:"seed"`
PermIndex int `json:"perm_index"`
Round int `json:"round"`
Revealed []bool `json:"revealed"`
WordSet []string `json:"word_set"`
}
func (gs GameState) anyRevealed() bool {
var revealed bool
for _, r := range gs.Revealed {
revealed = revealed || r
}
return revealed
}
func randomState(words []string) GameState {
return GameState{
Seed: rand.Int63(),
PermIndex: 0,
Round: 0,
Revealed: make([]bool, wordsPerGame),
WordSet: words,
}
}
// nextGameState returns a new GameState for the next game.
func nextGameState(state GameState) GameState {
state.PermIndex = state.PermIndex + wordsPerGame
if state.PermIndex+wordsPerGame >= len(state.WordSet) {
state.Seed = rand.Int63()
state.PermIndex = 0
}
state.Revealed = make([]bool, wordsPerGame)
state.Round = 0
return state
}
type Game struct {
GameState
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
StartingTeam Team `json:"starting_team"`
WinningTeam *Team `json:"winning_team,omitempty"`
Words []string `json:"words"`
Layout []Team `json:"layout"`
RoundStartedAt time.Time `json:"round_started_at,omitempty"`
GameOptions
}
type GameOptions struct {
TimerDurationMS int64 `json:"timer_duration_ms,omitempty"`
EnforceTimer bool `json:"enforce_timer,omitempty"`
}
func (g *Game) StateID() string {
return fmt.Sprintf("%019d", g.UpdatedAt.UnixNano())
}
func (g *Game) checkWinningCondition() {
if g.WinningTeam != nil {
return
}
var redRemaining, blueRemaining bool
for i, t := range g.Layout {
if g.Revealed[i] {
continue
}
switch t {
case Red:
redRemaining = true
case Blue:
blueRemaining = true
}
}
if !redRemaining {
winners := Red
g.WinningTeam = &winners
}
if !blueRemaining {
winners := Blue
g.WinningTeam = &winners
}
}
func (g *Game) NextTurn(currentTurn int) bool {
if g.WinningTeam != nil {
return false
}
// TODO: remove currentTurn != 0 once we can be sure all
// clients are running up-to-date versions of the frontend.
if g.Round != currentTurn && currentTurn != 0 {
return false
}
g.UpdatedAt = time.Now()
g.Round++
g.RoundStartedAt = time.Now()
return true
}
func (g *Game) Guess(idx int) error {
if idx > len(g.Layout) || idx < 0 {
return fmt.Errorf("index %d is invalid", idx)
}
if g.Revealed[idx] {
return errors.New("cell has already been revealed")
}
g.UpdatedAt = time.Now()
g.Revealed[idx] = true
if g.Layout[idx] == Black {
winners := g.currentTeam().Other()
g.WinningTeam = &winners
return nil
}
g.checkWinningCondition()
if g.Layout[idx] != g.currentTeam() {
g.Round = g.Round + 1
g.RoundStartedAt = time.Now()
}
return nil
}
func (g *Game) currentTeam() Team {
if g.Round%2 == 0 {
return g.StartingTeam
}
return g.StartingTeam.Other()
}
func newGame(id string, state GameState, opts GameOptions) *Game {
// consistent randomness across games with the same seed
seedRnd := rand.New(rand.NewSource(state.Seed))
// distinct randomness across games with same seed
randRnd := rand.New(rand.NewSource(state.Seed * int64(state.PermIndex+1)))
game := &Game{
ID: id,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
StartingTeam: Team(randRnd.Intn(2)) + Red,
Words: make([]string, 0, wordsPerGame),
Layout: make([]Team, 0, wordsPerGame),
GameState: state,
RoundStartedAt: time.Now(),
GameOptions: opts,
}
// Pick the next `wordsPerGame` words from the
// randomly generated permutation
perm := seedRnd.Perm(len(state.WordSet))
permIndex := state.PermIndex
for _, i := range perm[permIndex : permIndex+wordsPerGame] {
w := state.WordSet[perm[i]]
game.Words = append(game.Words, w)
}
// Pick a random permutation of team assignments.
var teamAssignments []Team
teamAssignments = append(teamAssignments, Red.Repeat(8)...)
teamAssignments = append(teamAssignments, Blue.Repeat(8)...)
teamAssignments = append(teamAssignments, Neutral.Repeat(7)...)
teamAssignments = append(teamAssignments, Black)
teamAssignments = append(teamAssignments, game.StartingTeam)
shuffleCount := randRnd.Intn(5) + 5
for i := 0; i < shuffleCount; i++ {
shuffle(randRnd, teamAssignments)
}
game.Layout = teamAssignments
return game
}
func shuffle(rnd *rand.Rand, teamAssignments []Team) {
for i := range teamAssignments {
j := rnd.Intn(i + 1)
teamAssignments[i], teamAssignments[j] = teamAssignments[j], teamAssignments[i]
}
}

58
game_test.go Normal file
View file

@ -0,0 +1,58 @@
package horsepaste
import (
"encoding/json"
"testing"
"github.com/jbowens/dictionary"
)
var testWords []string
func init() {
d, err := dictionary.Load("assets/original.txt")
if err != nil {
panic(err)
}
testWords = d.Words()
}
func BenchmarkGameMarshal(b *testing.B) {
b.StopTimer()
d, err := dictionary.Load("assets/original.txt")
if err != nil {
b.Fatal(err)
}
g := newGame("foo", GameState{
Seed: 1,
Round: 0,
Revealed: make([]bool, 25),
WordSet: d.Words(),
}, GameOptions{})
b.StartTimer()
for i := 0; i < b.N; i++ {
_, err = json.Marshal(g)
if err != nil {
b.Fatal(err)
}
}
}
func TestGameShuffle(t *testing.T) {
gamesWithoutRepeats := len(testWords)/25 - 1
initialState := randomState(testWords)
currState := initialState
m := map[string]int{}
for i := 0; i < gamesWithoutRepeats; i++ {
g := newGame("foo", currState, GameOptions{})
for _, w := range g.Words {
if prevI, ok := m[w]; ok {
t.Errorf("Word %q appeared twice, once in game %d and once in game %d.", w, prevI, i)
}
m[w] = i
}
currState = nextGameState(currState)
}
}

16
go.mod Normal file
View file

@ -0,0 +1,16 @@
module github.com/jbowens/horsepaste
go 1.13
require (
github.com/certifi/gocertifi v0.0.0-20200211180108-c7c1fbc02894 // indirect
github.com/cockroachdb/errors v1.8.2 // indirect
github.com/cockroachdb/pebble v0.0.0-20221028164002-fd4988b3fe89
github.com/cockroachdb/redact v1.0.9 // indirect
github.com/getsentry/raven-go v0.2.0 // indirect
github.com/jbowens/dictionary v0.0.0-20160629041621-229cf68df1a6
github.com/kr/pretty v0.2.1
github.com/kr/text v0.2.0 // indirect
github.com/pkg/errors v0.9.1
golang.org/x/exp v0.0.0-20201229011636-eab1b5eb1a03 // indirect
)

355
go.sum Normal file
View file

@ -0,0 +1,355 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/CloudyKit/fastprinter v0.0.0-20170127035650-74b38d55f37a/go.mod h1:EFZQ978U7x8IRnstaskI3IysnWY5Ao3QgZUKOXlsAdw=
github.com/CloudyKit/jet v2.1.3-0.20180809161101-62edd43e4f88+incompatible/go.mod h1:HPYO+50pSWkPoj9Q/eq0aRGByCL6ScRlUmiEX5Zgm+w=
github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY=
github.com/Joker/jade v1.0.1-0.20190614124447-d475f43051e7/go.mod h1:6E6s8o2AE4KhCrqr6GRJjdC/gNfTdxkIXvuGZZda2VM=
github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398/go.mod h1:a1uqRtAwp2Xwc6WNPJEufxJ7fx3npB4UV/JOLmbu5I0=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/certifi/gocertifi v0.0.0-20200211180108-c7c1fbc02894 h1:JLaf/iINcLyjwbtTsCJjc6rtlASgHeIJPrB6QmwURnA=
github.com/certifi/gocertifi v0.0.0-20200211180108-c7c1fbc02894/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA=
github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cockroachdb/datadriven v1.0.0/go.mod h1:5Ib8Meh+jk1RlHIXej6Pzevx/NLlNvQB9pmSBZErGA4=
github.com/cockroachdb/errors v1.2.4 h1:Lap807SXTH5tri2TivECb/4abUkMZC9zRoLarvcKDqs=
github.com/cockroachdb/errors v1.2.4/go.mod h1:rQD95gz6FARkaKkQXUksEje/d9a6wBJoCr5oaCLELYA=
github.com/cockroachdb/errors v1.5.0 h1:QR6H/GgNSyIrt+AOhC3CdMi7UExDp2pJRXZ/39vXPH8=
github.com/cockroachdb/errors v1.5.0/go.mod h1:tm6FTP5G81vwJ5lC0SizQo374JNCOPrHyXGitRJoDqM=
github.com/cockroachdb/errors v1.6.1/go.mod h1:tm6FTP5G81vwJ5lC0SizQo374JNCOPrHyXGitRJoDqM=
github.com/cockroachdb/errors v1.7.5 h1:ptyO1BLW+sBxwBTSKJfS6kGzYCVKhI7MyBhoXAnPIKM=
github.com/cockroachdb/errors v1.7.5/go.mod h1:m/IWRCPXYZ6TvLLDuC0kfLR1pp/+BiZ0h16WHaBMRMM=
github.com/cockroachdb/errors v1.8.1 h1:A5+txlVZfOqFBDa4mGz2bUWSp0aHElvHX2bKkdbQu+Y=
github.com/cockroachdb/errors v1.8.1/go.mod h1:qGwQn6JmZ+oMjuLwjWzUNqblqk0xl4CVV3SQbGwK7Ac=
github.com/cockroachdb/errors v1.8.2 h1:rnnWK9Nn5kEMOGz9531HuDx/FOleL4NVH20VsDexVC8=
github.com/cockroachdb/errors v1.8.2/go.mod h1:qGwQn6JmZ+oMjuLwjWzUNqblqk0xl4CVV3SQbGwK7Ac=
github.com/cockroachdb/logtags v0.0.0-20190617123548-eb05cc24525f h1:o/kfcElHqOiXqcou5a3rIlMc7oJbMQkeLk0VQJ7zgqY=
github.com/cockroachdb/logtags v0.0.0-20190617123548-eb05cc24525f/go.mod h1:i/u985jwjWRlyHXQbwatDASoW0RMlZ/3i9yJHE2xLkI=
github.com/cockroachdb/pebble v0.0.0-20200327223045-58925e05a8ba h1:G7iTvlQ1Va3kLOYGLMIhua5qyUOuvrW5gQ0GFsuk5Ew=
github.com/cockroachdb/pebble v0.0.0-20200327223045-58925e05a8ba/go.mod h1:97dSg7Ku6fZIyYdu6XUrh31SaKMdnSUN3aDW2Fi8Zp8=
github.com/cockroachdb/pebble v0.0.0-20200402175215-d0f4cdba99a9 h1:rLWIDo4OdC18Ylge5IJulkuG6ci0ahv4FSXWmUdNLhw=
github.com/cockroachdb/pebble v0.0.0-20200402175215-d0f4cdba99a9/go.mod h1:4htqGJZBuuX7YR/S8KKD6GBBC0Ibl+wvy6VEuiGAb6c=
github.com/cockroachdb/pebble v0.0.0-20200730174046-fdd14bd4180c h1:V22IS3X6Vx7cuAx7Hrq9ayHbsBV/bICtuiCI6eBkGj4=
github.com/cockroachdb/pebble v0.0.0-20200730174046-fdd14bd4180c/go.mod h1:TipC4T/j2WXDHhGHuPue5m00pjk21v7qPMYIVj4Ay+I=
github.com/cockroachdb/pebble v0.0.0-20201007144542-b79d619f4761 h1:kzwW9T8+7A1/xuIZi2QCzzmnsS8Ws8zgl6lPXBod5EA=
github.com/cockroachdb/pebble v0.0.0-20201007144542-b79d619f4761/go.mod h1:hU7vhtrqonEphNF+xt8/lHdaBprxmV1h8BOGrd9XwmQ=
github.com/cockroachdb/pebble v0.0.0-20201113231719-11399317ed18 h1:SyU+66SkkE5wFIfUTFm8B4RKdbSrbkA5cTufPb2oyiQ=
github.com/cockroachdb/pebble v0.0.0-20201113231719-11399317ed18/go.mod h1:c3G8ud5zF3+nYHCWmVmtsA8eEtjrDSa6qeLtcRZyevE=
github.com/cockroachdb/pebble v0.0.0-20201230164344-95e53b0ff513 h1:xuicIi37rcc/Nd8eVyScgpq4PSZ7Z7psH3B5QlhU54Q=
github.com/cockroachdb/pebble v0.0.0-20201230164344-95e53b0ff513/go.mod h1:9RB/z2OoNt2vP08nc73FlTVOUhwvgA2/nPSQfgSxq4g=
github.com/cockroachdb/pebble v0.0.0-20221028164002-fd4988b3fe89 h1:ve22cJT315SUC2HVxzOcZlWEqF1+aKuvZ1rFpb0JSXg=
github.com/cockroachdb/pebble v0.0.0-20221028164002-fd4988b3fe89/go.mod h1:buxOO9GBtOcq1DiXDpIPYrmxY020K2A8lOrwno5FetU=
github.com/cockroachdb/redact v0.0.0-20200622112456-cd282804bbd3 h1:2+dpIJzYMSbLi0587YXpi8tOJT52qCOI/1I0UNThc/I=
github.com/cockroachdb/redact v0.0.0-20200622112456-cd282804bbd3/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
github.com/cockroachdb/redact v1.0.6/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
github.com/cockroachdb/redact v1.0.7 h1:7cjFsEgqTuNh+72+gtpnVTCqTiG0vT86ffETvTxyvUo=
github.com/cockroachdb/redact v1.0.7/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
github.com/cockroachdb/redact v1.0.8 h1:8QG/764wK+vmEYoOlfobpe12EQcS81ukx/a4hdVMxNw=
github.com/cockroachdb/redact v1.0.8/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
github.com/cockroachdb/redact v1.0.9 h1:sjlUvGorKMIVQfo+w2RqDi5eewCHn453C/vdIXMzjzI=
github.com/cockroachdb/redact v1.0.9/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
github.com/cockroachdb/sentry-go v0.6.1-cockroachdb.2 h1:IKgmqgMQlVJIZj19CdocBeSfSaiCbEBZGKODaixqtHM=
github.com/cockroachdb/sentry-go v0.6.1-cockroachdb.2/go.mod h1:8BT+cPK6xvFOcRlk0R8eg+OTkcqI6baNH4xAkpiYVvQ=
github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw=
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/flosch/pongo2 v0.0.0-20190707114632-bbf5a6c351f4/go.mod h1:T9YF2M40nIgbVgp3rreNmTged+9HrbNTIQf1PsaIiTA=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
github.com/getsentry/raven-go v0.2.0 h1:no+xWJRb5ZI7eE8TWgIq1jLulQiIoLG0IfYxv5JYMGs=
github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
github.com/ghemawat/stream v0.0.0-20171120220530-696b145b53b9/go.mod h1:106OIgooyS7OzLDOpUGgm9fA3bQENb/cFSyyBmMoJDs=
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/gogo/googleapis v0.0.0-20180223154316-0cd9801be74a/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls=
github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
github.com/gogo/status v1.1.0/go.mod h1:BFv9nrluPLmrS0EmGVvLaPNmRosr9KapBYd5/hpY1WM=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db h1:woRePGFeVFfLKN/pOkfl+p/TAqKOfFu+7KPlMVpok/w=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.2-0.20190904063534-ff6b7dc882cf h1:gFVkHXmVAhEbxZVDln5V9GKrLaluNoFHDbrZwAWZgws=
github.com/golang/snappy v0.0.2-0.20190904063534-ff6b7dc882cf/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.2 h1:aeE13tS0IiQgFjYdoL8qN3K1N2bXXtI6Vi51/y7BpMw=
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.3 h1:fHPg5GQYlCeLIPB9BZqMVR5nR9A+IM5zcgeTdjMYmLA=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/hydrogen18/memlistener v0.0.0-20141126152155-54553eb933fb/go.mod h1:qEIFzExnS6016fRpRfxrExeVn2gbClQA99gQhnIcdhE=
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/iris-contrib/blackfriday v2.0.0+incompatible/go.mod h1:UzZ2bDEoaSGPbkg6SAB4att1aAwTmVIx/5gCVqeyUdI=
github.com/iris-contrib/go.uuid v2.0.0+incompatible/go.mod h1:iz2lgM/1UnEf1kP0L/+fafWORmlnuysV2EMP8MW+qe0=
github.com/iris-contrib/i18n v0.0.0-20171121225848-987a633949d0/go.mod h1:pMCz62A0xJL6I+umB2YTlFRwWXaDFA0jy+5HzGiJjqI=
github.com/iris-contrib/schema v0.0.1/go.mod h1:urYA3uvUNG1TIIjOSCzHr9/LmbQo8LrOcOqfqxa4hXw=
github.com/jbowens/dictionary v0.0.0-20160629041621-229cf68df1a6 h1:0nXhHuURcs0MorJcVwuKnonLQwGw4SoI8gsbOkorzTM=
github.com/jbowens/dictionary v0.0.0-20160629041621-229cf68df1a6/go.mod h1:b/VP1njSxI6yVc/lGA3fuzc1hxJ84X0X8vXcb8x3jow=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/juju/errors v0.0.0-20181118221551-089d3ea4e4d5/go.mod h1:W54LbzXuIE0boCoNJfwqpmkKJ1O4TCTZMetAt6jGk7Q=
github.com/juju/loggo v0.0.0-20180524022052-584905176618/go.mod h1:vgyd7OREkbtVEN/8IXZe5Ooef3LQePvuBm9UWj6ZL8U=
github.com/juju/testing v0.0.0-20180920084828-472a3e8b2073/go.mod h1:63prj8cnj0tU0S9OHjGJn+b1h0ZghCndfnbQolrYTwA=
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
github.com/kataras/golog v0.0.9/go.mod h1:12HJgwBIZFNGL0EJnMRhmvGA0PQGx8VFwrZtM4CqbAk=
github.com/kataras/iris/v12 v12.0.1/go.mod h1:udK4vLQKkdDqMGJJVd/msuMtN6hpYJhg/lSzuxjhO+U=
github.com/kataras/neffos v0.0.10/go.mod h1:ZYmJC07hQPW67eKuzlfY7SO3bC0mw83A3j6im82hfqw=
github.com/kataras/pio v0.0.0-20190103105442-ea782b38602d/go.mod h1:NV88laa9UiiDuX9AhMbDPkGYSPugBOV6yTZB1l2K9Z0=
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.9.0/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.11.7 h1:0hzRabrMN4tSTvMfnL3SCv1ZGeAP23ynzodBgaHeMeg=
github.com/klauspost/compress v1.11.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.1.11/go.mod h1:i541M3Fj6f76NZtHSj7TXnyM8n2gaodfvfxNnFqi74g=
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
github.com/mediocregopher/mediocre-go-lib v0.0.0-20181029021733-cb65787f37ed/go.mod h1:dSsfyI2zABAdhcbvkXqgxOxrCsbYeHCPgrZkku60dSg=
github.com/mediocregopher/radix/v3 v3.3.0/go.mod h1:EmfVyvspXz1uZEyPBMyGK+kjWiKQGvsUt6O3Pj+LDCQ=
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
github.com/nats-io/nats.go v1.8.1/go.mod h1:BrFz9vVn0fU3AcH9Vn4Kd7W0NpJ651tD5omQ3M8LwxM=
github.com/nats-io/nkeys v0.0.2/go.mod h1:dab7URMsZm6Z/jp9Z5UGa87Uutgc2mVpXLC4B7TDb/4=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.13.0/go.mod h1:+REjRxOmWfHCjfv9TTWB1jD1Frx4XydAD3zm1lskyM0=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190426190305-956cc1757749 h1:Bduxdpx1O6126WsH6F6NwKywZ/FPncphlTduoPxFG78=
golang.org/x/exp v0.0.0-20190426190305-956cc1757749/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4=
golang.org/x/exp v0.0.0-20200513190911-00229845015e h1:rMqLP+9XLy+LdbCXHjJHAmTfXCr93W7oruWA6Hq1Alc=
golang.org/x/exp v0.0.0-20200513190911-00229845015e/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw=
golang.org/x/exp v0.0.0-20201008143054-e3b2a7f2fdc7 h1:2/QncOxxpPAdiH+E00abYw/SaQG353gltz79Nl1zrYE=
golang.org/x/exp v0.0.0-20201008143054-e3b2a7f2fdc7/go.mod h1:1phAWC201xIgDyaFpmDeZkgf70Q4Pd/CNqfRtVPtxNw=
golang.org/x/exp v0.0.0-20201229011636-eab1b5eb1a03 h1:XlAInxBYX5nBofPaY51uv/x9xmRgZGr/lDOsePd2AcE=
golang.org/x/exp v0.0.0-20201229011636-eab1b5eb1a03/go.mod h1:I6l2HNBLBZEcrOoCpyKLdY2lHoRZ8lI4x60KMCQDft4=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa h1:KIDDMLT1O0Nr7TSxp8xM5tJcdn8tgyAONntO829og1M=
golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299 h1:DYfZAGf2WMFjMxbgTjaC+2HC7NkNAQs+6Q8b9WEB/F4=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200802091954-4b90ce9b60b3 h1:qDJKu1y/1SjhWac4BQZjLljqvqiWUhjmDMnonmVGDAU=
golang.org/x/sys v0.0.0-20200802091954-4b90ce9b60b3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634 h1:bNEHhJCnrwMKNMmOx3yAynp5vs5/gRy+XWFtZFu7NBM=
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201231184435-2d18734c6014 h1:joucsQqXmyBVxViHCPFjG3hx8JzIFSaym3l3MM/Jsdg=
golang.org/x/sys v0.0.0-20201231184435-2d18734c6014/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210909193231-528a39cd75f3 h1:3Ad41xy2WCESpufXwgs7NpDSu+vjxqLt2UFqUV+20bI=
golang.org/x/sys v0.0.0-20210909193231-528a39cd75f3/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190327201419-c70d86f8b7cf/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180518175338-11a468237815/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/grpc v1.12.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View file

@ -0,0 +1,5 @@
#!/bin/bash
set -ex
pushd frontend
./build.sh
popd

View file

@ -0,0 +1,5 @@
#!/bin/bash
set -ex
pushd frontend
yarn install
popd

466
server.go Normal file
View file

@ -0,0 +1,466 @@
package horsepaste
import (
"crypto/subtle"
"encoding/json"
"html/template"
"io"
"log"
"net/http"
"net/http/pprof"
"os"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/jbowens/dictionary"
)
var closed chan struct{}
func init() {
closed = make(chan struct{})
close(closed)
}
type Server struct {
Server http.Server
Store Store
tpl *template.Template
gameIDWords []string
mu sync.Mutex
games map[string]*GameHandle
defaultWords []string
mux *http.ServeMux
statOpenRequests int64 // atomic access
statTotalRequests int64 // atomic access
}
type Store interface {
Save(*Game) error
Delete(*Game) error
Checkpoint(io.Writer) error
}
type GameHandle struct {
store Store
mu sync.Mutex
updated chan struct{} // closed when the game is updated
replaced chan struct{} // closed when the game has been replaced
marshaled []byte
g *Game
}
func newHandle(g *Game, s Store) *GameHandle {
gh := &GameHandle{
store: s,
g: g,
updated: make(chan struct{}),
replaced: make(chan struct{}),
}
err := s.Save(g)
if err != nil {
log.Printf("Unable to write updated game %q to disk: %s\n", gh.g.ID, err)
}
return gh
}
func (gh *GameHandle) update(fn func(*Game) bool) {
gh.mu.Lock()
defer gh.mu.Unlock()
ok := fn(gh.g)
if !ok {
// game wasn't updated
return
}
gh.marshaled = nil
ch := gh.updated
gh.updated = make(chan struct{})
// write the updated game to disk
err := gh.store.Save(gh.g)
if err != nil {
log.Printf("Unable to write updated game %q to disk: %s\n", gh.g.ID, err)
}
close(ch)
}
func (gh *GameHandle) gameStateChanged(stateID *string) (updated <-chan struct{}, replaced <-chan struct{}) {
if stateID == nil {
return closed, nil
}
gh.mu.Lock()
defer gh.mu.Unlock()
if gh.g.StateID() != *stateID {
return closed, nil
}
return gh.updated, gh.replaced
}
// MarshalJSON implements the encoding/json.Marshaler interface.
// It caches a marshalled value of the game object.
func (gh *GameHandle) MarshalJSON() ([]byte, error) {
gh.mu.Lock()
defer gh.mu.Unlock()
var err error
if gh.marshaled == nil {
gh.marshaled, err = json.Marshal(struct {
*Game
StateID string `json:"state_id"`
}{gh.g, gh.g.StateID()})
}
return gh.marshaled, err
}
func (s *Server) getGame(gameID string) *GameHandle {
s.mu.Lock()
defer s.mu.Unlock()
gh, ok := s.games[gameID]
if ok {
return gh
}
gh = newHandle(newGame(gameID, randomState(s.defaultWords), GameOptions{}), s.Store)
s.games[gameID] = gh
return gh
}
// POST /game-state
func (s *Server) handleGameState(rw http.ResponseWriter, req *http.Request) {
var body struct {
GameID string `json:"game_id"`
StateID *string `json:"state_id"`
}
err := json.NewDecoder(req.Body).Decode(&body)
if err != nil {
http.Error(rw, "Error decoding request body", 400)
return
}
gh := s.getGame(body.GameID)
updated, replaced := gh.gameStateChanged(body.StateID)
select {
case <-req.Context().Done():
return
case <-time.After(15 * time.Second):
writeGame(rw, gh)
case <-updated:
writeGame(rw, gh)
case <-replaced:
gh = s.getGame(body.GameID)
writeGame(rw, gh)
}
}
// POST /guess
func (s *Server) handleGuess(rw http.ResponseWriter, req *http.Request) {
var request struct {
GameID string `json:"game_id"`
Index int `json:"index"`
}
decoder := json.NewDecoder(req.Body)
if err := decoder.Decode(&request); err != nil {
http.Error(rw, "Error decoding", 400)
return
}
gh := s.getGame(request.GameID)
var err error
gh.update(func(g *Game) bool {
err = g.Guess(request.Index)
return err == nil
})
if err != nil {
http.Error(rw, err.Error(), 400)
return
}
writeGame(rw, gh)
}
// POST /end-turn
func (s *Server) handleEndTurn(rw http.ResponseWriter, req *http.Request) {
var request struct {
GameID string `json:"game_id"`
CurrentRound int `json:"current_round"`
}
decoder := json.NewDecoder(req.Body)
if err := decoder.Decode(&request); err != nil {
http.Error(rw, "Error decoding", 400)
return
}
gh := s.getGame(request.GameID)
gh.update(func(g *Game) bool {
return g.NextTurn(request.CurrentRound)
})
writeGame(rw, gh)
}
func (s *Server) handleNextGame(rw http.ResponseWriter, req *http.Request) {
var request struct {
GameID string `json:"game_id"`
WordSet []string `json:"word_set"`
CreateNew bool `json:"create_new"`
TimerDurationMS int64 `json:"timer_duration_ms"`
EnforceTimer bool `json:"enforce_timer"`
}
if err := json.NewDecoder(req.Body).Decode(&request); err != nil {
http.Error(rw, "Error decoding", 400)
return
}
wordSet := map[string]bool{}
for _, w := range request.WordSet {
wordSet[strings.TrimSpace(strings.ToUpper(w))] = true
}
if len(wordSet) > 0 && len(wordSet) < 25 {
http.Error(rw, "Need at least 25 words", 400)
return
}
if len(wordSet) > 10000 {
http.Error(rw, "Too many words in the set.", 400)
return
}
var gh *GameHandle
func() {
s.mu.Lock()
defer s.mu.Unlock()
words := s.defaultWords
if len(wordSet) > 0 {
words = nil
for w := range wordSet {
words = append(words, w)
}
sort.Strings(words)
}
opts := GameOptions{
TimerDurationMS: request.TimerDurationMS,
EnforceTimer: request.EnforceTimer,
}
var ok bool
gh, ok = s.games[request.GameID]
if !ok {
// no game exists, create for the first time
gh = newHandle(newGame(request.GameID, randomState(words), opts), s.Store)
s.games[request.GameID] = gh
} else if request.CreateNew {
replacedCh := gh.replaced
previousGame := gh.g
nextState := nextGameState(gh.g.GameState)
gh = newHandle(newGame(request.GameID, nextState, opts), s.Store)
s.games[request.GameID] = gh
// signal to waiting /game-state goroutines that the
// old game was swapped out for a new game.
close(replacedCh)
// Delete the old game from the store. This isn't strictly
// necessary, but it helps us reclaim disk space a little more
// quickly.
err := s.Store.Delete(previousGame)
if err != nil {
log.Printf("Unable to delete old game %q from disk: %s\n", previousGame.ID, err)
}
}
}()
writeGame(rw, gh)
}
type statsResponse struct {
GamesTotal int `json:"games_total"`
GamesInProgress int `json:"games_in_progress"`
GamesCreatedOneHour int `json:"games_created_1h"`
RequestsTotal int64 `json:"requests_total_process_lifetime"`
RequestsInFlight int64 `json:"requests_in_flight"`
}
func (s *Server) handleStats(rw http.ResponseWriter, req *http.Request) {
hourAgo := time.Now().Add(-time.Hour)
s.mu.Lock()
defer s.mu.Unlock()
var inProgress, createdWithinAnHour int
for _, gh := range s.games {
gh.mu.Lock()
if gh.g.WinningTeam == nil && gh.g.anyRevealed() {
inProgress++
}
if hourAgo.Before(gh.g.CreatedAt) {
createdWithinAnHour++
}
gh.mu.Unlock()
}
writeJSON(rw, statsResponse{
GamesTotal: len(s.games),
GamesInProgress: inProgress,
GamesCreatedOneHour: createdWithinAnHour,
RequestsTotal: atomic.LoadInt64(&s.statTotalRequests),
RequestsInFlight: atomic.LoadInt64(&s.statOpenRequests),
})
}
func (s *Server) handleCheckpoint(rw http.ResponseWriter, req *http.Request) {
err := s.Store.Checkpoint(rw)
if err != nil {
log.Printf("[ERROR] Write checkpoint %s\n", err)
}
}
func (s *Server) cleanupOldGames() {
s.mu.Lock()
defer s.mu.Unlock()
for id, gh := range s.games {
gh.mu.Lock()
if gh.g.WinningTeam != nil && gh.g.CreatedAt.Add(3*time.Hour).Before(time.Now()) {
delete(s.games, id)
log.Printf("Removed completed game %s\n", id)
} else if gh.g.CreatedAt.Add(72 * time.Hour).Before(time.Now()) {
delete(s.games, id)
log.Printf("Removed expired game %s\n", id)
}
gh.mu.Unlock()
}
}
func (s *Server) Start(games map[string]*Game) error {
gameIDs, err := dictionary.Load("assets/game-id-words.txt")
if err != nil {
return err
}
d, err := dictionary.Load("assets/original.txt")
if err != nil {
return err
}
s.tpl, err = template.New("index").Parse(tpl)
if err != nil {
return err
}
s.mux = http.NewServeMux()
s.mux.HandleFunc("/stats", s.handleStats)
s.mux.HandleFunc("/next-game", s.handleNextGame)
s.mux.HandleFunc("/end-turn", s.handleEndTurn)
s.mux.HandleFunc("/guess", s.handleGuess)
s.mux.HandleFunc("/game-state", s.handleGameState)
s.mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("frontend/dist"))))
s.mux.HandleFunc("/", s.handleIndex)
bootstrapPW := os.Getenv("BOOTSTRAPPW")
// If no bootstrap PW is set, don't expose the checkpoint endpoint so we
// don't default to open.
if bootstrapPW != "" {
log.Printf("/checkpoint endpoint enabled\n")
s.mux.Handle("/checkpoint", basicAuth(
http.HandlerFunc(s.handleCheckpoint),
os.Getenv("BOOTSTRAPPW"),
"admin"))
}
gameIDs = dictionary.Filter(gameIDs, func(s string) bool { return len(s) >= 3 })
s.gameIDWords = gameIDs.Words()
for i, w := range s.gameIDWords {
s.gameIDWords[i] = strings.ToLower(w)
}
s.games = make(map[string]*GameHandle)
s.defaultWords = d.Words()
sort.Strings(s.defaultWords)
s.Server.Handler = withPProfHandler(s)
if s.Store == nil {
s.Store = discardStore{}
}
if games != nil {
for _, g := range games {
s.games[g.ID] = newHandle(g, s.Store)
}
}
go func() {
for range time.Tick(10 * time.Minute) {
s.cleanupOldGames()
}
}()
return s.Server.ListenAndServe()
}
func (s *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
atomic.AddInt64(&s.statTotalRequests, 1)
atomic.AddInt64(&s.statOpenRequests, 1)
defer func() { atomic.AddInt64(&s.statOpenRequests, -1) }()
s.mux.ServeHTTP(rw, req)
}
func withPProfHandler(next http.Handler) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/debug/pprof/", pprof.Index)
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
pprofHandler := basicAuth(mux, os.Getenv("PPROFPW"), "admin")
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
if strings.HasPrefix(req.URL.Path, "/debug/pprof") {
pprofHandler.ServeHTTP(rw, req)
return
}
next.ServeHTTP(rw, req)
})
}
func basicAuth(handler http.Handler, password, realm string) http.Handler {
p := []byte(password)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, pass, ok := r.BasicAuth()
if !ok || subtle.ConstantTimeCompare([]byte(pass), p) != 1 {
w.Header().Set("WWW-Authenticate", `Basic realm="`+realm+`"`)
w.WriteHeader(401)
io.WriteString(w, "Unauthorized\n")
return
}
handler.ServeHTTP(w, r)
})
}
func writeGame(rw http.ResponseWriter, gh *GameHandle) {
writeJSON(rw, gh)
}
func writeJSON(rw http.ResponseWriter, resp interface{}) {
j, err := json.Marshal(resp)
if err != nil {
http.Error(rw, "unable to marshal response: "+err.Error(), 500)
return
}
rw.Header().Set("Content-Type", "application/json")
rw.Write(j)
}

160
store.go Normal file
View file

@ -0,0 +1,160 @@
package horsepaste
import (
"compress/gzip"
"encoding/gob"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"log"
"math"
"os"
"path/filepath"
"time"
"github.com/cockroachdb/pebble"
)
// PebbleStore wraps a *pebble.DB with an implementation of the
// Store interface, persisting games under a []byte(`/games/`)
// key prefix.
type PebbleStore struct {
DB *pebble.DB
}
// Restore loads all persisted games from storage.
func (ps *PebbleStore) Restore() (map[string]*Game, error) {
iter := ps.DB.NewIter(&pebble.IterOptions{
LowerBound: []byte("/games/"),
UpperBound: []byte(fmt.Sprintf("/games/%019d", math.MaxInt64)),
})
defer iter.Close()
games := make(map[string]*Game)
for _ = iter.First(); iter.Valid(); iter.Next() {
var g Game
err := json.Unmarshal(iter.Value(), &g)
if err != nil {
return nil, fmt.Errorf("Unmarshal game: %w", err)
}
games[g.ID] = &g
}
if err := iter.Error(); err != nil {
return nil, fmt.Errorf("restore iter: %w", err)
}
return games, nil
}
// DeleteExpired deletes all games created before `expiry.`
func (ps *PebbleStore) DeleteExpired(expiry time.Time) error {
return ps.DB.DeleteRange(
mkkey(0, ""),
mkkey(expiry.Unix(), ""),
nil,
)
}
// Save saves the game to persistent storage.
func (ps *PebbleStore) Save(g *Game) error {
k, v, err := gameKV(g)
if err != nil {
return fmt.Errorf("trySave: %w", err)
}
err = ps.DB.Set(k, v, &pebble.WriteOptions{Sync: true})
if err != nil {
return fmt.Errorf("db.Set: %w", err)
}
return err
}
// Delete removes a game from persistent storage.
func (ps *PebbleStore) Delete(g *Game) error {
k := mkkey(g.CreatedAt.Unix(), g.ID)
err := ps.DB.Delete(k, nil)
if err != nil {
return fmt.Errorf("db.Delete: %w", err)
}
return nil
}
type CheckpointFile struct {
Name string
Data []byte
}
// Checkpoint returns a serialized represenation of the entire store.
func (ps *PebbleStore) Checkpoint(w io.Writer) error {
// Compact the entire key space. The database tends to be small and there
// tends to be a significant number of obsolete keys, so this shouldn't be
// too expensive but will reduce the number of bytes we need to send over
// the network.
err := ps.DB.Compact([]byte{}, []byte{0xFF, 0xFF, 0xFF, 0xFF}, true /* parallel */)
if err != nil {
return err
}
// Create a Pebble checkpoint in a temporary directory.
name, err := ioutil.TempDir("", "checkpoint")
if err != nil {
return err
}
if err := os.RemoveAll(name); err != nil {
return err
}
defer os.RemoveAll(name)
err = ps.DB.Checkpoint(name)
if err != nil {
return err
}
// Write all the files in the checkpoint out over the network.
gzipWriter := gzip.NewWriter(w)
enc := gob.NewEncoder(gzipWriter)
err = filepath.Walk(name, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
b, err := ioutil.ReadFile(path)
if err != nil {
return err
}
relPath, err := filepath.Rel(name, path)
if err != nil {
return err
}
log.Printf("Checkpoint sending file %s (%d bytes)\n", relPath, len(b))
return enc.Encode(CheckpointFile{
Name: relPath,
Data: b,
})
})
if err != nil {
return err
}
return gzipWriter.Close()
}
func gameKV(g *Game) (key, value []byte, err error) {
value, err = json.Marshal(g)
if err != nil {
return nil, nil, fmt.Errorf("marshaling GameState: %w", err)
}
return mkkey(g.CreatedAt.Unix(), g.ID), value, nil
}
func mkkey(unixSecs int64, id string) []byte {
// We could use a binary encoding for keys,
// but it's not like we're storing that many
// kv pairs. Ease of debugging is probably
// more important.
return []byte(fmt.Sprintf("/games/%019d/%q", unixSecs, id))
}
type discardStore struct{}
func (ds discardStore) Save(*Game) error { return nil }
func (ds discardStore) Delete(*Game) error { return nil }
func (ds discardStore) Checkpoint(io.Writer) error { return nil }

93
store_test.go Normal file
View file

@ -0,0 +1,93 @@
package horsepaste
import (
"io/ioutil"
"reflect"
"testing"
"github.com/cockroachdb/pebble"
"github.com/jbowens/dictionary"
"github.com/kr/pretty"
)
var gameIDs, words []string
func init() {
dictGameIDs, err := dictionary.Load("assets/game-id-words.txt")
if err != nil {
panic(err)
}
dictWords, err := dictionary.Load("assets/original.txt")
if err != nil {
panic(err)
}
gameIDs = dictGameIDs.Words()
words = dictWords.Words()
}
func randomGames(n int) map[string]*Game {
games := make(map[string]*Game)
for _, w := range gameIDs[:n] {
games[w] = newGame(w, randomState(words), GameOptions{})
}
return games
}
func TestPersist(t *testing.T) {
dir, err := ioutil.TempDir("", "test-persist-*")
if err != nil {
t.Fatal(err)
}
t.Logf("pebble store dir: %s\n", dir)
var ps PebbleStore
ps.DB, err = pebble.Open(dir, nil)
if err != nil {
t.Fatal(err)
}
games := randomGames(5)
for _, g := range games {
if err := ps.Save(g); err != nil {
t.Fatal(err)
}
}
if err := ps.DB.Close(); err != nil {
t.Fatal(err)
}
// Re-open the DB.
ps.DB, err = pebble.Open(dir, nil)
if err != nil {
t.Fatal(err)
}
restoredGames, err := ps.Restore()
if err != nil {
t.Fatal(err)
}
if err := ps.DB.Close(); err != nil {
t.Fatal(err)
}
// Verify the game states are the same.
for id, g := range games {
got, ok := restoredGames[id]
if !ok {
t.Fatalf("restoredGames[%q] doesn't exist", id)
}
if !reflect.DeepEqual(got.GameState, g.GameState) {
t.Fatalf("%s: GameStates don't match: %s, %s",
id, pretty.Sprint(got.GameState), pretty.Sprint(g.GameState))
}
if !reflect.DeepEqual(got.Words, g.Words) {
t.Fatalf("%s: Words don't match: %s, %s",
id, pretty.Sprint(got.Words), pretty.Sprint(g.Words))
}
if !reflect.DeepEqual(got.Layout, g.Layout) {
t.Fatalf("%s: Layout don't match: %s, %s",
id, pretty.Sprint(got.Layout), pretty.Sprint(g.Layout))
}
}
}

64
wordset.go Normal file
View file

@ -0,0 +1,64 @@
package horsepaste
import (
"crypto/sha1"
"errors"
"fmt"
"io"
"sort"
"strings"
"sync"
)
type wordSetID [sha1.Size]byte
func (i wordSetID) String() string {
return fmt.Sprintf("%x", i[:])
}
type WordSets struct {
mu sync.Mutex
byID map[wordSetID][]string
}
func (ws *WordSets) init() {
if ws.byID == nil {
ws.byID = make(map[wordSetID][]string)
}
}
func (ws *WordSets) Canonicalize(words []string) (wordSetID, []string, error) {
ws.mu.Lock()
defer ws.mu.Unlock()
ws.init()
set := map[string]bool{}
for _, w := range words {
set[strings.TrimSpace(strings.ToUpper(w))] = true
}
if len(set) > 0 && len(set) < 25 {
return wordSetID{}, nil, errors.New("need at least 25 words")
}
words = words[:0]
for w := range set {
words = append(words, w)
}
sort.Strings(words)
// Calculate the word set ID, a hash of the canonicalized word set.
h := sha1.New()
for _, w := range words {
io.WriteString(h, w)
h.Write([]byte{0x00})
}
idBytes := h.Sum(nil)
var id wordSetID
copy(id[:], idBytes)
if interned, ok := ws.byID[id]; ok {
return id, interned, nil
}
ws.byID[id] = words
return id, words, nil
}

43
wordset_test.go Normal file
View file

@ -0,0 +1,43 @@
package horsepaste
import (
"bytes"
"encoding/json"
"io/ioutil"
"testing"
)
func TestWordSetCanonicalize(t *testing.T) {
b, err := ioutil.ReadFile("frontend/words.json")
if err != nil {
t.Fatal(err)
}
var defaultWordsets map[string][]string
err = json.NewDecoder(bytes.NewReader(b)).Decode(&defaultWordsets)
if err != nil {
t.Fatal(err)
}
internedSets := map[string][]string{}
var ws WordSets
for name, words := range defaultWordsets {
id, interned, err := ws.Canonicalize(words)
if err != nil {
t.Fatal(err)
}
t.Logf("%s : %s\n", name, id)
internedSets[name] = interned
}
for name, words := range defaultWordsets {
words2 := append([]string{}, words...)
_, interned, err := ws.Canonicalize(words2)
if err != nil {
t.Fatal(err)
}
if &internedSets[name][0] != &interned[0] {
t.Errorf("word set %q has different slice pointer 2nd canonicalization", name)
}
}
}