Initial project structure with sails.js
This commit is contained in:
55
api/controllers/account/logout.js
Normal file
55
api/controllers/account/logout.js
Normal file
@ -0,0 +1,55 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'Logout',
|
||||
|
||||
|
||||
description: 'Log out of this app.',
|
||||
|
||||
|
||||
extendedDescription:
|
||||
`This action deletes the \`req.session.userId\` key from the session of the requesting user agent.
|
||||
Actual garbage collection of session data depends on this app's session store, and
|
||||
potentially also on the [TTL configuration](https://sailsjs.com/docs/reference/configuration/sails-config-session)
|
||||
you provided for it.
|
||||
|
||||
Note that this action does not check to see whether or not the requesting user was
|
||||
actually logged in. (If they weren't, then this action is just a no-op.)`,
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
success: {
|
||||
description: 'The requesting user agent has been successfully logged out.'
|
||||
},
|
||||
|
||||
redirect: {
|
||||
description: 'The requesting user agent looks to be a web browser.',
|
||||
extendedDescription: 'After logging out from a web browser, the user is redirected away.',
|
||||
responseType: 'redirect'
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function () {
|
||||
|
||||
// Clear the `userId` property from this session.
|
||||
delete this.req.session.userId;
|
||||
|
||||
// Broadcast a message that we can display in other open tabs.
|
||||
if (sails.hooks.sockets) {
|
||||
await sails.helpers.broadcastSessionChange(this.req);
|
||||
}
|
||||
|
||||
// Then finish up, sending an appropriate response.
|
||||
// > Under the covers, this persists the now-logged-out session back
|
||||
// > to the underlying session store.
|
||||
if (!this.req.wantsJSON) {
|
||||
throw {redirect: '/login'};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
79
api/controllers/account/update-billing-card.js
Normal file
79
api/controllers/account/update-billing-card.js
Normal file
@ -0,0 +1,79 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'Update billing card',
|
||||
|
||||
|
||||
description: 'Update the credit card for the logged-in user.',
|
||||
|
||||
|
||||
inputs: {
|
||||
|
||||
stripeToken: {
|
||||
type: 'string',
|
||||
example: 'tok_199k3qEXw14QdSnRwmsK99MH',
|
||||
description: 'The single-use Stripe Checkout token identifier representing the user\'s payment source (i.e. credit card.)',
|
||||
extendedDescription: 'Omit this (or use "") to remove this user\'s payment source.',
|
||||
whereToGet: {
|
||||
description: 'This Stripe.js token is provided to the front-end (client-side) code after completing a Stripe Checkout or Stripe Elements flow.'
|
||||
}
|
||||
},
|
||||
|
||||
billingCardLast4: {
|
||||
type: 'string',
|
||||
example: '4242',
|
||||
description: 'Omit if removing card info.',
|
||||
whereToGet: { description: 'Credit card info is provided by Stripe after completing the checkout flow.' }
|
||||
},
|
||||
|
||||
billingCardBrand: {
|
||||
type: 'string',
|
||||
example: 'visa',
|
||||
description: 'Omit if removing card info.',
|
||||
whereToGet: { description: 'Credit card info is provided by Stripe after completing the checkout flow.' }
|
||||
},
|
||||
|
||||
billingCardExpMonth: {
|
||||
type: 'string',
|
||||
example: '08',
|
||||
description: 'Omit if removing card info.',
|
||||
whereToGet: { description: 'Credit card info is provided by Stripe after completing the checkout flow.' }
|
||||
},
|
||||
|
||||
billingCardExpYear: {
|
||||
type: 'string',
|
||||
example: '2023',
|
||||
description: 'Omit if removing card info.',
|
||||
whereToGet: { description: 'Credit card info is provided by Stripe after completing the checkout flow.' }
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function ({stripeToken, billingCardLast4, billingCardBrand, billingCardExpMonth, billingCardExpYear}) {
|
||||
|
||||
// Add, update, or remove the default payment source for the logged-in user's
|
||||
// customer entry in Stripe.
|
||||
var stripeCustomerId = await sails.helpers.stripe.saveBillingInfo.with({
|
||||
stripeCustomerId: this.req.me.stripeCustomerId,
|
||||
token: stripeToken || '',
|
||||
}).timeout(5000).retry();
|
||||
|
||||
// Update (or clear) the card info we have stored for this user in our database.
|
||||
// > Remember, never store complete card numbers-- only the last 4 digits + expiration!
|
||||
// > Storing (or even receiving) complete, unencrypted card numbers would require PCI
|
||||
// > compliance in the U.S.
|
||||
await User.updateOne({ id: this.req.me.id })
|
||||
.set({
|
||||
stripeCustomerId,
|
||||
hasBillingCard: stripeToken ? true : false,
|
||||
billingCardBrand: stripeToken ? billingCardBrand : '',
|
||||
billingCardLast4: stripeToken ? billingCardLast4 : '',
|
||||
billingCardExpMonth: stripeToken ? billingCardExpMonth : '',
|
||||
billingCardExpYear: stripeToken ? billingCardExpYear : ''
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
35
api/controllers/account/update-password.js
Normal file
35
api/controllers/account/update-password.js
Normal file
@ -0,0 +1,35 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'Update password',
|
||||
|
||||
|
||||
description: 'Update the password for the logged-in user.',
|
||||
|
||||
|
||||
inputs: {
|
||||
|
||||
password: {
|
||||
description: 'The new, unencrypted password.',
|
||||
example: 'abc123v2',
|
||||
required: true
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function ({password}) {
|
||||
|
||||
// Hash the new password.
|
||||
var hashed = await sails.helpers.passwords.hashPassword(password);
|
||||
|
||||
// Update the record for the logged-in user.
|
||||
await User.updateOne({ id: this.req.me.id })
|
||||
.set({
|
||||
password: hashed
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
160
api/controllers/account/update-profile.js
Normal file
160
api/controllers/account/update-profile.js
Normal file
@ -0,0 +1,160 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'Update profile',
|
||||
|
||||
|
||||
description: 'Update the profile for the logged-in user.',
|
||||
|
||||
|
||||
inputs: {
|
||||
|
||||
fullName: {
|
||||
type: 'string'
|
||||
},
|
||||
|
||||
emailAddress: {
|
||||
type: 'string'
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
emailAlreadyInUse: {
|
||||
statusCode: 409,
|
||||
description: 'The provided email address is already in use.',
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function ({fullName, emailAddress}) {
|
||||
|
||||
var newEmailAddress = emailAddress;
|
||||
if (newEmailAddress !== undefined) {
|
||||
newEmailAddress = newEmailAddress.toLowerCase();
|
||||
}
|
||||
|
||||
// Determine if this request wants to change the current user's email address,
|
||||
// revert her pending email address change, modify her pending email address
|
||||
// change, or if the email address won't be affected at all.
|
||||
var desiredEmailEffect;// ('change-immediately', 'begin-change', 'cancel-pending-change', 'modify-pending-change', or '')
|
||||
if (
|
||||
newEmailAddress === undefined ||
|
||||
(this.req.me.emailStatus !== 'change-requested' && newEmailAddress === this.req.me.emailAddress) ||
|
||||
(this.req.me.emailStatus === 'change-requested' && newEmailAddress === this.req.me.emailChangeCandidate)
|
||||
) {
|
||||
desiredEmailEffect = '';
|
||||
} else if (this.req.me.emailStatus === 'change-requested' && newEmailAddress === this.req.me.emailAddress) {
|
||||
desiredEmailEffect = 'cancel-pending-change';
|
||||
} else if (this.req.me.emailStatus === 'change-requested' && newEmailAddress !== this.req.me.emailAddress) {
|
||||
desiredEmailEffect = 'modify-pending-change';
|
||||
} else if (!sails.config.custom.verifyEmailAddresses || this.req.me.emailStatus === 'unconfirmed') {
|
||||
desiredEmailEffect = 'change-immediately';
|
||||
} else {
|
||||
desiredEmailEffect = 'begin-change';
|
||||
}
|
||||
|
||||
|
||||
// If the email address is changing, make sure it is not already being used.
|
||||
if (_.contains(['begin-change', 'change-immediately', 'modify-pending-change'], desiredEmailEffect)) {
|
||||
let conflictingUser = await User.findOne({
|
||||
or: [
|
||||
{ emailAddress: newEmailAddress },
|
||||
{ emailChangeCandidate: newEmailAddress }
|
||||
]
|
||||
});
|
||||
if (conflictingUser) {
|
||||
throw 'emailAlreadyInUse';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Start building the values to set in the db.
|
||||
// (We always set the fullName if provided.)
|
||||
var valuesToSet = {
|
||||
fullName,
|
||||
};
|
||||
|
||||
switch (desiredEmailEffect) {
|
||||
|
||||
// Change now
|
||||
case 'change-immediately':
|
||||
_.extend(valuesToSet, {
|
||||
emailAddress: newEmailAddress,
|
||||
emailChangeCandidate: '',
|
||||
emailProofToken: '',
|
||||
emailProofTokenExpiresAt: 0,
|
||||
emailStatus: this.req.me.emailStatus === 'unconfirmed' ? 'unconfirmed' : 'confirmed'
|
||||
});
|
||||
break;
|
||||
|
||||
// Begin new email change, or modify a pending email change
|
||||
case 'begin-change':
|
||||
case 'modify-pending-change':
|
||||
_.extend(valuesToSet, {
|
||||
emailChangeCandidate: newEmailAddress,
|
||||
emailProofToken: await sails.helpers.strings.random('url-friendly'),
|
||||
emailProofTokenExpiresAt: Date.now() + sails.config.custom.emailProofTokenTTL,
|
||||
emailStatus: 'change-requested'
|
||||
});
|
||||
break;
|
||||
|
||||
// Cancel pending email change
|
||||
case 'cancel-pending-change':
|
||||
_.extend(valuesToSet, {
|
||||
emailChangeCandidate: '',
|
||||
emailProofToken: '',
|
||||
emailProofTokenExpiresAt: 0,
|
||||
emailStatus: 'confirmed'
|
||||
});
|
||||
break;
|
||||
|
||||
// Otherwise, do nothing re: email
|
||||
}
|
||||
|
||||
// Save to the db
|
||||
await User.updateOne({id: this.req.me.id })
|
||||
.set(valuesToSet);
|
||||
|
||||
// If this is an immediate change, and billing features are enabled,
|
||||
// then also update the billing email for this user's linked customer entry
|
||||
// in the Stripe API to make sure they receive email receipts.
|
||||
// > Note: If there was not already a Stripe customer entry for this user,
|
||||
// > then one will be set up implicitly, so we'll need to persist it to our
|
||||
// > database. (This could happen if Stripe credentials were not configured
|
||||
// > at the time this user was originally created.)
|
||||
if(desiredEmailEffect === 'change-immediately' && sails.config.custom.enableBillingFeatures) {
|
||||
let didNotAlreadyHaveCustomerId = (! this.req.me.stripeCustomerId);
|
||||
let stripeCustomerId = await sails.helpers.stripe.saveBillingInfo.with({
|
||||
stripeCustomerId: this.req.me.stripeCustomerId,
|
||||
emailAddress: newEmailAddress
|
||||
}).timeout(5000).retry();
|
||||
if (didNotAlreadyHaveCustomerId){
|
||||
await User.updateOne({ id: this.req.me.id })
|
||||
.set({
|
||||
stripeCustomerId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If an email address change was requested, and re-confirmation is required,
|
||||
// send the "confirm account" email.
|
||||
if (desiredEmailEffect === 'begin-change' || desiredEmailEffect === 'modify-pending-change') {
|
||||
await sails.helpers.sendTemplateEmail.with({
|
||||
to: newEmailAddress,
|
||||
subject: 'Your account has been updated',
|
||||
template: 'email-verify-new-email',
|
||||
templateData: {
|
||||
fullName: fullName||this.req.me.fullName,
|
||||
token: valuesToSet.emailProofToken
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
30
api/controllers/account/view-account-overview.js
Normal file
30
api/controllers/account/view-account-overview.js
Normal file
@ -0,0 +1,30 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'View account overview',
|
||||
|
||||
|
||||
description: 'Display "Account Overview" page.',
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
success: {
|
||||
viewTemplatePath: 'pages/account/account-overview',
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function () {
|
||||
|
||||
// If billing features are enabled, include our configured Stripe.js
|
||||
// public key in the view locals. Otherwise, leave it as undefined.
|
||||
return {
|
||||
stripePublishableKey: sails.config.custom.enableBillingFeatures? sails.config.custom.stripePublishableKey : undefined,
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
26
api/controllers/account/view-edit-password.js
Normal file
26
api/controllers/account/view-edit-password.js
Normal file
@ -0,0 +1,26 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'View edit password',
|
||||
|
||||
|
||||
description: 'Display "Edit password" page.',
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
success: {
|
||||
viewTemplatePath: 'pages/account/edit-password'
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function () {
|
||||
|
||||
return {};
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
26
api/controllers/account/view-edit-profile.js
Normal file
26
api/controllers/account/view-edit-profile.js
Normal file
@ -0,0 +1,26 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'View edit profile',
|
||||
|
||||
|
||||
description: 'Display "Edit profile" page.',
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
success: {
|
||||
viewTemplatePath: 'pages/account/edit-profile',
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function () {
|
||||
|
||||
return {};
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
27
api/controllers/dashboard/view-welcome.js
Normal file
27
api/controllers/dashboard/view-welcome.js
Normal file
@ -0,0 +1,27 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'View welcome page',
|
||||
|
||||
|
||||
description: 'Display the dashboard "Welcome" page.',
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
success: {
|
||||
viewTemplatePath: 'pages/dashboard/welcome',
|
||||
description: 'Display the welcome page for authenticated users.'
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function () {
|
||||
|
||||
return {};
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
79
api/controllers/deliver-contact-form-message.js
Normal file
79
api/controllers/deliver-contact-form-message.js
Normal file
@ -0,0 +1,79 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'Deliver contact form message',
|
||||
|
||||
|
||||
description: 'Deliver a contact form message to the appropriate internal channel(s).',
|
||||
|
||||
|
||||
inputs: {
|
||||
|
||||
emailAddress: {
|
||||
required: true,
|
||||
type: 'string',
|
||||
description: 'A return email address where we can respond.',
|
||||
example: 'hermione@hogwarts.edu'
|
||||
},
|
||||
|
||||
topic: {
|
||||
required: true,
|
||||
type: 'string',
|
||||
description: 'The topic from the contact form.',
|
||||
example: 'I want to buy stuff.'
|
||||
},
|
||||
|
||||
fullName: {
|
||||
required: true,
|
||||
type: 'string',
|
||||
description: 'The full name of the human sending this message.',
|
||||
example: 'Hermione Granger'
|
||||
},
|
||||
|
||||
message: {
|
||||
required: true,
|
||||
type: 'string',
|
||||
description: 'The custom message, in plain text.'
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
success: {
|
||||
description: 'The message was sent successfully.'
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function({emailAddress, topic, fullName, message}) {
|
||||
|
||||
if (!sails.config.custom.internalEmailAddress) {
|
||||
throw new Error(
|
||||
`Cannot deliver incoming message from contact form because there is no internal
|
||||
email address (\`sails.config.custom.internalEmailAddress\`) configured for this
|
||||
app. To enable contact form emails, you'll need to add this missing setting to
|
||||
your custom config -- usually in \`config/custom.js\`, \`config/staging.js\`,
|
||||
\`config/production.js\`, or via system environment variables.`
|
||||
);
|
||||
}
|
||||
|
||||
await sails.helpers.sendTemplateEmail.with({
|
||||
to: sails.config.custom.internalEmailAddress,
|
||||
subject: 'New contact form message',
|
||||
template: 'internal/email-contact-form',
|
||||
layout: false,
|
||||
templateData: {
|
||||
contactName: fullName,
|
||||
contactEmail: emailAddress,
|
||||
topic,
|
||||
message,
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
160
api/controllers/entrance/confirm-email.js
Normal file
160
api/controllers/entrance/confirm-email.js
Normal file
@ -0,0 +1,160 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'Confirm email',
|
||||
|
||||
|
||||
description:
|
||||
`Confirm a new user's email address, or an existing user's request for an email address change,
|
||||
then redirect to either a special landing page (for newly-signed up users), or the account page
|
||||
(for existing users who just changed their email address).`,
|
||||
|
||||
|
||||
inputs: {
|
||||
|
||||
token: {
|
||||
description: 'The confirmation token from the email.',
|
||||
example: '4-32fad81jdaf$329'
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
success: {
|
||||
description: 'Email address confirmed and requesting user logged in.'
|
||||
},
|
||||
|
||||
redirect: {
|
||||
description: 'Email address confirmed and requesting user logged in. Since this looks like a browser, redirecting...',
|
||||
responseType: 'redirect'
|
||||
},
|
||||
|
||||
invalidOrExpiredToken: {
|
||||
responseType: 'expired',
|
||||
description: 'The provided token is expired, invalid, or already used up.',
|
||||
},
|
||||
|
||||
emailAddressNoLongerAvailable: {
|
||||
statusCode: 409,
|
||||
viewTemplatePath: '500',
|
||||
description: 'The email address is no longer available.',
|
||||
extendedDescription: 'This is an edge case that is not always anticipated by websites and APIs. Since it is pretty rare, the 500 server error page is used as a simple catch-all. If this becomes important in the future, this could easily be expanded into a custom error page or resolution flow. But for context: this behavior of showing the 500 server error page mimics how popular apps like Slack behave under the same circumstances.',
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function ({token}) {
|
||||
|
||||
// If no token was provided, this is automatically invalid.
|
||||
if (!token) {
|
||||
throw 'invalidOrExpiredToken';
|
||||
}
|
||||
|
||||
// Get the user with the matching email token.
|
||||
var user = await User.findOne({ emailProofToken: token });
|
||||
|
||||
// If no such user exists, or their token is expired, bail.
|
||||
if (!user || user.emailProofTokenExpiresAt <= Date.now()) {
|
||||
throw 'invalidOrExpiredToken';
|
||||
}
|
||||
|
||||
if (user.emailStatus === 'unconfirmed') {
|
||||
// ┌─┐┌─┐┌┐┌┌─┐┬┬─┐┌┬┐┬┌┐┌┌─┐ ╔═╗╦╦═╗╔═╗╔╦╗ ╔╦╗╦╔╦╗╔═╗ ╦ ╦╔═╗╔═╗╦═╗ ┌─┐┌┬┐┌─┐┬┬
|
||||
// │ │ ││││├┤ │├┬┘││││││││ ┬ ╠╣ ║╠╦╝╚═╗ ║───║ ║║║║║╣ ║ ║╚═╗║╣ ╠╦╝ ├┤ │││├─┤││
|
||||
// └─┘└─┘┘└┘└ ┴┴└─┴ ┴┴┘└┘└─┘ ╚ ╩╩╚═╚═╝ ╩ ╩ ╩╩ ╩╚═╝ ╚═╝╚═╝╚═╝╩╚═ └─┘┴ ┴┴ ┴┴┴─┘
|
||||
// If this is a new user confirming their email for the first time,
|
||||
// then just update the state of their user record in the database,
|
||||
// store their user id in the session (just in case they aren't logged
|
||||
// in already), and then redirect them to the "email confirmed" page.
|
||||
await User.updateOne({ id: user.id }).set({
|
||||
emailStatus: 'confirmed',
|
||||
emailProofToken: '',
|
||||
emailProofTokenExpiresAt: 0
|
||||
});
|
||||
this.req.session.userId = user.id;
|
||||
|
||||
// In case there was an existing session, broadcast a message that we can
|
||||
// display in other open tabs.
|
||||
if (sails.hooks.sockets) {
|
||||
await sails.helpers.broadcastSessionChange(this.req);
|
||||
}
|
||||
|
||||
if (this.req.wantsJSON) {
|
||||
return;
|
||||
} else {
|
||||
throw { redirect: '/email/confirmed' };
|
||||
}
|
||||
|
||||
} else if (user.emailStatus === 'change-requested') {
|
||||
// ┌─┐┌─┐┌┐┌┌─┐┬┬─┐┌┬┐┬┌┐┌┌─┐ ╔═╗╦ ╦╔═╗╔╗╔╔═╗╔═╗╔╦╗ ┌─┐┌┬┐┌─┐┬┬
|
||||
// │ │ ││││├┤ │├┬┘││││││││ ┬ ║ ╠═╣╠═╣║║║║ ╦║╣ ║║ ├┤ │││├─┤││
|
||||
// └─┘└─┘┘└┘└ ┴┴└─┴ ┴┴┘└┘└─┘ ╚═╝╩ ╩╩ ╩╝╚╝╚═╝╚═╝═╩╝ └─┘┴ ┴┴ ┴┴┴─┘
|
||||
if (!user.emailChangeCandidate){
|
||||
throw new Error(`Consistency violation: Could not update Stripe customer because this user record's emailChangeCandidate ("${user.emailChangeCandidate}") is missing. (This should never happen.)`);
|
||||
}
|
||||
|
||||
// Last line of defense: since email change candidates are not protected
|
||||
// by a uniqueness constraint in the database, it's important that we make
|
||||
// sure no one else managed to grab this email in the mean time since we
|
||||
// last checked its availability. (This is a relatively rare edge case--
|
||||
// see exit description.)
|
||||
if (await User.count({ emailAddress: user.emailChangeCandidate }) > 0) {
|
||||
throw 'emailAddressNoLongerAvailable';
|
||||
}
|
||||
|
||||
// If billing features are enabled, also update the billing email for this
|
||||
// user's linked customer entry in the Stripe API to make sure they receive
|
||||
// email receipts.
|
||||
// > Note: If there was not already a Stripe customer entry for this user,
|
||||
// > then one will be set up implicitly, so we'll need to persist it to our
|
||||
// > database. (This could happen if Stripe credentials were not configured
|
||||
// > at the time this user was originally created.)
|
||||
if(sails.config.custom.enableBillingFeatures) {
|
||||
let didNotAlreadyHaveCustomerId = (! user.stripeCustomerId);
|
||||
let stripeCustomerId = await sails.helpers.stripe.saveBillingInfo.with({
|
||||
stripeCustomerId: user.stripeCustomerId,
|
||||
emailAddress: user.emailChangeCandidate
|
||||
}).timeout(5000).retry();
|
||||
if (didNotAlreadyHaveCustomerId){
|
||||
await User.updateOne({ id: user.id }).set({
|
||||
stripeCustomerId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Finally update the user in the database, store their id in the session
|
||||
// (just in case they aren't logged in already), then redirect them to
|
||||
// their "my account" page so they can see their updated email address.
|
||||
await User.updateOne({ id: user.id })
|
||||
.set({
|
||||
emailStatus: 'confirmed',
|
||||
emailProofToken: '',
|
||||
emailProofTokenExpiresAt: 0,
|
||||
emailAddress: user.emailChangeCandidate,
|
||||
emailChangeCandidate: '',
|
||||
});
|
||||
this.req.session.userId = user.id;
|
||||
|
||||
// In case there was an existing session, broadcast a message that we can
|
||||
// display in other open tabs.
|
||||
if (sails.hooks.sockets) {
|
||||
await sails.helpers.broadcastSessionChange(this.req);
|
||||
}
|
||||
|
||||
if (this.req.wantsJSON) {
|
||||
return;
|
||||
} else {
|
||||
throw { redirect: '/account' };
|
||||
}
|
||||
|
||||
} else {
|
||||
throw new Error(`Consistency violation: User ${user.id} has an email proof token, but somehow also has an emailStatus of "${user.emailStatus}"! (This should never happen.)`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
119
api/controllers/entrance/login.js
Normal file
119
api/controllers/entrance/login.js
Normal file
@ -0,0 +1,119 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'Login',
|
||||
|
||||
|
||||
description: 'Log in using the provided email and password combination.',
|
||||
|
||||
|
||||
extendedDescription:
|
||||
`This action attempts to look up the user record in the database with the
|
||||
specified email address. Then, if such a user exists, it uses
|
||||
bcrypt to compare the hashed password from the database with the provided
|
||||
password attempt.`,
|
||||
|
||||
|
||||
inputs: {
|
||||
|
||||
emailAddress: {
|
||||
description: 'The email to try in this attempt, e.g. "irl@example.com".',
|
||||
type: 'string',
|
||||
required: true
|
||||
},
|
||||
|
||||
password: {
|
||||
description: 'The unencrypted password to try in this attempt, e.g. "passwordlol".',
|
||||
type: 'string',
|
||||
required: true
|
||||
},
|
||||
|
||||
rememberMe: {
|
||||
description: 'Whether to extend the lifetime of the user\'s session.',
|
||||
extendedDescription:
|
||||
`Note that this is NOT SUPPORTED when using virtual requests (e.g. sending
|
||||
requests over WebSockets instead of HTTP).`,
|
||||
type: 'boolean'
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
success: {
|
||||
description: 'The requesting user agent has been successfully logged in.',
|
||||
extendedDescription:
|
||||
`Under the covers, this stores the id of the logged-in user in the session
|
||||
as the \`userId\` key. The next time this user agent sends a request, assuming
|
||||
it includes a cookie (like a web browser), Sails will automatically make this
|
||||
user id available as req.session.userId in the corresponding action. (Also note
|
||||
that, thanks to the included "custom" hook, when a relevant request is received
|
||||
from a logged-in user, that user's entire record from the database will be fetched
|
||||
and exposed as \`req.me\`.)`
|
||||
},
|
||||
|
||||
badCombo: {
|
||||
description: `The provided email and password combination does not
|
||||
match any user in the database.`,
|
||||
responseType: 'unauthorized'
|
||||
// ^This uses the custom `unauthorized` response located in `api/responses/unauthorized.js`.
|
||||
// To customize the generic "unauthorized" response across this entire app, change that file
|
||||
// (see api/responses/unauthorized).
|
||||
//
|
||||
// To customize the response for _only this_ action, replace `responseType` with
|
||||
// something else. For example, you might set `statusCode: 498` and change the
|
||||
// implementation below accordingly (see http://sailsjs.com/docs/concepts/controllers).
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function ({emailAddress, password, rememberMe}) {
|
||||
|
||||
// Look up by the email address.
|
||||
// (note that we lowercase it to ensure the lookup is always case-insensitive,
|
||||
// regardless of which database we're using)
|
||||
var userRecord = await User.findOne({
|
||||
emailAddress: emailAddress.toLowerCase(),
|
||||
});
|
||||
|
||||
// If there was no matching user, respond thru the "badCombo" exit.
|
||||
if(!userRecord) {
|
||||
throw 'badCombo';
|
||||
}
|
||||
|
||||
// If the password doesn't match, then also exit thru "badCombo".
|
||||
await sails.helpers.passwords.checkPassword(password, userRecord.password)
|
||||
.intercept('incorrect', 'badCombo');
|
||||
|
||||
// If "Remember Me" was enabled, then keep the session alive for
|
||||
// a longer amount of time. (This causes an updated "Set Cookie"
|
||||
// response header to be sent as the result of this request -- thus
|
||||
// we must be dealing with a traditional HTTP request in order for
|
||||
// this to work.)
|
||||
if (rememberMe) {
|
||||
if (this.req.isSocket) {
|
||||
sails.log.warn(
|
||||
'Received `rememberMe: true` from a virtual request, but it was ignored\n'+
|
||||
'because a browser\'s session cookie cannot be reset over sockets.\n'+
|
||||
'Please use a traditional HTTP request instead.'
|
||||
);
|
||||
} else {
|
||||
this.req.session.cookie.maxAge = sails.config.custom.rememberMeCookieMaxAge;
|
||||
}
|
||||
}//fi
|
||||
|
||||
// Modify the active session instance.
|
||||
// (This will be persisted when the response is sent.)
|
||||
this.req.session.userId = userRecord.id;
|
||||
|
||||
// In case there was an existing session (e.g. if we allow users to go to the login page
|
||||
// when they're already logged in), broadcast a message that we can display in other open tabs.
|
||||
if (sails.hooks.sockets) {
|
||||
await sails.helpers.broadcastSessionChange(this.req);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
};
|
66
api/controllers/entrance/send-password-recovery-email.js
Normal file
66
api/controllers/entrance/send-password-recovery-email.js
Normal file
@ -0,0 +1,66 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'Send password recovery email',
|
||||
|
||||
|
||||
description: 'Send a password recovery notification to the user with the specified email address.',
|
||||
|
||||
|
||||
inputs: {
|
||||
|
||||
emailAddress: {
|
||||
description: 'The email address of the alleged user who wants to recover their password.',
|
||||
example: 'rydahl@example.com',
|
||||
type: 'string',
|
||||
required: true
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
success: {
|
||||
description: 'The email address might have matched a user in the database. (If so, a recovery email was sent.)'
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function ({emailAddress}) {
|
||||
|
||||
// Find the record for this user.
|
||||
// (Even if no such user exists, pretend it worked to discourage sniffing.)
|
||||
var userRecord = await User.findOne({ emailAddress });
|
||||
if (!userRecord) {
|
||||
return;
|
||||
}//•
|
||||
|
||||
// Come up with a pseudorandom, probabilistically-unique token for use
|
||||
// in our password recovery email.
|
||||
var token = await sails.helpers.strings.random('url-friendly');
|
||||
|
||||
// Store the token on the user record
|
||||
// (This allows us to look up the user when the link from the email is clicked.)
|
||||
await User.updateOne({ id: userRecord.id })
|
||||
.set({
|
||||
passwordResetToken: token,
|
||||
passwordResetTokenExpiresAt: Date.now() + sails.config.custom.passwordResetTokenTTL,
|
||||
});
|
||||
|
||||
// Send recovery email
|
||||
await sails.helpers.sendTemplateEmail.with({
|
||||
to: emailAddress,
|
||||
subject: 'Password reset instructions',
|
||||
template: 'email-reset-password',
|
||||
templateData: {
|
||||
fullName: userRecord.fullName,
|
||||
token: token
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
127
api/controllers/entrance/signup.js
Normal file
127
api/controllers/entrance/signup.js
Normal file
@ -0,0 +1,127 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'Signup',
|
||||
|
||||
|
||||
description: 'Sign up for a new user account.',
|
||||
|
||||
|
||||
extendedDescription:
|
||||
`This creates a new user record in the database, signs in the requesting user agent
|
||||
by modifying its [session](https://sailsjs.com/documentation/concepts/sessions), and
|
||||
(if emailing with Mailgun is enabled) sends an account verification email.
|
||||
|
||||
If a verification email is sent, the new user's account is put in an "unconfirmed" state
|
||||
until they confirm they are using a legitimate email address (by clicking the link in
|
||||
the account verification message.)`,
|
||||
|
||||
|
||||
inputs: {
|
||||
|
||||
emailAddress: {
|
||||
required: true,
|
||||
type: 'string',
|
||||
isEmail: true,
|
||||
description: 'The email address for the new account, e.g. m@example.com.',
|
||||
extendedDescription: 'Must be a valid email address.',
|
||||
},
|
||||
|
||||
password: {
|
||||
required: true,
|
||||
type: 'string',
|
||||
maxLength: 200,
|
||||
example: 'passwordlol',
|
||||
description: 'The unencrypted password to use for the new account.'
|
||||
},
|
||||
|
||||
fullName: {
|
||||
required: true,
|
||||
type: 'string',
|
||||
example: 'Frida Kahlo de Rivera',
|
||||
description: 'The user\'s full name.',
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
success: {
|
||||
description: 'New user account was created successfully.'
|
||||
},
|
||||
|
||||
invalid: {
|
||||
responseType: 'badRequest',
|
||||
description: 'The provided fullName, password and/or email address are invalid.',
|
||||
extendedDescription: 'If this request was sent from a graphical user interface, the request '+
|
||||
'parameters should have been validated/coerced _before_ they were sent.'
|
||||
},
|
||||
|
||||
emailAlreadyInUse: {
|
||||
statusCode: 409,
|
||||
description: 'The provided email address is already in use.',
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function ({emailAddress, password, fullName}) {
|
||||
|
||||
var newEmailAddress = emailAddress.toLowerCase();
|
||||
|
||||
// Build up data for the new user record and save it to the database.
|
||||
// (Also use `fetch` to retrieve the new ID so that we can use it below.)
|
||||
var newUserRecord = await User.create(_.extend({
|
||||
fullName,
|
||||
emailAddress: newEmailAddress,
|
||||
password: await sails.helpers.passwords.hashPassword(password),
|
||||
tosAcceptedByIp: this.req.ip
|
||||
}, sails.config.custom.verifyEmailAddresses? {
|
||||
emailProofToken: await sails.helpers.strings.random('url-friendly'),
|
||||
emailProofTokenExpiresAt: Date.now() + sails.config.custom.emailProofTokenTTL,
|
||||
emailStatus: 'unconfirmed'
|
||||
}:{}))
|
||||
.intercept('E_UNIQUE', 'emailAlreadyInUse')
|
||||
.intercept({name: 'UsageError'}, 'invalid')
|
||||
.fetch();
|
||||
|
||||
// If billing feaures are enabled, save a new customer entry in the Stripe API.
|
||||
// Then persist the Stripe customer id in the database.
|
||||
if (sails.config.custom.enableBillingFeatures) {
|
||||
let stripeCustomerId = await sails.helpers.stripe.saveBillingInfo.with({
|
||||
emailAddress: newEmailAddress
|
||||
}).timeout(5000).retry();
|
||||
await User.updateOne({id: newUserRecord.id})
|
||||
.set({
|
||||
stripeCustomerId
|
||||
});
|
||||
}
|
||||
|
||||
// Store the user's new id in their session.
|
||||
this.req.session.userId = newUserRecord.id;
|
||||
|
||||
// In case there was an existing session (e.g. if we allow users to go to the signup page
|
||||
// when they're already logged in), broadcast a message that we can display in other open tabs.
|
||||
if (sails.hooks.sockets) {
|
||||
await sails.helpers.broadcastSessionChange(this.req);
|
||||
}
|
||||
|
||||
if (sails.config.custom.verifyEmailAddresses) {
|
||||
// Send "confirm account" email
|
||||
await sails.helpers.sendTemplateEmail.with({
|
||||
to: newEmailAddress,
|
||||
subject: 'Please confirm your account',
|
||||
template: 'email-verify-account',
|
||||
templateData: {
|
||||
fullName,
|
||||
token: newUserRecord.emailProofToken
|
||||
}
|
||||
});
|
||||
} else {
|
||||
sails.log.info('Skipping new account email verification... (since `verifyEmailAddresses` is disabled)');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
};
|
80
api/controllers/entrance/update-password-and-login.js
Normal file
80
api/controllers/entrance/update-password-and-login.js
Normal file
@ -0,0 +1,80 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'Update password and login',
|
||||
|
||||
|
||||
description: 'Finish the password recovery flow by setting the new password and '+
|
||||
'logging in the requesting user, based on the authenticity of their token.',
|
||||
|
||||
|
||||
inputs: {
|
||||
|
||||
password: {
|
||||
description: 'The new, unencrypted password.',
|
||||
example: 'abc123v2',
|
||||
required: true
|
||||
},
|
||||
|
||||
token: {
|
||||
description: 'The password token that was generated by the `sendPasswordRecoveryEmail` endpoint.',
|
||||
example: 'gwa8gs8hgw9h2g9hg29hgwh9asdgh9q34$$$$$asdgasdggds',
|
||||
required: true
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
success: {
|
||||
description: 'Password successfully updated, and requesting user agent is now logged in.'
|
||||
},
|
||||
|
||||
invalidToken: {
|
||||
description: 'The provided password token is invalid, expired, or has already been used.',
|
||||
responseType: 'expired'
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function ({password, token}) {
|
||||
|
||||
if(!token) {
|
||||
throw 'invalidToken';
|
||||
}
|
||||
|
||||
// Look up the user with this reset token.
|
||||
var userRecord = await User.findOne({ passwordResetToken: token });
|
||||
|
||||
// If no such user exists, or their token is expired, bail.
|
||||
if (!userRecord || userRecord.passwordResetTokenExpiresAt <= Date.now()) {
|
||||
throw 'invalidToken';
|
||||
}
|
||||
|
||||
// Hash the new password.
|
||||
var hashed = await sails.helpers.passwords.hashPassword(password);
|
||||
|
||||
// Store the user's new password and clear their reset token so it can't be used again.
|
||||
await User.updateOne({ id: userRecord.id })
|
||||
.set({
|
||||
password: hashed,
|
||||
passwordResetToken: '',
|
||||
passwordResetTokenExpiresAt: 0
|
||||
});
|
||||
|
||||
// Log the user in.
|
||||
// (This will be persisted when the response is sent.)
|
||||
this.req.session.userId = userRecord.id;
|
||||
|
||||
// In case there was an existing session, broadcast a message that we can
|
||||
// display in other open tabs.
|
||||
if (sails.hooks.sockets) {
|
||||
await sails.helpers.broadcastSessionChange(this.req);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
27
api/controllers/entrance/view-confirmed-email.js
Normal file
27
api/controllers/entrance/view-confirmed-email.js
Normal file
@ -0,0 +1,27 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'View confirmed email',
|
||||
|
||||
|
||||
description: 'Display "Confirmed email" page.',
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
success: {
|
||||
viewTemplatePath: 'pages/entrance/confirmed-email'
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function () {
|
||||
|
||||
// Respond with view.
|
||||
return {};
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
36
api/controllers/entrance/view-forgot-password.js
Normal file
36
api/controllers/entrance/view-forgot-password.js
Normal file
@ -0,0 +1,36 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'View forgot password',
|
||||
|
||||
|
||||
description: 'Display "Forgot password" page.',
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
success: {
|
||||
viewTemplatePath: 'pages/entrance/forgot-password',
|
||||
},
|
||||
|
||||
redirect: {
|
||||
description: 'The requesting user is already logged in.',
|
||||
extendedDescription: 'Logged-in users should change their password in "Account settings."',
|
||||
responseType: 'redirect',
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function () {
|
||||
|
||||
if (this.req.me) {
|
||||
throw {redirect: '/'};
|
||||
}
|
||||
|
||||
return {};
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
35
api/controllers/entrance/view-login.js
Normal file
35
api/controllers/entrance/view-login.js
Normal file
@ -0,0 +1,35 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'View login',
|
||||
|
||||
|
||||
description: 'Display "Login" page.',
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
success: {
|
||||
viewTemplatePath: 'pages/entrance/login',
|
||||
},
|
||||
|
||||
redirect: {
|
||||
description: 'The requesting user is already logged in.',
|
||||
responseType: 'redirect'
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function () {
|
||||
|
||||
if (this.req.me) {
|
||||
throw {redirect: '/'};
|
||||
}
|
||||
|
||||
return {};
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
57
api/controllers/entrance/view-new-password.js
Normal file
57
api/controllers/entrance/view-new-password.js
Normal file
@ -0,0 +1,57 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'View new password',
|
||||
|
||||
|
||||
description: 'Display "New password" page.',
|
||||
|
||||
|
||||
inputs: {
|
||||
|
||||
token: {
|
||||
description: 'The password reset token from the email.',
|
||||
example: '4-32fad81jdaf$329'
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
success: {
|
||||
viewTemplatePath: 'pages/entrance/new-password'
|
||||
},
|
||||
|
||||
invalidOrExpiredToken: {
|
||||
responseType: 'expired',
|
||||
description: 'The provided token is expired, invalid, or has already been used.',
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function ({token}) {
|
||||
|
||||
// If password reset token is missing, display an error page explaining that the link is bad.
|
||||
if (!token) {
|
||||
sails.log.warn('Attempting to view new password (recovery) page, but no reset password token included in request! Displaying error page...');
|
||||
throw 'invalidOrExpiredToken';
|
||||
}//•
|
||||
|
||||
// Look up the user with this reset token.
|
||||
var userRecord = await User.findOne({ passwordResetToken: token });
|
||||
// If no such user exists, or their token is expired, display an error page explaining that the link is bad.
|
||||
if (!userRecord || userRecord.passwordResetTokenExpiresAt <= Date.now()) {
|
||||
throw 'invalidOrExpiredToken';
|
||||
}
|
||||
|
||||
// Grab token and include it in view locals
|
||||
return {
|
||||
token,
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
35
api/controllers/entrance/view-signup.js
Normal file
35
api/controllers/entrance/view-signup.js
Normal file
@ -0,0 +1,35 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'View signup',
|
||||
|
||||
|
||||
description: 'Display "Signup" page.',
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
success: {
|
||||
viewTemplatePath: 'pages/entrance/signup',
|
||||
},
|
||||
|
||||
redirect: {
|
||||
description: 'The requesting user is already logged in.',
|
||||
responseType: 'redirect'
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function () {
|
||||
|
||||
if (this.req.me) {
|
||||
throw {redirect: '/'};
|
||||
}
|
||||
|
||||
return {};
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
27
api/controllers/legal/view-privacy.js
Normal file
27
api/controllers/legal/view-privacy.js
Normal file
@ -0,0 +1,27 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'View privacy',
|
||||
|
||||
|
||||
description: 'Display "Privacy policy" page.',
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
success: {
|
||||
viewTemplatePath: 'pages/legal/privacy'
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function () {
|
||||
|
||||
// All done.
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
27
api/controllers/legal/view-terms.js
Normal file
27
api/controllers/legal/view-terms.js
Normal file
@ -0,0 +1,27 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'View terms',
|
||||
|
||||
|
||||
description: 'Display "Legal terms" page.',
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
success: {
|
||||
viewTemplatePath: 'pages/legal/terms'
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function () {
|
||||
|
||||
// All done.
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
32
api/controllers/observe-my-session.js
Normal file
32
api/controllers/observe-my-session.js
Normal file
@ -0,0 +1,32 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'Observe my session',
|
||||
|
||||
|
||||
description: 'Subscribe to the logged-in user\'s session so that you receive socket broadcasts when logged out in another tab.',
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
success: {
|
||||
description: 'The requesting socket is now subscribed to socket broadcasts about the logged-in user\'s session.',
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function ({}) {
|
||||
|
||||
if (!this.req.isSocket) {
|
||||
throw new Error('This action is designed for use with the virtual request interpreter (over sockets, not traditional HTTP).');
|
||||
}
|
||||
|
||||
let roomName = `session${_.deburr(this.req.sessionID)}`;
|
||||
sails.sockets.join(this.req, roomName);
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
27
api/controllers/view-contact.js
Normal file
27
api/controllers/view-contact.js
Normal file
@ -0,0 +1,27 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'View contact',
|
||||
|
||||
|
||||
description: 'Display "Contact" page.',
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
success: {
|
||||
viewTemplatePath: 'pages/contact'
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function () {
|
||||
|
||||
// Respond with view.
|
||||
return {};
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
27
api/controllers/view-faq.js
Normal file
27
api/controllers/view-faq.js
Normal file
@ -0,0 +1,27 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'View faq',
|
||||
|
||||
|
||||
description: 'Display "FAQ" page.',
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
success: {
|
||||
viewTemplatePath: 'pages/faq'
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function () {
|
||||
|
||||
// Respond with view.
|
||||
return {};
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
37
api/controllers/view-homepage-or-redirect.js
Normal file
37
api/controllers/view-homepage-or-redirect.js
Normal file
@ -0,0 +1,37 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'View homepage or redirect',
|
||||
|
||||
|
||||
description: 'Display or redirect to the appropriate homepage, depending on login status.',
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
success: {
|
||||
statusCode: 200,
|
||||
description: 'Requesting user is a guest, so show the public landing page.',
|
||||
viewTemplatePath: 'pages/homepage'
|
||||
},
|
||||
|
||||
redirect: {
|
||||
responseType: 'redirect',
|
||||
description: 'Requesting user is logged in, so redirect to the internal welcome page.'
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function () {
|
||||
|
||||
if (this.req.me) {
|
||||
throw {redirect:'/welcome'};
|
||||
}
|
||||
|
||||
return {};
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
45
api/helpers/broadcast-session-change.js
Normal file
45
api/helpers/broadcast-session-change.js
Normal file
@ -0,0 +1,45 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'Broadcast session change',
|
||||
|
||||
|
||||
description: 'Broadcast a socket notification indicating a change in login status.',
|
||||
|
||||
|
||||
inputs: {
|
||||
|
||||
req: {
|
||||
type: 'ref',
|
||||
required: true,
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
success: {
|
||||
description: 'All done.',
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function ({ req }) {
|
||||
|
||||
// If there's no sessionID, we don't need to broadcase a message about the old session.
|
||||
if(!req.sessionID) {
|
||||
return;
|
||||
}
|
||||
|
||||
let roomName = `session${_.deburr(req.sessionID)}`;
|
||||
let messageText = `You have signed out or signed into a different session in another tab or window. Reload the page to refresh your session.`;
|
||||
sails.sockets.broadcast(roomName, 'session', { notificationText: messageText }, req);
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
33
api/helpers/redact-user.js
Normal file
33
api/helpers/redact-user.js
Normal file
@ -0,0 +1,33 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'Redact user',
|
||||
|
||||
|
||||
description: 'Destructively remove properties from the provided User record to prepare it for publication.',
|
||||
|
||||
|
||||
sync: true,
|
||||
|
||||
|
||||
inputs: {
|
||||
|
||||
user: {
|
||||
type: 'ref',
|
||||
readOnly: false
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: function ({ user }) {
|
||||
for (let [attrName, attrDef] of Object.entries(User.attributes)) {
|
||||
if (attrDef.protect) {
|
||||
delete user[attrName];
|
||||
}//fi
|
||||
}//∞
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
282
api/helpers/send-template-email.js
Normal file
282
api/helpers/send-template-email.js
Normal file
@ -0,0 +1,282 @@
|
||||
module.exports = {
|
||||
|
||||
|
||||
friendlyName: 'Send template email',
|
||||
|
||||
|
||||
description: 'Send an email using a template.',
|
||||
|
||||
|
||||
extendedDescription: 'To ease testing and development, if the provided "to" email address ends in "@example.com", '+
|
||||
'then the email message will be written to the terminal instead of actually being sent.'+
|
||||
'(Thanks [@simonratner](https://github.com/simonratner)!)',
|
||||
|
||||
|
||||
inputs: {
|
||||
|
||||
|
||||
template: {
|
||||
description: 'The relative path to an EJS template within our `views/emails/` folder -- WITHOUT the file extension.',
|
||||
extendedDescription: 'Use strings like "foo" or "foo/bar", but NEVER "foo/bar.ejs" or "/foo/bar". For example, '+
|
||||
'"internal/email-contact-form" would send an email using the "views/emails/internal/email-contact-form.ejs" template.',
|
||||
example: 'email-reset-password',
|
||||
type: 'string',
|
||||
required: true
|
||||
},
|
||||
|
||||
templateData: {
|
||||
description: 'A dictionary of data which will be accessible in the EJS template.',
|
||||
extendedDescription: 'Each key will be a local variable accessible in the template. For instance, if you supply '+
|
||||
'a dictionary with a \`friends\` key, and \`friends\` is an array like \`[{name:"Chandra"}, {name:"Mary"}]\`),'+
|
||||
'then you will be able to access \`friends\` from the template:\n'+
|
||||
'\`\`\`\n'+
|
||||
'<ul>\n'+
|
||||
'<% for (friend of friends){ %><li><%= friend.name %></li><% }); %>\n'+
|
||||
'</ul>\n'+
|
||||
'\`\`\`'+
|
||||
'\n'+
|
||||
'This is EJS, so use \`<%= %>\` to inject the HTML-escaped content of a variable, \`<%= %>\` to skip HTML-escaping '+
|
||||
'and inject the data as-is, or \`<% %>\` to execute some JavaScript code such as an \`if\` statement or \`for\` loop.',
|
||||
type: {},
|
||||
defaultsTo: {}
|
||||
},
|
||||
|
||||
to: {
|
||||
description: 'The email address of the primary recipient.',
|
||||
extendedDescription: 'If this is any address ending in "@example.com", then don\'t actually deliver the message. '+
|
||||
'Instead, just log it to the console.',
|
||||
example: 'nola.thacker@example.com',
|
||||
required: true,
|
||||
isEmail: true,
|
||||
},
|
||||
|
||||
toName: {
|
||||
description: 'Name of the primary recipient as displayed in their inbox.',
|
||||
example: 'Nola Thacker',
|
||||
},
|
||||
|
||||
subject: {
|
||||
description: 'The subject of the email.',
|
||||
example: 'Hello there.',
|
||||
defaultsTo: ''
|
||||
},
|
||||
|
||||
from: {
|
||||
description: 'An override for the default "from" email that\'s been configured.',
|
||||
example: 'anne.martin@example.com',
|
||||
isEmail: true,
|
||||
},
|
||||
|
||||
fromName: {
|
||||
description: 'An override for the default "from" name.',
|
||||
example: 'Anne Martin',
|
||||
},
|
||||
|
||||
layout: {
|
||||
description: 'Set to `false` to disable layouts altogether, or provide the path (relative '+
|
||||
'from `views/layouts/`) to an override email layout.',
|
||||
defaultsTo: 'layout-email',
|
||||
custom: (layout)=>layout===false || _.isString(layout)
|
||||
},
|
||||
|
||||
ensureAck: {
|
||||
description: 'Whether to wait for acknowledgement (to hear back) that the email was successfully sent (or at least queued for sending) before returning.',
|
||||
extendedDescription: 'Otherwise by default, this returns immediately and delivers the request to deliver this email in the background.',
|
||||
type: 'boolean',
|
||||
defaultsTo: false
|
||||
},
|
||||
|
||||
bcc: {
|
||||
description: 'The email addresses of recipients secretly copied on the email.',
|
||||
example: ['jahnna.n.malcolm@example.com'],
|
||||
},
|
||||
|
||||
attachments: {
|
||||
description: 'Attachments to include in the email, with the file content encoded as base64.',
|
||||
whereToGet: {
|
||||
description: 'If you have `sails-hook-uploads` installed, you can use `sails.reservoir` to get an attachment into the expected format.',
|
||||
},
|
||||
example: [
|
||||
{
|
||||
contentBytes: 'iVBORw0KGgoAA…',
|
||||
name: 'sails.png',
|
||||
type: 'image/png',
|
||||
}
|
||||
],
|
||||
defaultsTo: [],
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
|
||||
exits: {
|
||||
|
||||
success: {
|
||||
outputFriendlyName: 'Email delivery report',
|
||||
outputDescription: 'A dictionary of information about what went down.',
|
||||
outputType: {
|
||||
loggedInsteadOfSending: 'boolean'
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
|
||||
fn: async function({template, templateData, to, toName, subject, from, fromName, layout, ensureAck, bcc, attachments}) {
|
||||
|
||||
var path = require('path');
|
||||
var url = require('url');
|
||||
var util = require('util');
|
||||
|
||||
|
||||
if (!_.startsWith(path.basename(template), 'email-')) {
|
||||
sails.log.warn(
|
||||
'The "template" that was passed in to `sendTemplateEmail()` does not begin with '+
|
||||
'"email-" -- but by convention, all email template files in `views/emails/` should '+
|
||||
'be namespaced in this way. (This makes it easier to look up email templates by '+
|
||||
'filename; e.g. when using CMD/CTRL+P in Sublime Text.)\n'+
|
||||
'Continuing regardless...'
|
||||
);
|
||||
}
|
||||
|
||||
if (_.startsWith(template, 'views/') || _.startsWith(template, 'emails/')) {
|
||||
throw new Error(
|
||||
'The "template" that was passed in to `sendTemplateEmail()` was prefixed with\n'+
|
||||
'`emails/` or `views/` -- but that part is supposed to be omitted. Instead, please\n'+
|
||||
'just specify the path to the desired email template relative from `views/emails/`.\n'+
|
||||
'For example:\n'+
|
||||
' template: \'email-reset-password\'\n'+
|
||||
'Or:\n'+
|
||||
' template: \'admin/email-contact-form\'\n'+
|
||||
' [?] If you\'re unsure or need advice, see https://sailsjs.com/support'
|
||||
);
|
||||
}//•
|
||||
|
||||
// Determine appropriate email layout and template to use.
|
||||
var emailTemplatePath = path.join('emails/', template);
|
||||
var emailTemplateLayout;
|
||||
if (layout) {
|
||||
emailTemplateLayout = path.relative(path.dirname(emailTemplatePath), path.resolve('layouts/', layout));
|
||||
} else {
|
||||
emailTemplateLayout = false;
|
||||
}
|
||||
|
||||
// Compile HTML template.
|
||||
// > Note that we set the layout, provide access to core `url` package (for
|
||||
// > building links and image srcs, etc.), and also provide access to core
|
||||
// > `util` package (for dumping debug data in internal emails).
|
||||
var htmlEmailContents = await sails.renderView(
|
||||
emailTemplatePath,
|
||||
_.extend({layout: emailTemplateLayout, url, util }, templateData)
|
||||
)
|
||||
.intercept((err)=>{
|
||||
err.message =
|
||||
'Could not compile view template.\n'+
|
||||
'(Usually, this means the provided data is invalid, or missing a piece.)\n'+
|
||||
'Details:\n'+
|
||||
err.message;
|
||||
return err;
|
||||
});
|
||||
|
||||
// Sometimes only log info to the console about the email that WOULD have been sent.
|
||||
// Specifically, if the "To" email address is anything "@example.com".
|
||||
//
|
||||
// > This is used below when determining whether to actually send the email,
|
||||
// > for convenience during development, but also for safety. (For example,
|
||||
// > a special-cased version of "user@example.com" is used by Trend Micro Mars
|
||||
// > scanner to "check apks for malware".)
|
||||
var isToAddressConsideredFake = Boolean(to.match(/@example\.com$/i));
|
||||
|
||||
// If that's the case, or if we're in the "test" environment, then log
|
||||
// the email instead of sending it:
|
||||
var dontActuallySend = (
|
||||
sails.config.environment === 'test' || isToAddressConsideredFake
|
||||
);
|
||||
if (dontActuallySend) {
|
||||
sails.log(
|
||||
'Skipped sending email, either because the "To" email address ended in "@example.com"\n'+
|
||||
'or because the current \`sails.config.environment\` is set to "test".\n'+
|
||||
'\n'+
|
||||
'But anyway, here is what WOULD have been sent:\n'+
|
||||
'-=-=-=-=-=-=-=-=-=-=-=-=-= Email log =-=-=-=-=-=-=-=-=-=-=-=-=-\n'+
|
||||
'To: '+to+'\n'+
|
||||
'Subject: '+subject+'\n'+
|
||||
'\n'+
|
||||
'Body:\n'+
|
||||
htmlEmailContents+'\n'+
|
||||
'-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-'
|
||||
);
|
||||
} else {
|
||||
// Otherwise, we'll check that all required Mailgun credentials are set up
|
||||
// and, if so, continue to actually send the email.
|
||||
|
||||
if (!sails.config.custom.sendgridSecret) {
|
||||
throw new Error(
|
||||
'Cannot deliver email to "'+to+'" because:\n'+
|
||||
(()=>{
|
||||
let problems = [];
|
||||
if (!sails.config.custom.sendgridSecret) {
|
||||
problems.push(' • Sendgrid secret is missing from this app\'s configuration (`sails.config.custom.sendgridSecret`)');
|
||||
}
|
||||
return problems.join('\n');
|
||||
})()+
|
||||
'\n'+
|
||||
'To resolve these configuration issues, add the missing config variables to\n'+
|
||||
'\`config/custom.js\`-- or in staging/production, set them up as system\n'+
|
||||
'environment vars. (If you don\'t have a Sendgrid secret, you can\n'+
|
||||
'sign up for free at https://sendgrid.com to receive credentials.)\n'+
|
||||
'\n'+
|
||||
'> Note that, for convenience during development, there is another alternative:\n'+
|
||||
'> In lieu of setting up real Sendgrid credentials, you can "fake" email\n'+
|
||||
'> delivery by using any email address that ends in "@example.com". This will\n'+
|
||||
'> write automated emails to your logs rather than actually sending them.\n'+
|
||||
'> (To simulate clicking on a link from an email, just copy and paste the link\n'+
|
||||
'> from the terminal output into your browser.)\n'+
|
||||
'\n'+
|
||||
'[?] If you\'re unsure, visit https://sailsjs.com/support'
|
||||
);
|
||||
}
|
||||
|
||||
var subjectLinePrefix = sails.config.environment === 'production' ? '' : sails.config.environment === 'staging' ? '[FROM STAGING] ' : '[FROM LOCALHOST] ';
|
||||
var messageData = {
|
||||
htmlMessage: htmlEmailContents,
|
||||
to: to,
|
||||
toName: toName,
|
||||
bcc: bcc,
|
||||
subject: subjectLinePrefix+subject,
|
||||
from: from,
|
||||
fromName: fromName,
|
||||
attachments
|
||||
};
|
||||
|
||||
var deferred = sails.helpers.sendgrid.sendHtmlEmail.with(messageData);
|
||||
if (ensureAck) {
|
||||
await deferred;
|
||||
} else {
|
||||
// FUTURE: take advantage of .background() here instead (when available)
|
||||
deferred.exec((err)=>{
|
||||
if (err) {
|
||||
sails.log.error(
|
||||
'Background instruction failed: Could not deliver email:\n'+
|
||||
util.inspect({template, templateData, to, toName, subject, from, fromName, layout, ensureAck, bcc, attachments},{depth:null})+'\n',
|
||||
'Error details:\n'+
|
||||
util.inspect(err)
|
||||
);
|
||||
} else {
|
||||
sails.log.info(
|
||||
'Background instruction complete: Email sent via email delivery service (or at least queued):\n'+
|
||||
util.inspect({to, toName, subject, from, fromName, bcc},{depth:null})
|
||||
);
|
||||
}
|
||||
});//_∏_
|
||||
}//fi
|
||||
}//fi
|
||||
|
||||
// All done!
|
||||
return {
|
||||
loggedInsteadOfSending: dontActuallySend,
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
};
|
257
api/hooks/custom/index.js
Normal file
257
api/hooks/custom/index.js
Normal file
@ -0,0 +1,257 @@
|
||||
/**
|
||||
* @description :: The conventional "custom" hook. Extends this app with custom server-start-time and request-time logic.
|
||||
* @docs :: https://sailsjs.com/docs/concepts/extending-sails/hooks
|
||||
*/
|
||||
|
||||
module.exports = function defineCustomHook(sails) {
|
||||
|
||||
return {
|
||||
|
||||
/**
|
||||
* Runs when a Sails app loads/lifts.
|
||||
*/
|
||||
initialize: async function () {
|
||||
|
||||
sails.log.info('Initializing project hook... (`api/hooks/custom/`)');
|
||||
|
||||
// Check Stripe/Sendgrid configuration (for billing and emails).
|
||||
var IMPORTANT_STRIPE_CONFIG = ['stripeSecret', 'stripePublishableKey'];
|
||||
var IMPORTANT_SENDGRID_CONFIG = ['sendgridSecret', 'internalEmailAddress'];
|
||||
var isMissingStripeConfig = _.difference(IMPORTANT_STRIPE_CONFIG, Object.keys(sails.config.custom)).length > 0;
|
||||
var isMissingSendgridConfig = _.difference(IMPORTANT_SENDGRID_CONFIG, Object.keys(sails.config.custom)).length > 0;
|
||||
|
||||
if (isMissingStripeConfig || isMissingSendgridConfig) {
|
||||
|
||||
let missingFeatureText = isMissingStripeConfig && isMissingSendgridConfig ? 'billing and email' : isMissingStripeConfig ? 'billing' : 'email';
|
||||
let suffix = '';
|
||||
if (_.contains(['silly'], sails.config.log.level)) {
|
||||
suffix =
|
||||
`
|
||||
> Tip: To exclude sensitive credentials from source control, use:
|
||||
> • config/local.js (for local development)
|
||||
> • environment variables (for production)
|
||||
>
|
||||
> If you want to check them in to source control, use:
|
||||
> • config/custom.js (for development)
|
||||
> • config/env/staging.js (for staging)
|
||||
> • config/env/production.js (for production)
|
||||
>
|
||||
> (See https://sailsjs.com/docs/concepts/configuration for help configuring Sails.)
|
||||
`;
|
||||
}
|
||||
|
||||
let problems = [];
|
||||
if (sails.config.custom.stripeSecret === undefined) {
|
||||
problems.push('No `sails.config.custom.stripeSecret` was configured.');
|
||||
}
|
||||
if (sails.config.custom.stripePublishableKey === undefined) {
|
||||
problems.push('No `sails.config.custom.stripePublishableKey` was configured.');
|
||||
}
|
||||
if (sails.config.custom.sendgridSecret === undefined) {
|
||||
problems.push('No `sails.config.custom.sendgridSecret` was configured.');
|
||||
}
|
||||
if (sails.config.custom.internalEmailAddress === undefined) {
|
||||
problems.push('No `sails.config.custom.internalEmailAddress` was configured.');
|
||||
}
|
||||
|
||||
sails.log.verbose(
|
||||
`Some optional settings have not been configured yet:
|
||||
---------------------------------------------------------------------
|
||||
${problems.join('\n')}
|
||||
|
||||
Until this is addressed, this app's ${missingFeatureText} features
|
||||
will be disabled and/or hidden in the UI.
|
||||
|
||||
[?] If you're unsure or need advice, come by https://sailsjs.com/support
|
||||
---------------------------------------------------------------------${suffix}`);
|
||||
}//fi
|
||||
|
||||
// Set an additional config keys based on whether Stripe config is available.
|
||||
// This will determine whether or not to enable various billing features.
|
||||
sails.config.custom.enableBillingFeatures = !isMissingStripeConfig;
|
||||
|
||||
// After "sails-hook-organics" finishes initializing, configure Stripe
|
||||
// and Sendgrid packs with any available credentials.
|
||||
sails.after('hook:organics:loaded', ()=>{
|
||||
|
||||
sails.helpers.stripe.configure({
|
||||
secret: sails.config.custom.stripeSecret
|
||||
});
|
||||
|
||||
sails.helpers.sendgrid.configure({
|
||||
secret: sails.config.custom.sendgridSecret,
|
||||
from: sails.config.custom.fromEmailAddress,
|
||||
fromName: sails.config.custom.fromName,
|
||||
});
|
||||
|
||||
});//_∏_
|
||||
|
||||
// ... Any other app-specific setup code that needs to run on lift,
|
||||
// even in production, goes here ...
|
||||
|
||||
},
|
||||
|
||||
|
||||
routes: {
|
||||
|
||||
/**
|
||||
* Runs before every matching route.
|
||||
*
|
||||
* @param {Ref} req
|
||||
* @param {Ref} res
|
||||
* @param {Function} next
|
||||
*/
|
||||
before: {
|
||||
'/*': {
|
||||
skipAssets: true,
|
||||
fn: async function(req, res, next){
|
||||
|
||||
var url = require('url');
|
||||
|
||||
// First, if this is a GET request (and thus potentially a view),
|
||||
// attach a couple of guaranteed locals.
|
||||
if (req.method === 'GET') {
|
||||
|
||||
// The `_environment` local lets us do a little workaround to make Vue.js
|
||||
// run in "production mode" without unnecessarily involving complexities
|
||||
// with webpack et al.)
|
||||
if (res.locals._environment !== undefined) {
|
||||
throw new Error('Cannot attach Sails environment as the view local `_environment`, because this view local already exists! (Is it being attached somewhere else?)');
|
||||
}
|
||||
res.locals._environment = sails.config.environment;
|
||||
|
||||
// The `me` local is set explicitly to `undefined` here just to avoid having to
|
||||
// do `typeof me !== 'undefined'` checks in our views/layouts/partials.
|
||||
// > Note that, depending on the request, this may or may not be set to the
|
||||
// > logged-in user record further below.
|
||||
if (res.locals.me !== undefined) {
|
||||
throw new Error('Cannot attach view local `me`, because this view local already exists! (Is it being attached somewhere else?)');
|
||||
}
|
||||
res.locals.me = undefined;
|
||||
}//fi
|
||||
|
||||
// Next, if we're running in our actual "production" or "staging" Sails
|
||||
// environment, check if this is a GET request via some other host,
|
||||
// for example a subdomain like `webhooks.` or `click.`. If so, we'll
|
||||
// automatically go ahead and redirect to the corresponding path under
|
||||
// our base URL, which is environment-specific.
|
||||
// > Note that we DO NOT redirect virtual socket requests and we DO NOT
|
||||
// > redirect non-GET requests (because it can confuse some 3rd party
|
||||
// > platforms that send webhook requests.) We also DO NOT redirect
|
||||
// > requests in other environments to allow for flexibility during
|
||||
// > development (e.g. so you can preview an app running locally on
|
||||
// > your laptop using a local IP address or a tool like ngrok, in
|
||||
// > case you want to run it on a real, physical mobile/IoT device)
|
||||
var configuredBaseHostname;
|
||||
try {
|
||||
configuredBaseHostname = url.parse(sails.config.custom.baseUrl).host;
|
||||
} catch (unusedErr) { /*…*/}
|
||||
if ((sails.config.environment === 'staging' || sails.config.environment === 'production') && !req.isSocket && req.method === 'GET' && req.hostname !== configuredBaseHostname) {
|
||||
sails.log.info('Redirecting GET request from `'+req.hostname+'` to configured expected host (`'+configuredBaseHostname+'`)...');
|
||||
return res.redirect(sails.config.custom.baseUrl+req.url);
|
||||
}//•
|
||||
|
||||
// Prevent the browser from caching logged-in users' pages.
|
||||
// (including w/ the Chrome back button)
|
||||
// > • https://mixmax.com/blog/chrome-back-button-cache-no-store
|
||||
// > • https://madhatted.com/2013/6/16/you-do-not-understand-browser-history
|
||||
//
|
||||
// This also prevents an issue where webpages may be cached by browsers, and thus
|
||||
// reference an old bundle file (e.g. dist/production.min.js or dist/production.min.css),
|
||||
// which might have a different hash encoded in its filename. This way, by preventing caching
|
||||
// of the webpage itself, the HTML is always fresh, and thus always trying to load the latest,
|
||||
// correct bundle files.
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store');
|
||||
|
||||
// No session? Proceed as usual.
|
||||
// (e.g. request for a static asset)
|
||||
if (!req.session) { return next(); }
|
||||
|
||||
// Not logged in? Proceed as usual.
|
||||
if (!req.session.userId) { return next(); }
|
||||
|
||||
// Otherwise, look up the logged-in user.
|
||||
var loggedInUser = await User.findOne({
|
||||
id: req.session.userId
|
||||
});
|
||||
|
||||
// If the logged-in user has gone missing, log a warning,
|
||||
// wipe the user id from the requesting user agent's session,
|
||||
// and then send the "unauthorized" response.
|
||||
if (!loggedInUser) {
|
||||
sails.log.warn('Somehow, the user record for the logged-in user (`'+req.session.userId+'`) has gone missing....');
|
||||
delete req.session.userId;
|
||||
return res.unauthorized();
|
||||
}
|
||||
|
||||
// Add additional information for convenience when building top-level navigation.
|
||||
// (i.e. whether to display "Dashboard", "My Account", etc.)
|
||||
if (!loggedInUser.password || loggedInUser.emailStatus === 'unconfirmed') {
|
||||
loggedInUser.dontDisplayAccountLinkInNav = true;
|
||||
}
|
||||
|
||||
// Expose the user record as an extra property on the request object (`req.me`).
|
||||
// > Note that we make sure `req.me` doesn't already exist first.
|
||||
if (req.me !== undefined) {
|
||||
throw new Error('Cannot attach logged-in user as `req.me` because this property already exists! (Is it being attached somewhere else?)');
|
||||
}
|
||||
req.me = loggedInUser;
|
||||
|
||||
// If our "lastSeenAt" attribute for this user is at least a few seconds old, then set it
|
||||
// to the current timestamp.
|
||||
//
|
||||
// (Note: As an optimization, this is run behind the scenes to avoid adding needless latency.)
|
||||
var MS_TO_BUFFER = 60*1000;
|
||||
var now = Date.now();
|
||||
if (loggedInUser.lastSeenAt < now - MS_TO_BUFFER) {
|
||||
User.updateOne({id: loggedInUser.id})
|
||||
.set({ lastSeenAt: now })
|
||||
.exec((err)=>{
|
||||
if (err) {
|
||||
sails.log.error('Background task failed: Could not update user (`'+loggedInUser.id+'`) with a new `lastSeenAt` timestamp. Error details: '+err.stack);
|
||||
return;
|
||||
}//•
|
||||
sails.log.verbose('Updated the `lastSeenAt` timestamp for user `'+loggedInUser.id+'`.');
|
||||
// Nothing else to do here.
|
||||
});//_∏_ (Meanwhile...)
|
||||
}//fi
|
||||
|
||||
|
||||
// If this is a GET request, then also expose an extra view local (`<%= me %>`).
|
||||
// > Note that we make sure a local named `me` doesn't already exist first.
|
||||
// > Also note that we strip off any properties that correspond with protected attributes.
|
||||
if (req.method === 'GET') {
|
||||
if (res.locals.me !== undefined) {
|
||||
throw new Error('Cannot attach logged-in user as the view local `me`, because this view local already exists! (Is it being attached somewhere else?)');
|
||||
}
|
||||
|
||||
// Exclude any fields corresponding with attributes that have `protect: true`.
|
||||
var sanitizedUser = _.extend({}, loggedInUser);
|
||||
sails.helpers.redactUser(sanitizedUser);
|
||||
|
||||
// If there is still a "password" in sanitized user data, then delete it just to be safe.
|
||||
// (But also log a warning so this isn't hopelessly confusing.)
|
||||
if (sanitizedUser.password) {
|
||||
sails.log.warn('The logged in user record has a `password` property, but it was still there after pruning off all properties that match `protect: true` attributes in the User model. So, just to be safe, removing the `password` property anyway...');
|
||||
delete sanitizedUser.password;
|
||||
}//fi
|
||||
|
||||
res.locals.me = sanitizedUser;
|
||||
|
||||
// Include information on the locals as to whether billing features
|
||||
// are enabled for this app, and whether email verification is required.
|
||||
res.locals.isBillingEnabled = sails.config.custom.enableBillingFeatures;
|
||||
res.locals.isEmailVerificationRequired = sails.config.custom.verifyEmailAddresses;
|
||||
|
||||
}//fi
|
||||
|
||||
return next();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
};
|
171
api/models/User.js
Normal file
171
api/models/User.js
Normal file
@ -0,0 +1,171 @@
|
||||
/**
|
||||
* User.js
|
||||
*
|
||||
* A user who can log in to this application.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
|
||||
attributes: {
|
||||
|
||||
// ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗
|
||||
// ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
|
||||
// ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝
|
||||
|
||||
emailAddress: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
unique: true,
|
||||
isEmail: true,
|
||||
maxLength: 200,
|
||||
example: 'mary.sue@example.com'
|
||||
},
|
||||
|
||||
emailStatus: {
|
||||
type: 'string',
|
||||
isIn: ['unconfirmed', 'change-requested', 'confirmed'],
|
||||
defaultsTo: 'confirmed',
|
||||
description: 'The confirmation status of the user\'s email address.',
|
||||
extendedDescription:
|
||||
`Users might be created as "unconfirmed" (e.g. normal signup) or as "confirmed" (e.g. hard-coded
|
||||
admin users). When the email verification feature is enabled, new users created via the
|
||||
signup form have \`emailStatus: 'unconfirmed'\` until they click the link in the confirmation email.
|
||||
Similarly, when an existing user changes their email address, they switch to the "change-requested"
|
||||
email status until they click the link in the confirmation email.`
|
||||
},
|
||||
|
||||
emailChangeCandidate: {
|
||||
type: 'string',
|
||||
isEmail: true,
|
||||
description: 'A still-unconfirmed email address that this user wants to change to (if relevant).'
|
||||
},
|
||||
|
||||
password: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Securely hashed representation of the user\'s login password.',
|
||||
protect: true,
|
||||
example: '2$28a8eabna301089103-13948134nad'
|
||||
},
|
||||
|
||||
fullName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Full representation of the user\'s name.',
|
||||
maxLength: 120,
|
||||
example: 'Mary Sue van der McHenst'
|
||||
},
|
||||
|
||||
isSuperAdmin: {
|
||||
type: 'boolean',
|
||||
description: 'Whether this user is a "super admin" with extra permissions, etc.',
|
||||
extendedDescription:
|
||||
`Super admins might have extra permissions, see a different default home page when they log in,
|
||||
or even have a completely different feature set from normal users. In this app, the \`isSuperAdmin\`
|
||||
flag is just here as a simple way to represent two different kinds of users. Usually, it's a good idea
|
||||
to keep the data model as simple as possible, only adding attributes when you actually need them for
|
||||
features being built right now.
|
||||
|
||||
For example, a "super admin" user for a small to medium-sized e-commerce website might be able to
|
||||
change prices, deactivate seasonal categories, add new offerings, and view live orders as they come in.
|
||||
On the other hand, for an e-commerce website like Walmart.com that has undergone years of development
|
||||
by a large team, those administrative features might be split across a few different roles.
|
||||
|
||||
So, while this \`isSuperAdmin\` demarcation might not be the right approach forever, it's a good place to start.`
|
||||
},
|
||||
|
||||
passwordResetToken: {
|
||||
type: 'string',
|
||||
description: 'A unique token used to verify the user\'s identity when recovering a password. Expires after 1 use, or after a set amount of time has elapsed.'
|
||||
},
|
||||
|
||||
passwordResetTokenExpiresAt: {
|
||||
type: 'number',
|
||||
description: 'A JS timestamp (epoch ms) representing the moment when this user\'s `passwordResetToken` will expire (or 0 if the user currently has no such token).',
|
||||
example: 1502844074211
|
||||
},
|
||||
|
||||
emailProofToken: {
|
||||
type: 'string',
|
||||
description: 'A pseudorandom, probabilistically-unique token for use in our account verification emails.'
|
||||
},
|
||||
|
||||
emailProofTokenExpiresAt: {
|
||||
type: 'number',
|
||||
description: 'A JS timestamp (epoch ms) representing the moment when this user\'s `emailProofToken` will expire (or 0 if the user currently has no such token).',
|
||||
example: 1502844074211
|
||||
},
|
||||
|
||||
stripeCustomerId: {
|
||||
type: 'string',
|
||||
protect: true,
|
||||
description: 'The id of the customer entry in Stripe associated with this user (or empty string if this user is not linked to a Stripe customer -- e.g. if billing features are not enabled).',
|
||||
extendedDescription:
|
||||
`Just because this value is set doesn't necessarily mean that this user has a billing card.
|
||||
It just means they have a customer entry in Stripe, which might or might not have a billing card.`
|
||||
},
|
||||
|
||||
hasBillingCard: {
|
||||
type: 'boolean',
|
||||
description: 'Whether this user has a default billing card hooked up as their payment method.',
|
||||
extendedDescription:
|
||||
`More specifically, this indcates whether this user record's linked customer entry in Stripe has
|
||||
a default payment source (i.e. credit card). Note that a user have a \`stripeCustomerId\`
|
||||
without necessarily having a billing card.`
|
||||
},
|
||||
|
||||
billingCardBrand: {
|
||||
type: 'string',
|
||||
example: 'Visa',
|
||||
description: 'The brand of this user\'s default billing card (or empty string if no billing card is set up).',
|
||||
extendedDescription: 'To ensure PCI compliance, this data comes from Stripe, where it reflects the user\'s default payment source.'
|
||||
},
|
||||
|
||||
billingCardLast4: {
|
||||
type: 'string',
|
||||
example: '4242',
|
||||
description: 'The last four digits of the card number for this user\'s default billing card (or empty string if no billing card is set up).',
|
||||
extendedDescription: 'To ensure PCI compliance, this data comes from Stripe, where it reflects the user\'s default payment source.'
|
||||
},
|
||||
|
||||
billingCardExpMonth: {
|
||||
type: 'string',
|
||||
example: '08',
|
||||
description: 'The two-digit expiration month from this user\'s default billing card, formatted as MM (or empty string if no billing card is set up).',
|
||||
extendedDescription: 'To ensure PCI compliance, this data comes from Stripe, where it reflects the user\'s default payment source.'
|
||||
},
|
||||
|
||||
billingCardExpYear: {
|
||||
type: 'string',
|
||||
example: '2023',
|
||||
description: 'The four-digit expiration year from this user\'s default billing card, formatted as YYYY (or empty string if no credit card is set up).',
|
||||
extendedDescription: 'To ensure PCI compliance, this data comes from Stripe, where it reflects the user\'s default payment source.'
|
||||
},
|
||||
|
||||
tosAcceptedByIp: {
|
||||
type: 'string',
|
||||
description: 'The IP (ipv4) address of the request that accepted the terms of service.',
|
||||
extendedDescription: 'Useful for certain types of businesses and regulatory requirements (KYC, etc.)',
|
||||
moreInfoUrl: 'https://en.wikipedia.org/wiki/Know_your_customer'
|
||||
},
|
||||
|
||||
lastSeenAt: {
|
||||
type: 'number',
|
||||
description: 'A JS timestamp (epoch ms) representing the moment at which this user most recently interacted with the backend while logged in (or 0 if they have not interacted with the backend at all yet).',
|
||||
example: 1502844074211
|
||||
},
|
||||
|
||||
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
|
||||
// ║╣ ║║║╠╩╗║╣ ║║╚═╗
|
||||
// ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝
|
||||
// n/a
|
||||
|
||||
// ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
|
||||
// ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗
|
||||
// ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝
|
||||
// n/a
|
||||
|
||||
},
|
||||
|
||||
|
||||
};
|
26
api/policies/is-logged-in.js
Normal file
26
api/policies/is-logged-in.js
Normal file
@ -0,0 +1,26 @@
|
||||
/**
|
||||
* is-logged-in
|
||||
*
|
||||
* A simple policy that allows any request from an authenticated user.
|
||||
*
|
||||
* For more about how to use policies, see:
|
||||
* https://sailsjs.com/config/policies
|
||||
* https://sailsjs.com/docs/concepts/policies
|
||||
* https://sailsjs.com/docs/concepts/policies/access-control-and-permissions
|
||||
*/
|
||||
module.exports = async function (req, res, proceed) {
|
||||
|
||||
// If `req.me` is set, then we know that this request originated
|
||||
// from a logged-in user. So we can safely proceed to the next policy--
|
||||
// or, if this is the last policy, the relevant action.
|
||||
// > For more about where `req.me` comes from, check out this app's
|
||||
// > custom hook (`api/hooks/custom/index.js`).
|
||||
if (req.me) {
|
||||
return proceed();
|
||||
}
|
||||
|
||||
//--•
|
||||
// Otherwise, this request did not come from a logged-in user.
|
||||
return res.unauthorized();
|
||||
|
||||
};
|
28
api/policies/is-super-admin.js
Normal file
28
api/policies/is-super-admin.js
Normal file
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* is-super-admin
|
||||
*
|
||||
* A simple policy that blocks requests from non-super-admins.
|
||||
*
|
||||
* For more about how to use policies, see:
|
||||
* https://sailsjs.com/config/policies
|
||||
* https://sailsjs.com/docs/concepts/policies
|
||||
* https://sailsjs.com/docs/concepts/policies/access-control-and-permissions
|
||||
*/
|
||||
module.exports = async function (req, res, proceed) {
|
||||
|
||||
// First, check whether the request comes from a logged-in user.
|
||||
// > For more about where `req.me` comes from, check out this app's
|
||||
// > custom hook (`api/hooks/custom/index.js`).
|
||||
if (!req.me) {
|
||||
return res.unauthorized();
|
||||
}//•
|
||||
|
||||
// Then check that this user is a "super admin".
|
||||
if (!req.me.isSuperAdmin) {
|
||||
return res.forbidden();
|
||||
}//•
|
||||
|
||||
// IWMIH, we've got ourselves a "super admin".
|
||||
return proceed();
|
||||
|
||||
};
|
37
api/responses/expired.js
Normal file
37
api/responses/expired.js
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* expired.js
|
||||
*
|
||||
* A custom response that content-negotiates the current request to either:
|
||||
* • serve an HTML error page about the specified token being invalid or expired
|
||||
* • or send back 498 (Token Expired/Invalid) with no response body.
|
||||
*
|
||||
* Example usage:
|
||||
* ```
|
||||
* return res.expired();
|
||||
* ```
|
||||
*
|
||||
* Or with actions2:
|
||||
* ```
|
||||
* exits: {
|
||||
* badToken: {
|
||||
* description: 'Provided token was expired, invalid, or already used up.',
|
||||
* responseType: 'expired'
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
module.exports = function expired() {
|
||||
|
||||
var req = this.req;
|
||||
var res = this.res;
|
||||
|
||||
sails.log.verbose('Ran custom response: res.expired()');
|
||||
|
||||
if (req.wantsJSON) {
|
||||
return res.status(498).send('Token Expired/Invalid');
|
||||
}
|
||||
else {
|
||||
return res.status(498).view('498');
|
||||
}
|
||||
|
||||
};
|
43
api/responses/unauthorized.js
Normal file
43
api/responses/unauthorized.js
Normal file
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* unauthorized.js
|
||||
*
|
||||
* A custom response that content-negotiates the current request to either:
|
||||
* • log out the current user and redirect them to the login page
|
||||
* • or send back 401 (Unauthorized) with no response body.
|
||||
*
|
||||
* Example usage:
|
||||
* ```
|
||||
* return res.unauthorized();
|
||||
* ```
|
||||
*
|
||||
* Or with actions2:
|
||||
* ```
|
||||
* exits: {
|
||||
* badCombo: {
|
||||
* description: 'That email address and password combination is not recognized.',
|
||||
* responseType: 'unauthorized'
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
module.exports = function unauthorized() {
|
||||
|
||||
var req = this.req;
|
||||
var res = this.res;
|
||||
|
||||
sails.log.verbose('Ran custom response: res.unauthorized()');
|
||||
|
||||
if (req.wantsJSON) {
|
||||
return res.sendStatus(401);
|
||||
}
|
||||
// Or log them out (if necessary) and then redirect to the login page.
|
||||
else {
|
||||
|
||||
if (req.session.userId) {
|
||||
delete req.session.userId;
|
||||
}
|
||||
|
||||
return res.redirect('/login');
|
||||
}
|
||||
|
||||
};
|
Reference in New Issue
Block a user