From 0022b9596090da3878df1986394e97b28a15d881 Mon Sep 17 00:00:00 2001 From: Justin Wind Date: Thu, 15 Apr 2021 16:41:19 -0700 Subject: [PATCH] initial commit --- .eslintrc.json | 82 ++++ .gitignore | 4 + .nycrc.json | 6 + README.md | 12 + init-static.sql | 5 + package.json | 38 ++ server.js | 39 ++ src/authenticator.js | 404 ++++++++++++++++ src/common.js | 12 + src/db/base.js | 72 +++ src/db/errors.js | 30 ++ src/db/index.js | 44 ++ src/db/postgres/index.js | 311 ++++++++++++ src/db/postgres/sql/auth-get-by-id.sql | 1 + src/db/postgres/sql/link-access.sql | 5 + src/db/postgres/sql/link-expire.sql | 1 + src/db/postgres/sql/link-get-all.sql | 6 + src/db/postgres/sql/link-get-by-id.sql | 5 + src/db/postgres/sql/link-get-by-url.sql | 5 + src/db/postgres/sql/link-update.sql | 1 + src/db/postgres/sql/link-upsert.sql | 10 + src/db/postgres/sql/schema/1.0.0/apply.sql | 46 ++ src/db/postgres/sql/schema/1.0.0/revert.sql | 7 + src/db/postgres/sql/schema/init.sql | 8 + src/db/sqlite/index.js | 288 ++++++++++++ src/enum.js | 35 ++ src/errors.js | 31 ++ src/logger.js | 74 +++ src/manager.js | 364 ++++++++++++++ src/service.js | 258 ++++++++++ src/slug.js | 45 ++ src/template/index.js | 7 + src/template/info-html.js | 32 ++ src/template/report-html.js | 44 ++ src/template/root-html.js | 58 +++ static/copy-link.svg | 7 + static/favicon.ico | Bin 0 -> 99678 bytes static/index.html | 16 + static/robots.txt | 2 + static/theme.css | 73 +++ test/src/authenticator.js | 497 ++++++++++++++++++++ test/src/common.js | 8 + test/src/db/base.js | 70 +++ test/src/db/index.js | 58 +++ test/src/db/postgres/index.js | 348 ++++++++++++++ test/src/db/sqlite/index.js | 474 +++++++++++++++++++ test/src/manager.js | 427 +++++++++++++++++ test/src/service.js | 185 ++++++++ test/src/slug.js | 72 +++ 49 files changed, 4627 insertions(+) create mode 100644 .eslintrc.json create mode 100644 .gitignore create mode 100644 .nycrc.json create mode 100644 README.md create mode 100644 init-static.sql create mode 100644 package.json create mode 100644 server.js create mode 100644 src/authenticator.js create mode 100644 src/common.js create mode 100644 src/db/base.js create mode 100644 src/db/errors.js create mode 100644 src/db/index.js create mode 100644 src/db/postgres/index.js create mode 100644 src/db/postgres/sql/auth-get-by-id.sql create mode 100644 src/db/postgres/sql/link-access.sql create mode 100644 src/db/postgres/sql/link-expire.sql create mode 100644 src/db/postgres/sql/link-get-all.sql create mode 100644 src/db/postgres/sql/link-get-by-id.sql create mode 100644 src/db/postgres/sql/link-get-by-url.sql create mode 100644 src/db/postgres/sql/link-update.sql create mode 100644 src/db/postgres/sql/link-upsert.sql create mode 100644 src/db/postgres/sql/schema/1.0.0/apply.sql create mode 100644 src/db/postgres/sql/schema/1.0.0/revert.sql create mode 100644 src/db/postgres/sql/schema/init.sql create mode 100644 src/db/sqlite/index.js create mode 100644 src/enum.js create mode 100644 src/errors.js create mode 100644 src/logger.js create mode 100644 src/manager.js create mode 100644 src/service.js create mode 100644 src/slug.js create mode 100644 src/template/index.js create mode 100644 src/template/info-html.js create mode 100644 src/template/report-html.js create mode 100644 src/template/root-html.js create mode 100644 static/copy-link.svg create mode 100644 static/favicon.ico create mode 100644 static/index.html create mode 100644 static/robots.txt create mode 100644 static/theme.css create mode 100644 test/src/authenticator.js create mode 100644 test/src/common.js create mode 100644 test/src/db/base.js create mode 100644 test/src/db/index.js create mode 100644 test/src/db/postgres/index.js create mode 100644 test/src/db/sqlite/index.js create mode 100644 test/src/manager.js create mode 100644 test/src/service.js create mode 100644 test/src/slug.js diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..7602b33 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,82 @@ +{ + "env": { + "browser": false, + "es6": true, + "node": true + }, + "extends": [ + "eslint:recommended", + "plugin:node/recommended", + "plugin:security/recommended", + "plugin:sonarjs/recommended" + ], + "parserOptions": { + "ecmaVersion": 2018 + }, + "plugins": [ + "node", + "security", + "sonarjs" + ], + "rules": { + "array-element-newline": [ + "error", + "consistent" + ], + "arrow-parens": [ + "error", + "always" + ], + "arrow-spacing": [ + "error", + { + "after": true, + "before": true + } + ], + "block-scoped-var": "error", + "block-spacing": "error", + "brace-style": "error", + "callback-return": "error", + "camelcase": "error", + "capitalized-comments": "warn", + "class-methods-use-this": "error", + "comma-dangle": [ + "error", + "always-multiline" + ], + "comma-spacing": [ + "error", + { + "after": true, + "before": false + } + ], + "comma-style": [ + "error", + "last" + ], + "sonarjs/cognitive-complexity": "warn", + "keyword-spacing": "error", + "linebreak-style": [ + "error", + "unix" + ], + "no-unused-vars": [ + "error", { + "varsIgnorePattern": "^_" + } + ], + "object-curly-spacing": [ + "error", + "always" + ], + "prefer-const": "error", + "quotes": [ + "error", + "single" + ], + "strict": "error", + "vars-on-top": "error" + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..349ed65 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +.nyc_output +coverage +.vscode diff --git a/.nycrc.json b/.nycrc.json new file mode 100644 index 0000000..497d8af --- /dev/null +++ b/.nycrc.json @@ -0,0 +1,6 @@ +{ + "reporter": [ + "lcov", + "text" + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..3681c58 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# URLittler + +The world needed another link shortener. + +* GET / - presents a simple webform for short link creation +* POST / { url } - create new short link for url +* GET /:link - visit short link +* GET /:link/info - data about link +* DELETE /:link { token } - expires short link +* PUT /:link { token, [url] } - update short link url, or create with admin access +* GET /admin/report - data about all links +* GET /static/:asset - serve asset diff --git a/init-static.sql b/init-static.sql new file mode 100644 index 0000000..49b8e49 --- /dev/null +++ b/init-static.sql @@ -0,0 +1,5 @@ +INSERT INTO link (id, url) VALUES ('static', '/static/index.html'); +INSERT INTO link (id, url) VALUES ('favicon.ico', '/static/favicon.ico'); +INSERT INTO link (id, url) VALUES ('robots.txt', '/static/robots.txt'); + +-- INSERT INTO link (id, url) VALUES ('wp-login.php', ''); diff --git a/package.json b/package.json new file mode 100644 index 0000000..6e09c20 --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "@squeep/urlittler", + "version": "1.0.0", + "description": "another link shortener", + "main": "server.js", + "scripts": { + "coverage": "nyc npm test", + "eslint": "eslint *.js src", + "nodemon": "nodemon", + "test": "mocha --recursive" + }, + "repository": { + "type": "git", + "url": "https://git.squeep.com/urlittler" + }, + "engines": { + "node": ">=12.9" + }, + "author": "Justin Wind ", + "license": "ISC", + "dependencies": { + "@squeep/api-dingus": "git+https://git.squeep.com/squeep-api-dingus/#v1.0.0", + "argon2": "^0.28.2", + "better-sqlite3": "^7.4.1", + "pg-promise": "10.10.2", + "pm2": "^5.1.0", + "uuid": "^8.3.2" + }, + "devDependencies": { + "eslint": "^7.30.0", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-security": "^1.4.0", + "eslint-plugin-sonarjs": "^0.9.1", + "mocha": "^9.0.2", + "nyc": "^15.1.0", + "sinon": "^11.1.1" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..404a7c5 --- /dev/null +++ b/server.js @@ -0,0 +1,39 @@ +/* eslint-disable capitalized-comments */ +'use strict'; + +const http = require('http'); + +const DB = require('./src/db'); +const Logger = require('./src/logger'); +const Service = require('./src/service'); +const { fileScope } = require('./src/common'); +const { version } = require('./package.json'); + +const _fileScope = fileScope(__filename)('main'); + +const PORT = process.env.PORT || 3001; +const ADDR = process.env.LISTEN_ADDR || '127.0.0.1'; + +const logger = new Logger({ + // ignoreBelowLevel: 'info', +}); +const db = new DB(logger, { + connectionString: 'postgresql://%2Fhome%2Ffek%2Fprojects%2Fhubl%2Fnodejs%2Fpostgres_dev-13/urlittler', + // connectionString: 'sqlite://', +}); +const service = new Service(logger, db, { + proxyPrefix: process.env.PROXY_PREFIX || '', // '/lil', + manager: { + selfBaseUrl: process.env.BASE_URL || '', + }, +}); + +http.createServer((req, res) => { + service.dispatch(req, res); +}).listen(PORT, ADDR, (err) => { + if (err) { + logger.error(_fileScope, 'error starting server:', err); + throw err; + } + logger.info(_fileScope, `server (version ${version}) started on ${ADDR}:${PORT}`); +}); diff --git a/src/authenticator.js b/src/authenticator.js new file mode 100644 index 0000000..f3d33ec --- /dev/null +++ b/src/authenticator.js @@ -0,0 +1,404 @@ +'use strict'; + +const argon2 = require('argon2'); +const crypto = require('crypto'); +const common = require('./common'); +const Enum = require('./enum'); +const { ResponseError } = require('./errors'); + +const _fileScope = common.fileScope(__filename); + +/** + * Let us roll our own authentication dingus. + * For 'admin' level access, we allow this custom header scheme, or optionally Basic if a password is stored. + * We shall authenticate an id/secret from a header of this format: + * Signature: id:epoch:hmac(id + epoch + body) + * + * For 'normal' level access, we allow a token passed as a parameter or a Bearer token. + */ + +const defaultOptions = { + basicRealm: 'Authenticate', + customAuthName: 'SqueepSignature', + timestampGraceMs: 120 * 1000, // In ms +}; + +class Authenticator { + constructor(logger, db, options = {}) { + common.setOptions(this, defaultOptions, options); + this.logger = logger; + this.db = db; + } + + + /** + * For Reasons, get rid of iffy b64 characters. + * @param {string} s + */ + static b64Armor(s) { + return s && s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '.'); + } + + + /** + * Put them back, if we need to. + * @param {string} s + */ + static b64Unarmor(s) { + return s && s.replace(/-/g, '+').replace(/_/g, '/').replace(/\./g, '='); + } + + + /** + * + * @param {string} secret + * @param {string} id + * @param {string} epoch + * @param {string} body + */ + static signature(secret, id, epoch, body) { + const hmac = crypto.createHmac('sha384', secret); + hmac.update(new Buffer.from(id)); + hmac.update(new Buffer.from(epoch.toString())); + hmac.update(new Buffer.from(body)); + const digest = hmac.digest('base64'); + return Authenticator.b64Armor(digest); + } + + + /** + * + * @param {number} size + */ + static async generateToken(size = 36) { + const randomBuffer = await common.randomBytesAsync(size); + const token = randomBuffer.toString('base64'); + return Authenticator.b64Armor(token); + } + + + /** + * + * @param {http.ServerResponse} res + */ + requestSignature(res) { + res.setHeader(Enum.Header.WWWAuthenticate, `${this.customAuthName}`); + throw new ResponseError(Enum.ErrorResponse.Unauthorized); + } + + + /** + * Check our bespoke scheme. If valid, update context with auth details. + * @param {String} reqSig + * @param {Object} ctx + * @param {string} ctx.rawBody + */ + async isValidSignature(reqSig, ctx) { + const _scope = _fileScope('isValidSignature'); + + this.logger.debug(_scope, 'checking authentication', { reqSig }); + + const reqSigParts = (reqSig || '').split(':'); + + if (reqSigParts.length !== 3) { + this.logger.debug(_scope, 'unparsable', { reqSigParts }); + return false; + } + + // Get secret for id or fail. + const authenticationId = reqSigParts[0]; + let authData; + await this.db.context(async (dbCtx) => { + authData = await this.db.getAuthById(dbCtx, authenticationId); + }); + const secret = authData && authData.secret; + + if (!secret) { + this.logger.debug(_scope, 'failed, invalid authenticationId', { ctx }) + return false; + } + + // Check timestamp + ctx.authenticationEpoch = parseInt(reqSigParts[1], 10); + const currentEpoch = Date.now() / 1000; + ctx.authenticationDrift = currentEpoch - ctx.authenticationEpoch; + if (Math.abs(ctx.authenticationDrift) > this.timestampGraceMs) { + this.logger.debug(_scope, 'failed, invalid timestamp', { ctx }) + return false; + } + + // Check signature + ctx.authenticationSignatureProvided = reqSigParts[2]; + ctx.authenticationSignatureComputed = Authenticator.signature(secret, authenticationId, ctx.authenticationEpoch.toString(), ctx.rawBody); + if (ctx.authenticationSignatureProvided !== ctx.authenticationSignatureComputed) { + this.logger.debug(_scope, 'failed, signature mismatch', { ctx }); + return false; + } + + ctx.authenticationId = authenticationId; + ctx.authenticationSecret = secret; // Retain this for computing response signature + + return true; // Success + } + + + /** + * Check for valid basic auth. + * @param {string} credentials + * @param {object} ctx + */ + async isValidBasic(credentials, ctx) { + const _scope = _fileScope('isValidBasic'); + this.logger.debug(_scope, 'called', { ctx }); + + const [ authenticationId, authenticationPass ] = common.splitFirst(credentials, ':', ''); + + let authData; + await this.db.context(async (dbCtx) => { + authData = await this.db.getAuthById(dbCtx, authenticationId); + }); + if (!authData) { + this.logger.debug(_scope, 'failed, invalid authenticationId', { ctx }); + return false; + } + + // Update pwhash + // authData.password = await argon2.hash(newPassword, { type: argon2.id }); + if (authData.password.startsWith('$argon2')) { + if (await argon2.verify(authdata.password, authenticationPass)) { + this.logger.debug(_scope, 'failed argon2 verify', { ctx }); + return false; + } else { + this.logger.debug(_scope, 'passed argon2 verify', { ctx }); + } + } else { + if (authData.password !== authenticationPass) { + this.logger.debug(_scope, 'failed, password mismatch', { ctx }); + return false; + } + } + + ctx.authenticationId = authenticationId; + + return true; // Success + } + + + /** + * Check if a presented Bearer token matches the presented link. + * @param {string} credentials + * @param {object} ctx + */ + async isValidBearer(credentials, ctx) { + const _scope = _fileScope('isValidBearer'); + this.logger.debug(_scope, 'called', { ctx }); + + const linkId = ctx.params.id; + return await this.isValidToken(linkId, credentials); + } + + + /** + * + * @param {string} linkId + * @param {string} token + */ + async isValidToken(linkId, token) { + const _scope = _fileScope('isValidToken'); + this.logger.debug(_scope, 'called', { linkId, token }); + + if (!token) { + this.logger.debug(_scope, 'no token, ignoring', { linkId, token }); + return false; + } + + if (!linkId) { + this.logger.debug(_scope, 'no id, ignoring token', { linkId, token }); + return false; + } + + let link; + await this.db.context(async (dbCtx) => { + link = await this.db.getLinkById(dbCtx, linkId); + }); + + if (!link) { + this.logger.debug(_scope, 'failed, invalid id', { linkId, token }); + return false; + } + + // eslint-disable-next-line security/detect-possible-timing-attacks + if (link.authToken !== token) { + this.logger.debug(_scope, 'failed, token mismatch', { linkId, token }); + return false; + } + + // Success + return true; + } + + + /** + * Determine which sort of Authorization header is available, and either parse it or request one. + * @param {string} authorizationHeader + * @param {object} ctx + */ + async isValidAuthorization(authorizationHeader, ctx) { + const _scope = _fileScope('isValidAuthorization'); + this.logger.debug(_scope, 'called', { authorizationHeader, ctx }); + + const [ authMethod, b64Credentials ] = common.splitFirst(authorizationHeader, ' ', '').map((x) => x.trim()); + const credentials = Buffer.from(b64Credentials, 'base64').toString('utf-8'); + // eslint-disable-next-line sonarjs/no-small-switch + switch (authMethod.toLowerCase()) { + case 'basic': + return await this.isValidBasic(credentials, ctx); + + case 'bearer': + return await this.isValidBearer(credentials, ctx); + + default: + return false; + } + } + + + /** + * + * @param {http.ServerResponse} res + */ + requestBasic(res) { + res.setHeader(Enum.Header.WWWAuthenticate, `Basic realm="${this.basicRealm}", charset="UTF-8"`); + throw new ResponseError(Enum.ErrorResponse.Unauthorized); + } + + + /** + * Require a request passes auth. + * Sets ctx.authenticationId on valid non-token authentication. + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async required(req, res, ctx) { + const _scope = _fileScope('required'); + this.logger.debug(_scope, 'called', { ctx }); + + let authData; + + // Prefer custom + authData = req.getHeader(Enum.Header.Signature); + if (authData) { + const validSignature = await this.isValidSignature(authData, ctx); + if (validSignature) { + return true; // Success + } + // Signature was present, but failed. Request signature auth. + return this.requestSignature(res); + } + + // Accept standards + authData = req.getHeader(Enum.Header.Authorization); + if (authData) { + const validAuthorization = await this.isValidAuthorization(authData, ctx); + if (validAuthorization) { + return true; // Success + } + return this.requestBasic(res); + } + + const linkId = ctx.params && ctx.params.id; + // If there is an id parameter, check for a valid token query parameter + if (linkId) { + authData = (ctx.queryParams && ctx.queryParams.token) || (ctx.parsedBody && ctx.parsedBody.token); + if (authData) { + const validToken = await this.isValidToken(linkId, authData); + if (validToken) { + return true; // Success + } + } + } + + return this.requestBasic(res); + } + + + /** + * Check and set authentication if provided and valid, but do not require it. + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async optional(req, res, ctx) { + const _scope = _fileScope('optional'); + this.logger.debug(_scope, 'called', { ctx }); + + let authData; + + // Allow and prefer a valid signature. + authData = req.getHeader(Enum.Header.Signature); + if (authData) { + const validSignature = await this.isValidSignature(authData, ctx); + if (validSignature) { + return true; // Success + } + } + + // Allow valid auth. + authData = req.getHeader(Enum.Header.Authorization); + if (authData) { + const validAuthorization = await this.isValidAuthorization(authData, ctx); + if (validAuthorization) { + return true; // Success + } + } + + // Allow a valid plain token. + const linkId = ctx.params && ctx.params.id; + if (linkId) { + const token = (ctx.queryParams && ctx.queryParams.token) || (ctx.parsedBody && ctx.parsedBody.token); + if (token) { + const validToken = await this.isValidToken(linkId, token); + if (validToken) { + return true; // Success + } + } + } + + return false; + } + + + /** + * If a request included signature auth, respond in kind. + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + signResponse(req, res, ctx) { + const _scope = _fileScope('signResponse'); + + // Keep huge payloads out of logs... + const logCtx = Object.assign({}, ctx); + if (logCtx.responseBody) { + if (typeof logCtx.responseBody !== 'string') { + logCtx.responseBody = logCtx.responseBody.toString(); + } + if (logCtx.responseBody.length > 128) { + logCtx.responseBody = logCtx.responseBody.slice(0, 128) + `... (${logCtx.responseBody.length} bytes)`; + } + } + this.logger.debug(_scope, 'called', { logCtx }); + + if (ctx.authenticationSecret && ctx.authenticationId) { + const currentEpoch = Date.now() / 1000; + const signature = Authenticator.signature(ctx.authenticationSecret, ctx.authenticationId, currentEpoch, ctx.responseBody || ''); + const signatureHeader = [ctx.authenticationId, currentEpoch, signature].join(':'); + res.setHeader(Enum.Header.Signature, signatureHeader); + this.logger.debug(_scope, 'added signature', { logCtx, signatureHeader }); + } + } + +} + +module.exports = Authenticator; diff --git a/src/common.js b/src/common.js new file mode 100644 index 0000000..21324c3 --- /dev/null +++ b/src/common.js @@ -0,0 +1,12 @@ +'use strict'; + +const { common } = require('@squeep/api-dingus'); + +const { promisify } = require('util'); +const { randomBytes } = require('crypto'); +const randomBytesAsync = promisify(randomBytes); + +module.exports = { + ...common, + randomBytesAsync, +}; diff --git a/src/db/base.js b/src/db/base.js new file mode 100644 index 0000000..b109efd --- /dev/null +++ b/src/db/base.js @@ -0,0 +1,72 @@ +'use strict'; + +const common = require('../common'); +const DBErrors = require('./errors'); + +const _fileScope = common.fileScope(__filename); + +class BaseDatabase { + constructor(logger) { + this.logger = logger; + } + + static _camelfy(snakeCase, delim = '_') { + if (!snakeCase || typeof snakeCase.split !== 'function') { + return undefined; + } + const words = snakeCase.split(delim); + return [ + words.shift(), + ...words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)), + ].join(''); + } + + _notImplemented(method, args) { + const _scope = _fileScope(method); + this.logger.error(_scope, 'abstract method called', Array.from(args)); + throw new DBErrors.NotImplemented(); + } + + async context(fn) { + this._notImplemented('context', { fn }); + } + + async transaction(dbCtx, fn) { + this._notImplemented('transaction', { dbCtx, fn }); + } + + async getAuthById(dbCtx, id) { + this._notImplemented('getAuthById', { dbCtx, id }); + } + + async insertLink(dbCtx, id, url, authToken) { + this._notImplemented('insertLink', { dbCtx, id, url, authToken }); + } + + async getLinkById(dbCtx, id) { + this._notImplemented('getLinkById', { dbCtx, id }); + } + + async getLinkByUrl(dbCtx, url) { + this._notImplemented('getLinkByUrl', { dbCtx, url }); + } + + async accessLink(dbCtx, id) { + this._notImplemented('accessLink', { dbCtx, id }); + } + + async expireLink(dbCtx, id, expires) { + this._notImplemented('expireLink', { dbCtx, id, expires }); + } + + async updateLink(dbCtx, id, url) { + this._notImplemented('updateLink', { dbCtx, id, url }); + } + + async getAllLinks(dbCtx) { + this._notImplemented('getAllLinks', { dbCtx }); + } + +} + +module.exports = BaseDatabase; diff --git a/src/db/errors.js b/src/db/errors.js new file mode 100644 index 0000000..f13baba --- /dev/null +++ b/src/db/errors.js @@ -0,0 +1,30 @@ +'use strict'; + + const { DatabaseError } = require('../errors'); + + class NotImplemented extends DatabaseError { + constructor(...args) { + super(...args); + Error.captureStackTrace(NotImplemented); + } + } + + class UnexpectedResult extends DatabaseError { + constructor(...args) { + super(...args); + Error.captureStackTrace(UnexpectedResult); + } + } + + class UnsupportedEngine extends DatabaseError { + constructor(...args) { + super(...args); + Error.captureStackTrace(UnsupportedEngine); + } + } + +module.exports = { + NotImplemented, + UnexpectedResult, + UnsupportedEngine, +}; \ No newline at end of file diff --git a/src/db/index.js b/src/db/index.js new file mode 100644 index 0000000..452e0cf --- /dev/null +++ b/src/db/index.js @@ -0,0 +1,44 @@ +'use strict'; + +const common = require('../common'); +const DBErrors = require('./errors'); + +const _fileScope = common.fileScope(__filename); + +const defaultOptions = { + connectionString: '', +}; + +class DatabaseFactory { + constructor(logger, options, ...rest) { + const _scope = _fileScope + ':constructor'; + options = Object.assign({}, defaultOptions, options); + const protocol = options.connectionString.slice(0, options.connectionString.indexOf('://')).toLowerCase(); + // eslint-disable-next-line sonarjs/no-small-switch + switch (protocol) { + case DatabaseFactory.Engines.PostgreSQL: { + const Postgres = require('./postgres'); + return new Postgres(logger, options, ...rest); + } + + case DatabaseFactory.Engines.SQLite: { + const SQLite = require('./sqlite'); + return new SQLite(logger, options, ...rest); + } + + default: + logger.error(_scope, 'unsupported connectionString', { options }); + throw new DBErrors.UnsupportedEngine(protocol); + } + } + + static get Engines() { + return { + PostgreSQL: 'postgresql', + SQLite: 'sqlite', + }; + } + +} + +module.exports = DatabaseFactory; diff --git a/src/db/postgres/index.js b/src/db/postgres/index.js new file mode 100644 index 0000000..f848bea --- /dev/null +++ b/src/db/postgres/index.js @@ -0,0 +1,311 @@ +'use strict'; + +const pgpInitOptions = { + capSQL: true, +}; + +const path = require('path'); +const pgp = require('pg-promise')(pgpInitOptions); +const BaseDatabase = require('../base'); +const common = require('../../common'); +const DBErrors = require('../errors'); + +const _fileScope = common.fileScope(__filename); + +const defaultOptions = { + connectionString: undefined, + queryLogLevel: undefined, + listenerPingDelayMs: 2000, + listenerRetryDelayMs: 5000, + listenerRetryTimes: 10, + listenerChannel: 'cache_invalidation', + listenerCallback: () => {}, + listenerLostCallback: () => {}, +}; + +class PostgresDatabase extends BaseDatabase { + constructor(logger, options, _pgp = pgp) { + const _scope = _fileScope('constructor'); + + super(logger); + common.setOptions(this, defaultOptions, options); + + this.logger.debug(_scope, 'connecting', { connectionString: this.connectionString }); + + this.db = _pgp(this.connectionString); + + if (this.queryLogLevel) { + pgpInitOptions.query = (e) => { + this.logger[this.queryLogLevel](_fileScope('query'), e.query, { params: e.params }); + }; + } + + pgpInitOptions.error = (err, e) => { + this.logger[this.queryLogLevel](_fileScope('pgp'), '', { err, e }); + }; + + pgpInitOptions.receive = (data, result, e) => { + const exemplaryRow = data[0]; + for (const prop in exemplaryRow) { + const camel = BaseDatabase._camelfy(prop); + if (!(camel in exemplaryRow)) { + for (const d of data) { + // eslint-disable-next-line security/detect-object-injection + d[camel] = d[prop]; + // eslint-disable-next-line security/detect-object-injection + delete d[prop]; + } + } + } + + if (this.queryLogLevel) { + this.logger[this.queryLogLevel](_fileScope('result'), e.query, PostgresDatabase._resultLog(result)); + } + }; + this._initStatements(_pgp); + } + + static _resultLog(result) { + return { + command: result.commaand, + rowCount: result.rowCount, + duration: result.duration, + }; + } + + _initStatements(_pgp) { + const _scope = _fileScope('_initStatements'); + + const qfOptions = { + minify: true, + }; + this.statement = _pgp.utils.enumSql(path.join(__dirname, 'sql'), {}, (file) => { + const qf = new _pgp.QueryFile(file, qfOptions); + this.logger.debug(_scope, file, { file: qf.file, options: qf.options, query: qf.query }); + if (qf.error) { + this.logger.error(_scope, file, { error: qf.error }); + throw qf.error; + } + return qf; + }); + + this.logger.debug(_scope, 'complete', { statementCount: Object.keys(this.statement).length }); + } + + // WIP not used yet + async _initListener() { + await this._reconnectListener(0, 1); + this._sendListenerNotifications(); + } + _sendListenerNotifications() { + const _scope = _fileScope('_sendListenerNotifications'); + setTimeout(async () => { + if (this.listenerConnection) { + try { + await this.listenerConnection.none('NOTIFY $1~, $2', [this.listenerChannel, 'ping']); + } catch (e) { + this.logger.error(_scope, 'failed', e); + } finally { + this._sendListenerNotifications(); + } + } + }, this.listenerPingDelayMs); + } + async _onListenerConnectionLost(err, ev) { + const _scope = _fileScope('_onConnectionLost'); + const eventName = 'notification'; + this.listenerConnection = null; + this.logger.error(_scope, 'listener connection lost', { err, ev }); + ev.client.removeListener(eventName, this._onListenerNotificationBound); + await this.listenerLostCallback(); + await this._reconnectListener(this.listenerRetryDelayMs, this.listenerRetryTimes); + this.logger.debug(_scope, 'listener reconnected'); + } + async _onListenerNotification(data) { + return await this.listenerCallback(data.payload); + } + async _reconnectListener(delay, retriesRemaining) { + const _scope = _fileScope('_reconnectListener'); + const eventName = 'notification'; + if (this.listenerConnection) { + this.listenerConnection.done(); + this.listenerConnection = null; + } + if (this.listenerReconnectPending) { + clearTimeout(this.listenerReconnectPending); + delete this.listenerReconnectPending; + } + return new Promise((resolve, reject) => { + this.listenerReconnectPending = setTimeout(async () => { + try { + this.listenerConnection = await this.db.connect({ + direct: true, + onLost: this._onListenerConnectionLost.bind(this), + }); + if (!this._onListenerNotificationBound) { + this._onListenerNotificationBound = this._onListenerNotification.bind(this); + } + this.listenerConnection.client.on(eventName, this._onListenerNotificationBound); + await this.listenerConnection.none('LISTEN $1~', this.listenerChannel); + delete this.listenerReconnectPending; + this.logger.debug(_scope, 'listener connection established'); + resolve(); + } catch (e) { + if (retriesRemaining > 0) { + try { + await this._reconnectListener(delay, retriesRemaining - 1); + resolve(); + } catch (e2) { + reject(e2); + } + } else { + reject(e); + } + } + }, delay); + }); + } + + + // eslint-disable-next-line class-methods-use-this + _postgresInfo(result) { + return { + changes: result.rowCount, + lastInsertRowid: result.rows.length ? result.rows[0].id : undefined, + duration: result.duration, + }; + } + + async context(fn) { + return await this.db.task(async (t) => await fn(t)); + } + + + async transaction(dbCtx, fn) { + dbCtx = dbCtx || this.db; + return await dbCtx.txIf(async (t) => await fn(t)); + } + + + async getAuthById(dbCtx, id) { + const _scope = _fileScope('getAuthById'); + this.logger.debug(_scope, 'called', { id }); + + dbCtx = dbCtx || this.db; + + const auth = await dbCtx.oneOrNone(this.statement.authGetById, { id }); + this.logger.debug(_scope, 'get', { auth }); + return auth; + } + + + static _epochFix(epoch) { + switch (epoch) { + case Infinity: + return Number.MAX_SAFE_INTEGER; + + case -Infinity: + return 0; + + default: + return epoch; + } + } + + + static _linkToNative(link) { + return link && { + ...link, + created: PostgresDatabase._epochFix(link.created), + lastAccess: PostgresDatabase._epochFix(link.lastAccess), + expires: PostgresDatabase._epochFix(link.expires), + }; + } + + async insertLink(dbCtx, id, url, authToken) { + const _scope = _fileScope('insertLink'); + this.logger.debug(_scope, 'called', { id, url, authToken }); + + dbCtx = dbCtx || this.db; + + const result = await dbCtx.result(this.statement.linkUpsert, { id, url, authToken }); + this.logger.debug(_scope, 'result', PostgresDatabase._resultLog(result) ); + return this._postgresInfo(result); + } + + + async getLinkById(dbCtx, id) { + const _scope = _fileScope('getLinkById'); + this.logger.debug(_scope, 'called', { id }); + + dbCtx = dbCtx || this.db; + + const link = await dbCtx.oneOrNone(this.statement.linkGetById, { id }); + this.logger.debug(_scope, 'get', { link }); + return PostgresDatabase._linkToNative(link); + } + + + async getLinkByUrl(dbCtx, url) { + const _scope = _fileScope('getLinkByUrl'); + this.logger.debug(_scope, 'called', { url }); + + dbCtx = dbCtx || this.db; + + const link = await dbCtx.oneOrNone(this.statement.linkGetByUrl, { url }); + this.logger.debug(_scope, 'get', { link }); + return PostgresDatabase._linkToNative(link); + } + + + async accessLink(dbCtx, id) { + const _scope = _fileScope('accessLink'); + this.logger.debug(_scope, 'called', { id }); + + dbCtx = dbCtx || this.db; + + const link = await dbCtx.oneOrNone(this.statement.linkAccess, { id }); + this.logger.debug(_scope, 'get', { link }); + return PostgresDatabase._linkToNative(link); + } + + + async expireLink(dbCtx, id, expires) { + const _scope = _fileScope('expireLink'); + this.logger.debug(_scope, 'called', { id, expires }); + + dbCtx = dbCtx || this.db; + + const result = await dbCtx.result(this.statement.linkExpire, { expires, id }); + this.logger.debug(_scope, 'result', PostgresDatabase._resultLog(result) ); + return this._postgresInfo(result); + } + + + async updateLink(dbCtx, id, url) { + const _scope = _fileScope('updateLink'); + this.logger.debug(_scope, 'called', { id, url }); + + dbCtx = dbCtx || this.db; + + const result = await dbCtx.result(this.statement.linkUpdate, { id, url }); + this.logger.debug(_scope, 'result', PostgresDatabase._resultLog(result) ); + return this._postgresInfo(result); + } + + + async getAllLinks(dbCtx) { + const _scope = _fileScope('getAllLinks'); + this.logger.debug(_scope, 'called', { }); + + dbCtx = dbCtx || this.db; + + const links = await dbCtx.manyOrNone(this.statement.linkGetAll, { }); + this.logger.debug(_scope, 'get', { links }); + return links.map((l) => PostgresDatabase._linkToNative(l)); + } + + +} + +module.exports = PostgresDatabase; diff --git a/src/db/postgres/sql/auth-get-by-id.sql b/src/db/postgres/sql/auth-get-by-id.sql new file mode 100644 index 0000000..98f0434 --- /dev/null +++ b/src/db/postgres/sql/auth-get-by-id.sql @@ -0,0 +1 @@ +SELECT * FROM auth WHERE id = $(id) \ No newline at end of file diff --git a/src/db/postgres/sql/link-access.sql b/src/db/postgres/sql/link-access.sql new file mode 100644 index 0000000..5f4c128 --- /dev/null +++ b/src/db/postgres/sql/link-access.sql @@ -0,0 +1,5 @@ +UPDATE link SET accesses = accesses + 1, last_access = now() WHERE id = $(id) +RETURNING *, + extract(epoch from created) AS created, + extract(epoch from last_access) AS last_access, + extract(epoch from expires) AS expires diff --git a/src/db/postgres/sql/link-expire.sql b/src/db/postgres/sql/link-expire.sql new file mode 100644 index 0000000..a799b8a --- /dev/null +++ b/src/db/postgres/sql/link-expire.sql @@ -0,0 +1 @@ +UPDATE link SET expires = to_timestamp($(expires)) WHERE id = $(id) \ No newline at end of file diff --git a/src/db/postgres/sql/link-get-all.sql b/src/db/postgres/sql/link-get-all.sql new file mode 100644 index 0000000..40a935f --- /dev/null +++ b/src/db/postgres/sql/link-get-all.sql @@ -0,0 +1,6 @@ +SELECT *, + extract(epoch from created) AS created, + extract(epoch from last_access) AS last_access, + extract(epoch from expires) AS expires +FROM link +ORDER BY id \ No newline at end of file diff --git a/src/db/postgres/sql/link-get-by-id.sql b/src/db/postgres/sql/link-get-by-id.sql new file mode 100644 index 0000000..8eeb27d --- /dev/null +++ b/src/db/postgres/sql/link-get-by-id.sql @@ -0,0 +1,5 @@ +SELECT *, + extract(epoch from created) AS created, + extract(epoch from last_access) AS last_access, + extract(epoch from expires) AS expires +FROM link WHERE id = $(id) \ No newline at end of file diff --git a/src/db/postgres/sql/link-get-by-url.sql b/src/db/postgres/sql/link-get-by-url.sql new file mode 100644 index 0000000..113837a --- /dev/null +++ b/src/db/postgres/sql/link-get-by-url.sql @@ -0,0 +1,5 @@ +SELECT *, + extract(epoch from created) AS created, + extract(epoch from last_access) AS last_access, + extract(epoch from expires) AS expires +FROM link WHERE url = $(url) \ No newline at end of file diff --git a/src/db/postgres/sql/link-update.sql b/src/db/postgres/sql/link-update.sql new file mode 100644 index 0000000..fb04ea4 --- /dev/null +++ b/src/db/postgres/sql/link-update.sql @@ -0,0 +1 @@ +UPDATE link SET url = $(url) WHERE id = $(id) \ No newline at end of file diff --git a/src/db/postgres/sql/link-upsert.sql b/src/db/postgres/sql/link-upsert.sql new file mode 100644 index 0000000..9a8e024 --- /dev/null +++ b/src/db/postgres/sql/link-upsert.sql @@ -0,0 +1,10 @@ +INSERT INTO link (id, url, auth_token) + VALUES + ($(id), $(url), $(authToken)) + ON CONFLICT (id) DO UPDATE + SET + url = $(url) +RETURNING *, + extract(epoch from created) AS created, + extract(epoch from last_access) AS last_access, + extract(epoch from expires) AS expires diff --git a/src/db/postgres/sql/schema/1.0.0/apply.sql b/src/db/postgres/sql/schema/1.0.0/apply.sql new file mode 100644 index 0000000..d4d7a84 --- /dev/null +++ b/src/db/postgres/sql/schema/1.0.0/apply.sql @@ -0,0 +1,46 @@ +BEGIN; + + -- core data + CREATE TABLE link ( + id TEXT NOT NULL PRIMARY KEY, + url TEXT NOT NULL UNIQUE, + created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + last_access TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT '-infinity'::timestamptz, + accesses INTEGER NOT NULL DEFAULT 0, + expires TIMESTAMP WITH TIME ZONE, + auth_token TEXT + ); + + -- send notices to invalidate cacheable link data + CREATE OR REPLACE FUNCTION cache_invalidation_link() + RETURNS TRIGGER + LANGUAGE plpgsql + AS $$ + DECLARE + payload varchar; + BEGIN + IF NEW.url != OLD.url OR NEW.expires != OLD.expires + THEN + payload = 'link|' || CAST(NEW.id AS text); + PERFORM pg_notify('cache_invalidation', payload); + END IF; + RETURN NEW; + END; + $$ + ; + CREATE TRIGGER cache_invalidation_link + AFTER UPDATE + ON link + FOR EACH ROW + EXECUTE PROCEDURE cache_invalidation(); + + -- better auth TBD + CREATE TABLE auth ( + id TEXT NOT NULL PRIMARY KEY, + secret TEXT NOT NULL, + password TEXT + ); + + -- migration complete + INSERT INTO _meta_schema_version (major, minor, patch) VALUES (1, 0, 0); +COMMIT; diff --git a/src/db/postgres/sql/schema/1.0.0/revert.sql b/src/db/postgres/sql/schema/1.0.0/revert.sql new file mode 100644 index 0000000..38ed34d --- /dev/null +++ b/src/db/postgres/sql/schema/1.0.0/revert.sql @@ -0,0 +1,7 @@ +BEGIN; + DROP TRIGGER cache_invalidation_link ON link; + DROP FUNCTION cache_invalidation_link(); + DROP TABLE auth; + DROP TABLE link; + DELETE FROM _meta_schema_version WHERE major = 1 AND minor = 0 AND patch = 0; +COMMIT; diff --git a/src/db/postgres/sql/schema/init.sql b/src/db/postgres/sql/schema/init.sql new file mode 100644 index 0000000..8343f3d --- /dev/null +++ b/src/db/postgres/sql/schema/init.sql @@ -0,0 +1,8 @@ +CREATE EXTENSION IF NOT EXISTS pg_stat_statements; +CREATE TABLE IF NOT EXISTS _meta_schema_version ( + major BIGINT NOT NULL, + minor BIGINT NOT NULL, + patch BIGINT NOT NULL, + applied TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + PRIMARY KEY (major, minor, patch) +); diff --git a/src/db/sqlite/index.js b/src/db/sqlite/index.js new file mode 100644 index 0000000..5971142 --- /dev/null +++ b/src/db/sqlite/index.js @@ -0,0 +1,288 @@ +'use strict'; + +const SQLite = require('better-sqlite3'); +// XXX: const NodeCleanup = require('node-cleanup'); +const BaseDatabase = require('../base'); +const common = require('../../common'); +const DBErrors = require('../errors'); + +const _fileScope = common.fileScope(__filename); + +const defaultOptions = { + connectionString: '', + optimizeAfterChanges: 1000, + queryLogLevel: 'debug', +}; + +const EPOCH_NOW = '(strftime(\'%s\', \'now\'))'; + +class SQLiteDatabase extends BaseDatabase { + constructor(logger, options) { + const _scope = _fileScope('constructor'); + + super(logger); + common.setOptions(this, defaultOptions, options); + + this.dbFilename = this.connectionString.slice('sqlite://'.length) || ':memory:'; + + this.logger.debug(_scope, 'connecting', { dbFilename: this.dbFilename }); + + this.db = new SQLite(this.dbFilename, { + verbose: (query) => this.queryLogLevel && this.logger[this.queryLogLevel](_fileScope('statement'), 'executing', { query }), + }); + + this.changesSinceLastOptimize = BigInt(0); + + /* + NodeCleanup(() => { + this._optimize(); + this._closeConnection(); + }); + */ + + this.db.pragma('foreign_keys = on'); + this.db.pragma('journal_mode = WAL'); + this.db.defaultSafeIntegers(true); + + this._initTables(); + this._initStatements(); + } + + _initTables() { + const _scope = _fileScope('_initTables'); + const tableExists = this.db.prepare('SELECT name FROM sqlite_master WHERE type=\'table\' AND name=:tableName').pluck(true); + + const tables = [ + { + name: 'auth', + statements: [ + `CREATE TABLE IF NOT EXISTS auth ( + id TEXT NOT NULL PRIMARY KEY, + secret TEXT NOT NULL, + password TEXT + )`, + `INSERT INTO auth (id, secret, password) + VALUES + ('foo', 'secret', 'quux')`, + ], + }, + { + name: 'link', + statements: [ + `CREATE TABLE IF NOT EXISTS link ( + id TEXT NOT NULL PRIMARY KEY, + url TEXT NOT NULL UNIQUE, + created INTEGER NOT NULL DEFAULT ${EPOCH_NOW}, + last_access INTEGER NOT NULL DEFAULT 0, + accesses INTEGER NOT NULL DEFAULT 0, + expires INTEGER, + auth_token TEXT + )`, + 'CREATE INDEX IF NOT EXISTS link_url_idx ON link(url)', + ], + }, + ]; + + tables.forEach((t) => { + const table = tableExists.get({ tableName: t.name }); + if (table === undefined) { + t.statements.forEach((s) => { + this.logger.info(_scope, 'creating table', { tableName: t.name }); + this.db.prepare(s).run(); + }); + } + }); + } + + _initStatements() { + this.statement = { + getAuthById: this.db.prepare('SELECT * FROM auth WHERE id = :id'), + getLinkById: this.db.prepare('SELECT * FROM link WHERE id = :id'), + getLinkByUrl: this.db.prepare('SELECT * FROM link WHERE url = :url'), + insertLink: this.db.prepare('INSERT INTO link (id, url, auth_token) VALUES (:id, :url, :authToken)'), + updateLink: this.db.prepare('UPDATE link SET url = :url WHERE id = :id'), + incrementLinkAccess: this.db.prepare(`UPDATE link SET accesses = accesses + 1, last_access = ${EPOCH_NOW} WHERE id = :id`), + expireLink: this.db.prepare('UPDATE link SET expires = :expires WHERE id = :id'), + linkGetAll: this.db.prepare('SELECT * FROM link'), + }; + } + + _optimize() { + const _scope = _fileScope('_optimize'); + this.logger.debug(_scope, 'called', {}); + const optimizations = this.db.prepare('SELECT * FROM pragma_optimize(0x03)').all(); + this.logger.debug(_scope, 'pragma preview', { optimizations }); + this.db.pragma('optimize'); + this.changesSinceLastOptimize = BigInt(0); + } + + _maybeOptimize() { + if (this.changesSinceLastOptimize >= this.optimizeAfterChanges) { + this._optimize(); + } + } + + _sqliteInfo(info) { + if (info.changes) { + this.changesSinceLastOptimize += BigInt(info.changes); + this._maybeOptimize(); + } + return { + changes: Number(info.changes), + lastInsertRowid: Number(info.lastInsertRowid), + }; + } + + static _deOphidiate(row) { + return row && Object.keys(row).reduce((snakelessRow, k) => Object.assign(snakelessRow, { + // eslint-disable-next-line security/detect-object-injection + [BaseDatabase._camelfy(k)]: row[k], + }), {}); + } + + + async context(fn) { + const dbCtx = this.db; + return await fn(dbCtx); + } + + async transaction(dbCtx, fn, ...rest) { + dbCtx = dbCtx || this.db; + return await dbCtx.transaction(async (...args) => await fn(...args))(dbCtx, ...rest); + } + + async getAuthById(dbCtx, id) { + const _scope = _fileScope('getAuthById'); + this.logger.debug(_scope, 'called', { id }); + + let auth; + auth = this.statement.getAuthById.get({ id }); + auth = SQLiteDatabase._deOphidiate(auth); + + this.logger.debug(_scope, 'get', { auth }); + return auth; + } + + async insertLink(dbCtx, id, url, authToken) { + const _scope = _fileScope('insertLink'); + this.logger.debug(_scope, 'called', { id, url }); + + let info; + try { + info = this.statement.insertLink.run({ id, url, authToken }); + } catch (e) { + switch (e.code) { + case 'SQLITE_CONSTRAINT_UNIQUE': + case 'SQLITE_CONSTRAINT_PRIMARYKEY': { + this.logger.debug(_scope, 'updating existing id', { id, url }); + info = this.statement.updateLink.run({ id, url }); + break; + } + + default: { + this.logger.error(_scope, 'failed to insert link', { error: e, id, url }); + throw e; + } + } + } + this.logger.debug(_scope, 'run', { info }); + if (info.changes != 1) { + this.logger.error(_scope, 'failed to insert link', { id, url, info }); + throw new DBErrors.UnexpectedResult(); + } + + return this._sqliteInfo(info); + } + + static _linkToNative(link) { + return link && { + id: link.id, + url: link.url, + created: Number(link.created), + lastAccess: Number(link.lastAccess), + accesses: Number(link.accesses), + expires: ('expires' in link) ? Number(link.expires) : undefined, + authToken: link.authToken, + }; + } + + async getLinkById(dbCtx, id) { + const _scope = _fileScope('getLinkById'); + this.logger.debug(_scope, 'called', { id }); + + let link = this.statement.getLinkById.get({ id }); + link = SQLiteDatabase._deOphidiate(link); + + this.logger.debug(_scope, 'get', { link }); + return SQLiteDatabase._linkToNative(link); + } + + async getLinkByUrl(dbCtx, url) { + const _scope = _fileScope('getLinkByUrl'); + this.logger.debug(_scope, 'called', { url }); + + let link = this.statement.getLinkByUrl.get({ url }); + link = SQLiteDatabase._deOphidiate(link); + + this.logger.debug(_scope, 'get', { link }); + return SQLiteDatabase._linkToNative(link); + } + + async accessLink(dbCtx, id) { + const _scope = _fileScope('accessLink'); + this.logger.debug(_scope, 'called', { id }); + + let link = this.statement.getLinkById.get({ id }); + link = SQLiteDatabase._deOphidiate(link); + + this.logger.debug(_scope, 'get', { id, link }); + + if (link) { + const info = this.statement.incrementLinkAccess.run({ id }); + this.logger.debug(_scope, 'increment', { id, info }); + if (info.changes != 1) { + this.logger.error(_scope, 'failed to increment link access', { id, link, info }); + throw new DBErrors.UnexpectedResult(); + } + } + return SQLiteDatabase._linkToNative(link); + } + + async expireLink(dbCtx, id, expires) { + const _scope = _fileScope('expireLink'); + this.logger.debug(_scope, 'called', { id }); + + const info = this.statement.expireLink.run({ id, expires }); + if (info.changes != 1) { + throw new DBErrors.UnexpectedResult(); + } + return this._sqliteInfo(info); + } + + async updateLink(dbCtx, id, url) { + const _scope = _fileScope('updateLink'); + this.logger.debug(_scope, 'called', { id, url }); + + const info = this.statement.updateLink.run({ id, url }); + if (info.changes != 1) { + throw new DBErrors.UnexpectedResult(); + } + return this._sqliteInfo(info); + } + + // eslint-disable-next-line no-unused-vars + async getAllLinks(dbCtx) { + const _scope = _fileScope('getAllLinks'); + this.logger.debug(_scope, 'called', { }); + + let links; + links = this.statement.linkGetAll.all({}); + links = links.map((l) => SQLiteDatabase._deOphidiate(l)); + this.logger.debug(_scope, 'get', { links }); + links = links.map((l) => SQLiteDatabase._linkToNative(l)); + return links; + } + +} + +module.exports = SQLiteDatabase; diff --git a/src/enum.js b/src/enum.js new file mode 100644 index 0000000..10248a2 --- /dev/null +++ b/src/enum.js @@ -0,0 +1,35 @@ +'use strict'; + +const common = require('./common'); +const { Enum } = require('@squeep/api-dingus'); + +common.mergeEnum(Enum, { + ContentType: { + }, + + ErrorResponse: { + InvalidAuthentication: { + statusCode: 403, + errorMessage: 'Invalid Authentication', + }, + InvalidLinkId: { + statusCode: 404, + errorMessage: 'Not Found', + details: 'not a valid link id', + }, + InvalidURLParameter: { + statusCode: 400, + errorMessage: 'Bad Request', + details: 'invalid url parameter', + }, + }, + + Header: { + Authorization: 'Authorization', + LastSeen: 'Last-Seen', + Signature: 'Signature', + WWWAuthenticate: 'WWW-Authenticate', + }, +}); + +module.exports = Enum; \ No newline at end of file diff --git a/src/errors.js b/src/errors.js new file mode 100644 index 0000000..fb7a212 --- /dev/null +++ b/src/errors.js @@ -0,0 +1,31 @@ +'use strict'; + +const { Errors } = require('@squeep/api-dingus'); + +class DatabaseError extends Errors.DingusError { + constructor(...args) { + super(...args); + Error.captureStackTrace(DatabaseError); + } +} + +class ServeStaticFile extends Errors.DingusError { + constructor(file, ...args) { + super(...args); + this.file = file; + delete this.stack; + } +} + +class SlugGeneratorExhausted extends Errors.DingusError { + constructor(...args) { + super(...args); + } +} + +module.exports = { + ...Errors, + DatabaseError, + ServeStaticFile, + SlugGeneratorExhausted, +}; \ No newline at end of file diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..7c8e3cb --- /dev/null +++ b/src/logger.js @@ -0,0 +1,74 @@ +'use strict'; + +/** + * Log as JSON to stdout/stderr. + */ + +const common = require('./common'); + +// This is uncomfortable, but is the simplest way to let logging work for BigInts. +// TODO: revisit with better solution +BigInt.prototype.toJSON = function() { + return this.toString(); +} + +// Also uncomfortable +Object.defineProperty(Error.prototype, 'toJSON', { + configurable: true, + value: function () { + const result = {}; + const dupKey = function (key) { + // eslint-disable-next-line security/detect-object-injection + result[key] = this[key]; + }; + Object.getOwnPropertyNames(this).forEach(dupKey, this); + return result; + }, +}); + +const defaultOptions = { + ignoreBelowLevel: 'debug', + backend: console, +}; + +class Logger { + constructor(options = {}) { + common.setOptions(this, defaultOptions, options); + + this.logLevels = [ + 'error', + 'warn', + 'info', + 'log', + 'debug', + ]; + + const ignoreLevelIdx = this.logLevels.indexOf(this.ignoreBelowLevel); + this.logLevels.forEach((level) => { + // eslint-disable-next-line security/detect-object-injection + this[level] = (this.logLevels.indexOf(level) > ignoreLevelIdx) ? + () => {} : + Logger.levelTemplateFn(this.backend, level); + }); + } + + static levelTemplateFn(backend, level) { + return function (...args) { + // eslint-disable-next-line security/detect-object-injection + backend[level](Logger.payload(level, ...args)); + }; + } + + static payload(level, scope, message, data, ...other) { + return JSON.stringify({ + timestamp: Date.now(), + level: level, + scope: scope || '[unknown]', + message: message || '', + data: data || {}, + ...(other.length && { other }), + }); + } +} + +module.exports = Logger; diff --git a/src/manager.js b/src/manager.js new file mode 100644 index 0000000..a5035cd --- /dev/null +++ b/src/manager.js @@ -0,0 +1,364 @@ +'use strict'; + +const Authenticator = require('./authenticator'); +const common = require('./common'); +const Enum = require('./enum'); +const { ResponseError, ServeStaticFile, SlugGeneratorExhausted } = require('./errors'); +const { makeSlugGenerator } = require('./slug'); +const Template = require('./template'); + +const _fileScope = common.fileScope(__filename); + +const defaultOptions = { + pageTitle: require('../package.json').name, + selfBaseUrl: '', + staticDirectory: '/static/', +}; + +class Manager { + constructor(logger, db, options = {}) { + common.setOptions(this, defaultOptions, options); + this.logger = logger; + this.db = db; + this.makeSlugGenerator = makeSlugGenerator; + } + + + /** + * Render root content. Shared by GET and POST. + * @param {Object} ctx + * @param {string} ctx.authToken + * @param {string} ctx.createdLink + * @param {string} ctx.message + * @param {string} ctx.sourceLink + */ + rootContent(ctx) { + // eslint-disable-next-line sonarjs/no-small-switch + switch (ctx.responseType) { + case Enum.ContentType.ApplicationJson: + return JSON.stringify({ + link: ctx.createdLink, + src: ctx.sourceLink, + token: ctx.authToken, + }); + + case Enum.ContentType.TextHTML: + default: + return Template.rootHTML(ctx, this.pageTitle); + } + } + + + /** + * GET request for root. + * NB only this manager gets req, to access headers for cache response. + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async getRoot(req, res, ctx) { + const _scope = _fileScope('getRoot'); + this.logger.debug(_scope, 'called', { ctx }); + + const content = this.rootContent(ctx); + + /* Since this GET response will never change while server is running, + * set headers to make it cacheable. + */ + if (!this.startTime) { + const now = new Date(); + this.startTime = now.toGMTString(); + this.startTimeMs = now.getTime(); + this.getRootETag = common.generateETag(undefined, undefined, content); + } + res.setHeader(Enum.Header.LastModified, this.startTime); + res.setHeader(Enum.Header.ETag, this.getRootETag); + + if (common.isClientCached(req, this.startTimeMs, this.getRootETag)) { + this.logger.debug(_scope, 'client cached response', { ctx }); + res.statusCode = 304; + res.end(); + return; + } + this.logger.debug(_scope, 'response', { ctx }); + res.end(content); + } + + + /** + * The heart of the littler. + * TODO: other styles of generators + * @param {*} dbCtx + */ + async _getNewIdentifier(dbCtx, url) { + const idGen = this.makeSlugGenerator(url); + let id, done; + do { + ({ done, value: id } = await idGen.next()); + if (done || !id) { + throw new SlugGeneratorExhausted(); + } + } while (await this.db.getLinkById(dbCtx, id)); + return id; + } + + + /** + * Validates the ctx.sourceLink is acceptable to add. + * @param {object} ctx + * @param {string} ctx.sourceLink + * @param {string=} ctx.authenticationId + */ + _validateContextURL(ctx) { + const _scope = _fileScope('_validateContextURL'); + + // Admin is allowed to create locally served entries. + if (ctx.sourceLink && ctx.sourceLink.startsWith(this.staticDirectory) + && ctx.authenticationId) { + return; + } + // Otherwise, url must be complete. + try { + new URL(ctx.sourceLink); + } catch (e) { + this.logger.debug(_scope, 'invalid url', { error: e, ctx }); + throw new ResponseError(Enum.ErrorResponse.InvalidURLParameter); + } + } + + + /** + * Get request for link, redirect. + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async getById(res, ctx) { + const _scope = _fileScope('getById'); + this.logger.debug(_scope, 'called', { ctx }); + + let link; + await this.db.context(async (dbCtx) => { + link = await this.db.accessLink(dbCtx, ctx.params.id); + }); + if (!link) { + this.logger.info(_scope, 'no such link', { ctx }); + throw new ResponseError(Enum.ErrorResponse.InvalidLinkId); + } + this.logger.debug(_scope, 'found link', { link, ctx }); + + if (link.expires && link.expires <= Date.now()) { + this.logger.info(_scope, 'link expired', { link, ctx }); + const expiresDate = new Date(link.expires * 1000).toGMTString(); + res.setHeader(Enum.Header.LastSeen, expiresDate); + throw new ResponseError(Enum.ErrorResponse.Gone); + } + + if (link.url.startsWith(this.staticDirectory)) { + /* This is only a little awkward: throw back up to service to let the + * dingus handle the static file serving. + */ + const fileName = link.url.slice(this.staticDirectory.length); + this.logger.info(_scope, 'static link', { link, ctx }); + throw new ServeStaticFile(fileName); + } + + this.logger.info(_scope, 'redirect link', { link, ctx }); + + res.statusCode = 307; // Temporary Redirect + res.setHeader('Location', link.url); + res.end('Redirecting to ' + link.url); + } + + + /** + * POST request for root, create new link. + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async postRoot(res, ctx) { + const _scope = _fileScope('postRoot'); + this.logger.debug(_scope, 'called', { ctx }); + + let content; + ctx.sourceLink = ctx.parsedBody.url; + if (!ctx.sourceLink) { + throw new ResponseError(Enum.ErrorResponse.BadRequest, 'missing required parameter'); + } + this._validateContextURL(ctx); + await this.db.context(async (dbCtx) => { + await this.db.transaction(dbCtx, async (txCtx) => { + const existingLink = await this.db.getLinkByUrl(txCtx, ctx.sourceLink); + if (existingLink) { + res.statusCode = 200; // OK + ctx.createdLink = `${this.selfBaseUrl}/${existingLink.id}`; + if (existingLink.expires < Date.now()) { + await this.db.expireLink(txCtx, existingLink.id, null); + ctx.authToken = existingLink.authToken; + this.logger.info(_scope, 'un-expired link', { existingLink, ctx }); + } + } else { + res.statusCode = 201; // Created + const id = await this._getNewIdentifier(txCtx, ctx.sourceLink); + ctx.authToken = await Authenticator.generateToken(); + await this.db.insertLink(txCtx, id, ctx.sourceLink, ctx.authToken); + ctx.createdLink = `${this.selfBaseUrl}/${id}`; + this.logger.info(_scope, 'created new link', { id, ctx }); + } + content = this.rootContent(ctx); + return res.end(content); + }); + }); + } + + + /** + * Put request for link, create with id, or update url. + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async putById(res, ctx) { + const _scope = _fileScope('putById'); + this.logger.debug(_scope, 'called', { ctx }); + + ctx.sourceLink = ctx.parsedBody.url; + if (!ctx.sourceLink) { + throw new ResponseError(Enum.ErrorResponse.BadRequest, 'missing required parameter'); + } + this._validateContextURL(ctx); + await this.db.context(async (dbCtx) => { + await this.db.transaction(dbCtx, async (txCtx) => { + const existingLink = await this.db.getLinkById(txCtx, ctx.params.id); + if (!existingLink) { + // To create a specific link, need admin authentication. + if (!ctx.authenticationId) { + this.logger.debug(_scope, 'cannot put without valid credentials', { ctx }); + throw new ResponseError(Enum.ErrorResponse.Forbidden); + } + + res.statusCode = 201; // Created + ctx.authToken = await Authenticator.generateToken(); + this.db.insertLink(txCtx, ctx.params.id, ctx.sourceLink, ctx.authToken); + ctx.createdLink = `${this.selfBaseUrl}/${ctx.params.id}`; + this.logger.info(_scope, 'created new link', { ctx }); + } else { + // Update link + await this.db.insertLink(txCtx, existingLink.id, ctx.sourceLink, existingLink.token); + ctx.createdLink = `${this.selfBaseUrl}/${existingLink.id}`; + this.logger.info(_scope, 'updated link', { ctx }); + } + }); + }); + + const content = this.rootContent(ctx); + return res.end(content); + } + + + /** + * Delete request for link, expire. + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async deleteById(res, ctx) { + const _scope = _fileScope('deleteById'); + this.logger.debug(_scope, 'called', { ctx }); + + let link; + await this.db.context(async (dbCtx) => { + await this.db.transaction(dbCtx, async (txCtx) => { + link = await this.db.getLinkById(txCtx, ctx.params.id); + if (!link) { + this.logger.debug(_scope, 'no such id', { ctx }); + throw new ResponseError(Enum.ErrorResponse.InvalidLinkId); + } + if (link.expires && link.expires < Date.now()) { + res.statusCode = 304; // Not Modified + return; + } + const when = Date.now() / 1000; + await this.db.expireLink(txCtx, ctx.params.id, when); + this.logger.info(_scope, 'expired link', { ctx, when }); + res.statusCode = 204; // No Content + }); + }); + res.end(); + } + + + /** + * Render link information. + * @param {*} ctx + * @param {*} details + */ + infoContent(ctx, details) { + switch (ctx.responseType) { + case Enum.ContentType.ApplicationJson: + return JSON.stringify(details); + + case Enum.ContentType.TextHTML: + default: + return Template.infoHTML(ctx, details, this.pageTitle); + } + } + + + /** + * Get request for link information. + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async getByIdInfo(res, ctx) { + const _scope = _fileScope('getByIdInfo'); + this.logger.debug(_scope, 'called', { ctx }); + + let link; + await this.db.context(async (dbCtx) => { + link = await this.db.getLinkById(dbCtx, ctx.params.id); + if (!link) { + this.logger.debug(_scope, 'no such link', { ctx }); + throw new ResponseError(Enum.ErrorResponse.InvalidLinkId); + } + this.logger.info(_scope, 'link found', { ctx, link }); + }); + + res.end(this.infoContent(ctx, link)); + } + + + /** Render report content. + * @param {object} ctx + * @param {object[]} links + */ + reportContent(ctx, links) { + switch (ctx.responseType) { + case Enum.ContentType.ApplicationJson: + return JSON.stringify(links); + + case Enum.ContentType.TextHTML: + default: + return Template.reportHTML(ctx, links, this.pageTitle); + } + } + + + /** + * Get request for link information. + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async getAdminReport(res, ctx) { + const _scope = _fileScope('getAdminReport'); + this.logger.debug(_scope, 'called', { ctx }); + + let links; + await this.db.context(async (dbCtx) => { + links = await this.db.getAllLinks(dbCtx); + this.logger.info(_scope, 'found links', { ctx, links }); + }); + + res.end(this.reportContent(ctx, links)); + } + +} + +module.exports = Manager; \ No newline at end of file diff --git a/src/service.js b/src/service.js new file mode 100644 index 0000000..1b09d2d --- /dev/null +++ b/src/service.js @@ -0,0 +1,258 @@ +'use strict'; + +/** + * Here we extend the base API server to define our routes and any route-specific + * behavior (middlewares) before handing off to the manager. + */ + +const { Dingus } = require('@squeep/api-dingus'); +const path = require('path'); +const common = require('./common'); +const Enum = require('./enum'); +const { ServeStaticFile } = require('./errors'); +const Authenticator = require('./authenticator'); +const Manager = require('./manager'); + +const _fileScope = common.fileScope(__filename); + +const defaultOptions = { + ignoreTrailingSlash: true, + staticDirectory: path.join(__dirname, '..', 'static'), + createRequiresAuth: false, + proxyPrefix: '', +}; + +class Service extends Dingus { + constructor(logger, db, options = {}) { + super(logger, { ...defaultOptions, ...options }); + common.setOptions(this, defaultOptions, options); + + this.authenticator = new Authenticator(logger, db, options.authenticator); + this._postRootAuth = this.authenticator[this.createRequiresAuth ? 'required' : 'optional'].bind(this.authenticator); + + this.manager = new Manager(logger, db, options.manager); + + this.responseTypes.push( + // 'other/handled-type', + ); + + this.on('POST', '/', this.handlerPostRoot.bind(this)); + this.on(['GET', 'HEAD'], '/', this.handlerGetRoot.bind(this)); + this.on(['GET', 'HEAD'], '/:id', this.handlerGetId.bind(this)); + this.on('DELETE', '/:id', this.handlerDeleteId.bind(this)); + this.on('PUT', '/:id', this.handlerPutId.bind(this)); + this.on(['GET', 'HEAD'], '/:id/info', this.handlerGetIdInfo.bind(this)); + this.on(['GET', 'HEAD'], '/static/:file', this.handlerGetStatic.bind(this)); + this.on(['GET', 'HEAD'], '/admin/report', this.handlerGetAdminReport.bind(this)); + } + + + /** + * Return a content-type appropriate rendering of an errorResponse object. + * @param {string} contentType content type of response + * @param {ResponseError|Exception} err + */ + renderError(contentType, err) { + // eslint-disable-next-line sonarjs/no-small-switch + switch (contentType) { + // Handle any additional content types here + + default: + return super.renderError(contentType, err); + } + } + + + /** + * Parse rawBody from ctx as contentType into parsedBody. + * @param {string} contentType + * @param {object} ctx + */ + parseBody(contentType, ctx) { + // eslint-disable-next-line sonarjs/no-small-switch + switch (contentType) { + // Handle any additional content types here + + default: + super.parseBody(contentType, ctx); + } + } + + + /** + * Called before every request handler. + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async preHandler(req, res, ctx) { + super.preHandler(req, res, ctx); + Dingus.setEndBodyHandler(req, res, ctx, this._endHandler.bind(this)); + } + + /** + * Do anything needed before sending. + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + _endHandler(req, res, ctx) { + this.authenticator.signResponse(req, res, ctx); + } + + /** + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async handlerPostRoot(req, res, ctx) { + const _scope = _fileScope('handlerPostRoot'); + this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx }); + + this.setResponseType(this.responseTypes, req, res, ctx); + await this.ingestBody(req, res, ctx); + await this._postRootAuth(req, res, ctx); + + await this.manager.postRoot(res, ctx); + } + + + /** + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async handlerGetRoot(req, res, ctx) { + const _scope = _fileScope('handlerGetRoot'); + const responseTypes = [ + Enum.ContentType.TextHTML, + ]; + this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx }); + + Dingus.setHeadHandler(req, res, ctx); + + this.setResponseType(responseTypes, req, res, ctx); + this.authenticator.optional(req, res, ctx); + + // NB special case for this handler, pass in req so it can check headers + await this.manager.getRoot(req, res, ctx); + } + + + /** + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async handlerGetId(req, res, ctx) { + const _scope = _fileScope('handlerGetId'); + this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx }); + + Dingus.setHeadHandler(req, res, ctx); + + this.setResponseType(this.responseTypes, req, res, ctx); + this.authenticator.optional(req, res, ctx); + + try { + await this.manager.getById(res, ctx); + } catch (e) { + if (e instanceof ServeStaticFile + && e.file) { + return await this.serveFile(req, res, ctx, this.staticDirectory, e.file); + } + throw e; + } + } + + + /** + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async handlerDeleteId(req, res, ctx) { + const _scope = _fileScope('handlerDeleteId'); + this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx }); + + this.setResponseType(this.responseTypes, req, res, ctx); + await this.authenticator.required(req, res, ctx); + + await this.manager.deleteById(res, ctx); + } + + + /** + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async handlerPutId(req, res, ctx) { + const _scope = _fileScope('handlerPutId'); + this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx }); + + this.setResponseType(this.responseTypes, req, res, ctx); + await this.ingestBody(req, res, ctx); + await this.authenticator.required(req, res, ctx); + + await this.manager.putById(res, ctx); + } + + + /** + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async handlerGetIdInfo(req, res, ctx) { + const _scope = _fileScope('handlerGetIdInfo'); + this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx }); + + Dingus.setHeadHandler(req, res, ctx); + + this.setResponseType(this.responseTypes, req, res, ctx); + this.authenticator.optional(req, res, ctx); + + await this.manager.getByIdInfo(res, ctx); + } + + + /** + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async handlerGetStatic(req, res, ctx) { + const _scope = _fileScope('handlerGetStatic'); + this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx }); + + Dingus.setHeadHandler(req, res, ctx); + + // We set default response type to handle any errors, but will override for actual static content type. + this.setResponseType(this.responseTypes, req, res, ctx); + this.authenticator.optional(req, res, ctx); + + await this.serveFile(req, res, ctx, this.staticDirectory, ctx.params.file); + } + + + /** + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async handlerGetAdminReport(req, res, ctx) { + const _scope = _fileScope('handlerAdminReport'); + this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx }); + + Dingus.setHeadHandler(req, res, ctx); + + this.setResponseType(this.responseTypes, req, res, ctx); + await this.authenticator.required(req, res, ctx); + + await this.manager.getAdminReport(res, ctx); + } + + +} + +module.exports = Service; diff --git a/src/slug.js b/src/slug.js new file mode 100644 index 0000000..ef489f5 --- /dev/null +++ b/src/slug.js @@ -0,0 +1,45 @@ +'use strict'; + +const common = require('./common'); +const { createHash } = require('crypto'); + +/** + * TODO: base62 would be slightly prettier + * TODO: filter potential horrors + * @param {string} seed + * @param {number} maxLength + */ +async function newSlug(seed, maxLength = 86) { + let slug; + if (maxLength > 86) { + maxLength = 86; + } + if (typeof seed !== 'undefined') { + const hash = createHash('sha512'); + hash.update(seed); + slug = hash.digest('base64'); + } else { + const slugRaw = await common.randomBytesAsync(Math.round(maxLength * 3 / 4)); + slug = slugRaw.toString('base64'); + } + return slug.slice(0, maxLength).replace('/', '-').replace('+', '.'); +} + + +async function* makeSlugGenerator(seed, initialLength = 5, maxLength = 22) { + let length = initialLength; + let slug = await newSlug(seed, maxLength); + while (true) { + yield slug.slice(0, length); + length++; + if (length > maxLength) { + length = initialLength; + slug = await newSlug(undefined, maxLength); // Hash not suitable, try randomness. + } + } +} + +module.exports = { + makeSlugGenerator, + newSlug, +}; \ No newline at end of file diff --git a/src/template/index.js b/src/template/index.js new file mode 100644 index 0000000..cefbe45 --- /dev/null +++ b/src/template/index.js @@ -0,0 +1,7 @@ +'use strict'; + +module.exports = { + infoHTML: require('./info-html'), + reportHTML: require('./report-html'), + rootHTML: require('./root-html'), +}; \ No newline at end of file diff --git a/src/template/info-html.js b/src/template/info-html.js new file mode 100644 index 0000000..38ad53f --- /dev/null +++ b/src/template/info-html.js @@ -0,0 +1,32 @@ +'use strict'; + +const toDateString = (ts) => ts ? new Date(ts * 1000).toISOString() : 'never'; + +module.exports = (ctx, details, pageTitle) => { + const created = toDateString(details.created); + const lastAccess = toDateString(details.lastAccess); + const expires = details.expires ? toDateString(details.expires) : ''; + + return ` + + + + ${pageTitle} - ${details.id} Details + + + +
+

Link Details

+
+
+ +
url: ${details.url}
+
created:
+
accesses: ${details.accesses}
+
last access:
` + + (!details.expires ? '' : ` +
expires:
`) + ` +
+ +`; +}; \ No newline at end of file diff --git a/src/template/report-html.js b/src/template/report-html.js new file mode 100644 index 0000000..7313daf --- /dev/null +++ b/src/template/report-html.js @@ -0,0 +1,44 @@ +'use strict'; + +const toDateString = (ts) => ts ? new Date(ts * 1000).toISOString() : 'never'; + +module.exports = (ctx, links, pageTitle) => { + return ` + + + + ${pageTitle} - Link Report + + + +
+

Link Report

+
+
+ + + + + + + + + + + + ` + + links.map((l) => ` + + + + + + + + `).join('\n') + ` + +
idurlaccessescreatedexpireslast access
${l.id}${l.url}${l.accesses}${toDateString(l.created)}${toDateString(l.expires)}${toDateString(l.lastAccess)}
+ + +`; +}; \ No newline at end of file diff --git a/src/template/root-html.js b/src/template/root-html.js new file mode 100644 index 0000000..65e34c7 --- /dev/null +++ b/src/template/root-html.js @@ -0,0 +1,58 @@ +'use strict'; + +module.exports = (ctx, pageTitle) => { + return ` + + + + + ${pageTitle} + + + + +
+

${pageTitle}

+
` + + (!ctx.createdLink ? '' : ` + `) + + (!ctx.message ? '' : ` +
+ ${ctx.message} +
`) + ` + + +`; +}; \ No newline at end of file diff --git a/static/copy-link.svg b/static/copy-link.svg new file mode 100644 index 0000000..5f961b5 --- /dev/null +++ b/static/copy-link.svg @@ -0,0 +1,7 @@ + + + + + Svg Vector Icons : http://www.onlinewebfonts.com/icon + + \ No newline at end of file diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a473d17141a7ad4715e5afebda19c0bdd4f23f93 GIT binary patch literal 99678 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 + + + + Static Assets + + + +
+

Static Assets

+
+
+ welcome to my static +
+ + diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000..1f53798 --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/static/theme.css b/static/theme.css new file mode 100644 index 0000000..a0de35d --- /dev/null +++ b/static/theme.css @@ -0,0 +1,73 @@ +* { + font-family: helvetica, verdana, sans-serif; +} +body { + margin: 0; +} +header { + background: linear-gradient(0deg, rgba(255,255,255,0) 0%, rgb(160, 82, 45) 100%); +} +h1 { + margin: 0; + padding: 2em 1em .5em 1em; +} +section { + padding: 1em; + border-top: 1px solid sienna; +} +label { + display: inline-block; + font-size: larger; +} +input[type=text] { + width: 94%; + border: 1px solid sienna; + border-radius: .2em; + padding: .4em .2em .2em .2em; + font-size: larger; +} +input[type=submit] { + border-radius: .2em; + font-size: larger; + margin: .5em; + padding: .2em; +} +.meta-link { + font-size: small; + color: #666; +} +.meta-link .auth-token { + font-family: Courier, "Courier New", "Lucida Typewriter", monospace; +} +.copy-button { + display: inline-block; + vertical-align: baseline; + margin: .5em; + padding: .2em; + border-radius: .5em; +} +.copy-button:before { + background: url("copy-link.svg") no-repeat scroll center center / 100% auto rgba(0, 0, 0, 0); + content: ""; + display: inline-block; + padding: 1em; + margin: 0 .5em 0 0; + position: relative; + vertical-align: middle; +} +table { + width: 100%; +} +tbody tr:nth-child(even) td, tbody tr:nth-child(even) th { + background-color: #fff; + border: #4e9a06; +} +tbody tr:nth-child(odd) td, tbody tr:nth-child(odd) th { + background-color: #e4ffc7; + border: #4e9a06; +} +thead tr th { + background-color: #fff; + border-bottom: 1px solid #4e9a06; + padding: .5ex 0 .5ex 0; +} \ No newline at end of file diff --git a/test/src/authenticator.js b/test/src/authenticator.js new file mode 100644 index 0000000..70ce764 --- /dev/null +++ b/test/src/authenticator.js @@ -0,0 +1,497 @@ +/* eslint-env mocha */ +/* eslint-disable capitalized-comments */ +'use strict'; + +const assert = require('assert'); +const sinon = require('sinon'); + +const Authenticator = require('../../src/authenticator'); +const { Errors: { ResponseError } } = require('@squeep/api-dingus'); + + +const noExpectedException = 'did not get expected exception'; + +describe('Authenticator', function () { + let authenticator, logger, db, options; + + beforeEach(function () { + logger = { debug: () => {} }; + // logger = console; + db = { + context: async (fn) => fn({}), + getAuthById: async () => {}, + getLinkById: async () => {}, + }; + authenticator = new Authenticator(logger, db, options); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('b64 armor', function () { + it('reciprocates', function () { + const src = '3qmOlsGY5I3qY4O/tqF7LuvS400BBPh/AMzbyoIlvyXPwMA1tg=='; + + const armored = Authenticator.b64Armor(src); + const unarmored = Authenticator.b64Unarmor(armored); + + assert.strictEqual(unarmored, src); + }); + }); // b64 armor + + describe('generateToken', function () { + it('generates a token', async function () { + const result = await Authenticator.generateToken(); + assert.strictEqual(result.length, 36 * 4 / 3); + }); + }); // generateToken + + describe('signature', function () { + it('generates a signature', async function () { + const secret = 'secret'; + const id = 'identifier'; + const epoch = '1602887798'; + const body = 'some data'; + const expected = 'P5IXYXu5a7aobQvRinPQlN_k1g8GHycRpx3JrK1O7YJlqmhv3WRP5M3ubObPdUWM'; + const result = await Authenticator.signature(secret, id, epoch, body); + assert.strictEqual(result, expected); + }); + }); // generateToken + + describe('requestSignature', function () { + let res; + + beforeEach(function () { + res = { + setHeader: sinon.stub(), + }; + }); + + it('requests custom auth', function () { + try { + authenticator.requestSignature(res); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof ResponseError); + assert.strictEqual(e.statusCode, 401); + assert(res.setHeader.called); + } + }); + }); // requestSignature + + describe('isValidSignature', function () { + let reqSig, ctx; + + beforeEach(function () { + reqSig = undefined; + ctx = { + rawBody: 'all about werewolves', + }; + sinon.stub(authenticator.db, 'getAuthById').resolves({ secret: 'bluemoon' }); + }); + + it('passes valid signature', async function () { + reqSig = 'awoo:1604155860:Zz43TbCdVeqRAqhxTnVpTcC2aMdh3PLXNw_FuQ4KVhjsEFTEnpokwTHb-Qbt3ttz'; + sinon.stub(Date, 'now').returns(1604155861 * 1000); + const result = await authenticator.isValidSignature(reqSig, ctx); + assert.strictEqual(result, true); + assert.strictEqual(ctx.authenticationId, 'awoo'); + }); + + it('fails invalid data', async function () { + reqSig = 'not a signature'; + sinon.stub(Date, 'now').returns(1604155861 * 1000); + const result = await authenticator.isValidSignature(reqSig, ctx); + assert.strictEqual(result, false); + }); + + it('fails missing data', async function () { + sinon.stub(Date, 'now').returns(1604155861 * 1000); + const result = await authenticator.isValidSignature(reqSig, ctx); + assert.strictEqual(result, false); + }); + + it('fails invalid signature', async function () { + reqSig = 'awoo:1604155860:bad signature'; + sinon.stub(Date, 'now').returns(1604155861 * 1000); + const result = await authenticator.isValidSignature(reqSig, ctx); + assert.strictEqual(result, false); + }); + + it('fails invalid timestamp', async function () { + reqSig = 'awoo:0:Zz43TbCdVeqRAqhxTnVpTcC2aMdh3PLXNw_FuQ4KVhjsEFTEnpokwTHb-Qbt3ttz'; + sinon.stub(Date, 'now').returns(1604155861 * 1000); + const result = await authenticator.isValidSignature(reqSig, ctx); + assert.strictEqual(result, false); + }); + + it('fails invalid auth id', async function () { + reqSig = 'awoo:1604155860:Zz43TbCdVeqRAqhxTnVpTcC2aMdh3PLXNw_FuQ4KVhjsEFTEnpokwTHb-Qbt3ttz'; + authenticator.db.getAuthById.restore(); + sinon.stub(authenticator.db, 'getAuthById').resolves({}); + sinon.stub(Date, 'now').returns(1604155861 * 1000); + const result = await authenticator.isValidSignature(reqSig, ctx); + assert.strictEqual(result, false); + }); + }); // isValidSignature + + describe('isValidToken', function () { + let token, linkId; + + beforeEach(function () { + token = undefined; + linkId = 'identifier'; + sinon.stub(authenticator.db, 'getLinkById'); + }); + + it('accepts token', async function () { + token = 'this_is_a_token'; + authenticator.db.getLinkById.resolves({ authToken: token }); + const result = await authenticator.isValidToken(linkId, token); + assert.strictEqual(result, true); + }); + + it('rejects missing token', async function () { + const result = await authenticator.isValidToken(linkId, token); + assert.strictEqual(result, false); + }); + + it('rejects wrong token', async function () { + token = 'this_is_a_token'; + authenticator.db.getLinkById.resolves({ authToken: 'some_other_token' }); + const result = await authenticator.isValidToken(linkId, token); + assert.strictEqual(result, false); + }); + + it('rejects missing id', async function () { + token = 'this_is_a_token'; + linkId = undefined; + authenticator.db.getLinkById.resolves(); + const result = await authenticator.isValidToken(linkId, token); + assert.strictEqual(result, false); + }); + + it('rejects invalid id', async function () { + token = 'this_is_a_token'; + const result = await authenticator.isValidToken(linkId, token); + assert.strictEqual(result, false); + }); + }); // isValidToken + + describe('isValidBasic', function () { + let credentials, ctx; + + beforeEach(function () { + sinon.stub(authenticator, 'requestBasic'); + credentials = 'id:password'; + ctx = {}; + }); + + it('accepts credentials', async function () { + sinon.stub(authenticator.db, 'getAuthById').resolves({ password: 'password' }); + const result = await authenticator.isValidBasic(credentials, ctx); + assert.strictEqual(result, true); + assert.strictEqual(ctx.authenticationId, 'id'); + }); + + it('rejects wrong password', async function () { + sinon.stub(authenticator.db, 'getAuthById').resolves({ password: 'wrong_password' }); + const result = await authenticator.isValidBasic(credentials, ctx); + assert.strictEqual(result, false); + assert(!('authenticationId' in ctx)); + }); + + it('rejects missing id', async function () { + sinon.stub(authenticator.db, 'getAuthById').resolves(); + const result = await authenticator.isValidBasic(credentials, ctx); + assert.strictEqual(result, false); + assert(!('authenticationId' in ctx)); + }); + }); // isValidBasic + + describe('isValidBearer', function () { + let credentials, ctx; + + beforeEach(function () { + credentials = 'id:password'; + ctx = { + params: {}, + }; + }); + + it('accepts token', async function () { + ctx.params.id = 'identifier'; + credentials = 'token'; + sinon.stub(authenticator.db, 'getLinkById').resolves({ authToken: 'token' }); + const result = await authenticator.isValidBearer(credentials, ctx); + assert.strictEqual(result, true); + }); + + it('rejects wrong token', async function () { + ctx.params.id = 'identifier'; + sinon.stub(authenticator.db, 'getLinkById').resolves({ authToken: 'wrong_token' }); + const result = await authenticator.isValidBearer(credentials, ctx); + assert.strictEqual(result, false); + }); + + it('rejects missing id', async function () { + sinon.stub(authenticator.db, 'getLinkById').resolves(); + const result = await authenticator.isValidBearer(credentials, ctx); + assert.strictEqual(result, false); + }); + }); // isValidBearer + + describe('isValidAuthorization', function () { + let header, ctx; + + beforeEach(function () { + header = undefined; + sinon.stub(authenticator, 'isValidBasic'); + sinon.stub(authenticator, 'isValidBearer'); + sinon.stub(authenticator, 'requestBasic'); + }); + + it('dispatches basic', async function () { + header = 'Basic blahblahblah='; + await authenticator.isValidAuthorization(header, ctx); + assert(authenticator.isValidBasic.called); + }); + + it('dispatches bearer', async function () { + header = 'Bearer blahblahblah='; + await authenticator.isValidAuthorization(header, ctx); + assert(authenticator.isValidBearer.called); + }); + + it('handles fallback', async function () { + header = 'Digest blahblahblah='; + const result = await authenticator.isValidAuthorization(header, ctx); + assert.strictEqual(result, false); + }); + }); // isValidAuthorization + + describe('requestBasic', function () { + let res; + + beforeEach(function () { + res = { + setHeader: sinon.stub(), + }; + }); + + it('requests custom auth', function () { + try { + authenticator.requestBasic(res); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof ResponseError); + assert.strictEqual(e.statusCode, 401); + assert(res.setHeader.called); + } + }); + }); // requestBasic + + describe('required', function () { + let req, res, ctx; + + beforeEach(function () { + req = { + getHeader: sinon.stub(), + }; + res = {}; + ctx = {}; + sinon.stub(authenticator, 'isValidToken').resolves(false); + sinon.stub(authenticator, 'isValidSignature').resolves(false); + sinon.stub(authenticator, 'isValidAuthorization').resolves(false); + sinon.stub(authenticator, 'requestBasic'); + sinon.stub(authenticator, 'requestSignature'); + }); + + it('validates signature auth', async function () { + req.getHeader.returns('signature'); + authenticator.isValidSignature.restore(); + sinon.stub(authenticator, 'isValidSignature').resolves(true); + const result = await authenticator.required(req, res, ctx); + assert.strictEqual(result, true); + assert(!authenticator.requestBasic.called); + }); + + it('requests signature auth on signature failure', async function () { + req.getHeader.returns('signature'); + await authenticator.required(req, res, ctx); + assert(authenticator.requestSignature.called); + }); + + it('validates authorization auth', async function () { + req.getHeader.onCall(1).returns('signature'); + authenticator.isValidAuthorization.restore(); + sinon.stub(authenticator, 'isValidAuthorization').resolves(true); + const result = await authenticator.required(req, res, ctx); + assert.strictEqual(result, true); + assert(!authenticator.requestBasic.called); + }); + + it('requests authorization on auth failure', async function () { + req.getHeader.onCall(1).returns('signature'); + await authenticator.required(req, res, ctx); + assert(authenticator.requestBasic.called); + }); + + it('validates a queryparams token', async function () { + ctx.params = { id: 'identifier' }; + ctx.queryParams = { token: 'token' }; + authenticator.isValidToken.restore(); + sinon.stub(authenticator, 'isValidToken').resolves(true); + const result = await authenticator.required(req, res, ctx); + assert.strictEqual(result, true); + assert(!authenticator.requestBasic.called); + }); + + it('validates a body token', async function () { + ctx.params = { id: 'identifier' }; + ctx.parsedBody = { token: 'token' }; + authenticator.isValidToken.restore(); + sinon.stub(authenticator, 'isValidToken').resolves(true); + const result = await authenticator.required(req, res, ctx); + assert.strictEqual(result, true); + assert(!authenticator.requestBasic.called); + }); + + it('fails invalid token', async function () { + ctx.params = { id: 'identifier' }; + ctx.parsedBody = { token: 'token' }; + await authenticator.required(req, res, ctx); + assert(authenticator.requestBasic.called); + }); + + it('fails missing token', async function () { + ctx.params = { id: 'identifier' }; + await authenticator.required(req, res, ctx); + assert(authenticator.requestBasic.called); + }); + + it('requests basic when all else fails', async function () { + await authenticator.required(req, res, ctx); + assert(authenticator.requestBasic.called); + }); + + }); // required + + describe('optional', function () { + let req, res, ctx; + + beforeEach(function () { + req = { + getHeader: sinon.stub(), + }; + res = {}; + ctx = {}; + sinon.stub(authenticator, 'isValidToken').resolves(false); + sinon.stub(authenticator, 'isValidSignature').resolves(false); + sinon.stub(authenticator, 'isValidAuthorization').resolves(false); + }); + + it('rejects with no auth', async function () { + const result = await authenticator.optional(req, res, ctx); + assert.strictEqual(result, false); + }); + + it('validates signature auth', async function () { + req.getHeader.onCall(0).returns('signature'); + authenticator.isValidSignature.restore(); + sinon.stub(authenticator, 'isValidSignature').resolves(true); + const result = await authenticator.optional(req, res, ctx); + assert.strictEqual(result, true); + }); + + it('rejects invalid signature auth', async function () { + req.getHeader.onCall(0).returns('signature'); + const result = await authenticator.optional(req, res, ctx); + assert.strictEqual(result, false); + }); + + it('validates auth', async function () { + req.getHeader.onCall(1).returns('basic auth'); + authenticator.isValidAuthorization.restore(); + sinon.stub(authenticator, 'isValidAuthorization').resolves(true); + const result = await authenticator.optional(req, res, ctx); + assert.strictEqual(result, true); + }); + + it('rejects invalid auth', async function () { + req.getHeader.onCall(1).returns('basic auth'); + const result = await authenticator.optional(req, res, ctx); + assert.strictEqual(result, false); + }); + + it('validates queryparam token', async function () { + ctx.queryParams = { token: 'token' }; + ctx.params = { id: 'identifier' }; + authenticator.isValidToken.restore(); + sinon.stub(authenticator, 'isValidToken').resolves(true); + const result = await authenticator.optional(req, res, ctx); + assert.strictEqual(result, true); + }); + + it('validates body token', async function () { + ctx.parsedBody = { token: 'token' }; + ctx.params = { id: 'identifier' }; + authenticator.isValidToken.restore(); + sinon.stub(authenticator, 'isValidToken').resolves(true); + const result = await authenticator.optional(req, res, ctx); + assert.strictEqual(result, true); + }); + + it('rejects invalid token', async function () { + ctx.queryParams = { token: 'token' }; + ctx.params = { id: 'identifier' }; + const result = await authenticator.optional(req, res, ctx); + assert.strictEqual(result, false); + }); + + it('rejects missing token', async function () { + ctx.params = { id: 'identifier' }; + const result = await authenticator.optional(req, res, ctx); + assert.strictEqual(result, false); + }); + + }); // optional + + describe('signResponse', function () { + let req, res, ctx; + beforeEach(function () { + req = {}; + res = { + setHeader: sinon.stub(), + }; + ctx = {}; + }); + it('does nothing without auth', function () { + authenticator.signResponse(req, res, ctx); + assert(!res.setHeader.called); + }); + it('signs a response', function () { + ctx.authenticationId = 'identified'; + ctx.authenticationSecret = 'secret'; + ctx.responseBody = 'awoo'; + authenticator.signResponse(req, res, ctx); + assert(res.setHeader.called); + }); + it('signs an empty response', function () { + ctx.authenticationId = 'identified'; + ctx.authenticationSecret = 'secret'; + authenticator.signResponse(req, res, ctx); + assert(res.setHeader.called); + }); + it('covers big response non-logging', function () { + ctx.responseBody = 'orgle'.repeat(1000); + authenticator.signResponse(req, res, ctx); + }); + it('covers buffer response', function () { + ctx.responseBody = Buffer.from('orgle'.repeat(1000)); + authenticator.signResponse(req, res, ctx); + }); + }); // signResponse + +}); // Authenticator diff --git a/test/src/common.js b/test/src/common.js new file mode 100644 index 0000000..25fc433 --- /dev/null +++ b/test/src/common.js @@ -0,0 +1,8 @@ +/* eslint-env mocha */ + +'use strict'; + +const sinon = require('sinon'); + +describe('common', function () { +}); \ No newline at end of file diff --git a/test/src/db/base.js b/test/src/db/base.js new file mode 100644 index 0000000..753f8c9 --- /dev/null +++ b/test/src/db/base.js @@ -0,0 +1,70 @@ +/* eslint-env mocha */ +/* eslint-disable capitalized-comments */ + +'use strict'; + +const assert = require('assert'); +const sinon = require('sinon'); +const BaseDatabase = require('../../../src/db/base'); +const DBErrors = require('../../../src/db/errors'); + +const noExpectedException = 'did not get expected exception'; + +describe('BaseDatabase', function () { + let logger, db; + + beforeEach(function () { + logger = { error: () => {} }; + // logger = console; + db = new BaseDatabase(logger); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('_camelfy', function () { + it('empty arg', function () { + const result = BaseDatabase._camelfy(); + assert.strictEqual(result, undefined); + }); + it('no change', function () { + const str = 'camelCase'; + const result = BaseDatabase._camelfy(str); + assert.strictEqual(result, str); + }); + it('does expected', function () { + const str = 'snake_case_thing'; + const result = BaseDatabase._camelfy(str); + assert.strictEqual(result, 'snakeCaseThing'); + }); + }); // _camelfy + + describe('interface', function () { + it('covers methods', async function () { + const methods = [ + 'context', + 'transaction', + 'getAuthById', + 'insertLink', + 'getLinkById', + 'getLinkByUrl', + 'accessLink', + 'expireLink', + 'updateLink', + 'getAllLinks', + ]; + const invokedMethods = methods.map(async (m) => { + try { + // eslint-disable-next-line security/detect-object-injection + await db[m](); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof DBErrors.NotImplemented); + } + }); + await Promise.all(invokedMethods); + }); + }); // interface + +}); \ No newline at end of file diff --git a/test/src/db/index.js b/test/src/db/index.js new file mode 100644 index 0000000..b94ca02 --- /dev/null +++ b/test/src/db/index.js @@ -0,0 +1,58 @@ +/* eslint-env mocha */ +/* eslint-disable capitalized-comments */ + +'use strict'; + +const assert = require('assert'); +// eslint-disable-next-line node/no-unpublished-require +const sinon = require('sinon'); +const DatabaseFactory = require('../../../src/db'); +const DBErrors = require('../../../src/db/errors'); + +const noExpectedException = 'did not get expected exception'; + +describe('DatabaseFactory', function () { + let db, logger, options; + + beforeEach(function () { + logger = { + debug: () => {}, + error: () => {}, + info: () => {}, + }; + options = {}; + }); + + afterEach(function () { + sinon.restore(); + }); + + it('fails on invalid engine', function () { + try { + new DatabaseFactory(logger, options); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof DBErrors.UnsupportedEngine); + } + }); + + it('creates sqlite engine', function () { + this.slow(500); + options.connectionString = 'sqlite://'; + db = new DatabaseFactory(logger, options); + assert.strictEqual(db.constructor.name, 'SQLiteDatabase'); + }); + + it('creates postgres engine', function () { + this.slow(500); + const stubPgp = sinon.stub(); + stubPgp.utils = { + enumSql: sinon.stub().returns({}), + }; + stubPgp.QueryFile = sinon.stub().returns({}); + options.connectionString = 'postgresql://'; + db = new DatabaseFactory(logger, options, stubPgp); + assert.strictEqual(db.constructor.name, 'PostgresDatabase'); + }); + +}); \ No newline at end of file diff --git a/test/src/db/postgres/index.js b/test/src/db/postgres/index.js new file mode 100644 index 0000000..35a8328 --- /dev/null +++ b/test/src/db/postgres/index.js @@ -0,0 +1,348 @@ +/* eslint-disable capitalized-comments */ +/* eslint-env mocha */ + +'use strict'; + +const assert = require('assert'); +const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require + +const noExpectedException = 'did not get expected exception'; + +describe('PostgresDatabase', function () { + const PostgresDatabase = require('../../../../src/db/postgres'); + let pgpStub, db, logger, options, dbCtx; + + beforeEach(function () { + + pgpStub = () => { + const stub = { + result: sinon.stub(), + all: sinon.stub(), + get: sinon.stub(), + run: sinon.stub(), + one: sinon.stub(), + manyOrNone: sinon.stub(), + oneOrNone: sinon.stub(), + query: sinon.stub(), + batch: sinon.stub(), + }; + stub.tx = async (fn) => await fn(stub); + stub.txIf = async (fn) => await fn(stub); + stub.task = async (fn) => await fn(stub); + return stub; + }; + pgpStub.utils = { + enumSql: () => ({}), + }; + pgpStub.QueryFile = class {}; + pgpStub.end = () => {}, + + logger = { + debug: sinon.stub(), + error: sinon.stub(), + }; + options = {}; + db = new PostgresDatabase(logger, options, pgpStub); + db.statement = {}; + dbCtx = undefined; + }); + + describe('context', function () { + it('covers', async function () { + const fn = sinon.stub(); + await db.context(fn); + assert(fn.called); + }); + }); // context + + describe('transaction', function () { + it('covers', async function () { + const fn = sinon.stub(); + await db.transaction(undefined, fn); + assert(fn.called); + }); + }); // transaction + + describe('getAuthById', function () { + let id; + it('stubbed success', async function () { + const expected = { + id: 'id', + secret: 'secret', + password: 'password', + }; + id = 'id'; + db.db.oneOrNone.resolves(expected); + const result = await db.getAuthById(dbCtx, id); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + id = 'id'; + db.db.oneOrNone.rejects(expectedExeption); + try { + await db.getAuthById(dbCtx, id); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // getAuthById + + describe('_epochFix', function () { + it('clamps infinity', function () { + const epoch = Infinity; + const expected = Number.MAX_SAFE_INTEGER; + const result = PostgresDatabase._epochFix(epoch); + assert.strictEqual(result, expected); + }); + it('clamps negative infinity', function () { + const epoch = -Infinity; + const expected = 0; + const result = PostgresDatabase._epochFix(epoch); + assert.strictEqual(result, expected); + }); + it('returns number', function () { + const epoch = 123; + const expected = 123; + const result = PostgresDatabase._epochFix(epoch); + assert.strictEqual(result, expected); + }); + }); // _epochFix + + describe('_linkToNative', function () { + it('handles missing link', function () { + const link = undefined; + const expected = undefined; + const result = PostgresDatabase._linkToNative(link); + assert.deepStrictEqual(result, expected); + }); + it('converts epochs', function () { + const link = { + id: 'id', + url: 'url', + expires: null, + lastAccess: -Infinity, + created: 123, + }; + const expected = { + id: 'id', + url: 'url', + expires: null, + lastAccess: 0, + created: 123, + }; + const result = PostgresDatabase._linkToNative(link); + assert.deepStrictEqual(result, expected); + }); + }); // _linkToNative + + describe('insertLink', function () { + let id, url, authToken; + it('stubbed success', async function () { + const returns = { + rowCount: 0, + rows: [], + duration: 0, + }; + id = 'id'; + db.db.result.resolves(returns); + const expected = { + changes: 0, + duration: 0, + lastInsertRowid: undefined, + }; + const result = await db.insertLink(dbCtx, id, url, authToken); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + id = 'id'; + db.db.result.rejects(expectedExeption); + try { + await db.insertLink(dbCtx, id, url, authToken); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // insertLink + + describe('getLinkById', function () { + let id; + it('stubbed success', async function () { + const expected = { + id: 'id', + url: 'url', + created: 0, + expires: 0, + lastAccess: 0, + }; + id = 'id'; + db.db.oneOrNone.resolves(expected); + const result = await db.getLinkById(dbCtx, id); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + id = 'id'; + db.db.oneOrNone.rejects(expectedExeption); + try { + await db.getLinkById(dbCtx, id); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // getLinkById + + describe('getLinkByUrl', function () { + let url; + it('stubbed success', async function () { + const expected = { + id: 'id', + url: 'url', + created: 0, + expires: 0, + lastAccess: 0, + }; + url = 'url'; + db.db.oneOrNone.resolves(expected); + const result = await db.getLinkByUrl(dbCtx, url); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + url = 'url'; + db.db.oneOrNone.rejects(expectedExeption); + try { + await db.getLinkByUrl(dbCtx, url); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // getLinkByUrl + + describe('accessLink', function () { + let id; + it('stubbed success', async function () { + const expected = { + id: 'id', + url: 'url', + created: 0, + expires: 0, + lastAccess: 0, + }; + id = 'id'; + db.db.oneOrNone.resolves(expected); + const result = await db.accessLink(dbCtx, id); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + id = 'id'; + db.db.oneOrNone.rejects(expectedExeption); + try { + await db.accessLink(dbCtx, id); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // accessLink + + describe('expireLink', function () { + let id, expires; + it('stubbed success', async function () { + const returns = { + rowCount: 1, + rows: [ { id: 1 } ], + duration: 10, + }; + const expected = { + changes: 1, + duration: 10, + lastInsertRowid: 1, + }; + id = 'id'; + expires = null; + db.db.result.resolves(returns); + const result = await db.expireLink(dbCtx, id, expires); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + id = 'id'; + expires = null; + db.db.result.rejects(expectedExeption); + try { + await db.expireLink(dbCtx, id, expires); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // expireLink + + describe('updateLink', function () { + let id, expires; + it('stubbed success', async function () { + const returns = { + rowCount: 1, + rows: [ { id: 1 } ], + duration: 10, + }; + const expected = { + changes: 1, + duration: 10, + lastInsertRowid: 1, + }; + id = 'id'; + expires = null; + db.db.result.resolves(returns); + const result = await db.updateLink(dbCtx, id, expires); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + id = 'id'; + expires = null; + db.db.result.rejects(expectedExeption); + try { + await db.updateLink(dbCtx, id, expires); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // updateLink + + describe('getAllLinks', function () { + it('stubbed success', async function () { + const expected = [ + { + id: 'id', + url: 'url', + created: 0, + expires: 0, + lastAccess: 0, + }, + ]; + db.db.manyOrNone.resolves(expected); + const result = await db.getAllLinks(dbCtx); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + db.db.manyOrNone.rejects(expectedExeption); + try { + await db.getAllLinks(dbCtx); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // getAllLinks + +}); \ No newline at end of file diff --git a/test/src/db/sqlite/index.js b/test/src/db/sqlite/index.js new file mode 100644 index 0000000..0a1b803 --- /dev/null +++ b/test/src/db/sqlite/index.js @@ -0,0 +1,474 @@ +/* eslint-disable capitalized-comments */ +/* eslint-env mocha */ + +'use strict'; + +const assert = require('assert'); +const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require +const DBErrors = require('../../../../src/db/errors'); + +const noExpectedException = 'did not get expected exception'; + +describe('SQLiteDatabase', function () { + const SQLiteDatabase = require('../../../../src/db/sqlite'); + let db, logger, options, dbCtx; + + beforeEach(function () { + logger = { + debug: sinon.stub(), + info: sinon.stub(), + error: sinon.stub(), + }; + options = {}; + db = new SQLiteDatabase(logger, options); + dbCtx = undefined; + }); + + describe('context', function () { + it('covers', async function () { + const fn = sinon.stub(); + await db.context(fn); + assert(fn.called); + }); + }); // context + + describe('transaction', function () { + it('covers', async function () { + const fn = sinon.stub(); + await db.transaction(dbCtx, fn); + assert(fn.called); + }); + }); // transaction + + describe('getAuthById', function () { + let id; + beforeEach(function () { + sinon.stub(db.statement.getAuthById, 'get'); + }); + + it('stubbed success', async function () { + const expected = { + id: 'id', + secret: 'secret', + password: 'password', + }; + id = 'id'; + db.statement.getAuthById.get.returns(expected); + const result = await db.getAuthById(dbCtx, id); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + id = 'id'; + db.statement.getAuthById.get.throws(expectedExeption); + try { + await db.getAuthById(dbCtx, id); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // getAuthById + + describe('insertLink', function () { + let id, url, authToken; + beforeEach(function () { + sinon.stub(db.statement.insertLink, 'run'); + sinon.stub(db.statement.updateLink, 'run'); + }); + + it('stubbed insert success', async function () { + const info = { + changes: BigInt(1), + lastInsertRowid: BigInt(123), + }; + id = 'id'; + db.statement.insertLink.run.returns(info); + const expected = { + changes: 1, + lastInsertRowid: 123, + }; + const result = await db.insertLink(dbCtx, id, url, authToken); + assert.deepStrictEqual(result, expected); + }); + it('stubbed update success', async function () { + const info = { + changes: BigInt(1), + lastInsertRowid: BigInt(123), + }; + id = 'id'; + db.statement.insertLink.run.throws({ code: 'SQLITE_CONSTRAINT_UNIQUE' }); + db.statement.updateLink.run.returns(info); + const expected = { + changes: 1, + lastInsertRowid: 123, + }; + const result = await db.insertLink(dbCtx, id, url, authToken); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + id = 'id'; + db.statement.insertLink.run.throws(expectedExeption); + try { + await db.insertLink(dbCtx, id, url, authToken); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + it('stubbed unexpected failure', async function () { + const expectedException = DBErrors.UnexpectedResult; + const returns = { + changes: 0, + lastInsertRowid: undefined, + }; + id = 'id'; + db.statement.insertLink.run.returns(returns); + try { + await db.insertLink(dbCtx, id, url, authToken); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof expectedException); + } + }); + }); // insertLink + + describe('getLinkById', function () { + let id; + + beforeEach(function () { + sinon.stub(db.statement.getLinkById, 'get'); + }); + + it('stubbed success', async function () { + const returns = { + id: 'id', + url: 'url', + created: 0, + expires: 0, + 'auth_token': 'abc', + 'last_access': 0, + accesses: 0, + }; + const expected = { + id: 'id', + url: 'url', + created: 0, + expires: 0, + authToken: 'abc', + lastAccess: 0, + accesses: 0, + }; + id = 'id'; + db.statement.getLinkById.get.returns(returns); + const result = await db.getLinkById(dbCtx, id); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + id = 'id'; + db.statement.getLinkById.get.throws(expectedExeption); + try { + await db.getLinkById(dbCtx, id); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // getLinkById + + describe('getLinkByUrl', function () { + let url; + + beforeEach(function () { + sinon.stub(db.statement.getLinkByUrl, 'get'); + }); + + it('stubbed success', async function () { + const returns = { + id: 'id', + url: 'url', + created: 0, + expires: 123, + 'auth_token': 'abc', + 'last_access': 0, + accesses: 0, + }; + const expected = { + id: 'id', + url: 'url', + created: 0, + expires: 123, + authToken: 'abc', + lastAccess: 0, + accesses: 0, + }; + url = 'url'; + db.statement.getLinkByUrl.get.returns(returns); + const result = await db.getLinkByUrl(dbCtx, url); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + url = 'url'; + db.statement.getLinkByUrl.get.throws(expectedExeption); + try { + await db.getLinkByUrl(dbCtx, url); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // getLinkByUrl + + describe('accessLink', function () { + let id; + + beforeEach(function () { + sinon.stub(db.statement.getLinkById, 'get'); + sinon.stub(db.statement.incrementLinkAccess, 'run'); + }); + + it('stubbed exists success', async function () { + const returns = { + id: 'id', + url: 'url', + created: 0, + expires: 0, + 'auth_token': 'abc', + 'last_access': 0, + accesses: 0, + }; + const expected = { + id: 'id', + url: 'url', + created: 0, + expires: 0, + authToken: 'abc', + lastAccess: 0, + accesses: 0, + }; + id = 'id'; + db.statement.getLinkById.get.returns(returns); + db.statement.incrementLinkAccess.run.returns({ changes: 1 }); + const result = await db.accessLink(dbCtx, id); + assert.deepStrictEqual(result, expected); + }); + it('stubbed missing success', async function () { + const returns = undefined; + const expected = undefined; + id = 'id'; + db.statement.getLinkById.get.returns(returns); + db.statement.incrementLinkAccess.run.returns({ changes: 0 }); + const result = await db.accessLink(dbCtx, id); + assert.deepStrictEqual(result, expected); + }); + it('stubbed increment failure', async function () { + const expectedExeption = DBErrors.UnexpectedResult; + const returns = { + id: 'id', + url: 'url', + created: 0, + expires: 0, + 'auth_token': 'abc', + 'last_access': 0, + accesses: 0, + }; + id = 'id'; + db.statement.getLinkById.get.returns(returns); + db.statement.incrementLinkAccess.run.returns({ changes: 0 }); + try { + await db.accessLink(dbCtx, id); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof expectedExeption, noExpectedException); + } + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + id = 'id'; + db.statement.getLinkById.get.throws(expectedExeption); + try { + await db.accessLink(dbCtx, id); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // accessLink + + describe('expireLink', function () { + let id, expires; + + beforeEach(function () { + sinon.stub(db.statement.expireLink, 'run'); + }); + + it('stubbed success', async function () { + const returns = { + changes: 1, + lastInsertRowid: 123, + }; + const expected = { + changes: 1, + lastInsertRowid: 123, + }; + id = 'id'; + expires = null; + db.statement.expireLink.run.returns(returns); + const result = await db.expireLink(dbCtx, id, expires); + assert.deepStrictEqual(result, expected); + }); + it('stubbed change failure', async function () { + const expectedExeption = DBErrors.UnexpectedResult; + const returns = { + changes: 0, + lastInsertRowid: undefined, + }; + id = 'id'; + db.statement.expireLink.run.returns(returns); + try { + await db.expireLink(dbCtx, id); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof expectedExeption, noExpectedException); + } + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + id = 'id'; + expires = null; + db.statement.expireLink.run.throws(expectedExeption); + try { + await db.expireLink(dbCtx, id, expires); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // expireLink + + describe('updateLink', function () { + let id, expires; + + beforeEach(function () { + sinon.stub(db.statement.updateLink, 'run'); + }); + + it('stubbed success', async function () { + const returns = { + changes: 1, + lastInsertRowid: 1, + }; + const expected = { + changes: 1, + lastInsertRowid: 1, + }; + id = 'id'; + expires = null; + db.statement.updateLink.run.returns(returns); + const result = await db.updateLink(dbCtx, id, expires); + assert.deepStrictEqual(result, expected); + }); + it('stubbed change failure', async function () { + const expectedExeption = DBErrors.UnexpectedResult; + const returns = { + changes: 0, + lastInsertRowid: undefined, + }; + id = 'id'; + db.statement.updateLink.run.returns(returns); + try { + await db.updateLink(dbCtx, id); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof expectedExeption, noExpectedException); + } + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + id = 'id'; + expires = null; + db.statement.updateLink.run.throws(expectedExeption); + try { + await db.updateLink(dbCtx, id, expires); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // updateLink + + describe('getAllLinks', function () { + beforeEach(function () { + sinon.stub(db.statement.linkGetAll, 'all'); + }); + + it('stubbed success', async function () { + const returns = [ + { + id: 'id', + url: 'url', + created: 0, + expires: 0, + 'auth_token': 'abc', + 'last_access': 0, + accesses: 0, + }, + ]; + const expected = [ + { + id: 'id', + url: 'url', + created: 0, + expires: 0, + authToken: 'abc', + lastAccess: 0, + accesses: 0, + }, + ]; + db.statement.linkGetAll.all.returns(returns); + const result = await db.getAllLinks(dbCtx); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + db.statement.linkGetAll.all.throws(expectedExeption); + try { + await db.getAllLinks(dbCtx); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // getAllLinks + + describe('_optimize', function () { + let cslo, oac; + beforeEach(function () { + cslo = db.changesSinceLastOptimize; + oac = db.optimizeAfterChanges; + sinon.stub(db.db, 'prepare').returns({ + all: sinon.stub(), + }); + sinon.stub(db.db, 'pragma'); + }); + afterEach(function () { + db.changesSinceLastOptimize = cslo; + db.optimizeAfterChanges = oac; + }); + it('covers', function () { + db._optimize(); + assert(db.db.pragma.called); + }); + it('_maybeOptimize', function () { + db.changesSinceLastOptimize = BigInt(1000); + db.optimizeAfterChanges = BigInt(10); + sinon.stub(db, '_optimize'); + db._maybeOptimize(); + assert(db._optimize.called); + }); + }); + +}); \ No newline at end of file diff --git a/test/src/manager.js b/test/src/manager.js new file mode 100644 index 0000000..ad1c11f --- /dev/null +++ b/test/src/manager.js @@ -0,0 +1,427 @@ +/* eslint-env mocha */ +/* eslint-disable capitalized-comments */ + +'use strict'; + +const assert = require('assert'); +const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require + +const Manager = require('../../src/manager'); +const common = require('../../src/common'); +const Enum = require('../../src/enum'); +const { ServeStaticFile, SlugGeneratorExhausted } = require('../../src/errors'); + +const noExpectedException = 'did not get expected exception'; + +describe('Manager', function () { + let manager, options; + let res, ctx; + + beforeEach(function () { + const stubLogger = { + debug: sinon.stub(), + error: sinon.stub(), + info: sinon.stub(), + }; + const stubDb = { + context: async (fn) => await fn({}), + transaction: async (_dbCtx, fn) => await fn({}), + getLinkById: sinon.stub(), + accessLink: sinon.stub(), + getLinkByUrl: sinon.stub(), + expireLink: sinon.stub(), + insertLink: sinon.stub(), + getAllLinks: sinon.stub(), + }; + options = {}; + res = { + end: sinon.stub(), + setHeader: sinon.stub(), + }; + ctx = { + params: {}, + }; + manager = new Manager(stubLogger, stubDb, options); + + }); + afterEach(function () { + sinon.restore(); + }); + + it('instantiates', function () { + assert(manager); + }); + + it('defaults options', function () { + manager = new Manager({}, {}); + }); + + describe('rootContent', function () { + it('generates content for empty context', function () { + const result = manager.rootContent(ctx); + assert(result.length); + }); + it('generates json content', function () { + ctx.responseType = Enum.ContentType.ApplicationJson; + const result = manager.rootContent(ctx); + assert(result.length); + JSON.parse(result); + }); + it('generates html content', function () { + ctx.responseType = Enum.ContentType.TextHTML; + const result = manager.rootContent(ctx); + assert(result.length); + }); + it('includes context fields', function () { + ctx.createdLink = 'http://example.com/foo'; + ctx.authToken = 'token'; + ctx.message = 'message'; + ctx.sourceLink = 'http://source.example.com/'; + const result = manager.rootContent(ctx); + assert(result.length); + }); + }); // rootContent + + describe('getRoot', function () { + let req; + beforeEach(function () { + sinon.stub(common, 'isClientCached'); + req = {}; + }); + it('normal response', async function () { + common.isClientCached.returns(false); + await manager.getRoot(req, res, ctx); + assert(res.end.called); + }); + it('repeat response', async function () { + manager.startTime = (new Date()).toGMTString(); + common.isClientCached.returns(true); + await manager.getRoot(req, res, ctx); + assert(res.end.called); + }); + it('cached response', async function () { + common.isClientCached.returns(true); + await manager.getRoot(req, res, ctx); + assert(res.end.called); + assert.strictEqual(res.statusCode, 304); + }); + }); // getRoot + + describe('_getNewIdentifier', function () { + const url = 'http://example.com/bar'; + let dbCtx, nextStub; + beforeEach(function () { + dbCtx = {}; + nextStub = sinon.stub(); + manager.db.getLinkById.onCall(0).resolves({ id:'existing' }).onCall(1).resolves(); + sinon.stub(manager, 'makeSlugGenerator').callsFake(() => { + return { + next: nextStub, + }; + }); + }); + it('gets identifiers', async function () { + nextStub.resolves({ value: 'slug', done: false }); + const result = await manager._getNewIdentifier(dbCtx, url); + assert(result); + assert.strictEqual(nextStub.callCount, 2); + }); + it('handles empty slug', async function () { + nextStub.resolves({ value: '', done: false }); + try { + await manager._getNewIdentifier(dbCtx, url); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof SlugGeneratorExhausted, noExpectedException); + } + }); + it('handles end of generator', async function () { + nextStub.resolves({ value: 'blah', done: true }); + try { + await manager._getNewIdentifier(dbCtx, url); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof SlugGeneratorExhausted, noExpectedException); + } + }); + }); // _getNewIdentifier + + describe('_validateContextURL', function () { + it('allows admin to create local static link', function () { + ctx.sourceLink = `${manager.staticDirectory}file.txt`; + ctx.authenticationId = 'awoo'; + manager._validateContextURL(ctx); + }); + it('accepts valid url', function () { + ctx.sourceLink = 'http://example.com/file.txt'; + manager._validateContextURL(ctx); + }); + it('rejects missing url', function () { + try { + manager._validateContextURL(ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.strictEqual(e.message, Enum.ErrorResponse.InvalidURLParameter.errorMessage, noExpectedException); + } + }); + it('rejects invalid url', function () { + ctx.sourceLink = 'not a url'; + try { + manager._validateContextURL(ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.strictEqual(e.message, Enum.ErrorResponse.InvalidURLParameter.errorMessage, noExpectedException); + } + }); + }); // _validateContextURL + + describe('getById', function () { + let link; + beforeEach(function () { + link = undefined; + }); + it('handles missing link', async function () { + try { + await manager.getById(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.strictEqual(e.message, 'Not Found'); + } + }); + it('handles expired link', async function () { + link = { + expires: 1, + }; + manager.db.accessLink.resolves(link) + try { + await manager.getById(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.strictEqual(e.message, 'Gone'); + } + }); + it('handles local static link', async function () { + const file = 'foop.txt'; + link = { + url: `${manager.staticDirectory}${file}`, + }; + manager.db.accessLink.resolves(link); + try { + await manager.getById(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof ServeStaticFile); + assert.strictEqual(e.file, file); + } + }); + it('redirects a link', async function () { + link = { + url: 'http://example.com/awoo', + }; + manager.db.accessLink.resolves(link); + await manager.getById(res, ctx); + assert.strictEqual(res.statusCode, 307); + }); + }); // getById + + describe('postRoot', function () { + beforeEach(function () { + ctx.parsedBody = {}; + }); + it('requires url parameter', async function () { + try { + await manager.postRoot(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.strictEqual(e.message, 'Bad Request'); + } + }); + it('creates a link', async function () { + ctx.parsedBody.url = 'http://example.com/insert'; + await manager.postRoot(res, ctx); + assert(manager.db.insertLink.called); + assert(res.end.called); + }); + it('returns existing link', async function () { + ctx.parsedBody.url = 'http://example.com/existing'; + const existingLink = { + id: 'blah', + sourceLink: ctx.parsedBody.url, + }; + manager.db.getLinkByUrl.resolves(existingLink); + await manager.postRoot(res, ctx); + assert(!manager.db.insertLink.called); + assert(!manager.db.expireLink.called); + assert(res.end.called); + }); + it('restores expired link', async function () { + ctx.parsedBody.url = 'http://example.com/expired'; + const existingLink = { + id: 'blah', + sourceLink: ctx.parsedBody.url, + expires: 1, + }; + manager.db.getLinkByUrl.resolves(existingLink); + await manager.postRoot(res, ctx); + assert(!manager.db.insertLink.called); + assert(manager.db.expireLink.called); + assert(res.end.called); + }); + }); // postRoot + + describe('putById', function () { + let url; + beforeEach(function () { + url = 'http://example.com/put'; + ctx.parsedBody = {}; + }); + it('requires url parameter', async function () { + try { + await manager.putById(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.strictEqual(e.message, 'Bad Request'); + } + }); + it('updates existing', async function () { + ctx.parsedBody.url = url; + const existingLink = { + id: 'blah', + }; + manager.db.getLinkById.resolves(existingLink); + await manager.putById(res, ctx); + assert(manager.db.insertLink.called); + assert(res.end.called); + }); + it('does not create without admin', async function () { + ctx.parsedBody.url = url; + try { + await manager.putById(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.strictEqual(e.message, 'Forbidden'); + } + }); + it('allows admin creation', async function () { + ctx.parsedBody.url = url; + ctx.authenticationId = 'blah'; + await manager.putById(res, ctx); + assert(manager.db.insertLink.called); + assert.strictEqual(res.statusCode, 201); + assert(res.end.called); + }); + }); // putById + + describe('deleteById', function () { + it('handles missing id', async function () { + try { + await manager.deleteById(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.strictEqual(e.message, 'Not Found'); + } + }); + it('expires link', async function () { + const existingLink = { + id: 'awoo', + }; + manager.db.getLinkById.resolves(existingLink); + await manager.deleteById(res, ctx); + assert(manager.db.expireLink.called); + assert.strictEqual(res.statusCode, 204); + }); + it('ignores expired link', async function () { + const existingLink = { + id: 'awoo', + expires: 123, + }; + manager.db.getLinkById.resolves(existingLink); + await manager.deleteById(res, ctx); + assert(!manager.db.expireLink.called); + assert.strictEqual(res.statusCode, 304); + }); + }); // deleteById + + describe('infoContent', function () { + let details; + beforeEach(function () { + details = { + created: 1604155860, + lastAccess: 1604155861, + accesses: 6, + }; + }); + it('generates info', function () { + details.expires = 1604155862; + const result = manager.infoContent(ctx, details); + assert(result); + }); + it('generates json info', function () { + ctx.responseType = Enum.ContentType.ApplicationJson; + const result = manager.infoContent(ctx, details); + JSON.parse(result); + }); + it('generates html info', function () { + ctx.responseType = Enum.ContentType.TextHTML; + const result = manager.infoContent(ctx, details); + assert(result); + }); + }); // infoContent + + describe('getByIdInfo', function () { + it('handles missing link', async function () { + try { + await manager.getByIdInfo(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.strictEqual(e.message, 'Not Found'); + } + }); + it('gets link', async function () { + const existingLink = { + id: 'blah', + }; + manager.db.getLinkById.resolves(existingLink); + await manager.getByIdInfo(res, ctx); + assert(res.end.called); + }); + }); // getByIdInfo + + describe('reportContent', function () { + let links; + it('generates report', function () { + links = [ + { + id: 'awoo', + created: 1604155860, + lastAccess: 1604155861, + url: 'http://example.com/awoo', + }, + ]; + const result = manager.reportContent(ctx, links); + assert(result); + }); + it('generates json report', function () { + links = []; + ctx.responseType = Enum.ContentType.ApplicationJson; + const result = manager.reportContent(ctx, links); + JSON.parse(result); + }); + it('generates html report', function () { + links = []; + ctx.responseType = Enum.ContentType.TextHTML; + const result = manager.reportContent(ctx, links); + assert(result); + }); + }); // reportContent + + describe('getAdminReport', function () { + it('does links', async function () { + const links = []; + manager.db.getAllLinks.resolves(links); + await manager.getAdminReport(res, ctx); + assert(res.end.called); + }); + }); // getAdminReport + +}); // Manager \ No newline at end of file diff --git a/test/src/service.js b/test/src/service.js new file mode 100644 index 0000000..4e1d017 --- /dev/null +++ b/test/src/service.js @@ -0,0 +1,185 @@ +/* eslint-env mocha */ +/* eslint-disable capitalized-comments */ + +'use strict'; + +const assert = require('assert'); +// eslint-disable-next-line node/no-unpublished-require +const sinon = require('sinon'); + +const Service = require('../../src/service'); +const { ServeStaticFile } = require('../../src/errors'); + + +describe('service', function () { + let service, logger, options; + let req, res, ctx; + const dbStub = {}; + + beforeEach(function () { + options = {}; + logger = {}; + // logger = console; + service = new Service(logger, dbStub, options); + sinon.stub(service.manager); + sinon.stub(service.authenticator); + sinon.stub(service, 'setResponseType'); + sinon.stub(service, 'serveFile'); + sinon.stub(service, 'ingestBody').resolves(); + req = { + getHeader: sinon.stub(), + }; + res = { + setHeader: sinon.stub(), + write: sinon.stub(), + end: sinon.stub(), + }; + ctx = {}; + }); + + afterEach(function () { + sinon.restore(); + }); + + it('instantiates', function () { + assert(service); + }); + + it('covers no options', function () { + const r = new Service(logger, dbStub); + assert(r); + }); + + describe('renderError', function () { + it('covers default', function () { + const contentType = 'text/plain'; + const err = { + statusCode: 418, + errorMessage: 'I am a teapot', + details: 'what are you', + }; + const result = service.renderError(contentType, err); + assert.strictEqual(result, 'I am a teapot\r\nwhat are you'); + }); + }); // renderError + + describe('parseBody', function () { + it('covers default', function () { + const contentType = 'application/json'; + const someData = { foo: 'bar', quux: 3 }; + ctx.rawBody = JSON.stringify(someData); + service.parseBody(contentType, ctx); + assert.deepStrictEqual(ctx.parsedBody, someData); + }); + }); // parseBody + + describe('preHandler', function () { + it('covers default', async function () { + await service.preHandler(req, res, ctx); + }); + }); // preHandler + + describe('_endHandler', function () { + beforeEach(function () { + req = {}; + res = {}; + ctx = {}; + }); + it('covers', function() { + service._endHandler(req, res, ctx); + assert(service.authenticator.signResponse.called); + }); + }); // _endHandler + + describe('handlerPostRoot', function () { + it('covers public mode', async function () { + sinon.stub(service, '_postRootAuth'); + + await service.handlerPostRoot(req, res, ctx); + assert(service.manager.postRoot.called); + }); + it('covers private mode', async function () { + service = new Service(logger, dbStub, { createRequiresAuth: true }); + sinon.stub(service.manager); + sinon.stub(service.authenticator); + sinon.stub(service, '_postRootAuth'); + sinon.stub(service, 'setResponseType'); + sinon.stub(service, 'serveFile'); + sinon.stub(service, 'ingestBody').resolves(); + + await service.handlerPostRoot(req, res, ctx); + assert(service.manager.postRoot.called); + }); + }); // handlerPostRoot + + describe('handlerGetRoot', function () { + it('covers', async function () { + await service.handlerGetRoot(req, res, ctx); + assert(service.manager.getRoot.called); + }); + }); // handlerGetRoot + + describe('handlerGetId', function () { + it('covers', async function () { + await service.handlerGetId(req, res, ctx); + assert(service.manager.getById.called); + }); + it('covers static', async function () { + service.manager.getById.rejects(new ServeStaticFile('this_file.txt')); + await service.handlerGetId(req, res, ctx); + assert(service.manager.getById.called); + assert(service.serveFile.called); + }); + it('cover errors', async function () { + const expectedException = 'blah'; + service.manager.getById.rejects(expectedException); + try { + await service.handlerGetId(req, res, ctx); + assert.fail('did not get expected exception'); + } catch (e) { + assert.strictEqual(e.name, expectedException, 'did not get expected exception'); + } + assert(service.manager.getById.called); + }); + }); // handlerGetId + + describe('handlerDeleteId', function () { + it('covers', async function () { + await service.handlerDeleteId(req, res, ctx); + assert(service.manager.deleteById.called); + }); + }); // handlerDeleteId + + describe('handlerPutId', function () { + it('covers', async function () { + await service.handlerPutId(req, res, ctx); + assert(service.manager.putById.called); + }); + }); // handlerPutId + + describe('handlerGetIdInfo', function () { + it('covers', async function () { + await service.handlerGetIdInfo(req, res, ctx); + assert(service.manager.getByIdInfo.called); + }); + }); // handlerGetIdInfo + + describe('handlerGetStatic', function () { + it('covers', async function () { + ctx.params = { + file: '', + }; + await service.handlerGetStatic(req, res, ctx); + assert(service.serveFile.called); + }); + }); // handlerGetStatic + + describe('handlerGetAdminReport', function () { + it('covers', async function () { + await service.handlerGetAdminReport(req, res, ctx); + assert(service.authenticator.required.called); + assert(service.manager.getAdminReport.called); + }); + }); + +}); \ No newline at end of file diff --git a/test/src/slug.js b/test/src/slug.js new file mode 100644 index 0000000..4b768c7 --- /dev/null +++ b/test/src/slug.js @@ -0,0 +1,72 @@ +/* eslint-env mocha */ +/* eslint-disable capitalized-comments */ + +'use strict'; + +const assert = require('assert'); +const sinon = require('sinon'); + +describe('slug', function () { + const Slug = require('../../src/slug'); + + const maxSlugLength = 86; + + afterEach(function () { + sinon.restore(); + }); + + describe('newSlug', function () { + it('generates a slug from seed', async function () { + const seed = ''; + const seedSHA512b64trunc = 'z4PhNX7vuL3xVChQ1m2AB9Yg5AULVxXcg-SpIdNs6c5H0NE8XYXysP.DGNKHfuwvY7kxvUdBeoGlODJ6+SfaPg'; + const result = await Slug.newSlug(seed); + assert.strictEqual(result, seedSHA512b64trunc); + }); + it('generates a random slug', async function () { + const result = await Slug.newSlug(); + assert.strictEqual(result.length, maxSlugLength); + }); + it('only makes a slug so big', async function () { + const slugLen = 222; + const result = await Slug.newSlug(undefined, slugLen); + assert.strictEqual(result.length, maxSlugLength); + }); + }); // newSlug + + describe('makeSlugGenerator', function () { + it('makes a slug generator which generates a slug', async function () { + const seed = ''; + const sg = Slug.makeSlugGenerator(seed); + const { done, value } = await sg.next(); + assert(!done); + assert.strictEqual(value.length, 5); + }); + it('makes a slug generator which generates many slugs', async function () { + const seed = ''; + const sg = Slug.makeSlugGenerator(seed, 5, 8); + + const checkNext = async (expected) => { + const { done, value } = await sg.next(); + assert(!done); + assert.strictEqual(value, expected); + }; + const checkNextLen = async (expected) => { + const { done, value } = await sg.next(); + assert(!done); + assert.strictEqual(value.length, expected); + }; + + await checkNext('z4PhN'); + await checkNext('z4PhNX'); + await checkNext('z4PhNX7'); + await checkNext('z4PhNX7v'); + await checkNextLen(5); + await checkNextLen(6); + await checkNextLen(7); + await checkNextLen(8); + await checkNextLen(5); + await checkNextLen(6); + }); + }); // makeSlugGenerator + +}); // slug \ No newline at end of file -- 2.43.2