all repos — felt @ 43d85c2abcdbea311b821eb434ec286519206f86

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

enforce subprotocol/tableKey to alphanumeric and underscore, refine UI
Iris Lightshard nilix@nilfm.cc
PGP Signature
-----BEGIN PGP SIGNATURE-----

iQIzBAABCAAdFiEEkFh6dA+k/6CXFXU4O3+8IhROY5gFAmSmQroACgkQO3+8IhRO
Y5gJyw//ZlonpHqLmwuyZv6sMVKqpqBXKU2xjDU6vKZX0GpYKd6Y9Vt1bbI2qKaK
APrOGDxiEDy+CSqe1n2f0C26UwmJwRf1dfrHGSR1F5cIdxTE82hUCLIBMAmtvgZI
PIZLIYyc8rze4w3FqOQ0zfMrCzX1akjDW052ol82Rzkxc/mSnQLsNZAbMzRnVjar
89ATTanbu9033l2+RMlirERsxDRzx9Z8LwnBYsBs2dHyZY7hKiO5UzRUEdA+2C1H
+0hQxESnTwzwt/F0FP5ectBUSmh0n8Qwy6eOq/Ohnk0wD2TYra77OWKRaOlJXJTM
ajAj1pbBIhhKY72YMPgjNH5vhTWUTpT1uHo4bJ7R7gogAWkKTASl2LBmG3koV6z6
eqsXNjvXwRt0uZOKueTfclDSNOqd4jWQhBeZACiTiDaPrDfLs+d7n3UH0L6y3Q7R
yrai/Zm55OsW4Aa/h1fC+6+YzSnXIKcqQvt2FNJaHq7SK29jVE5UdSu9OvyWhg/o
Lf0unBr6VSuRneFthi1HQuNX3IxU6GxycXkVDZ2BLwL/HOae+YPtXrDZAa2rZoVR
HaFM8X18+s2LWUDQwUsbCwP5fVgw1OborQL8sT4ylgD8PqoLgtXNXstFZHcR07Fs
IWa6UioizS36F1GNSY+s94qLIJX+OBZTx14hKZc3GzXi14h+1Tw=
=Rosd
-----END PGP SIGNATURE-----
commit

43d85c2abcdbea311b821eb434ec286519206f86

parent

0d3b4fb5aeecd86b0356d618d3b76cf8d6acec38

M admin/admin.goadmin/admin.go

@@ -16,6 +16,7 @@ "io/ioutil"

"net/http" "os" "path/filepath" + "regexp" ) func apiGetTableList(next http.Handler, udb auth.UserStore) http.Handler {

@@ -76,6 +77,13 @@ tableKey := models.TableKey{}

err := json.NewDecoder(req.Body).Decode(&tableKey) if err != nil { w.WriteHeader(400) + next.ServeHTTP(w, req) + return + } + + r := regexp.MustCompile("^[a-zA-Z0-9_]+$") + if !r.MatchString(tableKey.Name) || !r.MatchString(tableKey.Passcode) { + w.WriteHeader(422) next.ServeHTTP(w, req) return }
M static/admin.jsstatic/admin.js

@@ -127,7 +127,9 @@ body: JSON.stringify(tableKey)

}); if (res.ok) { conn.close(1000); + initializeMap(""); getTables(); + } else { setErr(await res.json()); }
M static/index.htmlstatic/index.html

@@ -24,11 +24,9 @@ <label>table.passcode<br><input id="input_table_pass"></label><br>

<button type="submit" id="table_join" onclick="dial();">Join</button> </form> </details><br/> - - <div id="tabletop" style="display:none;"> - <details class="ui_win"><summary>dice</summary> + <details id="dice_win" class="ui_win"><summary>dice</summary> <select id="num_dice"> <option>1</option> <option>2</option>
M static/map.jsstatic/map.js

@@ -14,6 +14,10 @@ mapImg = L.imageOverlay(mapImgUrl, [[-180, 180],[180, -180]]);

mapImg.addTo(map); map.setMaxBounds([[-180,180],[180,-180]]); map.setView([0,0], 2); + while (tokens.some(t=>t)) { + tokens[0].m.removeFrom(map); + tokens.shift(); + } } // this works but assumes the map is square (reasonable limitation I think)
M static/socket.jsstatic/socket.js

