all repos — felt @ 952f80dbc25de7d35aa59625ae75fc24e681b74e

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

delete assets when destroying table, clear dice message on roll, clear map on socket disconnect; start registration controller
Iris Lightshard nilix@nilfm.cc
commit

952f80dbc25de7d35aa59625ae75fc24e681b74e

parent

e7081a53201fe48f7741119c3f475371aad013e2

M admin/admin.goadmin/admin.go

@@ -110,7 +110,7 @@

return http.HandlerFunc(handlerFunc) } -func apiDestroyTable(next http.Handler, udb auth.UserStore, dbAdapter mongodb.DbAdapter) http.Handler { +func apiDestroyTable(next http.Handler, udb auth.UserStore, dbAdapter mongodb.DbAdapter, uploads string) http.Handler { handlerFunc := func(w http.ResponseWriter, req *http.Request) { // check table actually belongs to this user user := util.GetUserFromToken(req)

@@ -139,6 +139,7 @@ }

} if destroy { + os.RemoveAll(filepath.Join(uploads, table.Name)) newTables := append(tables[:i], tables[i+1:]...) util.SetTablesForUser(user, newTables, udb) w.WriteHeader(204)

@@ -338,7 +339,7 @@ }

func CreateAdminInterface(udb auth.UserStore, dbAdapter mongodb.DbAdapter, uploads string, uploadMaxMB int) http.Handler { // create quartzgun router - rtr := &router.Router{Fallback: *template.Must(template.ParseFiles("static/error.html"))} + rtr := &router.Router{Fallback: *template.Must(template.ParseFiles("templates/error.html"))} scopes := map[string]string{}

@@ -348,7 +349,7 @@ // table management

rtr.Get("/api/table/", Validate(apiGetTableList(renderer.JSON("tableList"), udb), udb, scopes)) rtr.Get(`/api/table/(?P<Slug>\S+)`, Validate(apiGetTableData(renderer.JSON("tableData"), udb, dbAdapter), udb, scopes)) rtr.Post("/api/table/", Validate(apiCreateTable(renderer.JSON("result"), udb, dbAdapter), udb, scopes)) - rtr.Delete(`/api/table/(?P<Slug>\S+)`, Validate(apiDestroyTable(renderer.JSON("result"), udb, dbAdapter), udb, scopes)) + rtr.Delete(`/api/table/(?P<Slug>\S+)`, Validate(apiDestroyTable(renderer.JSON("result"), udb, dbAdapter, uploads), udb, scopes)) // asset management rtr.Post(`/api/upload/(?P<Slug>\S+)/map/`, Validate(apiUploadImg(renderer.JSON("location"), dbAdapter, uploads, "map", uploadMaxMB), udb, scopes))

@@ -357,7 +358,6 @@ rtr.Delete(`/api/upload/(?P<table>\S+)/map/(?P<file>\S+)`, Validate(apiDeleteImage(renderer.JSON("deleted"), uploads, "map", udb, dbAdapter), udb, scopes))

rtr.Delete(`/api/upload/(?P<table>\S+)/token/(?P<file>\S+)`, Validate(apiDeleteImage(renderer.JSON("deleted"), uploads, "token", udb, dbAdapter), udb, scopes)) rtr.Post(`/api/upload/(?P<Slug>\S+)/token/`, Validate(apiUploadImg(renderer.JSON("location"), dbAdapter, uploads, "token", uploadMaxMB), udb, scopes)) rtr.Get(`/api/upload/(?P<Slug>\S+)/token/`, Validate(apiListImages(renderer.JSON("files"), uploads, "token", udb, dbAdapter), udb, scopes)) - // DELETE /api/upload/<table>/token/<token> return http.HandlerFunc(rtr.ServeHTTP) }
M config/config.goconfig/config.go

@@ -10,10 +10,11 @@ "strings"

) type Config struct { - Port int - Uploads string - UploadMaxMB int - MongoURI string + Port int + Uploads string + UploadMaxMB int + MongoURI string + RegistrationSecret string } func GetConfigLocation() string {

@@ -80,6 +81,10 @@

inputBuf = "" fmt.Printf("Max file upload size (MB)? ") self.UploadMaxMB = ensureNumberOption(&inputBuf) + + fmt.Printf("Encryption secret for admin invite codes? ") + ensureNonEmptyOption(&inputBuf) + self.RegistrationSecret = inputBuf fmt.Printf("Configuration complete!\n") self.Write()
M gametable/server.gogametable/server.go

@@ -10,6 +10,7 @@ "golang.org/x/time/rate"

"hacklab.nilfm.cc/felt/admin" "hacklab.nilfm.cc/felt/models" "hacklab.nilfm.cc/felt/mongodb" + "hacklab.nilfm.cc/felt/register" "hacklab.nilfm.cc/quartzgun/auth" "hacklab.nilfm.cc/quartzgun/renderer" "io/ioutil"

@@ -37,7 +38,7 @@ dbAdapter mongodb.DbAdapter

udb auth.UserStore } -func New(adapter mongodb.DbAdapter, udb auth.UserStore, uploads string, uploadMaxMB int) *GameTableServer { +func New(adapter mongodb.DbAdapter, udb auth.UserStore, uploads string, uploadMaxMB int, registrationSecret string) *GameTableServer { srvr := &GameTableServer{ subscribeMessageBuffer: 16, logf: log.Printf,

@@ -49,6 +50,7 @@ }

srvr.serveMux.Handle("/table/", http.StripPrefix("/table/", renderer.Subtree("./static"))) srvr.serveMux.Handle("/uploads/", http.StripPrefix("/uploads/", renderer.Subtree(uploads))) srvr.serveMux.Handle("/admin/", http.StripPrefix("/admin", admin.CreateAdminInterface(udb, adapter, uploads, uploadMaxMB))) + srvr.serveMux.Handle("/register/", http.StripPrefix("/register", register.CreateRegistrationInterface(udb, registrationSecret))) srvr.serveMux.HandleFunc("/subscribe", srvr.subscribeHandler) srvr.serveMux.HandleFunc("/publish", srvr.publishHandler)
M main.gomain.go

@@ -48,7 +48,7 @@ if err != nil {

return err } - gt := gametable.New(dbEngine, udb, cfg.Uploads, cfg.UploadMaxMB) + gt := gametable.New(dbEngine, udb, cfg.Uploads, cfg.UploadMaxMB, cfg.RegistrationSecret) s := &http.Server{ Handler: gt, ReadTimeout: time.Second * 10,
A register/register.go

@@ -0,0 +1,91 @@

+package register + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "html/template" + "net/http" + + "hacklab.nilfm.cc/quartzgun/auth" + "hacklab.nilfm.cc/quartzgun/renderer" + "hacklab.nilfm.cc/quartzgun/router" +) + +var bytes = []byte{99, 207, 33, 57, 28, 01, 50, 76, 01} + +type SymmetricCrypto interface { + Encode(b []byte) string + Decode(s string) []byte + Encrypt(text string) (string, error) + Decrypt(text string) (string, error) +} + +type SymmetricCrypt struct { + Secret string +} + +func (self *SymmetricCrypt) Encode(b []byte) string { + return base64.StdEncoding.EncodeToString(b) +} + +func (self *SymmetricCrypt) Decode(s string) []byte { + data, err := base64.StdEncoding.DecodeString(s) + if err != nil { + panic(err) + } + return data +} + +func (self *SymmetricCrypt) Encrypt(text string) (string, error) { + block, err := aes.NewCipher([]byte(self.Secret)) + if err != nil { + return "", err + } + plainText := []byte(text) + cfb := cipher.NewCFBEncrypter(block, bytes) + cipherText := make([]byte, len(plainText)) + cfb.XORKeyStream(cipherText, plainText) + return self.Encode(cipherText), nil +} + +func (self *SymmetricCrypt) Decrypt(text string) (string, error) { + block, err := aes.NewCipher([]byte(self.Secret)) + if err != nil { + return "", err + } + cipherText := self.Decode(text) + cfb := cipher.NewCFBDecrypter(block, bytes) + plainText := make([]byte, len(cipherText)) + cfb.XORKeyStream(plainText, cipherText) + return string(plainText), nil +} + +func WithCrypto(next http.Handler, crypto SymmetricCrypto) http.Handler { + handlerFunc := func(w http.ResponseWriter, req *http.Request) { + *req = *req.WithContext(context.WithValue(req.Context(), "crypto", crypto)) + next.ServeHTTP(w, req) + } + + return http.HandlerFunc(handlerFunc) +} + +func WithUserStore(next http.Handler, udb auth.UserStore) http.Handler { + handlerFunc := func(w http.ResponseWriter, req *http.Request) { + *req = *req.WithContext(context.WithValue(req.Context(), "udb", udb)) + next.ServeHTTP(w, req) + } + + return http.HandlerFunc(handlerFunc) +} + +func CreateRegistrationInterface(udb auth.UserStore, secret string) http.Handler { + rtr := &router.Router{Fallback: *template.Must(template.ParseFiles("templates/error.html"))} + crypto := &SymmetricCrypt{Secret: secret} + + rtr.Get(`/(?P<cipher>.*)`, WithCrypto(renderer.Template("templates/register.html"), crypto)) + rtr.Post(`/(?P<cipher>.*)`, WithUserStore(WithCrypto(renderer.Template("templates/registered.html"), crypto), udb)) + + return http.HandlerFunc(rtr.ServeHTTP) +}
M static/admin.jsstatic/admin.js

@@ -180,9 +180,11 @@

function reinitializeSpritePreview() { const img = document.createElement("img"); img.src = tokenSpriteDropdown[tokenSpriteDropdown.selectedIndex].value; + const tokenNameParts = tokenSpriteDropdown[tokenSpriteDropdown.selectedIndex].text.split("."); tokenNameParts.pop(); tokenName.value = tokenNameParts.join("."); + img.onload = () => { const w = img.naturalWidth; const h = img.naturalHeight;

@@ -191,6 +193,7 @@ tokenWidth.value = w;

tokenHeight.value = h; scaleSpritePreview(); } + previewZone.innerHTML = ""; previewZone.appendChild(img); }

@@ -425,9 +428,17 @@ getTables();

adminWrapper.style.display="inline"; adminZone.style.display = "block"; closeErr(); + replaceAdminModal(); } else { setErr("Incorrect credentials"); } + } +} + +function replaceAdminModal() { + const adminModal = document.getElementById("admin_modal"); + if (adminModal) { + adminModal.innerHTML = "<a href='./'>Logout</a>"; } }
M static/dice.jsstatic/dice.js

