diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..264b93f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch Program", + "skipFiles": [ + "/**" + ], + "program": "${workspaceFolder}/src/server.js" + } + ] +} \ No newline at end of file diff --git a/dev/mat.json b/dev/mat.json index b711c57..0f6e964 100644 --- a/dev/mat.json +++ b/dev/mat.json @@ -1,29 +1,65 @@ { "zKsAXcOCNd9U": { "mat": [ - 0.0, 0.0, 0.7, 0.7, - 0.0, 0.0, 0.0, 0.1, - 0.0, 0.0, 0.0, 0.0, - 0.0, 0.0, 0.0, 0.0 + 0.30000000000000004, + 0.5, + 0.5, + 0.5, + 0.30000000000000004, + 0.30000000000000004, + 0.30000000000000004, + 0.5, + 0.10000000000000003, + 0.10000000000000003, + 0.10000000000000003, + 0.10000000000000003, + 0.10000000000000003, + 0.10000000000000003, + 0.10000000000000003, + 0.10000000000000003 ], - "nums": 2 + "nums": 6 }, "eFkYZBMofIVG": { "mat": [ - 0.0, 0.0, 0.8, 0.4, - 0.0, 0.0, 0.0, 0.4, - 0.0, 0.0, 0.0, 0.0, - 0.0, 0.0, 0.0, 0.0 + 0.30000000000000004, + 0.30000000000000004, + 0.8999999999999999, + 0.8999999999999999, + 0.30000000000000004, + 0.30000000000000004, + 0.7, + 1.0999999999999999, + -0.09999999999999998, + -0.09999999999999998, + -0.09999999999999998, + -0.09999999999999998, + -0.09999999999999998, + -0.09999999999999998, + -0.09999999999999998, + -0.09999999999999998 ], - "nums": 2 + "nums": 9 }, "JOAT6FV7VKKi": { "mat": [ - 0.7, 0.6, 0.2, 0.7, - 1.0, 0.9, 0.5, 0.1, - 1.0, 1.0, 0.0, 0.0, - 0.0, 0.0, 0.0, 0.0 + 0.6, + 0.4, + 0.20000000000000004, + 0.9999999999999999, + 0.6, + 0.6, + 0.7999999999999999, + 0.9999999999999999, + -0.19999999999999998, + -0.19999999999999998, + -0.19999999999999998, + -0.19999999999999998, + -0.19999999999999998, + -0.19999999999999998, + -0.19999999999999998, + -0.19999999999999998 ], - "nums": 2 + "nums": 8 } } \ No newline at end of file diff --git a/package.json b/package.json index 0d55b04..3ac5e61 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "src/server.js", "type": "module", "scripts": { - "server": "nodemon src/server.js", + "server": "nodemon src/server.js --ignore dev/ --ignore public/ --ignore src/client/", "dev": "NODE_ENV=development; rollup -c -w", "build": "NODE_ENV=production; rollup -c", "test": "echo \"Error: no test specified\" && exit 1" diff --git a/src/client/client.js b/src/client/client.js index 246c405..d968358 100644 --- a/src/client/client.js +++ b/src/client/client.js @@ -1,29 +1,89 @@ -import UserSession from '../shared/models/UserSession.js'; +import initializeSession from './helpers/initializeSession.js'; +import getImage from './helpers/getImage.js'; +import verifyImage from './helpers/verifyImage.js'; /** * Create a uCaptcha box - * @param {string} key Website key + * @param {string} websiteKey Website key * @return {HTMLElement} uCaptcha box */ -function uCaptchaBox(key) { +function uCaptchaBox(websiteKey) { + const styles = ` + #ucaptcha-next { + border: none; + background-color: royalblue; + color: #FFF; + padding: 10px 20px; + text-transform: uppercase; + border-radius: 3px; + } + #ucaptcha-grid { + width: 384px; + height: 384px; + } + #ucaptcha-grid td { + transition: .1s ease; + cursor: pointer; + padding: 10px; + } + + #ucaptcha-grid td.selected { + padding: 0; + border: 10px solid white; + } + `; + const styleTag = createElement('style'); + styleTag.innerHTML = styles; + document.head.appendChild(styleTag); + const checkbox = createElement('div', { style: 'cursor:pointer;border-radius:3px;border:2px solid #888;width:25px;height:25px;display:inline-block', }); - checkbox.onclick = function() { - fetch(`https://localhost:444/api/init?k=${key}`) - .then((r)=>r.text()) - .then((r)=>r.substr(2)) - .then((r)=>JSON.parse(r)) - .then((resp)=>{ - const session = new UserSession(); - session.deserialize(resp); + + const captchaBox = createElement('div'); + captchaBox.appendChild(checkbox); + + const imageContainer = createElement('div', {id: 'ucaptcha-container'}); + + const image = createElement('img', {id: 'ucaptcha-img', style: 'position:absolute;display:block;z-index:-999'}); + imageContainer.appendChild(image); + + const btn = createElement('button', {id: 'ucaptcha-next'}); + btn.textContent = 'Next'; + + captchaBox.appendChild(imageContainer); + captchaBox.appendChild(btn); + + checkbox.onclick = async function() { + /** @type {import('../shared/models/UserSession.js').UserSession} */ + const session = await initializeSession(websiteKey); + + const imageGrid = createElement('table', {id: 'ucaptcha-grid'}); + for (let i = 0; i < 4; i++) { + const tr = createElement('tr'); + for (let ii = 0; ii < 4; ii++) { + const td = createElement('td', + {'style': 'cursor:pointer'}); + td.addEventListener('click', (e)=>{ + e.target.classList.toggle('selected'); }); + tr.appendChild(td); + } + imageGrid.appendChild(tr); + } + + btn.addEventListener('click', (e)=>{ + verifyImage(session, imageGrid); + getImage(session, captchaBox); + }); + + imageContainer.appendChild(imageGrid); + + getImage(session, captchaBox); checkbox.setAttribute('style', checkbox.getAttribute('style') + 'background-color:royalblue;'); }; - const captchaBox = createElement('div'); - captchaBox.appendChild(checkbox); return captchaBox; } diff --git a/src/client/helpers/getImage.js b/src/client/helpers/getImage.js new file mode 100644 index 0000000..094a928 --- /dev/null +++ b/src/client/helpers/getImage.js @@ -0,0 +1,20 @@ +import request from './util/request.js'; +import UserSession from '../../shared/models/UserSession.js'; + +/** + * Instantiate the uCaptcha loop + * @param {UserSession} session + * @param {HTMLElement} captchaBox + */ +export default function instantiateLoop(session, captchaBox) { + console.log(captchaBox); + request(`/api/image?s=${session.sessionId}`, {responseType: 'blob'}) + .then((blob)=>{ + const imageUrl = URL.createObjectURL(blob); + console.log(imageUrl); + captchaBox + .querySelector('#ucaptcha-container') + .querySelector('#ucaptcha-img') + .setAttribute('src', imageUrl); + }); +} diff --git a/src/client/helpers/initializeSession.js b/src/client/helpers/initializeSession.js new file mode 100644 index 0000000..1cdfca9 --- /dev/null +++ b/src/client/helpers/initializeSession.js @@ -0,0 +1,21 @@ +import request from './util/request.js'; +import UserSession from '../../shared/models/UserSession.js'; + +/** + * @param {string} websiteKey + * @return {Promise} + */ +export default function initializeSession(websiteKey) { + return new Promise((resolve, reject)=>{ + request(`/api/init?k=${websiteKey}`) + .then((resp)=>{ + console.log(resp); + const session = new UserSession(); + session.deserialize(resp); + resolve(session); + }) + .catch((e)=>{ + reject(e); + }); + }); +} diff --git a/src/client/helpers/util/request.js b/src/client/helpers/util/request.js new file mode 100644 index 0000000..47c0401 --- /dev/null +++ b/src/client/helpers/util/request.js @@ -0,0 +1,49 @@ +/** + * @typedef {Object} RequestOptions + * @property {Object} [body] Request body + * @property {string} [method] Request method + * @property {XMLHttpRequestResponseType} [responseType] Return raw response from server + */ + +/** @type {RequestOptions} */ +const defaultOptions = { + method: 'GET', + responseType: 'text', // Text because we will need to substr it. +}; + +/** + * Make an HTTP request + * @param {string} url + * @param {RequestOptions} options + * @return {Promise} + */ +export default function(url, options) { + console.log(options); + options = Object.assign({}, defaultOptions, options); + console.log(options); + return new Promise((resolve, reject)=>{ + const xhr = new XMLHttpRequest(); + xhr.open(options.method, url); + + xhr.responseType = options.responseType; + + xhr.onload = function() { + if (this.status < 400) { + if (options.responseType !== 'text') { + resolve(this.response); + } else { + resolve(JSON.parse(this.responseText.substr(2))); + } + } else { + reject(JSON.parse(this.responseText)); + } + }; + + if (options.method === 'POST') { + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(JSON.stringify(options.body)); + } else { + xhr.send(); + } + }); +} diff --git a/src/client/helpers/verifyImage.js b/src/client/helpers/verifyImage.js new file mode 100644 index 0000000..f1bef14 --- /dev/null +++ b/src/client/helpers/verifyImage.js @@ -0,0 +1,33 @@ +import request from './util/request.js'; +import UserSession from '../../shared/models/UserSession.js'; + +/** + * Verify the image + * @param {UserSession} session + * @param {HTMLElement} captchaGrid + */ +export default async function(session, captchaGrid) { + const tds = captchaGrid.querySelectorAll('td'); + const selectedTds = []; + + const mat = new Array(tds.length); + for (let i = 0; i < tds.length; i++) { + if (tds[i].classList.contains('selected')) { + mat[i] = 1; + selectedTds.push(tds[i]); + } else { + mat[i] = 0; + } + } + + const body = { + s: session.sessionId, + mat, + }; + + await request('/api/verify', {method: 'POST', body}); + + selectedTds.map((td)=>{ + td.classList.remove('selected'); + }); +} diff --git a/src/models/IDBSession.js b/src/models/IDBSession.js index ce67998..d579da0 100644 --- a/src/models/IDBSession.js +++ b/src/models/IDBSession.js @@ -2,6 +2,7 @@ * Session storage in IDB * @typedef {Object} IDBSession * @property {string} sessionId User session ID + * @property {string} websiteKey Website ID * @property {string} image Image URL for challenge * @property {number} score How likely is the user to be a human? */ diff --git a/src/routes/api/index.js b/src/routes/api/index.js index 40d9799..452c49c 100644 --- a/src/routes/api/index.js +++ b/src/routes/api/index.js @@ -6,19 +6,26 @@ import serveImage from './serveImage.js'; import verifyImage from './verifyImage.js'; import sendJson from '../../helpers/sendJson.js'; -import {IMAGES_FOLDER} from '../../R.js'; +import {IMAGES_FOLDER, MAX_SESSION_TIME} from '../../R.js'; const router = new express.Router(); router.get('/init', async (req, res)=>{ - const websiteKey = req.query.k; - if (!websiteKey) { - res.end(); - return; - } + try { + const websiteKey = req.query.k; + if (!websiteKey) { + res.end(); + return; + } - const result = await initializeSession(websiteKey); - sendJson(res, result); + const session = await initializeSession(websiteKey, req.cookies); + res.cookie(websiteKey, session.sessionId, + {httpOnly: true, maxAge: MAX_SESSION_TIME * 1000}); + sendJson(res, session.serialize()); + } catch (e) { + console.error(e); + res.end(); + } }); router.get('/image', async (req, res)=>{ @@ -40,8 +47,12 @@ router.get('/image', async (req, res)=>{ router.post('/verify', async (req, res)=>{ const sessionId = req.body.s; const mat = req.body.mat; + console.log(req.body); await verifyImage(sessionId, mat); + sendJson(res, { + ok: 1, + }); }); export default router; diff --git a/src/routes/api/initializeSession.js b/src/routes/api/initializeSession.js index 2052f3a..54114d5 100644 --- a/src/routes/api/initializeSession.js +++ b/src/routes/api/initializeSession.js @@ -14,19 +14,34 @@ import {MAX_SESSION_TIME} from '../../R.js'; /** * @param {string} websiteKey - * @return {any} + * @param {Object} cookies + * @return {UserSession} */ -export default async function initializeSession(websiteKey) { - const randomSessionId = randomBytes(8); +export default async function initializeSession(websiteKey, cookies) { + let randomSessionId; + + if (cookies[websiteKey]) { + randomSessionId = cookies[websiteKey]; + + const session = new UserSession(); + session.sessionId = randomSessionId; + session.websiteKey = websiteKey; + + return session; + } else { + randomSessionId = randomBytes(8); + } + const image = await pickRandomFile(); const session = new UserSession(); session.sessionId = randomSessionId; - const userPayload = session.serialize(); + session.websiteKey = websiteKey; /** @type {IDBSession} */ const idbPayload = { sessionId: session.sessionId, + websiteKey: session.websiteKey, image, score: 0.5, }; @@ -34,5 +49,5 @@ export default async function initializeSession(websiteKey) { client.setex( session.sessionId, MAX_SESSION_TIME, JSON.stringify(idbPayload)); - return userPayload; + return session; }; diff --git a/src/routes/api/pickRandomFile.js b/src/routes/api/pickRandomFile.js index 2093ce1..96127b0 100644 --- a/src/routes/api/pickRandomFile.js +++ b/src/routes/api/pickRandomFile.js @@ -5,7 +5,7 @@ import {IMAGES_FOLDER} from '../../R.js'; * @return {Promise} Image file without extension */ export default () => { - return new Promise((reject, resolve)=>{ + return new Promise((resolve, reject)=>{ fs.readdir(IMAGES_FOLDER, (err, files)=>{ if (err) return reject(err); return resolve( diff --git a/src/routes/api/serveImage.js b/src/routes/api/serveImage.js index 90b7cd1..6aebb3d 100644 --- a/src/routes/api/serveImage.js +++ b/src/routes/api/serveImage.js @@ -11,6 +11,7 @@ import {client} from '../../helpers/idb.js'; export default function(sessionId) { return new Promise((resolve, reject)=>{ client.get(sessionId, (err, result)=>{ + // FIXME: `result` is sometimes null on a perfect query if (err) return reject(err); /** @type {IDBSession} */ diff --git a/src/routes/api/verifyImage.js b/src/routes/api/verifyImage.js index ce435ed..521949b 100644 --- a/src/routes/api/verifyImage.js +++ b/src/routes/api/verifyImage.js @@ -16,20 +16,53 @@ import pickRandomFile from './pickRandomFile.js'; * @param {string} filename * @return {FileMat} */ -function getMat(filename) { +function getMat(image) { const mats = JSON.parse( fs.readFileSync( path.join(PROJECT_ROOT, 'dev', 'mat.json'), {encoding: 'utf8'})); - return mats[filename]; + return mats[image]; +} + +/** + * Update the image mat + * @param {FileMat} imageMat + * @param {Array} userMat + * @param {string} image + */ +function updateMat(imageMat, userMat, image) { + const updatedMat = Object.assign([], imageMat.mat); + for (let i = 0; i < imageMat.mat.length; i++) { + const delta = 0.1; + if (userMat[i] === 1) { + updatedMat[i] += delta; + } else { + updatedMat[i] -= delta; + } + } + + console.log('OLD MAT: ', imageMat.mat); + console.log('NEW MAT: ', updatedMat); + + imageMat.nums += 1; + imageMat.mat = updatedMat; + + const mats = JSON.parse( + fs.readFileSync( + path.join(PROJECT_ROOT, 'dev', 'mat.json'), {encoding: 'utf8'})); + mats[image] = imageMat; + + fs.writeFileSync( + path.join(PROJECT_ROOT, 'dev', 'mat.json'), + JSON.stringify(mats, null, 2), {encoding: 'utf8'}); } /** * @param {string} sessionId - * @param {Array} mat + * @param {Array} userMat * @return {Promise} */ -export default function(sessionId, mat) { +export default function(sessionId, userMat) { return new Promise((resolve, reject)=>{ client.get(sessionId, (err, result)=>{ if (err) return reject(err); @@ -38,19 +71,29 @@ export default function(sessionId, mat) { result = JSON.parse(result); const imageMat = getMat(result.image); - const trueArgmax = argmaxThresh(imageMat.mat, 0.2).join(','); - const userArgmax = argmaxThresh(mat, 1).join(','); + const trueArgmax = argmaxThresh(imageMat.mat, 0.3).join(','); + const userArgmax = argmaxThresh(userMat, 1).join(','); + + updateMat(imageMat, userMat, result.image); + + console.log('trueArgmax', trueArgmax); + console.log('userArgmax', userArgmax); if (userArgmax !== trueArgmax) { // TODO: Decrease the score based on mat dispersion // High variance = small reduction // Low variance = high reduction result.score -= 0.2; + } else { + result.score += 0.2; } pickRandomFile().then((image)=>{ + const update = JSON.stringify( + Object.assign(result, {image: image})); + client.setex(sessionId, MAX_SESSION_TIME, - Object.assign(result, {image: image}), (err)=>{ + update, (err)=>{ if (err) return reject(err); resolve(); }); diff --git a/src/shared/models/UserSession.js b/src/shared/models/UserSession.js index 9c57671..644e18e 100644 --- a/src/shared/models/UserSession.js +++ b/src/shared/models/UserSession.js @@ -2,6 +2,7 @@ * User session model * @type {Object} Session Object. * @property {string} _sessionId The session identifier + * @property {string} _websiteKey The website id for session */ export class UserSession { /** @@ -9,7 +10,6 @@ export class UserSession { * @param {string} id */ set sessionId(id) { - if (id.length !== 8) throw Error('ClientID is not 8 characters'); this._sessionId = id; } @@ -21,24 +21,40 @@ export class UserSession { return this._sessionId; } + /** + * Set session ID + * @param {string} id + */ + set websiteKey(id) { + this._websiteKey = id; + } + + /** + * Get the session ID + * @type {string} + */ + get websiteKey() { + return this._websiteKey; + } + /** * Serialize the session into a JSON object * @return {Array} */ serialize() { - const {sessionId} = this; - if (!sessionId) { - throw Error('Unable to serialize because of missing field(s)'); + const {sessionId, websiteKey} = this; + if (!sessionId || !websiteKey) { + throw Error(); } - return [sessionId]; + return [sessionId, websiteKey]; } /** * Deserialize session data from JSON object * @param {Array} payload */ - deserialize([sessionId]) { - Object.assign(this, {sessionId}); + deserialize([sessionId, websiteKey]) { + Object.assign(this, {sessionId, websiteKey}); } };