@@ -14,10 +14,19 @@ modal.style.display = show ? "block" : "none";

} } +function fmtLeading(n) { + return n < 10 ? "0" + n : String(n); +} + function formatDice(r) { const date = new Date(r.timestamp) const p = document.createElement("p"); - p.innerHTML = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()} ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()} ${r.player} rolled ${r.roll.length}d${r.faces} ${(r.note ? "(" + r.note + ")" : "")}<br>[${r.roll}] (total ${r.roll.reduce((a,c)=>a+c,0)})`; + const month = date.getMonth() + 1; + const day = date.getDate(); + const hours = date.getHours(); + const minutes = date.getMinutes(); + const seconds = date.getSeconds(); + p.innerHTML = `${date.getFullYear()}-${fmtLeading(month)}-${fmtLeading(day)} ${fmtLeading(hours)}:${fmtLeading(minutes)}:${fmtLeading(seconds)} ${r.player} rolled ${r.roll.length}d${r.faces} ${(r.note ? "(" + r.note + ")" : "")}<br>[${r.roll}] (total ${r.roll.reduce((a,c)=>a+c,0)})`; return p; }

@@ -67,58 +76,72 @@ function setMapImg(url) {

initializeMap(url); } +function isTableKeyValid(name, passcode) { + const r = /^[a-zA-Z0-9_]+$/; + return r.test(name) && r.test(passcode); +} + function dial() { // get tableKey from UI const tblNameInput = document.getElementById("input_table_name"); const tblPassInput = document.getElementById("input_table_pass"); if (tblNameInput && tblPassInput && tblNameInput.value && tblPassInput.value) { - tableKey.name = tblNameInput.value; - tableKey.passcode = tblPassInput.value; - - if (conn) { - conn.close(1000); - } - conn = new WebSocket(`ws://${location.host}/subscribe`, `${tableKey.name}.${tableKey.passcode}`); - conn.addEventListener("close", e => { - if (e.code > 1001) { - // TODO: add message to let user know they are reconnecting - setTimeout(dial, 1000) - } else { + if (isTableKeyValid(tblNameInput.value, tblPassInput.value)) { + tableKey.name = tblNameInput.value; + tableKey.passcode = tblPassInput.value; + + if (conn) { + conn.close(1000); + } + conn = new WebSocket(`ws://${location.host}/subscribe`, `${tableKey.name}.${tableKey.passcode}`); + conn.addEventListener("close", e => { + if (e.code == 1006) { + setErr("Table not found - check the name and passcode are correct"); + } else if (e.code > 1001) { + // TODO: add message to let user know they are reconnecting + setErr("Websocket error: trying to reconnect"); + setTimeout(dial, 1000) + } else { + tabletop = document.getElementById("tabletop"); + if (tabletop) { + tabletop.style.display = "none"; + } + table = null; + } + }); + conn.addEventListener("open", e => { + // TODO: add message to let user know they are at the table + console.info("socket connected"); tabletop = document.getElementById("tabletop"); if (tabletop) { - tabletop.style.display = "none"; + tabletop.style.display = "block"; } - table = null; - } - }); - conn.addEventListener("open", e => { - // TODO: add message to let user know they are at the table - console.info("socket connected"); - tabletop = document.getElementById("tabletop"); - if (tabletop) { - tabletop.style.display = "block"; - } - }); - conn.addEventListener("error", e => { - setErr(`${e.name}: ${e.message}`); - conn.close(3000); - }) - - conn.addEventListener("message", e => { - const data = JSON.parse(e.data); - if (table == null) { - // first fetch comes from mongo, so the rolls array in each diceRoll is a byte array and needs to be decoded - data.diceRolls.forEach(r=>{ - r.roll = Uint8Array.from(atob(r.roll), c => c.charCodeAt(0)) - }) - table = data; - makeUpToDate(table); - } else { - makeUpToDate(data); - } + }); + conn.addEventListener("error", e => { + setErr(`${e.name}: ${e.message}`); + conn.close(3000); + }) - console.log(data); - }); + conn.addEventListener("message", e => { + const data = JSON.parse(e.data); + if (table == null) { + // first fetch comes from mongo, so the rolls array in each diceRoll is a byte array and needs to be decoded + data.diceRolls.forEach(r=>{ + r.roll = Uint8Array.from(atob(r.roll), c => c.charCodeAt(0)) + }) + table = data; + makeUpToDate(table); + } else { + makeUpToDate(data); + } + + console.log(data); + }); + } else { + setErr("Table name and passcode can only be alphanumeric and underscores"); + } + }else { + setErr("Table name and passcode required"); } }

