- Replaced broken .NET backend with Node.js/Express - Restored original website design with purple gradient hero - Updated homepage and shop pages with Bootstrap 5 responsive design - Disabled GitHub remote sync - all code now stored locally only - Created backup scripts and documentation - All changes saved on Ubuntu server at /var/www/SkyArtShop
443 lines
14 KiB
JavaScript
443 lines
14 KiB
JavaScript
// @ts-check
|
||
/// <reference types="node" />
|
||
|
||
'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<T>} 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<string, any>} 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<void>|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<void>}
|
||
*/
|
||
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<void>}
|
||
*/
|
||
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<void>}
|
||
*/
|
||
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<PGStoreQueryResult|undefined>}
|
||
* @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;
|
||
};
|