@@ -30,5 +30,6 @@ player: name.value,

note: note.value, timestamp: new Date(), }}); + note.value = ""; } }
M static/index.htmlstatic/index.html

@@ -14,7 +14,7 @@ <div id="errWrapper" style='display:none'><button id="closeErr" onclick="closeErr()">x</button><div id="errDiv"></div></div>

<nav> <section id="user_section"> - <details class="ui_win"><summary>identity</summary> + <details class="ui_win" open><summary>identity</summary> <label for="name_entry">username</label> <input id="name_entry" onblur="saveName()"> </details><br/>
M static/map.jsstatic/map.js

@@ -7,7 +7,7 @@ function initializeMap(mapImgUrl) {

let init = false; if (!map) { init = true; - map = L.map('map', { minZoom: 0, maxZoom: 4, crs: L.CRS.Simple }); + map = L.map('map', { minZoom: 0, maxZoom: 4, crs: L.CRS.Simple, attributionControl: true, zoomControl: false }); map.on("zoomend", ()=>{resizeMarkers();scaleSpritePreview();}); } if (mapImg) {

@@ -19,8 +19,6 @@ map.setMaxBounds(worldBounds);

if (init) { map.setView([0,0], 2); } - /* -*/ } // this works but assumes the map is square (reasonable limitation I think)

@@ -113,7 +111,7 @@ autoPan: true

}); marker.on("moveend", ()=>{moveToken(token.id)}); - + return { t: token, m: marker,
M static/socket.jsstatic/socket.js

@@ -89,7 +89,7 @@ }

