Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

build(deps): bump next from 12.0.10 to 12.1.0 #123

Merged
merged 10 commits into from
Apr 3, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 66 additions & 56 deletions lib/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,32 @@ process.env.NODE_ENV = 'production';
// ! Make sure this comes before the fist import
process.env.NEXT_SHARP_PATH = require.resolve('sharp');

import { ImageConfig, imageConfigDefault } from 'next/dist/server/image-config';
import { parse as parseUrl } from 'url';

import {
defaultConfig,
NextConfigComplete,
} from 'next/dist/server/config-shared';
import type {
APIGatewayProxyEventV2,
APIGatewayProxyStructuredResultV2,
// Disable is tolerable since we only import the types here, not the module
// itself
// eslint-disable-next-line import/no-unresolved
} from 'aws-lambda';
import { Writable } from 'stream';
import S3 from 'aws-sdk/clients/s3';
import { IncomingMessage } from 'http';

import { imageOptimizer, S3Config } from './image-optimizer';
import { normalizeHeaders } from './normalized-headers';
import { createDeferred } from './utils';

/* -----------------------------------------------------------------------------
* Types
* ---------------------------------------------------------------------------*/
type ImageConfig = Partial<NextConfigComplete['images']>;

/* -----------------------------------------------------------------------------
* Utils
* ---------------------------------------------------------------------------*/

function generateS3Config(bucketName?: string): S3Config | undefined {
let s3: S3;
Expand Down Expand Up @@ -56,6 +66,13 @@ function parseFromEnv<T>(key: string, defaultValue: T) {
}
}

/* -----------------------------------------------------------------------------
* Globals
* ---------------------------------------------------------------------------*/
// `images` property is defined on default config
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const imageConfigDefault = defaultConfig.images!;

