src/functions.ts (167 lines of code) (raw):
import fs from 'fs';
import os from 'os';
import path from 'path';
import stream from 'stream';
import util from 'util';
import express, {
NextFunction,
Request,
RequestHandler,
Response,
} from 'express';
import bodyParser from 'body-parser';
import fetch from 'node-fetch';
import safeCompare from 'safe-compare';
type ApiError = Error & {
extraInfo?: string;
status?: number;
};
type CreateApiErrorParams = {
message: string;
extraInfo?: string;
status?: number;
};
export const createApiError = ({
message,
extraInfo,
status = 500,
}: CreateApiErrorParams): ApiError => {
const error: ApiError = new Error(message);
error.status = status;
error.extraInfo = extraInfo;
return error;
};
// The `createExpressApp` adds new attributes to the Express request.
export type RequestWithFiles = Request & {
xpiFilepath?: string;
};
export type FunctionConfig = {
_console?: typeof console;
_fetch?: typeof fetch;
_process?: typeof process;
_unlinkFile?: typeof fs.promises.unlink;
apiKeyEnvVarName?: string;
requiredApiKeyParam?: string;
requiredDownloadUrlParam?: string;
tmpDir?: string;
xpiFilename?: string;
};
export const createExpressApp =
({
_console = console,
_fetch = fetch,
_process = process,
_unlinkFile = fs.promises.unlink,
apiKeyEnvVarName = 'LAMBDA_API_KEY',
requiredApiKeyParam = 'api_key',
requiredDownloadUrlParam = 'download_url',
tmpDir = os.tmpdir(),
xpiFilename = 'input.xpi',
}: FunctionConfig = {}) =>
(handler: RequestHandler) => {
const app = express();
const allowedOrigin = _process.env.ALLOWED_ORIGIN || null;
if (!allowedOrigin) {
throw new Error('ALLOWED_ORIGIN is not set or unexpectedly empty!');
}
const apiKey = _process.env[apiKeyEnvVarName] || null;
if (apiKey) {
// Delete the env var to not expose it to add-ons.
// eslint-disable-next-line no-param-reassign
delete _process.env[apiKeyEnvVarName];
}
// Parse JSON body requests.
app.use(bodyParser.json());
// This middleware handles the common logic needed to expose our tools. It
// adds a new `xpiFilepath` attribute to the Express request or returns an
// error that will be converted to an API error by the error handler
// middleware declared at the bottom of the middleware chain.
app.use(
async (req: RequestWithFiles, res: Response, next: NextFunction) => {
const allowedMethods = ['POST'];
if (req.headers['content-type'] !== 'application/json') {
// We do not throw because we are inside a callback, so we pass an error
// to the next middleware, which will be the error handler.
// See: https://expressjs.com/en/guide/error-handling.html
next(
createApiError({
message: 'unsupported content type',
status: 415,
}),
);
return;
}
if (typeof req.body[requiredApiKeyParam] === 'undefined') {
next(
createApiError({
message: `missing "${requiredApiKeyParam}" parameter`,
status: 400,
}),
);
return;
}
if (!apiKey || !safeCompare(apiKey, req.body[requiredApiKeyParam])) {
next(
createApiError({
message: 'authentication has failed',
status: 401,
}),
);
return;
}
if (
!allowedMethods
.map((method) => method.toLowerCase())
.includes(req.method.toLowerCase())
) {
next(createApiError({ message: 'method not allowed', status: 405 }));
return;
}
const downloadURL = req.body[requiredDownloadUrlParam];
if (!downloadURL) {
next(
createApiError({
message: `missing "${requiredDownloadUrlParam}" parameter`,
status: 400,
}),
);
return;
}
if (!downloadURL.startsWith(allowedOrigin)) {
next(createApiError({ message: 'invalid origin', status: 400 }));
return;
}
try {
const xpiFilepath = path.join(tmpDir, xpiFilename);
const streamPipeline = util.promisify(stream.pipeline);
const response = await _fetch(downloadURL);
if (!response.ok) {
throw new Error(`unexpected response ${response.statusText}`);
}
await streamPipeline(
response.body,
fs.createWriteStream(xpiFilepath),
);
req.xpiFilepath = xpiFilepath;
// Add a listener that will run code after the response is sent.
res.on('finish', () => {
_unlinkFile(xpiFilepath).catch((error) => {
_console.error(`_unlinkFile(): ${error}`);
});
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
next(
createApiError({
message: 'failed to download file',
extraInfo: err.message,
}),
);
return;
}
next();
},
);
// We register the handler for the tool that will be exposed. This handler is
// guaranteed to have a valid `xpiFilepath` stored on disk.
app.post('/', handler);
// NotFound handler.
app.use((req: Request, res: Response, next: NextFunction) => {
next(createApiError({ message: 'not found', status: 404 }));
});
// Error handler. Even though we are not using `next`, it must be kept
// because the Express error handler signature requires 4 arguments.
app.use(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(err: ApiError, req: Request, res: Response, next: NextFunction) => {
const error = {
error: err.message,
extra_info: err.extraInfo || null,
};
res.status(err.status || 500).json(error);
// Also send the error to the cloud provider.
_console.error(error);
},
);
return app;
};