Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion common/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion common/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
1 change: 1 addition & 0 deletions config/database/dev-people.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
3 changes: 3 additions & 0 deletions config/kansa.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 13 additions & 11 deletions config/siteselection/ballot-data.js
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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 || '',
Expand Down
2 changes: 2 additions & 0 deletions integration-tests/test/hugo-nominations.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
89 changes: 89 additions & 0 deletions integration-tests/test/siteselect.spec.js
Original file line number Diff line number Diff line change
@@ -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/))
})
41 changes: 10 additions & 31 deletions server/lib/siteselect.js → modules/siteselect/lib/admin.js
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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(
`
Expand Down Expand Up @@ -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 =>
Expand Down Expand Up @@ -133,4 +112,4 @@ class Siteselect {
}
}

module.exports = Siteselect
module.exports = Admin
48 changes: 48 additions & 0 deletions modules/siteselect/lib/ballot.js
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions modules/siteselect/lib/router.js
Original file line number Diff line number Diff line change
@@ -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
}
24 changes: 24 additions & 0 deletions modules/siteselect/lib/token.js
Original file line number Diff line number Diff line change
@@ -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 }
15 changes: 15 additions & 0 deletions modules/siteselect/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading