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..78a82c9 100644 --- a/config/kansa.yaml +++ b/config/kansa.yaml @@ -42,6 +42,14 @@ modules: # Superadmin actions: get & set admin levels, mass sync actions admin: true + # Badge previews and print logging. Uses tarra, the badge-printer, which in + # turn will need to have the proper fonts included. + badge: false + + # Barcodes to help speed up registration. Uses tarra, the badge-printer, which + # in turn will need to have the proper fonts included. + barcode: false + # Nomination and voting for the Hugo Awards hugo: true @@ -54,6 +62,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/badge.spec.js b/integration-tests/test/badge.spec.js index 95862aa..35483d8 100644 --- a/integration-tests/test/badge.spec.js +++ b/integration-tests/test/badge.spec.js @@ -1,24 +1,24 @@ const assert = require('assert') const fs = require('fs') const request = require('supertest') -//const YAML = require('yaml').default +const YAML = require('yaml').default + +const config = YAML.parse(fs.readFileSync('../config/kansa.yaml', 'utf8')) +if (!config.modules.badge) return -//const config = YAML.parse(fs.readFileSync('../config/kansa.yaml', 'utf8')) const ca = fs.readFileSync('../proxy/ssl/localhost.cert', 'utf8') const host = 'localhost:4430' -let pdfType = 'application/pdf' let pngType = 'image/png' if (process.env.CI) { // Tarra requires fonts that are normally mounted from the file system, and // are not included in the build on the CI servers. So we hack around the // problem for now by expecting the responses to fail. -- Eemeli, 2018-09-09 - pdfType = 'text/html; charset=UTF-8' pngType = 'text/html; charset=UTF-8' } -describe('Badges & barcodes', () => { +describe('Badges', () => { const key = 'key' let id = null @@ -41,28 +41,16 @@ describe('Badges & barcodes', () => { it('get own badge', () => member - .get(`/api/people/${id}/badge`) + .get(`/api/badge/${id}`) .expect(200) .expect('Content-Type', pngType)) it("fail to get other's badge", () => - member.get(`/api/people/${id - 1}/badge`).expect(401)) - - it('get own barcode with id as PNG', () => - member - .get(`/api/people/${id}/barcode.png`) - .expect(200) - .expect('Content-Type', pngType)) - - it('get own barcode with id as PDF', () => - member - .get(`/api/people/${id}/barcode.pdf`) - .expect(200) - .expect('Content-Type', pdfType)) + member.get(`/api/badge/${id - 1}`).expect(401)) it('fail to log own badge as printed', () => member - .post(`/api/people/${id}/print`) + .post(`/api/badge/${id}/print`) .send() .expect(401)) }) @@ -72,24 +60,12 @@ describe('Badges & barcodes', () => { it('get blank badge', () => anonymous - .get('/api/blank-badge') + .get('/api/badge/blank') .expect(200) .expect('Content-Type', pngType)) it("fail to get member's badge", () => - anonymous.get(`/api/people/${id}/badge`).expect(401)) - - it("get member's barcode with key as PNG", () => - anonymous - .get(`/api/barcode/${key}/${id}.png`) - .expect(200) - .expect('Content-Type', pngType)) - - it("get member's barcode with key as PDF", () => - anonymous - .get(`/api/barcode/${key}/${id}.pdf`) - .expect(200) - .expect('Content-Type', pdfType)) + anonymous.get(`/api/badge/${id}`).expect(401)) }) describe('admin access', () => { @@ -109,25 +85,13 @@ describe('Badges & barcodes', () => { it("get member's badge", () => admin - .get(`/api/people/${id}/badge`) + .get(`/api/badge/${id}`) .expect(200) .expect('Content-Type', pngType)) - it("get member's barcode with id as PNG", () => - admin - .get(`/api/people/${id}/barcode.png`) - .expect(200) - .expect('Content-Type', pngType)) - - it("get member's barcode with id as PDF", () => - admin - .get(`/api/people/${id}/barcode.pdf`) - .expect(200) - .expect('Content-Type', pdfType)) - it("log the member's badge as printed", () => admin - .post(`/api/people/${id}/print`) + .post(`/api/badge/${id}/print`) .send() .expect(200) .expect(res => assert.equal(res.body.status, 'success'))) diff --git a/integration-tests/test/barcode.spec.js b/integration-tests/test/barcode.spec.js new file mode 100644 index 0000000..6ec79c7 --- /dev/null +++ b/integration-tests/test/barcode.spec.js @@ -0,0 +1,109 @@ +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')) +if (!config.modules.barcode) return + +const ca = fs.readFileSync('../proxy/ssl/localhost.cert', 'utf8') +const host = 'localhost:4430' + +let pdfType = 'application/pdf' +let pngType = 'image/png' + +if (process.env.CI) { + // Tarra requires fonts that are normally mounted from the file system, and + // are not included in the build on the CI servers. So we hack around the + // problem for now by expecting the responses to fail. -- Eemeli, 2018-09-09 + pdfType = 'text/html; charset=UTF-8' + pngType = 'text/html; charset=UTF-8' +} + +describe('Barcodes', () => { + const key = 'key' + let id = null + + describe('member access', () => { + const member = request.agent(`https://${host}`, { ca }) + + before(() => { + const email = 'member@example.com' + 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') + }) + }) + + it('get own barcode with id as PNG', () => + member + .get(`/api/barcode/${id}.png`) + .expect(200) + .expect('Content-Type', pngType)) + + it('get own barcode with id as PDF', () => + member + .get(`/api/barcode/${id}.pdf`) + .expect(200) + .expect('Content-Type', pdfType)) + + it("fail to get other's barcode", () => + member.get(`/api/barcode/${id - 1}.png`).expect(401)) + + it('fail to get own barcode with bad key', () => + member.get(`/api/barcode/${key + 'x'}/${id}.png`).expect(401)) + }) + + describe('anonymous access', () => { + const anonymous = request.agent(`https://${host}`, { ca }) + + it("get member's barcode with key as PNG", () => + anonymous + .get(`/api/barcode/${key}/${id}.png`) + .expect(200) + .expect('Content-Type', pngType)) + + it("get member's barcode with key as PDF", () => + anonymous + .get(`/api/barcode/${key}/${id}.pdf`) + .expect(200) + .expect('Content-Type', pdfType)) + + it('fail to get barcode with bad key', () => + anonymous.get(`/api/barcode/${key + 'x'}/${id}.png`).expect(401)) + }) + + describe('admin access', () => { + const admin = request.agent(`https://${host}`, { ca }) + before(() => { + const email = 'admin@example.com' + 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('member_admin'), -1) + }) + }) + + it("get member's barcode with id as PNG", () => + admin + .get(`/api/barcode/${id}.png`) + .expect(200) + .expect('Content-Type', pngType)) + + it("get member's barcode with id as PDF", () => + admin + .get(`/api/barcode/${id}.pdf`) + .expect(200) + .expect('Content-Type', pdfType)) + }) +}) 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/modules/badge/lib/badge.js b/modules/badge/lib/badge.js new file mode 100644 index 0000000..53b67a8 --- /dev/null +++ b/modules/badge/lib/badge.js @@ -0,0 +1,102 @@ +const fetch = require('node-fetch') +const { matchesId } = require('@kansa/common/auth-user') +const config = require('@kansa/common/config') +const { InputError } = require('@kansa/common/errors') +const splitName = require('@kansa/common/split-name') + +const fetchBadge = ({ member_number, membership, names, subtitle }) => + fetch('http://tarra/label.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + format: 'png', + labeldata: [ + { + id: String(member_number) || 'number', + Class: membership || '', + FirstName: names[0], + Surname: names[1], + Info: subtitle || '' + } + ], + ltype: 'web' + }) + }) + +class Badge { + constructor(db) { + this.db = db + this.getBadge = this.getBadge.bind(this) + this.getBlank = this.getBlank.bind(this) + this.logPrint = this.logPrint.bind(this) + } + + getBlank(req, res, next) { + const { name, subtitle } = req.query + return fetchBadge({ names: splitName(name || ''), subtitle }) + .then(({ body, headers }) => { + res.setHeader( + 'Content-Disposition', + `inline; filename="${config.id}-badge.png"` + ) + res.setHeader('Content-Type', headers.get('content-type')) + res.setHeader('Content-Length', headers.get('content-length')) + body.pipe(res) + }) + .catch(next) + } + + getBadge(req, res, next) { + this.db + .task(async ts => { + const id = await matchesId(ts, req, ['member_admin', 'member_list']) + const data = await ts.oneOrNone( + `SELECT p.id, p.member_number, membership, + get_badge_name(p) AS name, + get_badge_subtitle(p) AS subtitle + FROM people p LEFT JOIN membership_types m USING (membership) + WHERE id = $1 AND m.badge = true`, + id + ) + if (!data) + throw new InputError('This member type is not eligible for a badge') + return data + }) + .then(async ({ id, member_number, membership, name, subtitle }) => { + const { body, headers } = await fetchBadge({ + member_number, + membership, + names: splitName(req.query.name || name || ''), + subtitle: req.query.subtitle || subtitle + }) + res.setHeader( + 'Content-Disposition', + `inline; filename="${config.id}-badge-${id}.png"` + ) + res.setHeader('Content-Type', headers.get('content-type')) + res.setHeader('Content-Length', headers.get('content-length')) + body.pipe(res) + }) + .catch(next) + } + + logPrint(req, res, next) { + const id = parseInt(req.params.id) + if (isNaN(id) || id < 0) return next(new InputError('Bad id number')) + this.db + .one( + `INSERT INTO badge_and_daypass_prints + (person, membership, member_number, daypass) + ( + SELECT p.id, p.membership, p.member_number, d.id + FROM people p LEFT JOIN daypasses d ON (p.id = d.person_id) + WHERE p.id = $1 + ) RETURNING timestamp`, + id + ) + .then(({ timestamp }) => res.json({ status: 'success', timestamp })) + .catch(next) + } +} + +module.exports = Badge diff --git a/modules/badge/lib/router.js b/modules/badge/lib/router.js new file mode 100644 index 0000000..23adbea --- /dev/null +++ b/modules/badge/lib/router.js @@ -0,0 +1,12 @@ +const express = require('express') +const { isSignedIn, hasRole } = require('@kansa/common/auth-user') +const Badge = require('./badge') + +module.exports = db => { + const badge = new Badge(db) + const router = express.Router() + router.get('/blank', badge.getBlank) + router.get('/:id', isSignedIn, badge.getBadge) + router.post('/:id/print', hasRole('member_admin'), badge.logPrint) + return router +} diff --git a/modules/badge/package.json b/modules/badge/package.json new file mode 100644 index 0000000..9d36872 --- /dev/null +++ b/modules/badge/package.json @@ -0,0 +1,14 @@ +{ + "name": "@kansa/badge", + "version": "1.0.0", + "description": "Badge generator 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": "*" + } +} diff --git a/modules/barcode/lib/barcode.js b/modules/barcode/lib/barcode.js new file mode 100644 index 0000000..fbeb16f --- /dev/null +++ b/modules/barcode/lib/barcode.js @@ -0,0 +1,53 @@ +const fetch = require('node-fetch') +const splitName = require('@kansa/common/split-name') + +module.exports = { getBarcodeId, getBarcodeData, fetchBarcode } + +function getBarcodeId(membership, member_number, id) { + const ch = membership.charAt(0) + const num = member_number || `i${id}` + return `${ch}-${num}` +} + +function getBarcodeData(db, id, key) { + return db.oneOrNone( + `SELECT p.id, member_number, membership, + get_badge_name(p) AS name, get_badge_subtitle(p) AS subtitle, + d.status AS daypass, daypass_days(d) AS days + FROM people p + JOIN keys k USING (email) + LEFT JOIN membership_types m USING (membership) + LEFT JOIN daypasses d ON (p.id = d.person_id) + WHERE p.id=$(id) AND + (m.badge = true OR d.status IS NOT NULL) + ${key ? 'AND key=$(key)' : ''}`, + { id, key } + ) +} + +function fetchBarcode( + dayNames, + format, + { daypass, days, id, member_number, membership, name, subtitle } +) { + const [FirstName, Surname] = splitName(name || '') + const Info = daypass + ? 'Daypass ' + dayNames.filter((_, i) => days[i]).join('/') + : subtitle || '' + return fetch('http://tarra/label.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + format, + labeldata: [ + { + id: getBarcodeId(membership, member_number, id), + FirstName, + Surname, + Info + } + ], + ltype: 'mail' + }) + }) +} diff --git a/modules/barcode/lib/router.js b/modules/barcode/lib/router.js new file mode 100644 index 0000000..43b3e17 --- /dev/null +++ b/modules/barcode/lib/router.js @@ -0,0 +1,41 @@ +const express = require('express') +const { isSignedIn, matchesId } = require('@kansa/common/auth-user') +const config = require('@kansa/common/config') +const { AuthError, InputError } = require('@kansa/common/errors') +const { getBarcodeData, fetchBarcode } = require('./barcode') + +// Used to form the subtitle for daypass holders +const dayNames = ['Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + +const getBarcode = db => (req, res, next) => { + const id = parseInt(req.params.id) + if (isNaN(id) || id < 0) return next(new InputError('Bad id number')) + const key = req.params.key + const format = req.params.fmt + if (format !== 'pdf' && format !== 'png') + return next(new InputError('Format must be either pdf or png')) + db.task(async ts => { + if (!key) await matchesId(ts, req, 'member_admin') + return getBarcodeData(ts, id, key) + }) + .then(async data => { + if (!data) throw new AuthError() + const { body, headers } = await fetchBarcode(dayNames, format, data) + res.setHeader( + 'Content-Disposition', + `inline; filename="${config.id}-barcode-${id}.${format}"` + ) + res.setHeader('Content-Type', headers.get('content-type')) + res.setHeader('Content-Length', headers.get('content-length')) + body.pipe(res) + }) + .catch(next) +} + +module.exports = db => { + const gb = getBarcode(db) + const router = express.Router() + router.get('/:key/:id.:fmt', gb) + router.get('/:id.:fmt', isSignedIn, gb) + return router +} diff --git a/modules/barcode/package.json b/modules/barcode/package.json new file mode 100644 index 0000000..de83a47 --- /dev/null +++ b/modules/barcode/package.json @@ -0,0 +1,14 @@ +{ + "name": "@kansa/barcode", + "version": "1.0.0", + "description": "Barcode generator 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": "*" + } +} 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/badge.js b/server/lib/badge.js deleted file mode 100644 index fd6e18a..0000000 --- a/server/lib/badge.js +++ /dev/null @@ -1,128 +0,0 @@ -const fetch = require('node-fetch') -const config = require('@kansa/common/config') -const { AuthError } = require('@kansa/common/errors') -const splitName = require('@kansa/common/split-name') - -module.exports = { getBadge, getBarcode, logPrint } - -function getBadge(req, res, next) { - const id = parseInt(req.params.id || '0') - req.app.locals.db - .oneOrNone( - `SELECT - p.member_number, membership, - get_badge_name(p) AS name, get_badge_subtitle(p) AS subtitle - FROM people p - LEFT JOIN membership_types m USING (membership) - WHERE id = $1 AND m.badge = true`, - id - ) - .then(data => { - const { member_number, membership, name, subtitle } = data || {} - const [FirstName, Surname] = splitName(req.query.name || name || '') - return fetch('http://tarra/label.php', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - format: 'png', - labeldata: [ - { - id: String(member_number) || 'number', - Class: membership, - FirstName, - Surname, - Info: req.query.subtitle || subtitle || '' - } - ], - ltype: 'web' - }) - }).then(({ body, headers }) => { - res.setHeader( - 'Content-Disposition', - `inline; filename="${config.id}-badge-${id}.png"` - ) - res.setHeader('Content-Type', headers.get('content-type')) - res.setHeader('Content-Length', headers.get('content-length')) - body.pipe(res) - }) - }) - .catch(next) -} - -function getBarcode(req, res, next) { - const id = parseInt(req.params.id) - const key = req.params.key - const format = req.params.fmt === 'pdf' ? 'pdf' : 'png' - req.app.locals.db - .one( - ` - SELECT member_number, membership, - get_badge_name(p) AS name, get_badge_subtitle(p) AS subtitle, - d.status AS daypass, daypass_days(d) AS days - FROM people p - JOIN keys k USING (email) - LEFT JOIN membership_types m USING (membership) - LEFT JOIN daypasses d ON (p.id = d.person_id) - WHERE p.id=$(id) AND - (m.badge = true OR d.status IS NOT NULL) - ${key ? 'AND key=$(key)' : ''}`, - { id, key } - ) - .then(data => { - const { daypass, days, member_number, membership, name, subtitle } = data - const code = membership.charAt(0) + '-' + (member_number || `i${id}`) - const [FirstName, Surname] = splitName(name || '') - const Info = daypass - ? 'Daypass ' + - ['Wed', 'Thu', 'Fri', 'Sat', 'Sun'] - .filter((_, i) => days[i]) - .join('/') - : subtitle || '' - return fetch('http://tarra/label.php', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - format, - labeldata: [ - { - id: code, - FirstName, - Surname, - Info - } - ], - ltype: 'mail' - }) - }).then(({ body, headers }) => { - res.setHeader( - 'Content-Disposition', - `inline; filename="${config.id}-barcode-${id}.${format}"` - ) - res.setHeader('Content-Type', headers.get('content-type')) - res.setHeader('Content-Length', headers.get('content-length')) - body.pipe(res) - }) - }) - .catch(error => { - if (error.message === 'No data returned from the query.') { - error = new AuthError() - } - next(error) - }) -} - -function logPrint(req, res, next) { - req.app.locals.db - .one( - `INSERT INTO badge_and_daypass_prints - (person, membership, member_number, daypass) - ( - SELECT p.id, p.membership, p.member_number, d.id - FROM people p LEFT JOIN daypasses d ON (p.id = d.person_id) - WHERE p.id = $1 - ) RETURNING timestamp`, - parseInt(req.params.id) - ) - .then(({ timestamp }) => res.json({ status: 'success', timestamp })) - .catch(next) -} 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..2c3297e 100644 --- a/server/lib/people/router.js +++ b/server/lib/people/router.js @@ -1,9 +1,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') const lookupPerson = require('./lookup') @@ -77,11 +74,5 @@ 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) - return router } diff --git a/server/lib/router.js b/server/lib/router.js index 221595f..2dffa5b 100644 --- a/server/lib/router.js +++ b/server/lib/router.js @@ -1,13 +1,10 @@ -const cors = require('cors') const express = require('express') const { isSignedIn, hasRole } = require('@kansa/common/auth-user') -const badge = require('./badge') -const peopleRouter = require('./people/router') const adminRouter = require('./admin/router') const getConfig = require('./get-config') +const peopleRouter = require('./people/router') const Purchase = require('./purchase') -const Siteselect = require('./siteselect') const userRouter = require('./user/router') module.exports = (db, ctx) => { @@ -21,9 +18,6 @@ module.exports = (db, ctx) => { router.use(userRouter(db, ctx)) - router.get('/barcode/:key/:id.:fmt', badge.getBarcode) - router.get('/blank-badge', badge.getBadge) - const purchase = new Purchase(db) router.post('/purchase', purchase.makeMembershipPurchase) router.get('/purchase/data', purchase.getPurchaseData) @@ -44,13 +38,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 }