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