diff --git a/common/README.md b/common/README.md index 128918c..2580640 100644 --- a/common/README.md +++ b/common/README.md @@ -44,13 +44,15 @@ server start. ## Errors ```js -const { AuthError, InputError } = require('@kansa/common/errors') +const { AuthError, InputError, NotFoundError } = require('@kansa/common/errors') ``` ### `new AuthError(message: string)` ### `new InputError(message: string)` +### `new NotFoundError(message: string)` + Handled by the server's error handling. May also have their `status` set. ## Log entries diff --git a/common/errors.js b/common/errors.js index 6c16ba0..12bd6f0 100644 --- a/common/errors.js +++ b/common/errors.js @@ -13,4 +13,12 @@ function InputError(message = 'Input error') { } InputError.prototype = new Error() -module.exports = { AuthError, InputError } +function NotFoundError(message = 'Not Found') { + this.name = 'NotFoundError' + this.message = message + this.status = 404 + this.stack = new Error().stack +} +NotFoundError.prototype = new Error() + +module.exports = { AuthError, InputError, NotFoundError } diff --git a/config/database/dev-people.sql b/config/database/dev-people.sql index 8bf2e2a..6b44d82 100644 --- a/config/database/dev-people.sql +++ b/config/database/dev-people.sql @@ -42,6 +42,7 @@ ALTER SEQUENCE member_number_seq RESTART WITH 42; CREATE FUNCTION reset_test_users() RETURNS void AS $$ BEGIN UPDATE keys SET key='key', expires=NULL WHERE email='admin@example.com'; + UPDATE keys SET key='key', expires=NULL WHERE email='site-select@example.com'; UPDATE keys SET key='key', expires='2017-08-13' WHERE email='expired@example.com'; END; $$ LANGUAGE plpgsql; diff --git a/config/kansa.yaml b/config/kansa.yaml index 1554b9f..635b95b 100644 --- a/config/kansa.yaml +++ b/config/kansa.yaml @@ -54,6 +54,9 @@ modules: # Art show management raami: false + # Site selection token management + siteselect: true + # Invite generator for a Slack organisation slack: #org: worldcon75 diff --git a/config/siteselection/ballot-data.js b/config/siteselection/ballot-data.js index bd325ce..e793ae4 100644 --- a/config/siteselection/ballot-data.js +++ b/config/siteselection/ballot-data.js @@ -1,14 +1,16 @@ -function ballotData({ - member_number, - legal_name, - email, - city, - state, - country, - badge_name, - paper_pubs, +function ballotData( + { + member_number, + legal_name, + email, + city, + state, + country, + badge_name, + paper_pubs + }, token -}) { +) { const address = (paper_pubs && paper_pubs.address.split(/[\n\r]+/)) || [''] return { info: { @@ -24,7 +26,7 @@ function ballotData({ City: city || '', Country: paper_pubs ? paper_pubs.country : country || '', 'Membership number': member_number || '........', - 'Voting token': token, + 'Voting token': token || '', 'E-mail': email, 'State/Province/Prefecture': state || '', 'Badge name': badge_name || '', diff --git a/integration-tests/test/hugo-nominations.spec.js b/integration-tests/test/hugo-nominations.spec.js index 698c873..a428f8e 100644 --- a/integration-tests/test/hugo-nominations.spec.js +++ b/integration-tests/test/hugo-nominations.spec.js @@ -10,6 +10,8 @@ const host = 'localhost:4430' const admin = request.agent(`https://${host}`, { ca }) const nominator = request.agent(`https://${host}`, { ca }) +if (!config.modules.hugo) return + const randomString = () => (Math.random().toString(36) + '0000000').slice(2, 7) describe('Hugo nominations', () => { diff --git a/integration-tests/test/siteselect.spec.js b/integration-tests/test/siteselect.spec.js new file mode 100644 index 0000000..078d5a2 --- /dev/null +++ b/integration-tests/test/siteselect.spec.js @@ -0,0 +1,89 @@ +const assert = require('assert') +const fs = require('fs') +const request = require('supertest') +const YAML = require('yaml').default + +const config = YAML.parse(fs.readFileSync('../config/kansa.yaml', 'utf8')) +const ca = fs.readFileSync('../proxy/ssl/localhost.cert', 'utf8') +const host = 'localhost:4430' +const admin = request.agent(`https://${host}`, { ca }) +const member = request.agent(`https://${host}`, { ca }) + +if (!config.modules.siteselect) return + +describe('Site selection', () => { + let id = null + before(() => { + const email = 'member@example.com' + const key = 'key' + return member + .get('/api/login') + .query({ email, key }) + .expect('set-cookie', /w75/) + .expect(200, { status: 'success', email }) + .then(() => member.get('/api/user')) + .then(res => { + id = res.body.people[0].id + assert.equal(typeof id, 'number') + }) + }) + + before(() => { + const email = 'site-select@example.com' + const key = 'key' + return admin + .get('/api/login') + .query({ email, key }) + .expect('set-cookie', /w75/) + .expect(200, { status: 'success', email }) + .then(() => admin.get('/api/user')) + .then(res => { + assert.notEqual(res.body.roles.indexOf('siteselection'), -1) + }) + }) + + it('member: get own ballot', () => + member + .get(`/api/siteselect/${id}/ballot`) + .expect(200) + .expect('Content-Type', 'application/pdf')) + + it("member: fail to get others' ballot", () => + member.get(`/api/siteselect/${id - 1}/ballot`).expect(401)) + + it('member: fail to list tokens', () => + member.get(`/api/siteselect/tokens.json`).expect(401)) + + it('member: fail to list voters', () => + member.get(`/api/siteselect/voters.json`).expect(401)) + + it('admin: get member ballot', () => + admin + .get(`/api/siteselect/${id}/ballot`) + .expect(200) + .expect('Content-Type', 'application/pdf')) + + it('admin: list tokens as JSON', () => + admin + .get(`/api/siteselect/tokens.json`) + .expect(200) + .expect(res => assert(Array.isArray(res.body)))) + + it('admin: list tokens as CSV', () => + admin + .get(`/api/siteselect/tokens.csv`) + .expect(200) + .expect('Content-Type', /text\/csv/)) + + it('admin: list voters as JSON', () => + admin + .get(`/api/siteselect/voters.json`) + .expect(200) + .expect(res => assert(Array.isArray(res.body)))) + + it('admin: list voters as CSV', () => + admin + .get(`/api/siteselect/voters.csv`) + .expect(200) + .expect('Content-Type', /text\/csv/)) +}) diff --git a/server/lib/siteselect.js b/modules/siteselect/lib/admin.js similarity index 77% rename from server/lib/siteselect.js rename to modules/siteselect/lib/admin.js index 73f681a..8662a30 100644 --- a/server/lib/siteselect.js +++ b/modules/siteselect/lib/admin.js @@ -1,28 +1,7 @@ -const randomstring = require('randomstring') -const { InputError } = require('@kansa/common/errors') - -class Siteselect { - static generateToken() { - return randomstring.generate({ - length: 6, - charset: 'ABCDEFHJKLMNPQRTUVWXY0123456789' - }) - } - - static parseToken(token) { - return ( - token && - token - .trim() - .toUpperCase() - .replace(/G/g, '6') - .replace(/I/g, '1') - .replace(/O/g, '0') - .replace(/S/g, '5') - .replace(/Z/g, '2') - ) - } +const { InputError, NotFoundError } = require('@kansa/common/errors') +const { parseToken } = require('./token') +class Admin { constructor(db) { this.db = db this.findToken = this.findToken.bind(this) @@ -33,20 +12,20 @@ class Siteselect { } findToken(req, res, next) { - const token = Siteselect.parseToken(req.params.token) - if (!token) return res.status(404).json({ error: 'not found' }) + const token = parseToken(req.params.token) + if (!token) return next(new NotFoundError()) this.db .oneOrNone(`SELECT * FROM token_lookup WHERE token=$1`, token) .then(data => { - if (data) res.json(data) - else res.status(404).json({ error: 'not found' }) + if (!data) throw new NotFoundError() + res.json(data) }) .catch(next) } findVoterTokens(req, res, next) { const { id } = req.params - if (!id) return res.status(404).json({ error: 'not found' }) + if (!id) return next(new NotFoundError()) this.db .any( ` @@ -85,7 +64,7 @@ class Siteselect { vote(req, res, next) { const { id } = req.params - const token = Siteselect.parseToken(req.body.token) + const token = parseToken(req.body.token) let { voter_name, voter_email } = req.body this.db .task(dbTask => @@ -133,4 +112,4 @@ class Siteselect { } } -module.exports = Siteselect +module.exports = Admin diff --git a/modules/siteselect/lib/ballot.js b/modules/siteselect/lib/ballot.js new file mode 100644 index 0000000..57f8a9c --- /dev/null +++ b/modules/siteselect/lib/ballot.js @@ -0,0 +1,48 @@ +const fetch = require('node-fetch') +const { AuthError, InputError } = require('@kansa/common/errors') + +// source is at /config/siteselection/ballot-data.js +const ballotData = require('/ss-ballot-data') + +class Ballot { + constructor(db) { + this.db = db + this.getBallot = this.getBallot.bind(this) + } + + getBallot(req, res, next) { + const id = parseInt(req.params.id) + if (isNaN(id) || id <= 0) + return next(new InputError('Invalid id parameter')) + const { user } = req.session + return this.db + .task(async t => { + let pq = `SELECT + member_number, legal_name, email, city, state, country, + badge_name, paper_pubs + FROM People WHERE id = $(id)` + if (!user.siteselection) pq += ` and email = $(email)` + const person = await t.oneOrNone(pq, { id, email: user.email }) + if (!person) throw new AuthError() + const token = await t.oneOrNone( + `SELECT data->>'token' AS token + FROM payments WHERE + person_id = $1 AND type = 'ss-token' AND data->>'token' IS NOT NULL + LIMIT 1`, + id, + r => r && r.token + ) + const data = ballotData(person, token) + const pdfRes = await fetch('http://tuohi:3000/ss-ballot.pdf', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }) + res.setHeader('Content-Type', 'application/pdf') + pdfRes.body.pipe(res) + }) + .catch(next) + } +} + +module.exports = Ballot diff --git a/modules/siteselect/lib/router.js b/modules/siteselect/lib/router.js new file mode 100644 index 0000000..a04dd40 --- /dev/null +++ b/modules/siteselect/lib/router.js @@ -0,0 +1,23 @@ +const express = require('express') +const { isSignedIn, hasRole } = require('@kansa/common/auth-user') +const Admin = require('./admin') +const Ballot = require('./ballot') + +module.exports = db => { + const router = express.Router() + + const ballot = new Ballot(db) + router.get('/:id/ballot', isSignedIn, ballot.getBallot) + + const admin = new Admin(db) + router.use('/tokens*', hasRole('siteselection')) + router.get('/tokens.:fmt', admin.getTokens) + router.get('/tokens/:token', admin.findToken) + + router.use('/voters*', hasRole('siteselection')) + router.get('/voters.:fmt', admin.getVoters) + router.get('/voters/:id', admin.findVoterTokens) + router.post('/voters/:id', admin.vote) + + return router +} diff --git a/modules/siteselect/lib/token.js b/modules/siteselect/lib/token.js new file mode 100644 index 0000000..d80383f --- /dev/null +++ b/modules/siteselect/lib/token.js @@ -0,0 +1,24 @@ +const randomstring = require('randomstring') + +function generateToken() { + return randomstring.generate({ + length: 6, + charset: 'ABCDEFHJKLMNPQRTUVWXY0123456789' + }) +} + +function parseToken(token) { + return ( + token && + token + .trim() + .toUpperCase() + .replace(/G/g, '6') + .replace(/I/g, '1') + .replace(/O/g, '0') + .replace(/S/g, '5') + .replace(/Z/g, '2') + ) +} + +module.exports = { generateToken, parseToken } diff --git a/modules/siteselect/package.json b/modules/siteselect/package.json new file mode 100644 index 0000000..a15ad3c --- /dev/null +++ b/modules/siteselect/package.json @@ -0,0 +1,15 @@ +{ + "name": "@kansa/siteselect", + "version": "1.0.0", + "description": "Worldcon Site Selection for Kansa", + "private": true, + "license": "Apache-2.0", + "repository": "maailma/kansa", + "main": "lib/router.js", + "peerDependencies": { + "@kansa/common": "1.x", + "express": "4.x", + "node-fetch": "*", + "randomstring": "1.x" + } +} diff --git a/server/lib/ballot.js b/server/lib/ballot.js deleted file mode 100644 index a563f5c..0000000 --- a/server/lib/ballot.js +++ /dev/null @@ -1,36 +0,0 @@ -const fetch = require('node-fetch') -const ballotData = require('/ss-ballot-data') - -class Ballot { - constructor(db) { - this.db = db - this.getBallot = this.getBallot.bind(this) - } - - getBallot(req, res, next) { - const id = parseInt(req.params.id) - this.db - .any( - ` - SELECT member_number, legal_name, email, city, state, country, badge_name, paper_pubs, m.data->>'token' as token - FROM People p JOIN Payments m ON (p.id = m.person_id) - WHERE p.id = $1 AND m.type = 'ss-token' AND m.data->>'token' IS NOT NULL`, - id - ) - .then(data => { - if (data.length === 0) throw { status: 404, message: 'Not found' } - return fetch('http://tuohi:3000/ss-ballot.pdf', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(ballotData(data[0])) - }) - }) - .then(pdfRes => { - res.setHeader('Content-Type', 'application/pdf') - pdfRes.body.pipe(res) - }) - .catch(next) - } -} - -module.exports = Ballot diff --git a/server/lib/people/router.js b/server/lib/people/router.js index 19d705e..3d38f88 100644 --- a/server/lib/people/router.js +++ b/server/lib/people/router.js @@ -2,7 +2,6 @@ const express = require('express') const { isSignedIn, hasRole, matchesId } = require('@kansa/common/auth-user') const badge = require('../badge') -const Ballot = require('../ballot') const addPerson = require('./add') const { getPerson, getPrevNames, getPersonLog } = require('./get') @@ -77,8 +76,6 @@ module.exports = (db, ctx) => { .catch(next) }) - const ballot = new Ballot(db) - router.get('/:id/ballot', ballot.getBallot) router.get('/:id/badge', badge.getBadge) router.get('/:id/barcode.:fmt', badge.getBarcode) router.post('/:id/print', hasRole('member_admin'), badge.logPrint) diff --git a/server/lib/router.js b/server/lib/router.js index 221595f..2d9f9ec 100644 --- a/server/lib/router.js +++ b/server/lib/router.js @@ -7,7 +7,6 @@ const peopleRouter = require('./people/router') const adminRouter = require('./admin/router') const getConfig = require('./get-config') const Purchase = require('./purchase') -const Siteselect = require('./siteselect') const userRouter = require('./user/router') module.exports = (db, ctx) => { @@ -44,13 +43,5 @@ module.exports = (db, ctx) => { router.use('/people', ar) router.use('/people', peopleRouter(db, ctx)) - const siteselect = new Siteselect(db) - router.use('/siteselect', hasRole('siteselection')) - router.get('/siteselect/tokens.:fmt', siteselect.getTokens) - router.get('/siteselect/tokens/:token', siteselect.findToken) - router.get('/siteselect/voters.:fmt', siteselect.getVoters) - router.get('/siteselect/voters/:id', siteselect.findVoterTokens) - router.post('/siteselect/voters/:id', siteselect.vote) - return router } diff --git a/server/lib/types/payment.js b/server/lib/types/payment.js index 8e0bb47..018e1e7 100644 --- a/server/lib/types/payment.js +++ b/server/lib/types/payment.js @@ -1,7 +1,7 @@ const Stripe = require('stripe') const config = require('@kansa/common/config') const { InputError } = require('@kansa/common/errors') -const { generateToken } = require('../siteselect') +const { generateToken } = require('./token') function checkData(shape, data) { const missing = shape diff --git a/server/lib/types/token.js b/server/lib/types/token.js new file mode 100644 index 0000000..d80383f --- /dev/null +++ b/server/lib/types/token.js @@ -0,0 +1,24 @@ +const randomstring = require('randomstring') + +function generateToken() { + return randomstring.generate({ + length: 6, + charset: 'ABCDEFHJKLMNPQRTUVWXY0123456789' + }) +} + +function parseToken(token) { + return ( + token && + token + .trim() + .toUpperCase() + .replace(/G/g, '6') + .replace(/I/g, '1') + .replace(/O/g, '0') + .replace(/S/g, '5') + .replace(/Z/g, '2') + ) +} + +module.exports = { generateToken, parseToken }