-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* DS-917: Deploy webhook with delegation.
- Loading branch information
Showing
5 changed files
with
377 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
# ENVIRONMENT VARIABLES | ||
|
||
Environment variables are contextually used in Netlify. See [this documentation](https://www.npmjs.com/package/netlify-plugin-contextual-env) for information about using the prefix naming convention. You can use this strategy in this project. | ||
|
||
For example: | ||
|
||
Default value: `MY_TOKEN` | ||
Production value: `PRODUCTION_MY_TOKEN` | ||
Preview value: `DEPLOY_PREVIEW_MY_TOKEN` | ||
Dev branch value: `DEV_MY_TOKEN` | ||
|
||
## VAULT_ROLE_ID | ||
*Required* | ||
example: `a1bg13234-1234-ab12-a1b2-1a2d5y9s6g5s` | ||
|
||
This is the role ID that is used to fetch the variables from vault.stanford.edu on builds or when running `npm run vault` | ||
|
||
## VAULT_SECRET_ID | ||
*Required* | ||
example: `a1bg13234-1234-ab12-a1b2-1a2d5y9s6g5s` | ||
|
||
This is the secret ID that is used to fetch the variables from vault.stanford.edu on builds or when running `npm run vault` | ||
|
||
## VAULT_OVERWRITE | ||
*Optional* | ||
*Values:* true | false | ||
|
||
When pulling values from vault, set to true to overwrite the values you have in your `.env` file and set to false to only add new items. | ||
|
||
## LOCAL_STORYBLOK_ACCESS_TOKEN | ||
*Optional* | ||
example: `a456d8asd6f8asdfas5afs64` | ||
|
||
An optional override variable for local development. When running `npm run vault` this value replaces the value in `STORYBLOK_ACCESS_TOKEN`. You should set this to a `preview` token for the Storyblok Space. | ||
|
||
## NEXT_PUBLIC_STORYBLOK_ACCESS_TOKEN | ||
*Required* | ||
example: `a456d8asd6f8asdfas5afs64` | ||
|
||
This is a public token that gets compiled into the front end build. This should be a `public` scoped token of the Storyblok space. | ||
|
||
## PRODUCTION_STORYBLOK_ACCESS_TOKEN | ||
*Required* | ||
example: `a456d8asd6f8asdfas5afs64` | ||
|
||
This token is used in production Netlify environment builds. This should be set to your production Storyblok Space and ideally using a `public` scoped token to ensure no draft content ever makes it to production builds. | ||
|
||
## STORYBLOK_ACCESS_TOKEN | ||
*Required* | ||
example: `a456d8asd6f8asdfas5afs64` | ||
|
||
This is the default Storyblok API access token. This can be set as a `preview` scoped token and is used in all non-production environments. | ||
|
||
## STORYBLOK_PREVIEW_EDITOR_TOKEN | ||
*Required* | ||
example: `a456d8asd6f8asdfas5afs64` | ||
|
||
This is the preview editor token and is used to authenticate the Storyblok visual editor. This token needs to match the token in the Storyblok visual editor token in the url parameter. You can manage the visual editor urls in Storyblok and change this token. It must be scoped to a `preview` token in order to work with draft content in the visual editor. | ||
|
||
## STORYBLOK_WEBHOOK_SECRET | ||
*Required* | ||
example: `oEk69ba5EBQHtX2` | ||
|
||
This secret is used to validate the webhook calls from Storyblok. At the time of writing this documentation it is used in the background function that delegates builds. | ||
|
||
## NETLIFY_DEPLOY_HOOK_ID | ||
*Required* | ||
example: `7896as5fa5sdf8as9df4as7a` | ||
|
||
This is the webhook id of a netlify build webhook for the branch/environment to build. You can find it on the end of the URL `https://api.netlify.com/build_hooks/{ID HERE}` and is created in the deploy configuration in Netlify. | ||
|
||
## SLACK_WEBHOOK | ||
*Optional* | ||
example: `https://hooks.slack.com/services/AB6A8DF7F/B9D89SD8D/0AD89S8F97SD8F7S8FS8DF7A` | ||
|
||
The Slack channel webhook to post to for build notifications. | ||
|
||
## STORYBLOK_SLUG_PREFIX | ||
*Optional* | ||
default: `tour` | ||
|
||
The `slug` of the content directory in the Storyblok space. No trailing space. This is used to strip and append the slug in the application where needed. The slug will be stripped when creating links across the front end of the website and is added when fetching content from the Storyblok API. | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,254 @@ | ||
import dotenv from 'dotenv'; | ||
import { type Config } from '@netlify/functions'; | ||
import { createHmac } from 'node:crypto'; | ||
import StoryblokClient, { ISbStoryParams } from 'storyblok-js-client'; | ||
|
||
// LOAD ENV VARIABLES. | ||
// --------------------------------------------------- | ||
dotenv.config(); | ||
|
||
// NETLIFY FUNCTION CONFIG. | ||
// --------------------------------------------------- | ||
export const config: Config = { | ||
path: '/webhook/storyblok', | ||
}; | ||
|
||
// HELPER FUNCTIONS. | ||
// --------------------------------------------------- | ||
|
||
/** | ||
* Trigger a Netlify build using a build hook. | ||
* | ||
* @param hookID The id of the hook. | ||
* @throws Error if the build could not be triggered. | ||
* @returns void | ||
*/ | ||
const triggerNetlifyBuild = async (hookID) => { | ||
console.log('Triggering deploy for:', hookID); | ||
const deployUrl = `https://api.netlify.com/build_hooks/${hookID}`; | ||
const res = await fetch(deployUrl, { | ||
method: 'POST', | ||
body: JSON.stringify({}), | ||
}); | ||
|
||
if (!res.ok) { | ||
throw new Error('Failed to trigger deploy'); | ||
} | ||
|
||
console.log('Deploy triggered for:', hookID); | ||
} | ||
|
||
/** | ||
* Pings a Slack webhook with a message. | ||
* | ||
* @param message the message | ||
* @returns void | ||
* @throws Error if the Slack webhook could not be pinged. | ||
*/ | ||
const pingSlack = async (message: string) => { | ||
const slackWebhook = process.env.SLACK_WEBHOOK ?? ''; | ||
const URL = process.env.DEPLOY_PRIME_URL || process.env.URL; | ||
const ENV = process.env.CONTEXT || process.env.SITE_NAME; | ||
|
||
if (!slackWebhook) { | ||
console.error('No Slack webhook'); | ||
return; | ||
} | ||
|
||
try { | ||
const res = await fetch(slackWebhook, { | ||
method: 'POST', | ||
body: JSON.stringify( | ||
{ | ||
text: message, | ||
blocks: [ | ||
{ | ||
type: 'section', | ||
text: { | ||
type: 'mrkdwn', | ||
text: message, | ||
}, | ||
}, | ||
{ | ||
type: 'section', | ||
text: { | ||
type: 'mrkdwn', | ||
text: `*Environment:* ${ENV}`, | ||
}, | ||
}, | ||
{ | ||
type: 'section', | ||
text: { | ||
type: 'mrkdwn', | ||
text: `*URL:* ${URL}`, | ||
}, | ||
} | ||
], | ||
} | ||
), | ||
}); | ||
|
||
if (!res.ok) { | ||
console.error('Failed to ping Slack'); | ||
} | ||
} catch (error) { | ||
console.error('Failed to ping Slack:', error); | ||
} | ||
} | ||
|
||
/** | ||
* Fetches a story from Storyblok. | ||
* | ||
* @param storyID the story ID | ||
* @returns the story object | ||
* @throws Error if the story could not be fetched | ||
*/ | ||
const fetchStory = async (storyID: string) => { | ||
console.log('Fetching story:', storyID); | ||
const context = process.env.CONTEXT; | ||
let sbToken; | ||
if (context === 'production') { | ||
sbToken = process.env.PRODUCTION_STORYBLOK_ACCESS_TOKEN || process.env.STORYBLOK_ACCESS_TOKEN; | ||
} else { | ||
sbToken = process.env.STORYBLOK_ACCESS_TOKEN; | ||
} | ||
const sbConfig = { | ||
accessToken: sbToken, | ||
region: 'us' | ||
}; | ||
const sbParams = { | ||
version: 'draft' as ISbStoryParams['version'], | ||
cv: Date.now(), | ||
by_ids: storyID | ||
} | ||
const sbClient = new StoryblokClient(sbConfig); | ||
const { data } = await sbClient.getStories(sbParams); | ||
|
||
if (data && data.stories && data.stories.length) { | ||
console.log('Story fetched:', data.stories[0].full_slug); | ||
return data.stories[0]; | ||
} | ||
|
||
throw new Error('Story not found'); | ||
} | ||
|
||
/** | ||
* Validates the request. | ||
* | ||
* @param req the request | ||
* @param netlifyHookID the Netlify hook id | ||
* @param webhookSecret the webhook secret | ||
* @returns true if the request is valid | ||
* @throws Error if the request is invalid | ||
*/ | ||
const validateRequest = async (req: Request, netlifyHookID:string, webhookSecret: string) => { | ||
console.log('Validating request'); | ||
const signature = req.headers.get('webhook-signature') ?? ''; | ||
|
||
if (!signature) { | ||
throw new Error('No signature'); | ||
} | ||
|
||
if (!netlifyHookID) { | ||
throw new Error('Missing deploy info'); | ||
} | ||
|
||
// Clone the req so we can read the body elsewhere. | ||
const reqClone = req.clone(); | ||
const rawData = await reqClone.text(); | ||
|
||
if (!rawData) { | ||
throw new Error('No payload'); | ||
} | ||
|
||
const generatedSignature = createHmac('sha1', webhookSecret).update(rawData).digest('hex'); | ||
|
||
if (signature !== generatedSignature) { | ||
throw new Error('Wrong signature'); | ||
} | ||
console.log('Request validated'); | ||
return true; | ||
} | ||
|
||
// NETLIFY FUNCTION HANDLER. | ||
// --------------------------------------------------- | ||
export default async (req: Request) => { | ||
console.log('++++++++ START WEBHOOK ++++++++'); | ||
const slugPrefix = process.env.STORYBLOK_SLUG_PREFIX ?? 'momentum'; | ||
const netlifyHookID = process.env.NETLIFY_DEPLOY_HOOK_ID ?? ''; | ||
const webhookSecret = process.env.STORYBLOK_WEBHOOK_SECRET ?? ''; | ||
|
||
// VALIDATE THE REQUEST AND PAYLOAD. | ||
// --------------------------------------------------- | ||
try { | ||
await validateRequest(req, netlifyHookID, webhookSecret); | ||
} | ||
catch (error) { | ||
console.error('Error:', error); | ||
return; | ||
} | ||
|
||
// REQUEST AND PAYLOAD OK. | ||
// --------------------------------------------------- | ||
|
||
// Check the payload for the action and for the full_slug. | ||
// If the full slug starts with with the configured slug prefix trigger a netlify build. | ||
// Otherwise do nothing. | ||
// For everything else trigger a build. | ||
|
||
// Get the payload. | ||
let payload; | ||
try { | ||
console.log('Parsing payload'); | ||
payload = await req.json(); | ||
} | ||
catch (error) { | ||
console.error('Error:', error); | ||
await pingSlack(`Failed to parse payload: ${error}`); | ||
return; | ||
} | ||
|
||
// Trigger a build if the action and full_slug meet the slug prefix. | ||
// This handles the publish and unpublish actions for stories. | ||
if (payload.action && payload.full_slug && payload.full_slug.length) { | ||
console.log('Handling published/unpublished story'); | ||
if (!payload.full_slug.startsWith(slugPrefix)) { | ||
console.log('No build needed'); | ||
return; | ||
} | ||
} | ||
|
||
// The moved action doesn't include the full_slug so we need to fetch the story to get it. | ||
// This handles the moved action for stories. | ||
if (payload.action === 'moved' && payload.story_id) { | ||
console.log('Handling moved story'); | ||
const { story_id } = payload; | ||
let story; | ||
try { | ||
story = await fetchStory(story_id); | ||
} catch (error) { | ||
console.error('Error:', error); | ||
await pingSlack(`Failed to fetch story: ${error}`); | ||
return; | ||
} | ||
const { full_slug } = story; | ||
if (!full_slug || !full_slug.startsWith(slugPrefix)) { | ||
console.log('No build needed'); | ||
return; | ||
} | ||
} | ||
|
||
// For everything else trigger a build. | ||
// Trigger a build. | ||
try { | ||
await triggerNetlifyBuild(netlifyHookID); | ||
} | ||
catch (error) { | ||
console.error('Error:', error); | ||
await pingSlack(`Failed to trigger build: ${error}`); | ||
} | ||
|
||
console.log('++++++++ END WEBHOOK ++++++++'); | ||
}; | ||
|
||
|
Oops, something went wrong.