Skip to content

Commit

Permalink
Add support for authn/authz/alternate resolution preflight function
Browse files Browse the repository at this point in the history
  • Loading branch information
mbklein committed Sep 1, 2021
1 parent 4b6e250 commit ca2758a
Show file tree
Hide file tree
Showing 10 changed files with 215 additions and 58 deletions.
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,39 @@ To generate a code coverage report run:
npm test --coverage
```

## Advanced Usage

The SAM deploy template takes an optional `PreflightFunctionARN` parameter. This parameter, if provided, refers to a [CloudFront Function](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cloudfront-functions.html) (*not* a standard Lambda) that will be associated with the CloudFront distribution at the `viewer-request` stage. This function can perform authentication and authorization functions, or it can change how the S3 file and/or image dimensions are resolved.

### Examples

#### Simple Authorization

```JavaScript
function handler(event) {
if (notAuthorized) { // based on something in the event.request
return {
statusCode: 403,
statusDescription: 'Unauthorized'
};
};
return event.request;
}
```

#### Custom File Location / Image Dimensions

```JavaScript
function handler(event) {
const request = event.request;
request.headers['x-preflight-location'] = 's3://image-bucket/path/to/correct/image.tif'
request.headers['x-preflight-dimensions'] = JSON.stringify({ width: 640, height: 480 });
return request;
}
```

*Note:* The SAM deploy template adds a `preflight=true` environment variable to the main IIIF Lambda if a preflight function is provided. The function will _only_ look for the preflight headers if this environment variable is `true`. This prevents requests from including those headers directly if no preflight function is present. If you do use a preflight function, make sure it strips out any `x-preflight-location` and `x-preflight-dimensions` headers that it doesn't set itself.

## Notes

AWS API Gateway Lambda integration has a payload (request/response body) size limit of approximately 6MB in both directions. To overcome this limitation, the API is configured behind an AWS CloudFront distribution with two origins – the API and a cache bucket. Responses larger than 6MB are saved to the cache bucket at the same relative path as the request, and the API returns a `404 Not Found` response to CloudFront. CloudFront then fails over to the second origin (the cache bucket), where it finds the actual response and returns it.
Expand Down
60 changes: 30 additions & 30 deletions dependencies/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"description": "Lambda wrapper for iiif-processor",
"author": "Michael B. Klein",
"license": "Apache-2.0",
"dependencies": {},
"dependencies": {
"uri-js": "^4.4.1"
},
"devDependencies": {
"aws-sdk": "^2.368.0",
"coveralls": "^3.1.0",
Expand Down
4 changes: 3 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const helpers = require('./helpers');
const resolvers = require('./resolvers');
const errorHandler = require('./error');

const preflight = process.env.preflight === 'true';

const handleRequestFunc = async (event, context, callback) => {
const { eventPath, fileMissing, getRegion } = helpers;

Expand All @@ -32,7 +34,7 @@ const handleRequestFunc = async (event, context, callback) => {

const handleImageRequestFunc = async (event, context, callback) => {
const { getUri, isTooLarge } = helpers;
const { streamResolver, dimensionResolver } = resolvers;
const { streamResolver, dimensionResolver } = resolvers.resolverFactory(event, preflight);
const { getCached, makeCache } = cache;

let resource;
Expand Down
4 changes: 3 additions & 1 deletion src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"description": "Lambda wrapper for iiif-processor",
"author": "Michael B. Klein",
"license": "Apache-2.0",
"dependencies": {},
"dependencies": {
"uri-js": "^4.4.1"
},
"devDependencies": {
"aws-sdk": "^2.368.0",
"iiif-processor": "~0.3.4"
Expand Down
73 changes: 62 additions & 11 deletions src/resolvers.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
const AWS = require('aws-sdk');
const sourceBucket = process.env.tiffBucket;
const URI = require('uri-js');

// IIIF RESOLVERS
const streamResolver = async (id, callback) => {

// Create input stream from S3 location
const s3Stream = async (location, callback) => {
const s3 = new AWS.S3();
const key = id + '.tif';
const request = s3.getObject({ Bucket: sourceBucket, Key: key });
const request = s3.getObject(location);
const stream = request.createReadStream();
try {
return await callback(stream);
Expand All @@ -15,12 +17,16 @@ const streamResolver = async (id, callback) => {
}
};

const dimensionResolver = async (id) => {
// Compute default stream location from ID
const defaultStreamLocation = (id) => {
const key = id + '.tif';
return { Bucket: sourceBucket, Key: key };
};

// Retrieve dimensions from S3 metadata
const dimensionRetriever = async (location) => {
const s3 = new AWS.S3();
const obj = await s3.headObject({
Bucket: sourceBucket,
Key: `${id}.tif`
}).promise();
const obj = await s3.headObject(location).promise();
if (obj.Metadata.width && obj.Metadata.height) {
return {
width: parseInt(obj.Metadata.width, 10),
Expand All @@ -30,7 +36,52 @@ const dimensionResolver = async (id) => {
return null;
};

module.exports = {
streamResolver: streamResolver,
dimensionResolver: dimensionResolver
// Preflight resolvers
const parseLocationHeader = (event) => {
const locationHeader = event.headers['X-Preflight-Location'] || event.headers['x-preflight-location'];
if (locationHeader && locationHeader.match(/^s3:\/\//)) {
const parsedURI = URI.parse(locationHeader);
return { Bucket: parsedURI.host, Key: parsedURI.path.slice(1) };
};
return null;
};

const parseDimensionsHeader = (event) => {
const dimensionsHeader = event.headers['X-Preflight-Dimensions'] || event.headers['x-preflight-dimensions'];
if (!dimensionsHeader) return null;
return JSON.parse(dimensionsHeader);
};

const preflightResolver = (event) => {
const preflightLocation = parseLocationHeader(event);
const preflightDimensions = parseDimensionsHeader(event);

return {
streamResolver: async (id, callback) => {
const location = preflightLocation || defaultStreamLocation(id);
return s3Stream(location, callback);
},
dimensionResolver: async (id) => {
const location = preflightLocation || defaultStreamLocation(id);
return preflightDimensions || dimensionRetriever(location);
}
};
};

// Standard (non-preflight) resolvers
const standardResolver = () => {
return {
streamResolver: async (id, callback) => {
return s3Stream(defaultStreamLocation(id), callback);
},
dimensionResolver: async (id) => {
return dimensionRetriever(defaultStreamLocation(id));
}
};
};

const resolverFactory = (event, preflight) => {
return preflight ? preflightResolver(event) : standardResolver();
};

module.exports = { resolverFactory };
30 changes: 25 additions & 5 deletions template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,26 @@ Parameters:
Type: Number
Description: The timeout for the lambda.
Default: 10
PreflightFunctionARN:
Type: String
Description: ARN of the CloudFront Function to use for auth/preflight
Default: ""
Conditions:
CreateCacheBucket:
Fn::Equals: [!Ref UseCacheBucket, "true"]
CreateDistribution:
Fn::Or:
- Fn::Equals: [!Ref UseCloudFront, "true"]
- Fn::Equals: [!Ref UseCacheBucket, "true"]
- Fn::Not:
- Fn::Equals: [!Ref PreflightFunctionARN, ""]
CreateCacheBucketAndDistribution:
Fn::And:
- Condition: CreateCacheBucket
- Condition: CreateDistribution
UsePreflightFunction:
Fn::Not:
- Fn::Equals: [!Ref PreflightFunctionARN, ""]
Resources:
Dependencies:
Type: "AWS::Serverless::LayerVersion"
Expand All @@ -69,10 +78,10 @@ Resources:
Description: Dependencies for IIIF app
ContentUri: ./dependencies
CompatibleRuntimes:
- nodejs12.x
- nodejs14.x
LicenseInfo: "Apache-2.0"
Metadata:
BuildMethod: nodejs12.x
BuildMethod: nodejs14.x
CacheBucket:
Type: "AWS::S3::Bucket"
Condition: CreateCacheBucket
Expand Down Expand Up @@ -102,7 +111,7 @@ Resources:
IiifFunction:
Type: "AWS::Serverless::Function"
Properties:
Runtime: nodejs12.x
Runtime: nodejs14.x
Handler: index.handler
MemorySize: 3008
Timeout:
Expand Down Expand Up @@ -144,7 +153,12 @@ Resources:
Fn::If:
- CreateCacheBucket
- Fn::Sub: "${AWS::StackName}-cache"
- AWS::NoValue
- !Ref AWS::NoValue
preflight:
Fn::If:
- UsePreflightFunction
- true
- !Ref AWS::NoValue
tiffBucket:
Fn::Sub: "${SourceBucket}"
Events:
Expand Down Expand Up @@ -509,7 +523,7 @@ Resources:
- !Ref CachingIdentity
DomainName:
Fn::Sub: "${CacheBucket}.s3.${AWS::Region}.amazonaws.com"
- AWS::NoValue
- !Ref AWS::NoValue
OriginGroups:
Quantity:
Fn::If: [CreateCacheBucket, 1, 0]
Expand All @@ -536,6 +550,12 @@ Resources:
MinTTL: !Ref CacheMinimumTTL
MaxTTL: !Ref CacheMaximumTTL
DefaultTTL: !Ref CacheDefaultTTL
FunctionAssociations:
Fn::If:
- UsePreflightFunction
- - EventType: viewer-request
FunctionARN: !Ref PreflightFunctionARN
- !Ref AWS::NoValue
Outputs:
Endpoint:
Description: IIIF Endpoint URL
Expand Down
4 changes: 2 additions & 2 deletions tests/__mocks/mockS3.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const S3 = jest.fn().mockImplementation(() => {
});

const upload = jest.fn((params, callback) => {
if (params.Key == "new_cache_key/default.jpg") {
if (params.Key === "new_cache_key/default.jpg") {
callback(null, {});
} else {
callback("unknown cache key", null);
Expand All @@ -45,7 +45,7 @@ const upload = jest.fn((params, callback) => {
const S3Cache = jest.fn().mockImplementation(() => {
return {
headObject: function (params, callback) {
if (params.Key == 'cache_hit/default.jpg') {
if (params.Key === 'cache_hit/default.jpg') {
callback(null, {});
} else {
callback("error", null);
Expand Down

0 comments on commit ca2758a

Please sign in to comment.