all repos — felt @ 25e51fb2d5626544e3bcd3c339f3899ae854c29c

virtual tabletop for dungeons and dragons (and similar) using Go, MongoDB, and websockets

initial commit - basic websocket server and db init
Iris Lightshard nilix@nilfm.cc
PGP Signature
-----BEGIN PGP SIGNATURE-----

iQIzBAABCAAdFiEEkFh6dA+k/6CXFXU4O3+8IhROY5gFAmNsfW0ACgkQO3+8IhRO
Y5hQAQ/+IUCdSKhL67mmgDVkyFtOA20vaJGGDJYLV7saW9CFuRI6QZoIel4OOPIG
H1QW9vM0oI6fvonOwYFvJP1gOvMl33UcRzKENSvPCSKfEIbVZhCIepRVyLD0ao4f
5yhcco/iKhetl9JJhBckz3tnw2EbkYAH5LF8uEDgJ25BUmt4zyKOn4n8O/QMWgIW
8XiFYi6KXtCOVVEKRzaQ3Z0DYBSStafWhBQBGnF569fdmMY2JR4XYQ6ZFfdvTPnq
NhYy0MGXgi4SFNXVhmK8tPOZajbDJrHLbUjxY8/9b8+cBJ82ukb29YSWgaXjYgEu
Wsnt+k0A6apYwR4R6TVh8Kmi5OTF02GjerWp/DZFapajE/HVgxJrEwWraGmrGaeE
3ffx0SW3Lr9GMhjV2rSaOUTtmiafixOXrQ+WU4xMjcxMMShOIk15CehF5iBgS4YG
5bu8SoKDTe2Q2aC+r4FriwHcH6UXuRxzERwBrFJqPAiqlqP6VyfpARUJjbR7cMiI
pUdctVXd1o6s/hBuqSX8i+AisB6TneFq86bYTOz/hh79R2WG2CJIu9C8MwCkWEhN
gHYzVgv0V0mWtGmLflz013gVqB1LpIy9gL+FguDtVYo7lFdB1OsACNBSfGRxdpHt
AcvtLww7Fq8MSqvzhRJBWopbxaNpvtUxZDgHjgDl0teBiWAFGgo=
=5JCT
-----END PGP SIGNATURE-----
commit

25e51fb2d5626544e3bcd3c339f3899ae854c29c

A .gitignore

@@ -0,0 +1,3 @@