if (table.diceRolls) { logDice(table.diceRolls); } else if (table.diceRoll) { - logDice([table.diceRoll]); + logDice(table.diceRoll); } if (table.tokens) { updateTokens(table.tokens);

@@ -135,10 +135,12 @@ if (tabletop) {

tabletop.style.display = "none"; } table = null; - while (tokens.some(t=>t)) { - tokens[0].m.removeFrom(map); - tokens.shift(); - } + while (tokens.some(t=>t)) { + tokens[0].m.removeFrom(map); + tokens.shift(); + } + mapImg.removeFrom(map); + mapImg = null; } }); conn.addEventListener("open", e => {
M static/style.cssstatic/style.css

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

:root { - --bg_color: rgba(0,0,0,0.7); + --bg_color: #000000cc; --fg_color: #ccc; --main_color: #1f9b92; --sub_color: #002b36;
M static/util.jsstatic/util.js

@@ -1,6 +1,13 @@

const errDiv = document.getElementById("errDiv"); const errWrapper = document.getElementById("errWrapper"); +const defaultTheme = [ "#000000cc", "#ccccccff", "#1f9b92ff", "#002b36ff" ]; + +const saveData = { + username: "", + theme: defaultTheme, +} + function setErr(x) { if (errDiv) { errDiv.innerHTML = x;

@@ -16,23 +23,22 @@ errWrapper.style.display = "none";

} } -function saveName() { - console.log("saving username"); +function loadStorage() { + saveData.username = localStorage.getItem("username"); + saveData.theme = JSON.parse(localStorage.getItem("theme")); + const username = document.getElementById("name_entry"); if (username) { - document.cookie = "username=" + username.value; + username.value = saveData.username; } } -function loadName() { +function saveName() { + console.log("saving username"); const username = document.getElementById("name_entry"); if (username) { - const cookies = document.cookie.split(";") - cookies.forEach(c=>{ - if (c.trim().startsWith("username=")) { - username.value = c.trim().split("=")[1]; - } - }); + saveData.username = username.value; + localStorage.setItem("username", saveData.username); } }

@@ -47,4 +53,4 @@ });

} setupDiceAutoScroll(); -loadName();+loadStorage();