diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41b0f29 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.log +node_modules/ +dist/ +data/ +**/config.toml \ No newline at end of file diff --git a/server/config.template.toml b/server/config.template.toml new file mode 100644 index 0000000..69e2fb2 --- /dev/null +++ b/server/config.template.toml @@ -0,0 +1,26 @@ +[server] +host = "" +port = 6969 + +[server.auth] +enabled = false +# username = "" +# password = "" + + +[log] +level = "info" + +[twitch] +client_id = "" +client_secret = "" + +[twitch.auth] +redirect_uri = "/twitch/login/callback" +base_url = "https://id.twitch.tv/oauth2" +scopes = [ + "channel:read:polls", + "channel:manage:polls", + "channel:read:predictions", + "channel:manage:predictions" +] \ No newline at end of file diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..b7eee74 --- /dev/null +++ b/server/package.json @@ -0,0 +1,33 @@ +{ + "name": "server", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Oliver Akins", + "license": "UNLICENSED", + "devDependencies": { + "@types/glob": "^7.2.0", + "@types/hapi__basic": "^5.1.2", + "@types/hapi__hapi": "^20.0.10", + "@types/hapi__inert": "^5.2.3", + "@types/node": "^17.0.17" + }, + "dependencies": { + "@hapi/basic": "^6.0.0", + "@hapi/boom": "^9.1.4", + "@hapi/hapi": "^20.2.1", + "@hapi/inert": "^6.0.5", + "axios": "^0.24.0", + "glob": "^7.2.0", + "module-alias": "^2.2.2", + "path": "^0.12.7", + "toml": "^3.0.0", + "tslog": "^3.3.2" + }, + "_moduleAliases": { + "~": "./dist" + } +} diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml new file mode 100644 index 0000000..dc60195 --- /dev/null +++ b/server/pnpm-lock.yaml @@ -0,0 +1,506 @@ +lockfileVersion: 5.3 + +specifiers: + '@hapi/basic': ^6.0.0 + '@hapi/boom': ^9.1.4 + '@hapi/hapi': ^20.2.1 + '@hapi/inert': ^6.0.5 + '@types/glob': ^7.2.0 + '@types/hapi__basic': ^5.1.2 + '@types/hapi__hapi': ^20.0.10 + '@types/hapi__inert': ^5.2.3 + '@types/node': ^17.0.17 + axios: ^0.24.0 + glob: ^7.2.0 + module-alias: ^2.2.2 + path: ^0.12.7 + toml: ^3.0.0 + tslog: ^3.3.2 + +dependencies: + '@hapi/basic': 6.0.0 + '@hapi/boom': 9.1.4 + '@hapi/hapi': 20.2.1 + '@hapi/inert': 6.0.5 + axios: 0.24.0 + glob: 7.2.0 + module-alias: 2.2.2 + path: 0.12.7 + toml: 3.0.0 + tslog: 3.3.2 + +devDependencies: + '@types/glob': 7.2.0 + '@types/hapi__basic': 5.1.2 + '@types/hapi__hapi': 20.0.10 + '@types/hapi__inert': 5.2.3 + '@types/node': 17.0.17 + +packages: + + /@hapi/accept/5.0.2: + resolution: {integrity: sha512-CmzBx/bXUR8451fnZRuZAJRlzgm0Jgu5dltTX/bszmR2lheb9BpyN47Q1RbaGTsvFzn0PXAEs+lXDKfshccYZw==} + dependencies: + '@hapi/boom': 9.1.4 + '@hapi/hoek': 9.2.1 + dev: false + + /@hapi/ammo/5.0.1: + resolution: {integrity: sha512-FbCNwcTbnQP4VYYhLNGZmA76xb2aHg9AMPiy18NZyWMG310P5KdFGyA9v2rm5ujrIny77dEEIkMOwl0Xv+fSSA==} + dependencies: + '@hapi/hoek': 9.2.1 + dev: false + + /@hapi/b64/5.0.0: + resolution: {integrity: sha512-ngu0tSEmrezoiIaNGG6rRvKOUkUuDdf4XTPnONHGYfSGRmDqPZX5oJL6HAdKTo1UQHECbdB4OzhWrfgVppjHUw==} + dependencies: + '@hapi/hoek': 9.2.1 + + /@hapi/basic/6.0.0: + resolution: {integrity: sha512-nWWSXNCq3WptnP3To2c8kfQiRFDUnd9FQOcMS0B85y1x/m12c0hhp+VdmK60BMe44k6WIog1n6g8f9gZOagqBg==} + dependencies: + '@hapi/boom': 9.1.4 + '@hapi/hoek': 9.2.1 + dev: false + + /@hapi/boom/9.1.4: + resolution: {integrity: sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==} + dependencies: + '@hapi/hoek': 9.2.1 + + /@hapi/bounce/2.0.0: + resolution: {integrity: sha512-JesW92uyzOOyuzJKjoLHM1ThiOvHPOLDHw01YV8yh5nCso7sDwJho1h0Ad2N+E62bZyz46TG3xhAi/78Gsct6A==} + dependencies: + '@hapi/boom': 9.1.4 + '@hapi/hoek': 9.2.1 + dev: false + + /@hapi/bourne/2.0.0: + resolution: {integrity: sha512-WEezM1FWztfbzqIUbsDzFRVMxSoLy3HugVcux6KDDtTqzPsLE8NDRHfXvev66aH1i2oOKKar3/XDjbvh/OUBdg==} + + /@hapi/call/8.0.1: + resolution: {integrity: sha512-bOff6GTdOnoe5b8oXRV3lwkQSb/LAWylvDMae6RgEWWntd0SHtkYbQukDHKlfaYtVnSAgIavJ0kqszF/AIBb6g==} + dependencies: + '@hapi/boom': 9.1.4 + '@hapi/hoek': 9.2.1 + dev: false + + /@hapi/catbox-memory/5.0.1: + resolution: {integrity: sha512-QWw9nOYJq5PlvChLWV8i6hQHJYfvdqiXdvTupJFh0eqLZ64Xir7mKNi96d5/ZMUAqXPursfNDIDxjFgoEDUqeQ==} + dependencies: + '@hapi/boom': 9.1.4 + '@hapi/hoek': 9.2.1 + dev: false + + /@hapi/catbox/11.1.1: + resolution: {integrity: sha512-u/8HvB7dD/6X8hsZIpskSDo4yMKpHxFd7NluoylhGrL6cUfYxdQPnvUp9YU2C6F9hsyBVLGulBd9vBN1ebfXOQ==} + dependencies: + '@hapi/boom': 9.1.4 + '@hapi/hoek': 9.2.1 + '@hapi/podium': 4.1.3 + '@hapi/validate': 1.1.3 + dev: false + + /@hapi/content/5.0.2: + resolution: {integrity: sha512-mre4dl1ygd4ZyOH3tiYBrOUBzV7Pu/EOs8VLGf58vtOEECWed8Uuw6B4iR9AN/8uQt42tB04qpVaMyoMQh0oMw==} + dependencies: + '@hapi/boom': 9.1.4 + dev: false + + /@hapi/cryptiles/5.1.0: + resolution: {integrity: sha512-fo9+d1Ba5/FIoMySfMqPBR/7Pa29J2RsiPrl7bkwo5W5o+AN1dAYQRi4SPrPwwVxVGKjgLOEWrsvt1BonJSfLA==} + engines: {node: '>=12.0.0'} + dependencies: + '@hapi/boom': 9.1.4 + + /@hapi/file/2.0.0: + resolution: {integrity: sha512-WSrlgpvEqgPWkI18kkGELEZfXr0bYLtr16iIN4Krh9sRnzBZN6nnWxHFxtsnP684wueEySBbXPDg/WfA9xJdBQ==} + dev: false + + /@hapi/hapi/20.2.1: + resolution: {integrity: sha512-OXAU+yWLwkMfPFic+KITo+XPp6Oxpgc9WUH+pxXWcTIuvWbgco5TC/jS8UDvz+NFF5IzRgF2CL6UV/KLdQYUSQ==} + engines: {node: '>=12.0.0'} + dependencies: + '@hapi/accept': 5.0.2 + '@hapi/ammo': 5.0.1 + '@hapi/boom': 9.1.4 + '@hapi/bounce': 2.0.0 + '@hapi/call': 8.0.1 + '@hapi/catbox': 11.1.1 + '@hapi/catbox-memory': 5.0.1 + '@hapi/heavy': 7.0.1 + '@hapi/hoek': 9.2.1 + '@hapi/mimos': 6.0.0 + '@hapi/podium': 4.1.3 + '@hapi/shot': 5.0.5 + '@hapi/somever': 3.0.1 + '@hapi/statehood': 7.0.3 + '@hapi/subtext': 7.0.3 + '@hapi/teamwork': 5.1.0 + '@hapi/topo': 5.1.0 + '@hapi/validate': 1.1.3 + dev: false + + /@hapi/heavy/7.0.1: + resolution: {integrity: sha512-vJ/vzRQ13MtRzz6Qd4zRHWS3FaUc/5uivV2TIuExGTM9Qk+7Zzqj0e2G7EpE6KztO9SalTbiIkTh7qFKj/33cA==} + dependencies: + '@hapi/boom': 9.1.4 + '@hapi/hoek': 9.2.1 + '@hapi/validate': 1.1.3 + dev: false + + /@hapi/hoek/9.2.1: + resolution: {integrity: sha512-gfta+H8aziZsm8pZa0vj04KO6biEiisppNgA1kbJvFrrWu9Vm7eaUEy76DIxsuTaWvti5fkJVhllWc6ZTE+Mdw==} + + /@hapi/inert/6.0.5: + resolution: {integrity: sha512-eVAdUVhJLmmXLM/Zt7u5H5Vzazs9GKe4zfPK2b97ePHEfs3g/AQkxHfYQjJqMy11hvyB7a21Z6rBEA0R//dtXw==} + dependencies: + '@hapi/ammo': 5.0.1 + '@hapi/boom': 9.1.4 + '@hapi/bounce': 2.0.0 + '@hapi/hoek': 9.2.1 + '@hapi/validate': 1.1.3 + lru-cache: 6.0.0 + dev: false + + /@hapi/iron/6.0.0: + resolution: {integrity: sha512-zvGvWDufiTGpTJPG1Y/McN8UqWBu0k/xs/7l++HVU535NLHXsHhy54cfEMdW7EjwKfbBfM9Xy25FmTiobb7Hvw==} + dependencies: + '@hapi/b64': 5.0.0 + '@hapi/boom': 9.1.4 + '@hapi/bourne': 2.0.0 + '@hapi/cryptiles': 5.1.0 + '@hapi/hoek': 9.2.1 + + /@hapi/mimos/6.0.0: + resolution: {integrity: sha512-Op/67tr1I+JafN3R3XN5DucVSxKRT/Tc+tUszDwENoNpolxeXkhrJ2Czt6B6AAqrespHoivhgZBWYSuANN9QXg==} + dependencies: + '@hapi/hoek': 9.2.1 + mime-db: 1.51.0 + dev: false + + /@hapi/nigel/4.0.2: + resolution: {integrity: sha512-ht2KoEsDW22BxQOEkLEJaqfpoKPXxi7tvabXy7B/77eFtOyG5ZEstfZwxHQcqAiZhp58Ae5vkhEqI03kawkYNw==} + engines: {node: '>=12.0.0'} + dependencies: + '@hapi/hoek': 9.2.1 + '@hapi/vise': 4.0.0 + dev: false + + /@hapi/pez/5.0.3: + resolution: {integrity: sha512-mpikYRJjtrbJgdDHG/H9ySqYqwJ+QU/D7FXsYciS9P7NYBXE2ayKDAy3H0ou6CohOCaxPuTV4SZ0D936+VomHA==} + dependencies: + '@hapi/b64': 5.0.0 + '@hapi/boom': 9.1.4 + '@hapi/content': 5.0.2 + '@hapi/hoek': 9.2.1 + '@hapi/nigel': 4.0.2 + dev: false + + /@hapi/podium/4.1.3: + resolution: {integrity: sha512-ljsKGQzLkFqnQxE7qeanvgGj4dejnciErYd30dbrYzUOF/FyS/DOF97qcrT3bhoVwCYmxa6PEMhxfCPlnUcD2g==} + dependencies: + '@hapi/hoek': 9.2.1 + '@hapi/teamwork': 5.1.0 + '@hapi/validate': 1.1.3 + + /@hapi/shot/5.0.5: + resolution: {integrity: sha512-x5AMSZ5+j+Paa8KdfCoKh+klB78otxF+vcJR/IoN91Vo2e5ulXIW6HUsFTCU+4W6P/Etaip9nmdAx2zWDimB2A==} + dependencies: + '@hapi/hoek': 9.2.1 + '@hapi/validate': 1.1.3 + dev: false + + /@hapi/somever/3.0.1: + resolution: {integrity: sha512-4ZTSN3YAHtgpY/M4GOtHUXgi6uZtG9nEZfNI6QrArhK0XN/RDVgijlb9kOmXwCR5VclDSkBul9FBvhSuKXx9+w==} + dependencies: + '@hapi/bounce': 2.0.0 + '@hapi/hoek': 9.2.1 + dev: false + + /@hapi/statehood/7.0.3: + resolution: {integrity: sha512-pYB+pyCHkf2Amh67QAXz7e/DN9jcMplIL7Z6N8h0K+ZTy0b404JKPEYkbWHSnDtxLjJB/OtgElxocr2fMH4G7w==} + dependencies: + '@hapi/boom': 9.1.4 + '@hapi/bounce': 2.0.0 + '@hapi/bourne': 2.0.0 + '@hapi/cryptiles': 5.1.0 + '@hapi/hoek': 9.2.1 + '@hapi/iron': 6.0.0 + '@hapi/validate': 1.1.3 + dev: false + + /@hapi/subtext/7.0.3: + resolution: {integrity: sha512-CekDizZkDGERJ01C0+TzHlKtqdXZxzSWTOaH6THBrbOHnsr3GY+yiMZC+AfNCypfE17RaIakGIAbpL2Tk1z2+A==} + dependencies: + '@hapi/boom': 9.1.4 + '@hapi/bourne': 2.0.0 + '@hapi/content': 5.0.2 + '@hapi/file': 2.0.0 + '@hapi/hoek': 9.2.1 + '@hapi/pez': 5.0.3 + '@hapi/wreck': 17.1.0 + dev: false + + /@hapi/teamwork/5.1.0: + resolution: {integrity: sha512-llqoQTrAJDTXxG3c4Kz/uzhBS1TsmSBa/XG5SPcVXgmffHE1nFtyLIK0hNJHCB3EuBKT84adzd1hZNY9GJLWtg==} + engines: {node: '>=12.0.0'} + + /@hapi/topo/5.1.0: + resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} + dependencies: + '@hapi/hoek': 9.2.1 + + /@hapi/validate/1.1.3: + resolution: {integrity: sha512-/XMR0N0wjw0Twzq2pQOzPBZlDzkekGcoCtzO314BpIEsbXdYGthQUbxgkGDf4nhk1+IPDAsXqWjMohRQYO06UA==} + dependencies: + '@hapi/hoek': 9.2.1 + '@hapi/topo': 5.1.0 + + /@hapi/vise/4.0.0: + resolution: {integrity: sha512-eYyLkuUiFZTer59h+SGy7hUm+qE9p+UemePTHLlIWppEd+wExn3Df5jO04bFQTm7nleF5V8CtuYQYb+VFpZ6Sg==} + dependencies: + '@hapi/hoek': 9.2.1 + dev: false + + /@hapi/wreck/17.1.0: + resolution: {integrity: sha512-nx6sFyfqOpJ+EFrHX+XWwJAxs3ju4iHdbB/bwR8yTNZOiYmuhA8eCe7lYPtYmb4j7vyK/SlbaQsmTtUrMvPEBw==} + dependencies: + '@hapi/boom': 9.1.4 + '@hapi/bourne': 2.0.0 + '@hapi/hoek': 9.2.1 + dev: false + + /@sideway/address/4.1.3: + resolution: {integrity: sha512-8ncEUtmnTsMmL7z1YPB47kPUq7LpKWJNFPsRzHiIajGC5uXlWGn+AmkYPcHNl8S4tcEGx+cnORnNYaw2wvL+LQ==} + dependencies: + '@hapi/hoek': 9.2.1 + dev: true + + /@sideway/formula/3.0.0: + resolution: {integrity: sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg==} + dev: true + + /@sideway/pinpoint/2.0.0: + resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} + dev: true + + /@types/glob/7.2.0: + resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} + dependencies: + '@types/minimatch': 3.0.5 + '@types/node': 17.0.17 + dev: true + + /@types/hapi__basic/5.1.2: + resolution: {integrity: sha512-sqoQ34nwmRlNgQ5fdHHnghyVzs23aC2d30l9G1sKvdrh4PDV22bnQ+8fBCjoItT+0gGSuXIoG4qdoVZGBjH5EQ==} + dependencies: + '@types/hapi__hapi': 20.0.10 + joi: 17.6.0 + dev: true + + /@types/hapi__catbox/10.2.4: + resolution: {integrity: sha512-A6ivRrXD5glmnJna1UAGw87QNZRp/vdFO9U4GS+WhOMWzHnw+oTGkMvg0g6y1930CbeheGOCm7A1qHsqH7AXqg==} + dev: true + + /@types/hapi__hapi/20.0.10: + resolution: {integrity: sha512-Nt/SY/20/JAlHhbgH616j0g18vsANR9OWoyMdQcytlW6o7TBN+wRgf0MB8AgzjYpuzQam5oTiqyED9WwHmQKYQ==} + dependencies: + '@hapi/boom': 9.1.4 + '@hapi/iron': 6.0.0 + '@hapi/podium': 4.1.3 + '@types/hapi__catbox': 10.2.4 + '@types/hapi__mimos': 4.1.4 + '@types/hapi__shot': 4.1.2 + '@types/node': 17.0.17 + joi: 17.6.0 + dev: true + + /@types/hapi__inert/5.2.3: + resolution: {integrity: sha512-I1mWQrEc7oMqGtofT0rwBgRBCBurz0wNzbq8QZsHWR+aXM0bk1j9GA6zwyGIeO53PNl2C1c2kpXlc084xCV+Tg==} + dependencies: + '@types/hapi__hapi': 20.0.10 + dev: true + + /@types/hapi__mimos/4.1.4: + resolution: {integrity: sha512-i9hvJpFYTT/qzB5xKWvDYaSXrIiNqi4ephi+5Lo6+DoQdwqPXQgmVVOZR+s3MBiHoFqsCZCX9TmVWG3HczmTEQ==} + dependencies: + '@types/mime-db': 1.43.1 + dev: true + + /@types/hapi__shot/4.1.2: + resolution: {integrity: sha512-8wWgLVP1TeGqgzZtCdt+F+k15DWQvLG1Yv6ZzPfb3D5WIo5/S+GGKtJBVo2uNEcqabP5Ifc71QnJTDnTmw1axA==} + dependencies: + '@types/node': 17.0.17 + dev: true + + /@types/mime-db/1.43.1: + resolution: {integrity: sha512-kGZJY+R+WnR5Rk+RPHUMERtb2qBRViIHCBdtUrY+NmwuGb8pQdfTqQiCKPrxpdoycl8KWm2DLdkpoSdt479XoQ==} + dev: true + + /@types/minimatch/3.0.5: + resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==} + dev: true + + /@types/node/17.0.17: + resolution: {integrity: sha512-e8PUNQy1HgJGV3iU/Bp2+D/DXh3PYeyli8LgIwsQcs1Ar1LoaWHSIT6Rw+H2rNJmiq6SNWiDytfx8+gYj7wDHw==} + dev: true + + /axios/0.24.0: + resolution: {integrity: sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==} + dependencies: + follow-redirects: 1.14.8 + transitivePeerDependencies: + - debug + dev: false + + /balanced-match/1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + dev: false + + /brace-expansion/1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + dev: false + + /buffer-from/1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + dev: false + + /concat-map/0.0.1: + resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} + dev: false + + /follow-redirects/1.14.8: + resolution: {integrity: sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + dev: false + + /fs.realpath/1.0.0: + resolution: {integrity: sha1-FQStJSMVjKpA20onh8sBQRmU6k8=} + dev: false + + /glob/7.2.0: + resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.0.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + dev: false + + /inflight/1.0.6: + resolution: {integrity: sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=} + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + dev: false + + /inherits/2.0.3: + resolution: {integrity: sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=} + dev: false + + /inherits/2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + dev: false + + /joi/17.6.0: + resolution: {integrity: sha512-OX5dG6DTbcr/kbMFj0KGYxuew69HPcAE3K/sZpEV2nP6e/j/C0HV+HNiBPCASxdx5T7DMoa0s8UeHWMnb6n2zw==} + dependencies: + '@hapi/hoek': 9.2.1 + '@hapi/topo': 5.1.0 + '@sideway/address': 4.1.3 + '@sideway/formula': 3.0.0 + '@sideway/pinpoint': 2.0.0 + dev: true + + /lru-cache/6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + dependencies: + yallist: 4.0.0 + dev: false + + /mime-db/1.51.0: + resolution: {integrity: sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g==} + engines: {node: '>= 0.6'} + dev: false + + /minimatch/3.0.5: + resolution: {integrity: sha512-tUpxzX0VAzJHjLu0xUfFv1gwVp9ba3IOuRAVH2EGuRW8a5emA2FlACLqiT/lDVtS1W+TGNwqz3sWaNyLgDJWuw==} + dependencies: + brace-expansion: 1.1.11 + dev: false + + /module-alias/2.2.2: + resolution: {integrity: sha512-A/78XjoX2EmNvppVWEhM2oGk3x4lLxnkEA4jTbaK97QKSDjkIoOsKQlfylt/d3kKKi596Qy3NP5XrXJ6fZIC9Q==} + dev: false + + /once/1.4.0: + resolution: {integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=} + dependencies: + wrappy: 1.0.2 + dev: false + + /path-is-absolute/1.0.1: + resolution: {integrity: sha1-F0uSaHNVNP+8es5r9TpanhtcX18=} + engines: {node: '>=0.10.0'} + dev: false + + /path/0.12.7: + resolution: {integrity: sha1-1NwqUGxM4hl+tIHr/NWzbAFAsQ8=} + dependencies: + process: 0.11.10 + util: 0.10.4 + dev: false + + /process/0.11.10: + resolution: {integrity: sha1-czIwDoQBYb2j5podHZGn1LwW8YI=} + engines: {node: '>= 0.6.0'} + dev: false + + /source-map-support/0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + dev: false + + /source-map/0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + dev: false + + /toml/3.0.0: + resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + dev: false + + /tslog/3.3.2: + resolution: {integrity: sha512-K+XduMfa9+yiHE/vxbUD/dL7RAbw9yIfi9tMjQj3uQ8evkPRKkmw0mQgEkzmueyg23hJHGaOQmDnCEZoKEws+w==} + engines: {node: '>=10'} + dependencies: + source-map-support: 0.5.21 + dev: false + + /util/0.10.4: + resolution: {integrity: sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==} + dependencies: + inherits: 2.0.3 + dev: false + + /wrappy/1.0.2: + resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=} + dev: false + + /yallist/4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + dev: false diff --git a/server/src/endpoints/auth/twitch/callback.ts b/server/src/endpoints/auth/twitch/callback.ts new file mode 100644 index 0000000..c2259cb --- /dev/null +++ b/server/src/endpoints/auth/twitch/callback.ts @@ -0,0 +1,48 @@ +import { Request, ResponseToolkit } from "@hapi/hapi"; +import { TwitchAuth } from "~/utils/TwitchAuth"; +import { config, log, twitchAuths } from "~/main"; +import axios from "axios"; + +export default { + method: `GET`, path: `/twitch/login/callback`, + options: { auth: false }, + async handler(request: Request, _: ResponseToolkit): Promise { + log.silly(`An authentication request is being processed`); + try { + + let code: string = request.query.code; + + let qs = new URLSearchParams(); + qs.set("client_id", config.twitch.client_id); + qs.set("client_secret", config.twitch.client_secret); + qs.set("redirect_uri", config.twitch.auth.redirect_uri); + qs.set("grant_type", "authorization_code"); + qs.set("code", code); + + let r = await axios.post( + config.twitch.auth.base_url + "/token?" + qs.toString() + ); + + if (r.status !== 200) { + return _.response({ + message: "Something went wrong while request access from Twitch", + }).code(403); + }; + + let auth = new TwitchAuth(r.data); + let userdata = await auth.request(`GET`, `/users`); + let user = userdata.data[0]; + auth.channel = user.login; + auth.bid = user.id; + + twitchAuths[auth.channel as string] = auth; + + return _.response({ + message: `Setup auth correctly for ${auth.channel}, you can leave this page now.`, + }).code(200); + } catch (err) { + log.error(err); + throw err; + }; + }, +}; \ No newline at end of file diff --git a/server/src/endpoints/auth/twitch/redirect.ts b/server/src/endpoints/auth/twitch/redirect.ts new file mode 100644 index 0000000..ac2aa63 --- /dev/null +++ b/server/src/endpoints/auth/twitch/redirect.ts @@ -0,0 +1,19 @@ +import { Request, ResponseToolkit } from "@hapi/hapi"; +import { config, log } from "~/main"; + +export default { + method: `GET`, path: `/twitch/login`, + options: { auth: false }, + async handler(_: Request, h: ResponseToolkit): Promise { + log.silly(`A new authentication process has begun`); + + let qs = new URLSearchParams(); + + qs.set("client_id", config.twitch.client_id); + qs.set("redirect_uri", config.twitch.auth.redirect_uri); + qs.set("response_type", "code"); + qs.set("scope", config.twitch.auth.scopes.join(" ")); + + return h.redirect( config.twitch.auth.base_url + "/authorize?" + qs.toString() ); + }, +}; \ No newline at end of file diff --git a/server/src/endpoints/polls/create.ts b/server/src/endpoints/polls/create.ts new file mode 100644 index 0000000..2a9243d --- /dev/null +++ b/server/src/endpoints/polls/create.ts @@ -0,0 +1,36 @@ +import { Request, ResponseToolkit } from "@hapi/hapi"; +import { log, twitchAuths } from "~/main"; +import boom from "@hapi/boom"; + +export default { + method: `POST`, path: `/twitch/{user}/poll`, + options: { auth: false }, + async handler(request: Request, h: ResponseToolkit): Promise { + log.debug(`Making a Twitch poll!`); + let user = request.params.user; + + // Assert user existance + if (twitchAuths[user] == null) { + throw boom.notFound("Invalid user"); + }; + + let auth = twitchAuths[user]; + let pollData = request.payload as any; + + pollData.broadcaster_id = auth.bid; + + try { + let r = await auth.request("POST", "/polls", { + data: pollData, + }); + + let poll = r.data[0]; + + return h.response({ + poll_id: poll.id, + }) + } catch (err: any) { + throw boom.internal(err.response.message); + }; + }, +}; \ No newline at end of file diff --git a/server/src/endpoints/site/channel_polls.ts b/server/src/endpoints/site/channel_polls.ts new file mode 100644 index 0000000..93e70e2 --- /dev/null +++ b/server/src/endpoints/site/channel_polls.ts @@ -0,0 +1,23 @@ +import { Request, ResponseToolkit } from "@hapi/hapi"; +import { twitchAuths, log } from "~/main"; +import boom from "@hapi/boom"; +import path from "path"; + +export default { + method: `GET`, path: `/dashboard/{user}`, + options: { auth: 'simple' }, + async handler(request: Request, h: ResponseToolkit): Promise { + let user = request.params.user; + + if (twitchAuths[user] == null) { + throw boom.notFound("Invalid user"); + }; + + let uri = path.join(process.cwd(), `../site/${user}.html`); + log.silly(`Filepath: ${uri}`) + + return h.file(uri, { + confine: false + }); + }, +}; \ No newline at end of file diff --git a/server/src/main.ts b/server/src/main.ts new file mode 100644 index 0000000..5f78c5f --- /dev/null +++ b/server/src/main.ts @@ -0,0 +1,73 @@ +// Filepath alias resolution +import "module-alias/register"; + +import { clean_exit } from "~/utils/clean_exit"; +import { init_webserver } from "~/webserver"; +import { Logger } from "tslog"; +import toml from "toml"; +import fs from "fs"; +import { TwitchAuth } from "./utils/TwitchAuth"; + + +// Load the config from disk +if (!fs.existsSync(`config.toml`)) { + console.log(`Please create the config and edit it then run the server again.`); + process.exit(1); +}; +export const config: Config = toml.parse(fs.readFileSync(`config.toml`, `utf-8`)); + + +// Setup the logger with the appropriate settings +export const log = new Logger({ + displayFilePath: `hidden`, + displayFunctionName: false, + displayDateTime: true, + displayLogLevel: true, + minLevel: config.log.level, + name: config.log.name, +}); + + +// Load the database +if (!fs.existsSync(`data/db.json`)) { + log.info(`Can't find database file, creating default`); + try { + fs.writeFileSync(`data/db.json`, `{"authed_channels": {}}`); + } catch (err) { + log.error(`Unable to create the default database, make sure the data directory exists.`); + process.exit(1); + }; +}; +export const db: Database = JSON.parse( + fs.readFileSync(`data/db.json`, `utf-8`) +); + +export const twitchAuths: {[index: string]: TwitchAuth} = {}; + + +// Signal listeners to save persistent storage +process.on(`SIGINT`, clean_exit); +process.on(`SIGTERM`, clean_exit); +process.on(`uncaughtException`, clean_exit); + + +// Setup the utilities that are needed throughout the system +async function init() { + + // Restore all the auth classes for users. + for (var token_data of db.authed_channels) { + if (token_data.channel) { + twitchAuths[token_data.channel] = new TwitchAuth(token_data); + } else { + let auth = new TwitchAuth(token_data); + let userdata = await auth.request(`GET`, `/users`); + auth.channel = userdata.data[0].login; + + twitchAuths[auth.channel as string] = auth; + }; + }; + + await init_webserver(); +}; + +init(); \ No newline at end of file diff --git a/server/src/types/Config.d.ts b/server/src/types/Config.d.ts new file mode 100644 index 0000000..c81462b --- /dev/null +++ b/server/src/types/Config.d.ts @@ -0,0 +1,24 @@ +interface Config { + server: { + host: string; + port: number; + auth: { + enabled: boolean; + username?: string; + password?: string; + }; + }; + twitch: { + client_id: string; + client_secret: string; + auth: { + redirect_uri: string; + base_url: string; + scopes: string[]; + }; + }; + log: { + name: string; + level: "silly" | "trace" | "debug" | "info" | "warn" | "error" | "fatal"; + }; +}; \ No newline at end of file diff --git a/server/src/types/Database.d.ts b/server/src/types/Database.d.ts new file mode 100644 index 0000000..6e205d6 --- /dev/null +++ b/server/src/types/Database.d.ts @@ -0,0 +1,13 @@ +interface TokenData { + access_token: string; + expires_in: number; + refresh_token: string; + channel?: string; + scope: string; + token_type: string; + bid?: string; +} + +interface Database { + authed_channels: any[]; +} \ No newline at end of file diff --git a/server/src/utils/TwitchAuth.ts b/server/src/utils/TwitchAuth.ts new file mode 100644 index 0000000..137a6a1 --- /dev/null +++ b/server/src/utils/TwitchAuth.ts @@ -0,0 +1,160 @@ +import { config, log, twitchAuths } from "~/main"; +import axios, { Method } from "axios"; + +export class TwitchAuth { + private _channel: string | undefined; + private _token: string; + private _refresh_token: string; + private token_type: string; + private scope: string; + private expires_in: number; + private invalidated: boolean = false; + private _bid: string | undefined; + + constructor(token_data: TokenData) { + + this._token = token_data.access_token; + this._refresh_token = token_data.refresh_token; + + this.token_type = token_data.token_type; + this.scope = token_data.scope; + this.expires_in = token_data.expires_in; + + if (token_data?.channel == null) { + log.silly("Unknown channel authenticated, not adding to the twitchAuths."); + } else { + log.silly("Known channel authenticated."); + this._channel = token_data.channel; + twitchAuths[this._channel] = this; + }; + if (token_data?.bid == null) { + log.silly("Channel auth doesn't contain broadcaster ID"); + } else { + this._bid = token_data.bid; + }; + }; + + /** Get the authenticated user's channel name. */ + private async getChannel() { + try { + log.debug(`Requesting user info`) + let r = await this.request("GET", "/users"); + this._channel = r.data.data[0].login; + if (this._channel) { + twitchAuths[this._channel] = this; + }; + } catch (err) { + log.error(`User info errored`) + setTimeout(() => { + this.getChannel(); + }, 2000); + }; + }; + + /** The channel login name that this authentication is for */ + get channel() { return this._channel }; + set channel(value: string | undefined) { + if (!this._channel) { + this._channel = value; + }; + }; + + /** The authenticated user's broadcaster ID */ + get bid() { return this._bid }; + set bid(value: string | undefined) { + if (!this._bid) { + this._bid = value + }; + }; + + /** The token used to make requests for this channel */ + get token() { return `Bearer ${this._token}` }; + + private async refresh() { + + let qs = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: this._refresh_token, + client_id: config.twitch.client_id, + client_secret: config.twitch.client_secret, + }); + + try { + let r = await this.request( + "POST", + config.twitch.auth.base_url + "/token?" + encodeURIComponent(qs.toString()), + ); + this._refresh_token = r.refresh_token; + this._token = r.access_token, + this.expires_in = r.expires_in; + this.scope = r.scope; + this.token_type = r.token_type; + } catch (err) { + log.error(`Could not refresh the token for ${this._channel}`) + } + }; + + public saveData(): TokenData { + return { + access_token: this._token, + refresh_token: this._refresh_token, + token_type: this.token_type, + expires_in: this.expires_in, + scope: this.scope, + channel: this.channel, + }; + }; + + /** + * Makes a request to Twitch on behalf of the authenticated user. + * + * @param method The HTTP method to make the request with + * @param url The URL to make the request to Twitch with. Relative URLs are supported. + * @param conf The Axios RequestConfig, without method, url, or baseURL + * @returns The data returned from the Twitch API + */ + public async request(method: Method, url: string, conf:any={}): Promise { + + if (this.invalidated) { + throw new Error("Can't make a request with an invalidated token"); + }; + + try { + let headers = { + "Client-Id": config.twitch.client_id, + "Authorization": this.token, // Bearer + }; + if (conf?.headers != null) { + headers = { + ...headers, + ...conf.headers, + }; + delete conf.headers; + }; + + let r = await axios({ + method, + url, + headers, + baseURL: "https://api.twitch.tv/helix", + ...(conf ?? {}) + }); + + return r.data as T; + } catch (err: any) { + if (err.response) { + + // global error handling, + switch (err.response.status) { + case 403: + this.refresh(); + break; + default: + log.error(err); + }; + }; + log.error(err); + throw err; + }; + }; +} \ No newline at end of file diff --git a/server/src/utils/clean_exit.ts b/server/src/utils/clean_exit.ts new file mode 100644 index 0000000..6d98a9f --- /dev/null +++ b/server/src/utils/clean_exit.ts @@ -0,0 +1,24 @@ +import { db, log, twitchAuths } from "~/main"; +import fs from "fs"; + +export function clean_exit() { + log.info(`Exiting the program cleanly`); + + db.authed_channels = []; + for (var channel in twitchAuths) { + log.debug(`Saving ${channel}'s auth information into the database file.`); + db.authed_channels.push(twitchAuths[channel].saveData()); + }; + + // Attempt to write the persistent storage to the database + try { + fs.writeFileSync( + `./data/db.json`, + JSON.stringify(db) + ); + } catch (err) { + log.error(`Couldn't save program storage properly, writing to log.`, db); + }; + + process.exit(1); +}; \ No newline at end of file diff --git a/server/src/webserver.ts b/server/src/webserver.ts new file mode 100644 index 0000000..839303a --- /dev/null +++ b/server/src/webserver.ts @@ -0,0 +1,63 @@ +import { ResponseToolkit, Server, ServerRoute } from "@hapi/hapi"; +import { config, log } from "~/main"; +import basic from "@hapi/basic"; +import inert from "@hapi/inert"; +import glob from "glob"; +import path from "path"; + +export async function init_webserver() { + const server = new Server({ + port: config.server.port, + }); + await server.register(inert); + + + await server.register(basic); + server.auth.strategy(`simple`, `basic`, { + // @ts-expect-error + async validate(r: Request, user: string, password: string, h: ResponseToolkit) { + + if (!config.server.auth.enabled) { + return { + isValid: true, + credentials: { + user: "", + password: "", + }, + }; + }; + + // Assert usernames match if it was set in the config + if (config.server.auth.username != null) { + if (user !== config.server.auth.username) { + return { + isValid: false, + }; + }; + }; + + return { + isValid: config.server.auth.password === password, + credentials: { user, password } + }; + }, + allowEmptyUsername: true, + }); + server.auth.default(`simple`); + + + // Register all the endpoints that we need for the server functionality + let files = glob.sync( + `endpoints/**/!(*.map)`, + { cwd: __dirname, nodir: true } + ); + for (var file of files) { + let route: ServerRoute = (await import(path.join(__dirname, file))).default; + log.debug(`Registering route: ${route.method} ${route.path}`); + server.route(route); + }; + + server.start().then(() => { + log.info(`Server running on: ${config.server.host}:${config.server.port}`); + }); +}; \ No newline at end of file diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..72110fc --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,74 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Basic Options */ + // "incremental": true, /* Enable incremental compilation */ + "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */ + "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ + // "lib": [], /* Specify library files to be included in the compilation. */ + // "allowJs": true, /* Allow javascript files to be compiled. */ + // "checkJs": true, /* Report errors in .js files. */ + // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ + // "declaration": true, /* Generates corresponding '.d.ts' file. */ + // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ + "sourceMap": true, /* Generates corresponding '.map' file. */ + // "outFile": "./", /* Concatenate and emit output to single file. */ + "outDir": "./dist", /* Redirect output structure to the directory. */ + "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + // "composite": true, /* Enable project compilation */ + // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ + // "removeComments": true, /* Do not emit comments to output. */ + // "noEmit": true, /* Do not emit outputs. */ + // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ + // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ + + /* Strict Type-Checking Options */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* Enable strict null checks. */ + // "strictFunctionTypes": true, /* Enable strict checking of function types. */ + // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ + // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ + // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ + // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + + /* Additional Checks */ + "noUnusedLocals": true, /* Report errors on unused locals. */ + "noUnusedParameters": true, /* Report errors on unused parameters. */ + "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ + + /* Module Resolution Options */ + // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ + "paths": { + "~/*": [ "./src/*" ] + }, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ + // "typeRoots": [], /* List of folders to include type definitions from. */ + // "types": [], /* Type declaration files to be included in compilation. */ + // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ + "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + + /* Source Map Options */ + // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + + /* Experimental Options */ + // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ + // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + + /* Advanced Options */ + "skipLibCheck": true, /* Skip type checking of declaration files. */ + "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ + } +} diff --git a/site/alkali_metal.html b/site/alkali_metal.html new file mode 100644 index 0000000..6940a0e --- /dev/null +++ b/site/alkali_metal.html @@ -0,0 +1,377 @@ + + + + + + + CGE Poll Manager + + + + + +

CGE Poll Quick-Creator

+
+
+
+

{{poll.name}}

+

+ Duration: {{poll.data.duration}} seconds +
+ Choices: +

+
    +
  • + {{choice.title}} +
  • +
+ +
+
+ + + \ No newline at end of file diff --git a/site/czechgamesedition.html b/site/czechgamesedition.html new file mode 100644 index 0000000..974ae96 --- /dev/null +++ b/site/czechgamesedition.html @@ -0,0 +1,377 @@ + + + + + + + CGE Poll Manager + + + + + +

CGE Poll Quick-Creator

+
+
+
+

{{poll.name}}

+

+ Duration: {{poll.data.duration}} seconds +
+ Choices: +

+
    +
  • + {{choice.title}} +
  • +
+ +
+
+ + + \ No newline at end of file