--- /dev/null
+{
+ "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
--- /dev/null
+node_modules
+.nyc_output
+coverage
+.vscode
--- /dev/null
+{
+ "reporter": [
+ "lcov",
+ "text"
+ ]
+}
--- /dev/null
+# 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
--- /dev/null
+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', '');
--- /dev/null
+{
+ "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 <jwind-urlittler@squeep.com>",
+ "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"
+ }
+}
--- /dev/null
+/* 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}`);
+});
--- /dev/null
+'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;
--- /dev/null
+'use strict';
+
+const { common } = require('@squeep/api-dingus');
+
+const { promisify } = require('util');
+const { randomBytes } = require('crypto');
+const randomBytesAsync = promisify(randomBytes);
+
+module.exports = {
+ ...common,
+ randomBytesAsync,
+};
--- /dev/null
+'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;
--- /dev/null
+'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
--- /dev/null
+'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;
--- /dev/null
+'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;
--- /dev/null
+SELECT * FROM auth WHERE id = $(id)
\ No newline at end of file
--- /dev/null
+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
--- /dev/null
+UPDATE link SET expires = to_timestamp($(expires)) WHERE id = $(id)
\ No newline at end of file
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+UPDATE link SET url = $(url) WHERE id = $(id)
\ No newline at end of file
--- /dev/null
+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
--- /dev/null
+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;
--- /dev/null
+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;
--- /dev/null
+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)
+);
--- /dev/null
+'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;
--- /dev/null
+'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
--- /dev/null
+'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
--- /dev/null
+'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;
--- /dev/null
+'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
--- /dev/null
+'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;
--- /dev/null
+'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
--- /dev/null
+'use strict';
+
+module.exports = {
+ infoHTML: require('./info-html'),
+ reportHTML: require('./report-html'),
+ rootHTML: require('./root-html'),
+};
\ No newline at end of file
--- /dev/null
+'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 `<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <title>${pageTitle} - ${details.id} Details</title>
+ <link rel="stylesheet" href="../../static/theme.css">
+ </head>
+ <body>
+ <header>
+ <h1>Link Details</h1>
+ </header>
+ <main>
+ <div class="identifier">id: <span><a href="/${details.id}">${details.id}</a></span></div>
+ <div class="url">url: <span>${details.url}</span></div>
+ <div class="created">created: <time datetime="${created}">${created}</time></div>
+ <div class="accesses">accesses: <span>${details.accesses}</span></div>
+ <div class="lastaccess">last access: <time datetime="${lastAccess}">${lastAccess}</time></div>` +
+ (!details.expires ? '' : `
+ <div class="expires">expires: <time datetime="${expires}">${expires}</time></div>`) + `
+ </main>
+ </body>
+</html>`;
+};
\ No newline at end of file
--- /dev/null
+'use strict';
+
+const toDateString = (ts) => ts ? new Date(ts * 1000).toISOString() : 'never';
+
+module.exports = (ctx, links, pageTitle) => {
+ return `<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <title>${pageTitle} - Link Report</title>
+ <link rel="stylesheet" href="../../static/theme.css">
+ </head>
+ <body>
+ <header>
+ <h1>Link Report</h1>
+ </header>
+ <main>
+ <table>
+ <thead>
+ <tr>
+ <th scope="col">id</th>
+ <th scope="col">url</th>
+ <th scope="col">accesses</th>
+ <th scope="col">created</th>
+ <th scope="col">expires</th>
+ <th scope="col">last access</th>
+ </tr>
+ </thead>
+ <tbody>` +
+ links.map((l) => `
+ <tr>
+ <th scope="row"><a href="/${l.id}/info">${l.id}</a></th>
+ <td>${l.url}</td>
+ <td>${l.accesses}</td>
+ <td>${toDateString(l.created)}</td>
+ <td>${toDateString(l.expires)}</td>
+ <td>${toDateString(l.lastAccess)}</td>
+ </tr>`).join('\n') + `
+ </tbody>
+ <table>
+ </main>
+ </body>
+</html>`;
+};
\ No newline at end of file
--- /dev/null
+'use strict';
+
+module.exports = (ctx, pageTitle) => {
+ return `<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="robots" content="noindex">
+ <title>${pageTitle}</title>
+ <link rel="stylesheet" href="static/theme.css">
+ </head>
+ <body>
+ <script type="text/javascript">
+ document.addEventListener('DOMContentLoaded', function () {
+ document.querySelector('.copy-button').addEventListener('click', function(event) {
+ const linkHref = document.querySelector('.link').href;
+ navigator.clipboard.writeText(linkHref);
+ const copyButton = this;
+ copyButton.style.backgroundColor = 'lightgreen';
+ setTimeout(function () {
+ copyButton.style.backgroundColor = 'initial';
+ }, 3000);
+ });
+ });
+ </script>
+ <header>
+ <h1>${pageTitle}</h1>
+ </header>` +
+ (!ctx.createdLink ? '' : `
+ <section class="created-link">
+ <a class="src-link" href="${ctx.sourceLink}">${ctx.sourceLink}</a>
+ is now liked to by
+ <a class="link" href="${ctx.createdLink}">${ctx.createdLink}</a>
+ <span class="copy">
+ <button class="copy-button">Copy to Clipboard</button>
+ </span>
+ <div class="meta-link">
+ control token <span class="auth-token">${ctx.authToken}</span>
+ </div>
+ </section>`) +
+ (!ctx.message ? '' : `
+ <section class="message">
+ ${ctx.message}
+ </section>`) + `
+ <section class="create-link">
+ <form action="" method="post" class="submit-url">
+ <div class="submit-url">
+ <label for="url">URL: </label>
+ <input type="text" name="url" id="url" required>
+ </div>
+ <div class="submit-url">
+ <input type="submit" value="Create Link">
+ </div>
+ </form>
+ </section>
+ </body>
+</html>`;
+};
\ No newline at end of file
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>\r
+<!-- Svg Vector Icons : http://www.onlinewebfonts.com/icon -->\r
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\r
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 1000 1000" enable-background="new 0 0 1000 1000" xml:space="preserve">\r
+<metadata> Svg Vector Icons : http://www.onlinewebfonts.com/icon </metadata>\r
+<g><path d="M500,10C229.4,10,10,229.4,10,500c0,270.6,219.4,490,490,490c270.6,0,490-219.4,490-490C990,229.4,770.6,10,500,10L500,10L500,10z M435.3,743.6c-49.3,49.3-129.6,49.3-179,0c-49.3-49.3-49.3-129.6,0-178.9l126.8-126.8c49.3-49.3,129.6-49.3,179,0c1.3,1.3,2.6,2.7,3.9,4.1c0.7,0.6,1.5,1.3,2.2,2c2,2,3.6,4.2,5,6.5c0.2,0.2,0.3,0.4,0.5,0.6c0,0-0.1,0.1-0.1,0.1c6.1,11.5,4.3,26-5.4,35.6c-9.7,9.7-24.4,11.5-35.9,5.2c-0.8,0.8-8.7-7.1-13-11.3l0,0c-25.8-25.8-67.3-26-93.1-0.2l-127,127c-25.8,25.8-25.8,67.5,0,93.3l0,0c25.8,25.8,67.5,25.8,93.3,0l76.4-76.4c26.5,13,57.7,13.4,84.5,1.1L435.3,743.6L435.3,743.6L435.3,743.6z M743.6,435.3L616.9,562.1c-49.3,49.3-129.6,49.3-179,0c-1.3-1.3-2.6-2.7-3.9-4.1c-0.7-0.6-1.5-1.3-2.1-2c-2-2-3.6-4.2-5-6.5c-0.2-0.2-0.3-0.4-0.5-0.6c0,0,0.1-0.1,0.1-0.1c-6.1-11.5-4.3-26,5.4-35.6c9.7-9.7,24.4-11.4,35.9-5.2c0.8-0.8,8.7,7.1,13,11.3l0,0c25.8,25.8,67.3,26,93.1,0.2l127-127c25.8-25.8,25.8-67.5,0-93.3l0,0c-25.8-25.8-67.5-25.8-93.3,0L531,375.6c-26.4-12.9-57.7-13.4-84.5-1.1l118.1-118.1c49.3-49.3,129.6-49.3,179,0C793,305.7,793,386,743.6,435.3L743.6,435.3L743.6,435.3z"/></g>\r
+</svg>
\ No newline at end of file
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <title>Static Assets</title>
+ <link rel="stylesheet" href="static/theme.css">
+</head>
+<body>
+ <header>
+ <h1>Static Assets</h1>
+ </header>
+ <main>
+ welcome to my static
+ </main>
+</body>
+</html>
--- /dev/null
+User-agent: *
+Disallow: /
--- /dev/null
+* {
+ 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
--- /dev/null
+/* 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
--- /dev/null
+/* eslint-env mocha */
+
+'use strict';
+
+const sinon = require('sinon');
+
+describe('common', function () {
+});
\ No newline at end of file
--- /dev/null
+/* 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
--- /dev/null
+/* 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
--- /dev/null
+/* 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
--- /dev/null
+/* 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
--- /dev/null
+/* 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
--- /dev/null
+/* 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
--- /dev/null
+/* 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