diff --git a/hooks/sapling/user/forgot.js b/hooks/sapling/user/forgot.js index 4025377..50938eb 100644 --- a/hooks/sapling/user/forgot.js +++ b/hooks/sapling/user/forgot.js @@ -7,6 +7,7 @@ /* Dependencies */ import { console } from '@sapling/sapling/lib/Cluster.js'; +import Redirect from '@sapling/sapling/lib/Redirect.js'; import Response from '@sapling/sapling/lib/Response.js'; import SaplingError from '@sapling/sapling/lib/SaplingError.js'; import Validation from '@sapling/sapling/lib/Validation.js'; @@ -56,9 +57,7 @@ export default async function forgot(app, request, response) { /* Respond the same way whether or not we did anything */ /* If we need to redirect, let's redirect */ - if (request.query.redirect) { - response.redirect(request.query.redirect); - } else { + if (!(new Redirect(app, request, response)).do()) { /* Respond positively */ return new Response(app, request, response); } diff --git a/hooks/sapling/user/login.js b/hooks/sapling/user/login.js index 3927341..5ba1090 100644 --- a/hooks/sapling/user/login.js +++ b/hooks/sapling/user/login.js @@ -9,6 +9,7 @@ import _ from 'underscore'; import Hash from '@sapling/sapling/lib/Hash.js'; +import Redirect from '@sapling/sapling/lib/Redirect.js'; import Response from '@sapling/sapling/lib/Response.js'; import SaplingError from '@sapling/sapling/lib/SaplingError.js'; @@ -121,9 +122,7 @@ export default async function login(app, request, response) { } /* If we need to redirect, let's redirect */ - if (request.query.redirect) { - response.redirect(request.query.redirect); - } else { + if (!(new Redirect(app, request, response, request.session.user)).do()) { /* Otherwise, reply with the user object */ return new Response(app, request, response, null, request.session.user); } diff --git a/hooks/sapling/user/logout.js b/hooks/sapling/user/logout.js index 5aca749..99c1f40 100644 --- a/hooks/sapling/user/logout.js +++ b/hooks/sapling/user/logout.js @@ -5,6 +5,7 @@ */ /* Dependencies */ +import Redirect from '@sapling/sapling/lib/Redirect.js'; import Response from '@sapling/sapling/lib/Response.js'; @@ -15,9 +16,7 @@ export default async function logout(app, request, response) { request.session = null; /* Redirect if needed, respond otherwise */ - if (request.query.redirect) { - response.redirect(request.query.redirect); - } else { + if (!(new Redirect(app, request, response)).do()) { return new Response(app, request, response); } } diff --git a/hooks/sapling/user/recover.js b/hooks/sapling/user/recover.js index 6a6bc6d..35901b4 100644 --- a/hooks/sapling/user/recover.js +++ b/hooks/sapling/user/recover.js @@ -7,6 +7,7 @@ /* Dependencies */ import Hash from '@sapling/sapling/lib/Hash.js'; +import Redirect from '@sapling/sapling/lib/Redirect.js'; import Response from '@sapling/sapling/lib/Response.js'; import SaplingError from '@sapling/sapling/lib/SaplingError.js'; @@ -103,21 +104,19 @@ export default async function recover(app, request, response) { session: app.adminSession, }); - /* If we need to redirect, let's redirect */ - if (request.query.redirect) { - response.redirect(request.query.redirect); - } else { - /* Clean the output */ - if (userData.length > 0) { - if ('password' in userData[0]) { - delete userData[0].password; - } - - if ('_salt' in userData[0]) { - delete userData[0]._salt; - } + /* Clean the output */ + if (userData.length > 0) { + if ('password' in userData[0]) { + delete userData[0].password; } + if ('_salt' in userData[0]) { + delete userData[0]._salt; + } + } + + /* If we need to redirect, let's redirect */ + if (!(new Redirect(app, request, response, userData)).do()) { /* Respond with the user object */ return new Response(app, request, response, null, userData); } diff --git a/hooks/sapling/user/register.js b/hooks/sapling/user/register.js index 7888864..e63d38f 100644 --- a/hooks/sapling/user/register.js +++ b/hooks/sapling/user/register.js @@ -9,6 +9,7 @@ import _ from 'underscore'; import { console } from '@sapling/sapling/lib/Cluster.js'; import Hash from '@sapling/sapling/lib/Hash.js'; +import Redirect from '@sapling/sapling/lib/Redirect.js'; import Response from '@sapling/sapling/lib/Response.js'; import SaplingError from '@sapling/sapling/lib/SaplingError.js'; @@ -97,9 +98,7 @@ export default async function register(app, request, response) { console.log('REGISTER', errors, userData); /* If we need to redirect, let's redirect */ - if (request.query.redirect) { - response.redirect(request.query.redirect); - } else { + if (!(new Redirect(app, request, response, userData)).do()) { /* Respond with the user object */ return new Response(app, request, response, null, userData); } diff --git a/hooks/sapling/user/update.js b/hooks/sapling/user/update.js index 188dca4..3966e51 100644 --- a/hooks/sapling/user/update.js +++ b/hooks/sapling/user/update.js @@ -8,6 +8,7 @@ /* Dependencies */ import Hash from '@sapling/sapling/lib/Hash.js'; +import Redirect from '@sapling/sapling/lib/Redirect.js'; import Response from '@sapling/sapling/lib/Response.js'; import SaplingError from '@sapling/sapling/lib/SaplingError.js'; @@ -102,16 +103,14 @@ export default async function update(app, request, response) { session: request.session, }); - /* If we need to redirect, let's redirect */ - if (request.query.redirect) { - response.redirect(request.query.redirect); - } else { - /* Clean the output */ - for (const record of userData) { - delete record.password; - delete record._salt; - } + /* Clean the output */ + for (const record of userData) { + delete record.password; + delete record._salt; + } + /* If we need to redirect, let's redirect */ + if (!(new Redirect(app, request, response, userData)).do()) { /* Respond with the user object */ return new Response(app, request, response, null, userData); } diff --git a/lib/Redirect.js b/lib/Redirect.js new file mode 100644 index 0000000..663e45b --- /dev/null +++ b/lib/Redirect.js @@ -0,0 +1,47 @@ +/** + * Redirect + * + * Figures out if the current request needs to be redirected, + * and does so if needed. Otherwise returns false. + */ + +/* Dependencies */ +import { inject } from 'regexparam'; + + +/** + * The Redirect class + */ +export default class Redirect { + /** + * Initialise the Redirect class + * + * @param {object} App The App instance + * @param {object} request Request object from Express + * @param {object} response Response object from Express + * @param {object} data Data to apply to redirect destination + */ + constructor(App, request, response, data) { + this.app = App; + this.request = request; + this.response = response; + + this.data = Array.isArray(data) ? data[0] : data; + } + + + /** + * Execute the redirection + * + * @returns {boolean} Whether redirection happened or not + */ + do() { + if ('redirect' in this.request.query || 'goto' in this.request.query) { + const destination = String(this.request.query.redirect || this.request.query.goto); + this.response.redirect(inject(destination, this.data)); + return true; + } + + return false; + } +} diff --git a/lib/Request.js b/lib/Request.js index 0c5518b..828878f 100644 --- a/lib/Request.js +++ b/lib/Request.js @@ -6,6 +6,7 @@ /* Dependencies */ import _ from 'underscore'; +import { getQueryParams } from '@tinyhttp/url'; import { console } from './Cluster.js'; import Validation from './Validation.js'; @@ -74,7 +75,8 @@ export default class Request { */ parse(request) { /* Get the URL segments from the requested URL */ - const query = new URL(request.url, `${request.protocol}://${request.hostname}`); + const url = request.originalUrl || request.url; + const query = new URL(url, 'https://localhost'); const parts = query.pathname.split('/'); /* Request method */ @@ -110,16 +112,6 @@ export default class Request { console.warn(`You should add a permission for \`${request.url}\`.`); } - /* Convert URLSearchParams to a regular object */ - const queryObject = {}; - for (const queryKey of query.searchParams.keys()) { - if (query.searchParams.getAll(queryKey).length > 1) { - queryObject[queryKey] = query.searchParams.getAll(queryKey); - } else { - queryObject[queryKey] = query.searchParams.get(queryKey); - } - } - /* Format incoming data */ if (request.body) { /* Go through every key in incoming data */ @@ -190,7 +182,7 @@ export default class Request { collection, fields, values, - query: queryObject, // Query params + query: url.includes('?') ? getQueryParams(url) : {}, // Query params type: parts.length >= 3 ? 'filter' : 'all', isLogged: Boolean(request.session && request.session.user), }); diff --git a/lib/Response.js b/lib/Response.js index e9edade..4d2e35e 100644 --- a/lib/Response.js +++ b/lib/Response.js @@ -10,6 +10,7 @@ import path from 'node:path'; import isobject from 'isobject'; import { console } from './Cluster.js'; +import Redirect from './Redirect.js'; import Templating from './Templating.js'; @@ -175,10 +176,10 @@ export default class Response { * Respond with results from storage */ async dataResponse() { - /* If the URI includes a goto param, use it */ - if (this.request.query.redirect) { - this.response.redirect(this.request.query.redirect); - return true; + /* Check if redirect is needed */ + const redirect = (new Redirect(this, this.request, this.response, this.content)).do(); + if (redirect) { + return false; } /* Otherwise just return the data */ diff --git a/package-lock.json b/package-lock.json index 9b3b403..95c8ee6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -507,6 +507,7 @@ "version": "file:", "dev": true, "requires": { + "@tinyhttp/app": "^2.0.11", "async": "^3.2.0", "body-parser": "1.19.0", "chalk": "^4.1.0", @@ -524,6 +525,7 @@ "morgan": "^1.9.1", "nodemailer": "6.7.0", "path-match": "^1.2.4", + "regexparam": "^2.0.0", "sirv": "^1.0.11", "underscore": "1.13.1", "unused-filename": "^3.0.0", diff --git a/package.json b/package.json index b5c3041..68c1cda 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "bin": "./index.js", "dependencies": { "@tinyhttp/app": "^2.0.11", + "@tinyhttp/url": "^2.0.3", "async": "^3.2.0", "body-parser": "1.19.0", "chalk": "^4.1.0", @@ -44,6 +45,7 @@ "morgan": "^1.9.1", "nodemailer": "6.7.0", "path-match": "^1.2.4", + "regexparam": "^2.0.0", "sirv": "^1.0.11", "underscore": "1.13.1", "unused-filename": "^3.0.0", diff --git a/test/_utils/request.js b/test/_utils/request.js index b4f8ac2..a4f013d 100644 --- a/test/_utils/request.js +++ b/test/_utils/request.js @@ -4,9 +4,7 @@ export default () => { method: 'GET', url: '', originalUrl: '', - query: { - redirect: false - }, + query: {}, body: {}, params: {}, headers: {}, diff --git a/test/lib/Redirect.test.js b/test/lib/Redirect.test.js new file mode 100644 index 0000000..6e38e0e --- /dev/null +++ b/test/lib/Redirect.test.js @@ -0,0 +1,125 @@ +import test from 'ava'; + +import Redirect from '../../lib/Redirect.js'; + + +test.beforeEach(async t => { + t.context.app = (await import('../_utils/app.js')).default(); + + t.context.request = (await import('../_utils/request.js')).default(); + + t.context.response = () => { + const response = {}; + response.redirect = () => { + t.fail('Response should not redirect'); + return response; + }; + response.status = () => { + t.fail('Response should not send a status'); + return response; + }; + response.send = () => { + t.fail('Response should not be a view'); + return response; + }; + response.json = () => { + t.fail('Response should not be JSON'); + return response; + }; + return response; + }; +}); + + +test('does not redirect when no query string is passed', t => { + const response = t.context.response(); + + const result = (new Redirect(t.context.app, t.context.request, response)).do(); + t.false(result); +}); + +test.cb('redirects when redirect query string is passed', t => { + t.plan(2); + + t.context.request.query.redirect = '/post'; + + const response = t.context.response(); + + response.redirect = destination => { + t.is(destination, '/post'); + t.end(); + return response; + }; + + const result = (new Redirect(t.context.app, t.context.request, response)).do(); + t.true(result); +}); + +test.cb('redirects when goto query string is passed', t => { + t.plan(2); + + t.context.request.query.goto = '/post'; + + const response = t.context.response(); + + response.redirect = destination => { + t.is(destination, '/post'); + t.end(); + return response; + }; + + const result = (new Redirect(t.context.app, t.context.request, response)).do(); + t.true(result); +}); + +test.cb('prefers redirect over goto', t => { + t.plan(2); + + t.context.request.query.redirect = '/post'; + t.context.request.query.goto = '/update'; + + const response = t.context.response(); + + response.redirect = destination => { + t.is(destination, '/post'); + t.end(); + return response; + }; + + const result = (new Redirect(t.context.app, t.context.request, response)).do(); + t.true(result); +}); + +test.cb('applies data to params', t => { + t.plan(2); + + t.context.request.query.redirect = '/post/:_id'; + + const response = t.context.response(); + + response.redirect = destination => { + t.is(destination, '/post/15'); + t.end(); + return response; + }; + + const result = (new Redirect(t.context.app, t.context.request, response, { _id: 15, title: 'Hello' })).do(); + t.true(result); +}); + +test.cb('applies an array of data to params', t => { + t.plan(2); + + t.context.request.query.redirect = '/post/:_id'; + + const response = t.context.response(); + + response.redirect = destination => { + t.is(destination, '/post/15'); + t.end(); + return response; + }; + + const result = (new Redirect(t.context.app, t.context.request, response, [ { _id: 15, title: 'Hello' }, { _id: 20, title: 'Hi' } ])).do(); + t.true(result); +});