diff --git a/.gitignore b/.gitignore index eda6428..761a310 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ bin node_modules scripts/* tmp +config.yaml \ No newline at end of file diff --git a/app.js b/app.js index 6b35600..f08a7fc 100644 --- a/app.js +++ b/app.js @@ -3,26 +3,50 @@ const createError = require('http-errors'), path = require('path'), cookieParser = require('cookie-parser'), logger = require('morgan'), + compileSass = require('express-compile-sass'), + session = require('express-session'), + fsx = require('fs-extra'), + yaml = require('js-yaml'), indexRouter = require('./routes/index'), usersRouter = require('./routes/users'), - riddleRouter = require('./routes/riddle'); + riddleRouter = require('./routes/riddle'), + bingoRouter = require('./routes/bingo'); + +let settings = yaml.safeLoad(fsx.readFileSync('default-config.yaml')); + +if (fsx.existsSync('config.yaml')) + Object.assign(settings, yaml.safeLoad(fsx.readFileSync('config.yaml'))); let app = express(); // view engine setup app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'pug'); +app.set('trust proxy', 1); app.use(logger('dev')); app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.use(cookieParser()); +app.use(session({ + secret: settings.sessions.secret, + resave: false, + saveUninitialized: true, + cookie: { maxAge: settings.sessions.maxAge } +})); +app.use('/sass', compileSass({ + root: './public/stylesheets/sass', + sourceMap: true, + watchFiles: true, + logToConsole: true +})); app.use(express.static(path.join(__dirname, 'public'))); app.use('/', indexRouter); app.use('/users', usersRouter); app.use(/\/riddle(\/.*)?/, riddleRouter); +app.use(/\/bingo?.*/, bingoRouter); // catch 404 and forward to error handler app.use(function(req, res, next) { @@ -40,4 +64,4 @@ app.use(function(err, req, res, next) { res.render('error'); }); -module.exports = app; +app.listen(settings.port); diff --git a/default-config.yaml b/default-config.yaml new file mode 100644 index 0000000..9edb835 --- /dev/null +++ b/default-config.yaml @@ -0,0 +1,5 @@ +sessions: + secret: averysecuresessionsecret + maxAge: 1000000 + +port: 3000 \ No newline at end of file diff --git a/package.json b/package.json index 8a65a6a..9fb1f5f 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,9 @@ "fs-extra": "^7.0.1", "http-errors": "~1.6.3", "morgan": "~1.9.1", - "pug": "2.0.0-beta11" + "pug": "2.0.0-beta11", + "express-compile-sass": "latest", + "express-session": "latest", + "js-yaml": "latest" } } diff --git a/public/javascripts/bingo-web.js b/public/javascripts/bingo-web.js new file mode 100644 index 0000000..4acfa78 --- /dev/null +++ b/public/javascripts/bingo-web.js @@ -0,0 +1,88 @@ +async function submitBingoWords() { + let textContent = document.querySelector('#bingo-textarea').value; + let words = textContent.replace(/[<>]/g, '').split('\n'); + let size = document.querySelector('#bingo-grid-size').value; + let dimY = document.querySelector('#bingo-grid-y').value; + let response = await postLocData({ + bingoWords: words, + size: size + }); + + let data = JSON.parse(response.data); + let gameid = data.id; + insertParam('game', gameid); +} + +async function submitUsername() { + let username = document.querySelector('#username-input').value; + let response = await postLocData({ + username: username + }); + + console.log(response); +} + +async function submitWord(word) { + let response = await postLocData({ + bingoWord: word + }); + console.log(response); + + let data = JSON.parse(response.data); + for (let row of data.fieldGrid) { + for (let field of row) { + document.querySelector(`.bingo-word-panel[b-word="${field.word}"]`) + .setAttribute('b-sub', field.submitted); + } + } + if (data.bingo) { + document.querySelector('#bingo-button').setAttribute('class', ''); + } +} + +async function submitBingo() { + let response = await postLocData({ + bingo: true + }); + let data = JSON.parse(response.data); + if (data.bingos.length > 0) { + displayWinner(data.users[data.bingos[0]].username); + clearInterval(refrInterval) + } + console.log(response); +} + +async function refresh() { + let response = await postLocData({}); + if (response.status === 400) + clearInterval(refrInterval); + let data = JSON.parse(response.data); + if (data.bingos.length > 0) { + displayWinner(data.users[data.bingos[0]].username); + clearInterval(refrInterval) + } + console.log(response); +} + +function displayWinner(name) { + let winnerDiv = document.createElement('div'); + let greyoverDiv = document.createElement('div'); + let winnerSpan = document.createElement('span'); + winnerDiv.setAttribute('class', 'popup'); + greyoverDiv.setAttribute('class', 'greyover'); + winnerSpan.innerText = `${name} has won!`; + winnerDiv.appendChild(winnerSpan); + document.body.append(greyoverDiv); + document.body.appendChild(winnerDiv); +} + +window.onload = () => { + if (window && !document.querySelector('#bingoform')) { + refrInterval = setInterval(refresh, 1000); + } + let gridSizeElem = document.querySelector('#bingo-grid-size'); + document.querySelector('#bingo-grid-y').innerText = gridSizeElem.value; + gridSizeElem.oninput = () => { + document.querySelector('#bingo-grid-y').innerText = gridSizeElem.value; + }; +}; \ No newline at end of file diff --git a/public/javascripts/common.js b/public/javascripts/common.js new file mode 100644 index 0000000..0883713 --- /dev/null +++ b/public/javascripts/common.js @@ -0,0 +1,45 @@ +function postLocData(postBody) { + let request = new XMLHttpRequest(); + return new Promise((res, rej) => { + + request.onload = () => { + res({ + status: request.status, + data: request.responseText + }); + }; + + request.onerror = () => { + rej(request.error); + }; + + request.open('POST', '#', true); + request.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); + request.send(JSON.stringify(postBody)); + }); +} + +function insertParam(key, value) { + key = encodeURI(key); + value = encodeURI(value); + + let kvp = document.location.search.substr(1).split('&'); + + let i = kvp.length; + let x; + while (i--) { + x = kvp[i].split('='); + + if (x[0] === key) { + x[1] = value; + kvp[i] = x.join('='); + break; + } + } + + if (i < 0) { + kvp[kvp.length] = [key, value].join('='); + } + + document.location.search = kvp.join('&'); +} \ No newline at end of file diff --git a/public/javascripts/riddle-web.js b/public/javascripts/riddle-web.js index 29e1219..3329b11 100644 --- a/public/javascripts/riddle-web.js +++ b/public/javascripts/riddle-web.js @@ -1,24 +1,3 @@ -function postLocData(postBody) { - let request = new XMLHttpRequest(); - return new Promise((res, rej) => { - - request.onload = () => { - res({ - status: request.status, - data: request.responseText - }); - }; - - request.onerror = () => { - rej(request.error); - }; - - request.open('POST', '#', true); - request.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); - request.send(JSON.stringify(postBody)); - }); -} - async function startSubredditDownload(subredditName) { let data = await postLocData({ subreddit: subredditName @@ -45,13 +24,13 @@ async function refreshDownloadInfo(downloadId) { } else { let dlLink = document.createElement('a'); dlLink.setAttribute('href', response.file); - dlLink.setAttribute('filename', `${subredditName}`); - for (let cNode of dlDiv.childNodes) - dlLink.appendChild(cNode); + dlLink.setAttribute('download', `${subredditName}`); + dlLink.innerHTML = dlDiv.innerHTML; + dlDiv.innerHTML = ''; dlDiv.appendChild(dlLink); setTimeout(() => { dlDiv.remove(); - }, 30000); + }, 300000); } } @@ -66,13 +45,13 @@ async function submitDownload() { document.querySelector('#download-list').prepend(dlDiv); let subnameSpan = document.createElement('span'); - subnameSpan.innerText = subredditName; - subnameSpan.setAttribute('class', 'subredditName'); + subnameSpan.innerText = 'r/'+subredditName; + subnameSpan.setAttribute('class', 'subredditName tableRow'); dlDiv.appendChild(subnameSpan); let dlStatusSpan = document.createElement('span'); dlStatusSpan.innerText = response.status; - dlStatusSpan.setAttribute('class', 'downloadStatus'); + dlStatusSpan.setAttribute('class', 'downloadStatus tableRow'); dlDiv.appendChild(dlStatusSpan); await refreshDownloadInfo(response.id); diff --git a/public/stylesheets/sass/bingo/style.sass b/public/stylesheets/sass/bingo/style.sass new file mode 100644 index 0000000..1761747 --- /dev/null +++ b/public/stylesheets/sass/bingo/style.sass @@ -0,0 +1,62 @@ +@import ../mixins +@import ../vars + +button + margin: 1rem + +textarea + @include default-element + display: block + margin: 1rem + border-radius: 0 + height: 50% + width: 50% + font-size: 15pt + +.number-input + width: 4rem + margin: 1rem + +#words-container + display: table + + .bingo-word-row + display: table-row + + .bingo-word-panel + @include default-element + display: table-cell + padding: 3rem + transition-duration: 0.3s + max-width: 15rem + + .bingo-word-panel:hover + background-color: darken($primary, 2%) + cursor: pointer + + .bingo-word-panel:active + background-color: $primary + + .bingo-word-panel[b-sub="true"] + background-color: forestgreen + +.popup + @include default-element + height: 5% + width: 40% + top: 47.5% + left: 30% + text-align: center + transition-duration: 1s + span + margin: 2% + display: block + +.greyover + width: 100% + height: 100% + position: fixed + z-index: 99 + top: 0 + left: 0 + background-color: transparentize($primary, 0.5) \ No newline at end of file diff --git a/public/stylesheets/sass/classes.sass b/public/stylesheets/sass/classes.sass new file mode 100644 index 0000000..0a6474d --- /dev/null +++ b/public/stylesheets/sass/classes.sass @@ -0,0 +1,13 @@ +.tableRow + display: table-row + +.hidden + display: None + +.popup + height: 60% + width: 40% + z-index: 1000 + position: fixed + top: 20% + left: 30% \ No newline at end of file diff --git a/public/stylesheets/sass/mixins.sass b/public/stylesheets/sass/mixins.sass new file mode 100644 index 0000000..82a0018 --- /dev/null +++ b/public/stylesheets/sass/mixins.sass @@ -0,0 +1,7 @@ +@import vars + +@mixin default-element + background: lighten($primary, 10%) + color: $primarySurface + border: 2px solid $primarySurface + border-radius: $borderRadius \ No newline at end of file diff --git a/public/stylesheets/sass/riddle/style.sass b/public/stylesheets/sass/riddle/style.sass new file mode 100644 index 0000000..159be34 --- /dev/null +++ b/public/stylesheets/sass/riddle/style.sass @@ -0,0 +1,22 @@ +@import ../mixins +@import ../vars + +#download-list + margin: 1rem 0 + +.download-container + @include default-element + display: inline-block + margin: 1rem + padding: 1rem + .subredditName + font-weight: bold + a + text-decoration: none + color: $primarySurface + +#submit-download + margin: 0 1rem + +#subreddit-input + margin: 0 1rem \ No newline at end of file diff --git a/public/stylesheets/sass/style.sass b/public/stylesheets/sass/style.sass new file mode 100644 index 0000000..653cfd1 --- /dev/null +++ b/public/stylesheets/sass/style.sass @@ -0,0 +1,28 @@ +@import vars +@import classes +@import mixins + +body + background-color: $primary + color: $primarySurface + font-size: 18pt + font-family: Arial, sans-serif + +button + @include default-element + font-size: 20pt + padding: 10px + transition-duration: 0.2s + +button:hover + background-color: darken($primary, 2%) + cursor: pointer + +button:active + background-color: lighten($primary, 15%) + +input + @include default-element + font-size: 20pt + background-color: lighten($primary, 10%) + padding: 9px \ No newline at end of file diff --git a/public/stylesheets/sass/vars.sass b/public/stylesheets/sass/vars.sass new file mode 100644 index 0000000..9f8b9b2 --- /dev/null +++ b/public/stylesheets/sass/vars.sass @@ -0,0 +1,4 @@ +$primary: #223 +$primarySurface: white + +$borderRadius: 20px \ No newline at end of file diff --git a/routes/bingo.js b/routes/bingo.js new file mode 100644 index 0000000..684fb17 --- /dev/null +++ b/routes/bingo.js @@ -0,0 +1,259 @@ +const express = require('express'), + router = express.Router(), + cproc = require('child_process'), + fsx = require('fs-extra'); + +const rWordOnly = /^\w+$/; + +let bingoSessions = {}; + +class BingoSession { + /** + * constructor + * @param words List + * @param [size] Number + */ + constructor(words, size = 3) { + this.id = generateBingoId(); + this.words = words; + this.gridSize = size; + this.users = {}; + this.bingos = []; // array with the users that already had bingo + this.finished = false; + } + + /** + * Adds a user to the session + * @param user + */ + addUser(user) { + let id = user.id; + this.users[id] = user; + } +} + +class BingoUser { + constructor() { + this.id = generateBingoId(); + this.game = null; + this.username = 'anonymous'; + this.grids = {}; + this.submittedWords = {}; + } +} + +class BingoWordField { + constructor(word) { + this.word = word; + this.submitted = false; + } +} + +class BingoGrid { + constructor(wordGrid) { + this.wordGrid = wordGrid; + this.fieldGrid = wordGrid.map(x => x.map(y => new BingoWordField(y))); + return this; + } +} + +/** + * Shuffles the elements in an array + * @param array {Array<*>} + * @returns {Array<*>} + */ +function shuffleArray(array) { + let counter = array.length; + while (counter > 0) { + let index = Math.floor(Math.random() * counter); + counter--; + let temp = array[counter]; + array[counter] = array[index]; + array[index] = temp; + } + return array; +} + +/** + * Generates an id for a subreddit download. + * @returns {string} + */ +function generateBingoId() { + return Date.now().toString(16); +} + +/** + * Generates a word grid with random word placements in the given dimensions + * @param dimensions {Array} - the dimensions of the grid + * @param words {Array} - the words included in the grid + * @returns {BingoGrid} + */ +function generateWordGrid(dimensions, words) { + let shuffledWords = shuffleArray(words); + let grid = []; + for (let x = 0; x < dimensions[1]; x++) { + grid[x] = []; + for (let y = 0; y < dimensions[0]; y++) { + grid[x][y] = shuffledWords[(x * dimensions[0]) + y]; + } + } + return (new BingoGrid(grid)); +} + +/** + * Sets the submitted parameter of the words in the bingo grid that match to true. + * @param word {String} + * @param bingoGrid {BingoGrid} + * @returns {boolean} + */ +function submitWord(word, bingoGrid) { + let results = bingoGrid.fieldGrid.find(x => x.find(y => (y.word === word))).find(x => x.word === word); + + if (results) { + (results instanceof Array)? results.forEach(x => {x.submitted = true}): results.submitted = true; + checkBingo(bingoGrid); + return true; + } + return false; +} + +/** + * Checks if a bingo exists in the bingo grid. + * @param bingoGrid {BingoGrid} + * @returns {boolean} + */ +function checkBingo(bingoGrid) { + let fg = bingoGrid.fieldGrid.map(x => x.map(y => y.submitted)); + + let diagonalBingo = true; + // diagonal check + for (let i = 0; i < fg.length; i++) + diagonalBingo = fg[i][i] && diagonalBingo; + if (diagonalBingo) { + bingoGrid.bingo = true; + return true; + } + diagonalBingo = true; + for (let i = 0; i < fg.length; i++) + diagonalBingo = fg[i][fg.length - i - 1] && diagonalBingo; + if (diagonalBingo) { + bingoGrid.bingo = true; + return true; + } + let bingoCheck = true; + // horizontal check + for (let row of fg) { + bingoCheck = true; + for (let field of row) + bingoCheck = field && bingoCheck; + if (bingoCheck) + break; + } + if (bingoCheck) { + bingoGrid.bingo = true; + return true; + } + bingoCheck = true; + // vertical check + for (let i = 0; i < fg.length; i++) { + bingoCheck = true; + for (let j = 0; j < fg.length; j++) + bingoCheck = fg[j][i] && bingoCheck; + if (bingoCheck) + break; + } + if (bingoCheck) { + bingoGrid.bingo = true; + return true; + } + return false; +} + +// -- Router stuff + +router.use((req, res, next) => { + if (!req.session.bingoUser) { + req.session.bingoUser = new BingoUser(); + } + next(); +}); + +router.get('/', (req, res) => { + let bingoUser = req.session.bingoUser; + if (req.query.game) { + let gameId = req.query.game; + + if (bingoSessions[gameId] && !bingoSessions[gameId].finished) { + bingoUser.game = gameId; + let bingoSession = bingoSessions[gameId]; + bingoSession.addUser(bingoUser); + + if (!bingoUser.grids[gameId]) { + bingoUser.grids[gameId] = generateWordGrid([bingoSession.gridSize, bingoSession.gridSize], bingoSession.words); + } + res.render('bingo/bingo-game', {grid: bingoUser.grids[gameId].wordGrid, username: bingoUser.username}); + } else { + res.render('bingo/bingo-submit'); + } + } else { + res.render('bingo/bingo-submit'); + } +}); + +router.post('/', (req, res) => { + let data = req.body; + let gameId = req.query.game; + let bingoUser = req.session.bingoUser; + let bingoSession = bingoSessions[gameId]; + + if (data.bingoWords) { + let words = data.bingoWords; + let size = data.size; + let game = new BingoSession(words, size); + + bingoSessions[game.id] = game; + + setTimeout(() => { // delete the game after one day + delete bingoSessions[game.id]; + }, 86400000); + + res.send(game); + } else if (data.username) { + bingoUser.username = data.username; + bingoSessions[gameId].addUser(bingoUser); + + res.send(bingoUser); + } else if (data.game) { + res.send(bingoSessions[data.game]); + } else if (data.bingoWord) { + if (!bingoUser.submittedWords[gameId]) + bingoUser.submittedWords[gameId] = []; + bingoUser.submittedWords[gameId].push(data.bingoWord); + console.log(typeof bingoUser.grids[gameId]); + if (bingoUser.grids[gameId]) + submitWord(data.bingoWord, bingoUser.grids[gameId]); + res.send(bingoUser.grids[gameId]); + } else if (data.bingo) { + if (checkBingo(bingoUser.grids[gameId])) { + if (!bingoSession.bingos.includes(bingoUser.id)) + bingoSession.bingos.push(bingoUser.id); + bingoSession.finished = true; + setTimeout(() => { // delete the finished game after five minutes + delete bingoSessions[game.id]; + }, 360000); + res.send(bingoSession); + } else { + res.status(400); + res.send({'error': "this is not a bingo!"}) + } + } else if (bingoSession) { + res.send(bingoSession); + } else { + res.status(400); + res.send({ + error: 'invalid request data' + }) + } +}); + +module.exports = router; diff --git a/routes/riddle.js b/routes/riddle.js index 3ce0092..6d23413 100644 --- a/routes/riddle.js +++ b/routes/riddle.js @@ -37,7 +37,7 @@ function startDownload(subreddit) { let dlWebPath = `/static/${downloadId}.zip`; let dl = new RedditDownload(dlWebPath); - dl.process = cproc.exec(`python -u riddle.py -o ../../public/static/${downloadId} -z --lzma ${subreddit}`, + dl.process = cproc.exec(`python3 -u riddle.py -o ../../public/static/${downloadId} -z --lzma ${subreddit}`, {cwd: './scripts/reddit-riddle', env: {PYTHONIOENCODING: 'utf-8', PYTHONUNBUFFERED: true}}, (err, stdout) => { if (err) { diff --git a/views/bingo/bingo-game.pug b/views/bingo/bingo-game.pug new file mode 100644 index 0000000..d0e2f31 --- /dev/null +++ b/views/bingo/bingo-game.pug @@ -0,0 +1,13 @@ +include bingo-layout + +block content + div(id='username-form') + input(type='text', id='username-input', placeholder='username', value=username) + button(onclick='submitUsername()') Set Username + button(id='bingo-button' onclick='submitBingo()', class='hidden') Bingo! + div(id='words-container') + each val in grid + div(class='bingo-word-row') + each word in val + div(class='bingo-word-panel', onclick=`submitWord('${word}')`, b-word=word, b-sub='false') + span= word \ No newline at end of file diff --git a/views/bingo/bingo-layout.pug b/views/bingo/bingo-layout.pug new file mode 100644 index 0000000..7f6f4a4 --- /dev/null +++ b/views/bingo/bingo-layout.pug @@ -0,0 +1,8 @@ +html + head + include ../includes/head + script(type='text/javascript', src='/javascripts/bingo-web.js') + link(rel='stylesheet', href='/sass/bingo/style.sass') + + body + block content \ No newline at end of file diff --git a/views/bingo/bingo-submit.pug b/views/bingo/bingo-submit.pug new file mode 100644 index 0000000..577cc1e --- /dev/null +++ b/views/bingo/bingo-submit.pug @@ -0,0 +1,9 @@ +extends bingo-layout + +block content + div(id='bingoform') + input(type='number', id='bingo-grid-size', class='number-input', value=3, min=1, max=8) + span x + span(id='bingo-grid-y', class='number-input') 3 + button(onclick='submitBingoWords()') Submit + textarea(id='bingo-textarea', placeholder='Bingo Words') \ No newline at end of file diff --git a/views/includes/head.pug b/views/includes/head.pug new file mode 100644 index 0000000..2c11aef --- /dev/null +++ b/views/includes/head.pug @@ -0,0 +1,2 @@ +link(rel='stylesheet', href='/sass/style.sass') +script(type='text/javascript', src='/javascripts/common.js') \ No newline at end of file diff --git a/views/riddle.pug b/views/riddle.pug index f710a6f..d17ed94 100644 --- a/views/riddle.pug +++ b/views/riddle.pug @@ -1,8 +1,9 @@ html head title= title - link(rel='stylesheet', href='/stylesheets/style.css') + include includes/head.pug script(type='text/javascript', src='/javascripts/riddle-web.js') + link(rel='stylesheet', href='/sass/riddle/style.sass') body h1 Riddle Reddit downloader input(type='text' placeholder='subreddit' id='subreddit-input')