diff --git a/.gitignore b/.gitignore index 9fd926c..694cd0c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,15 @@ +# Terraform **/.terraform **/builds *.tfstate *.tfvars *.plan + +# SAM +samconfig.* +.aws-sam +package.yml + +# Local Build node_modules -data_services/model/.working \ No newline at end of file +data_services/model/.working diff --git a/iiif-server/README.md b/iiif-server/README.md new file mode 100644 index 0000000..3cdc58c --- /dev/null +++ b/iiif-server/README.md @@ -0,0 +1,36 @@ +# iiif-server + +This SAM project project includes the viewer-request function and SAM template required to install and configure the NUL IIIF Server. + +## Usage + +The `config-env` parameter will always be the environment you're deploying to (e.g., `staging`) + +### First Use +``` +sam build +sam deploy --config-env staging --guided +``` + +This will prompt for all of the deploy parameters, and then give you the option to save them in a config file for later use. + +### Subsequent Use +``` +sam build +sam deploy --config-env staging +``` + +This will use the previously saved config file + +## Deploy parameters + + - `Hostname`: The host part of the server FQDN (e.g., `iiif`) + - `DomainName`: The domain part of the server FQDN (e.g., `rdc.library.northwestern.edu`) + - `AllowFromReferers`: A regular expression to match against the Referer header for pass-through - `authorization` (Default: `""`) + - `CertificateArn`: The ARN of an SSL certificate that matches the server FQDN + - `DcApiEndpoint`: The public endpoint for the DC API + - `IiifLambdaMemory`: The amount of memory in MB for the IIIF lambda to use (Default: `2048`) + - `IiifLambdaTimeout`: The timeout for the IIIF lambda (Default: `10`) + - `Namespace`: The infrastructure namespace prefix to use for secrets management + - `ServerlessIiifVersion`: The version of Serverless IIIF to deploy (Default: `5.0.0`) + - `SourceBucket`: The bucket where the pyramid TIFF images are stored diff --git a/iiif-server/src/authorize.js b/iiif-server/src/authorize.js new file mode 100644 index 0000000..eb0c415 --- /dev/null +++ b/iiif-server/src/authorize.js @@ -0,0 +1,62 @@ +const isString = require("lodash.isstring"); +const fetch = require("node-fetch"); + +let allowFrom, dcApiEndpoint; +function reconfigure(config) { + ({ allowFrom, dcApiEndpoint } = config); +} + +function allowedFromRegexes(str) { + var configValues = isString(str) ? str.split(";") : []; + var result = []; + for (var re in configValues) { + result.push(new RegExp(configValues[re])); + } + return result; +} + +function isBlurred({ region, size }) { + if (region !== "full") return false; // not a full frame request + if (typeof size !== "string") return false; // size parameter not specified + + const match = size.match(/!(\d+)?,(\d+)?/); + if (match === null) return false; // constrained height and width not specified + const width = Number(match[1]); + const height = Number(match[2]); + if (width <= 5 || height <= 5) return true; // image constrained to <=5px in its largest dimension + + return false; +} + +async function authorize(params, referer, cookie, clientIp, config) { + reconfigure(config); + const allowedFrom = allowedFromRegexes(allowFrom); + + if (params.filename == "info.json") return true; + if (isBlurred(params)) return true; + + for (var re in allowedFrom) { + if (allowedFrom[re].test(referer)) return true; + } + + const id = params.id.split("/").slice(-1)[0]; + + return await getImageAuthorization(id, cookie, clientIp); +} + +async function getImageAuthorization(id, cookieHeader, clientIp) { + const opts = { + headers: { + cookie: cookieHeader, + }, + }; + if (clientIp) opts.headers["x-client-ip"] = clientIp; + + const response = await fetch( + `${dcApiEndpoint}/file-sets/${id}/authorization`, + opts + ); + return response.status == 204; +} + +module.exports = authorize; diff --git a/iiif-server/src/index.js b/iiif-server/src/index.js new file mode 100644 index 0000000..a9dadc5 --- /dev/null +++ b/iiif-server/src/index.js @@ -0,0 +1,165 @@ +const authorize = require("./authorize"); +const middy = require("@middy/core"); +const secretsManager = require("@middy/secrets-manager"); + +function getEventHeader(request, name) { + if ( + request.headers && + request.headers[name] && + request.headers[name].length > 0 + ) { + return request.headers[name][0].value; + } else { + return undefined; + } +} + +function viewerRequestOptions(request) { + const origin = getEventHeader(request, "origin") || "*"; + return { + status: "200", + statusDescription: "OK", + headers: { + "access-control-allow-headers": [ + { key: "Access-Control-Allow-Headers", value: "authorization, cookie" } + ], + "access-control-allow-credentials": [ + { key: "Access-Control-Allow-Credentials", value: "true" } + ], + "access-control-allow-methods": [ + { key: "Access-Control-Allow-Methods", value: "GET, OPTIONS" } + ], + "access-control-allow-origin": [ + { key: "Access-Control-Allow-Origin", value: origin } + ] + }, + body: "OK" + }; +} + +function parsePath(path) { + const segments = path.split(/\//).reverse(); + + if (segments.length < 8) { + return { + poster: segments[2] == "posters", + id: segments[1], + filename: segments[0] + }; + } else { + return { + poster: segments[5] == "posters", + id: segments[4], + region: segments[3], + size: segments[2], + rotation: segments[1], + filename: segments[0] + }; + } +} + +async function viewerRequestIiif(request, { config }) { + const path = decodeURI(request.uri.replace(/%2f/gi, "")); + const params = parsePath(path); + const referer = getEventHeader(request, "referer"); + const cookie = getEventHeader(request, "cookie"); + const authed = await authorize( + params, + referer, + cookie, + request.clientIp, + config + ); + console.log("Authorized:", authed); + + // Return a 403 response if not authorized to view the requested item + if (!authed) { + return { + status: "403", + statusDescription: "Forbidden", + body: "Forbidden" + }; + } + + // Set the x-preflight-location request header to the location of the requested item + const pairtree = params.id.match(/.{1,2}/g).join("/"); + const s3Location = params.poster + ? `s3://${config.tiffBucket}/posters/${pairtree}-poster.tif` + : `s3://${config.tiffBucket}/${pairtree}-pyramid.tif`; + request.headers["x-preflight-location"] = [ + { key: "X-Preflight-Location", value: s3Location } + ]; + return request; +} + +async function processViewerRequest(event, context) { + console.log("Initiating viewer-request trigger"); + const { request } = event.Records[0].cf; + let result; + + if (request.method === "OPTIONS") { + // Intercept OPTIONS request and return proper response + result = viewerRequestOptions(request); + } else { + result = await viewerRequestIiif(request, context); + } + + return result; +} + +// async function logSecret() { +// const { +// SecretsManagerClient, +// GetSecretValueCommand +// } = require("@aws-sdk/client-secrets-manager"); +// const client = new SecretsManagerClient(); +// const result = await client.send( +// new GetSecretValueCommand({ +// SecretId: process.env.AWS_LAMBDA_FUNCTION_NAME +// }) +// ); +// console.log("raw", result.SecretString); +// console.log("parsed", JSON.parse(result.SecretString)); +// } + +async function processRequest(event, context) { + const { eventType } = event.Records[0].cf.config; + let result; + + console.log("Event Type:", eventType); + if (eventType === "viewer-request") { + result = await processViewerRequest(event, context); + } else { + result = event.Records[0].cf.request; + } + + return result; +} + +function functionNameAndRegion() { + let nameVar = process.env.AWS_LAMBDA_FUNCTION_NAME; + const match = /^(?[a-z]{2}-[a-z]+-\d+)\.(?.+)$/.exec(nameVar); + if (match) { + return { ...match.groups } + } else { + return { + functionName: nameVar, + functionRegion: process.env.AWS_REGION + } + } +} + +const { functionName, functionRegion } = functionNameAndRegion(); +console.log("Initializing", functionName, 'in', functionRegion); + +module.exports = { + handler: + middy(processRequest) + .use( + secretsManager({ + fetchData: { config: functionName }, + awsClientOptions: { region: functionRegion }, + setToContext: true + }) + ) +}; diff --git a/iiif-server/src/package-lock.json b/iiif-server/src/package-lock.json new file mode 100644 index 0000000..71c5b78 --- /dev/null +++ b/iiif-server/src/package-lock.json @@ -0,0 +1,101 @@ +{ + "name": "iiif-trigger-lambda", + "version": "1.1.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "iiif-trigger-lambda", + "version": "1.1.0", + "license": "Apache-2.0", + "dependencies": { + "@middy/core": "4.6.4", + "@middy/secrets-manager": "4.6.4", + "lodash.isstring": "^4.0.1", + "node-fetch": "^2.3.0" + } + }, + "node_modules/@middy/core": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/@middy/core/-/core-4.6.4.tgz", + "integrity": "sha512-yO+z6ijOFUeXJj/SOmEy2lP0ZxGGkJyaNXSUUQZdyLeNIzHtqqCvef/eIym5WZnl4prEr76sHCv0dwEmfSwYLQ==", + "engines": { + "node": ">=16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/willfarrell" + } + }, + "node_modules/@middy/secrets-manager": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/@middy/secrets-manager/-/secrets-manager-4.6.4.tgz", + "integrity": "sha512-ORYsn+k9iomujAZtDOsCYtk3dAYYHtddKYul1Dc5jZvObfwJ4K8YYPZAKYf9Gz3cf5yxgDDfmW6K/1Eg6rcr6w==", + "dependencies": { + "@middy/util": "4.6.4" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/willfarrell" + } + }, + "node_modules/@middy/util": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/@middy/util/-/util-4.6.4.tgz", + "integrity": "sha512-U9wQtmBlnHKdkTMUfdJXVkVRU0BKdXBOi3jFPalUVgZL/SlMACLyelitFUmNhfsjXBPvBdPJg7wadnAzzWRRZQ==", + "engines": { + "node": ">=16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/willfarrell" + } + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + }, + "node_modules/node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==", + "engines": { + "node": "4.x || >=6.0.0" + } + } + }, + "dependencies": { + "@middy/core": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/@middy/core/-/core-4.6.4.tgz", + "integrity": "sha512-yO+z6ijOFUeXJj/SOmEy2lP0ZxGGkJyaNXSUUQZdyLeNIzHtqqCvef/eIym5WZnl4prEr76sHCv0dwEmfSwYLQ==" + }, + "@middy/secrets-manager": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/@middy/secrets-manager/-/secrets-manager-4.6.4.tgz", + "integrity": "sha512-ORYsn+k9iomujAZtDOsCYtk3dAYYHtddKYul1Dc5jZvObfwJ4K8YYPZAKYf9Gz3cf5yxgDDfmW6K/1Eg6rcr6w==", + "requires": { + "@middy/util": "4.6.4" + } + }, + "@middy/util": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/@middy/util/-/util-4.6.4.tgz", + "integrity": "sha512-U9wQtmBlnHKdkTMUfdJXVkVRU0BKdXBOi3jFPalUVgZL/SlMACLyelitFUmNhfsjXBPvBdPJg7wadnAzzWRRZQ==" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + }, + "node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" + } + } +} diff --git a/iiif-server/src/package.json b/iiif-server/src/package.json new file mode 100644 index 0000000..1d09714 --- /dev/null +++ b/iiif-server/src/package.json @@ -0,0 +1,14 @@ +{ + "name": "iiif-trigger-lambda", + "version": "1.1.0", + "description": "IIIF Server Preflight Function", + "main": "index.js", + "author": "Michael B. Klein", + "license": "Apache-2.0", + "dependencies": { + "@middy/core": "4.6.4", + "@middy/secrets-manager": "4.6.4", + "lodash.isstring": "^4.0.1", + "node-fetch": "^2.3.0" + } +} diff --git a/iiif-server/template.yaml b/iiif-server/template.yaml new file mode 100644 index 0000000..541b520 --- /dev/null +++ b/iiif-server/template.yaml @@ -0,0 +1,212 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::Serverless-2016-10-31 + - AWS::LanguageExtensions +Description: > + nul-iiif-server + + Deployment wrapper for NUL instance of serverless-iiif +Parameters: + Hostname: + Type: String + DomainName: + Type: String + AllowFromReferers: + Type: String + Default: "" + CertificateArn: + Type: String + DcApiEndpoint: + Type: String + IiifLambdaMemory: + Type: Number + Default: 2048 + IiifLambdaTimeout: + Type: Number + Default: 10 + Namespace: + Type: String + ServerlessIiifVersion: + Type: String + Default: 5.0.0 + SourceBucket: + Type: String +Resources: + ServerlessIiif: + Type: AWS::Serverless::Application + Properties: + Location: + ApplicationId: arn:aws:serverlessrepo:us-east-1:625046682746:applications/serverless-iiif + SemanticVersion: !Ref ServerlessIiifVersion + Parameters: + CorsAllowCredentials: "true" + CorsAllowHeaders: "authorization, cookie" + CorsAllowOrigin: REFLECT_ORIGIN + ForceHost: !Sub "${Hostname}.${DomainName}" + IiifLambdaMemory: !Ref IiifLambdaMemory + IiifLambdaTimeout: !Ref IiifLambdaTimeout + PixelDensity: 600 + Preflight: "true" + SharpLayer: "INTERNAL" + SourceBucket: !Ref SourceBucket + ViewerRequestFunction: + Type: AWS::Serverless::Function + Properties: + Runtime: nodejs18.x + CodeUri: ./src + Handler: index.handler + Architectures: + - x86_64 + Timeout: 3 + MemorySize: 128 + AutoPublishAlias: "Latest" + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: + - edgelambda.amazonaws.com + - lambda.amazonaws.com + Action: + - 'sts:AssumeRole' + ViewerRequestSecret: + Type: AWS::SecretsManager::Secret + Properties: + Name: !Ref ViewerRequestFunction + SecretString: + Fn::ToJsonString: + allowFrom: !Ref AllowFromReferers + dcApiEndpoint: !Ref DcApiEndpoint + tiffBucket: !Ref SourceBucket + ViewerRequestSecretAccess: + Type: AWS::SecretsManager::ResourcePolicy + Properties: + BlockPublicPolicy: true + ResourcePolicy: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - secretsmanager:DescribeSecret + - secretsmanager:GetSecretValue + Principal: + AWS: !GetAtt ViewerRequestFunctionRole.Arn + Resource: "*" + SecretId: !Ref ViewerRequestSecret + ConfigurationSecret: + Type: AWS::SecretsManager::Secret + Properties: + Name: !Sub "${Namespace}/iiif-url" + SecretString: + Fn::ToJsonString: + base: !Sub "https://${Hostname}.${DomainName}/" + v2: !Sub "https://${Hostname}.${DomainName}/iiif/2/" + v3: !Sub "https://${Hostname}.${DomainName}/iiif/3/" + OriginRequestPolicy: + Type: "AWS::CloudFront::OriginRequestPolicy" + Properties: + OriginRequestPolicyConfig: + Name: !Sub "${AWS::StackName}-allow-preflight-headers" + Comment: Allows IIIF preflight headers + CookiesConfig: + CookieBehavior: none + HeadersConfig: + HeaderBehavior: whitelist + Headers: + - x-preflight-location + - x-preflight-dimensions + QueryStringsConfig: + QueryStringBehavior: none + ResponseHeaderPolicy: + Type: AWS::CloudFront::ResponseHeadersPolicy + Properties: + ResponseHeadersPolicyConfig: + Name: !Sub "${AWS::StackName}-allow-cors-response-headers" + Comment: Allows IIIF CORS response headers + CorsConfig: + AccessControlAllowCredentials: false + AccessControlAllowHeaders: + Items: + - "*" + AccessControlAllowMethods: + Items: + - GET + - OPTIONS + AccessControlAllowOrigins: + Items: + - "*" + AccessControlExposeHeaders: + Items: + - cache-control + - content-language + - content-length + - content-type + - date + - expires + - last-modified + - pragma + AccessControlMaxAgeSec: 3600 + OriginOverride: false + CachingDistribution: + Type: AWS::CloudFront::Distribution + Properties: + DistributionConfig: + Enabled: true + PriceClass: PriceClass_100 + Aliases: + - !Sub "${Hostname}.${DomainName}" + ViewerCertificate: + AcmCertificateArn: !Ref CertificateArn + MinimumProtocolVersion: TLSv1 + SslSupportMethod: sni-only + Origins: + - Id: IiifLambda + CustomOriginConfig: + OriginProtocolPolicy: https-only + DomainName: !GetAtt ServerlessIiif.Outputs.FunctionDomain + - Id: PublicManifests + DomainName: + Fn::Sub: ${SourceBucket}.s3.${AWS::Region}.amazonaws.com + S3OriginConfig: {} + DefaultCacheBehavior: + TargetOriginId: IiifLambda + ViewerProtocolPolicy: https-only + AllowedMethods: + - GET + - HEAD + - OPTIONS + CachedMethods: + - GET + - HEAD + CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 + OriginRequestPolicyId: !Ref OriginRequestPolicy + ResponseHeadersPolicyId: !Ref ResponseHeaderPolicy + LambdaFunctionAssociations: + - EventType: viewer-request + LambdaFunctionARN: !Ref ViewerRequestFunction.Version + IncludeBody: false + CacheBehaviors: + - AllowedMethods: + - GET + - HEAD + - OPTIONS + CachedMethods: + - GET + - HEAD + - OPTIONS + Compress: true + CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad + ResponseHeadersPolicyId: !Ref ResponseHeaderPolicy + PathPattern: "public/*" + TargetOriginId: PublicManifests + ViewerProtocolPolicy: https-only + Route53Record: + Type: AWS::Route53::RecordSet + Properties: + Name: !Sub "${Hostname}.${DomainName}" + HostedZoneName: !Sub "${DomainName}." + Type: A + AliasTarget: + DNSName: !GetAtt CachingDistribution.DomainName + HostedZoneId: Z2FDTNDATAQYW2