@@ -131,4 +154,4 @@ });

if (!res.ok) { setErr("Failed to publish message"); } -}+}
M static/style.cssstatic/style.css

@@ -1,3 +1,10 @@

+:root { + --bg_color: rgba(0,0,0,0.7); + --fg_color: #ccc; + --main_color: #1f9b92; + --sub_color: #002b36; +} + * { box-sizing: border-box; padding: 0;

@@ -6,12 +13,12 @@ appearance: none;

outline: none; } -* { scrollbar-color:#1f9b92 #000;} +* { scrollbar-color:var(--main_color) var(--sub_color); } *::-webkit-scrollbar { width:6px;height:6px; } -*::-webkit-scrollbar-track { background:#000; } -*::-webkit-scrollbar-thumb { background-color:#1f9b92;border-radius:0;border:none; } -*::-webkit-scrollbar-corner { background:#000; } -*::selection { background-color:#1f9b92;color:#000;text-decoration:none;text-shadow:none; } +*::-webkit-scrollbar-track { background: var(--sub_color);} +*::-webkit-scrollbar-thumb { background:var(--main_color);border-radius:0;border:none; } +*::-webkit-scrollbar-corner { background:var(--sub_color); } +*::selection { background-color:var(--main_color);color:var(--bg_color);text-decoration:none;text-shadow:none; } body { background: url('./bg.png');

@@ -22,15 +29,18 @@ label {

font-size: 80%; } -input, select { - background: #002b36; - color: #93a1a1; - border: solid 1px gray; +input, select, textarea { + background: var(--sub_color); + color: var(--fg_color); + border: solid 1px transparent; +} + +input , select { margin-right: 1ch; } -input:active, input:focus, select:active, select:focus { - border: solid 1px cyan; +input:active, input:focus, select:active, select:focus, textarea:focus { + border: solid 1px var(--main_color); }

@@ -41,15 +51,15 @@ }

button { padding: 0.5ch; - background: #000; - color: #fff; - border: solid 2px lightseagreen; + background: transparent; + color: var(--fg_color); + border: solid 2px var(--main_color); margin-right: 1ch; } button:hover { - color: #000; - background: lightseagreen; + color: var(--bg_color); + background: var(--main_color); } #errWrapper {

@@ -72,8 +82,8 @@ display: inline;

} #dice_log { - background: #002b36; - color: #93a1a1; + background: var(--sub_color); + color: var(--fg_color); height: 10em; max-height: 10em; display: block;

@@ -85,7 +95,7 @@ padding: 0.5ch;

} #dice_log p:not(:last-child) { - border-bottom: solid 1px gray; + border-bottom: solid 1px var(--fg_color); } #aux {

@@ -94,14 +104,12 @@ }

pre { font-size: 125%; - background: #002b36; - color: #93a1a1; + background: var(--sub_color); + color: var(--fg_color); } #auxMsgZone { width: 100%; - color: #93a1a1; - background: #002b36; padding:0.2em; }

@@ -112,18 +120,18 @@ .ui_win {

text-align: left; position: relative; margin: 2em; - background: rgba(0,0,0,0.7); - color: #eee; + background: var(--bg_color); + color: var(--fg_color); display: inline; height: min-content; z-index:1; padding: 0.25em; - border: 2px solid dimgray;; - max-width: 80ch; + border: 2px solid transparent; + max-width: min(60ch, 80vw); } -.ui_win:hover, .ui_win:active { - border: 2px solid #1f9b92; +.ui_win:focus-within { + border: 2px solid var(--main_color); } .ui_win * {

@@ -135,7 +143,7 @@ color: #1f9b92;

} .ui_win a:hover, ui_win a:active { - color: #ff; + color: var(--fg_color); } #admin_section {
M static/util.jsstatic/util.js

@@ -38,4 +38,15 @@ });

} } +function setupDiceAutoScroll() { + const diceWin = document.getElementById("dice_win"); + diceWin.addEventListener("toggle", e => { + if (diceWin.open) { + const diceLog = document.getElementById("dice_log"); + diceLog.children[diceLog.children.length - 1].scrollIntoView(); + } + }); +} + +setupDiceAutoScroll(); loadName();