Initial project structure with sails.js

This commit is contained in:
2023-11-21 21:57:32 -05:00
commit 523978e520
197 changed files with 76740 additions and 0 deletions

View 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'};
}
}
};

View 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 : ''
});
}
};

View 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
});
}
};

View 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
}
});
}
}
};

View 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,
};
}
};

View 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 {};
}
};

View 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 {};
}
};

View 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 {};
}
};

View 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,
}
});
}
};

View 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.)`);
}
}
};

View 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);
}
}
};

View 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
}
});
}
};

View 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)');
}
}
};

View 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);
}
}
};

View 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 {};
}
};

View 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 {};
}
};

View 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 {};
}
};

View 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,
};
}
};

View 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 {};
}
};

View 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;
}
};

View 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;
}
};

View 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);
}
};

View 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 {};
}
};

View 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 {};
}
};

View 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 {};
}
};

View 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);
}
};

View 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
}//∞
}
};

View 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
View 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
View 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
},
};

View 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();
};

View 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
View 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');
}
};

View 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');
}
};