const domains = parseFromEnv(
'TF_NEXTIMAGE_DOMAINS',
imageConfigDefault.domains ?? []
Expand All @@ -72,6 +89,14 @@ const imageSizes = parseFromEnv(
'TF_NEXTIMAGE_IMAGE_SIZES',
imageConfigDefault.imageSizes
);
const dangerouslyAllowSVG = parseFromEnv(
'TF_NEXTIMAGE_DANGEROUSLY_ALLOW_SVG',
imageConfigDefault.dangerouslyAllowSVG
);
const contentSecurityPolicy = parseFromEnv(
'TF_NEXTIMAGE_CONTENT_SECURITY_POLICY',
imageConfigDefault.contentSecurityPolicy
);
const sourceBucket = process.env.TF_NEXTIMAGE_SOURCE_BUCKET ?? undefined;
const baseOriginUrl = process.env.TF_NEXTIMAGE_BASE_ORIGIN ?? undefined;

Expand All @@ -81,72 +106,57 @@ const imageConfig: ImageConfig = {
deviceSizes,
formats,
imageSizes,
dangerouslyAllowSVG,
contentSecurityPolicy,
};

/* -----------------------------------------------------------------------------
* Handler
* ---------------------------------------------------------------------------*/

export async function handler(
event: APIGatewayProxyEventV2
): Promise<APIGatewayProxyStructuredResultV2> {
const s3Config = generateS3Config(sourceBucket);

const reqMock = {
headers: normalizeHeaders(event.headers),
method: event.requestContext.http.method,
url: `/?${event.rawQueryString}`,
};
const parsedUrl = parseUrl(`/?${event.rawQueryString}`, true);
const imageOptimizerResult = await imageOptimizer(
{ headers: normalizeHeaders(event.headers) },
imageConfig,
{
baseOriginUrl,
parsedUrl,
s3Config,
}
);

const resBuffers: Buffer[] = [];
const resMock: any = new Writable();
const defer = createDeferred();
let didCallEnd = false;
if ('error' in imageOptimizerResult) {
return {
statusCode: imageOptimizerResult.statusCode,
body: imageOptimizerResult.error,
};
}

resMock.write = (chunk: Buffer | string) => {
resBuffers.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
};
resMock._write = (chunk: Buffer | string) => {
resMock.write(chunk);
};
const mockHeaders: Map<string, string | string[]> = new Map();
resMock.writeHead = (_status: any, _headers: any) =>
Object.assign(mockHeaders, _headers);
resMock.getHeader = (name: string) => mockHeaders.get(name.toLowerCase());
resMock.getHeaders = () => mockHeaders;
resMock.getHeaderNames = () => Object.keys(mockHeaders);
resMock.setHeader = (name: string, value: string | string[]) =>
mockHeaders.set(name.toLowerCase(), value);
// Empty function is tolerable here since it is part of a mock
// eslint-disable-next-line @typescript-eslint/no-empty-function
resMock._implicitHeader = () => {};

resMock.originalEnd = resMock.end;
resMock.on('close', () => defer.resolve());
resMock.end = (message: string) => {
didCallEnd = true;
resMock.originalEnd(message);
};
const { contentType, paramsResult, maxAge } = imageOptimizerResult;
const { isStatic, minimumCacheTTL } = paramsResult;
const cacheTTL = Math.max(minimumCacheTTL, maxAge);

const parsedUrl = parseUrl(reqMock.url, true);
await imageOptimizer(imageConfig, reqMock as IncomingMessage, resMock, {
baseOriginUrl,
parsedUrl,
s3Config,
});

const normalizedHeaders: Record<string, string> = {};
for (const [headerKey, headerValue] of mockHeaders.entries()) {
if (Array.isArray(headerValue)) {
normalizedHeaders[headerKey] = headerValue.join(', ');
continue;
}
const normalizedHeaders: Record<string, string> = {
Vary: 'Accept',
'Content-Type': contentType,
'Cache-Control': isStatic
? 'public, max-age=315360000, immutable'
: `public, max-age=${cacheTTL}`,
};

normalizedHeaders[headerKey] = headerValue;
if (imageConfig.contentSecurityPolicy) {
normalizedHeaders['Content-Security-Policy'] =
imageConfig.contentSecurityPolicy;
}

if (didCallEnd) defer.resolve();
await defer.promise;

return {
statusCode: resMock.statusCode || 200,
body: Buffer.concat(resBuffers).toString('base64'),
statusCode: 200,
body: imageOptimizerResult.buffer.toString('base64'),
isBase64Encoded: true,
headers: normalizedHeaders,
};
Expand Down
29 changes: 13 additions & 16 deletions lib/image-optimizer.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { IncomingMessage, ServerResponse } from 'http';
import { IncomingMessage } from 'http';
import { URL, UrlWithParsedQuery } from 'url';

import {
imageOptimizer as pixel,
ImageOptimizerOptions as PixelOptions,
} from '@millihq/pixel-core';
import { Pixel } from '@millihq/pixel-core';
import { ImageConfig } from 'next/dist/server/image-config';
import nodeFetch from 'node-fetch';
import S3 from 'aws-sdk/clients/s3';
Expand All @@ -13,6 +10,10 @@ import S3 from 'aws-sdk/clients/s3';
* Types
* ---------------------------------------------------------------------------*/

type RequestMock = {
headers: Record<string, string>;
};

type S3Config = {
s3: S3;
bucket: string;
Expand All @@ -29,18 +30,12 @@ type ImageOptimizerOptions = {
* ---------------------------------------------------------------------------*/

async function imageOptimizer(
req: RequestMock,
imageConfig: ImageConfig,
req: IncomingMessage,
res: ServerResponse,
options: ImageOptimizerOptions
): ReturnType<typeof pixel> {
): Promise<ReturnType<Pixel['imageOptimizer']>> {
const { baseOriginUrl, parsedUrl, s3Config } = options;
const pixelOptions: PixelOptions = {
/**
* Use default temporary folder from AWS Lambda
*/
distDir: '/tmp',

const pixel = new Pixel({
imageConfig: {
...imageConfig,
loader: 'default',
Expand Down Expand Up @@ -128,9 +123,11 @@ async function imageOptimizer(
res.end();
}
},
};
});

return pixel(req, res, parsedUrl, pixelOptions);
// req and res are not used anymore by the imageoptimizer, however they still
// exist as variables
return pixel.imageOptimizer(req as IncomingMessage, {} as any, parsedUrl);
}

export type { S3Config };
Expand Down
8 changes: 3 additions & 5 deletions lib/normalized-headers.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import { IncomingHttpHeaders } from 'http';

/**
* Normalizes the headers from API Gateway 2.0 format
*/
export function normalizeHeaders(
headers: Record<string, string>
): IncomingHttpHeaders {
const _headers: IncomingHttpHeaders = {};
): Record<string, string> {
const _headers: Record<string, string> = {};

for (const [key, value] of Object.entries(headers)) {
_headers[key.toLocaleLowerCase()] = value;
_headers[key.toLowerCase()] = value;
}

return _headers;
Expand Down
4 changes: 2 additions & 2 deletions lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@
"postpack": "rm ./LICENSE ./third-party-licenses.txt"
},
"dependencies": {
"@millihq/pixel-core": "2.0.0",
"@millihq/pixel-core": "4.1.0",
"aws-sdk": "*",
"next": "12.0.10",
"next": "12.1.0",
"node-fetch": "2.6.7",
"react": "17.0.2",
"sharp": "0.30.3"
Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,11 @@
"jest-file-snapshot": "^0.5.0",
"mime-types": "^2.1.34",
"node-fetch": "^2.6.7",
"node-mocks-http": "^1.10.0",
"prettier": "^2.2.1",
"release-it": "^14.12.3",
"release-it-yarn-workspaces": "^2.0.1",
"ts-jest": "^27.0.5",
"typescript": "^4.4.2"
"typescript": "^4.6.3"
},
"resolutions": {
"aws-sdk": "2.1001.0"
Expand Down
15 changes: 14 additions & 1 deletion test/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ const NODE_RUNTIME = 'nodejs14.x';
// Environment variables that should be set in the Lambda environment
const ENVIRONMENT_VARIABLES = {
NODE_ENV: 'production',
TF_NEXTIMAGE_DANGEROUSLY_ALLOW_SVG: JSON.stringify(true),
TF_NEXTIMAGE_CONTENT_SECURITY_POLICY: JSON.stringify(
"default-src 'self'; script-src 'none'; sandbox;"
),
};

jest.setTimeout(60_000);
Expand All @@ -27,7 +31,7 @@ describe('[e2e]', () => {
const s3Endpoint = `${hostIpAddress}:9000`;
const pathToWorker = path.resolve(__dirname, '../lib');
const fixturesDir = path.resolve(__dirname, './fixtures');
const cacheControlHeader = 'public, max-age=123456, must-revalidate';
const cacheControlHeader = 'public, max-age=123456';
let fixtureBucketName: string;
let s3: S3;

Expand Down Expand Up @@ -118,6 +122,15 @@ describe('[e2e]', () => {
expect(response.headers.get('Content-Type')).toBe(outputContentType);
expect(response.headers.get('Cache-Control')).toBe(cacheControlHeader);

// Check that Content-Security-Policy header is present to prevent potential
// XSS attack
// Fixed in Next.js 11.1.1
// https://github.com/vercel/next.js/security/advisories/GHSA-9gr3-7897-pp7m
// https://nvd.nist.gov/vuln/detail/CVE-2021-39178
expect(response.headers.get('Content-Security-Policy')).toBe(
"default-src 'self'; script-src 'none'; sandbox;"
);

// Header settings needed for CloudFront compression
expect(response.headers.has('Content-Length')).toBeTruthy();
expect(response.headers.has('Content-Encoding')).toBeFalsy();
Expand Down
Loading