diff --git a/CHANGELOG.md b/CHANGELOG.md index b56e1a55d4..83ea91de27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ The types of changes are: - Privacy Experience Bulk Create, Bulk Update, and Detail Endpoints [#3185](https://github.com/ethyca/fides/pull/3185) - Initial privacy experience UI [#3186](https://github.com/ethyca/fides/pull/3186) - Access and erasure support for OneSignal [#3199](https://github.com/ethyca/fides/pull/3199) +- Add the ability to "inject" location into `/fides.js` bundles and cache responses for one hour [#3272](https://github.com/ethyca/fides/pull/3272) ### Changed diff --git a/clients/package-lock.json b/clients/package-lock.json index 55dc6990ea..b597ed1ddd 100644 --- a/clients/package-lock.json +++ b/clients/package-lock.json @@ -4670,6 +4670,19 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.8.2", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", @@ -5575,6 +5588,11 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/cache-control-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/cache-control-parser/-/cache-control-parser-2.0.4.tgz", + "integrity": "sha512-/FyoH+1kaAHoJC86Yz+ix0+l4DCisx/+nNjZs4HRFQcyuUJv5O9TsEu9pXi4e56kV7veou6k3QMCl6LRGo0qdQ==" + }, "node_modules/cachedir": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.3.0.tgz", @@ -6044,6 +6062,18 @@ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", "dev": true }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -8012,6 +8042,15 @@ "tslib": "^2.1.0" } }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -11174,11 +11213,26 @@ "tmpl": "1.0.5" } }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/memoize-one": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", + "dev": true + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -11194,6 +11248,15 @@ "node": ">= 8" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/micromatch": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", @@ -11207,6 +11270,18 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -11808,6 +11883,36 @@ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "dev": true }, + "node_modules/node-mocks-http": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/node-mocks-http/-/node-mocks-http-1.12.2.tgz", + "integrity": "sha512-xhWwC0dh35R9rf0j3bRZXuISXdHxxtMx0ywZQBwjrg3yl7KpRETzogfeCamUIjltpn0Fxvs/ZhGJul1vPLrdJQ==", + "dev": true, + "dependencies": { + "accepts": "^1.3.7", + "content-disposition": "^0.5.3", + "depd": "^1.1.0", + "fresh": "^0.5.2", + "merge-descriptors": "^1.0.1", + "methods": "^1.1.2", + "mime": "^1.3.4", + "parseurl": "^1.3.3", + "range-parser": "^1.2.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/node-mocks-http/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-releases": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.10.tgz", @@ -12567,6 +12672,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -13017,6 +13131,15 @@ } ] }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/react": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", @@ -15172,6 +15295,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typed-array-length": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", @@ -15944,6 +16080,7 @@ "@fidesui/react": "^0.0.23", "@fontsource/inter": "^4.5.15", "@reduxjs/toolkit": "^1.9.3", + "cache-control-parser": "^2.0.4", "fides-js": "*", "formik": "^2.2.9", "framer-motion": "^5", @@ -15987,6 +16124,7 @@ "jest-environment-jsdom": "^29.5.0", "lint-staged": "^13.2.0", "msw": "^1.2.1", + "node-mocks-http": "^1.12.2", "openapi-typescript-codegen": "^0.23.0", "prettier": "^2.8.7", "typescript": "4.9.5", diff --git a/clients/privacy-center/__tests__/config/server-environment.test.ts b/clients/privacy-center/__tests__/app/server-environment.test.ts similarity index 100% rename from clients/privacy-center/__tests__/config/server-environment.test.ts rename to clients/privacy-center/__tests__/app/server-environment.test.ts diff --git a/clients/privacy-center/__tests__/common/browser-identities.ts b/clients/privacy-center/__tests__/common/browser-identities.test.ts similarity index 100% rename from clients/privacy-center/__tests__/common/browser-identities.ts rename to clients/privacy-center/__tests__/common/browser-identities.test.ts diff --git a/clients/privacy-center/__tests__/common/location.test.ts b/clients/privacy-center/__tests__/common/location.test.ts new file mode 100644 index 0000000000..9bfddf64f5 --- /dev/null +++ b/clients/privacy-center/__tests__/common/location.test.ts @@ -0,0 +1,108 @@ +import { createRequest } from "node-mocks-http"; + +import { getLocation } from "~/common/location"; + +describe("getLocation", () => { + describe("when using location headers", () => { + it("returns location data from country & region headers", () => { + const req = createRequest({ + url: "https://privacy.example.com/fides.js", + headers: { + "CloudFront-Viewer-Country": "US", + "CloudFront-Viewer-Country-Region": "NY", + }, + }); + const location = getLocation(req); + expect(location).toEqual({ + country: "US", + location: "US-NY", + region: "NY", + }); + }); + + it("returns location data from country header", () => { + const req = createRequest({ + url: "https://privacy.example.com/fides.js", + headers: { + "CloudFront-Viewer-Country": "FR", + }, + }); + const location = getLocation(req); + expect(location).toEqual({ + country: "FR", + location: "FR", + }); + }); + + it("ignores only region headers", () => { + const req = createRequest({ + url: "https://privacy.example.com/fides.js", + headers: { + "CloudFront-Viewer-Country-Region": "NY", + }, + }); + const location = getLocation(req); + expect(location).toBeUndefined(); + }); + + it("handles invalid location headers", () => { + const req = createRequest({ + url: "https://privacy.example.com/fides.js", + headers: { + "CloudFront-Viewer-Country": "Magicland", + }, + }); + const location = getLocation(req); + expect(location).toBeUndefined(); + }); + }); + + describe("when using ?location query param", () => { + it("returns location data from query param", () => { + const req = createRequest({ + url: "https://privacy.example.com/fides.js?location=FR-IDF", + }); + const location = getLocation(req); + expect(location).toEqual({ + country: "FR", + location: "FR-IDF", + region: "IDF", + }); + }); + + it("handles invalid location query param", () => { + const req = createRequest({ + url: "https://privacy.example.com/fides.js?location=America", + }); + const location = getLocation(req); + expect(location).toBeUndefined(); + }); + }); + + describe("when using both headers and query param", () => { + it("overrides headers with explicit location query param", () => { + const req = createRequest({ + url: "https://privacy.example.com/fides.js?location=US-CA", + headers: { + "CloudFront-Viewer-Country": "FR", + }, + }); + const location = getLocation(req); + expect(location).toEqual({ + country: "US", + location: "US-CA", + region: "CA", + }); + }); + }); + + describe("when using neither headers nor query param", () => { + it("returns undefined location", () => { + const req = createRequest({ + url: "https://privacy.example.com/fides.js", + }); + const location = getLocation(req); + expect(location).toBeUndefined(); + }); + }); +}); diff --git a/clients/privacy-center/common/location.ts b/clients/privacy-center/common/location.ts new file mode 100644 index 0000000000..a137e56875 --- /dev/null +++ b/clients/privacy-center/common/location.ts @@ -0,0 +1,75 @@ +import type { NextApiRequest } from "next"; + +// DEFER: Import this type from fides-js when it exists! +// (see https://github.com/ethyca/fides/pull/3191) +// import type { UserGeolocation } from "fides-js"; +export type UserGeolocation = { + country?: string; // "US" + ip?: string; // "192.168.0.1:12345" + location?: string; // "US-NY" + region?: string; // "NY" +}; + +// Regex to validate a location string, which must: +// 1) Start with a 2-3 character country code (e.g. "US") +// 2) Optionally end with a 2-3 character region code (e.g. "CA") +// 3) Separated by a dash (e.g. "US-CA") +const VALID_ISO_3166_LOCATION_REGEX = /^\w{2,3}(-\w{2,3})?$/; + +// Constants for the supported CloudFront geolocation headers +// (see https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html#cloudfront-headers-viewer-location) +const CLOUDFRONT_HEADER_COUNTRY = "cloudfront-viewer-country"; +const CLOUDFRONT_HEADER_REGION = "cloudfront-viewer-country-region"; +export const LOCATION_HEADERS = [ + CLOUDFRONT_HEADER_COUNTRY, + CLOUDFRONT_HEADER_REGION, +]; + +/** + * Lookup the "location" (ie country and region) for the given request by looking for either: + * 1) An explicit "location" query param (e.g. https://privacy.example.com/some/path?location=US-CA) + * 2) Supported geolocation headers (e.g. "Cloudfront-Viewer-Country: US") + * + * If neither of these are found, return an undefined location. + * + * NOTE: This specifically *does not* include performing a geo-IP lookup... yet! + */ +export const getLocation = ( + req: NextApiRequest +): UserGeolocation | undefined => { + // DEFER: read headers to determine & return the request's IP address + + // Check for a provided "location" query param + const { location: locationQuery } = req.query; + if ( + typeof locationQuery === "string" && + VALID_ISO_3166_LOCATION_REGEX.test(locationQuery) + ) { + const [country, region] = locationQuery.split("-"); + return { + location: locationQuery, + country, + region, + }; + } + + // Check for CloudFront viewer location headers + if (typeof req.headers[CLOUDFRONT_HEADER_COUNTRY] === "string") { + let location; + let region; + const country = req.headers[CLOUDFRONT_HEADER_COUNTRY].split(",")[0]; + location = country; + if (typeof req.headers[CLOUDFRONT_HEADER_REGION] === "string") { + [region] = req.headers[CLOUDFRONT_HEADER_REGION].split(","); + location = `${country}-${region}`; + } + if (VALID_ISO_3166_LOCATION_REGEX.test(location)) { + return { + location, + country, + region, + }; + } + } + return undefined; +}; diff --git a/clients/privacy-center/cypress/e2e/fides-js.cy.ts b/clients/privacy-center/cypress/e2e/fides-js.cy.ts new file mode 100644 index 0000000000..193ccac918 --- /dev/null +++ b/clients/privacy-center/cypress/e2e/fides-js.cy.ts @@ -0,0 +1,170 @@ +describe("fides.js API route", () => { + it("returns the fides.js package bundled with the global config", () => { + cy.request("/fides.js").then((response) => { + expect(response.status).to.eq(200); + expect(response) + .to.have.property("headers") + .to.have.property("content-type") + .to.eql("application/javascript"); + + // Run a few checks on the "bundled" response body, which should: + // 1) Be an IIFE that... + // 2) ...includes a call to Fides.init with a config JSON that... + // 3) ...is populated with the config.json options + expect(response.body) + .to.match(/^\s+\(function/, "should be an IIFE") + .to.match(/\}\)\(\);\s+$/, "should be an IIFE"); + expect(response.body) + .to.match(/var fidesConfig = \{/, "should bundle Fides.init") + .to.match(/Fides.init\(fidesConfig\);/, "should bundle Fides.init"); + const matches = response.body.match( + /var fidesConfig = (?\{.*?\});/ + ); + expect(matches).to.have.nested.property("groups.json"); + expect(JSON.parse(matches.groups.json)) + .to.have.nested.property("consent.options") + .to.have.length(3); + }); + }); + + describe("when pre-fetching location", () => { + it("returns location if provided as a '?location' query param", () => { + cy.request("/fides.js?location=US-CA").then((response) => { + expect(response.body).to.match(/var fidesConfig = \{/); + const matches = response.body.match( + /var fidesConfig = (?\{.*?\});/ + ); + expect(JSON.parse(matches.groups.json)) + .to.have.nested.property("location") + .to.deep.equal({ + location: "US-CA", + country: "US", + region: "CA", + }); + }); + }); + + it("returns location if provided as CloudFront location headers", () => { + cy.request({ + url: "/fides.js", + headers: { + "CloudFront-Viewer-Country": "FR", + "CloudFront-Viewer-Country-Region": "IDF", + }, + }).then((response) => { + expect(response.body).to.match(/var fidesConfig = \{/); + const matches = response.body.match( + /var fidesConfig = (?\{.*?\});/ + ); + expect(JSON.parse(matches.groups.json)) + .to.have.nested.property("location") + .to.deep.equal({ + location: "FR-IDF", + country: "FR", + region: "IDF", + }); + }); + }); + }); + + it("caches in the browser", () => { + cy.intercept("/fides.js").as("fidesJS"); + + // Load the demo page 3 times, and check /fides.js is called *at most* once + // NOTE: Depending on browser cache, it might not be called at all - so zero + // times is a valid number of calls + cy.visit("/fides-js-demo.html"); + cy.visit("/fides-js-demo.html"); + cy.visit("/fides-js-demo.html"); + cy.get("@fidesJS.all").its("length").should("be.within", 0, 1); + }); + + describe("when generating cache-control headers", () => { + beforeEach(() => { + cy.request("/fides.js").then((response) => { + expect(response.status).to.eq(200); + cy.wrap(response.headers).as("headers"); + cy.get("@headers").should("have.property", "etag").as("etag"); + cy.get("@headers") + .should("have.property", "cache-control") + .as("cacheHeaders"); + }); + }); + + it("stores publicly for at least one hour, at most one day", () => { + cy.get("@cacheHeaders").should("match", /public/); + cy.get("@cacheHeaders") + .invoke("match", /max-age=(?\d+)/) + .its("groups.expiry") + .then(parseInt) + .should("be.within", 3600, 86400); + }); + + it("generates 'etag' that is consistent when re-requested", () => { + cy.request("/fides.js") + .should("have.nested.property", "headers.etag") + .then((etag) => { + cy.get("@etag").should("eq", etag); + }); + }); + + it("generates 'etag' that varies based on location query params", () => { + cy.request("/fides.js?location=US-CA") + .should("have.nested.property", "headers.etag") + .as("USCATag") + .then((etag) => { + cy.get("@etag").should("not.eq", etag); + }); + + // Fetch a second time with a different location param + cy.request("/fides.js?location=FR") + .should("have.nested.property", "headers.etag") + .then((etag) => { + cy.get("@etag").should("not.eq", etag); + cy.get("@USCATag").should("not.eq", etag); + }); + }); + + it("generates 'etag' that varies based on Cloudfront location headers", () => { + cy.request({ + url: "/fides.js", + headers: { + "Cloudfront-Viewer-Country": "US", + "Cloudfront-Viewer-Country-Region": "CA", + }, + }) + .should("have.nested.property", "headers.etag") + .as("USCATag") + .then((etag) => { + cy.get("@etag").should("not.eq", etag); + }); + + // Fetch a second time with different location headers + cy.request({ + url: "/fides.js", + headers: { + "Cloudfront-Viewer-Country": "FR", + }, + }) + .should("have.nested.property", "headers.etag") + .as("headersTag") + .then((etag) => { + cy.get("@etag").should("not.eq", etag); + cy.get("@USCATag").should("not.eq", etag); + }); + }); + + it("returns 'vary' header for supported Cloudfront location headers", () => { + const expected = [ + "cloudfront-viewer-country", + "cloudfront-viewer-country-region", + ]; + cy.get("@headers") + .should("have.property", "vary") + .then((vary: any) => { + const varyHeaders = (vary as string).replace(" ", "").split(","); + expect(varyHeaders).to.include.members(expected); + }); + }); + }); +}); diff --git a/clients/privacy-center/package.json b/clients/privacy-center/package.json index bc61c9a322..d76626fecb 100644 --- a/clients/privacy-center/package.json +++ b/clients/privacy-center/package.json @@ -26,6 +26,7 @@ "@fidesui/react": "^0.0.23", "@fontsource/inter": "^4.5.15", "@reduxjs/toolkit": "^1.9.3", + "cache-control-parser": "^2.0.4", "fides-js": "*", "formik": "^2.2.9", "framer-motion": "^5", @@ -69,6 +70,7 @@ "jest-environment-jsdom": "^29.5.0", "lint-staged": "^13.2.0", "msw": "^1.2.1", + "node-mocks-http": "^1.12.2", "openapi-typescript-codegen": "^0.23.0", "prettier": "^2.8.7", "typescript": "4.9.5", diff --git a/clients/privacy-center/pages/api/fides-js.ts b/clients/privacy-center/pages/api/fides-js.ts index 76f430ef69..f35434ecb5 100644 --- a/clients/privacy-center/pages/api/fides-js.ts +++ b/clients/privacy-center/pages/api/fides-js.ts @@ -1,21 +1,29 @@ /* eslint-disable no-console */ import { promises as fsPromises } from "fs"; import type { NextApiRequest, NextApiResponse } from "next"; +import { CacheControl, stringify } from "cache-control-parser"; import { ConsentOption, FidesConfig } from "fides-js"; import { loadPrivacyCenterEnvironment } from "~/app/server-environment"; +import { + getLocation, + LOCATION_HEADERS, + UserGeolocation, +} from "~/common/location"; + +const FIDES_JS_MAX_AGE_SECONDS = 60 * 60; // one hour /** * Server-side API route to generate the customized "fides.js" script * based on the current configuration values. - * - * DEFER: Optimize this route, and ensure it is cacheable - * (see https://github.com/ethyca/fides/issues/3170) */ export default async function handler( req: NextApiRequest, res: NextApiResponse ) { + // Check if a location was provided via headers or query param; if so, inject into the bundle + const location = getLocation(req); + // Load the configured consent options (data uses, defaults, etc.) from environment const environment = await loadPrivacyCenterEnvironment(); let options: ConsentOption[] = []; @@ -28,20 +36,20 @@ export default async function handler( })); } - // Create the FidesConfig object that will be used to initialize fides.js - const fidesConfig: FidesConfig = { + // Create the FidesConfig JSON that will be used to initialize fides.js + // DEFER: update this to match what FidesConfig expects in the future for location + const fidesConfig: FidesConfig & { location?: UserGeolocation } = { consent: { options, }, + location, }; const fidesConfigJSON = JSON.stringify(fidesConfig); - // DEFER: Optimize this by loading from a vendored asset folder instead - // (see https://github.com/ethyca/fides/issues/3170) console.log( "Bundling generic fides.js & Privacy Center configuration together..." ); - const fidesJSBuffer = await fsPromises.readFile("../fides-js/dist/fides.js"); + const fidesJSBuffer = await fsPromises.readFile("public/lib/fides.js"); const fidesJS: string = fidesJSBuffer.toString(); if (!fidesJS || fidesJS === "") { throw new Error("Unable to load latest fides.js script from server!"); @@ -57,9 +65,17 @@ export default async function handler( })(); `; + // Instruct any caches to store this response, since these bundles do not change often + const cacheHeaders: CacheControl = { + "max-age": FIDES_JS_MAX_AGE_SECONDS, + public: true, + }; + // Send the bundled script, ready to be loaded directly into a page! res .status(200) .setHeader("Content-Type", "application/javascript") + .setHeader("Cache-Control", stringify(cacheHeaders)) + .setHeader("Vary", LOCATION_HEADERS) .send(script); } diff --git a/clients/turbo.json b/clients/turbo.json index 8f6bc5099d..bd120db9af 100644 --- a/clients/turbo.json +++ b/clients/turbo.json @@ -3,7 +3,7 @@ "pipeline": { "build": { "dependsOn": ["^build"], - "outputs": [".next/*", "!.next/cache/*"] + "outputs": [".next/*", "!.next/cache/*", "dist/*"] }, "dev": { "dependsOn": ["^build"],