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