diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..c2ef8f4
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,2 @@
+**/node_modules/
+**/package-lock.json
diff --git a/.gitignore b/.gitignore
index 9a0d9fe..f9abeed 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
.*
config/docker-compose.prod.yaml
config/docker-compose.prod.yml
+kansa-common*.tgz
node_modules/
package-lock.json
diff --git a/.travis.yml b/.travis.yml
index 7e99ca3..a7a7727 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -8,6 +8,7 @@ node_js:
- 'node'
before_install:
+ - mv common/$(cd common && npm pack) server/kansa-common.tgz
- docker-compose
-f config/docker-compose.base.yaml
-f config/docker-compose.dev.yaml
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..e96de29
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,24 @@
+FROM node:10
+
+ARG NODE_ENV
+ENV NODE_ENV $NODE_ENV
+
+WORKDIR /kansa
+COPY server/package.json .
+COPY common/package.json ./common/
+RUN npm install && \
+ npm install ./common && \
+ npm cache clean --force
+
+COPY modules/raami/package.json modules/raami/
+RUN cd modules/raami && npm install && npm cache clean --force
+
+COPY modules/slack/package.json modules/slack/
+RUN cd modules/slack && npm install && npm cache clean --force
+
+COPY common ./common
+COPY modules ./modules
+COPY server .
+
+EXPOSE 80
+CMD [ "npm", "start" ]
diff --git a/README.md b/README.md
index 4ce60ec..5327d70 100644
--- a/README.md
+++ b/README.md
@@ -5,24 +5,30 @@
Kansa
-Kansa is a convention member management system originally developed for [Worldcon 75](http://www.worldcon.fi),
+Kansa is a convention member management system originally developed for [Worldcon 75],
the World Science Fiction Convention organised in Helsinki in 2017. It is also used by
-[Dublin 2019: An Irish Worldcon](https://dublin2019.com/).
+[Dublin 2019: An Irish Worldcon] and [CoNZealand], the 2020 Worldcon.
-The system is modular and extensible. Together with its
-[front-end client](https://github.com/maailma/kansa-client) it provides the following services:
+The system is modular and extensible. Together with its [front-end client] it provides
+the following services:
- Member admin services, including an easy-to use admin front-end
- Support for multiple membership types, as well as for non-member accounts
-- [Stripe](https://stripe.com/) integration for membership and other purchases (via credit cards or
+- [Stripe] integration for membership and other purchases (via credit cards or
SEPA direct debit)
- Individual and bulk import of member data and transactions from other systems
- Member-facing front-end for e.g. name and address changes
- Passwordless authentication using login links sent by email
-- Emails sent using [Sendgrid](https://sendgrid.com/) and customisable
- [templates](config/message-templates/)
+- Emails sent using [Sendgrid] and customisable [templates](config/message-templates/)
- Synchronisation of contact info to Sendgrid for mass mailings
+[worldcon 75]: http://www.worldcon.fi
+[dublin 2019: an irish worldcon]: https://dublin2019.com/
+[conzealand]: https://conzealand.nz/
+[front-end client]: https://github.com/maailma/kansa-client
+[stripe]: https://stripe.com/
+[sendgrid]: https://sendgrid.com/
+
To help with at-con registration, Kansa has:
- Pre-con badge preview and customisation (with Unicode support)
@@ -101,21 +107,26 @@ Email messages are based on message templates, which are
### Directory Overview
-- **`config`** - System configuration
-- **`hugo`** - Provides the Hugo Nominations and Awards parts of the [REST API](docs/index.md)
-- **`integration-tests`** - Tests for the REST API, targeting the Stripe and Sendgrid interfaces in particular
-- **`kansa`** - Provides the core parts of the [REST API](docs/index.md)
-- **`kyyhky`** - Internal mailing service & [SendGrid](https://sendgrid.com/) integration for hugo & kansa
-- **`nginx`** - An SSL-terminating reverse proxy & file server, using [OpenResty](https://openresty.org/)
+- **`common`** - Shared utilities for the server & modules, published on npm as [`@kansa/common`][kc]
+- **`config`** - System configuration, see in particular [`config/kansa.yaml`](config/kansa.yaml)
+- **`integration-tests`** - Tests for the REST API endpoints
+- **`kyyhky`** - Internal mailing service & [SendGrid] integration for hugo & kansa
+- **`modules`** - Optional server modules providing additional functionality
- **`postgres`** - Configuration & schemas for our database
-- **`raami`** - Art show management [REST API](docs/raami.md)
+- **`proxy`** - An SSL-terminating reverse proxy & file server, using [OpenResty]
+- **`server`** - Provides the core parts of the [REST API](docs/index.md)
- **`tools`** - Semi-automated tools for importing data, and for other tasks
- **`tuohi`** - Fills out a PDF form, for `GET /people/:id/ballot`
-[Kansa](https://en.wiktionary.org/wiki/kansa#Finnish) is Finnish for "people" or "tribe", and it's
-the name for our member registry. The [Hugo Awards](http://www.thehugoawards.org/) are awards that
-are nominated and selected by the members of each year's Worldcon. Kyyhky is Finnish for "pigeon",
-Raami is "frame", and Tuohi is the bark of a birch tree.
+The [Hugo Awards] are awards that are nominated and selected by the members of each year's Worldcon.
+[Kansa] is Finnish for "people" or "tribe", Kyyhky is "pigeon", Raami is "frame", and Tuohi is the
+bark of a birch tree.
+
+[kc]: https://www.npmjs.com/package/@kansa/common
+[sendgrid]: https://sendgrid.com/
+[openresty]: https://openresty.org/
+[hugo awards]: http://www.thehugoawards.org/
+[kansa]: https://en.wiktionary.org/wiki/kansa#Finnish
### Common Issues
@@ -125,7 +136,7 @@ The particular places that may need manual adjustment are:
[self-signed certificate](http://www.selfsignedcertificate.com/) for `localhost`. This will not
be automatically accepted by browsers or other clients. If you have a signed certificate you can
use (and therefore a publicly visible address), you'll want to add the certificate files to
- `nginx/ssl/` and adjust the environment values set for the `nginx` service in
+ `proxy/ssl/` and adjust the environment values set for the `proxy` service in
[docker-compose.override.yaml](config/docker-compose.override.yaml) and/or your
`docker-compose.prod.yaml`.
@@ -134,7 +145,7 @@ The particular places that may need manual adjustment are:
default, the value should match the `http://localhost:8080` address of the client Webpack dev
servers.
-- If you're running the server on a separate machine or if you've changed the `nginx` port
+- If you're running the server on a separate machine or if you've changed the `proxy` port
configuration, you may need to tell clients where to find the server, using something like
`export API_HOST='remote.example.com'` before running `npm start`.
diff --git a/common/README.md b/common/README.md
new file mode 100644
index 0000000..128918c
--- /dev/null
+++ b/common/README.md
@@ -0,0 +1,123 @@
+# @kansa/common
+
+Common objects and utilities shared across the
+[Kansa](https://github.com/maailma/kansa) server & modules.
+
+## User authentication
+
+```js
+const { isSignedIn, hasRole, matchesId } = require('@kansa/common/auth-user')
+```
+
+### `function isSignedIn(req, res, next)`
+
+Express.js middleware function that verifies that the current user is signed in.
+On failure, calls `next(new AuthError())`.
+
+### `function hasRole(role: string | string[]): function(req, res, next)`
+
+Function returning an Express.js middleware function that verifies that the
+current user is signed in and has the specified `role` (or one of the roles, if
+an array). On failure, calls `next(new AuthError())`.
+
+### `function matchesId(db, req, role?: string | string[]): Promise`
+
+Verifies that the `id` parameter of the request `req` grants access to the
+session user's `email`. If set, `role` may define one or multiple roles that the
+user may have that grant access without a matching `email`.
+
+`db` should be a `pg-promise` instance, or e.g. a `task` extended from such an
+instance. Returns a promise that either resolves to the `id` value, or rejects
+with either `AuthError`, `InputError`, or a database error.
+
+### `new AuthError(message: string)`
+
+## Configuration
+
+```js
+const config = require('@kansa/common/config')
+```
+
+The server configuration, sourced from `config/kansa.yaml` and read during
+server start.
+
+## Errors
+
+```js
+const { AuthError, InputError } = require('@kansa/common/errors')
+```
+
+### `new AuthError(message: string)`
+
+### `new InputError(message: string)`
+
+Handled by the server's error handling. May also have their `status` set.
+
+## Log entries
+
+```js
+const LogEntry = require('@kansa/common/log-entry')
+new LogEntry(req, 'Log message').write(db)
+```
+
+### `new LogEntry(req, message: string)`
+
+Creates a new log entry for tracking changes. The entry's fields such as
+`author`, `description` and `subject` are modifiable before the entry gets
+stored with its `write(db): Promise` method.
+
+## Mail
+
+### `function sendMail(type: string, data: any, delay?: number): Promise`
+
+Schedule an email message of `type` with `data` to be sent, with an optional
+`delay` in minutes. The message should have a correspondingly named template
+available under `config/message-templates/`.
+
+### `function updateMailRecipient(db, email: string): Promise`
+
+Using the `pg-promise` instance `db`, fetch the appropriate data for `email`
+and forward it to be sent to Sendgrid. Returns a promise that will never reject.
+
+## Split names
+
+```js
+const splitName = require('@kansa/common/split-name')
+
+splitName('Bob Tucker')
+// ['', 'Bob Tucker']
+
+splitName('Bob Tucker', 8)
+// ['Bob', 'Tucker']
+
+splitName('Arthur Wilson "Bob" Tucker')
+// [ 'Arthur Wilson', '"Bob" Tucker' ]
+```
+
+### `function splitName(name: string, maxLength = 14): [string, string]`
+
+Splits a name (or other string) prettily on two lines.
+
+If the name already includes newlines, the first will be used to split the
+string and the others replaced with spaces. If the name is at most
+`maxLength` characters, it'll be returned as `['', name]`. Otherwise, we
+find the most balanced white space to use as a split point.
+
+## Trueish
+
+```js
+const isTrueish = require('@kansa/common/trueish')
+
+isTrueish() // false
+isTrueish('0') // false
+isTrueish(' False') // false
+isTrueish(-1) // true
+```
+
+### `function isTrueish(v: any): boolean`
+
+Casts input into boolean values. In addition to normal JavaScript casting
+rules, also trims and lower-cases strings before comparing them to the
+following: `''`, `'0'`, `'false'`, `'null'`. Matches to these result in a
+`false` value. The primary intent is to enable human-friendly handling of query
+parameter values.
diff --git a/common/auth-user.js b/common/auth-user.js
new file mode 100644
index 0000000..1c6aed6
--- /dev/null
+++ b/common/auth-user.js
@@ -0,0 +1,39 @@
+const { AuthError, InputError } = require('./errors')
+
+module.exports = { isSignedIn, hasRole, matchesId }
+
+function isSignedIn(req, res, next) {
+ const { user } = req.session
+ if (user && user.email) next()
+ else next(new AuthError())
+}
+
+function hasRole(role) {
+ return function _hasRole(req, res, next) {
+ const { user } = req.session
+ if (user && user.email) {
+ if (Array.isArray(role)) {
+ if (role.some(r => user[r])) return next()
+ } else {
+ if (user[role]) return next()
+ }
+ }
+ next(new AuthError())
+ }
+}
+
+function matchesId(db, req, role) {
+ const id = parseInt(req.params.id)
+ const { user } = req.session
+ if (isNaN(id) || id < 0)
+ return Promise.reject(new InputError('Bad id number'))
+ if (!user || !user.email) return Promise.reject(new AuthError())
+ if ((Array.isArray(role) && role.some(r => user[r])) || (role && user[role]))
+ return Promise.resolve(id)
+ return db
+ .oneOrNone('SELECT email FROM people WHERE id = $1', id)
+ .then(data => {
+ if (data && user.email === data.email) return id
+ throw new AuthError()
+ })
+}
diff --git a/kansa/lib/config.js b/common/config.js
similarity index 86%
rename from kansa/lib/config.js
rename to common/config.js
index 03eb758..22132c3 100644
--- a/kansa/lib/config.js
+++ b/common/config.js
@@ -10,12 +10,14 @@ const shape = {
name: 'string',
paid_paper_pubs: 'boolean',
auth: {
+ admin_roles: ['string'],
key_timeout: {
admin: 'duration',
normal: 'duration'
},
session_timeout: 'duration'
- }
+ },
+ modules: 'object'
}
const getIn = (config, path) => {
@@ -40,6 +42,15 @@ function parseConfig(config, path, shape) {
`Expected value for '${key}' to match regular expression ${shape}`
)
}
+ } else if (Array.isArray(shape)) {
+ if (!Array.isArray(value)) {
+ throw new Error(
+ `Expected array value for '${key}', but found ${typeof value}`
+ )
+ }
+ value.forEach((v, i) => {
+ parseConfig(config, path.concat(i), shape[0])
+ })
} else if (typeof shape === 'object') {
if (typeof value !== 'object') {
throw new Error(
diff --git a/hugo/lib/errors.js b/common/errors.js
similarity index 100%
rename from hugo/lib/errors.js
rename to common/errors.js
diff --git a/common/log-entry.js b/common/log-entry.js
new file mode 100644
index 0000000..4f7bdf1
--- /dev/null
+++ b/common/log-entry.js
@@ -0,0 +1,39 @@
+const logFields = [
+ // id SERIAL PRIMARY KEY
+ 'timestamp', // timestamptz NOT NULL DEFAULT now()
+ 'client_ip', // text NOT NULL
+ 'client_ua', // text
+ 'author', // text
+ 'subject', // integer REFERENCES People
+ 'action', // text NOT NULL
+ 'parameters', // jsonb NOT NULL
+ 'description' // text NOT NULL
+]
+
+class LogEntry {
+ constructor(req, desc = '') {
+ const { user } = req.session
+ this.timestamp = null
+ if (user && user.member_admin && req.body && req.body.timestamp) {
+ const ts = new Date(req.body.timestamp)
+ if (ts > 0) this.timestamp = ts.toISOString()
+ }
+ this.client_ip = req.ip
+ this.client_ua = req.headers['user-agent'] || null
+ this.author = (user && user.email) || null
+ this.subject = null
+ this.action = req.method + ' ' + req.baseUrl + req.path
+ this.parameters = Object.assign({}, req.query, req.body)
+ delete this.parameters.key
+ this.description = desc
+ }
+
+ write(db) {
+ const fields = logFields.filter(fn => this[fn] !== null)
+ const values = fields.map(fn => `$(${fn})`).join(', ')
+ const sqlValues = `(${fields.join(', ')}) VALUES(${values})`
+ return db.none(`INSERT INTO log ${sqlValues}`, this)
+ }
+}
+
+module.exports = LogEntry
diff --git a/kansa/lib/mail.js b/common/mail.js
similarity index 76%
rename from kansa/lib/mail.js
rename to common/mail.js
index 9f27461..4f19e15 100644
--- a/kansa/lib/mail.js
+++ b/common/mail.js
@@ -1,12 +1,12 @@
const fetch = require('node-fetch')
module.exports = {
- mailTask,
+ sendMail,
setAllMailRecipients,
updateMailRecipient
}
-function mailTask(type, data, delay) {
+function sendMail(type, data, delay) {
let url = `http://kyyhky/email/${type}`
if (delay) url += `?delay=${Number(delay)}` // in minutes
return fetch(url, {
@@ -65,21 +65,17 @@ function rxUpdateTask(recipients) {
})
}
-function setAllMailRecipients(req, res, next) {
- req.app.locals.db
- .any(`${mailRecipient.selector} ORDER BY email`)
- .then(res => {
- const er = res.reduce((set, r) => {
- if (set[r.email]) set[r.email].push(r)
- else set[r.email] = [r]
- return set
- }, {})
- const emails = Object.keys(er)
- const recipients = emails.map(email => mailRecipient(email, er[email]))
- return rxUpdateTask(recipients).then(() => emails.length)
- })
- .then(count => res.json({ success: true, count }))
- .catch(next)
+function setAllMailRecipients(db) {
+ return db.any(`${mailRecipient.selector} ORDER BY email`).then(res => {
+ const er = res.reduce((set, r) => {
+ if (set[r.email]) set[r.email].push(r)
+ else set[r.email] = [r]
+ return set
+ }, {})
+ const emails = Object.keys(er)
+ const recipients = emails.map(email => mailRecipient(email, er[email]))
+ return rxUpdateTask(recipients).then(() => emails.length)
+ })
}
function updateMailRecipient(db, email) {
diff --git a/common/package.json b/common/package.json
new file mode 100644
index 0000000..c835028
--- /dev/null
+++ b/common/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@kansa/common",
+ "version": "1.1.0",
+ "description": "Common objects and utilities shared across the Kansa server & modules",
+ "license": "Apache-2.0",
+ "repository": "maailma/kansa",
+ "dependencies": {
+ "node-fetch": "^2.2.0",
+ "timestring": "^5.0.1",
+ "yaml": "^1.0.0-rc.8"
+ }
+}
diff --git a/common/split-name.js b/common/split-name.js
new file mode 100644
index 0000000..d00cc92
--- /dev/null
+++ b/common/split-name.js
@@ -0,0 +1,33 @@
+const TITLE_MAX_LENGTH = 14
+
+module.exports = splitName
+
+function splitName(name, maxLength = TITLE_MAX_LENGTH) {
+ name = name.trim()
+ if (name.indexOf('\n') !== -1) {
+ const nm = name.match(/(.*)\s+([\s\S]*)/)
+ const n0 = nm[1].trim()
+ const n1 = nm[2].trim().replace(/\s+/g, ' ')
+ return [n0, n1]
+ } else if (name.length <= maxLength) {
+ return ['', name]
+ } else {
+ const na = name.split(/\s+/)
+ let n0 = na.shift() || ''
+ let n1 = na.pop() || ''
+ while (na.length) {
+ const p0 = na.shift()
+ const p1 = na.pop()
+ if (p1 && n0.length + p0.length > n1.length + p1.length) {
+ n1 = p1 + ' ' + n1
+ na.unshift(p0)
+ } else if (!p1 && n0.length + p0.length > n1.length + p0.length) {
+ n1 = p0 + ' ' + n1
+ } else {
+ n0 = n0 + ' ' + p0
+ if (p1) na.push(p1)
+ }
+ }
+ return [n0, n1]
+ }
+}
diff --git a/common/trueish.js b/common/trueish.js
new file mode 100644
index 0000000..a9667e2
--- /dev/null
+++ b/common/trueish.js
@@ -0,0 +1,8 @@
+module.exports = function isTrueish(v) {
+ if (!v) return false
+ if (typeof v === 'boolean') return v
+ const s = String(v)
+ .trim()
+ .toLowerCase()
+ return s !== '' && s !== '0' && s !== 'false' && s !== 'null'
+}
diff --git a/config/database/README.md b/config/database/README.md
index d5a3d02..02b673b 100644
--- a/config/database/README.md
+++ b/config/database/README.md
@@ -14,7 +14,7 @@ Some of the configuration is required, and run at specific points during the ini
Defines the types of memberships that are supported, and their attributes.
-### `31-hugo-categories.sql`
+### `hugo-categories.sql`
Defines the Hugo Awards categories. The order of values defines their listing order, so use something like this if adding categories:
diff --git a/config/database/31-hugo-categories.sql b/config/database/hugo-categories.sql
similarity index 100%
rename from config/database/31-hugo-categories.sql
rename to config/database/hugo-categories.sql
diff --git a/config/docker-compose.base.yaml b/config/docker-compose.base.yaml
index be6d091..92c3116 100644
--- a/config/docker-compose.base.yaml
+++ b/config/docker-compose.base.yaml
@@ -9,26 +9,20 @@ volumes:
pgdata: null
services:
- nginx:
- build: ../nginx
+ proxy:
+ build: ../proxy
+ image: kansa-proxy
links:
- - hugo
- - kansa
- - raami
- entrypoint: /bin/bash -c "envsubst '$$SERVER_NAME $$SSL_CERTIFICATE $$SSL_CERTIFICATE_KEY' < /nginx.conf.template > /usr/local/openresty/nginx/conf/nginx.conf && /usr/local/openresty/bin/openresty -g 'daemon off;'"
+ - server
+ entrypoint: >
+ /bin/bash -c
+ "envsubst '$$SERVER_NAME $$SSL_CERTIFICATE $$SSL_CERTIFICATE_KEY' < /nginx.conf.template
+ > /usr/local/openresty/nginx/conf/nginx.conf &&
+ /usr/local/openresty/bin/openresty -g 'daemon off;'"
- hugo:
- build: ../hugo
- entrypoint: ./wait-for-it.sh postgres:5432 -- npm start
- links:
- - postgres
- expose:
- - '80'
- volumes:
- - ../config/kansa.yaml:/kansa.yaml:ro
-
- kansa:
- build: ../kansa
+ server:
+ build: ../
+ image: kansa-server
entrypoint: ./wait-for-it.sh postgres:5432 -- npm start
links:
- kyyhky
@@ -41,16 +35,6 @@ services:
- ../config/kansa.yaml:/kansa.yaml:ro
- ../config/siteselection/ballot-data.js:/ss-ballot-data.js:ro
- raami:
- build: ../raami
- entrypoint: ./wait-for-it.sh postgres:5432 -- npm start
- links:
- - postgres
- expose:
- - '3000'
- volumes:
- - ../config/kansa.yaml:/kansa.yaml:ro
-
tarra:
image: worldcon75devops/label-printer:0.3.0
expose:
@@ -67,6 +51,7 @@ services:
kyyhky:
build: ../kyyhky
+ image: kansa-kyyhky
environment:
REDIS_HOST: redis
REDIS_PORT: 6379
@@ -101,7 +86,7 @@ services:
- ../postgres/init/25-public-data.sql:/docker-entrypoint-initdb.d/25-public-data.sql:ro
- ../postgres/init/28-siteselection.sql:/docker-entrypoint-initdb.d/28-siteselection.sql:ro
- ../config/database/membership-types.sql:/docker-entrypoint-initdb.d/29-membership-types.sql:ro
- - ../postgres/init/30-hugo-init.sql:/docker-entrypoint-initdb.d/30-hugo-init.sql:ro
- - ../config/database/31-hugo-categories.sql:/docker-entrypoint-initdb.d/31-hugo-categories.sql:ro
- - ../postgres/init/32-hugo-tables.sql:/docker-entrypoint-initdb.d/32-hugo-tables.sql:ro
- - ../postgres/init/40-raami-init.sql:/docker-entrypoint-initdb.d/40-raami-init.sql:ro
+ - ../modules/hugo/database/init.sql:/docker-entrypoint-initdb.d/30-hugo-init.sql:ro
+ - ../config/database/hugo-categories.sql:/docker-entrypoint-initdb.d/31-hugo-categories.sql:ro
+ - ../modules/hugo/database/tables.sql:/docker-entrypoint-initdb.d/32-hugo-tables.sql:ro
+ - ../modules/raami/database/init.sql:/docker-entrypoint-initdb.d/40-raami-init.sql:ro
diff --git a/config/docker-compose.dev.yaml b/config/docker-compose.dev.yaml
index 02d1c9e..9f07356 100644
--- a/config/docker-compose.dev.yaml
+++ b/config/docker-compose.dev.yaml
@@ -6,7 +6,7 @@ version: '2'
# docker-compose -f config/docker-compose.base.yaml -f config/docker-compose.dev.yaml up -d
services:
- nginx:
+ proxy:
environment:
JWT_SECRET: dev secret
SERVER_NAME: localhost
@@ -15,36 +15,23 @@ services:
ports:
- '4430:443'
volumes:
- - ../nginx/hugo-packet:/srv/hugo-packet:ro
- - ../nginx/member-files:/srv/member-files:ro
- - ../nginx/ssl:/ssl:ro
+ - ../proxy/hugo-packet:/srv/hugo-packet:ro
+ - ../proxy/member-files:/srv/member-files:ro
+ - ../proxy/ssl:/ssl:ro
- hugo:
- environment:
- CORS_ORIGIN: http://localhost:8080
- DATABASE_URL: postgres://hugo:pwd@postgres:5432/api
- JWT_SECRET: dev secret
- SESSION_SECRET: dev secret
-
- kansa:
+ server:
environment:
CORS_ORIGIN: http://localhost:8080
DATABASE_URL: postgres://kansa:pwd@postgres:5432/api
DEBUG: kansa:*
JWT_SECRET: dev secret
SESSION_SECRET: dev secret
- SLACK_ORG: worldcon75
- SLACK_REQ_MEMBER: 'true'
+ HUGO_PG_URL: postgres://hugo:pwd@postgres:5432/api
+ RAAMI_PG_URL: postgres://raami:pwd@postgres:5432/api
SLACK_TOKEN: ''
STRIPE_SECRET_APIKEY: sk_test_zq022Drx7npYPVEtXAVMaOJT
STRIPE_SECRET_APIKEY_siteselect: sk_test_35SiP34s6qovtenwPPLguIyY
- raami:
- environment:
- CORS_ORIGIN: http://localhost:8080
- DATABASE_URL: postgres://raami:pwd@postgres:5432/api
- SESSION_SECRET: dev secret
-
tarra:
volumes:
- ../tarra/fonts:/var/www/html/vendor/tecnickcom/tcpdf/fonts:ro
diff --git a/config/docker-compose.prod-template.yaml b/config/docker-compose.prod-template.yaml
index a728a70..b6c83f5 100644
--- a/config/docker-compose.prod-template.yaml
+++ b/config/docker-compose.prod-template.yaml
@@ -12,7 +12,7 @@ version: '2'
# DO NOT COMMIT PRODUCTION SECRETS TO ANY REPOSITORY
services:
- nginx:
+ proxy:
environment:
JWT_SECRET: ''
SERVER_NAME: ''
@@ -23,14 +23,7 @@ services:
- '443:443'
restart: always
- hugo:
- environment:
- DATABASE_URL: ''
- JWT_SECRET: ''
- SESSION_SECRET: ''
- restart: always
-
- kansa:
+ server:
environment:
DATABASE_URL: ''
DEBUG: kansa:errors
@@ -39,11 +32,6 @@ services:
STRIPE_SECRET_APIKEY: ''
restart: always
- raami:
- environment:
- DATABASE_URL: ''
- SESSION_SECRET: ''
-
kyyhky:
environment:
API_URI_ROOT: ''
diff --git a/config/kansa.yaml b/config/kansa.yaml
index 0956d4d..1554b9f 100644
--- a/config/kansa.yaml
+++ b/config/kansa.yaml
@@ -13,6 +13,17 @@ paid_paper_pubs: false
# Authentication configuration. Duration values should use suffixes to indicate
# units, e.g. "1d 3h" or "4w".
auth:
+ # This should correspond to a subset of the columns in the admin.admins
+ # Postgres table that are currently in use. Including unused roles here is
+ # fine, as long as they have corresponding database columns.
+ admin_roles:
+ - admin_admin
+ - hugo_admin
+ - member_admin
+ - member_list
+ - raami_admin
+ - siteselection
+
# If an expired key is used for login, the key is reset and automatically
# sent by email. Admin users are those with any administrative privileges.
key_timeout:
@@ -22,3 +33,28 @@ auth:
# After session timeout, a user is redirected to the sign-in page at their
# next request.
session_timeout: 5d
+
+# Modules add optional functionality to the core Kansa member management
+# system. This configuration controls the behaviour of the API server; if you
+# make changes you may want to also modify your Docker Compose and/or Postgres
+# configuration. To disable a module, set its value to `null` or `false`.
+modules:
+ # Superadmin actions: get & set admin levels, mass sync actions
+ admin: true
+
+ # Nomination and voting for the Hugo Awards
+ hugo: true
+
+ # Member statistics and other publicly accessible data, which may be made
+ # available for use from other domains with CORS. Note: proxy/nginx.conf
+ # includes caching configuration for paths under /api/public/.
+ public:
+ cors_origin: '*'
+
+ # Art show management
+ raami: false
+
+ # Invite generator for a Slack organisation
+ slack:
+ #org: worldcon75
+ #require_membership: true
diff --git a/docs/index.md b/docs/index.md
index 9b8c008..8213b34 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,9 +1,7 @@
# Member Services API
All API requests need to be made using secure connections, i.e. with `https` or
-`wss` protocols, and their paths should be prefixed by `/api/`. Requests for
-`/api/hugo/...` will be handled by the [`hugo`](../hugo/) server,
-`/api/raami/...` by [`raami`](../raami/), and all others by [`kansa`](../kansa/).
+`wss` protocols, and their paths should be prefixed by `/api/`.
Some GET paths include query parameters. POST body parameters may be included
either as `application/x-www-form-urlencoded` or as `application/json`. All
diff --git a/hugo/Dockerfile b/hugo/Dockerfile
deleted file mode 100644
index 209208d..0000000
--- a/hugo/Dockerfile
+++ /dev/null
@@ -1,3 +0,0 @@
-FROM node:6-onbuild
-
-EXPOSE 80
diff --git a/hugo/lib/kyyhky-send-email.js b/hugo/lib/kyyhky-send-email.js
deleted file mode 100644
index bb7ea28..0000000
--- a/hugo/lib/kyyhky-send-email.js
+++ /dev/null
@@ -1,13 +0,0 @@
-const fetch = require('node-fetch')
-
-function sendEmail(type, data, delay) {
- let url = `http://kyyhky/email/${type}`
- if (delay) url += `?delay=${Number(delay)}` // in minutes
- return fetch(url, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(data)
- })
-}
-
-module.exports = sendEmail
diff --git a/hugo/package.json b/hugo/package.json
deleted file mode 100644
index cf1478a..0000000
--- a/hugo/package.json
+++ /dev/null
@@ -1,28 +0,0 @@
-{
- "name": "hugo-server",
- "version": "1.0.0",
- "description": "API for Hugo Awards",
- "private": true,
- "license": "Apache-2.0",
- "repository": "maailma/kansa",
- "scripts": {
- "start": "node server"
- },
- "dependencies": {
- "bluebird": "^3.5.0",
- "body-parser": "^1.17.1",
- "connect-pg-simple": "^3.1.2",
- "cors": "^2.8.1",
- "csv-express": "^1.2.1",
- "debug": "^2.6.3",
- "express": "~4.14.0",
- "express-session": "^1.15.1",
- "express-ws": "^2.0.0",
- "jsonwebtoken": "^7.4.0",
- "morgan": "~1.8.1",
- "node-fetch": "^1.6.3",
- "pg-monitor": "^0.7.1",
- "pg-promise": "^5.6.4",
- "yaml": "^1.0.0-rc.7"
- }
-}
diff --git a/hugo/server.js b/hugo/server.js
deleted file mode 100644
index 1ea66ff..0000000
--- a/hugo/server.js
+++ /dev/null
@@ -1,45 +0,0 @@
-const debug = require('debug')('db-api:server')
-
-const { app, server } = require('./app')
-const port = normalizePort(process.env.PORT || '80')
-
-app.set('port', port)
-server.listen(port)
-server.on('error', onError)
-server.on('listening', onListening)
-
-// Normalize a port into a number, string, or false.
-function normalizePort(val) {
- var port = parseInt(val, 10)
- return isNaN(port)
- ? val // named pipe
- : port >= 0
- ? port // port number
- : false
-}
-
-function onError(error) {
- if (error.syscall !== 'listen') throw error
-
- var bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port
-
- // handle specific listen errors with friendly messages
- switch (error.code) {
- case 'EACCES':
- console.error(bind + ' requires elevated privileges')
- process.exit(1)
- break
- case 'EADDRINUSE':
- console.error(bind + ' is already in use')
- process.exit(1)
- break
- default:
- throw error
- }
-}
-
-function onListening() {
- var addr = server.address()
- var bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port
- debug('Listening on ' + bind)
-}
diff --git a/integration-tests/test/badge.spec.js b/integration-tests/test/badge.spec.js
new file mode 100644
index 0000000..95862aa
--- /dev/null
+++ b/integration-tests/test/badge.spec.js
@@ -0,0 +1,135 @@
+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'
+
+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', () => {
+ 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 badge', () =>
+ member
+ .get(`/api/people/${id}/badge`)
+ .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))
+
+ it('fail to log own badge as printed', () =>
+ member
+ .post(`/api/people/${id}/print`)
+ .send()
+ .expect(401))
+ })
+
+ describe('anonymous access', () => {
+ const anonymous = request.agent(`https://${host}`, { ca })
+
+ it('get blank badge', () =>
+ anonymous
+ .get('/api/blank-badge')
+ .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))
+ })
+
+ 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 badge", () =>
+ admin
+ .get(`/api/people/${id}/badge`)
+ .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`)
+ .send()
+ .expect(200)
+ .expect(res => assert.equal(res.body.status, 'success')))
+ })
+})
diff --git a/integration-tests/test/hugo-nominations.spec.js b/integration-tests/test/hugo-nominations.spec.js
index cab9af2..698c873 100644
--- a/integration-tests/test/hugo-nominations.spec.js
+++ b/integration-tests/test/hugo-nominations.spec.js
@@ -5,7 +5,7 @@ const WebSocket = require('ws')
const YAML = require('yaml').default
const config = YAML.parse(fs.readFileSync('../config/kansa.yaml', 'utf8'))
-const ca = fs.readFileSync('../nginx/ssl/localhost.cert', 'utf8')
+const ca = fs.readFileSync('../proxy/ssl/localhost.cert', 'utf8')
const host = 'localhost:4430'
const admin = request.agent(`https://${host}`, { ca })
const nominator = request.agent(`https://${host}`, { ca })
@@ -185,18 +185,47 @@ describe('Hugo nominations', () => {
assert.equal(o[1], n[1])
}))
+ it('admin: WebSocket connection to unhandled path', done => {
+ const sessionCookie = admin.jar.getCookie(config.id, { path: '/' })
+ const ws = new WebSocket(`wss://${host}/api/hugo/admin/nosuchpath`, {
+ ca,
+ headers: { Cookie: String(sessionCookie) }
+ })
+ let ok = false
+ ws.onclose = () => {
+ if (ok) done()
+ else done(new Error('Expected error before WebSocket close'))
+ }
+ ws.onerror = err => {
+ assert.equal(err.message, 'Unexpected server response: 404')
+ ok = true
+ ws.close()
+ }
+ ws.onmessage = ({ data }) => {
+ throw new Error(`Unexpected message! ${data}`)
+ }
+ ws.onopen = () => {
+ throw new Error('Unexpected WebSocket open event!')
+ }
+ })
+
it('admin: WebSocket client', done => {
const sessionCookie = admin.jar.getCookie(config.id, { path: '/' })
const ws = new WebSocket(`wss://${host}/api/hugo/admin/canon-updates`, {
ca,
headers: { Cookie: String(sessionCookie) }
})
- ws.onclose = () => done()
+ let ok = false
+ ws.onclose = () => {
+ if (ok) done()
+ else done(new Error('WebSocket closed before message received'))
+ }
ws.onerror = done
ws.onmessage = ({ data }) => {
const obj = JSON.parse(data)
assert.equal(obj && typeof obj.canon, 'object')
assert.equal(obj.canon.nomination.author, otherAuthor)
+ ok = true
ws.close()
}
ws.onopen = () =>
diff --git a/integration-tests/test/login.spec.js b/integration-tests/test/login.spec.js
index c317780..1fba83e 100644
--- a/integration-tests/test/login.spec.js
+++ b/integration-tests/test/login.spec.js
@@ -1,222 +1,122 @@
-const host = 'https://localhost:4430'
-const request = require('supertest')
const assert = require('assert')
const fs = require('fs')
+const request = require('supertest')
-// Create agent for unlogged and admin sessions
-const ca = fs.readFileSync('../nginx/ssl/localhost.cert', 'utf8')
+const ca = fs.readFileSync('../proxy/ssl/localhost.cert', 'utf8')
+const host = 'https://localhost:4430'
const unlogged = request.agent(host, { ca })
const admin = request.agent(host, { ca })
const loginparams = { email: 'admin@example.com', key: 'key' }
-describe('Check that API services are up', function() {
- this.timeout(120000)
- this.retries(10)
-
- afterEach(function(done) {
- if (this.currentTest.state !== 'passed') {
- setTimeout(done, 2000)
- } else {
- done()
- }
- })
-
- it('Should respond with json on api/', done => {
- unlogged
- .get('/api/')
- .expect('Content-Type', /json/)
- .end(done)
- })
-
- it('Should respond with json on api/hugo/', done => {
- unlogged
- .get('/api/hugo/')
- .expect('Content-Type', /json/)
- .end(done)
- })
-})
-
-describe('Public data', () => {
- it('Member list is an array', done => {
- unlogged
- .get('/api/public/people')
- .expect(res => {
- if (res.status !== 200 || !Array.isArray(res.body)) {
- throw new Error(`Fail! ${JSON.stringify(res.body)}`)
- }
- })
- .end(done)
- })
-
- it('Country statistics includes totals', done => {
- unlogged
- .get('/api/public/stats')
- .expect(res => {
- if (
- res.status !== 200 ||
- !res.body ||
- !res.body['='].hasOwnProperty('=')
- ) {
- throw new Error(`Fail! ${JSON.stringify(res.body)}`)
- }
- })
- .end(done)
- })
-
- it('Configuration is an object with id, name', done => {
+describe('Configuration', () => {
+ it('Configuration is an object with id, name', () =>
unlogged
.get('/api/config')
+ .expect(200)
.expect(res => {
- if (res.status !== 200 || !res.body || !res.body.id || !res.body.name) {
- throw new Error(`Fail! ${JSON.stringify(res.body)}`)
- }
- })
- .end(done)
- })
+ assert(typeof res.body.id, 'string')
+ assert(typeof res.body.name, 'string')
+ }))
})
describe('Login', () => {
context('Successful login', () => {
- it('gets a session cookie or it gets the hose again.', done => {
+ it('gets a session cookie or it gets the hose again.', () =>
admin
.get('/api/login')
.query(loginparams)
.expect('set-cookie', /w75/)
- .expect(200, { status: 'success', email: loginparams.email })
- .end(done)
- })
+ .expect(200, { status: 'success', email: loginparams.email }))
- it('gets user information', done => {
- admin
- .get('/api/user')
- .expect(200)
- .end(done)
- })
+ it('gets user information', () => admin.get('/api/user').expect(200))
})
context('Login with wrong email', () => {
- it('gets 401 response', done => {
+ it('gets 401 response', () =>
unlogged
.get('/api/login')
.query({ email: 'foo@doo.com', key: loginparams.key })
- .expect(401)
- .end(done)
- })
+ .expect(401))
- it('gets unauthorized from /api/user', done => {
- unlogged
- .get('/api/user')
- .expect(401, { status: 'unauthorized' })
- .end(done)
- })
+ it('gets unauthorized from /api/user', () =>
+ unlogged.get('/api/user').expect(401))
})
context('Login with wrong key', () => {
- it('gets 401 response', done => {
+ it('gets 401 response', () =>
unlogged
.get('/api/login')
.query({ email: loginparams.email, key: 'foo' })
- .expect(401)
- .end(done)
- })
+ .expect(401))
- it('gets unauthorized from /api/user', done => {
- unlogged
- .get('/api/user')
- .expect(401, { status: 'unauthorized' })
- .end(done)
- })
+ it('gets unauthorized from /api/user', () =>
+ unlogged.get('/api/user').expect(401))
})
context('Login with expired key', () => {
- it('gets 403 response', done => {
+ it('gets 403 response', () => {
const email = 'expired@example.com'
- unlogged
+ return unlogged
.get('/api/login')
.query({ email, key: 'key' })
- .expect(403, { email, status: 'expired' })
- .end(done)
+ .expect(403)
})
- it('gets unauthorized from /api/user', done => {
- unlogged
- .get('/api/user')
- .expect(401, { status: 'unauthorized' })
- .end(done)
- })
+ it('gets unauthorized from /api/user', () =>
+ unlogged.get('/api/user').expect(401))
})
})
describe('Logout', () => {
const testagent = request.agent(host, { ca })
- before(done => {
+ before(() =>
testagent
.get('/api/login')
.query(loginparams)
.expect('set-cookie', /w75/)
.expect(200, { status: 'success', email: loginparams.email })
- .end(done)
- })
+ )
context('Successful logout', () => {
- it('should be successful', done => {
+ it('should be successful', () =>
testagent
.get('/api/logout')
- .expect(200, { status: 'success', email: loginparams.email })
- .end(done)
- })
+ .expect(200, { status: 'success', email: loginparams.email }))
- it('gets unauthorized from /api/user', done => {
- testagent
- .get('/api/user')
- .expect(401, { status: 'unauthorized' })
- .end(done)
- })
+ it('gets unauthorized from /api/user', () =>
+ testagent.get('/api/user').expect(401))
})
context('Not logged in', () => {
- it('logout should be unauthorized', done => {
- unlogged
- .get('/api/logout')
- .expect(401, { status: 'unauthorized' })
- .end(done)
- })
+ it('logout should be unauthorized', () =>
+ unlogged.get('/api/logout').expect(401))
- it('gets unauthorized from /api/user', done => {
- testagent
- .get('/api/user')
- .expect(401, { status: 'unauthorized' })
- .end(done)
- })
+ it('gets unauthorized from /api/user', () =>
+ testagent.get('/api/user').expect(401))
})
})
describe('Key request', () => {
- before(done => {
+ before(() =>
admin
.get('/api/logout')
.expect(200, { status: 'success', email: loginparams.email })
- .end(done)
- })
+ )
context('Should not reset by default', () => {
- it('should be successful', done => {
+ it('should be successful', () =>
admin
.post('/api/key')
.send({ email: loginparams.email })
- .expect(200, { status: 'success', email: loginparams.email })
- .end(done)
- })
+ .expect(200, { status: 'success', email: loginparams.email }))
- it('should still be able to login', done => {
+ it('should still be able to login', () =>
admin
.get('/api/login')
.query(loginparams)
.expect('set-cookie', /w75/)
- .expect(200, { status: 'success', email: loginparams.email })
- .end(done)
- })
+ .expect(200, { status: 'success', email: loginparams.email }))
})
context('Account creation', () => {
@@ -225,12 +125,10 @@ describe('Key request', () => {
'test-' + (Math.random().toString(36) + '00000000000000000').slice(2, 7)
const testEmail = testName + '@example.com'
- it('Should create non-member accounts', done => {
+ it('Should create non-member accounts', () =>
agent
.post('/api/key')
.send({ email: testEmail, name: testName })
- .expect(200, { status: 'success', email: testEmail })
- .end(done)
- })
+ .expect(200, { status: 'success', email: testEmail }))
})
})
diff --git a/integration-tests/test/people.spec.js b/integration-tests/test/people.spec.js
new file mode 100644
index 0000000..faf7985
--- /dev/null
+++ b/integration-tests/test/people.spec.js
@@ -0,0 +1,254 @@
+const assert = require('assert')
+const fs = require('fs')
+const request = require('supertest')
+const WebSocket = require('ws')
+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 })
+
+describe('People', () => {
+ const origName = 'First Member'
+ const altName = 'Member the First'
+
+ let testId = null
+ const testName = 'test-' + (Math.random().toString(36) + '000000').slice(2, 7)
+
+ 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 = 'admin@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('hugo_admin'), -1)
+ })
+ })
+
+ describe('Member access', () => {
+ it('get own data', () =>
+ member
+ .get(`/api/people/${id}`)
+ .expect(200)
+ .expect(res => {
+ assert.equal(res.body.id, id)
+ assert.equal(typeof res.body.email, 'string')
+ }))
+
+ it("fail to get other member's data", () =>
+ member.get(`/api/people/${id - 1}`).expect(401))
+
+ it('update own data', () =>
+ member
+ .post(`/api/people/${id}`)
+ .send({ legal_name: altName })
+ .expect(200, {
+ status: 'success',
+ updated: ['legal_name'],
+ key_sent: false
+ }))
+
+ it('fail to update own email', () =>
+ member
+ .post(`/api/people/${id}`)
+ .send({ email: 'member@example.com' })
+ .expect(400))
+
+ it("fail to update other member's data", () =>
+ member
+ .post(`/api/people/${id - 1}`)
+ .send({ legal_name: 'Other Member' })
+ .expect(401))
+
+ it('get own previous names', () =>
+ member
+ .get(`/api/people/${id}/prev-names`)
+ .expect(200)
+ .expect(res => {
+ assert(Array.isArray(res.body))
+ }))
+
+ it("fail to get other member's previous names", () =>
+ member.get(`/api/people/${id - 1}/prev-names`).expect(401))
+
+ it('fail to get previous names for all members', () =>
+ member.get(`/api/people/prev-names.csv`).expect(401))
+
+ it('fail to add new person', () =>
+ member
+ .post(`/api/people`)
+ .send({
+ email: `${testName}@example.com`,
+ legal_name: testName,
+ membership: 'NonMember'
+ })
+ .expect(401))
+
+ it('fail to list all people', () => member.get(`/api/people`).expect(401))
+
+ it('fail to list member emails', () =>
+ member.get(`/api/members/emails`).expect(401))
+
+ it('fail to list member paper pubs', () =>
+ member.get(`/api/members/emails`).expect(401))
+
+ it('fail to open WebSocket connection for people updates', done => {
+ const sessionCookie = member.jar.getCookie(config.id, { path: '/' })
+ const ws = new WebSocket(`wss://${host}/api/people/updates`, {
+ ca,
+ headers: { Cookie: String(sessionCookie) }
+ })
+ ws.onclose = ev => {
+ if (ev.code === 4001) done()
+ else done(new Error(`Unexpected close event ${ev.code} ${ev.reason}`))
+ }
+ ws.onerror = done
+ ws.onmessage = ({ data }) => {
+ throw new Error(`Unexpected message! ${data}`)
+ }
+ })
+ })
+
+ describe('Admin access', () => {
+ it('get member data', () =>
+ admin
+ .get(`/api/people/${id}`)
+ .expect(200)
+ .expect(res => {
+ assert.equal(res.body.id, id)
+ assert.equal(typeof res.body.email, 'string')
+ }))
+
+ it('update member data', () =>
+ admin
+ .post(`/api/people/${id}`)
+ .send({ email: 'member@example.com', legal_name: origName })
+ .expect(200, {
+ status: 'success',
+ updated: ['legal_name', 'email'],
+ key_sent: false
+ }))
+
+ it('get previous names for one member', () =>
+ admin
+ .get(`/api/people/${id}/prev-names`)
+ .expect(200)
+ .expect(res => {
+ assert(Array.isArray(res.body))
+ assert(res.body.some(p => p.prev_legal_name === altName))
+ }))
+
+ it('get previous names for all members as CSV', () =>
+ admin
+ .get(`/api/people/prev-names.csv`)
+ .expect(200)
+ .expect('Content-Type', 'text/csv; charset=utf-8'))
+
+ it('get previous names for all members as JSON', () =>
+ admin
+ .get(`/api/people/prev-names.json`)
+ .expect(200)
+ .expect('Content-Type', 'application/json; charset=utf-8')
+ .expect(res => {
+ assert(Array.isArray(res.body))
+ assert(res.body.some(p => p.prev_name === altName))
+ }))
+
+ it('add new person', () =>
+ admin
+ .post(`/api/people`)
+ .send({
+ email: `${testName}@example.com`,
+ legal_name: testName,
+ membership: 'NonMember'
+ })
+ .expect(200)
+ .expect(res => {
+ assert.equal(res.body.status, 'success')
+ assert.equal(typeof res.body.id, 'number')
+ testId = res.body.id
+ }))
+
+ it('list all people', () =>
+ admin
+ .get(`/api/people`)
+ .expect(200)
+ .expect(res => {
+ assert(Array.isArray(res.body))
+ assert(res.body.some(p => p && p.legal_name === origName))
+ assert(
+ res.body.some(
+ p => p && p.id === testId && p.legal_name === testName
+ )
+ )
+ }))
+
+ it('list member emails', () =>
+ admin
+ .get(`/api/members/emails`)
+ .expect(200)
+ .expect('Content-Type', 'text/csv; charset=utf-8')
+ .expect(res => {
+ assert(/member@example.com/.test(res.text))
+ }))
+
+ it('list member paper pubs', () =>
+ admin
+ .get(`/api/members/emails`)
+ .expect(200)
+ .expect('Content-Type', 'text/csv; charset=utf-8'))
+
+ it('WebSocket: people updates', done => {
+ const sessionCookie = admin.jar.getCookie(config.id, { path: '/' })
+ const ws = new WebSocket(`wss://${host}/api/people/updates`, {
+ ca,
+ headers: { Cookie: String(sessionCookie) }
+ })
+ let ok = false
+ ws.onclose = () => {
+ if (ok) done()
+ else done(new Error('WebSocket closed before message received'))
+ }
+ ws.onerror = done
+ ws.onmessage = ({ data }) => {
+ const obj = JSON.parse(data)
+ assert.equal(obj.id, testId)
+ assert.equal(obj.legal_name, testName)
+ ok = true
+ ws.close()
+ }
+ ws.onopen = () =>
+ admin
+ .post(`/api/people/${testId}`)
+ .send({ email: `${testName}@example.org` })
+ .expect(200, {
+ status: 'success',
+ updated: ['email'],
+ key_sent: false
+ })
+ .catch(done)
+ })
+ })
+})
diff --git a/integration-tests/test/public.spec.js b/integration-tests/test/public.spec.js
new file mode 100644
index 0000000..4fbfeef
--- /dev/null
+++ b/integration-tests/test/public.spec.js
@@ -0,0 +1,34 @@
+const assert = require('assert')
+const fs = require('fs')
+const request = require('supertest')
+
+const ca = fs.readFileSync('../proxy/ssl/localhost.cert', 'utf8')
+const host = 'localhost:4430'
+const agent = request.agent(`https://${host}`, { ca })
+
+describe('Public data', () => {
+ it('Member list is an array', () =>
+ agent
+ .get('/api/public/people')
+ .expect(200)
+ .expect(res => {
+ assert(Array.isArray(res.body))
+ }))
+
+ it('Daypass statistics is an object', () =>
+ agent
+ .get('/api/public/daypass-stats')
+ .expect(200)
+ .expect(res => {
+ assert(res.text.trim().length > 0)
+ assert.equal(typeof res.body, 'object')
+ }))
+
+ it('Country statistics includes totals', () =>
+ agent
+ .get('/api/public/stats')
+ .expect(200)
+ .expect(res => {
+ assert(res.body['='].hasOwnProperty('='))
+ }))
+})
diff --git a/integration-tests/test/purchase-daypasses.spec.js b/integration-tests/test/purchase-daypasses.spec.js
index 5bac046..6c8c310 100644
--- a/integration-tests/test/purchase-daypasses.spec.js
+++ b/integration-tests/test/purchase-daypasses.spec.js
@@ -4,7 +4,7 @@ const stripe = require('stripe')(
process.env.STRIPE_SECRET_APIKEY || 'sk_test_zq022Drx7npYPVEtXAVMaOJT'
)
-const cert = fs.readFileSync('../nginx/ssl/localhost.cert', 'utf8')
+const cert = fs.readFileSync('../proxy/ssl/localhost.cert', 'utf8')
const host = 'https://localhost:4430'
const adminLoginParams = { email: 'admin@example.com', key: 'key' }
diff --git a/integration-tests/test/purchase-memberships.spec.js b/integration-tests/test/purchase-memberships.spec.js
index 5791f1e..79657ae 100644
--- a/integration-tests/test/purchase-memberships.spec.js
+++ b/integration-tests/test/purchase-memberships.spec.js
@@ -4,7 +4,7 @@ const stripe = require('stripe')(
process.env.STRIPE_SECRET_APIKEY || 'sk_test_zq022Drx7npYPVEtXAVMaOJT'
)
-const cert = fs.readFileSync('../nginx/ssl/localhost.cert', 'utf8')
+const cert = fs.readFileSync('../proxy/ssl/localhost.cert', 'utf8')
const host = 'https://localhost:4430'
const adminLoginParams = { email: 'admin@example.com', key: 'key' }
diff --git a/integration-tests/test/purchase-other.spec.js b/integration-tests/test/purchase-other.spec.js
index 4e9d907..18f979a 100644
--- a/integration-tests/test/purchase-other.spec.js
+++ b/integration-tests/test/purchase-other.spec.js
@@ -4,7 +4,7 @@ const stripe = require('stripe')(
process.env.STRIPE_SECRET_APIKEY || 'sk_test_zq022Drx7npYPVEtXAVMaOJT'
)
-const cert = fs.readFileSync('../nginx/ssl/localhost.cert', 'utf8')
+const cert = fs.readFileSync('../proxy/ssl/localhost.cert', 'utf8')
const host = 'https://localhost:4430'
const adminLoginParams = { email: 'admin@example.com', key: 'key' }
diff --git a/kansa/.dockerignore b/kansa/.dockerignore
deleted file mode 100644
index 504afef..0000000
--- a/kansa/.dockerignore
+++ /dev/null
@@ -1,2 +0,0 @@
-node_modules/
-package-lock.json
diff --git a/kansa/Dockerfile b/kansa/Dockerfile
deleted file mode 100644
index ca42414..0000000
--- a/kansa/Dockerfile
+++ /dev/null
@@ -1,13 +0,0 @@
-FROM node:10
-
-ARG NODE_ENV
-ENV NODE_ENV $NODE_ENV
-
-RUN mkdir -p /usr/src/app
-WORKDIR /usr/src/app
-COPY package.json /usr/src/app/
-RUN npm install && npm cache clean --force
-COPY . /usr/src/app
-
-EXPOSE 80
-CMD [ "npm", "start" ]
diff --git a/kansa/app.js b/kansa/app.js
deleted file mode 100644
index 717145a..0000000
--- a/kansa/app.js
+++ /dev/null
@@ -1,168 +0,0 @@
-const debug = require('debug')
-const debugErrors = debug('kansa:errors')
-
-const cors = require('cors')
-const csv = require('csv-express')
-const express = require('express')
-const http = require('http')
-const bodyParser = require('body-parser')
-const session = require('express-session')
-
-const pgSession = require('connect-pg-simple')(session)
-const pgOptions = {}
-const pgp = require('pg-promise')(pgOptions)
-if (debug.enabled('kansa:db')) {
- const pgMonitor = require('pg-monitor')
- pgMonitor.attach(pgOptions)
-}
-const db = pgp(process.env.DATABASE_URL)
-
-const admin = require('./lib/admin')
-const badge = require('./lib/badge')
-const Ballot = require('./lib/ballot')
-const ballot = new Ballot(db)
-const config = require('./lib/config')
-const key = require('./lib/key')
-const log = require('./lib/log')
-const { setAllMailRecipients } = require('./lib/mail')
-const people = require('./lib/people')
-const PeopleStream = require('./lib/PeopleStream')
-const publicData = require('./lib/public')
-const Purchase = require('./lib/purchase')
-const purchase = new Purchase(pgp, db)
-const Siteselect = require('./lib/siteselect')
-const siteselect = new Siteselect(db)
-const slack = require('./lib/slack')
-const upgrade = require('./lib/upgrade')
-const user = require('./lib/user')
-
-const app = express()
-const server = http.createServer(app)
-const expressWs = require('express-ws')(app, server)
-const router = express.Router()
-const peopleStream = new PeopleStream(db)
-
-// these are accessible without authentication
-router.get('/public/people', cors({ origin: '*' }), publicData.getPublicPeople)
-router.get('/public/stats', cors({ origin: '*' }), publicData.getPublicStats)
-router.get(
- '/public/daypass-stats',
- cors({ origin: '*' }),
- publicData.getDaypassStats
-)
-router.get('/config', publicData.getConfig)
-
-router.post('/key', key.setKey)
-router.all('/login', user.login)
-
-router.get('/barcode/:key/:id.:fmt', badge.getBarcode)
-router.get('/blank-badge', badge.getBadge)
-
-router.post('/purchase', purchase.makeMembershipPurchase)
-router.get('/purchase/data', purchase.getPurchaseData)
-router.get('/purchase/daypass-prices', purchase.getDaypassPrices)
-router.post('/purchase/daypass', purchase.makeDaypassPurchase)
-router.post('/purchase/invoice', purchase.createInvoice)
-router.get('/purchase/keys', purchase.getStripeKeys)
-router.get('/purchase/list', purchase.getPurchases)
-router.post('/purchase/other', purchase.makeOtherPurchase)
-router.post('/webhook/stripe', purchase.handleStripeWebhook)
-
-// subsequent routes require authentication
-router.use(user.authenticate)
-router.all('/logout', user.logout)
-
-router.get('/members/emails', people.getMemberEmails)
-router.get('/members/paperpubs', people.getMemberPaperPubs)
-
-router.get('/people', people.getPeople)
-router.post('/people', people.authAddPerson)
-router.post('/people/lookup', publicData.lookupPerson)
-router.get('/people/prev-names.:fmt', people.getAllPrevNames)
-
-router.all('/people/:id*', user.verifyPeopleAccess)
-router.get('/people/:id', people.getPerson)
-router.post('/people/:id', people.updatePerson)
-router.get('/people/:id/badge', badge.getBadge)
-router.get('/people/:id/ballot', ballot.getBallot)
-router.get('/people/:id/barcode.:fmt', badge.getBarcode)
-router.get('/people/:id/log', log.getPersonLog)
-router.get('/people/:id/prev-names', people.getPrevNames)
-router.post('/people/:id/print', badge.logPrint)
-router.post('/people/:id/upgrade', upgrade.authUpgradePerson)
-
-router.post('/slack/invite', slack.invite)
-
-router.get('/user', user.getInfo)
-router.get('/user/log', log.getUserLog)
-
-router.all('/siteselect*', siteselect.verifyAccess)
-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)
-
-router.all('/admin*', admin.isAdminAdmin)
-router.get('/admin', admin.getAdmins)
-router.post('/admin', admin.setAdmin)
-router.post('/admin/set-keys', key.setAllKeys)
-router.post('/admin/set-recipients', setAllMailRecipients)
-
-app.locals.db = db
-if (debug.enabled('kansa:http')) {
- const logger = require('morgan')
- app.use(logger('dev'))
-}
-app.use(bodyParser.json())
-app.use(bodyParser.urlencoded({ extended: false }))
-const corsOrigins = process.env.CORS_ORIGIN
-if (corsOrigins)
- app.use(
- cors({
- credentials: true,
- methods: ['GET', 'POST'],
- origin: corsOrigins.split(/[ ,]+/)
- })
- )
-app.use(
- session({
- cookie: { maxAge: config.auth.session_timeout },
- name: config.id,
- resave: false,
- saveUninitialized: false,
- secret: process.env.SESSION_SECRET,
- store: new pgSession({
- pgPromise: db,
- pruneSessionInterval: 24 * 60 * 60 // 1 day
- })
- })
-)
-app.ws('/people/updates', (ws, req) => {
- if (req.session.user.member_admin) peopleStream.addClient(ws)
- else ws.close(4001, 'Unauthorized')
-})
-app.ws('/*', (ws, req) => ws.close(4004, 'Not Found'))
-// express-ws monkeypatching breaks the server on unhandled paths
-app.use('/', router)
-
-// no match from router -> 404
-app.use((req, res, next) => {
- const err = new Error('Not Found')
- err.status = 404
- next(err)
-})
-
-// error handler
-const isDevEnv = app.get('env') === 'development'
-app.use((err, req, res, next) => {
- const error = err.error || err
- debugErrors(error instanceof Error ? error.message : err)
- const data = { status: 'error', message: error.message }
- if (isDevEnv) data.error = err
- const status =
- err.status || error.status || (error.name == 'InputError' && 400) || 500
- res.status(status).json(data)
-})
-
-module.exports = { app, server }
diff --git a/kansa/lib/admin.js b/kansa/lib/admin.js
deleted file mode 100644
index a19c1fc..0000000
--- a/kansa/lib/admin.js
+++ /dev/null
@@ -1,47 +0,0 @@
-const Admin = require('./types/admin')
-const LogEntry = require('./types/logentry')
-const util = require('./util')
-
-module.exports = { isAdminAdmin, getAdmins, setAdmin }
-
-function isAdminAdmin(req, res, next) {
- if (req.session.user.admin_admin) next()
- else res.status(401).json({ status: 'unauthorized' })
-}
-
-function getAdmins(req, res, next) {
- req.app.locals.db
- .any('SELECT * FROM admin.Admins')
- .then(data => {
- res.status(200).json(data)
- })
- .catch(err => next(err))
-}
-
-function setAdmin(req, res, next) {
- const data = Object.assign({}, req.body)
- const fields = Admin.roleFields.filter(fn => data.hasOwnProperty(fn))
- if (!data.email || fields.length == 0) {
- res.status(400).json({ status: 'error', data })
- } else {
- const log = new LogEntry(req, 'Set admin rights for ' + data.email)
- const fCols = fields.join(', ')
- const fValues = fields.map(fn => `$(${fn})`).join(', ')
- const fSet = fields.map(fn => `${fn} = EXCLUDED.${fn}`).join(', ')
- fields.forEach(fn => util.forceBool(data, fn))
- req.app.locals.db
- .tx(tx =>
- tx.batch([
- tx.none(
- `INSERT INTO admin.Admins (email, ${fCols}) VALUES ($(email), ${fValues}) ON CONFLICT (email) DO UPDATE SET ${fSet}`,
- data
- ),
- tx.none(`INSERT INTO Log ${log.sqlValues}`, log)
- ])
- )
- .then(() => {
- res.status(200).json({ status: 'success', set: data })
- })
- .catch(err => next(err))
- }
-}
diff --git a/kansa/lib/errors.js b/kansa/lib/errors.js
deleted file mode 100644
index 6c16ba0..0000000
--- a/kansa/lib/errors.js
+++ /dev/null
@@ -1,16 +0,0 @@
-function AuthError(message = 'Unauthorized') {
- this.name = 'AuthError'
- this.message = message
- this.status = 401
-}
-AuthError.prototype = new Error()
-
-function InputError(message = 'Input error') {
- this.name = 'InputError'
- this.message = message
- this.status = 400
- this.stack = new Error().stack
-}
-InputError.prototype = new Error()
-
-module.exports = { AuthError, InputError }
diff --git a/kansa/lib/key.js b/kansa/lib/key.js
deleted file mode 100644
index cfb9c94..0000000
--- a/kansa/lib/key.js
+++ /dev/null
@@ -1,147 +0,0 @@
-const randomstring = require('randomstring')
-
-const config = require('./config')
-const { InputError } = require('./errors')
-const { mailTask, updateMailRecipient } = require('./mail')
-const LogEntry = require('./types/logentry')
-
-module.exports = {
- refreshKey,
- resetExpiredKey,
- setKeyChecked,
- setKey,
- setAllKeys
-}
-
-const getKeyMaxAge = (db, email) =>
- db
- .one(`SELECT exists(SELECT 1 FROM admin.admins WHERE email = $1)`, email)
- .then(({ exists: isAdmin }) => {
- const type = isAdmin ? 'admin' : 'normal'
- return config.auth.key_timeout[type] / 1000
- })
-
-function refreshKey(req, db, email) {
- return db.task(async ts => {
- const maxAge = await getKeyMaxAge(ts, email)
- const data = await ts.oneOrNone(
- `
- UPDATE keys SET expires = now() + $(maxAge) * interval '1 second'
- WHERE email = $(email)
- RETURNING email, key`,
- { email, maxAge }
- )
- return data || setKeyChecked(req, ts, { email, maxAge })
- })
-}
-
-function resetExpiredKey(req, db, { email, path }) {
- return db.tx(async tx => {
- const key = randomstring.generate(12)
- const maxAge = await getKeyMaxAge(tx, email)
- await tx.none(
- `
- UPDATE keys SET key=$(key),
- expires = now() + $(maxAge) * interval '1 second'
- WHERE email = $(email)`,
- { email, key, maxAge }
- )
- const log = new LogEntry(req, 'Reset access key')
- log.author = email
- await tx.none(`INSERT INTO Log ${log.sqlValues}`, log)
- await updateMailRecipient(tx, email)
- await mailTask('kansa-set-key', { email, key, path })
- })
-}
-
-function setKeyChecked(req, db, { email, maxAge, name }) {
- return db.tx(async tx => {
- if (!maxAge) maxAge = await getKeyMaxAge(tx, email)
- const key = randomstring.generate(12)
- await tx.none(
- `
- INSERT INTO Keys (email, key, expires)
- VALUES ($(email), $(key), now() + $(maxAge) * interval '1 second')
- ON CONFLICT (email) DO
- UPDATE SET key = EXCLUDED.key, expires = EXCLUDED.expires`,
- { email, key, maxAge }
- )
- let description = 'Set access key'
- if (name) {
- await tx.none(
- `
- INSERT INTO People (membership, legal_name, email)
- VALUES ('NonMember', $1, $2)`,
- [name, email]
- )
- description = 'Create non-member account'
- }
- const log = new LogEntry(req, description)
- log.author = email
- await tx.none(`INSERT INTO Log ${log.sqlValues}`, log)
- await updateMailRecipient(tx, email)
- return { email, key, maxAge, set: true }
- })
-}
-
-function setKey(req, res, next) {
- const { email: reqEmail, name, path, reset } = req.body
- if (!reqEmail)
- return next(
- new InputError('An email address is required for setting its key!')
- )
- req.app.locals.db
- .task(async ts => {
- const rows = await ts.any(
- 'SELECT email FROM People WHERE email ILIKE $1',
- reqEmail
- )
- if (rows.length > 0) {
- const { email } = rows[0]
- const { key, set } = reset
- ? await setKeyChecked(req, ts, { email })
- : await refreshKey(req, ts, email)
- await mailTask('kansa-set-key', { email, key, path, set })
- res.json({ status: 'success', email })
- } else {
- if (!name)
- return next(
- new InputError(`Email address ${JSON.stringify(email)} not found`)
- )
- const { email, key } = await setKeyChecked(req, ts, {
- email: reqEmail,
- name
- })
- await mailTask('kansa-create-account', { email, key, name, path })
- res.json({ status: 'success', email })
- }
- })
- .catch(next)
-}
-
-function setAllKeys(req, res, next) {
- req.app.locals.db
- .tx(async tx => {
- const data = await tx.any(`
- SELECT DISTINCT p.email, a.email IS NOT NULL AS is_admin
- FROM people p
- LEFT JOIN keys k USING (email)
- LEFT JOIN admin.admins a USING (email)
- WHERE k.email IS NULL`)
- const kt = config.auth.key_timeout
- await tx.sequence(i => {
- if (!data[i]) return undefined
- const { email, is_admin } = data[i]
- const key = randomstring.generate(12)
- const maxAge = (is_admin ? kt.admin : kt.normal) / 1000
- return tx.any(
- `
- INSERT INTO Keys (email, key, expires)
- VALUES ($(email), $(key), now() + $(maxAge) * interval '1 second')`,
- { email, key, maxAge }
- )
- })
- res.json({ status: 'success', count: data.length })
- })
- .catch(next)
-}
diff --git a/kansa/lib/log.js b/kansa/lib/log.js
deleted file mode 100644
index 87ed6bf..0000000
--- a/kansa/lib/log.js
+++ /dev/null
@@ -1,22 +0,0 @@
-module.exports = { getPersonLog, getUserLog }
-
-function getPersonLog(req, res, next) {
- const id = parseInt(req.params.id)
- req.app.locals.db
- .any('SELECT * FROM Log WHERE subject = $1', id)
- .then(data => {
- res.status(200).json(data)
- })
- .catch(err => next(err))
-}
-
-function getUserLog(req, res, next) {
- const user = req.session.user
- const email = (user.member_admin && req.query.email) || user.email
- req.app.locals.db
- .any('SELECT * FROM Log WHERE author = $1', email)
- .then(log => {
- res.status(200).json({ email, log })
- })
- .catch(err => next(err))
-}
diff --git a/kansa/lib/people.js b/kansa/lib/people.js
deleted file mode 100644
index 8c54fb4..0000000
--- a/kansa/lib/people.js
+++ /dev/null
@@ -1,381 +0,0 @@
-const config = require('./config')
-const { setKeyChecked } = require('./key')
-const { AuthError, InputError } = require('./errors')
-const { mailTask, updateMailRecipient } = require('./mail')
-const LogEntry = require('./types/logentry')
-const Person = require('./types/person')
-
-const selectAllPeopleData = `
- SELECT p.*, preferred_name(p), d.status AS daypass, daypass_days(d)
- FROM people p LEFT JOIN daypasses d ON (p.id = d.person_id)`
-
-module.exports = {
- selectAllPeopleData,
- getMemberEmails,
- getMemberPaperPubs,
- getPeople,
- getPerson,
- getAllPrevNames,
- getPrevNames,
- addPerson,
- authAddPerson,
- updatePerson
-}
-
-function getPeopleQuery(req, res, next) {
- const cond = Object.keys(req.query).map(fn => {
- switch (fn) {
- case 'since':
- return 'last_modified > $(since)'
- case 'name':
- return '(legal_name ILIKE $(name) OR public_first_name ILIKE $(name) OR public_last_name ILIKE $(name))'
- case 'member_number':
- case 'membership':
- return `${fn} = $(${fn})`
- default:
- return Person.fields.indexOf(fn) !== -1
- ? `${fn} ILIKE $(${fn})`
- : 'true'
- }
- })
- req.app.locals.db
- .any(`${selectAllPeopleData} WHERE ${cond.join(' AND ')}`, req.query)
- .then(data => res.status(200).json(data))
- .catch(err => next(err))
-}
-
-function getMemberEmails(req, res, next) {
- if (!req.session.user.member_admin)
- return res.status(401).json({ status: 'unauthorized' })
- req.app.locals.db
- .any(
- `
- SELECT
- lower(email) AS email, legal_name AS ln,
- public_first_name AS pfn, public_last_name AS pln
- FROM People p
- LEFT JOIN membership_types m USING (membership)
- WHERE email != '' AND m.member = true
- ORDER BY public_last_name, public_first_name, legal_name`
- )
- .then(raw => {
- const namesByEmail = raw.reduce((map, { email, ln, pfn, pln }) => {
- const name =
- [pfn, pln]
- .filter(n => n)
- .join(' ')
- .replace(/ +/g, ' ')
- .trim() || ln.trim()
- if (map[email]) map[email].push(name)
- else map[email] = [name]
- return map
- }, {})
- const getCombinedName = names => {
- switch (names.length) {
- case 0:
- return ''
- case 1:
- return names[0]
- case 2:
- return `${names[0]} and ${names[1]}`
- default:
- names[names.length - 1] = `and ${names[names.length - 1]}`
- return names.join(', ')
- }
- }
- const data = Object.keys(namesByEmail).map(email => {
- const name = getCombinedName(namesByEmail[email])
- return { email, name }
- })
- res.status(200).csv(data, true)
- })
- .catch(next)
-}
-
-function getMemberPaperPubs(req, res, next) {
- if (!req.session.user.member_admin)
- return res.status(401).json({ status: 'unauthorized' })
- req.app.locals.db
- .any(
- `
- SELECT
- paper_pubs->>'name' AS name,
- paper_pubs->>'address' AS address,
- paper_pubs->>'country' AS country
- FROM People p
- LEFT JOIN membership_types m USING (membership)
- WHERE paper_pubs IS NOT NULL AND m.member = true`
- )
- .then(data => {
- res.status(200).csv(data, true)
- })
- .catch(next)
-}
-
-function getPeople(req, res, next) {
- if (!req.session.user.member_admin && !req.session.user.member_list) {
- return res.status(401).json({ status: 'unauthorized' })
- }
- if (Object.keys(req.query).length > 0) getPeopleQuery(req, res, next)
- else
- req.app.locals.db
- .any(selectAllPeopleData)
- .then(data => {
- const maxId = data.reduce((m, p) => Math.max(m, p.id), -1)
- if (isNaN(maxId)) {
- res.status(500).json({
- status: 'error',
- message: 'Contains non-numeric id?',
- data
- })
- } else {
- const arr = new Array(maxId + 1)
- data.forEach(p => {
- arr[p.id] = Person.fields.reduce(
- (o, fn) => {
- const v = p[fn]
- if (v !== null && v !== false) o[fn] = v
- return o
- },
- { id: p.id }
- )
- })
- res.status(200).json(arr)
- }
- })
- .catch(err => next(err))
-}
-
-function getPerson(req, res, next) {
- const id = parseInt(req.params.id)
- req.app.locals.db
- .one(
- `
- SELECT DISTINCT ON (p.id)
- p.*, preferred_name(p),
- d.status AS daypass, daypass_days(d),
- b.timestamp AS badge_print_time
- FROM people p
- LEFT JOIN daypasses d ON (p.id = d.person_id)
- LEFT JOIN badge_and_daypass_prints b ON (p.id = b.person)
- WHERE p.id = $1
- ORDER BY p.id, b.timestamp`,
- id
- )
- .then(data => res.json(data))
- .catch(next)
-}
-
-function getAllPrevNames(req, res, next) {
- const { user } = req.session
- if (!user || (!user.member_admin && !user.member_list))
- return next(new AuthError())
- const csv = req.params.fmt === 'csv'
- req.app.locals.db
- .any(
- `
- SELECT DISTINCT ON (h.id,h.legal_name)
- h.id,
- p.member_number,
- h.legal_name AS prev_name,
- to_char(h.timestamp, 'YYYY-MM-DD') AS date_from,
- to_char(l.timestamp, 'YYYY-MM-DD') AS date_to,
- p.legal_name AS curr_name,
- p.email AS curr_email
- FROM past_names h
- LEFT JOIN log l ON (h.id=l.subject)
- LEFT JOIN people p ON (l.subject=p.id)
- WHERE l.timestamp > h.timestamp AND
- l.parameters->>'legal_name' IS NOT NULL AND
- name_match(l.parameters->>'legal_name', h.legal_name) = false
- ORDER BY h.id,h.legal_name,l.timestamp`
- )
- .then(data => {
- if (csv) res.csv(data, true)
- else res.json(data)
- })
- .catch(next)
-}
-
-function getPrevNames(req, res, next) {
- const id = parseInt(req.params.id)
- req.app.locals.db
- .any(
- `
- SELECT DISTINCT ON (h.legal_name)
- h.legal_name AS prev_legal_name,
- h.timestamp AS time_from,
- l.timestamp AS time_to
- FROM past_names h LEFT JOIN log l ON (h.id=l.subject)
- WHERE h.id = $1 AND
- l.timestamp > h.timestamp AND
- l.parameters->>'legal_name' IS NOT NULL AND
- name_match(l.parameters->>'legal_name', h.legal_name) = false
- ORDER BY h.legal_name,l.timestamp`,
- id
- )
- .then(data => res.json(data))
- .catch(next)
-}
-
-function addPerson(req, db, person) {
- const passDays = person.passDays
- const status = person.data.membership
- if (passDays.length) {
- person.data.membership = 'NonMember'
- person.data.member_number = null
- }
- const log = new LogEntry(req, 'Add new person')
- let res
- return db
- .tx(tx =>
- tx
- .one(
- `
- INSERT INTO People ${person.sqlValues}
- RETURNING id, member_number`,
- person.data
- )
- .then(data => {
- person.data.id = data.id
- person.data.member_number = data.member_number
- res = data
- log.subject = data.id
- return tx.none(`INSERT INTO Log ${log.sqlValues}`, log)
- })
- .then(() => {
- if (passDays.length === 0) return null
- const trueDays = passDays.map(d => 'true').join(',')
- return tx.none(
- `
- INSERT INTO daypasses (person_id,status,${passDays.join(',')})
- VALUES ($(id),$(status),${trueDays})`,
- { id: res.id, status }
- )
- })
- )
- .then(() => res)
-}
-
-function authAddPerson(req, res, next) {
- if (
- !req.session.user.member_admin ||
- (typeof req.body.member_number !== 'undefined' &&
- !req.session.user.admin_admin)
- ) {
- return res.status(401).json({ status: 'unauthorized' })
- }
- let person
- try {
- person = new Person(req.body)
- } catch (err) {
- return next(err)
- }
- addPerson(req, req.app.locals.db, person)
- .then(({ id, member_number }) =>
- res.status(200).json({ status: 'success', id, member_number })
- )
- .catch(next)
-}
-
-function getUpdateQuery(data, id, isAdmin) {
- const values = Object.assign({}, data, { id })
- const fieldSrc = isAdmin ? Person.fields : Person.userModFields
- const fields = fieldSrc.filter(f => values.hasOwnProperty(f))
- if (fields.length == 0) throw new InputError('No valid parameters')
- let ppCond = ''
- if (fields.indexOf('paper_pubs') >= 0) {
- values.paper_pubs = Person.cleanPaperPubs(values.paper_pubs)
- if (config.paid_paper_pubs && !isAdmin) {
- if (!values.paper_pubs)
- throw new InputError('Removing paid paper publications is not allowed')
- ppCond = 'AND paper_pubs IS NOT NULL'
- }
- }
- const query = `
- WITH prev AS (
- SELECT email, m.hugo_nominator, m.wsfs_member
- FROM people p
- LEFT JOIN membership_types m USING (membership)
- WHERE id=$(id)
- )
- UPDATE People p
- SET ${fields.map(f => `${f}=$(${f})`).join(', ')}
- WHERE id=$(id) ${ppCond}
- RETURNING
- email AS next_email,
- preferred_name(p) as name,
- (SELECT email AS prev_email FROM prev),
- (SELECT hugo_nominator FROM prev),
- (SELECT wsfs_member FROM prev)`
- return { fields, ppCond, query, values }
-}
-
-function updatePerson(req, res, next) {
- const { fields, ppCond, query, values } = getUpdateQuery(
- req.body,
- parseInt(req.params.id),
- req.session.user.member_admin
- )
- const log = new LogEntry(req, 'Update fields: ' + fields.join(', '))
- log.subject = values.id
- req.app.locals.db.task(dbTask => {
- dbTask
- .tx(tx =>
- tx.batch([
- tx.one(query, values),
- values.email
- ? tx.oneOrNone(`SELECT key FROM Keys WHERE email=$(email)`, values)
- : {},
- tx.none(`INSERT INTO Log ${log.sqlValues}`, log)
- ])
- )
- .then(
- ([
- { hugo_nominator, wsfs_member, next_email, prev_email, name },
- prevKey
- ]) => {
- values.email = next_email
- if (next_email !== prev_email) {
- updateMailRecipient(dbTask, prev_email)
- if (hugo_nominator || wsfs_member) {
- return prevKey
- ? { key: prevKey.key, name }
- : setKeyChecked(req, dbTask, { email: values.email }).then(
- ({ key }) => ({ key, name })
- )
- }
- }
- return {}
- }
- )
- .then(
- ({ key, name }) =>
- !!(
- key &&
- mailTask('hugo-update-email', {
- email: values.email,
- key,
- memberId: values.id,
- name
- })
- )
- )
- .then(key_sent => {
- res.json({ status: 'success', updated: fields, key_sent })
- updateMailRecipient(dbTask, values.email)
- })
- .catch(err => {
- if (ppCond && Array.isArray(err) && !err[0].success) {
- const { message } = err.result || {}
- if (message === 'No data returned from the query.') {
- err = new InputError(
- 'Paper publications have not been enabled for this person'
- )
- err.status = 402
- }
- }
- next(err)
- })
- })
-}
diff --git a/kansa/lib/public.js b/kansa/lib/public.js
deleted file mode 100644
index 8fcfa28..0000000
--- a/kansa/lib/public.js
+++ /dev/null
@@ -1,122 +0,0 @@
-const config = require('./config')
-const { AuthError, InputError } = require('./errors')
-
-module.exports = {
- getConfig,
- getDaypassStats,
- getPublicPeople,
- getPublicStats,
- lookupPerson
-}
-
-function getConfig(req, res, next) {
- req.app.locals.db
- .any(
- `
- SELECT membership, badge, hugo_nominator, member, wsfs_member
- FROM membership_types`
- )
- .then(rows => {
- const membershipTypes = {}
- rows.forEach(({ membership, ...props }) => {
- membershipTypes[membership] = props
- })
- res.json(Object.assign({ membershipTypes }, config, { auth: undefined }))
- })
- .catch(next)
-}
-
-function getDaypassStats(req, res, next) {
- const csv = !!req.query.csv
- req.app.locals.db
- .any('SELECT * FROM daypass_stats')
- .then(data => {
- if (csv) res.csv(data, true)
- else {
- const days = { Wed: {}, Thu: {}, Fri: {}, Sat: {}, Sun: {} }
- data.forEach(row => {
- Object.keys(days).forEach(day => {
- if (row[day]) days[day][row.status] = row[day]
- })
- })
- res.json(days)
- }
- })
- .catch(next)
-}
-
-function getPublicPeople(req, res, next) {
- const csv = !!req.query.csv
- req.app.locals.db
- .any('SELECT * FROM public_members')
- .then(data => {
- if (csv) res.csv(data, true)
- else res.json(data)
- })
- .catch(next)
-}
-
-function getPublicStats(req, res, next) {
- const csv = !!req.query.csv
- req.app.locals.db
- .any('SELECT * from country_stats')
- .then(rows => {
- if (csv) return res.csv(rows, true)
- const data = {}
- rows.forEach(({ country, membership, count }) => {
- const c = data[country]
- if (c) c[membership] = Number(count)
- else data[country] = { [membership]: Number(count) }
- })
- res.json(data)
- })
- .catch(next)
-}
-
-function getLookupQuery({ email, member_number, name }) {
- const parts = []
- const values = {}
- if (email && /.@./.test(email)) {
- parts.push('lower(email) = $(email)')
- values.email = email.trim().toLowerCase()
- }
- if (member_number > 0) {
- parts.push('(member_number = $(number) OR id = $(number))')
- values.number = Number(member_number)
- }
- if (name) {
- parts.push(
- '(lower(legal_name) = $(name) OR lower(public_name(p)) = $(name))'
- )
- values.name = name.trim().toLowerCase()
- }
- if (parts.length === 0 || (parts.length === 1 && values.number)) {
- throw new InputError('No valid parameters')
- }
- const query = `
- SELECT id, membership, preferred_name(p) AS name
- FROM people p
- LEFT JOIN membership_types m USING (membership)
- WHERE ${parts.join(' AND ')} AND
- m.allow_lookup = true`
- return { query, values }
-}
-
-function lookupPerson(req, res, next) {
- if (!req.session || !req.session.user || !req.session.user.email)
- return next(new AuthError())
- const { query, values } = getLookupQuery(req.body)
- req.app.locals.db
- .any(query, values)
- .then(results => {
- switch (results.length) {
- case 0:
- return res.json({ status: 'not found' })
- case 1:
- return res.json(Object.assign({ status: 'success' }, results[0]))
- default:
- return res.json({ status: 'multiple' })
- }
- })
- .catch(next)
-}
diff --git a/kansa/lib/slack.js b/kansa/lib/slack.js
deleted file mode 100644
index 6ddb474..0000000
--- a/kansa/lib/slack.js
+++ /dev/null
@@ -1,76 +0,0 @@
-const FormData = require('form-data')
-const fetch = require('node-fetch')
-
-const { AuthError, InputError } = require('./errors')
-
-module.exports = { invite }
-
-function sendInvite(org, data) {
- const body = new FormData()
- Object.keys(data).forEach(key => body.append(key, data[key]))
- return fetch(`https://${org}.slack.com/api/users.admin.invite`, {
- method: 'POST',
- body
- })
- .then(res => res.json())
- .then(({ ok, error, needed }) => {
- if (!ok)
- switch (error) {
- case 'already_invited':
- case 'already_in_team':
- throw new InputError(
- 'You have already been invited to Slack. Look for an email from ' +
- `"feedback@slack.com" to ${JSON.stringify(
- data.email
- )} and join us at ` +
- `https://${org}.slack.com/`
- )
- case 'invalid_email':
- throw new InputError(
- `The email address ${JSON.stringify(data.email)} is invalid`
- )
- case 'missing_scope':
- throw new Error(`Slack token missing ${needed} scope!`)
- default:
- throw new Error(`Slack invite error: ${error}`)
- }
- })
-}
-
-function getUserData(db, session) {
- const email = session && session.user && session.user.email
- if (!email) return Promise.reject(new AuthError())
- let select = `
- SELECT public_first_name, public_last_name
- FROM People p
- LEFT JOIN membership_types m USING (membership)
- WHERE email = $1`
- if (process.env.SLACK_REQ_MEMBER) select += ` AND m.member = true`
- return db.any(select, email).then(people => {
- if (people.size === 0)
- throw new AuthError('Slack access requires membership')
- const user = { email }
- if (people.size === 1) {
- const { public_first_name, public_last_name } = people[0]
- if (public_first_name) user.first_name = public_first_name
- if (public_last_name) user.last_name = public_last_name
- }
- return user
- })
-}
-
-function invite(req, res, next) {
- const org = process.env.SLACK_ORG
- const token = process.env.SLACK_TOKEN
- if (!org || !token)
- return next(
- new Error('The SLACK_ORG and SLACK_TOKEN env vars are required')
- )
- getUserData(req.app.locals.db, req.session)
- .then(user =>
- sendInvite(org, Object.assign({ token }, user)).then(() =>
- res.json({ success: true, email: user.email })
- )
- )
- .catch(next)
-}
diff --git a/kansa/lib/types/admin.js b/kansa/lib/types/admin.js
deleted file mode 100644
index 3009db9..0000000
--- a/kansa/lib/types/admin.js
+++ /dev/null
@@ -1,46 +0,0 @@
-class Admin {
- static get fields() {
- return [
- 'email', // text PRIMARY KEY
- 'member_admin', // bool NOT NULL DEFAULT false
- 'member_list', // bool NOT NULL DEFAULT false
- 'siteselection', // bool NOT NULL DEFAULT false
- 'hugo_admin', // bool NOT NULL DEFAULT false
- 'raami_admin', // bool NOT NULL DEFAULT false
- 'admin_admin' // bool NOT NULL DEFAULT false
- ]
- }
-
- static get roleFields() {
- return [
- 'member_admin',
- 'member_list',
- 'siteselection',
- 'hugo_admin',
- 'raami_admin',
- 'admin_admin'
- ]
- }
-
- static get sqlRoles() {
- return Admin.roleFields.join(', ')
- }
-
- static get sqlValues() {
- const fields = Admin.fields
- const values = fields.map(fn => `$(${fn})`).join(', ')
- return `(${fields.join(', ')}) VALUES(${values})`
- }
-
- constructor(email) {
- this.email = email
- this.member_admin = false
- this.member_list = false
- this.siteselection = false
- this.hugo_admin = false
- this.raami_admin = false
- this.admin_admin = false
- }
-}
-
-module.exports = Admin
diff --git a/kansa/lib/types/inputerror.js b/kansa/lib/types/inputerror.js
deleted file mode 100644
index a0092e2..0000000
--- a/kansa/lib/types/inputerror.js
+++ /dev/null
@@ -1,9 +0,0 @@
-function InputError(message) {
- this.name = 'InputError'
- this.message = message || 'Input error'
- this.stack = new Error().stack
-}
-InputError.prototype = Object.create(Error.prototype)
-InputError.prototype.constructor = InputError
-
-module.exports = InputError
diff --git a/kansa/lib/types/logentry.js b/kansa/lib/types/logentry.js
deleted file mode 100644
index 65c3ace..0000000
--- a/kansa/lib/types/logentry.js
+++ /dev/null
@@ -1,45 +0,0 @@
-class LogEntry {
- static get fields() {
- return [
- // id SERIAL PRIMARY KEY
- 'timestamp', // timestamptz NOT NULL DEFAULT now()
- 'client_ip', // text NOT NULL
- 'client_ua', // text
- 'author', // text
- 'subject', // integer REFERENCES People
- 'action', // text NOT NULL
- 'parameters', // jsonb NOT NULL
- 'description' // text NOT NULL
- ]
- }
-
- constructor(req, desc = '') {
- this.timestamp = null
- if (
- req.session &&
- req.session.user &&
- req.session.user.member_admin &&
- req.body &&
- req.body.timestamp
- ) {
- const ts = new Date(req.body.timestamp)
- if (ts > 0) this.timestamp = ts.toISOString()
- }
- this.client_ip = req.ip
- this.client_ua = req.headers['user-agent'] || null
- this.author = (req.session.user && req.session.user.email) || null
- this.subject = null
- this.action = req.method + ' ' + req.baseUrl + req.path
- this.parameters = Object.assign({}, req.query, req.body)
- delete this.parameters.key
- this.description = desc
- }
-
- get sqlValues() {
- const fields = LogEntry.fields.filter(fn => this[fn] !== null)
- const values = fields.map(fn => `$(${fn})`).join(', ')
- return `(${fields.join(', ')}) VALUES(${values})`
- }
-}
-
-module.exports = LogEntry
diff --git a/kansa/lib/upgrade.js b/kansa/lib/upgrade.js
deleted file mode 100644
index 438f0fe..0000000
--- a/kansa/lib/upgrade.js
+++ /dev/null
@@ -1,132 +0,0 @@
-const LogEntry = require('./types/logentry')
-const Person = require('./types/person')
-const InputError = require('./types/inputerror')
-const { updateMailRecipient } = require('./mail')
-
-module.exports = { authUpgradePerson, upgradePerson }
-
-function upgradePaperPubs(req, db, data) {
- if (!data.paper_pubs) throw new InputError('No valid parameters')
- const log = new LogEntry(req, 'Add paper pubs')
- return db
- .tx(tx =>
- tx.batch([
- tx.one(
- `
- UPDATE People p
- SET paper_pubs=$(paper_pubs)
- FROM membership_types m
- WHERE id=$(id) AND
- m.membership = p.membership AND
- m.member = true
- RETURNING member_number`,
- data
- ),
- tx.none(`INSERT INTO Log ${log.sqlValues}`, log)
- ])
- )
- .then(results => ({
- member_number: results[0].member_number,
- updated: ['paper_pubs']
- }))
- .catch(err => {
- if (
- !err[0].success &&
- err[1].success &&
- err[0].result.message == 'No data returned from the query.'
- ) {
- const err2 = new Error(
- 'Paper publications are only available for members'
- )
- err2.status = 402
- throw err2
- } else {
- throw err
- }
- })
-}
-
-function getUpgradeQuery(data, addMemberNumber) {
- const fields = ['membership']
- let update = 'membership=$(membership)'
- if (addMemberNumber) {
- fields.push('member_number')
- update += ", member_number=nextval('member_number_seq')"
- }
- if (data.paper_pubs) {
- fields.push('paper_pubs')
- update += ', paper_pubs=$(paper_pubs)'
- }
- const query = `
- UPDATE people SET ${update} WHERE id=$(id)
- RETURNING email, member_number`
- return { fields, query }
-}
-
-function upgradeMembership(req, db, data) {
- return db.task(dbTask =>
- dbTask
- .batch([
- dbTask.any(`SELECT * FROM membership_prices`),
- dbTask.one(
- `SELECT membership, member_number FROM People WHERE id=$1`,
- data.id
- )
- ])
- .then(([priceRows, prev]) => {
- const nextPrice = priceRows.find(p => p.membership === data.membership)
- if (!nextPrice) {
- const strType = JSON.stringify(data.membership)
- throw new InputError(`Invalid membership type: ${strType}`)
- }
- const prevPrice = priceRows.find(p => p.membership === prev.membership)
- if (prevPrice && prevPrice.amount > nextPrice.amount) {
- throw new InputError(
- `Can't upgrade from ${prev.membership} to ${data.membership}`
- )
- }
- const addMemberNumber = !parseInt(prev.member_number)
- const { fields, query } = getUpgradeQuery(data, addMemberNumber)
- return dbTask.tx(tx =>
- tx.one(query, data).then(({ email, member_number }) => {
- const log = new LogEntry(req, `Upgrade to ${data.membership}`)
- if (data.paper_pubs) log.description += ' and add paper pubs'
- log.subject = data.id
- return tx
- .none(`INSERT INTO Log ${log.sqlValues}`, log)
- .then(() => ({ email, fields, member_number }))
- })
- )
- })
- .then(({ email, fields, member_number }) => {
- updateMailRecipient(dbTask, email)
- return { member_number, updated: fields }
- })
- )
-}
-
-function upgradePerson(req, db, data) {
- if (data.hasOwnProperty('paper_pubs')) {
- try {
- data.paper_pubs = Person.cleanPaperPubs(data.paper_pubs)
- } catch (err) {
- return Promise.reject(err)
- }
- }
- return data.membership
- ? upgradeMembership(req, db, data)
- : upgradePaperPubs(req, db, data)
-}
-
-function authUpgradePerson(req, res, next) {
- if (!req.session.user.member_admin)
- return res.status(401).json({ status: 'unauthorized' })
- const data = Object.assign({}, req.body, {
- id: parseInt(req.params.id)
- })
- upgradePerson(req, req.app.locals.db, data)
- .then(({ member_number, updated }) =>
- res.status(200).json({ status: 'success', member_number, updated })
- )
- .catch(next)
-}
diff --git a/kansa/lib/user.js b/kansa/lib/user.js
deleted file mode 100644
index 77d35f7..0000000
--- a/kansa/lib/user.js
+++ /dev/null
@@ -1,151 +0,0 @@
-const jwt = require('jsonwebtoken')
-const { promisify } = require('util')
-const config = require('./config')
-const { AuthError, InputError } = require('./errors')
-const { resetExpiredKey } = require('./key')
-const Admin = require('./types/admin')
-const LogEntry = require('./types/logentry')
-const { selectAllPeopleData } = require('./people')
-const util = require('./util')
-
-module.exports = { authenticate, verifyPeopleAccess, login, logout, getInfo }
-
-function authenticate(req, res, next) {
- if (req.session && req.session.user && req.session.user.email) next()
- else res.status(401).json({ status: 'unauthorized' })
-}
-
-function verifyPeopleAccess(req, res, next) {
- const id = parseInt(req.params.id)
- const user = req.session.user
- if (isNaN(id) || id < 0)
- res.status(400).json({ status: 'error', message: 'Bad id number' })
- else if (user.member_admin || (req.method === 'GET' && user.member_list))
- next()
- else
- req.app.locals.db
- .oneOrNone('SELECT email FROM People WHERE id = $1', id)
- .then(data => {
- if (data && user.email === data.email) next()
- else res.status(401).json({ status: 'unauthorized' })
- })
- .catch(err => next(err))
-}
-
-function login(req, res, next) {
- const cookieOptions = {
- files: { httpOnly: true, path: '/member-files', secure: true },
- session: { httpOnly: true, path: '/', maxAge: config.auth.session_timeout }
- }
- const email = (req.body && req.body.email) || req.query.email
- const key = (req.body && req.body.key) || req.query.key
- req.app.locals.db
- .task(async ts => {
- if (!email || !key)
- throw new InputError('Email and key are required for login')
- const user = await ts.oneOrNone(
- `
- SELECT
- k.email,
- k.expires IS NOT NULL AND k.expires < now() AS expired,
- ${Admin.sqlRoles}
- FROM kansa.Keys k
- LEFT JOIN admin.Admins a USING (email)
- WHERE email=$(email) AND key=$(key)`,
- { email, key }
- )
- if (!user) throw new AuthError(`Email and key don't match`)
- if (user.expired) {
- const path = req.body && req.body.path
- await resetExpiredKey(req, ts, { email, path })
- res.clearCookie('files', cookieOptions.files)
- res.clearCookie(config.id, cookieOptions.session)
- return res.status(403).json({ status: 'expired', email })
- }
- req.session.user = user
- const token = await promisify(jwt.sign)(
- { scope: 'wsfs' },
- process.env.JWT_SECRET,
- {
- expiresIn: 120 * 60,
- subject: email
- }
- )
- res.cookie('files', token, cookieOptions.files)
- res.json({ status: 'success', email })
- const log = new LogEntry(req, 'Login')
- ts.none(`INSERT INTO Log ${log.sqlValues}`, log)
- })
- .catch(error => {
- res.clearCookie('files', cookieOptions.files)
- res.clearCookie(config.id, cookieOptions.session)
- next(error)
- })
-}
-
-function logout(req, res, next) {
- const data = Object.assign({}, req.query, req.body)
- const opt = ['all', 'reset'].reduce(
- (prev, o) => (util.isTrueish(data[o]) ? o : prev),
- null
- )
- // null: log out this session only, 'all': log out all sessions, 'reset': also reset/forget login key
- const user = req.session.user
- if (data.email && !user.admin_admin)
- return res.status(401).json({ status: 'unauthorized' })
- // only admin_admin can log out other users
- const email = data.email || user.email
- if (email === user.email) delete req.session.user
- else if (!opt)
- return res
- .status(400)
- .json({ status: 'error', message: 'Add all=1 or reset=1 to parameters' })
- // if logging out someone else, make it clear what we're doing
- if (!opt) return res.status(200).json({ status: 'success', email })
- req.app.locals.db
- .task(t => {
- const tasks = [
- t.any(
- `DELETE FROM "session" WHERE sess #>> '{user, email}' = $1 RETURNING sid`,
- email
- )
- ]
- if (opt === 'reset')
- tasks.push(t.none(`DELETE FROM Keys WHERE email = $1`, email))
- return t.batch(tasks)
- })
- .then(data => {
- const sessions = data[0].length
- if (!sessions)
- res.status(400).json({ status: 'error', email, opt, sessions })
- else res.status(200).json({ status: 'success', email, opt, sessions })
- })
- .catch(err => next(err))
-}
-
-function getInfo(req, res, next) {
- const email =
- (req.session.user.member_admin && req.query.email) || req.session.user.email
- req.app.locals.db
- .task(t =>
- t.batch([
- t.any(
- `${selectAllPeopleData} WHERE email=$1
- ORDER BY coalesce(public_last_name, preferred_name(p))`,
- email
- ),
- t.oneOrNone(
- `SELECT ${Admin.sqlRoles} FROM admin.Admins WHERE email=$1`,
- email
- )
- ])
- )
- .then(data => {
- res.status(200).json({
- email,
- people: data[0],
- roles: data[1] ? Object.keys(data[1]).filter(r => data[1][r]) : []
- })
- })
- .catch(err => next(err))
-}
diff --git a/kansa/lib/util.js b/kansa/lib/util.js
deleted file mode 100644
index 3ccd10e..0000000
--- a/kansa/lib/util.js
+++ /dev/null
@@ -1,25 +0,0 @@
-module.exports = { isTrueish, forceBool, forceInt }
-
-function isTrueish(v) {
- if (!v) return false
- if (typeof v === 'boolean') return v
- const s = v
- .toString()
- .trim()
- .toLowerCase()
- return s !== '' && s !== '0' && s !== 'false' && s !== 'null'
-}
-
-function forceBool(obj, prop) {
- const src = obj[prop]
- if (obj.hasOwnProperty(prop) && typeof src !== 'boolean') {
- obj[prop] = isTrueish(src)
- }
-}
-
-function forceInt(obj, prop) {
- const src = obj[prop]
- if (obj.hasOwnProperty(prop) && !Number.isInteger(src)) {
- obj[prop] = src ? parseInt(src) : null
- }
-}
diff --git a/kansa/wait-for-it.sh b/kansa/wait-for-it.sh
deleted file mode 100755
index eca6c3b..0000000
--- a/kansa/wait-for-it.sh
+++ /dev/null
@@ -1,161 +0,0 @@
-#!/usr/bin/env bash
-# Use this script to test if a given TCP host/port are available
-
-cmdname=$(basename $0)
-
-echoerr() { if [[ $QUIET -ne 1 ]]; then echo "$@" 1>&2; fi }
-
-usage()
-{
- cat << USAGE >&2
-Usage:
- $cmdname host:port [-s] [-t timeout] [-- command args]
- -h HOST | --host=HOST Host or IP under test
- -p PORT | --port=PORT TCP port under test
- Alternatively, you specify the host and port as host:port
- -s | --strict Only execute subcommand if the test succeeds
- -q | --quiet Don't output any status messages
- -t TIMEOUT | --timeout=TIMEOUT
- Timeout in seconds, zero for no timeout
- -- COMMAND ARGS Execute command with args after the test finishes
-USAGE
- exit 1
-}
-
-wait_for()
-{
- if [[ $TIMEOUT -gt 0 ]]; then
- echoerr "$cmdname: waiting $TIMEOUT seconds for $HOST:$PORT"
- else
- echoerr "$cmdname: waiting for $HOST:$PORT without a timeout"
- fi
- start_ts=$(date +%s)
- while :
- do
- (echo > /dev/tcp/$HOST/$PORT) >/dev/null 2>&1
- result=$?
- if [[ $result -eq 0 ]]; then
- end_ts=$(date +%s)
- echoerr "$cmdname: $HOST:$PORT is available after $((end_ts - start_ts)) seconds"
- break
- fi
- sleep 1
- done
- return $result
-}
-
-wait_for_wrapper()
-{
- # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692
- if [[ $QUIET -eq 1 ]]; then
- timeout $TIMEOUT $0 --quiet --child --host=$HOST --port=$PORT --timeout=$TIMEOUT &
- else
- timeout $TIMEOUT $0 --child --host=$HOST --port=$PORT --timeout=$TIMEOUT &
- fi
- PID=$!
- trap "kill -INT -$PID" INT
- wait $PID
- RESULT=$?
- if [[ $RESULT -ne 0 ]]; then
- echoerr "$cmdname: timeout occurred after waiting $TIMEOUT seconds for $HOST:$PORT"
- fi
- return $RESULT
-}
-
-# process arguments
-while [[ $# -gt 0 ]]
-do
- case "$1" in
- *:* )
- hostport=(${1//:/ })
- HOST=${hostport[0]}
- PORT=${hostport[1]}
- shift 1
- ;;
- --child)
- CHILD=1
- shift 1
- ;;
- -q | --quiet)
- QUIET=1
- shift 1
- ;;
- -s | --strict)
- STRICT=1
- shift 1
- ;;
- -h)
- HOST="$2"
- if [[ $HOST == "" ]]; then break; fi
- shift 2
- ;;
- --host=*)
- HOST="${1#*=}"
- shift 1
- ;;
- -p)
- PORT="$2"
- if [[ $PORT == "" ]]; then break; fi
- shift 2
- ;;
- --port=*)
- PORT="${1#*=}"
- shift 1
- ;;
- -t)
- TIMEOUT="$2"
- if [[ $TIMEOUT == "" ]]; then break; fi
- shift 2
- ;;
- --timeout=*)
- TIMEOUT="${1#*=}"
- shift 1
- ;;
- --)
- shift
- CLI="$@"
- break
- ;;
- --help)
- usage
- ;;
- *)
- echoerr "Unknown argument: $1"
- usage
- ;;
- esac
-done
-
-if [[ "$HOST" == "" || "$PORT" == "" ]]; then
- echoerr "Error: you need to provide a host and port to test."
- usage
-fi
-
-TIMEOUT=${TIMEOUT:-15}
-STRICT=${STRICT:-0}
-CHILD=${CHILD:-0}
-QUIET=${QUIET:-0}
-
-if [[ $CHILD -gt 0 ]]; then
- wait_for
- RESULT=$?
- exit $RESULT
-else
- if [[ $TIMEOUT -gt 0 ]]; then
- wait_for_wrapper
- RESULT=$?
- else
- wait_for
- RESULT=$?
- fi
-fi
-
-if [[ $CLI != "" ]]; then
- if [[ $RESULT -ne 0 && $STRICT -eq 1 ]]; then
- echoerr "$cmdname: strict mode, refusing to execute subprocess"
- exit $RESULT
- fi
- exec $CLI
-else
- exit $RESULT
-fi
diff --git a/modules/README.md b/modules/README.md
new file mode 100644
index 0000000..6134b5f
--- /dev/null
+++ b/modules/README.md
@@ -0,0 +1,33 @@
+# Kansa Server Modules
+
+The functionality of the core Kansa member management system is extended by
+modules, which are loaded as Express.js router apps at a base path matching
+their name.
+
+To add/enable a module, it needs to be included in the `config/kansa.yaml`
+configuration's `modules` section, and the module needs to be loadable by the
+server from a path `/kansa/modules/${name}`. If the module has dependencies that
+are not satisfied by the Kansa server's dependencies, those dependencies will
+need to be installed -- see the server's `Dockerfile` for an example.
+
+To disable a module's API endpoints, set its configuration to a falsy value.
+This configuration will also be visible to the client. Do note that changes in
+configuration require a server restart to be applied.
+
+For modules that need database setup you'll need to handle that separately.
+
+The [actual code](../server/app.js) that loads a module looks like this:
+
+```js
+Object.keys(config.modules).forEach(name => {
+ const mc = config.modules[name]
+ if (mc) {
+ const mp = path.resolve(__dirname, 'modules', name)
+ const module = require(mp)
+ app.use(`/${name}`, module(db, mc))
+ }
+})
+```
+
+Here `db` is the default `pg-promise` database instance. To create an instance
+with different connection options, you may use `db.$config.pgp(url)`.
diff --git a/modules/admin/lib/admin.js b/modules/admin/lib/admin.js
new file mode 100644
index 0000000..11ca808
--- /dev/null
+++ b/modules/admin/lib/admin.js
@@ -0,0 +1,56 @@
+const randomstring = require('randomstring')
+const config = require('@kansa/common/config')
+const { InputError } = require('@kansa/common/errors')
+const isTrueish = require('@kansa/common/trueish')
+
+module.exports = { getAdmins, setAdmin, setAllKeys }
+
+function getAdmins(db) {
+ return db.any('SELECT * FROM admin.Admins')
+}
+
+function setAdmin(db, data) {
+ const fields = config.auth.admin_roles.filter(fn => data.hasOwnProperty(fn))
+ if (!data.email || fields.length == 0) {
+ return Promise.reject(new InputError('Missing email or valid fields'))
+ }
+ const fCols = fields.join(', ')
+ const fValues = fields.map(fn => `$(${fn})`).join(', ')
+ const fSet = fields.map(fn => `${fn} = EXCLUDED.${fn}`).join(', ')
+ fields.forEach(fn => {
+ if (data.hasOwnProperty(fn) && typeof data[fn] !== 'boolean') {
+ data[fn] = isTrueish(data[fn])
+ }
+ })
+ return db.none(
+ `INSERT INTO admin.Admins (email, ${fCols})
+ VALUES ($(email), ${fValues})
+ ON CONFLICT (email) DO UPDATE SET ${fSet}`,
+ data
+ )
+}
+
+function setAllKeys(db) {
+ return db.tx(async tx => {
+ const data = await tx.any(
+ `SELECT DISTINCT p.email, a.email IS NOT NULL AS is_admin
+ FROM people p
+ LEFT JOIN keys k USING (email)
+ LEFT JOIN admin.admins a USING (email)
+ WHERE k.email IS NULL`
+ )
+ const kt = config.auth.key_timeout
+ await tx.sequence(i => {
+ if (!data[i]) return undefined
+ const { email, is_admin } = data[i]
+ const key = randomstring.generate(12)
+ const maxAge = (is_admin ? kt.admin : kt.normal) / 1000
+ return tx.any(
+ `INSERT INTO Keys (email, key, expires)
+ VALUES ($(email), $(key), now() + $(maxAge) * interval '1 second')`,
+ { email, key, maxAge }
+ )
+ })
+ return data.length
+ })
+}
diff --git a/modules/admin/lib/router.js b/modules/admin/lib/router.js
new file mode 100644
index 0000000..e2f3aa4
--- /dev/null
+++ b/modules/admin/lib/router.js
@@ -0,0 +1,40 @@
+const express = require('express')
+const { hasRole } = require('@kansa/common/auth-user')
+const LogEntry = require('@kansa/common/log-entry')
+const { setAllMailRecipients } = require('@kansa/common/mail')
+const { getAdmins, setAdmin, setAllKeys } = require('./admin')
+
+module.exports = db => {
+ const router = express.Router()
+ router.use(hasRole('admin_admin'))
+
+ router.get('/', (req, res, next) => {
+ getAdmins(db)
+ .then(data => res.json(data))
+ .catch(next)
+ })
+
+ router.post('/', (req, res, next) => {
+ const data = Object.assign({}, req.body)
+ db.tx(async tx => {
+ await setAdmin(tx, data)
+ await new LogEntry(req, 'Set admin rights for ' + data.email).write(tx)
+ })
+ .then(() => res.json({ status: 'success', set: data }))
+ .catch(next)
+ })
+
+ router.post('/set-keys', (req, res, next) => {
+ setAllKeys(db)
+ .then(count => res.json({ status: 'success', count }))
+ .catch(next)
+ })
+
+ router.post('/set-recipients', (req, res, next) => {
+ setAllMailRecipients(db)
+ .then(count => res.json({ success: true, count }))
+ .catch(next)
+ })
+
+ return router
+}
diff --git a/modules/admin/package.json b/modules/admin/package.json
new file mode 100644
index 0000000..098e680
--- /dev/null
+++ b/modules/admin/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@kansa/admin",
+ "version": "1.0.0",
+ "description": "Kansa admin functionality",
+ "private": true,
+ "license": "Apache-2.0",
+ "repository": "maailma/kansa",
+ "main": "lib/router.js",
+ "peerDependencies": {
+ "@kansa/common": "^1.1.0",
+ "express": "4.x",
+ "randomstring": "1.x"
+ }
+}
diff --git a/postgres/init/30-hugo-init.sql b/modules/hugo/database/init.sql
similarity index 100%
rename from postgres/init/30-hugo-init.sql
rename to modules/hugo/database/init.sql
diff --git a/postgres/init/32-hugo-tables.sql b/modules/hugo/database/tables.sql
similarity index 100%
rename from postgres/init/32-hugo-tables.sql
rename to modules/hugo/database/tables.sql
diff --git a/hugo/lib/admin/admin.js b/modules/hugo/lib/admin/admin.js
similarity index 99%
rename from hugo/lib/admin/admin.js
rename to modules/hugo/lib/admin/admin.js
index 5e967c3..ca730a6 100644
--- a/hugo/lib/admin/admin.js
+++ b/modules/hugo/lib/admin/admin.js
@@ -1,4 +1,4 @@
-const { InputError } = require('../errors')
+const { InputError } = require('@kansa/common/errors')
const countVotes = require('./vote-count')
class Admin {
diff --git a/hugo/lib/admin/canon-stream.js b/modules/hugo/lib/admin/canon-stream.js
similarity index 100%
rename from hugo/lib/admin/canon-stream.js
rename to modules/hugo/lib/admin/canon-stream.js
diff --git a/hugo/lib/admin/router.js b/modules/hugo/lib/admin/router.js
similarity index 80%
rename from hugo/lib/admin/router.js
rename to modules/hugo/lib/admin/router.js
index 87a7993..d8b4762 100644
--- a/hugo/lib/admin/router.js
+++ b/modules/hugo/lib/admin/router.js
@@ -1,15 +1,11 @@
const express = require('express')
-const { AuthError } = require('../errors')
+const { hasRole } = require('@kansa/common/auth-user')
const Admin = require('./admin')
const CanonStream = require('./canon-stream')
module.exports = (pgp, db) => {
const router = express.Router()
- router.use((req, res, next) => {
- const { user } = req.session
- if (user && user.hugo_admin) next()
- else next(new AuthError())
- })
+ router.use(hasRole('hugo_admin'))
const admin = new Admin(pgp, db)
router.get('/ballots', admin.getAllBallots)
diff --git a/hugo/lib/admin/vote-count.js b/modules/hugo/lib/admin/vote-count.js
similarity index 100%
rename from hugo/lib/admin/vote-count.js
rename to modules/hugo/lib/admin/vote-count.js
diff --git a/hugo/lib/config.js b/modules/hugo/lib/config.js
similarity index 100%
rename from hugo/lib/config.js
rename to modules/hugo/lib/config.js
diff --git a/hugo/lib/nominate.js b/modules/hugo/lib/nominate.js
similarity index 93%
rename from hugo/lib/nominate.js
rename to modules/hugo/lib/nominate.js
index 4afcc4c..8150c34 100644
--- a/hugo/lib/nominate.js
+++ b/modules/hugo/lib/nominate.js
@@ -1,5 +1,5 @@
-const fetch = require('node-fetch')
-const { AuthError, InputError } = require('./errors')
+const { AuthError, InputError } = require('@kansa/common/errors')
+const { sendMail } = require('@kansa/common/mail')
class Nominate {
static access(req, db) {
@@ -67,10 +67,9 @@ class Nominate {
)
])
.then(([person, nominations]) =>
- fetch(url, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
+ sendMail(
+ 'hugo-update-nominations',
+ {
email: person.email,
key: person.key,
memberId: id,
@@ -80,8 +79,9 @@ class Nominate {
.join(' ')
.trim() || person.legal_name,
nominations
- })
- })
+ },
+ 30
+ )
)
.catch(err => console.error(err))
}
diff --git a/hugo/lib/router.js b/modules/hugo/lib/router.js
similarity index 76%
rename from hugo/lib/router.js
rename to modules/hugo/lib/router.js
index e9ec3db..bbb3ac6 100644
--- a/hugo/lib/router.js
+++ b/modules/hugo/lib/router.js
@@ -3,8 +3,12 @@ const adminRouter = require('./admin/router')
const Nominate = require('./nominate')
const Vote = require('./vote')
-module.exports = (pgp, dbUrl) => {
- const db = pgp(dbUrl)
+module.exports = origDb => {
+ const url = process.env.HUGO_PG_URL
+ if (!url) throw new Error('The hugo module requires the HUGO_PG_URL env var')
+ const { pgp } = origDb.$config
+ const db = pgp(url)
+
const router = express.Router()
router.use('/admin', adminRouter(pgp, db))
diff --git a/hugo/lib/vote.js b/modules/hugo/lib/vote.js
similarity index 79%
rename from hugo/lib/vote.js
rename to modules/hugo/lib/vote.js
index b81b8aa..abf5d11 100644
--- a/hugo/lib/vote.js
+++ b/modules/hugo/lib/vote.js
@@ -1,7 +1,7 @@
const jwt = require('jsonwebtoken')
-const AuthError = require('./errors').AuthError
-const InputError = require('./errors').InputError
-const sendEmail = require('./kyyhky-send-email')
+const { matchesId } = require('@kansa/common/auth-user')
+const { AuthError, InputError } = require('@kansa/common/errors')
+const { sendMail } = require('@kansa/common/mail')
class Vote {
constructor(pgp, db) {
@@ -27,32 +27,25 @@ class Vote {
db.many(`${pgp.helpers.insert(data, svCS)} RETURNING time`)
}
- access(req) {
+ access(req, requireVoter) {
+ if (!requireVoter) return matchesId(this.db, req, 'hugo_admin')
const id = parseInt(req.params.id)
+ const { user } = req.session
if (isNaN(id) || id < 0)
return Promise.reject(new InputError('Bad id number'))
- if (!req.session || !req.session.user || !req.session.user.email)
- return Promise.reject(new AuthError())
+ if (!user || !user.email) return Promise.reject(new AuthError())
+ if (user.hugo_admin) return Promise.resolve(id)
return this.db
.oneOrNone(
- `
- SELECT p.email, m.wsfs_member
- FROM kansa.People p
- LEFT JOIN kansa.membership_types m USING (membership)
- WHERE id = $1`,
+ `SELECT wsfs_member
+ FROM kansa.people LEFT JOIN kansa.membership_types USING (membership)
+ WHERE id = $1 AND email = $2`,
id
)
.then(data => {
- if (
- !data ||
- (!req.session.user.hugo_admin &&
- req.session.user.email !== data.email)
- )
- throw new AuthError()
- return {
- id,
- voter: !!data.wsfs_member
- }
+ if (!data) throw new AuthError()
+ if (!data.wsfs_member) throw new AuthError('Not a Hugo voter')
+ return id
})
}
@@ -62,7 +55,7 @@ class Vote {
`SELECT category, id, title, subtitle FROM Finalists ORDER BY sortindex, id`
)
.then(data =>
- res.status(200).json(
+ res.json(
data.reduce((res, { category, id, title, subtitle }) => {
const finalist = { id, title, subtitle: subtitle || undefined }
if (res[category]) res[category].push(finalist)
@@ -76,11 +69,10 @@ class Vote {
getPacket(req, res, next) {
const options = { httpOnly: true, path: '/hugo-packet', secure: true }
- this.access(req)
+ this.access(req, true)
.then(
- ({ id, voter }) =>
+ id =>
new Promise((resolve, reject) => {
- if (!voter) return reject(new AuthError('Not a Hugo voter'))
jwt.sign(
{ scope: 'wsfs' },
process.env.JWT_SECRET,
@@ -123,33 +115,28 @@ class Vote {
}
packetSeriesExtra(req, res, next) {
- this.access(req)
- .then(({ id, voter }) => {
- if (!voter) throw new AuthError()
- return this.db.one(
- `
- SELECT email, kansa.preferred_name(p) as name
- FROM kansa.People AS p
- WHERE id = $1`,
+ this.access(req, true)
+ .then(id =>
+ this.db.one(
+ `SELECT email, kansa.preferred_name(p) AS name
+ FROM kansa.people AS p WHERE id = $1`,
id
)
- })
+ )
.then(({ email, name }) =>
- sendEmail('hugo-packet-series-extra', { email, name })
+ sendMail('hugo-packet-series-extra', { email, name })
)
.then(() => res.json({ status: 'success' }))
.catch(next)
}
getVotes(req, res, next) {
- this.access(req)
- .then(({ id }) =>
+ this.access(req, false)
+ .then(id =>
this.db.any(
- `
- SELECT DISTINCT ON (category) category, votes, time
- FROM Votes
- WHERE person_id = $1
- ORDER BY category, time DESC`,
+ `SELECT DISTINCT ON (category) category, votes, time
+ FROM Votes WHERE person_id = $1
+ ORDER BY category, time DESC`,
id
)
)
@@ -199,7 +186,7 @@ class Vote {
])
)
.then(([person, votes]) =>
- sendEmail(
+ sendMail(
'hugo-update-votes',
Object.assign({ memberId: id, votes }, person),
30
@@ -219,9 +206,8 @@ class Vote {
return next(new InputError(e.message))
}
let data = null
- this.access(req)
- .then(({ id, voter }) => {
- if (!voter) throw new AuthError()
+ this.access(req, true)
+ .then(id => {
data = Object.keys(votes).map(category => ({
client_ip: req.ip,
client_ua: req.headers['user-agent'] || null,
diff --git a/modules/hugo/package.json b/modules/hugo/package.json
new file mode 100644
index 0000000..36b3649
--- /dev/null
+++ b/modules/hugo/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "@kansa/hugo",
+ "version": "1.0.0",
+ "description": "API for Hugo Awards",
+ "private": true,
+ "license": "Apache-2.0",
+ "repository": "maailma/kansa",
+ "main": "lib/router.js",
+ "peerDependencies": {
+ "@kansa/common": "^1.1.0",
+ "express": "4.x",
+ "express-ws": ">=2",
+ "jsonwebtoken": ">=7",
+ "yaml": "^1.0.0-rc.7"
+ }
+}
diff --git a/modules/public/lib/public.js b/modules/public/lib/public.js
new file mode 100644
index 0000000..4fad237
--- /dev/null
+++ b/modules/public/lib/public.js
@@ -0,0 +1,37 @@
+const config = require('@kansa/common/config')
+
+module.exports = {
+ getDaypassStats,
+ getPublicPeople,
+ getPublicStats
+}
+
+function getDaypassStats(db, csv) {
+ return db.any('SELECT * FROM daypass_stats').then(rows => {
+ if (csv) return rows
+ const data = { Wed: {}, Thu: {}, Fri: {}, Sat: {}, Sun: {} }
+ rows.forEach(row => {
+ Object.keys(data).forEach(day => {
+ if (row[day]) data[day][row.status] = row[day]
+ })
+ })
+ return data
+ })
+}
+
+function getPublicPeople(db) {
+ return db.any('SELECT * FROM public_members')
+}
+
+function getPublicStats(db, csv) {
+ return db.any('SELECT * from country_stats').then(rows => {
+ if (csv) return rows
+ const data = {}
+ rows.forEach(({ country, membership, count }) => {
+ const c = data[country]
+ if (c) c[membership] = Number(count)
+ else data[country] = { [membership]: Number(count) }
+ })
+ return data
+ })
+}
diff --git a/modules/public/lib/router.js b/modules/public/lib/router.js
new file mode 100644
index 0000000..6e2e170
--- /dev/null
+++ b/modules/public/lib/router.js
@@ -0,0 +1,32 @@
+const cors = require('cors')
+const express = require('express')
+
+const { getDaypassStats, getPublicPeople, getPublicStats } = require('./public')
+
+module.exports = (db, ctx, cfg) => {
+ const router = express.Router()
+ if (cfg.cors_origin) router.use(cors({ origin: cfg.cors_origin }))
+
+ router.get('/people', (req, res, next) => {
+ const csv = !!req.query.csv
+ getPublicPeople(db)
+ .then(data => (csv ? res.csv(data, true) : res.json(data)))
+ .catch(next)
+ })
+
+ router.get('/stats', (req, res, next) => {
+ const csv = !!req.query.csv
+ getPublicStats(db, csv)
+ .then(data => (csv ? res.csv(data, true) : res.json(data)))
+ .catch(next)
+ })
+
+ router.get('/daypass-stats', (req, res, next) => {
+ const csv = !!req.query.csv
+ getDaypassStats(db, csv)
+ .then(data => (csv ? res.csv(data, true) : res.json(data)))
+ .catch(next)
+ })
+
+ return router
+}
diff --git a/modules/public/package.json b/modules/public/package.json
new file mode 100644
index 0000000..a32a6be
--- /dev/null
+++ b/modules/public/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@kansa/public",
+ "version": "1.0.0",
+ "description": "Kansa public data endpoints",
+ "private": true,
+ "license": "Apache-2.0",
+ "repository": "maailma/kansa",
+ "main": "lib/router.js",
+ "peerDependencies": {
+ "@kansa/common": "^1.1.0",
+ "express": "4.x",
+ "randomstring": "1.x"
+ }
+}
diff --git a/postgres/init/40-raami-init.sql b/modules/raami/database/init.sql
similarity index 100%
rename from postgres/init/40-raami-init.sql
rename to modules/raami/database/init.sql
diff --git a/modules/raami/lib/queries.js b/modules/raami/lib/queries.js
new file mode 100644
index 0000000..7218114
--- /dev/null
+++ b/modules/raami/lib/queries.js
@@ -0,0 +1,261 @@
+const archiver = require('archiver')
+const fs = require('fs')
+const path = require('path')
+const { matchesId } = require('@kansa/common/auth-user')
+
+class Queries {
+ constructor(db) {
+ this.db = db
+ this.upsertArtist = this.upsertArtist.bind(this)
+ this.getArtist = this.getArtist.bind(this)
+ this.getWork = this.getWork.bind(this)
+ this.getWorks = this.getWorks.bind(this)
+ this.createWork = this.createWork.bind(this)
+ this.updateWork = this.updateWork.bind(this)
+ this.removeWork = this.removeWork.bind(this)
+ this.exportArtists = this.exportArtists.bind(this)
+ this.exportPreview = this.exportPreview.bind(this)
+ this.exportWorks = this.exportWorks.bind(this)
+ }
+
+ access(req) {
+ return matchesId(this.db, req, 'raami_admin')
+ }
+
+ getArtist(req, res, next) {
+ this.access(req)
+ .then(id =>
+ this.db.oneOrNone(`SELECT * FROM Artist WHERE people_id = $1`, id)
+ )
+ .then(data => res.json(data || {}))
+ .catch(next)
+ }
+
+ upsertArtist(req, res, next) {
+ this.access(req)
+ .then(id => {
+ const artist = Object.assign({}, req.body, { people_id: id })
+ const keys = [
+ 'people_id',
+ 'name',
+ 'continent',
+ 'url',
+ 'filename',
+ 'filedata',
+ 'category',
+ 'description',
+ 'transport',
+ 'auction',
+ 'print',
+ 'digital',
+ 'legal',
+ 'agent',
+ 'contact',
+ 'waitlist',
+ 'postage',
+ 'half'
+ ].filter(key => artist.hasOwnProperty(key))
+ const insertValues = keys.map(key => `$(${key})`).join(', ')
+ const insertArtist = `(${keys.join(', ')}) VALUES(${insertValues})`
+ const updateArtist = keys.map(key => `${key}=$(${key})`).join(', ')
+ return this.db.one(
+ `
+ INSERT INTO Artist ${insertArtist}
+ ON CONFLICT (people_id)
+ DO UPDATE SET ${updateArtist}
+ RETURNING people_id`,
+ artist
+ )
+ })
+ .then(people_id => res.json({ status: 'success', people_id }))
+ .catch(next)
+ }
+
+ /**** WORKS ***/
+
+ getWorks(req, res, next) {
+ this.access(req)
+ .then(id => this.db.any(`SELECT * FROM Works WHERE people_id=$1`, id))
+ .then(data => res.json(data))
+ .catch(next)
+ }
+
+ getWork(req, res, next) {
+ this.access(req)
+ .then(id => {
+ const params = Object.assign({}, req.params, { people_id: id })
+ this.db.one(
+ `SELECT * FROM Works WHERE id=$(work) AND people_id=$(people_id)`,
+ params
+ )
+ })
+ .then(data => res.json(data))
+ .catch(next)
+ }
+
+ createWork(req, res, next) {
+ this.access(req)
+ .then(id => {
+ const work = Object.assign({}, req.body, { people_id: id })
+ const keys = [
+ 'people_id',
+ 'title',
+ 'width',
+ 'height',
+ 'depth',
+ 'gallery',
+ 'original',
+ 'orientation',
+ 'technique',
+ 'filename',
+ 'filedata',
+ 'year',
+ 'price',
+ 'start',
+ 'sale',
+ 'copies',
+ 'form',
+ 'permission'
+ ].filter(key => work.hasOwnProperty(key))
+ const insertValues = keys.map(key => `$(${key})`).join(', ')
+ return this.db.one(
+ `
+ INSERT INTO Works
+ (${keys.join(', ')})
+ VALUES (${insertValues})
+ RETURNING id`,
+ work
+ )
+ })
+ .then(({ id }) => res.json({ status: 'success', inserted: id }))
+ .catch(next)
+ }
+
+ updateWork(req, res, next) {
+ this.access(req)
+ .then(id => {
+ const work = Object.assign({}, req.body, {
+ people_id: id,
+ work: req.params.work
+ })
+ const keys = [
+ 'people_id',
+ 'title',
+ 'width',
+ 'height',
+ 'depth',
+ 'gallery',
+ 'original',
+ 'orientation',
+ 'technique',
+ 'filename',
+ 'filedata',
+ 'year',
+ 'price',
+ 'start',
+ 'sale',
+ 'copies',
+ 'form',
+ 'permission'
+ ].filter(key => work.hasOwnProperty(key))
+ const updateWork = keys.map(key => `${key}=$(${key})`).join(', ')
+ return this.db.none(
+ `
+ UPDATE Works
+ SET ${updateWork}
+ WHERE id=$(work) AND people_id=$(people_id)`,
+ work
+ )
+ })
+ .then(() => res.json({ status: 'success' }))
+ .catch(next)
+ }
+
+ removeWork(req, res, next) {
+ this.access(req)
+ .then(id =>
+ this.db.result(
+ `
+ DELETE FROM Works
+ WHERE id=$(work) AND people_id=$(people_id)`,
+ { people_id: id, work: req.params.work }
+ )
+ )
+ .then(() => res.json({ status: 'success' }))
+ .catch(next)
+ }
+
+ /**** exports ****/
+
+ exportArtists(req, res, next) {
+ this.db
+ .any(
+ `
+ SELECT p.member_number, p.membership, p.legal_name, p.email, p.city, p.country,
+ a.name, a.continent, a.url,
+ a.category, a.description, a.transport, a.auction, a.print, a.digital, a.half,
+ a.legal, a.agent, a.contact, a.waitlist, a.postage
+ FROM Artist as a, kansa.people as p WHERE a.people_id = p.ID order by p.member_number
+ `
+ )
+ .then(data => res.csv(data, true))
+ .catch(next)
+ }
+
+ exportPreview(req, res, next) {
+ //const dir = '/tmp/raamitmp/'
+ const output = fs.createWriteStream('/tmp/raamipreview.zip')
+ const zip = archiver('zip', { store: true })
+ output.on('close', () => {
+ console.log(zip.pointer() + ' total bytes')
+ fs.stat('/tmp/raamipreview.zip', (err, stats) => {
+ if (err) return next(err)
+ console.log(stats)
+ })
+ res.sendFile(path.resolve('/tmp/raamipreview.zip'))
+ })
+ zip.on('error', next)
+ this.db
+ .any(
+ `
+ SELECT w.filedata, w.filename, a.name
+ FROM Works w LEFT JOIN Artist a USING (people_id)
+ WHERE w.filedata IS NOT NULL`
+ )
+ .then(data => {
+ zip.pipe(output)
+ for (img of data) {
+ const imgdata = img.filedata.match(
+ /^data:([A-Za-z-+\/]*);base64,(.+)$/
+ )
+ if (imgdata) {
+ const buffer3 = new Buffer.from(imgdata[2], 'base64')
+ zip.append(buffer3, { name: img.name + '_' + img.filename })
+ }
+ // fs.writeFile(dir+img.name+'_'+img.filename, img.filedata, (err)=>{
+ // if (err) throw err
+ // })
+ // console.log(dir+img.name+'_'+img.filename+' saved')
+ }
+ //archive.directory(dir);
+ zip.finalize()
+ })
+ .catch(next)
+ }
+
+ exportWorks(req, res, next) {
+ this.db
+ .any(
+ `
+ SELECT a.name, a.people_id AS artist_id, w.id AS work_id,
+ w.title, w.width, w.height, w.depth, w.technique, w.orientation,
+ w.graduation, w.filename, w.price, w.gallery, w.year, w.original,
+ w.copies, w.start, w.sale, w.permission, w.form
+ FROM Works w LEFT JOIN Artist a USING (people_id)`
+ )
+ .then(data => res.csv(data, true))
+ .catch(next)
+ }
+}
+
+module.exports = Queries
diff --git a/modules/raami/lib/router.js b/modules/raami/lib/router.js
new file mode 100644
index 0000000..138771e
--- /dev/null
+++ b/modules/raami/lib/router.js
@@ -0,0 +1,40 @@
+const bodyParser = require('body-parser')
+require('csv-express')
+const express = require('express')
+const { isSignedIn, hasRole } = require('@kansa/common/auth-user')
+const Queries = require('./queries')
+
+module.exports = origDb => {
+ const url = process.env.RAAMI_PG_URL
+ if (!url)
+ throw new Error('The raami module requires the RAAMI_PG_URL env var')
+ const { pgp } = origDb.$config
+ const db = pgp(url)
+
+ const router = express.Router()
+ router.use(bodyParser.json({ limit: '2mb' }))
+ router.use(
+ bodyParser.urlencoded({
+ limit: '2mb',
+ extended: true,
+ parameterLimit: 2000
+ })
+ )
+ router.use(isSignedIn)
+
+ const queries = new Queries(db)
+ router.get('/:id/artist', queries.getArtist)
+ router.post('/:id/artist', queries.upsertArtist)
+
+ router.get('/:id/works', queries.getWorks)
+ router.put('/:id/works', queries.createWork)
+ router.post('/:id/works/:work', queries.updateWork)
+ router.delete('/:id/works/:work', queries.removeWork)
+
+ router.use('/export', hasRole('raami_admin'))
+ router.get('/export/artists', queries.exportArtists)
+ router.get('/export/preview', queries.exportPreview)
+ router.get('/export/works.csv', queries.exportWorks)
+
+ return router
+}
diff --git a/modules/raami/package.json b/modules/raami/package.json
new file mode 100644
index 0000000..29ffd69
--- /dev/null
+++ b/modules/raami/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "@kansa/raami",
+ "version": "1.0.0",
+ "description": "API for art show",
+ "main": "lib/router.js",
+ "private": true,
+ "license": "Apache-2.0",
+ "repository": "maailma/kansa",
+ "dependencies": {
+ "archiver": "^1.3.0"
+ },
+ "peerDependencies": {
+ "@kansa/common": "^1.1.0",
+ "body-parser": "1.*",
+ "csv-express": "1.*",
+ "express": "4.*"
+ }
+}
diff --git a/modules/slack/lib/router.js b/modules/slack/lib/router.js
new file mode 100644
index 0000000..c66b359
--- /dev/null
+++ b/modules/slack/lib/router.js
@@ -0,0 +1,11 @@
+const express = require('express')
+const { isSignedIn } = require('@kansa/common/auth-user')
+const Slack = require('./slack')
+
+module.exports = (db, ctx, config) => {
+ const router = express.Router()
+ const slack = new Slack(db, config)
+ router.use(isSignedIn)
+ router.post('/invite', slack.invite)
+ return router
+}
diff --git a/modules/slack/lib/slack.js b/modules/slack/lib/slack.js
new file mode 100644
index 0000000..17eaca5
--- /dev/null
+++ b/modules/slack/lib/slack.js
@@ -0,0 +1,78 @@
+const FormData = require('form-data')
+const fetch = require('node-fetch')
+const { AuthError, InputError } = require('@kansa/common/errors')
+
+function sendInvite(org, data) {
+ const body = new FormData()
+ Object.keys(data).forEach(key => body.append(key, data[key]))
+ return fetch(`https://${org}.slack.com/api/users.admin.invite`, {
+ method: 'POST',
+ body
+ })
+ .then(res => res.json())
+ .then(({ ok, error, needed }) => {
+ if (!ok)
+ switch (error) {
+ case 'already_invited':
+ case 'already_in_team':
+ throw new InputError(
+ 'You have already been invited to Slack. Look for an email from ' +
+ `"feedback@slack.com" to ${JSON.stringify(
+ data.email
+ )} and join us at ` +
+ `https://${org}.slack.com/`
+ )
+ case 'invalid_email':
+ throw new InputError(
+ `The email address ${JSON.stringify(data.email)} is invalid`
+ )
+ case 'missing_scope':
+ throw new Error(`Slack token missing ${needed} scope!`)
+ default:
+ throw new Error(`Slack invite error: ${error}`)
+ }
+ })
+}
+
+class Slack {
+ constructor(db, { org, require_membership } = {}) {
+ this.db = db
+ this.org = org
+ this.reqMembership = !!require_membership
+ this.token = process.env.SLACK_TOKEN
+ if (!this.org) throw new Error('The Slack org config value is required')
+ if (!this.token) throw new Error('The SLACK_TOKEN env var is required')
+ this.invite = this.invite.bind(this)
+ }
+
+ getUserData({ email }) {
+ let select = `
+ SELECT public_first_name, public_last_name
+ FROM People LEFT JOIN membership_types USING (membership)
+ WHERE email = $1`
+ if (this.reqMembership) select += ` AND m.member = true`
+ return this.db.any(select, email).then(people => {
+ if (people.size === 0)
+ throw new AuthError('Slack access requires membership')
+ const user = { email }
+ if (people.size === 1) {
+ const { public_first_name, public_last_name } = people[0]
+ if (public_first_name) user.first_name = public_first_name
+ if (public_last_name) user.last_name = public_last_name
+ }
+ return user
+ })
+ }
+
+ invite(req, res, next) {
+ return this.getUserData(req.session.user)
+ .then(user =>
+ sendInvite(this.org, Object.assign({ token: this.token }, user)).then(
+ () => res.json({ success: true, email: user.email })
+ )
+ )
+ .catch(next)
+ }
+}
+
+module.exports = Slack
diff --git a/modules/slack/package.json b/modules/slack/package.json
new file mode 100644
index 0000000..02aaf90
--- /dev/null
+++ b/modules/slack/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "@kansa/slack",
+ "version": "1.0.0",
+ "description": "Slack invites for Kansa",
+ "private": true,
+ "license": "Apache-2.0",
+ "repository": "maailma/kansa",
+ "main": "lib/router.js",
+ "dependencies": {
+ "form-data": "^2.3.2"
+ },
+ "peerDependencies": {
+ "@kansa/common": "^1.1.0",
+ "express": "4.x",
+ "node-fetch": "*"
+ }
+}
diff --git a/postgres/init/22-kansa-tables.sql b/postgres/init/22-kansa-tables.sql
index f7e4c1e..22fe341 100644
--- a/postgres/init/22-kansa-tables.sql
+++ b/postgres/init/22-kansa-tables.sql
@@ -103,8 +103,8 @@ DECLARE
ac text;
bc text;
BEGIN
- ac := lower(trim(regexp_replace(a, '\s+', ' ', 'g')));
- bc := lower(trim(regexp_replace(b, '\s+', ' ', 'g')));
+ ac := substr(lower(trim(regexp_replace(a, '\s+', ' ', 'g'))), 0, 255);
+ bc := substr(lower(trim(regexp_replace(b, '\s+', ' ', 'g'))), 0, 255);
RETURN levenshtein_less_equal(ac, bc, 3) <= 3;
END;
$$ LANGUAGE plpgsql;
diff --git a/nginx/Dockerfile b/proxy/Dockerfile
similarity index 100%
rename from nginx/Dockerfile
rename to proxy/Dockerfile
diff --git a/nginx/favicon.ico b/proxy/favicon.ico
similarity index 100%
rename from nginx/favicon.ico
rename to proxy/favicon.ico
diff --git a/nginx/hugo-admin.html b/proxy/hugo-admin.html
similarity index 100%
rename from nginx/hugo-admin.html
rename to proxy/hugo-admin.html
diff --git a/nginx/hugo-packet.lua b/proxy/hugo-packet.lua
similarity index 100%
rename from nginx/hugo-packet.lua
rename to proxy/hugo-packet.lua
diff --git a/nginx/hugo-packet/README.zip b/proxy/hugo-packet/README.zip
similarity index 100%
rename from nginx/hugo-packet/README.zip
rename to proxy/hugo-packet/README.zip
diff --git a/nginx/index.html b/proxy/index.html
similarity index 100%
rename from nginx/index.html
rename to proxy/index.html
diff --git a/nginx/kansa-admin.html b/proxy/kansa-admin.html
similarity index 100%
rename from nginx/kansa-admin.html
rename to proxy/kansa-admin.html
diff --git a/nginx/member-files.lua b/proxy/member-files.lua
similarity index 100%
rename from nginx/member-files.lua
rename to proxy/member-files.lua
diff --git a/nginx/member-files/index.html b/proxy/member-files/index.html
similarity index 100%
rename from nginx/member-files/index.html
rename to proxy/member-files/index.html
diff --git a/nginx/nginx.conf b/proxy/nginx.conf
similarity index 92%
rename from nginx/nginx.conf
rename to proxy/nginx.conf
index 12ca2b3..a8eaa98 100644
--- a/nginx/nginx.conf
+++ b/proxy/nginx.conf
@@ -30,16 +30,8 @@ http {
proxy_cache_path /tmp/nginx keys_zone=public:1m max_size=10m inactive=10m use_temp_path=off;
-upstream hugo {
- server hugo:80;
-}
-
upstream kansa {
- server kansa:80;
-}
-
-upstream raami {
- server raami:3000;
+ server server:80;
}
server {
@@ -78,16 +70,12 @@ server {
charset utf-8;
location /api/raami/ {
- proxy_pass http://raami/;
client_max_body_size 0;
- }
-
- location /api/hugo/ {
- proxy_pass http://hugo/;
+ proxy_pass http://kansa/raami/;
}
location = /api/hugo/admin/canon-updates {
- proxy_pass http://hugo/admin/canon-updates;
+ proxy_pass http://kansa/hugo/admin/canon-updates;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s; # 24h
diff --git a/nginx/ssl/localhost.cert b/proxy/ssl/localhost.cert
similarity index 100%
rename from nginx/ssl/localhost.cert
rename to proxy/ssl/localhost.cert
diff --git a/nginx/ssl/localhost.key b/proxy/ssl/localhost.key
similarity index 100%
rename from nginx/ssl/localhost.key
rename to proxy/ssl/localhost.key
diff --git a/raami/Dockerfile b/raami/Dockerfile
deleted file mode 100644
index dd74240..0000000
--- a/raami/Dockerfile
+++ /dev/null
@@ -1,3 +0,0 @@
-FROM node:6-onbuild
-
-EXPOSE 3000
diff --git a/raami/app.js b/raami/app.js
deleted file mode 100644
index 603605c..0000000
--- a/raami/app.js
+++ /dev/null
@@ -1,94 +0,0 @@
-var express = require('express')
-const cors = require('cors')
-var path = require('path')
-var logger = require('morgan')
-var cookieParser = require('cookie-parser')
-var bodyParser = require('body-parser')
-
-const session = require('express-session')
-const pgSession = require('connect-pg-simple')(session)
-
-var promise = require('bluebird')
-
-var options = {
- // Initialization Options
- promiseLib: promise
-}
-
-var pgp = require('pg-promise')(options)
-var db = pgp(process.env.DATABASE_URL)
-
-var config = require('./config')
-var routes = require('./routes')
-
-var app = express()
-
-app.locals.db = db
-
-app.use(bodyParser.json({ limit: '2mb' }))
-app.use(
- bodyParser.urlencoded({ limit: '2mb', extended: true, parameterLimit: 2000 })
-)
-app.use(logger('dev'))
-app.use(bodyParser.json())
-app.use(bodyParser.urlencoded({ extended: false }))
-app.use(cookieParser())
-app.use(express.static(path.join(__dirname, 'public')))
-
-const corsOrigins = process.env.CORS_ORIGIN
-if (corsOrigins)
- app.use(
- cors({
- credentials: true,
- origin: corsOrigins.split(/[ ,]+/)
- })
- )
-app.use(
- session({
- cookie: { maxAge: 30 * 24 * 60 * 60 * 1000 }, // 30 days
- name: config.id,
- resave: false,
- saveUninitialized: false,
- secret: process.env.SESSION_SECRET,
- store: new pgSession({
- pg: pgp.PG,
- pruneSessionInterval: 24 * 60 * 60 // 1 day
- })
- })
-)
-
-app.use('/', routes)
-
-// catch 404 and forward to error handler
-app.use(function(req, res, next) {
- var err = new Error('Not Found')
- err.status = 404
- next(err)
-})
-
-// error handlers
-
-// development error handler
-// will print stacktrace
-const isDevEnv = app.get('env') === 'development'
-if (app.get('env') === 'development') {
- app.use((err, req, res, next) => {
- const error = err.error || err
- const data = { status: 'error', message: error.message }
- if (isDevEnv) data.error = err
- const status =
- err.status || error.status || (error.name == 'InputError' && 400) || 500
- res.status(status).json(data)
- })
-}
-
-// production error handler
-// no stacktraces leaked to user
-app.use(function(err, req, res, next) {
- res.status(err.code || 500).json({
- status: 'error',
- message: err
- })
-})
-
-module.exports = app
diff --git a/raami/bin/www b/raami/bin/www
deleted file mode 100755
index 973ac7e..0000000
--- a/raami/bin/www
+++ /dev/null
@@ -1,90 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * Module dependencies.
- */
-
-var app = require('../app');
-var debug = require('debug')('raami:server');
-var http = require('http');
-
-/**
- * Get port from environment and store in Express.
- */
-
-var port = normalizePort(process.env.PORT || '3000');
-app.set('port', port);
-
-/**
- * Create HTTP server.
- */
-
-var server = http.createServer(app);
-
-/**
- * Listen on provided port, on all network interfaces.
- */
-
-server.listen(port);
-server.on('error', onError);
-server.on('listening', onListening);
-
-/**
- * Normalize a port into a number, string, or false.
- */
-
-function normalizePort(val) {
- var port = parseInt(val, 10);
-
- if (isNaN(port)) {
- // named pipe
- return val;
- }
-
- if (port >= 0) {
- // port number
- return port;
- }
-
- return false;
-}
-
-/**
- * Event listener for HTTP server "error" event.
- */
-
-function onError(error) {
- if (error.syscall !== 'listen') {
- throw error;
- }
-
- var bind = typeof port === 'string'
- ? 'Pipe ' + port
- : 'Port ' + port;
-
- // handle specific listen errors with friendly messages
- switch (error.code) {
- case 'EACCES':
- console.error(bind + ' requires elevated privileges');
- process.exit(1);
- break;
- case 'EADDRINUSE':
- console.error(bind + ' is already in use');
- process.exit(1);
- break;
- default:
- throw error;
- }
-}
-
-/**
- * Event listener for HTTP server "listening" event.
- */
-
-function onListening() {
- var addr = server.address();
- var bind = typeof addr === 'string'
- ? 'pipe ' + addr
- : 'port ' + addr.port;
- debug('Listening on ' + bind);
-}
diff --git a/raami/config.js b/raami/config.js
deleted file mode 100644
index dd2d3a3..0000000
--- a/raami/config.js
+++ /dev/null
@@ -1,42 +0,0 @@
-const fs = require('fs')
-const YAML = require('yaml').default
-
-const src = fs.readFileSync('/kansa.yaml', 'utf8')
-const config = YAML.parse(src)
-
-const shape = {
- id: /^\w+$/
-}
-
-function checkConfig(key, config, shape) {
- if (shape instanceof RegExp) {
- if (typeof config !== 'string') {
- throw new Error(
- `Expected string value for '${key}', but found ${typeof config}`
- )
- }
- if (!shape.test(config)) {
- throw new Error(
- `Expected value for '${key}' to match regular expression ${shape}`
- )
- }
- } else if (typeof shape === 'object') {
- if (typeof config !== 'object') {
- throw new Error(
- key
- ? `Expected object value for '${key}', but found ${typeof config}`
- : `Expected configuration object, but found ${typeof config}`
- )
- }
- Object.keys(shape).forEach(k => {
- checkConfig(key ? `${key}.${k}` : k, config[k], shape[k])
- })
- } else if (typeof config !== shape) {
- throw new Error(
- `Expected ${shape} value for '${key}', but found ${typeof config}`
- )
- }
-}
-checkConfig('', config, shape)
-
-module.exports = config
diff --git a/raami/errors.js b/raami/errors.js
deleted file mode 100644
index 6c16ba0..0000000
--- a/raami/errors.js
+++ /dev/null
@@ -1,16 +0,0 @@
-function AuthError(message = 'Unauthorized') {
- this.name = 'AuthError'
- this.message = message
- this.status = 401
-}
-AuthError.prototype = new Error()
-
-function InputError(message = 'Input error') {
- this.name = 'InputError'
- this.message = message
- this.status = 400
- this.stack = new Error().stack
-}
-InputError.prototype = new Error()
-
-module.exports = { AuthError, InputError }
diff --git a/raami/package.json b/raami/package.json
deleted file mode 100644
index 1f912c5..0000000
--- a/raami/package.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
- "name": "raami",
- "version": "1.0.0",
- "description": "API for art show",
- "private": true,
- "license": "Apache-2.0",
- "repository": "maailma/kansa",
- "scripts": {
- "start": "node --debug ./bin/www"
- },
- "dependencies": {
- "archiver": "^1.3.0",
- "bluebird": "^3.3.4",
- "body-parser": "^1.13.3",
- "connect-pg-simple": "^3.1.2",
- "cookie-parser": "~1.3.5",
- "cors": "^2.8.1",
- "csv-express": "^1.2.0",
- "debug": "~2.2.0",
- "express": "~4.13.1",
- "express-session": "^1.15.0",
- "morgan": "~1.6.1",
- "pg-promise": "^3.2.3",
- "yaml": "^1.0.0-rc.7"
- }
-}
diff --git a/raami/queries.js b/raami/queries.js
deleted file mode 100644
index 7795a01..0000000
--- a/raami/queries.js
+++ /dev/null
@@ -1,288 +0,0 @@
-const AuthError = require('./errors').AuthError
-const InputError = require('./errors').InputError
-const csv = require('csv-express')
-const fs = require('fs')
-const path = require('path')
-const archiver = require('archiver')
-
-module.exports = {
- upsertArtist,
- getArtist,
- getWork,
- getWorks,
- createWork,
- updateWork,
- removeWork,
- exportArtists,
- exportPreview,
- exportWorks
-}
-
-function access(req) {
- const id = parseInt(req.params.id)
- if (isNaN(id) || id < 0)
- return Promise.reject(new InputError('Bad id number'))
- if (!req.session || !req.session.user || !req.session.user.email)
- return Promise.reject(new AuthError())
- return req.app.locals.db
- .oneOrNone('SELECT email FROM kansa.People WHERE id = $1', id)
- .then(data => {
- if (
- !data ||
- (!req.session.user.raami_admin && req.session.user.email !== data.email)
- )
- throw new AuthError()
- return {
- id
- }
- })
-}
-
-function getArtist(req, res, next) {
- access(req)
- .then(({ id }) =>
- req.app.locals.db.oneOrNone(
- `SELECT * FROM Artist WHERE people_id = $1`,
- id
- )
- )
- .then(data => res.status(200).json(data || {}))
- .catch(next)
-}
-
-function upsertArtist(req, res, next) {
- access(req)
- .then(({ id }) => {
- const artist = Object.assign({}, req.body, { people_id: id })
- const keys = [
- 'people_id',
- 'name',
- 'continent',
- 'url',
- 'filename',
- 'filedata',
- 'category',
- 'description',
- 'transport',
- 'auction',
- 'print',
- 'digital',
- 'legal',
- 'agent',
- 'contact',
- 'waitlist',
- 'postage',
- 'half'
- ].filter(key => artist.hasOwnProperty(key))
- const insertValues = keys.map(key => `$(${key})`).join(', ')
- const insertArtist = `(${keys.join(', ')}) VALUES(${insertValues})`
- const updateArtist = keys.map(key => `${key}=$(${key})`).join(', ')
- return req.app.locals.db.one(
- `
- INSERT INTO Artist ${insertArtist}
- ON CONFLICT (people_id)
- DO UPDATE SET ${updateArtist}
- RETURNING people_id`,
- artist
- )
- })
- .then(people_id => res.status(200).json({ status: 'success', people_id }))
- .catch(next)
-}
-
-/**** WORKS ***/
-
-function getWorks(req, res, next) {
- access(req)
- .then(({ id }) =>
- req.app.locals.db.any(`SELECT * FROM Works WHERE people_id=$1`, id)
- )
- .then(data => res.status(200).json(data))
- .catch(next)
-}
-
-function getWork(req, res, next) {
- access(req)
- .then(({ id }) => {
- const params = Object.assign({}, req.params, { people_id: id })
- req.app.locals.db.one(
- `SELECT * FROM Works WHERE id=$(work) AND people_id=$(people_id)`,
- params
- )
- })
- .then(data => res.status(200).json(data))
- .catch(next)
-}
-
-function createWork(req, res, next) {
- access(req)
- .then(({ id }) => {
- const work = Object.assign({}, req.body, { people_id: id })
- const keys = [
- 'people_id',
- 'title',
- 'width',
- 'height',
- 'depth',
- 'gallery',
- 'original',
- 'orientation',
- 'technique',
- 'filename',
- 'filedata',
- 'year',
- 'price',
- 'start',
- 'sale',
- 'copies',
- 'form',
- 'permission'
- ].filter(key => work.hasOwnProperty(key))
- const insertValues = keys.map(key => `$(${key})`).join(', ')
- return req.app.locals.db.one(
- `
- INSERT INTO Works
- (${keys.join(', ')})
- VALUES (${insertValues})
- RETURNING id`,
- work
- )
- })
- .then(({ id }) => res.status(200).json({ status: 'success', inserted: id }))
- .catch(next)
-}
-
-function updateWork(req, res, next) {
- access(req)
- .then(({ id }) => {
- const work = Object.assign({}, req.body, {
- people_id: id,
- work: req.params.work
- })
- const keys = [
- 'people_id',
- 'title',
- 'width',
- 'height',
- 'depth',
- 'gallery',
- 'original',
- 'orientation',
- 'technique',
- 'filename',
- 'filedata',
- 'year',
- 'price',
- 'start',
- 'sale',
- 'copies',
- 'form',
- 'permission'
- ].filter(key => work.hasOwnProperty(key))
- const updateWork = keys.map(key => `${key}=$(${key})`).join(', ')
- return req.app.locals.db.none(
- `
- UPDATE Works
- SET ${updateWork}
- WHERE id=$(work) AND people_id=$(people_id)`,
- work
- )
- })
- .then(() => res.status(200).json({ status: 'success' }))
- .catch(next)
-}
-
-function removeWork(req, res, next) {
- access(req)
- .then(({ id }) =>
- req.app.locals.db.result(
- `
- DELETE FROM Works
- WHERE id=$(work) AND people_id=$(people_id)`,
- { people_id: id, work: req.params.work }
- )
- )
- .then(() => res.status(200).json({ status: 'success' }))
- .catch(next)
-}
-
-/**** exports ****/
-
-function exportArtists(req, res, next) {
- if (!req.session.user.raami_admin)
- return res.status(401).json({ status: 'unauthorized' })
- req.app.locals.db
- .any(
- `
- SELECT p.member_number, p.membership, p.legal_name, p.email, p.city, p.country,
- a.name, a.continent, a.url,
- a.category, a.description, a.transport, a.auction, a.print, a.digital, a.half,
- a.legal, a.agent, a.contact, a.waitlist, a.postage
- FROM Artist as a, kansa.people as p WHERE a.people_id = p.ID order by p.member_number
- `
- )
- .then(data => res.status(200).csv(data, true))
- .catch(next)
-}
-
-function exportPreview(req, res, next) {
- //const dir = '/tmp/raamitmp/'
- const output = fs.createWriteStream('/tmp/raamipreview.zip')
- const zip = archiver('zip', {
- store: true // Sets the compression method to STORE.
- })
- output.on('close', () => {
- console.log(zip.pointer() + ' total bytes')
- fs.stat('/tmp/raamipreview.zip', (err, stats) => {
- if (err) return console.error(err)
- console.log(stats)
- })
- res.sendFile(path.resolve('/tmp/raamipreview.zip'))
- })
- zip.on('error', err => {
- throw err
- })
-
- if (!req.session.user.raami_admin)
- return res.status(401).json({ status: 'unauthorized' })
- req.app.locals.db
- .any(
- `
- SELECT w.filedata, w.filename, a.name
- FROM Works w LEFT JOIN Artist a USING (people_id)
- WHERE w.filedata IS NOT NULL`
- )
- .then(data => {
- zip.pipe(output)
- for (img of data) {
- const imgdata = img.filedata.match(/^data:([A-Za-z-+\/]*);base64,(.+)$/)
- if (imgdata) {
- const buffer3 = new Buffer.from(imgdata[2], 'base64')
- zip.append(buffer3, { name: img.name + '_' + img.filename })
- }
- // fs.writeFile(dir+img.name+'_'+img.filename, img.filedata, (err)=>{
- // if (err) throw err
- // })
- // console.log(dir+img.name+'_'+img.filename+' saved')
- }
- //archive.directory(dir);
- zip.finalize()
- })
- .catch(next)
-}
-
-function exportWorks(req, res, next) {
- const { user } = req.session || {}
- if (!user || !user.raami_admin) return next(new AuthError())
- req.app.locals.db
- .any(
- `
- SELECT a.name, a.people_id AS artist_id, w.id AS work_id,
- w.title, w.width, w.height, w.depth, w.technique, w.orientation,
- w.graduation, w.filename, w.price, w.gallery, w.year, w.original,
- w.copies, w.start, w.sale, w.permission, w.form
- FROM Works w LEFT JOIN Artist a USING (people_id)`
- )
- .then(data => res.csv(data, true))
- .catch(next)
-}
diff --git a/raami/routes.js b/raami/routes.js
deleted file mode 100644
index 6ade4fc..0000000
--- a/raami/routes.js
+++ /dev/null
@@ -1,29 +0,0 @@
-var express = require('express')
-var router = express.Router()
-
-function authenticate(req, res, next) {
- if (req.session && req.session.user && req.session.user.email) next()
- else res.status(401).json({ status: 'unauthorized' })
-}
-
-router.get('/', function(req, res, next) {
- res.render('index', { title: 'Express' })
-})
-
-var db = require('./queries')
-
-router.use(authenticate)
-
-router.get('/:id/artist', db.getArtist)
-router.post('/:id/artist', db.upsertArtist)
-
-router.get('/:id/works', db.getWorks)
-router.put('/:id/works', db.createWork)
-router.post('/:id/works/:work', db.updateWork)
-router.delete('/:id/works/:work', db.removeWork)
-
-router.get('/export/artists', db.exportArtists)
-router.get('/export/preview', db.exportPreview)
-router.get('/export/works.csv', db.exportWorks)
-
-module.exports = router
diff --git a/raami/wait-for-it.sh b/raami/wait-for-it.sh
deleted file mode 100755
index eca6c3b..0000000
--- a/raami/wait-for-it.sh
+++ /dev/null
@@ -1,161 +0,0 @@
-#!/usr/bin/env bash
-# Use this script to test if a given TCP host/port are available
-
-cmdname=$(basename $0)
-
-echoerr() { if [[ $QUIET -ne 1 ]]; then echo "$@" 1>&2; fi }
-
-usage()
-{
- cat << USAGE >&2
-Usage:
- $cmdname host:port [-s] [-t timeout] [-- command args]
- -h HOST | --host=HOST Host or IP under test
- -p PORT | --port=PORT TCP port under test
- Alternatively, you specify the host and port as host:port
- -s | --strict Only execute subcommand if the test succeeds
- -q | --quiet Don't output any status messages
- -t TIMEOUT | --timeout=TIMEOUT
- Timeout in seconds, zero for no timeout
- -- COMMAND ARGS Execute command with args after the test finishes
-USAGE
- exit 1
-}
-
-wait_for()
-{
- if [[ $TIMEOUT -gt 0 ]]; then
- echoerr "$cmdname: waiting $TIMEOUT seconds for $HOST:$PORT"
- else
- echoerr "$cmdname: waiting for $HOST:$PORT without a timeout"
- fi
- start_ts=$(date +%s)
- while :
- do
- (echo > /dev/tcp/$HOST/$PORT) >/dev/null 2>&1
- result=$?
- if [[ $result -eq 0 ]]; then
- end_ts=$(date +%s)
- echoerr "$cmdname: $HOST:$PORT is available after $((end_ts - start_ts)) seconds"
- break
- fi
- sleep 1
- done
- return $result
-}
-
-wait_for_wrapper()
-{
- # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692
- if [[ $QUIET -eq 1 ]]; then
- timeout $TIMEOUT $0 --quiet --child --host=$HOST --port=$PORT --timeout=$TIMEOUT &
- else
- timeout $TIMEOUT $0 --child --host=$HOST --port=$PORT --timeout=$TIMEOUT &
- fi
- PID=$!
- trap "kill -INT -$PID" INT
- wait $PID
- RESULT=$?
- if [[ $RESULT -ne 0 ]]; then
- echoerr "$cmdname: timeout occurred after waiting $TIMEOUT seconds for $HOST:$PORT"
- fi
- return $RESULT
-}
-
-# process arguments
-while [[ $# -gt 0 ]]
-do
- case "$1" in
- *:* )
- hostport=(${1//:/ })
- HOST=${hostport[0]}
- PORT=${hostport[1]}
- shift 1
- ;;
- --child)
- CHILD=1
- shift 1
- ;;
- -q | --quiet)
- QUIET=1
- shift 1
- ;;
- -s | --strict)
- STRICT=1
- shift 1
- ;;
- -h)
- HOST="$2"
- if [[ $HOST == "" ]]; then break; fi
- shift 2
- ;;
- --host=*)
- HOST="${1#*=}"
- shift 1
- ;;
- -p)
- PORT="$2"
- if [[ $PORT == "" ]]; then break; fi
- shift 2
- ;;
- --port=*)
- PORT="${1#*=}"
- shift 1
- ;;
- -t)
- TIMEOUT="$2"
- if [[ $TIMEOUT == "" ]]; then break; fi
- shift 2
- ;;
- --timeout=*)
- TIMEOUT="${1#*=}"
- shift 1
- ;;
- --)
- shift
- CLI="$@"
- break
- ;;
- --help)
- usage
- ;;
- *)
- echoerr "Unknown argument: $1"
- usage
- ;;
- esac
-done
-
-if [[ "$HOST" == "" || "$PORT" == "" ]]; then
- echoerr "Error: you need to provide a host and port to test."
- usage
-fi
-
-TIMEOUT=${TIMEOUT:-15}
-STRICT=${STRICT:-0}
-CHILD=${CHILD:-0}
-QUIET=${QUIET:-0}
-
-if [[ $CHILD -gt 0 ]]; then
- wait_for
- RESULT=$?
- exit $RESULT
-else
- if [[ $TIMEOUT -gt 0 ]]; then
- wait_for_wrapper
- RESULT=$?
- else
- wait_for
- RESULT=$?
- fi
-fi
-
-if [[ $CLI != "" ]]; then
- if [[ $RESULT -ne 0 && $STRICT -eq 1 ]]; then
- echoerr "$cmdname: strict mode, refusing to execute subprocess"
- exit $RESULT
- fi
- exec $CLI
-else
- exit $RESULT
-fi
diff --git a/server/lib/admin/info.js b/server/lib/admin/info.js
new file mode 100644
index 0000000..3d8613f
--- /dev/null
+++ b/server/lib/admin/info.js
@@ -0,0 +1,176 @@
+const Person = require('../people/person')
+
+module.exports = {
+ getPeople,
+ getPeopleSpaced,
+ getPeopleQuery,
+ getAllPrevNames,
+ getMemberEmails,
+ getMemberPaperPubs
+}
+
+/**
+ * List all people
+ *
+ * @param {Database} db
+ * @returns {Promise} Resolves to an array of person objects
+ */
+function getPeople(db) {
+ return db.any(Person.SELECT)
+}
+
+/**
+ * List all people, with id matching array index
+ *
+ * Fields with null and false values are filtered out of the response
+ *
+ * @param {Database} db
+ * @returns {Promise} Resolves to an array of nulls and person objects
+ */
+function getPeopleSpaced(db) {
+ return getPeople(db).then(data => {
+ const maxId = data.reduce((m, p) => Math.max(m, p.id), -1)
+ const arr = new Array(maxId + 1)
+ data.forEach(p => {
+ arr[p.id] = Person.fields.reduce(
+ (o, fn) => {
+ const v = p[fn]
+ if (v !== null && v !== false) o[fn] = v
+ return o
+ },
+ { id: p.id }
+ )
+ })
+ return arr
+ })
+}
+
+/**
+ * List all people matching query
+ *
+ * The `query` should have keys and their expected values. In addition to all
+ * person fields, "since" checks the last-modified date and "name" matches
+ * across all name fields.
+ *
+ * @param {Database} db
+ * @param {object} query
+ * @returns {Promise} Resolves to an array of person objects
+ */
+function getPeopleQuery(db, query) {
+ const cond = Object.keys(query).map(fn => {
+ switch (fn) {
+ case 'since':
+ return 'last_modified > $(since)'
+ case 'name':
+ return '(legal_name ILIKE $(name) OR public_first_name ILIKE $(name) OR public_last_name ILIKE $(name))'
+ case 'member_number':
+ case 'membership':
+ return `${fn} = $(${fn})`
+ default:
+ return Person.fields.indexOf(fn) !== -1
+ ? `${fn} ILIKE $(${fn})`
+ : 'true'
+ }
+ })
+ return db.any(`${Person.SELECT} WHERE ${cond.join(' AND ')}`, query)
+}
+
+/**
+ * List all past legal names
+ *
+ * Information on when and which each membership was associated with a
+ * different legal_name than currently. Near matches are discarded to filter
+ * out spelling corrections.
+ *
+ * @param {Database} db
+ * @returns {Promise} Resolves to an array of objects
+ */
+function getAllPrevNames(db) {
+ return db.any(
+ `SELECT DISTINCT ON (h.id,h.legal_name)
+ h.id,
+ p.member_number,
+ h.legal_name AS prev_name,
+ to_char(h.timestamp, 'YYYY-MM-DD') AS date_from,
+ to_char(l.timestamp, 'YYYY-MM-DD') AS date_to,
+ p.legal_name AS curr_name,
+ p.email AS curr_email
+ FROM past_names h
+ LEFT JOIN log l ON (h.id=l.subject)
+ LEFT JOIN people p ON (l.subject=p.id)
+ WHERE l.timestamp > h.timestamp AND
+ l.parameters->>'legal_name' IS NOT NULL AND
+ name_match(l.parameters->>'legal_name', h.legal_name) = false
+ ORDER BY h.id,h.legal_name,l.timestamp`
+ )
+}
+
+const getCombinedName = names => {
+ switch (names.length) {
+ case 0:
+ return ''
+ case 1:
+ return names[0]
+ case 2:
+ return `${names[0]} and ${names[1]}`
+ default:
+ names[names.length - 1] = `and ${names[names.length - 1]}`
+ return names.join(', ')
+ }
+}
+
+/**
+ * Get all members' email addresses
+ *
+ * For members sharing an address, the `name` is concatenated.
+ *
+ * @param {Database} db
+ * @returns {Promise} Resolves to an array of { name, email } objects
+ */
+function getMemberEmails(db) {
+ return db
+ .any(
+ `SELECT
+ lower(email) AS email, legal_name AS ln,
+ public_first_name AS pfn, public_last_name AS pln
+ FROM people p
+ LEFT JOIN membership_types m USING (membership)
+ WHERE email != '' AND m.member = true
+ ORDER BY public_last_name, public_first_name, legal_name`
+ )
+ .then(raw => {
+ const namesByEmail = raw.reduce((map, { email, ln, pfn, pln }) => {
+ const name =
+ [pfn, pln]
+ .filter(n => n)
+ .join(' ')
+ .replace(/ +/g, ' ')
+ .trim() || ln.trim()
+ if (map[email]) map[email].push(name)
+ else map[email] = [name]
+ return map
+ }, {})
+ return Object.keys(namesByEmail).map(email => {
+ const name = getCombinedName(namesByEmail[email])
+ return { email, name }
+ })
+ })
+}
+
+/**
+ * Get all members' paper pubs addresses
+ *
+ * @param {Database} db
+ * @returns {Promise} Resolves to an array of { name, address, country } objects
+ */
+function getMemberPaperPubs(db) {
+ return db.any(
+ `SELECT
+ paper_pubs->>'name' AS name,
+ paper_pubs->>'address' AS address,
+ paper_pubs->>'country' AS country
+ FROM People p
+ LEFT JOIN membership_types m USING (membership)
+ WHERE paper_pubs IS NOT NULL AND m.member = true`
+ )
+}
diff --git a/server/lib/admin/router.js b/server/lib/admin/router.js
new file mode 100644
index 0000000..5ed8804
--- /dev/null
+++ b/server/lib/admin/router.js
@@ -0,0 +1,63 @@
+const express = require('express')
+const { hasRole } = require('@kansa/common/auth-user')
+
+const {
+ getPeopleSpaced,
+ getPeopleQuery,
+ getAllPrevNames,
+ getMemberEmails,
+ getMemberPaperPubs
+} = require('./info')
+const PeopleStream = require('./stream')
+
+module.exports = db => {
+ const router = express.Router()
+
+ router.get(
+ '/',
+ hasRole(['member_admin', 'member_list']),
+ (req, res, next) => {
+ const p =
+ Object.keys(req.query).length > 0
+ ? getPeopleQuery(db, req.query)
+ : getPeopleSpaced(db)
+ p.then(data => res.json(data)).catch(next)
+ }
+ )
+
+ router.get(
+ '/prev-names.:fmt',
+ hasRole(['member_admin', 'member_list']),
+ (req, res, next) =>
+ getAllPrevNames(db)
+ .then(data => {
+ const csv = req.params.fmt === 'csv'
+ if (csv) res.csv(data, true)
+ else res.json(data)
+ })
+ .catch(next)
+ )
+
+ router.membersRouter = express.Router()
+ router.membersRouter.use(hasRole('member_admin'))
+ router.membersRouter.get('/emails', (req, res, next) =>
+ getMemberEmails(db)
+ .then(data => res.csv(data, true))
+ .catch(next)
+ )
+ router.membersRouter.get('/paperpubs', (req, res, next) =>
+ getMemberPaperPubs(db)
+ .then(data => res.csv(data, true))
+ .catch(next)
+ )
+
+ const peopleStream = new PeopleStream(db)
+ router.ws('/updates', (ws, req) => {
+ hasRole(['member_admin', 'member_list'])(req, null, err => {
+ if (err) ws.close(4001, 'Unauthorized')
+ else peopleStream.addClient(ws)
+ })
+ })
+
+ return router
+}
diff --git a/kansa/lib/PeopleStream.js b/server/lib/admin/stream.js
similarity index 100%
rename from kansa/lib/PeopleStream.js
rename to server/lib/admin/stream.js
diff --git a/hugo/app.js b/server/lib/app.js
similarity index 68%
rename from hugo/app.js
rename to server/lib/app.js
index d7d6612..84d8c7e 100644
--- a/hugo/app.js
+++ b/server/lib/app.js
@@ -1,24 +1,33 @@
+const bodyParser = require('body-parser')
const cors = require('cors')
require('csv-express')
+const debug = require('debug')
const express = require('express')
-const http = require('http')
-const logger = require('morgan')
-const bodyParser = require('body-parser')
const session = require('express-session')
-
const pgSession = require('connect-pg-simple')(session)
-const pgOptions = { promiseLib: require('bluebird') }
-const pgp = require('pg-promise')(pgOptions)
-require('pg-monitor').attach(pgOptions)
+const http = require('http')
+
+const config = require('@kansa/common/config')
+const loadModules = require('./modules')
-const config = require('./lib/config')
-const appRouter = require('./lib/router')
+const pgOptions = {}
+const pgp = require('pg-promise')(pgOptions)
+if (debug.enabled('kansa:db')) {
+ const pgMonitor = require('pg-monitor')
+ pgMonitor.attach(pgOptions)
+}
+const db = pgp(process.env.DATABASE_URL)
+const debugErrors = debug('kansa:errors')
const app = express()
const server = http.createServer(app)
require('express-ws')(app, server)
-app.use(logger('dev'))
+app.locals.db = db
+if (debug.enabled('kansa:http')) {
+ const logger = require('morgan')
+ app.use(logger('dev'))
+}
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: false }))
const corsOrigins = process.env.CORS_ORIGIN
@@ -32,24 +41,21 @@ if (corsOrigins)
)
app.use(
session({
- cookie: { maxAge: 30 * 24 * 60 * 60 * 1000 }, // 30 days
+ cookie: { maxAge: config.auth.session_timeout },
name: config.id,
resave: false,
saveUninitialized: false,
secret: process.env.SESSION_SECRET,
store: new pgSession({
- pg: pgp.PG,
+ pgPromise: db,
pruneSessionInterval: 24 * 60 * 60 // 1 day
})
})
)
-// express-ws monkeypatching breaks the server on unhandled paths
-app.ws('/*', (ws, req) => ws.close(4004, 'Not Found'))
-
-app.use(appRouter(pgp, process.env.DATABASE_URL))
+loadModules(db, app)
-// no match from router -> 404
+// no match from routers -> 404
app.use((req, res, next) => {
const err = new Error('Not Found')
err.status = 404
@@ -60,6 +66,7 @@ app.use((req, res, next) => {
const isDevEnv = app.get('env') === 'development'
app.use((err, req, res, next) => {
const error = err.error || err
+ debugErrors(error instanceof Error ? error.message : err)
const data = { status: 'error', message: error.message }
if (isDevEnv) data.error = err
const status =
diff --git a/kansa/lib/badge.js b/server/lib/badge.js
similarity index 64%
rename from kansa/lib/badge.js
rename to server/lib/badge.js
index cfeacfe..fd6e18a 100644
--- a/kansa/lib/badge.js
+++ b/server/lib/badge.js
@@ -1,59 +1,25 @@
const fetch = require('node-fetch')
-const config = require('./config')
-const { AuthError } = require('./errors')
-
-const TITLE_MAX_LENGTH = 14
+const config = require('@kansa/common/config')
+const { AuthError } = require('@kansa/common/errors')
+const splitName = require('@kansa/common/split-name')
module.exports = { getBadge, getBarcode, logPrint }
-const splitNameInTwain = name => {
- name = name.trim()
- if (name.indexOf('\n') !== -1) {
- const nm = name.match(/(.*)\s+([\s\S]*)/)
- const n0 = nm[1].trim()
- const n1 = nm[2].trim().replace(/\s+/g, ' ')
- return [n0, n1]
- } else if (name.length <= TITLE_MAX_LENGTH) {
- return ['', name]
- } else {
- const na = name.split(/\s+/)
- let n0 = na.shift() || ''
- let n1 = na.pop() || ''
- while (na.length) {
- const p0 = na.shift()
- const p1 = na.pop()
- if (p1 && n0.length + p0.length > n1.length + p1.length) {
- n1 = p1 + ' ' + n1
- na.unshift(p0)
- } else if (!p1 && n0.length + p0.length > n1.length + p0.length) {
- n1 = p0 + ' ' + n1
- } else {
- n0 = n0 + ' ' + p0
- if (p1) na.push(p1)
- }
- }
- return [n0, n1]
- }
-}
-
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`,
+ `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] = splitNameInTwain(
- req.query.name || name || ''
- )
+ const [FirstName, Surname] = splitName(req.query.name || name || '')
return fetch('http://tarra/label.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -105,7 +71,7 @@ function getBarcode(req, res, next) {
.then(data => {
const { daypass, days, member_number, membership, name, subtitle } = data
const code = membership.charAt(0) + '-' + (member_number || `i${id}`)
- const [FirstName, Surname] = splitNameInTwain(name || '')
+ const [FirstName, Surname] = splitName(name || '')
const Info = daypass
? 'Daypass ' +
['Wed', 'Thu', 'Fri', 'Sat', 'Sun']
@@ -146,18 +112,16 @@ function getBarcode(req, res, next) {
}
function logPrint(req, res, next) {
- const { member_admin } = req.session.user || {}
- if (!member_admin) return next(new AuthError())
- const id = parseInt(req.params.id)
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`,
- id
+ `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/kansa/lib/ballot.js b/server/lib/ballot.js
similarity index 100%
rename from kansa/lib/ballot.js
rename to server/lib/ballot.js
diff --git a/server/lib/get-config.js b/server/lib/get-config.js
new file mode 100644
index 0000000..d9d27f2
--- /dev/null
+++ b/server/lib/get-config.js
@@ -0,0 +1,18 @@
+const config = require('@kansa/common/config')
+
+module.exports = getConfig
+
+function getConfig(db) {
+ return db
+ .any(
+ `SELECT membership, badge, hugo_nominator, member, wsfs_member
+ FROM membership_types`
+ )
+ .then(rows => {
+ const membershipTypes = {}
+ rows.forEach(({ membership, ...props }) => {
+ membershipTypes[membership] = props
+ })
+ return Object.assign({ membershipTypes }, config, { auth: undefined })
+ })
+}
diff --git a/server/lib/key/max-age.js b/server/lib/key/max-age.js
new file mode 100644
index 0000000..6024d98
--- /dev/null
+++ b/server/lib/key/max-age.js
@@ -0,0 +1,10 @@
+const config = require('@kansa/common/config')
+
+module.exports = function getKeyMaxAge(db, email) {
+ return db
+ .one(`SELECT exists(SELECT 1 FROM admin.admins WHERE email = $1)`, email)
+ .then(({ exists }) => {
+ const type = exists ? 'admin' : 'normal'
+ return config.auth.key_timeout[type] / 1000
+ })
+}
diff --git a/server/lib/key/refresh.js b/server/lib/key/refresh.js
new file mode 100644
index 0000000..d800a2f
--- /dev/null
+++ b/server/lib/key/refresh.js
@@ -0,0 +1,15 @@
+const getKeyMaxAge = require('./max-age')
+const setKey = require('./set')
+
+module.exports = function refreshKey(req, db, email) {
+ return db.task(async ts => {
+ const maxAge = await getKeyMaxAge(ts, email)
+ const data = await ts.oneOrNone(
+ `UPDATE keys SET expires = now() + $(maxAge) * interval '1 second'
+ WHERE email = $(email)
+ RETURNING email, key`,
+ { email, maxAge }
+ )
+ return data || setKey(req, ts, { email, maxAge })
+ })
+}
diff --git a/server/lib/key/reset.js b/server/lib/key/reset.js
new file mode 100644
index 0000000..3bf9470
--- /dev/null
+++ b/server/lib/key/reset.js
@@ -0,0 +1,26 @@
+const randomstring = require('randomstring')
+const LogEntry = require('@kansa/common/log-entry')
+const { sendMail, updateMailRecipient } = require('@kansa/common/mail')
+const getKeyMaxAge = require('./max-age')
+
+module.exports = function resetKey(req, db, { email, path }) {
+ return db
+ .tx(async tx => {
+ const key = randomstring.generate(12)
+ const maxAge = await getKeyMaxAge(tx, email)
+ await tx.none(
+ `UPDATE keys
+ SET key=$(key), expires = now() + $(maxAge) * interval '1 second'
+ WHERE email = $(email)`,
+ { email, key, maxAge }
+ )
+ const log = new LogEntry(req, 'Reset access key')
+ log.author = email
+ await log.write(tx)
+ return key
+ })
+ .then(async key => {
+ await updateMailRecipient(db, email)
+ return sendMail('kansa-set-key', { email, key, path })
+ })
+}
diff --git a/server/lib/key/send.js b/server/lib/key/send.js
new file mode 100644
index 0000000..1f797d3
--- /dev/null
+++ b/server/lib/key/send.js
@@ -0,0 +1,42 @@
+const { InputError } = require('@kansa/common/errors')
+const { sendMail } = require('@kansa/common/mail')
+const refreshKey = require('./refresh')
+const setKey = require('./set')
+
+module.exports = function sendKey(req, db) {
+ const { email: reqEmail, name, path, reset } = req.body
+ if (!reqEmail) {
+ const msg = 'An email address is required for sending its key!'
+ return Promise.reject(new InputError(msg))
+ }
+ let msgTmpl
+ return db
+ .task(async ts => {
+ const rows = await ts.any(
+ 'SELECT email FROM People WHERE email ILIKE $1',
+ reqEmail
+ )
+ if (rows.length > 0) {
+ const { email } = rows[0]
+ const { key, set } = reset
+ ? await setKey(req, ts, { email })
+ : await refreshKey(req, ts, email)
+ msgTmpl = 'kansa-set-key'
+ return { email, key, path, set }
+ }
+ if (!name) {
+ const msg = `Email address ${JSON.stringify(email)} not found`
+ throw new InputError(msg)
+ }
+ const { email, key } = await setKey(req, ts, {
+ email: reqEmail,
+ name
+ })
+ msgTmpl = 'kansa-create-account'
+ return { email, key, name, path }
+ })
+ .then(async data => {
+ await sendMail(msgTmpl, data)
+ return data.email
+ })
+}
diff --git a/server/lib/key/set.js b/server/lib/key/set.js
new file mode 100644
index 0000000..a857e1d
--- /dev/null
+++ b/server/lib/key/set.js
@@ -0,0 +1,36 @@
+const randomstring = require('randomstring')
+const LogEntry = require('@kansa/common/log-entry')
+const { updateMailRecipient } = require('@kansa/common/mail')
+const getKeyMaxAge = require('./max-age')
+
+module.exports = function setKey(req, db, { email, maxAge, name }) {
+ return db
+ .tx(async tx => {
+ if (!maxAge) maxAge = await getKeyMaxAge(tx, email)
+ const key = randomstring.generate(12)
+ await tx.none(
+ `INSERT INTO Keys (email, key, expires)
+ VALUES ($(email), $(key), now() + $(maxAge) * interval '1 second')
+ ON CONFLICT (email) DO
+ UPDATE SET key = EXCLUDED.key, expires = EXCLUDED.expires`,
+ { email, key, maxAge }
+ )
+ let description = 'Set access key'
+ if (name) {
+ await tx.none(
+ `INSERT INTO People (membership, legal_name, email)
+ VALUES ('NonMember', $1, $2)`,
+ [name, email]
+ )
+ description = 'Create non-member account'
+ }
+ const log = new LogEntry(req, description)
+ log.author = email
+ await log.write(tx)
+ return { key, maxAge }
+ })
+ .then(async ({ key, maxAge }) => {
+ await updateMailRecipient(db, email)
+ return { email, key, maxAge, set: true }
+ })
+}
diff --git a/server/lib/modules.js b/server/lib/modules.js
new file mode 100644
index 0000000..e3510bd
--- /dev/null
+++ b/server/lib/modules.js
@@ -0,0 +1,19 @@
+const debug = require('debug')('kansa:server')
+const path = require('path')
+
+const config = require('@kansa/common/config')
+const appRouter = require('./router')
+
+module.exports = (db, app) => {
+ const ctx = {}
+ app.use('/', appRouter(db, ctx))
+ Object.keys(config.modules).forEach(name => {
+ const mc = config.modules[name]
+ if (!mc) return
+ debug(`Adding module ${name}`)
+ const mp = path.resolve(__dirname, '..', 'modules', name)
+ const module = require(mp)
+ app.use(`/${name}`, module(db, ctx, mc))
+ })
+ return ctx
+}
diff --git a/server/lib/people/add.js b/server/lib/people/add.js
new file mode 100644
index 0000000..2b0ff42
--- /dev/null
+++ b/server/lib/people/add.js
@@ -0,0 +1,38 @@
+const LogEntry = require('@kansa/common/log-entry')
+const Person = require('./person')
+
+module.exports = addPerson
+
+function addPerson(db, req, person) {
+ try {
+ if (!(person instanceof Person)) person = new Person(person)
+ } catch (err) {
+ return Promise.reject(err)
+ }
+ const passDays = person.passDays
+ const status = person.data.membership
+ if (passDays.length) {
+ person.data.membership = 'NonMember'
+ person.data.member_number = null
+ }
+ return db.tx(async tx => {
+ const { id, member_number } = await tx.one(
+ `INSERT INTO People ${person.sqlValues} RETURNING id, member_number`,
+ person.data
+ )
+ Object.assign(person.data, { id, member_number })
+ const log = new LogEntry(req, 'Add new person')
+ log.subject = id
+ await log.write(tx)
+ if (passDays.length > 0) {
+ const pdStr = passDays.join(',')
+ const trueDays = passDays.map(d => 'true').join(',')
+ await tx.none(
+ `INSERT INTO daypasses (person_id,status,${pdStr})
+ VALUES ($(id),$(status),${trueDays})`,
+ { id, status }
+ )
+ }
+ return { id, member_number }
+ })
+}
diff --git a/server/lib/people/get.js b/server/lib/people/get.js
new file mode 100644
index 0000000..77bbae0
--- /dev/null
+++ b/server/lib/people/get.js
@@ -0,0 +1,40 @@
+module.exports = {
+ getPerson,
+ getPrevNames,
+ getPersonLog
+}
+
+function getPerson(db, id) {
+ return db.one(
+ `SELECT DISTINCT ON (p.id)
+ p.*, preferred_name(p),
+ d.status AS daypass, daypass_days(d),
+ b.timestamp AS badge_print_time
+ FROM people p
+ LEFT JOIN daypasses d ON (p.id = d.person_id)
+ LEFT JOIN badge_and_daypass_prints b ON (p.id = b.person)
+ WHERE p.id = $1
+ ORDER BY p.id, b.timestamp`,
+ parseInt(id)
+ )
+}
+
+function getPrevNames(db, id) {
+ return db.any(
+ `SELECT DISTINCT ON (h.legal_name)
+ h.legal_name AS prev_legal_name,
+ h.timestamp AS time_from,
+ l.timestamp AS time_to
+ FROM past_names h LEFT JOIN log l ON (h.id=l.subject)
+ WHERE h.id = $1 AND
+ l.timestamp > h.timestamp AND
+ l.parameters->>'legal_name' IS NOT NULL AND
+ name_match(l.parameters->>'legal_name', h.legal_name) = false
+ ORDER BY h.legal_name,l.timestamp`,
+ parseInt(id)
+ )
+}
+
+function getPersonLog(db, id) {
+ return db.any('SELECT * FROM Log WHERE subject = $1', parseInt(id))
+}
diff --git a/server/lib/people/lookup.js b/server/lib/people/lookup.js
new file mode 100644
index 0000000..3098389
--- /dev/null
+++ b/server/lib/people/lookup.js
@@ -0,0 +1,56 @@
+const { InputError } = require('@kansa/common/errors')
+
+module.exports = lookupPerson
+
+function getLookupQuery({ email, member_number, name }) {
+ const parts = []
+ const values = {}
+ if (email && /.@./.test(email)) {
+ parts.push('lower(email) = $(email)')
+ values.email = email.trim().toLowerCase()
+ }
+ if (member_number > 0) {
+ parts.push('(member_number = $(number) OR id = $(number))')
+ values.number = Number(member_number)
+ }
+ if (name) {
+ parts.push(
+ '(lower(legal_name) = $(name) OR lower(public_name(p)) = $(name))'
+ )
+ values.name = name.trim().toLowerCase()
+ }
+ const query = `
+ SELECT id, membership, preferred_name(p) AS name
+ FROM people p
+ LEFT JOIN membership_types m USING (membership)
+ WHERE ${parts.join(' AND ')} AND
+ m.allow_lookup = true`
+ return { parts, query, values }
+}
+
+/**
+ * Look up data on a member based on their name, email address and/or member number
+ *
+ * @param {Database} db
+ * @param {string} [data.email]
+ * @param {number} [data.member_number]
+ * @param {string} [data.name]
+ * @returns {Promise}
+ * Resolves to an object with a status, and on success also id, membership & name fields
+ */
+function lookupPerson(db, data) {
+ const { parts, query, values } = getLookupQuery(data)
+ if (parts.length === 0 || (parts.length === 1 && values.number)) {
+ return Promise.reject(new InputError('No valid parameters'))
+ }
+ return db.any(query, values).then(results => {
+ switch (results.length) {
+ case 0:
+ return { status: 'not found' }
+ case 1:
+ return Object.assign({ status: 'success' }, results[0])
+ default:
+ return { status: 'multiple' }
+ }
+ })
+}
diff --git a/kansa/lib/types/person.js b/server/lib/people/person.js
similarity index 77%
rename from kansa/lib/types/person.js
rename to server/lib/people/person.js
index ab4a2ab..f993961 100644
--- a/kansa/lib/types/person.js
+++ b/server/lib/people/person.js
@@ -1,7 +1,12 @@
-const { InputError } = require('../errors')
-const util = require('../util')
+const { InputError } = require('@kansa/common/errors')
+const isTrueish = require('@kansa/common/trueish')
class Person {
+ static get SELECT() {
+ return `SELECT p.*, preferred_name(p), d.status AS daypass, daypass_days(d)
+ FROM people p LEFT JOIN daypasses d ON (p.id = d.person_id)`
+ }
+
static get fields() {
return [
// id SERIAL PRIMARY KEY
@@ -42,7 +47,7 @@ class Person {
}
static cleanPaperPubs(pp) {
- if (!util.isTrueish(pp)) return null
+ if (!isTrueish(pp)) return null
if (typeof pp == 'string') pp = JSON.parse(pp)
return Person.paperPubsFields.reduce((o, fn) => {
if (!pp[fn])
@@ -62,8 +67,13 @@ class Person {
)
}
this.data = Object.assign({}, src)
- util.forceInt(this.data, 'member_number')
- if (this.data.membership === 'NonMember') this.data.member_number = null
+ if (this.data.membership === 'NonMember') {
+ this.data.member_number = null
+ } else if (this.data.hasOwnProperty('member_number')) {
+ const mn = this.data.member_number
+ if (!Number.isInteger(mn))
+ this.data.member_number = mn ? parseInt(mn) : null
+ }
this.data.paper_pubs = Person.cleanPaperPubs(this.data.paper_pubs)
}
diff --git a/server/lib/people/router.js b/server/lib/people/router.js
new file mode 100644
index 0000000..19d705e
--- /dev/null
+++ b/server/lib/people/router.js
@@ -0,0 +1,87 @@
+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')
+const Person = require('./person')
+const updatePerson = require('./update')
+const upgradePerson = require('./upgrade')
+
+module.exports = (db, ctx) => {
+ ctx.people = {
+ addPerson,
+ getPerson,
+ updatePerson,
+ upgradePerson,
+ Person
+ }
+ const router = express.Router()
+
+ router.post('/', hasRole('member_admin'), (req, res, next) => {
+ addPerson(db, req, req.body)
+ .then(({ id, member_number }) =>
+ res.json({ status: 'success', id, member_number })
+ )
+ .catch(next)
+ })
+
+ router.post('/lookup', isSignedIn, (req, res, next) => {
+ lookupPerson(db, req.body)
+ .then(data => res.json(data))
+ .catch(next)
+ })
+
+ router.use('/:id*', (req, res, next) => {
+ const roles = ['member_admin']
+ if (req.method === 'GET') roles.push('member_list')
+ matchesId(db, req, roles)
+ .then(() => next())
+ .catch(next)
+ })
+
+ router.get('/:id', (req, res, next) =>
+ getPerson(db, req.params.id)
+ .then(data => res.json(data))
+ .catch(next)
+ )
+
+ router.get('/:id/log', (req, res, next) =>
+ getPersonLog(db, req.params.id)
+ .then(data => res.json(data))
+ .catch(next)
+ )
+
+ router.get('/:id/prev-names', (req, res, next) =>
+ getPrevNames(db, req.params.id)
+ .then(data => res.json(data))
+ .catch(next)
+ )
+
+ router.post('/:id', (req, res, next) =>
+ updatePerson(db, req)
+ .then(data => res.json(data))
+ .catch(next)
+ )
+
+ router.post('/:id/upgrade', hasRole('member_admin'), (req, res, next) => {
+ const data = Object.assign({}, req.body)
+ data.id = parseInt(req.params.id)
+ upgradePerson(req, db, data)
+ .then(({ member_number, updated }) =>
+ res.json({ status: 'success', member_number, updated })
+ )
+ .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/people/update.js b/server/lib/people/update.js
new file mode 100644
index 0000000..0cacb73
--- /dev/null
+++ b/server/lib/people/update.js
@@ -0,0 +1,103 @@
+const config = require('@kansa/common/config')
+const { InputError } = require('@kansa/common/errors')
+const LogEntry = require('@kansa/common/log-entry')
+const { sendMail, updateMailRecipient } = require('@kansa/common/mail')
+const setKey = require('../key/set')
+const Person = require('./person')
+
+module.exports = updatePerson
+
+function getUpdateQuery(data, id, isAdmin) {
+ const values = Object.assign({}, data, { id })
+ const fieldSrc = isAdmin ? Person.fields : Person.userModFields
+ const fields = fieldSrc.filter(f => values.hasOwnProperty(f))
+ if (fields.length == 0) throw new InputError('No valid parameters')
+ let ppCond = ''
+ if (fields.indexOf('paper_pubs') >= 0) {
+ values.paper_pubs = Person.cleanPaperPubs(values.paper_pubs)
+ if (config.paid_paper_pubs && !isAdmin) {
+ if (!values.paper_pubs)
+ throw new InputError('Removing paid paper publications is not allowed')
+ ppCond = 'AND paper_pubs IS NOT NULL'
+ }
+ }
+ const query = `
+ WITH prev AS (
+ SELECT email, m.hugo_nominator, m.wsfs_member
+ FROM people p
+ LEFT JOIN membership_types m USING (membership)
+ WHERE id=$(id)
+ )
+ UPDATE People p
+ SET ${fields.map(f => `${f}=$(${f})`).join(', ')}
+ WHERE id=$(id) ${ppCond}
+ RETURNING
+ email AS next_email,
+ preferred_name(p) as name,
+ (SELECT email AS prev_email FROM prev),
+ (SELECT hugo_nominator FROM prev),
+ (SELECT wsfs_member FROM prev)`
+ return { fields, ppCond, query, values }
+}
+
+function updatePerson(db, req) {
+ const { fields, ppCond, query, values } = getUpdateQuery(
+ req.body,
+ parseInt(req.params.id),
+ req.session.user.member_admin
+ )
+ return db
+ .tx(async tx => {
+ const data = await tx.oneOrNone(query, values)
+ if (!data) {
+ if (!ppCond) throw new Error('Update failed')
+ const err = new InputError(
+ 'Paper publications have not been enabled for this person'
+ )
+ err.status = 402
+ throw err
+ }
+ const log = new LogEntry(req, 'Update fields: ' + fields.join(', '))
+ log.subject = values.id
+ await log.write(tx)
+ if (!values.email) return { data, prevKey: {} }
+ const prevKey = await tx.oneOrNone(
+ `SELECT key FROM Keys WHERE email=$(email)`,
+ values
+ )
+ return { data, prevKey }
+ })
+ .then(
+ async ({
+ data: { hugo_nominator, wsfs_member, next_email, prev_email, name },
+ prevKey
+ }) => {
+ values.email = next_email
+ let key = null
+ if (next_email !== prev_email) {
+ await updateMailRecipient(db, prev_email)
+ if (hugo_nominator || wsfs_member) {
+ if (prevKey) {
+ key = prevKey.key
+ } else {
+ key = await setKey(req, db, {
+ email: values.email
+ }).then(({ key }) => key)
+ }
+ }
+ }
+ let key_sent = false
+ if (key) {
+ await sendMail('hugo-update-email', {
+ email: values.email,
+ key,
+ memberId: values.id,
+ name
+ })
+ key_sent = true
+ }
+ updateMailRecipient(db, values.email)
+ return { status: 'success', updated: fields, key_sent }
+ }
+ )
+}
diff --git a/server/lib/people/upgrade.js b/server/lib/people/upgrade.js
new file mode 100644
index 0000000..cfe90f2
--- /dev/null
+++ b/server/lib/people/upgrade.js
@@ -0,0 +1,87 @@
+const { InputError } = require('@kansa/common/errors')
+const LogEntry = require('@kansa/common/log-entry')
+const { updateMailRecipient } = require('@kansa/common/mail')
+const Person = require('./person')
+
+module.exports = upgradePerson
+
+function upgradePaperPubs(req, db, data) {
+ if (!data.paper_pubs) throw new InputError('No valid parameters')
+ return db.tx(async tx => {
+ const row = await tx.oneOrNone(
+ `UPDATE People p SET paper_pubs=$(paper_pubs)
+ FROM membership_types m
+ WHERE id=$(id) AND m.membership = p.membership AND m.member = true
+ RETURNING member_number`,
+ data
+ )
+ if (!row) {
+ const err = new Error('Paper publications are only available for members')
+ err.status = 402
+ throw err
+ }
+ await new LogEntry(req, 'Add paper pubs').write(tx)
+ const { member_number } = row
+ return { member_number, updated: ['paper_pubs'] }
+ })
+}
+
+function getUpgradeQuery(data, addMemberNumber) {
+ const fields = ['membership']
+ let update = 'membership=$(membership)'
+ if (addMemberNumber) {
+ fields.push('member_number')
+ update += ", member_number=nextval('member_number_seq')"
+ }
+ if (data.paper_pubs) {
+ fields.push('paper_pubs')
+ update += ', paper_pubs=$(paper_pubs)'
+ }
+ const query = `
+ UPDATE people SET ${update} WHERE id=$(id)
+ RETURNING email, member_number`
+ return { fields, query }
+}
+
+function upgradeMembership(req, db, data) {
+ return db.tx(async tx => {
+ const priceRows = await tx.any(`SELECT * FROM membership_prices`)
+ const prev = await tx.one(
+ `SELECT membership, member_number FROM People WHERE id=$1`,
+ data.id
+ )
+ const nextPrice = priceRows.find(p => p.membership === data.membership)
+ if (!nextPrice) {
+ const strType = JSON.stringify(data.membership)
+ throw new InputError(`Invalid membership type: ${strType}`)
+ }
+ const prevPrice = priceRows.find(p => p.membership === prev.membership)
+ if (prevPrice && prevPrice.amount > nextPrice.amount) {
+ throw new InputError(
+ `Can't upgrade from ${prev.membership} to ${data.membership}`
+ )
+ }
+ const addMemberNumber = !parseInt(prev.member_number)
+ const { fields, query } = getUpgradeQuery(data, addMemberNumber)
+ const { email, member_number } = await tx.one(query, data)
+ const log = new LogEntry(req, `Upgrade to ${data.membership}`)
+ if (data.paper_pubs) log.description += ' and add paper pubs'
+ log.subject = data.id
+ await log.write(tx)
+ updateMailRecipient(db, email)
+ return { member_number, updated: fields }
+ })
+}
+
+function upgradePerson(req, db, data) {
+ if (data.hasOwnProperty('paper_pubs')) {
+ try {
+ data.paper_pubs = Person.cleanPaperPubs(data.paper_pubs)
+ } catch (err) {
+ return Promise.reject(err)
+ }
+ }
+ return data.membership
+ ? upgradeMembership(req, db, data)
+ : upgradePaperPubs(req, db, data)
+}
diff --git a/kansa/lib/purchase.js b/server/lib/purchase.js
similarity index 93%
rename from kansa/lib/purchase.js
rename to server/lib/purchase.js
index 2e9fccc..41596d0 100644
--- a/kansa/lib/purchase.js
+++ b/server/lib/purchase.js
@@ -1,16 +1,16 @@
-const config = require('./config')
-const { AuthError, InputError } = require('./errors')
+const config = require('@kansa/common/config')
+const { InputError } = require('@kansa/common/errors')
+const { sendMail } = require('@kansa/common/mail')
const Payment = require('./types/payment')
-const Person = require('./types/person')
-const { refreshKey } = require('./key')
-const { mailTask } = require('./mail')
-const { addPerson } = require('./people')
-const { upgradePerson } = require('./upgrade')
+const refreshKey = require('./key/refresh')
+const addPerson = require('./people/add')
+const Person = require('./people/person')
+const upgradePerson = require('./people/upgrade')
class Purchase {
- constructor(pgp, db) {
- this.pgp = pgp
+ constructor(db) {
this.db = db
+ this.pgHelpers = db.$config.pgp.helpers
this.createInvoice = this.createInvoice.bind(this)
this.getDaypassPrices = this.getDaypassPrices.bind(this)
this.getPurchaseData = this.getPurchaseData.bind(this)
@@ -94,7 +94,7 @@ class Purchase {
}
getPurchases(req, res, next) {
- let email = req.session.user.email
+ let { email } = req.session.user
if (req.session.user.member_admin) {
if (req.query.email) {
email = req.query.email
@@ -102,21 +102,19 @@ class Purchase {
return this.db
.any(`SELECT * FROM Payments`)
.then(data => res.json(data))
+ .catch(next)
}
}
- if (!email) return next(new AuthError())
this.db
.any(
- `
- SELECT *
- FROM Payments
- WHERE payment_email=$1 OR
- person_id IN (
- SELECT id FROM People WHERE email=$1
- )`,
+ `SELECT * FROM Payments
+ WHERE payment_email=$1 OR person_id IN (
+ SELECT id FROM People WHERE email=$1
+ )`,
email
)
.then(data => res.json(data))
+ .catch(next)
}
handleStripeWebhook(req, res, next) {
@@ -152,7 +150,7 @@ class Purchase {
)
.then(({ shape, types }) => {
const typeData = types.find(td => td.key === item.type)
- return mailTask(
+ return sendMail(
'kansa-update-payment',
Object.assign(
{
@@ -350,7 +348,7 @@ class Purchase {
return refreshKey(req, db, u.email)
})
.then(({ key }) =>
- mailTask(
+ sendMail(
(!u.membership || u.membership === u.prev_membership) &&
u.paper_pubs
? 'kansa-add-paper-pubs'
@@ -359,7 +357,7 @@ class Purchase {
)
)
const applyNewMember = m =>
- addPerson(req, db, m)
+ addPerson(db, req, m)
.then(() => {
const pi = paidItems.find(item => item.data === m.data)
return (
@@ -376,7 +374,7 @@ class Purchase {
{ charge_id, key, name: m.preferredName },
m.data
)
- return mailTask('kansa-new-member', data).then(
+ return sendMail('kansa-new-member', data).then(
() => (set ? data.email : null)
)
})
@@ -395,7 +393,7 @@ class Purchase {
if (data.amount === 0) return []
const { account, email, source, items } = data
return new Payment(
- this.pgp,
+ this.pgHelpers,
dbTask,
account,
email,
@@ -445,7 +443,7 @@ class Purchase {
data: p.data
}))
return new Payment(
- this.pgp,
+ this.pgHelpers,
this.db,
'default',
email,
@@ -457,7 +455,7 @@ class Purchase {
charge_id = items[0].stripe_charge_id
return Promise.all(
passPeople.map(p =>
- addPerson(req, this.db, p)
+ addPerson(this.db, req, p)
.then(() => {
const pi = items.find(item => item.data === p.data)
return (
@@ -471,7 +469,7 @@ class Purchase {
.then(() => refreshKey(req, this.db, p.data.email))
.then(({ key, set }) => {
if (set) newEmailAddresses[p.data.email] = true
- return mailTask(
+ return sendMail(
'kansa-new-daypass',
Object.assign(
{ charge_id, key, name: p.preferredName },
@@ -494,7 +492,7 @@ class Purchase {
makeOtherPurchase(req, res, next) {
const { account, email, items, source } = req.body
- new Payment(this.pgp, this.db, account, email, source, items)
+ new Payment(this.pgHelpers, this.db, account, email, source, items)
.process()
.then(items =>
Promise.all(
@@ -510,7 +508,7 @@ class Purchase {
)
.then(({ shape, types }) => {
const typeData = types.find(td => td.key === item.type)
- return mailTask(
+ return sendMail(
'kansa-new-payment',
Object.assign(
{
@@ -538,18 +536,16 @@ class Purchase {
}
createInvoice(req, res, next) {
- if (!req.session.user || !req.session.user.member_admin)
- throw new AuthError()
const { email, items } = req.body
if (!email || !items || items.length === 0)
throw new InputError('Required parameters: email, items')
- new Payment(this.pgp, this.db, 'default', email, null, items)
+ new Payment(this.pgHelpers, this.db, 'default', email, null, items)
.process()
.then(items => {
if (items.some(item => !item.id || item.status !== 'invoice')) {
throw new Error('Bad item: ' + JSON.stringify(item))
}
- return mailTask('kansa-new-invoice', { email, items })
+ return sendMail('kansa-new-invoice', { email, items })
})
.then(() => res.json({ status: 'success', email }))
.catch(next)
diff --git a/server/lib/router.js b/server/lib/router.js
new file mode 100644
index 0000000..221595f
--- /dev/null
+++ b/server/lib/router.js
@@ -0,0 +1,56 @@
+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 Purchase = require('./purchase')
+const Siteselect = require('./siteselect')
+const userRouter = require('./user/router')
+
+module.exports = (db, ctx) => {
+ const router = express.Router()
+
+ router.get('/config', (req, res, next) =>
+ getConfig(db)
+ .then(data => res.json(data))
+ .catch(next)
+ )
+
+ 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)
+ router.get('/purchase/daypass-prices', purchase.getDaypassPrices)
+ router.post('/purchase/daypass', purchase.makeDaypassPurchase)
+ router.post(
+ '/purchase/invoice',
+ hasRole('member_admin'),
+ purchase.createInvoice
+ )
+ router.get('/purchase/keys', purchase.getStripeKeys)
+ router.get('/purchase/list', isSignedIn, purchase.getPurchases)
+ router.post('/purchase/other', purchase.makeOtherPurchase)
+ router.post('/webhook/stripe', purchase.handleStripeWebhook)
+
+ const ar = adminRouter(db)
+ router.use('/members', ar.membersRouter)
+ 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/kansa/lib/siteselect.js b/server/lib/siteselect.js
similarity index 94%
rename from kansa/lib/siteselect.js
rename to server/lib/siteselect.js
index 8c78937..73f681a 100644
--- a/kansa/lib/siteselect.js
+++ b/server/lib/siteselect.js
@@ -1,5 +1,5 @@
const randomstring = require('randomstring')
-const { AuthError, InputError } = require('./errors')
+const { InputError } = require('@kansa/common/errors')
class Siteselect {
static generateToken() {
@@ -25,7 +25,6 @@ class Siteselect {
constructor(db) {
this.db = db
- this.verifyAccess = this.verifyAccess.bind(this)
this.findToken = this.findToken.bind(this)
this.findVoterTokens = this.findVoterTokens.bind(this)
this.getTokens = this.getTokens.bind(this)
@@ -33,11 +32,6 @@ class Siteselect {
this.vote = this.vote.bind(this)
}
- verifyAccess(req, res, next) {
- if (req.session.user && req.session.user.siteselection) next()
- else next(new AuthError())
- }
-
findToken(req, res, next) {
const token = Siteselect.parseToken(req.params.token)
if (!token) return res.status(404).json({ error: 'not found' })
diff --git a/kansa/lib/types/payment.js b/server/lib/types/payment.js
similarity index 97%
rename from kansa/lib/types/payment.js
rename to server/lib/types/payment.js
index bbf007b..8e0bb47 100644
--- a/kansa/lib/types/payment.js
+++ b/server/lib/types/payment.js
@@ -1,8 +1,7 @@
const Stripe = require('stripe')
-
-const config = require('../config')
+const config = require('@kansa/common/config')
+const { InputError } = require('@kansa/common/errors')
const { generateToken } = require('../siteselect')
-const InputError = require('./inputerror')
function checkData(shape, data) {
const missing = shape
@@ -55,8 +54,8 @@ class Payment {
return 'payments'
}
- constructor(pgp, db, account, email, source, items) {
- this.pgp = pgp
+ constructor(pgHelpers, db, account, email, source, items) {
+ this.pgHelpers = pgHelpers
this.db = db
this.account = account || 'default'
this.email = email
@@ -266,7 +265,7 @@ class Payment {
throw new Error('Payment already made? charge ids:' + charges)
const newItems = this.items.filter(item => !item.id)
if (newItems.length === 0) return null
- const sqlInsert = this.pgp.helpers.insert(
+ const sqlInsert = this.pgHelpers.insert(
newItems,
Payment.fields,
Payment.table
diff --git a/server/lib/user/info.js b/server/lib/user/info.js
new file mode 100644
index 0000000..8b15c0f
--- /dev/null
+++ b/server/lib/user/info.js
@@ -0,0 +1,23 @@
+const config = require('@kansa/common/config')
+const Person = require('../people/person')
+
+const adminSqlRoles = config.auth.admin_roles.join(', ')
+
+module.exports = function getInfo(db, req) {
+ const { user } = req.session
+ const email = (user.member_admin && req.query.email) || user.email
+ return db.task(async t => {
+ const people = await t.any(
+ `${Person.SELECT}
+ WHERE email=$1
+ ORDER BY coalesce(public_last_name, preferred_name(p))`,
+ email
+ )
+ const roleData = await t.oneOrNone(
+ `SELECT ${adminSqlRoles} FROM admin.Admins WHERE email=$1`,
+ email
+ )
+ const roles = roleData ? Object.keys(roleData).filter(r => roleData[r]) : []
+ return { email, people, roles }
+ })
+}
diff --git a/server/lib/user/log.js b/server/lib/user/log.js
new file mode 100644
index 0000000..4374b5e
--- /dev/null
+++ b/server/lib/user/log.js
@@ -0,0 +1,7 @@
+module.exports = function getLog(db, req) {
+ const { user } = req.session
+ const email = (user.member_admin && req.query.email) || user.email
+ return db
+ .any('SELECT * FROM Log WHERE author = $1', email)
+ .then(log => ({ email, log }))
+}
diff --git a/server/lib/user/login.js b/server/lib/user/login.js
new file mode 100644
index 0000000..0227f9b
--- /dev/null
+++ b/server/lib/user/login.js
@@ -0,0 +1,48 @@
+const jwt = require('jsonwebtoken')
+const { promisify } = require('util')
+const config = require('@kansa/common/config')
+const { AuthError, InputError } = require('@kansa/common/errors')
+const LogEntry = require('@kansa/common/log-entry')
+const resetExpiredKey = require('../key/reset')
+
+const adminSqlRoles = config.auth.admin_roles.join(', ')
+
+module.exports = function login(db, req) {
+ const email = (req.body && req.body.email) || req.query.email
+ const key = (req.body && req.body.key) || req.query.key
+ if (!email || !key) {
+ const msg = 'Email and key are required for login'
+ return Promise.reject(new InputError(msg))
+ }
+ return db.task(async ts => {
+ const user = await ts.oneOrNone(
+ `SELECT
+ k.email,
+ k.expires IS NOT NULL AND k.expires < now() AS expired,
+ ${adminSqlRoles}
+ FROM kansa.Keys k
+ LEFT JOIN admin.Admins a USING (email)
+ WHERE email=$(email) AND key=$(key)`,
+ { email, key }
+ )
+ if (!user) throw new AuthError(`Email and key don't match`)
+ if (user.expired) {
+ const path = req.body && req.body.path
+ await resetExpiredKey(req, ts, { email, path })
+ const error = new InputError('Expired key')
+ error.status = 403
+ throw error
+ }
+ req.session.user = user
+ const token = await promisify(jwt.sign)(
+ { scope: 'wsfs' },
+ process.env.JWT_SECRET,
+ {
+ expiresIn: 120 * 60,
+ subject: email
+ }
+ )
+ await new LogEntry(req, 'Login').write(ts)
+ return { email, token }
+ })
+}
diff --git a/server/lib/user/logout.js b/server/lib/user/logout.js
new file mode 100644
index 0000000..f704a1f
--- /dev/null
+++ b/server/lib/user/logout.js
@@ -0,0 +1,34 @@
+const { AuthError, InputError } = require('@kansa/common/errors')
+const isTrueish = require('@kansa/common/trueish')
+
+module.exports = function logout(db, req) {
+ const data = Object.assign({}, req.query, req.body)
+ const opt = isTrueish(data.reset)
+ ? 'reset'
+ : isTrueish(data.all)
+ ? 'all'
+ : null
+ // null: log out this session only, 'all': log out all sessions, 'reset': also reset/forget login key
+ const { user } = req.session
+ if (data.email) {
+ if (!user.admin_admin) return Promise.reject(new AuthError())
+ if (!opt) {
+ const msg = 'If email is set, either all=1 or reset=1 is also required'
+ return Promise.reject(new InputError(msg))
+ }
+ if (data.email === user.email) delete req.session.user
+ } else {
+ delete req.session.user
+ if (!opt) return Promise.resolve({ email: user.email })
+ }
+ const email = data.email || user.email
+ return db.task(async ts => {
+ const data = await ts.any(
+ `DELETE FROM "session" WHERE sess #>> '{user, email}' = $1 RETURNING sid`,
+ email
+ )
+ if (opt === 'reset')
+ await ts.none(`DELETE FROM Keys WHERE email = $1`, email)
+ return { email, opt, sessions: data[0].length }
+ })
+}
diff --git a/server/lib/user/router.js b/server/lib/user/router.js
new file mode 100644
index 0000000..ec9fb6b
--- /dev/null
+++ b/server/lib/user/router.js
@@ -0,0 +1,67 @@
+const express = require('express')
+const { isSignedIn } = require('@kansa/common/auth-user')
+const config = require('@kansa/common/config')
+
+const sendKey = require('../key/send')
+const getInfo = require('./info')
+const getLog = require('./log')
+const login = require('./login')
+const logout = require('./logout')
+
+const cookieOptions = {
+ files: { httpOnly: true, path: '/member-files', secure: true },
+ session: {
+ httpOnly: true,
+ path: '/',
+ maxAge: config.auth.session_timeout
+ }
+}
+
+module.exports = (db, ctx) => {
+ const router = express.Router()
+
+ router.post('/key', (req, res, next) =>
+ sendKey(req, db)
+ .then(email => res.json({ status: 'success', email }))
+ .catch(next)
+ )
+
+ router.all('/login', (req, res, next) =>
+ login(db, req)
+ .then(({ email, token }) => {
+ res.cookie('files', token, cookieOptions.files)
+ res.json({ status: 'success', email })
+ })
+ .catch(next)
+ )
+ router.use('/login', (err, req, res, next) => {
+ res.clearCookie('files', cookieOptions.files)
+ res.clearCookie(config.id, cookieOptions.session)
+ next(err)
+ })
+
+ router.all('/logout', isSignedIn, (req, res, next) => {
+ res.clearCookie('files', cookieOptions.files)
+ res.clearCookie(config.id, cookieOptions.session)
+ logout(db, req)
+ .then(data => {
+ data.status = 'success'
+ res.json(data)
+ })
+ .catch(next)
+ })
+
+ router.get('/user', isSignedIn, (req, res, next) =>
+ getInfo(db, req)
+ .then(data => res.json(data))
+ .catch(next)
+ )
+
+ router.get('/user/log', isSignedIn, (req, res, next) =>
+ getLog(db, req)
+ .then(data => res.json(data))
+ .catch(next)
+ )
+
+ return router
+}
diff --git a/kansa/package.json b/server/package.json
similarity index 86%
rename from kansa/package.json
rename to server/package.json
index 596d09d..ca1256a 100644
--- a/kansa/package.json
+++ b/server/package.json
@@ -12,6 +12,7 @@
"node": ">=8"
},
"dependencies": {
+ "@kansa/common": "^1.1.0",
"body-parser": "^1.18.3",
"connect-pg-simple": "^5.0.0",
"cors": "^2.8.4",
@@ -20,15 +21,12 @@
"express": "^4.16.3",
"express-session": "^1.15.6",
"express-ws": "^4.0.0",
- "form-data": "^2.3.2",
"jsonwebtoken": "^8.3.0",
"morgan": "^1.9.0",
"node-fetch": "^2.2.0",
"pg-monitor": "^1.0.0",
"pg-promise": "^8.4.5",
"randomstring": "^1.1.5",
- "stripe": "^6.3.0",
- "timestring": "^5.0.1",
- "yaml": "^1.0.0-rc.7"
+ "stripe": "^6.3.0"
}
}
diff --git a/kansa/server.js b/server/server.js
similarity index 95%
rename from kansa/server.js
rename to server/server.js
index a5dc996..f2c4d32 100644
--- a/kansa/server.js
+++ b/server/server.js
@@ -1,5 +1,5 @@
const debug = require('debug')('kansa:server')
-const { app, server } = require('./app')
+const { app, server } = require('./lib/app')
// Normalize the port into a number, string, or false.
const port = (val => {
diff --git a/hugo/wait-for-it.sh b/server/wait-for-it.sh
similarity index 100%
rename from hugo/wait-for-it.sh
rename to server/wait-for-it.sh