From 0776970423ff6e7e856b921399b939c86fdce323 Mon Sep 17 00:00:00 2001 From: Sherakama Date: Wed, 9 Oct 2024 09:10:36 -0700 Subject: [PATCH] DS-917: Deploy webhook (#351) * DS-917: Deploy webhook with delegation. --- README.md | 2 + docs/environment-variables.md | 83 +++++++ netlify/functions/storyblok-background.mts | 254 +++++++++++++++++++++ package-lock.json | 37 +++ package.json | 1 + 5 files changed, 377 insertions(+) create mode 100644 docs/environment-variables.md create mode 100644 netlify/functions/storyblok-background.mts diff --git a/README.md b/README.md index 0e967299..02f1ff4a 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ You can find Architectural Decision Records and more documentation in the [docs] Environment variable set up and installation --- +For more information on what the environment variables are and do see: [Environment Vars](./docs/environment-variables.md) + _Development_ 1. Create a new `.env` file by cloning the `example.env` file provided: diff --git a/docs/environment-variables.md b/docs/environment-variables.md new file mode 100644 index 00000000..7e5c4284 --- /dev/null +++ b/docs/environment-variables.md @@ -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. + diff --git a/netlify/functions/storyblok-background.mts b/netlify/functions/storyblok-background.mts new file mode 100644 index 00000000..05142c03 --- /dev/null +++ b/netlify/functions/storyblok-background.mts @@ -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 ++++++++'); +}; + + diff --git a/package-lock.json b/package-lock.json index 1157e5ca..1bc1f7eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@headlessui/react": "^2.1.8", "@heroicons/react": "^2.1.5", + "@netlify/functions": "^2.8.2", "@netlify/plugin-nextjs": "^5.7.2", "@next/third-parties": "^14.2.13", "@storyblok/react": "^3.0.14", @@ -334,6 +335,25 @@ "resolved": "https://registry.npmjs.org/@marvr/storyblok-rich-text-types/-/storyblok-rich-text-types-2.0.1.tgz", "integrity": "sha512-S11Aey2mpmg2qXuI2OnhKMpEoZW8OzMhuDiLg9zW5Mqlna5g8aJSmy2468feo/0qOLwAT3gpHyNScPKBTtTswQ==" }, + "node_modules/@netlify/functions": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-2.8.2.tgz", + "integrity": "sha512-DeoAQh8LuNPvBE4qsKlezjKj0PyXDryOFJfJKo3Z1qZLKzQ21sT314KQKPVjfvw6knqijj+IO+0kHXy/TJiqNA==", + "dependencies": { + "@netlify/serverless-functions-api": "1.26.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@netlify/node-cookies": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@netlify/node-cookies/-/node-cookies-0.1.0.tgz", + "integrity": "sha512-OAs1xG+FfLX0LoRASpqzVntVV/RpYkgpI0VrUnw2u0Q1qiZUzcPffxRK8HF3gc4GjuhG5ahOEMJ9bswBiZPq0g==", + "engines": { + "node": "^14.16.0 || >=16.0.0" + } + }, "node_modules/@netlify/plugin-nextjs": { "version": "5.7.2", "resolved": "https://registry.npmjs.org/@netlify/plugin-nextjs/-/plugin-nextjs-5.7.2.tgz", @@ -342,6 +362,18 @@ "node": ">=18.0.0" } }, + "node_modules/@netlify/serverless-functions-api": { + "version": "1.26.1", + "resolved": "https://registry.npmjs.org/@netlify/serverless-functions-api/-/serverless-functions-api-1.26.1.tgz", + "integrity": "sha512-q3L9i3HoNfz0SGpTIS4zTcKBbRkxzCRpd169eyiTuk3IwcPC3/85mzLHranlKo2b+HYT0gu37YxGB45aD8A3Tw==", + "dependencies": { + "@netlify/node-cookies": "^0.1.0", + "urlpattern-polyfill": "8.0.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@next/env": { "version": "14.2.13", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.13.tgz", @@ -21876,6 +21908,11 @@ "requires-port": "^1.0.0" } }, + "node_modules/urlpattern-polyfill": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-8.0.2.tgz", + "integrity": "sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==" + }, "node_modules/usehooks-ts": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.0.tgz", diff --git a/package.json b/package.json index 493b9c5d..9fed9edb 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "dependencies": { "@headlessui/react": "^2.1.8", "@heroicons/react": "^2.1.5", + "@netlify/functions": "^2.8.2", "@netlify/plugin-nextjs": "^5.7.2", "@next/third-parties": "^14.2.13", "@storyblok/react": "^3.0.14",