fleet/website/api/hooks/custom/index.js
Eric 0693041f13
Website: Update attribution cookie (#40532)
Closes: https://github.com/fleetdm/confidential/issues/14391

Changes:
- Updated the attribution cookie set in the website's custom hook to
include the value of a user's `gclid` query parameter (if it is set)
- Updated the updateOrCreateContactAndAccount helper to set this value
on created Contact records.
2026-02-25 15:18:14 -06:00

435 lines
24 KiB
JavaScript
Vendored
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @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…
sails.after('hook:organics:loaded', ()=>{
// Configure Stripe and Sendgrid packs with any available credentials.
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,
});
// Validate all values in the githubRepoDRIByPath config variable.
if(sails.config.custom.githubRepoDRIByPath) {
if(!_.isObject(sails.config.custom.githubRepoDRIByPath)) {
throw new Error(`Invalid configuration! An invalid "sails.config.custom.githubRepoDRIByPath" value was provided. If set, this value should be a dictionary, where each key is a path in the GitHub repo, and each value is a GitHub username. Please change this value to be a dictionary and try running this script again.`);
}
for(let path in sails.config.custom.githubRepoDRIByPath) {
if(typeof sails.config.custom.githubRepoDRIByPath[path] !== 'string') {
throw new Error(`Invalid configuration! A path (${path}) in the "sails.config.custom.githubRepoDRIByPath" config value contains a DRI value that is not a string (type: ${typeof sails.config.custom.githubRepoDRIByPath[path]}). Please change the DRI for this path to be a string containing a single GitHub username and try running this script again.`);
}
}
}
// Send a request to our Algolia crawler to reindex the website.
// FUTURE: If this breaks again, use the Platform model to store when the website was last crawled
// (platform.algoliaLastCrawledWebsiteAt), and then only send a request if it was <30m ago, then remove dyno check.
if(sails.config.environment === 'production' && process.env.DYNO === 'web.1'){
sails.helpers.http.post.with({
url: `https://crawler.algolia.com/api/1/crawlers/${sails.config.custom.algoliaCrawlerId}/reindex`,
headers: { 'Authorization': sails.config.custom.algoliaCrawlerApiToken}
}).exec((err)=>{
if(err){
sails.log.warn('When trying to send a request to Algolia to refresh the Fleet website search index, an error occurred: '+err);
}
});//_∏_
}//fi
// Expose `ƒ`, for convenience.
global.ƒ = {};
global.ƒ[require('util').inspect.custom] = (unusedDepth, unusedInspectOptions, unusedInspect)=>{// https://nodejs.org/api/util.html#utilinspectcustom
return `[ƒ (derived from sails.helpers)]`;
};//ƒ
for (let keyName of Object.keys(sails.helpers)) {
if (['mailgun'].includes(keyName)) {
continue;
}//•
if (_.isFunction(sails.helpers[keyName])) {
if (global.ƒ.hasOwnProperty(keyName)) {
sails.log.warn(`Overwriting ƒ.${keyName}`);
}
global.ƒ[keyName] = sails.helpers[keyName];
} else {
for (let helperMethodName of Object.keys(sails.helpers[keyName])) {
if (global.ƒ.hasOwnProperty(helperMethodName)) {
sails.log.warn(`Overwriting ƒ.${helperMethodName}`);
}
global.ƒ[helperMethodName] = sails.helpers[keyName][helperMethodName];
}//∞
}
}//∞
});//_∏_
// ... 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) or a HEAD request,
// attach a couple of guaranteed locals.
if (req.method === 'GET' || req.method === 'HEAD') {
// 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;
// Set security headers for all GET and HEAD requests.
res.setHeader(`X-Content-Type-Options`, `nosniff`);//[?]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Content-Type-Options
res.setHeader('X-Frame-Options', 'SAMEORIGIN');// [?]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Frame-Options
res.setHeader(`Referrer-Policy`, `strict-origin-when-cross-origin`);// [?]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains;');// [?]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Strict-Transport-Security
res.setHeader(`Permissions-Policy`, `camera=(), microphone=(), usb=()`);// [?]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy
}//fi
// Check for query parameters set by ad clicks.
// This is used to track the reason behind a psychological stage change.
// If the user performs any action that causes a stage change
// within 24 hour of visiting the website from an ad, their psychological
// stage change will be attributed to the ad campaign that brought them here.
if(req.param('utm_source') && req.param('creative_id') && req.param('campaign_id')){
req.session.adAttributionString = `${req.param('utm_source')} ads - ${req.param('campaign_id')} - ${_.trim(req.param('creative_id'), '?')}`;// Trim questionmarks from the end of creative_id parameters.
// Example adAttributionString: Linkedin - 1245983829 - 41u3985237
req.session.visitedSiteFromAdAt = Date.now();
}
// If a user does not have a marketingAttriution cookie set, check for UTM parameters
if(!req.cookies.marketingAttribution) {
let marketingAttributionCookieInformation = {
source: req.param('utm_source'),// will be undefined if this is not set
medium: req.param('utm_medium'),// will be undefined if this is not set
campaign: req.param('utm_campaign'),// will be undefined if this is not set
gclid: req.param('gclid'),// will be undefined if this is not set
referrer: req.get('referer'),
initialUrl: req.url,
};
// Add the information to a new cookie for this user that expires 30 days from when it is set.
res.cookie('marketingAttribution', marketingAttributionCookieInformation, {maxAge: (1000 * 60 * 60 * 24 * 30)});
}
// FUTURE: Remove this code used for testing
if(req.param('clearAttributionCookie')){
res.clearCookie('marketingAttribution');
}
// Check for website personalization parameter, and if valid, absorb it in the session.
// (This makes the experience simpler and less confusing for people, prioritizing showing things that matter for them)
// [?] https://en.wikipedia.org/wiki/UTM_parameters
// e.g.
// https://fleetdm.com/device-management?utm_content=it-major-mdm
if (['clear', 'security-misc', 'security-vm', 'it-major-mdm', 'it-gap-filler-mdm', 'it-misc'].includes(req.param('utm_content'))) {
req.session.primaryBuyingSituation = req.param('utm_content') === 'clear' ? undefined : req.param('utm_content');
// FUTURE: reimplement the following (auto-redirect without querystring to make it prettier in the URL bar), but do it in the client-side JS
// using whatever that poppushstateblah thing is that makes it so you can change the URL bar from the browser-side code without screwing up
// the history stack (i.e. back button)
// ```
// return res.redirect(req.path);
// ```
}//fi
if (req.method === 'GET' || req.method === 'HEAD') {
// Include information about the primary buying situation for use in the HTML layout, views, and page scripts.
res.locals.primaryBuyingSituation = req.session.primaryBuyingSituation || 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? Set local variables for the start flow CTA.
if (!req.session.userId) {
res.locals.showStartCta = true;
res.locals.collapseStartCta = true;
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;
// Create a timestamp of thirty seconds ago. We'll use this to check the age of the user account before creating a fleetwebsite page view record in Salesforce.
let thirtySecondsAgoAt = Date.now() - (1000 * 30);
// Start tracking a website page view in the CRM for logged-in users:
res.once('finish', function onceFinish() {
// Only track a page view if the requested URL is not a redirect and if this user record is over 30 seconds old (To give time for the background task queued by the signup action to create the initial contact record.
if(res.statusCode === 200 && sanitizedUser.createdAt < thirtySecondsAgoAt){
sails.helpers.flow.build(async ()=>{
if(sails.config.environment !== 'production') {
sails.log.verbose('Skipping Salesforce integration...');
return;
}
let attributionCookieOrUndefined = req.cookies.marketingAttribution;// Will be undefined if this is not set.
let recordIds = await sails.helpers.salesforce.updateOrCreateContactAndAccount.with({
emailAddress: sanitizedUser.emailAddress,
firstName: sanitizedUser.firstName,
lastName: sanitizedUser.lastName,
contactSource: 'Website - Sign up',// Note: this is only set on new contacts.
marketingAttributionCookie: attributionCookieOrUndefined,
});
let websiteVisitReason;
if(req.session.adAttributionString && req.session.visitedSiteFromAdAt) {
let thirtyMinutesAgoAt = Date.now() - (1000 * 60 * 30);
// If this user visited the website from an ad, set the websiteVisitReason to be the adAttributionString stored in their session.
if(req.session.visitedSiteFromAdAt > thirtyMinutesAgoAt) {
websiteVisitReason = this.req.session.adAttributionString;
}
}
// Create the new Fleet website page view record.
await sails.helpers.salesforce.createHistoricalEvent.with({
salesforceContactId: recordIds.salesforceContactId,
salesforceAccountId: recordIds.salesforceAccountId,
fleetWebsitePageUrl: `https://fleetdm.com${req.url}`,
eventType: 'Website page view',
websiteVisitReason: websiteVisitReason
}).intercept((err)=>{
return new Error(`Could not create new Fleet website page view record. Error: ${err}`);
});
})
.exec((err)=>{
if(err && typeof err.errorCode !== 'undefined' && err.errorCode === 'DUPLICATES_DETECTED') {
// Swallow errors related to duplicate records.
sails.log.verbose(`Background task failed: When a logged-in user (email: ${sanitizedUser.emailAddress} visited a page, a Contact/Account/website activity record could not be created/updated in the CRM.`, require('util').inspect(err));
} else if(err){
sails.log.warn(`Background task failed: When a logged-in user (email: ${sanitizedUser.emailAddress} visited a page, a Contact/Account/website activity record could not be created/updated in the CRM.`, require('util').inspect(err));
}
return;
});//_∏_
}
});
// 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;
// Include information about the primary buying situation
// If set in the session (e.g. from an ad), use the primary buying situation for personalization.
res.locals.primaryBuyingSituation = req.session.primaryBuyingSituation || undefined;
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * //
// FUTURE: Only show this CTA to users who are below psyStage 6.
// > The code below is so we don't bother users who have completed the questionnaire
// Show this logged-in user a CTA to bring them to the /start questionnaire if they do not have billing information saved.
res.locals.showStartCta = !req.me.hasBillingCard;
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * //
// If an expandCtaAt timestamp is set in the user's sesssion, check the value to see if we should expand the CTA.
if(req.session.expandCtaAt && req.session.expandCtaAt > Date.now()) {
res.locals.collapseStartCta = true;
} else {
res.locals.collapseStartCta = false;
}
}//fi
return next();
}
}
}
}
};
};