+felt +mongodb/data/* +mongodb/.env
A LICENSE

@@ -0,0 +1,22 @@

+MIT License + +Copyright (c) 2018 Anmol Sethi <hi@nhooyr.io> +Copyright (c) 2022 Derek Stevens <nilix@nilfm.cc> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.
A gametable/server.go

@@ -0,0 +1,145 @@

+package gametable + +import ( + "context" + "nhooyr.io/websocket" + "golang.org/x/time/rate" + "io/ioutil" + "time" + "sync" + "net/http" + "log" + "errors" +) + +type Subscriber struct { + msgs chan []byte + closeSlow func() +} + +type GameTableServer struct { + subscribeMessageBuffer int + publishLimiter *rate.Limiter + logf func(f string, v ...interface{}) + serveMux http.ServeMux + subscribersLock sync.Mutex + subscribers map[*Subscriber]struct{} +} + +func New() *GameTableServer { + srvr := &GameTableServer { + subscribeMessageBuffer: 16, + logf: log.Printf, + subscribers: make(map[*Subscriber]struct{}), + publishLimiter: rate.NewLimiter(rate.Every(time.Millisecond*100), 8), + } + srvr.serveMux.Handle("/", http.FileServer(http.Dir("./static"))) + srvr.serveMux.HandleFunc("/subscribe", srvr.subscribeHandler) + srvr.serveMux.HandleFunc("/publish", srvr.publishHandler) + + return srvr +} + +func (self *GameTableServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + self.serveMux.ServeHTTP(w, r) +} + +func (self *GameTableServer) subscribeHandler(w http.ResponseWriter, r *http.Request) { + c, err := websocket.Accept(w, r, nil) + if err != nil { + self.logf("%v", err) + return + } + defer c.Close(websocket.StatusInternalError, "") + + err = self.subscribe(r.Context(), c) + if errors.Is(err, context.Canceled) { + return + } + if websocket.CloseStatus(err) == websocket.StatusNormalClosure || + websocket.CloseStatus(err) == websocket.StatusGoingAway { + return + } + if err != nil { + self.logf("%v", err) + return + } +} + +func (self *GameTableServer) subscribe(ctx context.Context, c *websocket.Conn) error { + ctx = c.CloseRead(ctx) + + s := &Subscriber{ + msgs: make(chan []byte, self.subscribeMessageBuffer), + closeSlow: func() { + c.Close(websocket.StatusPolicyViolation, "connection too slow to keep up with messages") + }, + } + self.addSubscriber(s) + defer self.deleteSubscriber(s) + + for { + select { + case msg := <-s.msgs: + err := writeTimeout(ctx, time.Second*5, c, msg) + if err != nil { + return err + } + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func (self *GameTableServer) publishHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + body := http.MaxBytesReader(w, r.Body, 8192) + msg, err := ioutil.ReadAll(body) + if err != nil { + http.Error(w, http.StatusText(http.StatusRequestEntityTooLarge), http.StatusRequestEntityTooLarge) + return + } + + self.publish(msg) + + w.WriteHeader(http.StatusAccepted) +} + +func (self *GameTableServer) publish(msg []byte) { + self.subscribersLock.Lock() + defer self.subscribersLock.Unlock() + + // decode message and store in DB + + self.publishLimiter.Wait(context.Background()) + + for s := range self.subscribers { + select { + case s.msgs <- msg: + default: + go s.closeSlow() + } + } +} + +func (self *GameTableServer) addSubscriber(s *Subscriber) { + self.subscribersLock.Lock() + self.subscribers[s] = struct{}{} + self.subscribersLock.Unlock() +} + +func (self *GameTableServer) deleteSubscriber(s *Subscriber) { + self.subscribersLock.Lock() + delete(self.subscribers, s) + self.subscribersLock.Unlock() +} + +func writeTimeout(ctx context.Context, timeout time.Duration, c *websocket.Conn, msg []byte) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + return c.Write(ctx, websocket.MessageText, msg) +}
A go.mod

@@ -0,0 +1,9 @@

+module nilfm.cc/git/felt + +go 1.19 + +require ( + github.com/klauspost/compress v1.10.3 // indirect + golang.org/x/time v0.1.0 // indirect + nhooyr.io/websocket v1.8.7 // indirect +)
A go.sum

@@ -0,0 +1,41 @@

+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/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= +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/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/klauspost/compress v1.10.3 h1:OP96hzwJVBIHYU52pVTI6CczrxPvrGfgqF9N5eTO0Q8= +github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +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/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.1.0 h1:xYY+Bajn2a7VBmTM5GikTmnK8ZuX8YgnQCqZpbBNtmA= +golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= +nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=
A main.go

@@ -0,0 +1,52 @@

+package main + +import ( + "context" + "nilfm.cc/git/felt/gametable" + "net" + "net/http" + "os" + "os/signal" + "time" + "log" +) + +func main() { + err := run() + if err != nil { + log.Fatal(err) + } +} + +func run() error { + l, err := net.Listen("tcp", os.Args[1]) + if err != nil { + return err + } + + gt := gametable.New() + s := &http.Server{ + Handler: gt, + ReadTimeout: time.Second * 10, + WriteTimeout: time.Second * 10, + } + + errc := make(chan error, 1) + go func() { + errc <- s.Serve(l) + }() + + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, os.Interrupt) + select { + case err := <-errc: + log.Printf("failed to serve: %v", err) + case sig := <-sigs: + log.Printf("terminating: %v", sig) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + + return s.Shutdown(ctx) +}
A models/models.go

@@ -0,0 +1,25 @@

+package models + +import ( + "time" +) + +type TableKey struct { + name: string + passcode: string +} + +type DiceRoll struct { + faces: uint8 + roll: uint8[] + player: string + note: string + timestamp: time.Time +} + +type Token struct { + id: string + spriteUri: string + x: int + y: int +}
A mongodb/.env.example

@@ -0,0 +1,5 @@

+MONGO_INITDB_ROOT_USERNAME=root +MONGO_INITDB_ROOT_PASSWORD=not_the_real_password +MONGO_INITDB_DATABASE=admin +dbUser=felt_api +dbPwd=not_the_real_password_either
A mongodb/Dockerfile

@@ -0,0 +1,3 @@

+FROM mongo:6.0.2 + +COPY ./db_init.sh /docker-entrypoint-initdb.d/
A mongodb/adapter.go

@@ -0,0 +1,76 @@

+package dbengine + +import ( + "context" + "time" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "nilfm.cc/git/felt/models" +) + +interface DbAdapter { + Init(mongoUri: string): error + + CreateTable(table: models.TableKey): error + DestroyTable(table: models.TableKey): error + + InsertDiceRoll(table: models.TableKey, diceRoll: models.DiceRoll): error + GetDiceRolls(table: models.TableKey): models.DiceRoll[], error + + SetMapImageUrl(table: models.TableKey, url: string): error + GetMapImageUrl(table: models.TableKey): string, error + + AddToken(table: models.TableKey, token: models.Token): error + RemoveToken(table: models.TableKey, tokenId: string): error + ModifyToken(table: models.TableKey, token: models.Token): error + GetTokens(table: models.TableKey): models.Token[], error +} + +type DbEngine struct { + client: mongo.Client +} + +func (self *DbEngine) Init(mongoUri: string) error { + client, err := mongo.NewClient(options.Client().ApplyURI(mongoUri)) + if err != nil { + return err + } + self.client = client + ctx, _ := context.WithTimeout(context.Background(), 10*time.Second) + + err = client.Connect(ctx) + if err != nil { + return err + } + defer client.Disconnect(ctx) + + db := client.Database("felt") + + err = self.ensureCollections(db) + return err +} + +func (self *DbEngine) ensureCollections(db: mongo.Database) error { + tables := db.Collection("tables") + if tables == nil { + createCmd := bson.D{ + {"create", "tables"}, + {"clusteredIndex", { + {"key", {"name"}}, + {"unique", true}, + {"name", "idx_tables_unique_names"} + }} + } + + var createResult bson.M + err := db.RunCommand( + context.WithTimeout(context.Background(), 10*time.Second), + createCmd).Decode(&createResult) + + if err != nil { + return err + } + } + return nil +}
A mongodb/db_init.sh

@@ -0,0 +1,40 @@

+#!/bin/bash +set -e + +# dbUser is the userName used from applicatoin code to interact with databases and dbPwd is the password for this user. +# MONGO_INITDB_ROOT_USERNAME & MONGO_INITDB_ROOT_PASSWORD is the config for db admin. +# admin user is expected to be already created when this script executes. We use it here to authenticate as admin to create +# dbUser and databases. + +echo ">>>>>>> trying to create database and users" +if [ -n "${MONGO_INITDB_ROOT_USERNAME:-}" ] && [ -n "${MONGO_INITDB_ROOT_PASSWORD:-}" ] && [ -n "${dbUser:-}" ] && [ -n "${dbPwd:-}" ]; then +mongosh -u $MONGO_INITDB_ROOT_USERNAME -p $MONGO_INITDB_ROOT_PASSWORD<<EOF + +// create DB +db=db.getSiblingDB('felt'); +use felt; + +if (db.system.users.find({user:'$dbUser'}).count()) { + return; +} + +// Create user account the API uses to connect to db +db.createUser({ + user: '$dbUser', + pwd: '$dbPwd', + roles: [{ + role: 'readWrite', + db: 'felt' + }] +}); + +// Insert default config options +db.config.insertOne({ + _immutable: true +}); + +EOF +else + echo "MONGO_INITDB_ROOT_USERNAME,MONGO_INITDB_ROOT_PASSWORD,dbUser and dbPwd must be provided. Some of these are missing, hence exiting database and user creation" + exit 403 +fi
A mongodb/run.sh

@@ -0,0 +1,11 @@

+#!/bin/sh + +if [ "$1" = "build" ]; then + docker build -t felt_db ./ +fi + +if [ ! -e .env ]; then + cp .env.example .env +fi + +docker run -it --rm --env-file .env -p 27017:27017 -v felt_data:/data/db felt_db