// @ts-check
///
'use strict';
const DEFAULT_PRUNE_INTERVAL_IN_SECONDS = 60 * 15;
const ONE_DAY = 86400;
/** @typedef {*} ExpressSession */
/** @typedef {*} ExpressSessionStore */
/**
* Inspired by util.callbackify()
*
* Never throws, even if callback is left out, as that's how it was
*
* @template T
* @param {Promise} value
* @param {((err: Error|null, result: T) => void)|undefined} cb
* @returns {void}
*/
const callbackifyPromiseResolution = (value, cb) => {
if (!cb) {
// eslint-disable-next-line promise/prefer-await-to-then
value.catch(() => {});
} else {
// eslint-disable-next-line promise/catch-or-return, promise/prefer-await-to-then
value.then(
// eslint-disable-next-line unicorn/no-null
(ret) => process.nextTick(cb, null, ret),
(err) => process.nextTick(cb, err || new Error('Promise was rejected with falsy value'))
);
}
};
/** @returns {number} */
const currentTimestamp = () => Math.ceil(Date.now() / 1000);
/**
* @see https://www.postgresql.org/docs/9.5/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS
* @param {string} value
* @returns {string}
*/
const escapePgIdentifier = (value) => value.replaceAll('"', '""');
/** @typedef {(err: Error|null) => void} SimpleErrorCallback */
/** @typedef {{ cookie: { maxAge?: number, expire?: number, [property: string]: any }, [property: string]: any }} SessionObject */
/** @typedef {(delay: number) => number} PGStorePruneDelayRandomizer */
/** @typedef {Object} PGStoreQueryResult */
/** @typedef {(err: Error|null, firstRow?: PGStoreQueryResult) => void} PGStoreQueryCallback */
/**
* @typedef PGStoreOptions
* @property {string} [schemaName]
* @property {string} [tableName]
* @property {boolean} [createTableIfMissing]
* @property {number} [ttl]
* @property {boolean} [disableTouch]
* @property {typeof console.error} [errorLog]
* @property {import('pg').Pool} [pool]
* @property {*} [pgPromise]
* @property {string} [conString]
* @property {*} [conObject]
* @property {false|number} [pruneSessionInterval]
* @property {false|PGStorePruneDelayRandomizer} [pruneSessionRandomizedInterval]
*/
/**
* @param {ExpressSession} session
* @returns {ExpressSessionStore}
*/
module.exports = function (session) {
/** @type {ExpressSessionStore} */
const Store = session.Store ||
// @ts-ignore
session.session.Store;
class PGStore extends Store {
/** @type {boolean} */
#createTableIfMissing;
/** @type {boolean} */
#disableTouch;
/** @type {typeof console.error} */
#errorLog;
/** @type {boolean} */
#ownsPg;
/** @type {*} */
#pgPromise;
/** @type {import('pg').Pool|undefined} */
#pool;
/** @type {false|number} */
#pruneSessionInterval;
/** @type {PGStorePruneDelayRandomizer|undefined} */
#pruneSessionRandomizedInterval;
/** @type {string|undefined} */
#schemaName;
/** @type {Promise|undefined} */
#tableCreationPromise;
/** @type {string} */
#tableName;
/** @param {PGStoreOptions} options */
constructor (options = {}) {
super(options);
this.#schemaName = options.schemaName ? escapePgIdentifier(options.schemaName) : undefined;
this.#tableName = options.tableName ? escapePgIdentifier(options.tableName) : 'session';
if (!this.#schemaName && this.#tableName.includes('"."')) {
// eslint-disable-next-line no-console
console.warn('DEPRECATION WARNING: Schema should be provided through its dedicated "schemaName" option rather than through "tableName"');
this.#tableName = this.#tableName.replace(/^([^"]+)""\.""([^"]+)$/, '$1"."$2');
}
this.#createTableIfMissing = !!options.createTableIfMissing;
this.#tableCreationPromise = undefined;
this.ttl = options.ttl; // TODO: Make this private as well, some bug in at least TS 4.6.4 stops that
this.#disableTouch = !!options.disableTouch;
// eslint-disable-next-line no-console
this.#errorLog = options.errorLog || console.error.bind(console);
if (options.pool !== undefined) {
this.#pool = options.pool;
this.#ownsPg = false;
} else if (options.pgPromise !== undefined) {
if (typeof options.pgPromise.any !== 'function') {
throw new TypeError('`pgPromise` config must point to an existing and configured instance of pg-promise pointing at your database');
}
this.#pgPromise = options.pgPromise;
this.#ownsPg = false;
} else {
const conString = options.conString || process.env['DATABASE_URL'];
let conObject = options.conObject;
if (!conObject) {
conObject = {};
if (conString) {
conObject.connectionString = conString;
}
}
this.#pool = new (require('pg')).Pool(conObject);
this.#pool.on('error', err => {
this.#errorLog('PG Pool error:', err);
});
this.#ownsPg = true;
}
if (options.pruneSessionInterval === false) {
this.#pruneSessionInterval = false;
} else {
this.#pruneSessionInterval = (options.pruneSessionInterval || DEFAULT_PRUNE_INTERVAL_IN_SECONDS) * 1000;
if (options.pruneSessionRandomizedInterval !== false) {
this.#pruneSessionRandomizedInterval = (
options.pruneSessionRandomizedInterval ||
// Results in at least 50% of the specified interval and at most 150%. Makes it so that multiple instances doesn't all prune at the same time.
(delay => Math.ceil(delay / 2 + delay * Math.random()))
);
}
}
}
/**
* Ensures the session store table exists, creating it if its missing
*
* @access private
* @returns {Promise}
*/
async _rawEnsureSessionStoreTable () {
const quotedTable = this.quotedTable();
const res = await this._asyncQuery('SELECT to_regclass($1::text)', [quotedTable], true);
if (res && res['to_regclass'] === null) {
const pathModule = require('node:path');
const fs = require('node:fs').promises;
const tableDefString = await fs.readFile(pathModule.resolve(__dirname, './table.sql'), 'utf8');
const tableDefModified = tableDefString.replaceAll('"session"', quotedTable);
await this._asyncQuery(tableDefModified, [], true);
}
}
/**
* Ensures the session store table exists, creating it if its missing
*
* @access private
* @param {boolean|undefined} noTableCreation
* @returns {Promise}
*/
async _ensureSessionStoreTable (noTableCreation) {
if (noTableCreation || this.#createTableIfMissing === false) return;
if (!this.#tableCreationPromise) {
this.#tableCreationPromise = this._rawEnsureSessionStoreTable();
}
return this.#tableCreationPromise;
}
/**
* Closes the session store
*
* Currently only stops the automatic pruning, if any, from continuing
*
* @access public
* @returns {Promise}
*/
async close () {
this.closed = true;
this.#clearPruneTimer();
if (this.#ownsPg && this.#pool) {
await this.#pool.end();
}
}
#initPruneTimer () {
if (this.#pruneSessionInterval && !this.closed && !this.pruneTimer) {
const delay = this.#pruneSessionRandomizedInterval
? this.#pruneSessionRandomizedInterval(this.#pruneSessionInterval)
: this.#pruneSessionInterval;
this.pruneTimer = setTimeout(
() => { this.pruneSessions(); },
delay
);
this.pruneTimer.unref();
}
}
#clearPruneTimer () {
if (this.pruneTimer) {
clearTimeout(this.pruneTimer);
this.pruneTimer = undefined;
}
}
/**
* Does garbage collection for expired session in the database
*
* @param {SimpleErrorCallback} [fn] - standard Node.js callback called on completion
* @returns {void}
* @access public
*/
pruneSessions (fn) {
this.query('DELETE FROM ' + this.quotedTable() + ' WHERE expire < to_timestamp($1)', [currentTimestamp()], err => {
if (fn && typeof fn === 'function') {
return fn(err);
}
if (err) {
this.#errorLog('Failed to prune sessions:', err);
}
this.#clearPruneTimer();
this.#initPruneTimer();
});
}
/**
* Get the quoted table.
*
* @returns {string} the quoted schema + table for use in queries
* @access private
*/
quotedTable () {
let result = '"' + this.#tableName + '"';
if (this.#schemaName) {
result = '"' + this.#schemaName + '".' + result;
}
return result;
}
/**
* Figure out when a session should expire
*
* @param {SessionObject} sess – the session object to store
* @returns {number} the unix timestamp, in seconds
* @access private
*/
#getExpireTime (sess) {
let expire;
if (sess && sess.cookie && sess.cookie['expires']) {
const expireDate = new Date(sess.cookie['expires']);
expire = Math.ceil(expireDate.valueOf() / 1000);
} else {
const ttl = this.ttl || ONE_DAY;
expire = Math.ceil(Date.now() / 1000 + ttl);
}
return expire;
}
/**
* Query the database.
*
* @param {string} query - the database query to perform
* @param {any[]} [params] - the parameters of the query
* @param {boolean} [noTableCreation]
* @returns {Promise}
* @access private
*/
async _asyncQuery (query, params, noTableCreation) {
await this._ensureSessionStoreTable(noTableCreation);
if (this.#pgPromise) {
const res = await this.#pgPromise.any(query, params);
return res && res[0] ? res[0] : undefined;
} else {
if (!this.#pool) throw new Error('Pool missing for some reason');
const res = await this.#pool.query(query, params);
return res && res.rows && res.rows[0] ? res.rows[0] : undefined;
}
}
/**
* Query the database.
*
* @param {string} query - the database query to perform
* @param {any[]|PGStoreQueryCallback} [params] - the parameters of the query or the callback function
* @param {PGStoreQueryCallback} [fn] - standard Node.js callback returning the resulting rows
* @param {boolean} [noTableCreation]
* @returns {void}
* @access private
*/
query (query, params, fn, noTableCreation) {
/** @type {any[]} */
let resolvedParams;
if (typeof params === 'function') {
if (fn) throw new Error('Two callback functions set at once');
fn = params;
resolvedParams = [];
} else {
resolvedParams = params || [];
}
const result = this._asyncQuery(query, resolvedParams, noTableCreation);
callbackifyPromiseResolution(result, fn);
}
/**
* Attempt to fetch session by the given `sid`.
*
* @param {string} sid – the session id
* @param {(err: Error|null, firstRow?: PGStoreQueryResult) => void} fn – a standard Node.js callback returning the parsed session object
* @access public
*/
get (sid, fn) {
this.#initPruneTimer();
this.query('SELECT sess FROM ' + this.quotedTable() + ' WHERE sid = $1 AND expire >= to_timestamp($2)', [sid, currentTimestamp()], (err, data) => {
if (err) { return fn(err); }
// eslint-disable-next-line unicorn/no-null
if (!data) { return fn(null); }
try {
// eslint-disable-next-line unicorn/no-null
return fn(null, (typeof data['sess'] === 'string') ? JSON.parse(data['sess']) : data['sess']);
} catch {
return this.destroy(sid, fn);
}
});
}
/**
* Commit the given `sess` object associated with the given `sid`.
*
* @param {string} sid – the session id
* @param {SessionObject} sess – the session object to store
* @param {SimpleErrorCallback} fn – a standard Node.js callback returning the parsed session object
* @access public
*/
set (sid, sess, fn) {
this.#initPruneTimer();
const expireTime = this.#getExpireTime(sess);
const query = 'INSERT INTO ' + this.quotedTable() + ' (sess, expire, sid) SELECT $1, to_timestamp($2), $3 ON CONFLICT (sid) DO UPDATE SET sess=$1, expire=to_timestamp($2) RETURNING sid';
this.query(
query,
[sess, expireTime, sid],
err => { fn && fn(err); }
);
}
/**
* Destroy the session associated with the given `sid`.
*
* @param {string} sid – the session id
* @param {SimpleErrorCallback} fn – a standard Node.js callback returning the parsed session object
* @access public
*/
destroy (sid, fn) {
this.#initPruneTimer();
this.query(
'DELETE FROM ' + this.quotedTable() + ' WHERE sid = $1',
[sid],
err => { fn && fn(err); }
);
}
/**
* Touch the given session object associated with the given session ID.
*
* @param {string} sid – the session id
* @param {SessionObject} sess – the session object to store
* @param {SimpleErrorCallback} fn – a standard Node.js callback returning the parsed session object
* @access public
*/
touch (sid, sess, fn) {
this.#initPruneTimer();
if (this.#disableTouch) {
// eslint-disable-next-line unicorn/no-null
fn && fn(null);
return;
}
const expireTime = this.#getExpireTime(sess);
this.query(
'UPDATE ' + this.quotedTable() + ' SET expire = to_timestamp($1) WHERE sid = $2 RETURNING sid',
[expireTime, sid],
err => { fn && fn(err); }
);
}
}
return PGStore;
};