├── .cfnnag_global_suppress_list ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── general_question.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── build.yml │ └── upgrade-dependencies.yml ├── .gitignore ├── .viperlightignore ├── .viperlightrc ├── .vscode └── settings.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── NOTICE.txt ├── README.md ├── architecture.png ├── buildspec.yml ├── deployment ├── build-s3-dist.sh ├── build-s3-dist2.sh ├── helper.py └── run-unit-tests.sh └── source ├── constructs ├── .eslintrc.json ├── .gitattributes ├── .gitignore ├── .npmignore ├── README.md ├── bin │ └── constructs.ts ├── cdk.context.json ├── cdk.json ├── ecs-image-handler-arch.svg ├── images │ ├── 01-edit-bucket-policy.png │ └── 02-edit-bucket-policy.png ├── lambda-image-handler-arch.svg ├── lib │ ├── api.json │ ├── constructs-stack.ts │ ├── ecs-image-handler.ts │ ├── lambda-image-handler.ts │ └── serverless-image-handler.ts ├── package.json ├── test │ ├── __snapshots__ │ │ ├── constructs.test.ts.snap │ │ └── ecs-image-handler.test.ts.snap │ ├── constructs.test.ts │ ├── ecs-image-handler.test.ts │ └── serverless-image-handler-test.json ├── tsconfig.eslint.json ├── tsconfig.jest.json ├── tsconfig.json └── yarn.lock ├── custom-resource ├── index.js ├── package.json └── test │ └── index.spec.js ├── demo-ui ├── index.html ├── scripts.js └── style.css ├── image-handler ├── image-handler.js ├── image-request.js ├── index.js ├── package.json ├── test │ ├── image-handler.spec.js │ ├── image-request.spec.js │ ├── image │ │ └── test.jpg │ ├── index.spec.js │ └── thumbor-mapping.spec.js └── thumbor-mapping.js └── new-image-handler ├── .dockerignore ├── .eslintrc.json ├── .gitattributes ├── .gitignore ├── .npmignore ├── Dockerfile ├── Dockerfile.dev ├── Dockerfile.lambda ├── Dockerfile.lambda.deps ├── ImageMagick.Makefile ├── fonts ├── FangZhengHeiTi-GBK-1.ttf └── README.md ├── package.json ├── src ├── config.ts ├── debug.ts ├── default.ts ├── imagemagick.ts ├── index-lambda.ts ├── index.ts ├── is.ts ├── processor │ ├── image │ │ ├── _base.ts │ │ ├── auto-orient.ts │ │ ├── blur.ts │ │ ├── bright.ts │ │ ├── cgif.ts │ │ ├── circle.ts │ │ ├── contrast.ts │ │ ├── crop.ts │ │ ├── format.ts │ │ ├── grey.ts │ │ ├── index.ts │ │ ├── indexcrop.ts │ │ ├── info.ts │ │ ├── interlace.ts │ │ ├── jpeg.js │ │ ├── quality.ts │ │ ├── resize.ts │ │ ├── rotate.ts │ │ ├── rounded-corners.ts │ │ ├── sharpen.ts │ │ ├── strip-metadata.ts │ │ ├── threshold.ts │ │ └── watermark.ts │ ├── index.ts │ ├── style.ts │ └── video.ts ├── store.ts ├── style.json └── typings.d.ts ├── test ├── bench │ ├── README.md │ ├── curl.sh │ ├── perf.ts │ ├── urls.txt │ └── vegeta-urls.txt ├── default.test.ts ├── e2e │ ├── README.md │ └── mkhtml.py ├── fixtures │ ├── .gitignore │ ├── aws_logo.png │ ├── example.gif │ ├── example.jpg │ ├── example.mp4 │ ├── f.jpg │ └── gray-200x100.jpg ├── imagemagick.test.ts ├── index-lambda.test.ts ├── processor │ ├── image │ │ ├── _base.test.ts │ │ ├── auto-orient.test.ts │ │ ├── blur.test.ts │ │ ├── bright.test.ts │ │ ├── cgif.test.ts │ │ ├── circle.test.ts │ │ ├── contrast.test.ts │ │ ├── crop.test.ts │ │ ├── format.test.ts │ │ ├── grey.test.ts │ │ ├── indexcrop.test.ts │ │ ├── info.test.ts │ │ ├── interlace.test.ts │ │ ├── quality.test.ts │ │ ├── resize.test.ts │ │ ├── rotate.test.ts │ │ ├── rounded-corners.test.ts │ │ ├── sharpen.test.ts │ │ ├── threshold.test.ts │ │ ├── utils.ts │ │ └── watermark.test.ts │ └── index.test.ts └── store.test.ts ├── tsconfig.eslint.json ├── tsconfig.jest.json ├── tsconfig.json └── yarn.lock /.cfnnag_global_suppress_list: -------------------------------------------------------------------------------- 1 | # Instructions 2 | # ------------ 3 | # 1) Add any cfn_nag rules that don't apply to this solution, providing a reason for each item 4 | # 2) Rename this file to .cfnnag_global_suppress_list 5 | # Reference: https://github.com/stelligent/cfn_nag#global-blacklist 6 | --- 7 | RulesToSuppress: 8 | - id: W89 9 | reason: Lambda functions in this solution do not need to be deployed in a VPC 10 | - id: W33 11 | reason: Your reason 12 | - id: W58 13 | reason: Your reason 14 | - id: W40 15 | reason: Your reason 16 | - id: W5 17 | reason: Your reason 18 | - id: W60 19 | reason: Your reason 20 | - id: W92 21 | reason: Your reason 22 | 23 | - id: W46 24 | reason: ApiGateway V2 should have access logging configured 25 | - id: W10 26 | reason: CloudFront Distribution should enable access logging 27 | - id: W70 28 | reason: Cloudfront should use minimum protocol version TLS 1.2 29 | - id: W78 30 | reason: DynamoDB table should have backup enabled, should be set using PointInTimeRecoveryEnabled 31 | - id: W74 32 | reason: DynamoDB table should have encryption enabled using a CMK stored in KMS -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 12 | 13 | **To Reproduce** 14 | 15 | 16 | **Expected behavior** 17 | 18 | 19 | **Please complete the following information about the solution:** 20 | - [ ] Version: [e.g. v1.0.0] 21 | - [ ] Region: [e.g. us-east-1] 22 | - [ ] Was the solution modified from the version published on this repository? 23 | - [ ] If the answer to the previous question was yes, are the changes available on GitHub? 24 | - [ ] Have you checked your [service quotas](https://docs.aws.amazon.com/general/latest/gr/aws_service_limits.html) for the sevices this solution uses? 25 | - [ ] Were there any errors in the CloudWatch Logs? 26 | 27 | **Screenshots** 28 | 29 | 30 | **Additional context** 31 | 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this solution 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | 12 | 13 | **Describe the feature you'd like** 14 | 15 | 16 | **Additional context** 17 | 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/general_question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: General question 3 | about: Ask a general question 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What is your question?** 11 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **Issue #, if available:** 2 | 3 | 4 | 5 | 6 | **Description of changes:** 7 | 8 | 9 | 10 | **Checklist** 11 | - [ ] :wave: I have run the unit tests, and all unit tests have passed. 12 | - [ ] :warning: This pull request might incur a breaking change. 13 | 14 | By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. 15 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | name: Build 4 | on: 5 | push: 6 | branches: 7 | - master 8 | pull_request: {} 9 | workflow_dispatch: {} 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node: [ '14' ] 16 | name: Node ${{ matrix.node }} 17 | defaults: 18 | run: 19 | working-directory: source/constructs 20 | permissions: 21 | checks: write 22 | contents: write 23 | env: 24 | CI: "true" 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v2 28 | with: 29 | ref: ${{ github.event.pull_request.head.ref }} 30 | repository: ${{ github.event.pull_request.head.repo.full_name }} 31 | - name: Setup node 32 | uses: actions/setup-node@v2 33 | with: 34 | node-version: ${{ matrix.node }} 35 | - name: Install dependencies 36 | run: yarn install --check-files --frozen-lockfile 37 | - name: Test 38 | run: yarn test 39 | - name: CDK synth 40 | run: yarn synth 41 | - name: Build docker 42 | run: yarn build:new-image-handler:docker 43 | -------------------------------------------------------------------------------- /.github/workflows/upgrade-dependencies.yml: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | name: upgrade-dependencies 4 | on: 5 | workflow_dispatch: {} 6 | schedule: 7 | - cron: 0 0 * * * 8 | jobs: 9 | upgrade-new-image-handler: 10 | name: Upgrade 11 | runs-on: ubuntu-latest 12 | defaults: 13 | run: 14 | working-directory: source/new-image-handler 15 | permissions: 16 | contents: read 17 | outputs: 18 | conclusion: ${{ steps.build.outputs.conclusion }} 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v2 22 | - name: Install dependencies 23 | run: yarn install --check-files --frozen-lockfile 24 | - name: Upgrade dependencies 25 | run: yarn upgrade-dependencies 26 | - name: Build 27 | id: build 28 | run: yarn build && yarn build:new-image-handler:docker && echo "::set-output name=conclusion::success" || echo 29 | "::set-output name=conclusion::failure" 30 | - name: Create Patch 31 | run: |- 32 | git add . 33 | git diff --patch --staged > .upgrade.tmp.patch 34 | - name: Upload patch 35 | uses: actions/upload-artifact@v2 36 | with: 37 | name: .upgrade.tmp.patch 38 | path: source/new-image-handler/.upgrade.tmp.patch 39 | pr: 40 | name: Create Pull Request 41 | needs: upgrade-new-image-handler 42 | runs-on: ubuntu-latest 43 | defaults: 44 | run: 45 | working-directory: source/new-image-handler 46 | permissions: 47 | contents: write 48 | pull-requests: write 49 | checks: write 50 | steps: 51 | - name: Checkout 52 | uses: actions/checkout@v2 53 | - name: Download patch 54 | uses: actions/download-artifact@v2 55 | with: 56 | name: .upgrade.tmp.patch 57 | path: ${{ runner.temp }} 58 | - name: Apply patch 59 | run: '[ -s ${{ runner.temp }}/.upgrade.tmp.patch ] && git apply ${{ runner.temp 60 | }}/.upgrade.tmp.patch || echo "Empty patch. Skipping."' 61 | - name: Create Pull Request 62 | id: create-pr 63 | uses: peter-evans/create-pull-request@v3 64 | with: 65 | token: ${{ secrets.GITHUB_TOKEN }} 66 | commit-message: >- 67 | chore(deps): upgrade dependencies 68 | 69 | 70 | Upgrades project dependencies. See details in [workflow run]. 71 | 72 | 73 | [Workflow Run]: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} 74 | 75 | 76 | ------ 77 | 78 | 79 | *Automatically created by projen via the "upgrade-dependencies" workflow* 80 | branch: github-actions/upgrade-dependencies 81 | title: "chore(deps): upgrade dependencies" 82 | body: >- 83 | Upgrades project dependencies. See details in [workflow run]. 84 | 85 | 86 | [Workflow Run]: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} 87 | 88 | 89 | ------ 90 | 91 | 92 | *Automatically created by projen via the "upgrade-dependencies" workflow* 93 | - name: Update status check 94 | if: steps.create-pr.outputs.pull-request-url != '' 95 | run: "curl -i --fail -X POST -H \"Accept: application/vnd.github.v3+json\" -H 96 | \"Authorization: token ${GITHUB_TOKEN}\" 97 | https://api.github.com/repos/${{ github.repository }}/check-runs -d 98 | '{\"name\":\"build\",\"head_sha\":\"github-actions/upgrade-dependenci\ 99 | es\",\"status\":\"completed\",\"conclusion\":\"${{ 100 | needs.upgrade.outputs.conclusion }}\",\"output\":{\"title\":\"Created 101 | via the upgrade-dependencies workflow.\",\"summary\":\"Action run URL: 102 | https://github.com/${{ github.repository }}/actions/runs/${{ 103 | github.run_id }}\"}}'" 104 | env: 105 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 106 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | **/dist 3 | **/global-s3-assets 4 | **/regional-s3-assets 5 | **/open-source 6 | **/.zip 7 | **/tmp 8 | **/out-tsc 9 | 10 | # dependencies 11 | **/node_modules 12 | 13 | # test assets 14 | **/coverage 15 | **/.nyc_output 16 | 17 | # misc 18 | **/npm-debug.log 19 | **/testem.log 20 | **/package-lock.json 21 | **/.vscode/settings.json 22 | demo-ui-config.js 23 | 24 | # System Files 25 | **/.DS_Store 26 | -------------------------------------------------------------------------------- /.viperlightignore: -------------------------------------------------------------------------------- 1 | # Ignore Config used with code.amazon.com 2 | Config 3 | 4 | # Use of opensource-codeofconduct email for amazon is expected 5 | CODE_OF_CONDUCT.md 6 | CONTRIBUTING.md 7 | README.md 8 | docs/en/index.md 9 | docs/zh/index.md 10 | 11 | cdk.out/ 12 | ^dist/ 13 | node_modules/ 14 | package-lock.json 15 | yarn.lock 16 | -------------------------------------------------------------------------------- /.viperlightrc: -------------------------------------------------------------------------------- 1 | { 2 | "all": true, 3 | "failOn": "medium" 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.format.enable": true, 3 | "eslint.lintTask.enable": true, 4 | "eslint.workingDirectories": [ 5 | "source/constructs", 6 | "source/new-image-handler", 7 | ], 8 | "yaml.customTags": [ 9 | "!And", 10 | "!And sequence", 11 | "!If", 12 | "!If sequence", 13 | "!Not", 14 | "!Not sequence", 15 | "!Equals", 16 | "!Equals sequence", 17 | "!Or", 18 | "!Or sequence", 19 | "!FindInMap", 20 | "!FindInMap sequence", 21 | "!Base64", 22 | "!Join", 23 | "!Join sequence", 24 | "!Cidr", 25 | "!Ref", 26 | "!Sub", 27 | "!Sub sequence", 28 | "!GetAtt", 29 | "!GetAZs", 30 | "!ImportValue", 31 | "!ImportValue sequence", 32 | "!Select", 33 | "!Select sequence", 34 | "!Split", 35 | "!Split sequence" 36 | ], 37 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 3 | documentation, we greatly value feedback and contributions from our community. 4 | 5 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 6 | information to effectively respond to your bug report or contribution. 7 | 8 | ## Reporting Bugs/Feature Requests 9 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 10 | 11 | When filing an issue, please check [existing open](https://github.com/awslabs/serverless-image-handler/issues), or [recently closed](https://github.com/awslabs/serverless-image-handler/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already 12 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 13 | 14 | * A reproducible test case or series of steps 15 | * The version of our code being used 16 | * Any modifications you've made relevant to the bug 17 | * Anything unusual about your environment or deployment 18 | 19 | ## Contributing via Pull Requests 20 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 21 | 22 | 1. You are working against the latest source on the *master* branch. 23 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 24 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 25 | 26 | To send us a pull request, please: 27 | 28 | 1. Fork the repository. 29 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 30 | 3. Ensure local tests pass. 31 | 4. Commit to your fork using clear commit messages. 32 | 5. Send us a pull request, answering any default questions in the pull request interface. 33 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 34 | 35 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 36 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 37 | 38 | ## Finding contributions to work on 39 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels ((enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/awslabs/serverless-image-handler/labels/help%20wanted) issues is a great place to start. 40 | 41 | ## Code of Conduct 42 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 43 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 44 | opensource-codeofconduct@amazon.com with any additional questions or comments. 45 | 46 | ## Security issue notifications 47 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 48 | 49 | ## Licensing 50 | See the [LICENSE](https://github.com/awslabs/serverless-image-handler/blob/master/LICENSE.txt) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 51 | We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. 52 | -------------------------------------------------------------------------------- /NOTICE.txt: -------------------------------------------------------------------------------- 1 | Serverless Image Handler 2 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | ********************** 5 | THIRD PARTY COMPONENTS 6 | ********************** 7 | This software includes third party software subject to the following copyrights: 8 | 9 | aws-sdk under the Apache License Version 2.0 10 | axios under the Massachusetts Institute of Technology (MIT) license 11 | axios-mock-adapter under the Massachusetts Institute of Technology (MIT) license 12 | bootstrap under the Massachusetts Institute of Technology (MIT) license 13 | color under the Massachusetts Institute of Technology (MIT) license 14 | color-name under the Massachusetts Institute of Technology (MIT) license 15 | jest under the Massachusetts Institute of Technology (MIT) license 16 | mocha under the Massachusetts Institute of Technology (MIT) license 17 | sharp under the Apache License Version 2.0 18 | uuid under the Massachusetts Institute of Technology (MIT) license -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **_Important Notice:_** 2 | Due to a [change in the AWS Lambda execution environment](https://aws.amazon.com/blogs/compute/upcoming-updates-to-the-aws-lambda-execution-environment/), Serverless Image Handler v3 deployments are functionally broken. To address the issue we have released [minor version update v3.1.1](https://solutions-reference.s3.amazonaws.com/serverless-image-handler/v3.1.1/serverless-image-handler.template). We recommend all users of v3 to run cloudformation stack update with v3.1.1. Additionally, we suggest you to look at v5 of the solution and migrate to v5 if it addresses all of your use cases. 3 | 4 | # AWS Serverless Image Handler Lambda wrapper for SharpJS 5 | A solution to dynamically handle images on the fly, utilizing Sharp (https://sharp.pixelplumbing.com/en/stable/). 6 | Published version, additional details and documentation are available here: https://aws.amazon.com/solutions/serverless-image-handler/ 7 | 8 | _Note:_ it is recommended to build the application binary on Amazon Linux. 9 | 10 | ## On This Page 11 | - [Architecture Overview](#architecture-overview) 12 | - [Creating a custom build](#creating-a-custom-build) 13 | - [External Contributors](#external-contributors) 14 | - [License](#license) 15 | 16 | ## Architecture Overview 17 | ![Architecture](architecture.png) 18 | 19 | The AWS CloudFormation template deploys an Amazon CloudFront distribution, Amazon API Gateway REST API, and an AWS Lambda function. Amazon CloudFront provides a caching layer to reduce the cost of image processing and the latency of subsequent image delivery. The Amazon API Gateway provides endpoint resources and triggers the AWS Lambda function. The AWS Lambda function retrieves the image from the customer's Amazon Simple Storage Service (Amazon S3) bucket and uses Sharp to return a modified version of the image to the API Gateway. Additionally, the solution generates a CloudFront domain name that provides cached access to the image handler API. 20 | 21 | _**Note**:_ From v5.0, all AWS CloudFormation template resources are created be [AWS CDK](https://aws.amazon.com/cdk/) and [AWS Solutions Constructs](https://aws.amazon.com/solutions/constructs/). Since the AWS CloudFormation template resources have the same logical ID comparing to v4.x, it makes the solution upgradable mostly from v4.x to v5. 22 | 23 | ## Creating a custom build 24 | The solution can be deployed through the CloudFormation template available on the solution home page. 25 | To make changes to the solution, download or clone this repo, update the source code and then run the deployment/build-s3-dist.sh script to deploy the updated Lambda code to an Amazon S3 bucket in your account. 26 | 27 | ### Prerequisites: 28 | * [AWS Command Line Interface](https://aws.amazon.com/cli/) 29 | * Node.js 12.x or later 30 | 31 | ### 1. Clone the repository 32 | ```bash 33 | git clone https://github.com/awslabs/serverless-image-handler.git 34 | ``` 35 | 36 | ### 2. Run unit tests for customization 37 | Run unit tests to make sure added customization passes the tests: 38 | ```bash 39 | cd ./deployment 40 | chmod +x ./run-unit-tests.sh 41 | ./run-unit-tests.sh 42 | ``` 43 | 44 | ### 3. Declare environment variables 45 | ```bash 46 | export REGION=aws-region-code # the AWS region to launch the solution (e.g. us-east-1) 47 | export DIST_OUTPUT_BUCKET=my-bucket-name # bucket where customized code will reside 48 | export SOLUTION_NAME=my-solution-name # the solution name 49 | export VERSION=my-version # version number for the customized code 50 | ``` 51 | 52 | ### 4. Create an Amazon S3 Bucket 53 | The CloudFormation template is configured to pull the Lambda deployment packages from Amazon S3 bucket in the region the template is being launched in. Create a bucket in the desired region with the region name appended to the name of the bucket. 54 | ```bash 55 | aws s3 mb s3://$DIST_OUTPUT_BUCKET-$REGION --region $REGION 56 | ``` 57 | 58 | ### 5. Create the deployment packages 59 | Build the distributable: 60 | ```bash 61 | chmod +x ./build-s3-dist.sh 62 | ./build-s3-dist.sh $DIST_OUTPUT_BUCKET $SOLUTION_NAME $VERSION 63 | ``` 64 | 65 | Deploy the distributable to the Amazon S3 bucket in your account: 66 | ```bash 67 | aws s3 sync ./regional-s3-assets/ s3://$DIST_OUTPUT_BUCKET-$REGION/$SOLUTION_NAME/$VERSION/ --acl bucket-owner-full-control 68 | aws s3 sync ./global-s3-assets/ s3://$DIST_OUTPUT_BUCKET-$REGION/$SOLUTION_NAME/$VERSION/ --acl bucket-owner-full-control 69 | ``` 70 | 71 | ### 6. Launch the CloudFormation template. 72 | * Get the link of the `serverless-image-handler.template` uploaded to your Amazon S3 bucket. 73 | * Deploy the Serverless Image Handler solution to your account by launching a new AWS CloudFormation stack using the S3 link of the `serverless-image-handler.template`. 74 | 75 | ## External Contributors 76 | - [@leviwilson](https://github.com/leviwilson) for [#117](https://github.com/awslabs/serverless-image-handler/pull/117) 77 | - [@rpong](https://github.com/rpong) for [#130](https://github.com/awslabs/serverless-image-handler/pull/130) 78 | - [@harriswong](https://github.com/harriswong) for [#138](https://github.com/awslabs/serverless-image-handler/pull/138) 79 | - [@ganey](https://github.com/ganey) for [#139](https://github.com/awslabs/serverless-image-handler/pull/139) 80 | - [@browniebroke](https://github.com/browniebroke) for [#151](https://github.com/awslabs/serverless-image-handler/pull/151), [#152](https://github.com/awslabs/serverless-image-handler/pull/152) 81 | - [@john-shaffer](https://github.com/john-shaffer) for [#158](https://github.com/awslabs/serverless-image-handler/pull/158) 82 | - [@toredash](https://github.com/toredash) for [#174](https://github.com/awslabs/serverless-image-handler/pull/174), [#195](https://github.com/awslabs/serverless-image-handler/pull/195) 83 | - [@lith-imad](https://github.com/lith-imad) for [#194](https://github.com/awslabs/serverless-image-handler/pull/194) 84 | - [@pch](https://github.com/pch) for [#227](https://github.com/awslabs/serverless-image-handler/pull/227) 85 | - [@atrope](https://github.com/atrope) for [#201](https://github.com/awslabs/serverless-image-handler/pull/201) 86 | - [@bretto36](https://github.com/bretto36) for [#182](https://github.com/awslabs/serverless-image-handler/pull/182) 87 | - [@makoncline](https://github.com/makoncline) for [#255](https://github.com/awslabs/serverless-image-handler/pull/255) 88 | 89 | *** 90 | ## License 91 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
92 | SPDX-License-Identifier: Apache-2.0 93 | -------------------------------------------------------------------------------- /architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wchaws/serverless-image-handler/423c5e761af468ba2fa8eb9fbbd700573c614c0a/architecture.png -------------------------------------------------------------------------------- /buildspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | phases: 4 | install: 5 | runtime-versions: 6 | nodejs: 12 7 | build: 8 | commands: 9 | - ./deployment/build-s3-dist2.sh ${DIST_OUTPUT_BUCKET} ${SOLUTION_NAME} ${VERSION} 10 | - mkdir -p deployment/open-source/ && touch deployment/open-source/.empty 11 | post_build: 12 | commands: 13 | - aws s3 cp s3://solutions-build-assets/changelog-spec.yml buildspec.yml 14 | artifacts: 15 | files: 16 | - .git/**/* 17 | - deployment/**/* 18 | - buildspec.yml 19 | - CHANGELOG.md 20 | - .cfnnag_* 21 | -------------------------------------------------------------------------------- /deployment/build-s3-dist.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # This assumes all of the OS-level configuration has been completed and git repo has already been cloned 4 | # 5 | # This script should be run from the repo's deployment directory 6 | # cd deployment 7 | # ./build-s3-dist.sh source-bucket-base-name trademarked-solution-name version-code 8 | # 9 | # Paramenters: 10 | # - source-bucket-base-name: Name for the S3 bucket location where the template will source the Lambda 11 | # code from. The template will append '-[region_name]' to this bucket name. 12 | # For example: ./build-s3-dist.sh solutions my-solution v1.0.0 13 | # The template will then expect the source code to be located in the solutions-[region_name] bucket 14 | # 15 | # - trademarked-solution-name: name of the solution for consistency 16 | # 17 | # - version-code: version of the package 18 | 19 | # Check to see if input has been provided: 20 | if [ -z "$1" ] || [ -z "$2" ] || [ -z "$3" ]; then 21 | echo "Please provide the base source bucket name, trademark approved solution name and version where the lambda code will eventually reside." 22 | echo "For example: ./build-s3-dist.sh solutions trademarked-solution-name v1.0.0" 23 | exit 1 24 | fi 25 | 26 | set -e 27 | 28 | # Get reference for all important folders 29 | template_dir="$PWD" 30 | template_dist_dir="$template_dir/global-s3-assets" 31 | build_dist_dir="$template_dir/regional-s3-assets" 32 | source_dir="$template_dir/../source" 33 | 34 | echo "------------------------------------------------------------------------------" 35 | echo "Rebuild distribution" 36 | echo "------------------------------------------------------------------------------" 37 | rm -rf $template_dist_dir 38 | mkdir -p $template_dist_dir 39 | rm -rf $build_dist_dir 40 | mkdir -p $build_dist_dir 41 | 42 | echo "------------------------------------------------------------------------------" 43 | echo "CloudFormation template with CDK and Constructs" 44 | echo "------------------------------------------------------------------------------" 45 | export BUCKET_NAME=$1 46 | export SOLUTION_NAME=$2 47 | export VERSION=$3 48 | 49 | cd $source_dir/constructs 50 | npm install 51 | npm run build && cdk synth --asset-metadata false --path-metadata false --json true > serverless-image-handler.json 52 | mv serverless-image-handler.json $template_dist_dir/serverless-image-handler.template 53 | 54 | echo "------------------------------------------------------------------------------" 55 | echo "Package the image-handler code" 56 | echo "------------------------------------------------------------------------------" 57 | cd $source_dir/image-handler 58 | npm install 59 | npm run build 60 | cp dist/image-handler.zip $build_dist_dir/image-handler.zip 61 | 62 | echo "------------------------------------------------------------------------------" 63 | echo "Package the demo-ui assets" 64 | echo "------------------------------------------------------------------------------" 65 | mkdir $build_dist_dir/demo-ui/ 66 | cp -r $source_dir/demo-ui/** $build_dist_dir/demo-ui/ 67 | 68 | echo "------------------------------------------------------------------------------" 69 | echo "Package the custom-resource code" 70 | echo "------------------------------------------------------------------------------" 71 | cd $source_dir/custom-resource 72 | npm install 73 | npm run build 74 | cp dist/custom-resource.zip $build_dist_dir/custom-resource.zip 75 | 76 | echo "------------------------------------------------------------------------------" 77 | echo "Generate the demo-ui manifest document" 78 | echo "------------------------------------------------------------------------------" 79 | cd $source_dir/demo-ui 80 | manifest=(`find * -type f ! -iname ".DS_Store"`) 81 | manifest_json=$(IFS=,;printf "%s" "${manifest[*]}") 82 | echo "{\"files\":[\"$manifest_json\"]}" | sed 's/,/","/g' >> $build_dist_dir/demo-ui-manifest.json 83 | -------------------------------------------------------------------------------- /deployment/build-s3-dist2.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | title() { 5 | echo "------------------------------------------------------------------------------" 6 | echo $* 7 | echo "------------------------------------------------------------------------------" 8 | } 9 | 10 | run() { 11 | >&2 echo "[run] $*" 12 | $* 13 | } 14 | 15 | __dir="$(cd "$(dirname $0)";pwd)" 16 | SRC_PATH="${__dir}/../source" 17 | CDK_OUT_PATH="${__dir}/cdk.out" 18 | 19 | if [ -z "$1" ] || [ -z "$2" ]; then 20 | echo "Parameters not enough" 21 | echo "Example: $(basename $0) [VERSION]" 22 | exit 1 23 | fi 24 | 25 | export BUCKET_NAME=$1 26 | export SOLUTION_NAME=$2 27 | if [ -z "$3" ]; then 28 | # export VERSION="v$(jq -r '.version' ${SRC_PATH}/version.json)" 29 | export VERSION=$(git describe --tags || echo latest) 30 | else 31 | export VERSION=$3 32 | fi 33 | export GLOBAL_S3_ASSETS_PATH="${__dir}/global-s3-assets" 34 | export REGIONAL_S3_ASSETS_PATH="${__dir}/regional-s3-assets" 35 | 36 | title "init env" 37 | 38 | run rm -rf ${GLOBAL_S3_ASSETS_PATH} && run mkdir -p ${GLOBAL_S3_ASSETS_PATH} 39 | run rm -rf ${REGIONAL_S3_ASSETS_PATH} && run mkdir -p ${REGIONAL_S3_ASSETS_PATH} 40 | run rm -rf ${CDK_OUT_PATH} 41 | 42 | echo "BUCKET_NAME=${BUCKET_NAME}" 43 | echo "SOLUTION_NAME=${SOLUTION_NAME}" 44 | echo "VERSION=${VERSION}" 45 | echo "${VERSION}" > ${GLOBAL_S3_ASSETS_PATH}/version 46 | 47 | title "cdk synth" 48 | 49 | run cd ${SRC_PATH}/constructs 50 | run yarn 51 | 52 | export USE_BSS=true 53 | export BSS_TEMPLATE_BUCKET_NAME="${BUCKET_NAME}" 54 | export BSS_FILE_ASSET_BUCKET_NAME="${BUCKET_NAME}-\${AWS::Region}" 55 | export BSS_FILE_ASSET_PREFIX="${SOLUTION_NAME}/${VERSION}/" 56 | export BSS_FILE_ASSET_REGION_SET="us-east-1,${BSS_FILE_ASSET_REGION_SET}" 57 | 58 | run npm run synth -- --output ${CDK_OUT_PATH} 59 | run ${__dir}/helper.py ${CDK_OUT_PATH} 60 | 61 | title "tips!" 62 | 63 | echo "To test your cloudformation template" 64 | echo "make sure you have the following bucket exists in your account" 65 | echo " - ${BUCKET_NAME}" 66 | echo ${BSS_FILE_ASSET_REGION_SET} | tr ',' '\n' | xargs -I {} echo " - ${BUCKET_NAME}-{}" 67 | echo "run \`aws s3 cp --recursive ${GLOBAL_S3_ASSETS_PATH} s3://${BUCKET_NAME}/${SOLUTION_NAME}/${VERSION}\`" 68 | echo "run \`echo \"${BSS_FILE_ASSET_REGION_SET}\" | tr ',' '\n' | xargs -t -I {} aws s3 cp --recursive --region {} ${REGIONAL_S3_ASSETS_PATH} s3://${BUCKET_NAME}-{}/${SOLUTION_NAME}/${VERSION}\`" 69 | -------------------------------------------------------------------------------- /deployment/helper.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | import json 6 | import glob 7 | import subprocess 8 | 9 | 10 | GLOBAL_S3_ASSETS_PATH = os.environ['GLOBAL_S3_ASSETS_PATH'] 11 | REGIONAL_S3_ASSETS_PATH = os.environ['REGIONAL_S3_ASSETS_PATH'] 12 | 13 | 14 | class Color(object): 15 | ISATTY = os.isatty(1) 16 | COLORS = { 17 | 'red': '\x1b[31m', 18 | 'green': '\x1b[32m', 19 | 'yellow': '\x1b[33m', 20 | 'blue': '\x1b[34m', 21 | 'reset': '\x1b[0m' 22 | } 23 | 24 | @staticmethod 25 | def c(s, code): 26 | if Color.ISATTY: 27 | return Color.COLORS[code] + s + Color.COLORS['reset'] 28 | return s 29 | 30 | @staticmethod 31 | def red(s): 32 | return Color.c(s, 'red') 33 | 34 | @staticmethod 35 | def green(s): 36 | return Color.c(s, 'green') 37 | 38 | @staticmethod 39 | def yellow(s): 40 | return Color.c(s, 'yellow') 41 | 42 | @staticmethod 43 | def blue(s): 44 | return Color.c(s, 'blue') 45 | 46 | 47 | def get_file_assets(filename): 48 | with open(filename, 'r') as fp: 49 | assets = json.load(fp) 50 | files = assets['files'] 51 | 52 | def _add_key(k, v): 53 | v['_id'] = k 54 | return v 55 | 56 | return [_add_key(k, v) for k, v in files.items()] 57 | 58 | 59 | def sh(*args): 60 | return subprocess.call(*args, shell=True) 61 | 62 | 63 | def zip(src, dst): 64 | print(f'{Color.yellow("[zip]")} {Color.green(f"{src} => {dst}")}') 65 | sh(f'cd {src} && zip -r {dst} .') 66 | 67 | 68 | def cp(src, dst): 69 | print(f'{Color.yellow("[cp]")} {Color.green(f"{src} => {dst}")}') 70 | sh(f'cp {src} {dst}') 71 | 72 | 73 | def main(): 74 | dir_in = os.path.abspath(sys.argv[1]) 75 | assets = glob.glob(os.path.join(dir_in, '*.assets.json')) 76 | 77 | for asset in assets: 78 | print(f'from {Color.blue(asset)}') 79 | file_assets = get_file_assets(asset) 80 | for file in file_assets: 81 | source = file['source'] 82 | src = os.path.join(dir_in, source['path']) 83 | if src.endswith('template.json'): 84 | dst = os.path.abspath(os.path.join(GLOBAL_S3_ASSETS_PATH, file['_id'].replace('.json', ''))) 85 | else: 86 | dst = os.path.abspath(os.path.join(REGIONAL_S3_ASSETS_PATH, file['_id'])) 87 | if source['packaging'] == 'zip': 88 | zip(src, dst) 89 | elif source['packaging'] == 'file': 90 | cp(src, dst) 91 | 92 | 93 | if __name__ == '__main__': 94 | main() 95 | -------------------------------------------------------------------------------- /deployment/run-unit-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | current_dir=$PWD 6 | source_dir=$current_dir/../source 7 | 8 | cd $source_dir/constructs 9 | npm install 10 | npm test 11 | 12 | cd $source_dir/image-handler 13 | npm test 14 | 15 | cd $source_dir/custom-resource 16 | npm test -------------------------------------------------------------------------------- /source/constructs/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true, 4 | "node": true 5 | }, 6 | "root": true, 7 | "plugins": [ 8 | "@typescript-eslint", 9 | "import" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaVersion": 2018, 14 | "sourceType": "module", 15 | "project": "./tsconfig.eslint.json" 16 | }, 17 | "extends": [ 18 | "plugin:import/typescript" 19 | ], 20 | "settings": { 21 | "import/parsers": { 22 | "@typescript-eslint/parser": [ 23 | ".ts", 24 | ".tsx" 25 | ] 26 | }, 27 | "import/resolver": { 28 | "node": {}, 29 | "typescript": { 30 | "project": "./tsconfig.eslint.json" 31 | } 32 | } 33 | }, 34 | "ignorePatterns": [ 35 | "*.js", 36 | "!.projenrc.js", 37 | "*.d.ts", 38 | "node_modules/", 39 | "*.generated.ts", 40 | "coverage" 41 | ], 42 | "rules": { 43 | "indent": [ 44 | "off" 45 | ], 46 | "@typescript-eslint/indent": [ 47 | "error", 48 | 2 49 | ], 50 | "quotes": [ 51 | "error", 52 | "single", 53 | { 54 | "avoidEscape": true 55 | } 56 | ], 57 | "comma-dangle": [ 58 | "error", 59 | "always-multiline" 60 | ], 61 | "comma-spacing": [ 62 | "error", 63 | { 64 | "before": false, 65 | "after": true 66 | } 67 | ], 68 | "no-multi-spaces": [ 69 | "error", 70 | { 71 | "ignoreEOLComments": false 72 | } 73 | ], 74 | "array-bracket-spacing": [ 75 | "error", 76 | "never" 77 | ], 78 | "array-bracket-newline": [ 79 | "error", 80 | "consistent" 81 | ], 82 | "object-curly-spacing": [ 83 | "error", 84 | "always" 85 | ], 86 | "object-curly-newline": [ 87 | "error", 88 | { 89 | "multiline": true, 90 | "consistent": true 91 | } 92 | ], 93 | "object-property-newline": [ 94 | "error", 95 | { 96 | "allowAllPropertiesOnSameLine": true 97 | } 98 | ], 99 | "keyword-spacing": [ 100 | "error" 101 | ], 102 | "brace-style": [ 103 | "error", 104 | "1tbs", 105 | { 106 | "allowSingleLine": true 107 | } 108 | ], 109 | "space-before-blocks": [ 110 | "error" 111 | ], 112 | "curly": [ 113 | "error", 114 | "multi-line", 115 | "consistent" 116 | ], 117 | "@typescript-eslint/member-delimiter-style": [ 118 | "error" 119 | ], 120 | "semi": [ 121 | "error", 122 | "always" 123 | ], 124 | "max-len": [ 125 | "error", 126 | { 127 | "code": 150, 128 | "ignoreUrls": true, 129 | "ignoreStrings": true, 130 | "ignoreTemplateLiterals": true, 131 | "ignoreComments": true, 132 | "ignoreRegExpLiterals": true 133 | } 134 | ], 135 | "quote-props": [ 136 | "error", 137 | "consistent-as-needed" 138 | ], 139 | "@typescript-eslint/no-require-imports": [ 140 | "error" 141 | ], 142 | "import/no-extraneous-dependencies": [ 143 | "error", 144 | { 145 | "devDependencies": [ 146 | "**/test/**", 147 | "**/build-tools/**" 148 | ], 149 | "optionalDependencies": false, 150 | "peerDependencies": true 151 | } 152 | ], 153 | "import/no-unresolved": [ 154 | "error" 155 | ], 156 | "import/order": [ 157 | "warn", 158 | { 159 | "groups": [ 160 | "builtin", 161 | "external" 162 | ], 163 | "alphabetize": { 164 | "order": "asc", 165 | "caseInsensitive": true 166 | } 167 | } 168 | ], 169 | "no-duplicate-imports": [ 170 | "error" 171 | ], 172 | "no-shadow": [ 173 | "off" 174 | ], 175 | "@typescript-eslint/no-shadow": [ 176 | "error" 177 | ], 178 | "key-spacing": [ 179 | "error" 180 | ], 181 | "no-multiple-empty-lines": [ 182 | "error" 183 | ], 184 | "@typescript-eslint/no-floating-promises": [ 185 | "error" 186 | ], 187 | "no-return-await": [ 188 | "off" 189 | ], 190 | "@typescript-eslint/return-await": [ 191 | "error" 192 | ], 193 | "no-trailing-spaces": [ 194 | "error" 195 | ], 196 | "dot-notation": [ 197 | "error" 198 | ], 199 | "no-bitwise": [ 200 | "error" 201 | ], 202 | "@typescript-eslint/member-ordering": [ 203 | "error", 204 | { 205 | "default": [ 206 | "public-static-field", 207 | "public-static-method", 208 | "protected-static-field", 209 | "protected-static-method", 210 | "private-static-field", 211 | "private-static-method", 212 | "field", 213 | "constructor", 214 | "method" 215 | ] 216 | } 217 | ] 218 | }, 219 | "overrides": [ 220 | { 221 | "files": [ 222 | ".projenrc.js" 223 | ], 224 | "rules": { 225 | "@typescript-eslint/no-require-imports": "off", 226 | "import/no-extraneous-dependencies": "off" 227 | } 228 | } 229 | ] 230 | } 231 | -------------------------------------------------------------------------------- /source/constructs/.gitattributes: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | *.snap linguist-generated 4 | /.eslintrc.json linguist-generated 5 | /.gitattributes linguist-generated 6 | /.github/pull_request_template.md linguist-generated 7 | /.github/workflows/build.yml linguist-generated 8 | /.github/workflows/upgrade-dependencies.yml linguist-generated 9 | /.gitignore linguist-generated 10 | /.mergify.yml linguist-generated 11 | /.npmignore linguist-generated 12 | /.projen/** linguist-generated 13 | /.projen/deps.json linguist-generated 14 | /.projen/tasks.json linguist-generated 15 | /cdk.json linguist-generated 16 | /LICENSE linguist-generated 17 | /package.json linguist-generated 18 | /tsconfig.eslint.json linguist-generated 19 | /tsconfig.jest.json linguist-generated 20 | /tsconfig.json linguist-generated 21 | /yarn.lock linguist-generated -------------------------------------------------------------------------------- /source/constructs/.gitignore: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | *.lcov 3 | *.log 4 | *.pid 5 | *.pid.lock 6 | *.seed 7 | *.tgz 8 | *.tsbuildinfo 9 | .cache 10 | .cdk.staging/ 11 | .eslintcache 12 | .nyc_output 13 | .parcel-cache/ 14 | .yarn-integrity 15 | /coverage 16 | /dist 17 | /test-reports/ 18 | build/Release 19 | cdk.out/ 20 | coverage 21 | jspm_packages/ 22 | junit.xml 23 | lerna-debug.log* 24 | lib-cov 25 | logs 26 | node_modules/ 27 | npm-debug.log* 28 | pids 29 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 30 | yarn-debug.log* 31 | yarn-error.log* 32 | !/.eslintrc.json 33 | !/.gitattributes 34 | !/.github/pull_request_template.md 35 | !/.github/workflows/build.yml 36 | !/.github/workflows/upgrade-dependencies.yml 37 | !/.mergify.yml 38 | !/.npmignore 39 | !/.projen/deps.json 40 | !/.projen/tasks.json 41 | !/.projenrc.js 42 | !/LICENSE 43 | !/cdk.json 44 | !/package.json 45 | !/src 46 | !/test 47 | !/tsconfig.eslint.json 48 | !/tsconfig.jest.json 49 | !/tsconfig.json 50 | -------------------------------------------------------------------------------- /source/constructs/.npmignore: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | .cdk.staging/ 3 | /.eslintrc.json 4 | /.github 5 | /.idea 6 | /.mergify.yml 7 | /.projen 8 | /.projenrc.js 9 | /.vscode 10 | /coverage 11 | /src 12 | /test 13 | /test-reports/ 14 | /tsconfig.eslint.json 15 | /tsconfig.jest.json 16 | /tsconfig.json 17 | cdk.out/ 18 | dist 19 | junit.xml 20 | tsconfig.tsbuildinfo 21 | !/lib 22 | !/lib/**/*.d.ts 23 | !/lib/**/*.js 24 | -------------------------------------------------------------------------------- /source/constructs/bin/constructs.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import * as cdk from '@aws-cdk/core'; 5 | import { BootstraplessStackSynthesizer } from 'cdk-bootstrapless-synthesizer'; 6 | import { ConstructsStack, ECSImageHandlerStack, LambdaImageHandlerStack } from '../lib/constructs-stack'; 7 | 8 | const app = new cdk.App(); 9 | new ConstructsStack(app, 'ConstructsStack'); 10 | const ecsStack = new ECSImageHandlerStack(app, 'serverless-ecs-image-handler-stack', { 11 | stackName: process.env.STACK_NAME, 12 | env: { 13 | account: process.env.CDK_DEFAULT_ACCOUNT, 14 | region: process.env.CDK_DEPLOY_REGION, 15 | }, 16 | }); 17 | 18 | cdk.Tags.of(ecsStack).add('name', 'serverless-ecs-image-handler'); 19 | const stackTags = ecsStack.node.tryGetContext('stack_tags'); 20 | if (!!stackTags) { 21 | for (const [key, value] of Object.entries(stackTags)) { 22 | cdk.Tags.of(ecsStack).add(key, value as string); 23 | } 24 | } 25 | 26 | new LambdaImageHandlerStack(app, 'lambda-image-handler-cn', { 27 | isChinaRegion: true, 28 | stackName: process.env.STACK_NAME, 29 | synthesizer: synthesizer(), 30 | }); 31 | 32 | new LambdaImageHandlerStack(app, 'lambda-image-handler', { 33 | stackName: process.env.STACK_NAME, 34 | synthesizer: synthesizer(), 35 | }); 36 | 37 | // new LambdaImageHandlerStack(app, 'lambda-image-handler-stack', { 38 | // stackName: process.env.STACK_NAME, 39 | // synthesizer: synthesizer(), 40 | // env: { 41 | // account: process.env.CDK_DEFAULT_ACCOUNT, 42 | // region: process.env.CDK_DEPLOY_REGION, 43 | // }, 44 | // }); 45 | 46 | function synthesizer() { 47 | return process.env.USE_BSS ? new BootstraplessStackSynthesizer() : undefined; 48 | } 49 | -------------------------------------------------------------------------------- /source/constructs/cdk.context.json: -------------------------------------------------------------------------------- 1 | { 2 | "buckets": [ 3 | "", 4 | "" 5 | ], 6 | "//use_vpc_id": "vpc-id", 7 | "//subnet_ids": [ 8 | "", 9 | "" 10 | ] , 11 | "//enable_public_alb": true, 12 | "//enable_cloudfront": true, 13 | "secret_arn": "arn:aws:secretsmanager:::secret:-", 14 | "stack_tags":{"key1":"value1", "key2":"value2"}, 15 | "config_json_parameter_name": "", 16 | "ecs_desired_count": 10, 17 | "env": { 18 | "SHARP_QUEUE_LIMIT": "1", 19 | "CACHE_TTL_SEC": "300", 20 | "CACHE_MAX_ITEMS": "10000", 21 | "CACHE_MAX_SIZE_MB": "1024" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /source/constructs/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/constructs.ts", 3 | "context": { 4 | "@aws-cdk/core:enableStackNameDuplicates": "false", 5 | "aws-cdk:enableDiffNoFail": "true" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /source/constructs/images/01-edit-bucket-policy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wchaws/serverless-image-handler/423c5e761af468ba2fa8eb9fbbd700573c614c0a/source/constructs/images/01-edit-bucket-policy.png -------------------------------------------------------------------------------- /source/constructs/images/02-edit-bucket-policy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wchaws/serverless-image-handler/423c5e761af468ba2fa8eb9fbbd700573c614c0a/source/constructs/images/02-edit-bucket-policy.png -------------------------------------------------------------------------------- /source/constructs/lib/api.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "title": "ServerlessImageHandler" 5 | }, 6 | "basePath": "/image", 7 | "schemes": [ "https" ], 8 | "paths": { 9 | "/{proxy+}": { 10 | "x-amazon-apigateway-any-method": { 11 | "produces": [ "application/json" ], 12 | "parameters": [ 13 | { 14 | "name": "proxy", 15 | "in": "path", 16 | "required": true, 17 | "type": "string" 18 | }, 19 | { 20 | "name": "signature", 21 | "in": "query", 22 | "description": "Signature of the image", 23 | "required": false, 24 | "type": "string" 25 | } 26 | ], 27 | "responses": {}, 28 | "x-amazon-apigateway-integration": { 29 | "responses": { 30 | "default": { "statusCode": "200" } 31 | }, 32 | "uri": { 33 | "Fn::Join": [ 34 | "", 35 | [ 36 | "arn:aws:apigateway:", 37 | { 38 | "Ref": "AWS::Region" 39 | }, 40 | ":", 41 | "lambda:path/2015-03-31/functions/", 42 | { 43 | "Fn::GetAtt": [ 44 | "ImageHandlerFunction", 45 | "Arn" 46 | ] 47 | }, 48 | "/invocations" 49 | ] 50 | ] 51 | }, 52 | "passthroughBehavior": "when_no_match", 53 | "httpMethod": "POST", 54 | "cacheNamespace": "xh7gp9", 55 | "cacheKeyParameters": [ "method.request.path.proxy" ], 56 | "contentHandling": "CONVERT_TO_TEXT", 57 | "type": "aws_proxy" 58 | } 59 | } 60 | } 61 | }, 62 | "x-amazon-apigateway-binary-media-types": [ 63 | "*/*" 64 | ] 65 | } -------------------------------------------------------------------------------- /source/constructs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "constructs", 3 | "description": "Serverless Image Handler Constructs", 4 | "version": "5.2.0", 5 | "license": "Apache-2.0", 6 | "bin": { 7 | "constructs": "bin/constructs.js" 8 | }, 9 | "scripts": { 10 | "cdk": "cdk", 11 | "compile": "echo nothing to do", 12 | "test:compile": "tsc --noEmit --project tsconfig.jest.json", 13 | "test": "export BUCKET_NAME=TEST SOLUTION_NAME=serverless-image-handler VERSION=TEST_VERSION && rm -fr dist/ && yarn test:compile && jest --passWithNoTests --all --updateSnapshot && yarn eslint", 14 | "test:new-image-handler": "cd ../new-image-handler && yarn test", 15 | "build": "yarn test && yarn compile && yarn synth", 16 | "build:new-image-handler:docker": "cd ../new-image-handler && yarn build:docker", 17 | "test:watch": "export BUCKET_NAME=TEST SOLUTION_NAME=serverless-image-handler VERSION=TEST_VERSION && jest --watch", 18 | "test:update": "export BUCKET_NAME=TEST SOLUTION_NAME=serverless-image-handler VERSION=TEST_VERSION && jest --updateSnapshot", 19 | "upgrade-dependencies": "export CI=0 && npm-check-updates --upgrade --target=minor --reject='projen' && yarn install --check-files && yarn upgrade", 20 | "eslint": "eslint --ext .ts,.tsx --no-error-on-unmatched-pattern bin lib test", 21 | "synth": "cdk synth", 22 | "deploy": "cdk deploy", 23 | "destroy": "cdk destroy", 24 | "diff": "cdk diff", 25 | "postinstall": "cd ../new-image-handler && yarn" 26 | }, 27 | "devDependencies": { 28 | "@aws-cdk/assert": "1.124.0", 29 | "@types/jest": "^26.0.24", 30 | "@types/node": "^14.11.2", 31 | "@typescript-eslint/eslint-plugin": "^4.28.1", 32 | "@typescript-eslint/parser": "^4.28.1", 33 | "aws-cdk": "1.124.0", 34 | "eslint": "^7.30.0", 35 | "eslint-import-resolver-node": "^0.3.4", 36 | "eslint-import-resolver-typescript": "^2.4.0", 37 | "eslint-plugin-import": "^2.23.4", 38 | "jest": "^27.0.6", 39 | "jest-junit": "^12", 40 | "json-schema": "^0.3.0", 41 | "npm-check-updates": "^11", 42 | "ts-jest": "^27.0.3", 43 | "ts-node": "^10.0.0", 44 | "typescript": "^4.3.5" 45 | }, 46 | "dependencies": { 47 | "@aws-cdk/aws-apigateway": "1.124.0", 48 | "@aws-cdk/aws-apigatewayv2": "1.124.0", 49 | "@aws-cdk/aws-apigatewayv2-integrations": "1.124.0", 50 | "@aws-cdk/aws-cloudfront": "1.124.0", 51 | "@aws-cdk/aws-cloudfront-origins": "1.124.0", 52 | "@aws-cdk/aws-dynamodb": "1.124.0", 53 | "@aws-cdk/aws-ec2": "1.124.0", 54 | "@aws-cdk/aws-ecs": "1.124.0", 55 | "@aws-cdk/aws-ecs-patterns": "1.124.0", 56 | "@aws-cdk/aws-iam": "1.124.0", 57 | "@aws-cdk/aws-lambda": "1.124.0", 58 | "@aws-cdk/aws-logs": "1.124.0", 59 | "@aws-cdk/aws-s3": "1.124.0", 60 | "@aws-cdk/aws-ssm": "1.124.0", 61 | "@aws-cdk/aws-secretsmanager": "1.124.0", 62 | "@aws-cdk/core": "1.124.0", 63 | "@aws-solutions-constructs/aws-apigateway-lambda": "1.124.0", 64 | "@aws-solutions-constructs/aws-cloudfront-apigateway-lambda": "1.124.0", 65 | "@aws-solutions-constructs/aws-cloudfront-s3": "1.124.0", 66 | "@aws-solutions-constructs/core": "1.124.0", 67 | "cdk-bootstrapless-synthesizer": "0.7.15" 68 | }, 69 | "engines": { 70 | "node": ">= 12.0.0" 71 | }, 72 | "jest": { 73 | "testPathIgnorePatterns": [ 74 | "/node_modules/", 75 | "/cdk.out/" 76 | ], 77 | "watchPathIgnorePatterns": [ 78 | "/node_modules/", 79 | "/cdk.out/" 80 | ], 81 | "testMatch": [ 82 | "**/__tests__/**/*.ts?(x)", 83 | "**/?(*.)+(spec|test).ts?(x)" 84 | ], 85 | "clearMocks": true, 86 | "collectCoverage": true, 87 | "coverageReporters": [ 88 | "json", 89 | "lcov", 90 | "clover", 91 | "text" 92 | ], 93 | "coverageDirectory": "coverage", 94 | "coveragePathIgnorePatterns": [ 95 | "/node_modules/", 96 | "/cdk.out/" 97 | ], 98 | "reporters": [ 99 | "default", 100 | [ 101 | "jest-junit", 102 | { 103 | "outputDirectory": "test-reports" 104 | } 105 | ] 106 | ], 107 | "preset": "ts-jest", 108 | "globals": { 109 | "ts-jest": { 110 | "tsconfig": "tsconfig.jest.json" 111 | } 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /source/constructs/test/constructs.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | import * as cdk from '@aws-cdk/core'; 5 | import * as Constructs from '../lib/constructs-stack'; 6 | 7 | 8 | test('Serverless Image Handler Stack', () => { 9 | const app = new cdk.App(); 10 | // WHEN 11 | const stack = new Constructs.ConstructsStack(app, 'MyTestStack'); 12 | // THEN 13 | 14 | expect(app.synth().getStackArtifact(stack.artifactId).template).toMatchSnapshot(); 15 | }); 16 | -------------------------------------------------------------------------------- /source/constructs/test/ecs-image-handler.test.ts: -------------------------------------------------------------------------------- 1 | import '@aws-cdk/assert/jest'; 2 | import { App } from '@aws-cdk/core'; 3 | import { ECSImageHandlerStack } from '../lib/constructs-stack'; 4 | 5 | test('Snapshot', () => { 6 | const app = new App({ 7 | context: { 8 | buckets: ['bucket-0'], 9 | secret_arn: 'arn:aws:secretsmanager:us-east-9:123456789012:secret:test-aaabbb', 10 | config_json_parameter_name: 'config_json_parameter_name', 11 | }, 12 | }); 13 | const stack = new ECSImageHandlerStack(app, 'test'); 14 | 15 | expect(app.synth().getStackArtifact(stack.artifactId).template).toMatchSnapshot(); 16 | }); -------------------------------------------------------------------------------- /source/constructs/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "declaration": true, 5 | "experimentalDecorators": true, 6 | "inlineSourceMap": true, 7 | "inlineSources": true, 8 | "lib": [ 9 | "es2018" 10 | ], 11 | "module": "CommonJS", 12 | "noEmitOnError": false, 13 | "noFallthroughCasesInSwitch": true, 14 | "noImplicitAny": true, 15 | "noImplicitReturns": true, 16 | "noImplicitThis": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "resolveJsonModule": true, 20 | "strict": true, 21 | "strictNullChecks": true, 22 | "strictPropertyInitialization": true, 23 | "stripInternal": true, 24 | "target": "ES2018" 25 | }, 26 | "include": [ 27 | "bin/**/*.ts", 28 | "lib/**/*.ts", 29 | "test/**/*.ts" 30 | ], 31 | "exclude": [ 32 | "node_modules", 33 | "cdk.out", 34 | ] 35 | } -------------------------------------------------------------------------------- /source/constructs/tsconfig.jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "declaration": true, 5 | "experimentalDecorators": true, 6 | "inlineSourceMap": true, 7 | "inlineSources": true, 8 | "lib": [ 9 | "es2018" 10 | ], 11 | "module": "CommonJS", 12 | "noEmitOnError": false, 13 | "noFallthroughCasesInSwitch": true, 14 | "noImplicitAny": true, 15 | "noImplicitReturns": true, 16 | "noImplicitThis": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "resolveJsonModule": true, 20 | "strict": true, 21 | "strictNullChecks": true, 22 | "strictPropertyInitialization": true, 23 | "stripInternal": true, 24 | "target": "ES2018" 25 | }, 26 | "include": [ 27 | "lib/**/*.ts", 28 | "test/**/*.ts" 29 | ], 30 | "exclude": [ 31 | "node_modules", 32 | "cdk.out", 33 | ] 34 | } -------------------------------------------------------------------------------- /source/constructs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "alwaysStrict": true, 5 | "declaration": true, 6 | "experimentalDecorators": true, 7 | "inlineSourceMap": true, 8 | "inlineSources": true, 9 | "lib": [ 10 | "es2018" 11 | ], 12 | "module": "CommonJS", 13 | "noEmitOnError": false, 14 | "noFallthroughCasesInSwitch": true, 15 | "noImplicitAny": true, 16 | "noImplicitReturns": true, 17 | "noImplicitThis": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "resolveJsonModule": true, 21 | "strict": true, 22 | "strictNullChecks": true, 23 | "strictPropertyInitialization": true, 24 | "stripInternal": true, 25 | "target": "ES2018" 26 | }, 27 | "include": [ 28 | "bin/**/*.ts", 29 | "lib/**/*.ts", 30 | "test/**/*.ts", 31 | ], 32 | "exclude": [ 33 | "node_modules", 34 | "cdk.out" 35 | ] 36 | } -------------------------------------------------------------------------------- /source/custom-resource/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "custom-resource", 3 | "description": "Serverless Image Handler custom resource", 4 | "main": "index.js", 5 | "author": { 6 | "name": "aws-solutions-builder" 7 | }, 8 | "version": "5.2.0", 9 | "private": true, 10 | "dependencies": { 11 | "axios": "^0.21.1", 12 | "uuid": "^8.3.0" 13 | }, 14 | "devDependencies": { 15 | "aws-sdk": "2.771.0", 16 | "axios-mock-adapter": "^1.18.2", 17 | "jest": "^26.4.2" 18 | }, 19 | "scripts": { 20 | "pretest": "npm run build:init && npm install", 21 | "test": "jest test/*.spec.js --coverage --silent", 22 | "build:init": "rm -rf dist && rm -rf node_modules", 23 | "build:zip": "zip -rq custom-resource.zip .", 24 | "build:dist": "mkdir dist && mv custom-resource.zip dist/", 25 | "build": "npm run build:init && npm install --production && npm run build:zip && npm run build:dist" 26 | }, 27 | "license": "Apache-2.0" 28 | } 29 | -------------------------------------------------------------------------------- /source/demo-ui/scripts.js: -------------------------------------------------------------------------------- 1 | /********************************************************************************************************************* 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. * 3 | * * 4 | * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance * 5 | * with the License. A copy of the License is located at * 6 | * * 7 | * http://www.apache.org/licenses/LICENSE-2.0 * 8 | * * 9 | * or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES * 10 | * OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions * 11 | * and limitations under the License. * 12 | *********************************************************************************************************************/ 13 | 14 | function importOriginalImage() { 15 | // Gather the bucket name and image key 16 | const bucketName = $(`#txt-bucket-name`).first().val(); 17 | const keyName = $(`#txt-key-name`).first().val(); 18 | // Assemble the image request 19 | const request = { 20 | bucket: bucketName, 21 | key: keyName 22 | } 23 | const strRequest = JSON.stringify(request); 24 | const encRequest = btoa(strRequest); 25 | // Import the image data into the element 26 | $(`#img-original`) 27 | .attr(`src`, `${appVariables.apiEndpoint}/${encRequest}`) 28 | .attr(`data-bucket`, bucketName) 29 | .attr(`data-key`, keyName); 30 | } 31 | 32 | function getPreviewImage() { 33 | // Gather the editor inputs 34 | const _width = $(`#editor-width`).first().val(); 35 | const _height = $(`#editor-height`).first().val(); 36 | const _resize = $(`#editor-resize-mode`).first().val(); 37 | const _fillColor = $(`#editor-fill-color`).first().val(); 38 | const _backgroundColor = $(`#editor-background-color`).first().val(); 39 | const _grayscale = $(`#editor-grayscale`).first().prop("checked"); 40 | const _flip = $(`#editor-flip`).first().prop("checked"); 41 | const _flop = $(`#editor-flop`).first().prop("checked"); 42 | const _negative = $(`#editor-negative`).first().prop("checked"); 43 | const _flatten = $(`#editor-flatten`).first().prop("checked"); 44 | const _normalize = $(`#editor-normalize`).first().prop("checked"); 45 | const _rgb = $(`#editor-rgb`).first().val(); 46 | const _smartCrop = $(`#editor-smart-crop`).first().prop("checked"); 47 | const _smartCropIndex = $(`#editor-smart-crop-index`).first().val(); 48 | const _smartCropPadding = $(`#editor-smart-crop-padding`).first().val(); 49 | // Setup the edits object 50 | const _edits = {} 51 | _edits.resize = {}; 52 | if (_resize !== "Disabled") { 53 | if (_width !== "") { _edits.resize.width = Number(_width) } 54 | if (_height !== "") { _edits.resize.height = Number(_height) } 55 | _edits.resize.fit = _resize; 56 | } 57 | if (_fillColor !== "") { _edits.resize.background = hexToRgbA(_fillColor, 1) } 58 | if (_backgroundColor !== "") { _edits.flatten = { background: hexToRgbA(_backgroundColor, undefined) }} 59 | if (_grayscale) { _edits.grayscale = _grayscale } 60 | if (_flip) { _edits.flip = _flip } 61 | if (_flop) { _edits.flop = _flop } 62 | if (_negative) { _edits.negate = _negative } 63 | if (_flatten) { _edits.flatten = _flatten } 64 | if (_normalize) { _edits.normalise = _normalize } 65 | if (_rgb !== "") { 66 | const input = _rgb.replace(/\s+/g, ''); 67 | const arr = input.split(','); 68 | const rgb = { r: Number(arr[0]), g: Number(arr[1]), b: Number(arr[2]) }; 69 | _edits.tint = rgb 70 | } 71 | if (_smartCrop) { 72 | _edits.smartCrop = {}; 73 | if (_smartCropIndex !== "") { _edits.smartCrop.faceIndex = Number(_smartCropIndex) } 74 | if (_smartCropPadding !== "") { _edits.smartCrop.padding = Number(_smartCropPadding) } 75 | } 76 | if (Object.keys(_edits.resize).length === 0) { delete _edits.resize }; 77 | // Gather the bucket and key names 78 | const bucketName = $(`#img-original`).first().attr(`data-bucket`); 79 | const keyName = $(`#img-original`).first().attr(`data-key`); 80 | // Set up the request body 81 | const request = { 82 | bucket: bucketName, 83 | key: keyName, 84 | edits: _edits 85 | } 86 | if (Object.keys(request.edits).length === 0) { delete request.edits }; 87 | console.log(request); 88 | // Setup encoded request 89 | const str = JSON.stringify(request); 90 | const enc = btoa(str); 91 | // Fill the preview image 92 | $(`#img-preview`).attr(`src`, `${appVariables.apiEndpoint}/${enc}`); 93 | // Fill the request body field 94 | $(`#preview-request-body`).html(JSON.stringify(request, undefined, 2)); 95 | // Fill the encoded URL field 96 | $(`#preview-encoded-url`).val(`${appVariables.apiEndpoint}/${enc}`); 97 | } 98 | 99 | function hexToRgbA(hex, _alpha) { 100 | var c; 101 | if(/^#([A-Fa-f0-9]{3}){1,2}$/.test(hex)){ 102 | c= hex.substring(1).split(''); 103 | if(c.length== 3){ 104 | c= [c[0], c[0], c[1], c[1], c[2], c[2]]; 105 | } 106 | c= '0x'+c.join(''); 107 | return { r: ((c>>16)&255), g: ((c>>8)&255), b: (c&255), alpha: Number(_alpha)}; 108 | } 109 | throw new Error('Bad Hex'); 110 | } 111 | 112 | function resetEdits() { 113 | $('.form-control').val(''); 114 | document.getElementById('editor-resize-mode').selectedIndex = 0; 115 | $(".form-check-input").prop('checked', false); 116 | } -------------------------------------------------------------------------------- /source/demo-ui/style.css: -------------------------------------------------------------------------------- 1 | .header { 2 | background: #37474f !important; 3 | color: #fff !important; 4 | padding: 10px 10px 10px 20px !important; 5 | font-size: 1.2em !important; 6 | } 7 | .header-italics { 8 | color: #b0bec5 !important; 9 | } 10 | .content { 11 | margin-top: 25px !important; 12 | } 13 | .card-original-image { 14 | margin-top: 20px !important; 15 | } 16 | #img-original { 17 | max-height: 100% !important; 18 | max-width: 100% !important; 19 | } 20 | #img-preview { 21 | max-height: 100% !important; 22 | max-width: 100% !important; 23 | } 24 | 25 | 26 | 27 | .gallery-item { 28 | width: 30%; 29 | margin: 1%; 30 | max-height: auto; 31 | border: 1px solid gray; 32 | border-radius: 4px; 33 | cursor: pointer; 34 | } 35 | .gallery-item-selected { 36 | border: 4px solid #ffa726; 37 | } 38 | .preview-code-block { 39 | background: #cfd8dc !important; 40 | padding: 8px; 41 | border-radius: 4px; 42 | } 43 | .preview-code-block code { 44 | color: black !important; 45 | } -------------------------------------------------------------------------------- /source/image-handler/index.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | const AWS = require('aws-sdk'); 5 | const s3 = new AWS.S3(); 6 | const rekognition = new AWS.Rekognition(); 7 | const secretsManager = new AWS.SecretsManager(); 8 | 9 | const ImageRequest = require('./image-request.js'); 10 | const ImageHandler = require('./image-handler.js'); 11 | 12 | exports.handler = async (event) => { 13 | console.log(event); 14 | const imageRequest = new ImageRequest(s3, secretsManager); 15 | const imageHandler = new ImageHandler(s3, rekognition); 16 | const isAlb = event.requestContext && event.requestContext.hasOwnProperty('elb'); 17 | 18 | try { 19 | const request = await imageRequest.setup(event); 20 | console.log(request); 21 | 22 | const processedRequest = await imageHandler.process(request); 23 | const headers = getResponseHeaders(false, isAlb); 24 | headers["Content-Type"] = request.ContentType; 25 | headers["Expires"] = request.Expires; 26 | headers["Last-Modified"] = request.LastModified; 27 | headers["Cache-Control"] = request.CacheControl; 28 | 29 | if (request.headers) { 30 | // Apply the custom headers overwritting any that may need overwriting 31 | for (let key in request.headers) { 32 | headers[key] = request.headers[key]; 33 | } 34 | } 35 | 36 | return { 37 | statusCode: 200, 38 | isBase64Encoded: true, 39 | headers : headers, 40 | body: processedRequest 41 | }; 42 | } catch (err) { 43 | console.error(err); 44 | 45 | // Default fallback image 46 | if (process.env.ENABLE_DEFAULT_FALLBACK_IMAGE === 'Yes' 47 | && process.env.DEFAULT_FALLBACK_IMAGE_BUCKET 48 | && process.env.DEFAULT_FALLBACK_IMAGE_BUCKET.replace(/\s/, '') !== '' 49 | && process.env.DEFAULT_FALLBACK_IMAGE_KEY 50 | && process.env.DEFAULT_FALLBACK_IMAGE_KEY.replace(/\s/, '') !== '') { 51 | try { 52 | const bucket = process.env.DEFAULT_FALLBACK_IMAGE_BUCKET; 53 | const objectKey = process.env.DEFAULT_FALLBACK_IMAGE_KEY; 54 | const defaultFallbackImage = await s3.getObject({ Bucket: bucket, Key: objectKey }).promise(); 55 | const headers = getResponseHeaders(false, isAlb); 56 | headers['Content-Type'] = defaultFallbackImage.ContentType; 57 | headers['Last-Modified'] = defaultFallbackImage.LastModified; 58 | headers['Cache-Control'] = 'max-age=31536000,public'; 59 | 60 | return { 61 | statusCode: err.status ? err.status : 500, 62 | isBase64Encoded: true, 63 | headers: headers, 64 | body: defaultFallbackImage.Body.toString('base64') 65 | }; 66 | } catch (error) { 67 | console.error('Error occurred while getting the default fallback image.', error); 68 | } 69 | } 70 | 71 | if (err.status) { 72 | return { 73 | statusCode: err.status, 74 | isBase64Encoded: false, 75 | headers : getResponseHeaders(true, isAlb), 76 | body: JSON.stringify(err) 77 | }; 78 | } else { 79 | return { 80 | statusCode: 500, 81 | isBase64Encoded: false, 82 | headers : getResponseHeaders(true, isAlb), 83 | body: JSON.stringify({ message: 'Internal error. Please contact the system administrator.', code: 'InternalError', status: 500 }) 84 | }; 85 | } 86 | } 87 | } 88 | 89 | /** 90 | * Generates the appropriate set of response headers based on a success 91 | * or error condition. 92 | * @param {boolean} isErr - has an error been thrown? 93 | * @param {boolean} isAlb - is the request from ALB? 94 | * @return {object} - Headers object 95 | */ 96 | const getResponseHeaders = (isErr = false, isAlb = false) => { 97 | const corsEnabled = (process.env.CORS_ENABLED === "Yes"); 98 | const headers = { 99 | "Access-Control-Allow-Methods": "GET", 100 | "Access-Control-Allow-Headers": "Content-Type, Authorization" 101 | } 102 | if (!isAlb) { 103 | headers["Access-Control-Allow-Credentials"] = true; 104 | } 105 | if (corsEnabled) { 106 | headers["Access-Control-Allow-Origin"] = process.env.CORS_ORIGIN; 107 | } 108 | if (isErr) { 109 | headers["Content-Type"] = "application/json" 110 | } 111 | return headers; 112 | } -------------------------------------------------------------------------------- /source/image-handler/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "image-handler", 3 | "description": "A Lambda function for performing on-demand image edits and manipulations.", 4 | "main": "index.js", 5 | "author": { 6 | "name": "aws-solutions-builder" 7 | }, 8 | "version": "5.2.0", 9 | "private": true, 10 | "dependencies": { 11 | "color": "3.1.3", 12 | "color-name": "1.1.4", 13 | "sharp": "^0.27.0" 14 | }, 15 | "devDependencies": { 16 | "aws-sdk": "2.771.0", 17 | "jest": "^26.4.2" 18 | }, 19 | "scripts": { 20 | "pretest": "npm run build:init && npm install", 21 | "test": "jest test/*.spec.js --coverage --silent", 22 | "build:init": "rm -rf package-lock.json dist/ node_modules/", 23 | "build:zip": "zip -rq image-handler.zip .", 24 | "build:dist": "mkdir dist && mv image-handler.zip dist/", 25 | "build": "npm run build:init && npm install --arch=x64 --platform=linux --production && npm run build:zip && npm run build:dist" 26 | }, 27 | "license": "Apache-2.0" 28 | } 29 | -------------------------------------------------------------------------------- /source/image-handler/test/image/test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wchaws/serverless-image-handler/423c5e761af468ba2fa8eb9fbbd700573c614c0a/source/image-handler/test/image/test.jpg -------------------------------------------------------------------------------- /source/new-image-handler/.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | Dockerfile.lambda.deps 3 | Dockerfile.lambda 4 | Dockerfile* 5 | node_modules 6 | coverage 7 | test-reports 8 | **/*.md 9 | **/*.bin -------------------------------------------------------------------------------- /source/new-image-handler/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true, 4 | "node": true 5 | }, 6 | "root": true, 7 | "plugins": [ 8 | "@typescript-eslint", 9 | "import" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaVersion": 2018, 14 | "sourceType": "module", 15 | "project": "./tsconfig.eslint.json" 16 | }, 17 | "extends": [ 18 | "plugin:import/typescript" 19 | ], 20 | "settings": { 21 | "import/parsers": { 22 | "@typescript-eslint/parser": [ 23 | ".ts", 24 | ".tsx" 25 | ] 26 | }, 27 | "import/resolver": { 28 | "node": {}, 29 | "typescript": { 30 | "project": "./tsconfig.eslint.json" 31 | } 32 | } 33 | }, 34 | "ignorePatterns": [ 35 | "*.js", 36 | "!.projenrc.js", 37 | "*.d.ts", 38 | "node_modules/", 39 | "*.generated.ts", 40 | "coverage" 41 | ], 42 | "rules": { 43 | "radix": "error", 44 | "space-infix-ops": "error", 45 | "spaced-comment": [ 46 | "error", 47 | "always" 48 | ], 49 | "no-var": "error", 50 | "eqeqeq": "error", 51 | "space-in-parens": [ 52 | "error", 53 | "never" 54 | ], 55 | "indent": [ 56 | "off" 57 | ], 58 | "@typescript-eslint/indent": [ 59 | "error", 60 | 2 61 | ], 62 | "quotes": [ 63 | "error", 64 | "single", 65 | { 66 | "avoidEscape": true 67 | } 68 | ], 69 | "comma-dangle": [ 70 | "error", 71 | "always-multiline" 72 | ], 73 | "comma-spacing": [ 74 | "error", 75 | { 76 | "before": false, 77 | "after": true 78 | } 79 | ], 80 | "no-multi-spaces": [ 81 | "error", 82 | { 83 | "ignoreEOLComments": false 84 | } 85 | ], 86 | "array-bracket-spacing": [ 87 | "error", 88 | "never" 89 | ], 90 | "array-bracket-newline": [ 91 | "error", 92 | "consistent" 93 | ], 94 | "object-curly-spacing": [ 95 | "error", 96 | "always" 97 | ], 98 | "object-curly-newline": [ 99 | "error", 100 | { 101 | "multiline": true, 102 | "consistent": true 103 | } 104 | ], 105 | "object-property-newline": [ 106 | "error", 107 | { 108 | "allowAllPropertiesOnSameLine": true 109 | } 110 | ], 111 | "keyword-spacing": [ 112 | "error" 113 | ], 114 | "brace-style": [ 115 | "error", 116 | "1tbs", 117 | { 118 | "allowSingleLine": true 119 | } 120 | ], 121 | "space-before-blocks": [ 122 | "error" 123 | ], 124 | "curly": [ 125 | "error", 126 | "multi-line", 127 | "consistent" 128 | ], 129 | "@typescript-eslint/member-delimiter-style": [ 130 | "error" 131 | ], 132 | "semi": [ 133 | "error", 134 | "always" 135 | ], 136 | "max-len": [ 137 | "error", 138 | { 139 | "code": 150, 140 | "ignoreUrls": true, 141 | "ignoreStrings": true, 142 | "ignoreTemplateLiterals": true, 143 | "ignoreComments": true, 144 | "ignoreRegExpLiterals": true 145 | } 146 | ], 147 | "quote-props": [ 148 | "error", 149 | "consistent-as-needed" 150 | ], 151 | "@typescript-eslint/no-require-imports": [ 152 | "error" 153 | ], 154 | "import/no-extraneous-dependencies": [ 155 | "error", 156 | { 157 | "devDependencies": [ 158 | "**/test/**", 159 | "**/build-tools/**" 160 | ], 161 | "optionalDependencies": false, 162 | "peerDependencies": true 163 | } 164 | ], 165 | "import/no-unresolved": [ 166 | "error" 167 | ], 168 | "import/order": [ 169 | "warn", 170 | { 171 | "groups": [ 172 | "builtin", 173 | "external" 174 | ], 175 | "alphabetize": { 176 | "order": "asc", 177 | "caseInsensitive": true 178 | } 179 | } 180 | ], 181 | "no-duplicate-imports": [ 182 | "error" 183 | ], 184 | "no-shadow": [ 185 | "off" 186 | ], 187 | "@typescript-eslint/no-shadow": [ 188 | "error" 189 | ], 190 | "key-spacing": [ 191 | "error" 192 | ], 193 | "no-multiple-empty-lines": [ 194 | "error" 195 | ], 196 | "@typescript-eslint/no-floating-promises": [ 197 | "error" 198 | ], 199 | "no-return-await": [ 200 | "off" 201 | ], 202 | "@typescript-eslint/return-await": [ 203 | "error" 204 | ], 205 | "no-trailing-spaces": [ 206 | "error" 207 | ], 208 | "dot-notation": [ 209 | "error" 210 | ], 211 | "no-bitwise": [ 212 | "error" 213 | ], 214 | "@typescript-eslint/member-ordering": [ 215 | "error", 216 | { 217 | "default": [ 218 | "public-static-field", 219 | "public-static-method", 220 | "protected-static-field", 221 | "protected-static-method", 222 | "private-static-field", 223 | "private-static-method", 224 | "field", 225 | "constructor", 226 | "method" 227 | ] 228 | } 229 | ] 230 | }, 231 | "overrides": [ 232 | { 233 | "files": [ 234 | ".projenrc.js" 235 | ], 236 | "rules": { 237 | "@typescript-eslint/no-require-imports": "off", 238 | "import/no-extraneous-dependencies": "off" 239 | } 240 | } 241 | ] 242 | } -------------------------------------------------------------------------------- /source/new-image-handler/.gitattributes: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | /.gitattributes linguist-generated 4 | /.projen/** linguist-generated -------------------------------------------------------------------------------- /source/new-image-handler/.gitignore: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | *.lcov 3 | *.log 4 | *.pid 5 | *.pid.lock 6 | *.seed 7 | *.tgz 8 | *.tsbuildinfo 9 | .cache 10 | .eslintcache 11 | .nyc_output 12 | .yarn-integrity 13 | /coverage 14 | /dist 15 | /lib 16 | /test-reports/ 17 | build/Release 18 | coverage 19 | jspm_packages/ 20 | junit.xml 21 | lerna-debug.log* 22 | lib-cov 23 | logs 24 | node_modules/ 25 | npm-debug.log* 26 | pids 27 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 28 | yarn-debug.log* 29 | yarn-error.log* 30 | !/.eslintrc.json 31 | !/.gitattributes 32 | !/.npmignore 33 | !/.projen/deps.json 34 | !/.projen/tasks.json 35 | !/.projenrc.js 36 | !/LICENSE 37 | !/package.json 38 | !/src 39 | !/test 40 | !/tsconfig.eslint.json 41 | !/tsconfig.jest.json 42 | !/tsconfig.json 43 | -------------------------------------------------------------------------------- /source/new-image-handler/.npmignore: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | /.eslintrc.json 3 | /.github 4 | /.idea 5 | /.projen 6 | /.projenrc.js 7 | /.vscode 8 | /coverage 9 | /src 10 | /test 11 | /test-reports/ 12 | /tsconfig.eslint.json 13 | /tsconfig.jest.json 14 | /tsconfig.json 15 | dist 16 | junit.xml 17 | tsconfig.tsbuildinfo 18 | !/lib 19 | !/lib/**/*.d.ts 20 | !/lib/**/*.js 21 | -------------------------------------------------------------------------------- /source/new-image-handler/Dockerfile: -------------------------------------------------------------------------------- 1 | # FROM public.ecr.aws/docker/library/node:14-alpine3.16 as node-with-vips 2 | 3 | # ARG VIPS_VERSION=8.11.3 4 | 5 | # RUN set -x -o pipefail \ 6 | # && wget -O- https://github.com/libvips/libvips/releases/download/v${VIPS_VERSION}/vips-${VIPS_VERSION}.tar.gz | tar xzC /tmp \ 7 | # && apk update \ 8 | # && apk upgrade \ 9 | # && apk add build-base \ 10 | # zlib-dev libxml2-dev glib-dev gobject-introspection-dev \ 11 | # libjpeg-turbo-dev libexif-dev lcms2-dev fftw-dev giflib-dev libpng-dev \ 12 | # libwebp-dev orc-dev tiff-dev poppler-dev librsvg-dev libgsf-dev openexr-dev \ 13 | # libheif-dev libimagequant-dev pango-dev cfitsio-dev expat-dev openjpeg-dev imagemagick-dev \ 14 | # py-gobject3-dev \ 15 | # && cd /tmp/vips-${VIPS_VERSION} \ 16 | # && ./configure --prefix=/usr \ 17 | # --with-magick \ 18 | # --disable-static \ 19 | # --disable-dependency-tracking \ 20 | # --enable-silent-rules \ 21 | # && make -s install-strip \ 22 | # && cd $OLDPWD \ 23 | # && rm -rf /tmp/vips-${VIPS_VERSION} \ 24 | # # && apk del --purge vips-dependencies \ 25 | # && rm -rf /var/cache/apk/* 26 | 27 | FROM public.ecr.aws/docker/library/node:14-alpine3.16 as builder 28 | 29 | WORKDIR /app 30 | 31 | COPY package.json yarn.lock /app/ 32 | 33 | # FIXME: try to remove font-noto when https://github.com/lovell/sharp/issues/3393 is fixed. 34 | RUN apk update && \ 35 | apk add fontconfig font-noto 36 | 37 | RUN yarn 38 | 39 | COPY . . 40 | 41 | RUN yarn build 42 | 43 | 44 | FROM public.ecr.aws/docker/library/node:14-alpine3.16 45 | 46 | WORKDIR /app 47 | 48 | COPY package.json yarn.lock /app/ 49 | COPY ./fonts/* /usr/share/fonts/ 50 | 51 | # FIXME: try to remove font-noto when https://github.com/lovell/sharp/issues/3393 is fixed. 52 | RUN apk update && \ 53 | apk add fontconfig font-noto ffmpeg 54 | 55 | ENV NODE_ENV=production 56 | 57 | RUN yarn --production && \ 58 | yarn cache clean --all 59 | 60 | COPY --from=builder /app/lib /app/lib 61 | # COPY test/fixtures /app/lib/test/fixtures 62 | 63 | EXPOSE 8080 64 | 65 | CMD ["node", "/app/lib/src/index.js"] -------------------------------------------------------------------------------- /source/new-image-handler/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | # FROM public.ecr.aws/docker/library/node:14-slim as node-with-vips 2 | 3 | # ARG VIPS_VERSION=8.11.3 4 | 5 | # RUN set -x -o pipefail \ 6 | # && wget -O- https://github.com/libvips/libvips/releases/download/v${VIPS_VERSION}/vips-${VIPS_VERSION}.tar.gz | tar xzC /tmp \ 7 | # && apk update \ 8 | # && apk upgrade \ 9 | # && apk add build-base \ 10 | # zlib-dev libxml2-dev glib-dev gobject-introspection-dev \ 11 | # libjpeg-turbo-dev libexif-dev lcms2-dev fftw-dev giflib-dev libpng-dev \ 12 | # libwebp-dev orc-dev tiff-dev poppler-dev librsvg-dev libgsf-dev openexr-dev \ 13 | # libheif-dev libimagequant-dev pango-dev cfitsio-dev expat-dev openjpeg-dev imagemagick-dev \ 14 | # py-gobject3-dev \ 15 | # && cd /tmp/vips-${VIPS_VERSION} \ 16 | # && ./configure --prefix=/usr \ 17 | # --with-magick \ 18 | # --disable-static \ 19 | # --disable-dependency-tracking \ 20 | # --enable-silent-rules \ 21 | # && make -s install-strip \ 22 | # && cd $OLDPWD \ 23 | # && rm -rf /tmp/vips-${VIPS_VERSION} \ 24 | # # && apk del --purge vips-dependencies \ 25 | # && rm -rf /var/cache/apk/* 26 | 27 | 28 | # FROM node-with-vips as builder 29 | 30 | # WORKDIR /app 31 | 32 | # COPY package.json yarn.lock /app/ 33 | 34 | # RUN apk update && \ 35 | # apk add fontconfig \ 36 | # imagemagick=~7.0 37 | 38 | # RUN yarn 39 | 40 | # COPY . . 41 | 42 | # RUN yarn build 43 | 44 | 45 | FROM public.ecr.aws/docker/library/node:14-alpine3.16 46 | 47 | WORKDIR /app 48 | 49 | COPY package.json yarn.lock /app/ 50 | COPY ./fonts/* /usr/share/fonts/ 51 | 52 | # FIXME: try to remove font-noto when https://github.com/lovell/sharp/issues/3393 is fixed. 53 | RUN apk update && \ 54 | apk add fontconfig font-noto ffmpeg 55 | 56 | # ENV NODE_ENV=production 57 | 58 | RUN yarn && \ 59 | yarn cache clean --all 60 | 61 | COPY . . 62 | 63 | EXPOSE 8080 64 | 65 | CMD yarn watch-server -------------------------------------------------------------------------------- /source/new-image-handler/Dockerfile.lambda: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/sam/build-nodejs14.x 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json yarn.lock /app/ 6 | 7 | RUN npx yarn 8 | 9 | COPY . /app/ 10 | 11 | RUN npx yarn build && \ 12 | mkdir -p /asset/ && \ 13 | cp -au lib/src /asset/ -------------------------------------------------------------------------------- /source/new-image-handler/Dockerfile.lambda.deps: -------------------------------------------------------------------------------- 1 | # FROM public.ecr.aws/sam/build-nodejs14.x as ImageMagickBuilder 2 | 3 | # WORKDIR /ws 4 | 5 | # RUN yum install -y yum-plugin-ovl && \ 6 | # yum update -y && \ 7 | # yum install -y cmake 8 | 9 | # COPY ImageMagick.Makefile /ws 10 | 11 | # RUN make -f ImageMagick.Makefile libs TARGET_DIR=/opt 12 | 13 | # RUN make -f ImageMagick.Makefile all TARGET_DIR=/opt 14 | 15 | 16 | FROM public.ecr.aws/sam/build-nodejs14.x 17 | 18 | WORKDIR /app 19 | 20 | COPY package.json yarn.lock /app/ 21 | 22 | RUN npx yarn install --prod && \ 23 | mkdir -p /asset/nodejs && \ 24 | cp -au node_modules /asset/nodejs/ 25 | 26 | # COPY --from=ImageMagickBuilder /opt/bin/magick /opt/bin/identify /opt/bin/convert /asset/bin/ 27 | 28 | # RUN strip /asset/bin/* 29 | 30 | RUN du -sh /asset/ -------------------------------------------------------------------------------- /source/new-image-handler/ImageMagick.Makefile: -------------------------------------------------------------------------------- 1 | LIBPNG_VERSION ?= 1.6.37 2 | LIBJPG_VERSION ?= 9c 3 | OPENJP2_VERSION ?= 2.3.1 4 | LIBTIFF_VERSION ?= 4.0.9 5 | BZIP2_VERSION ?= 1.0.6 6 | LIBWEBP_VERSION ?= 0.6.1 7 | IMAGEMAGICK_VERSION ?= 7.0.8-45 8 | 9 | TARGET_DIR ?= /opt/ 10 | PROJECT_ROOT = $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) 11 | CACHE_DIR=$(PROJECT_ROOT)build/cache 12 | 13 | .ONESHELL: 14 | 15 | CONFIGURE = PKG_CONFIG_PATH=$(CACHE_DIR)/lib/pkgconfig \ 16 | ./configure \ 17 | CPPFLAGS=-I$(CACHE_DIR)/include \ 18 | LDFLAGS=-L$(CACHE_DIR)/lib \ 19 | --disable-dependency-tracking \ 20 | --disable-shared \ 21 | --enable-static \ 22 | --prefix=$(CACHE_DIR) 23 | 24 | ## libjpg 25 | 26 | LIBJPG_SOURCE=jpegsrc.v$(LIBJPG_VERSION).tar.gz 27 | 28 | $(LIBJPG_SOURCE): 29 | curl -LO http://ijg.org/files/$(LIBJPG_SOURCE) 30 | 31 | $(CACHE_DIR)/lib/libjpeg.a: $(LIBJPG_SOURCE) 32 | tar xf $< 33 | cd jpeg* 34 | $(CONFIGURE) 35 | make 36 | make install 37 | 38 | 39 | ## libpng 40 | 41 | LIBPNG_SOURCE=libpng-$(LIBPNG_VERSION).tar.xz 42 | 43 | $(LIBPNG_SOURCE): 44 | curl -LO http://prdownloads.sourceforge.net/libpng/$(LIBPNG_SOURCE) 45 | 46 | $(CACHE_DIR)/lib/libpng.a: $(LIBPNG_SOURCE) 47 | tar xf $< 48 | cd libpng* 49 | $(CONFIGURE) 50 | make 51 | make install 52 | 53 | # libbz2 54 | 55 | BZIP2_SOURCE=bzip2-$(BZIP2_VERSION).tar.gz 56 | 57 | $(BZIP2_SOURCE): 58 | curl -LO http://prdownloads.sourceforge.net/bzip2/bzip2-$(BZIP2_VERSION).tar.gz 59 | 60 | $(CACHE_DIR)/lib/libbz2.a: $(BZIP2_SOURCE) 61 | tar xf $< 62 | cd bzip2-* 63 | make libbz2.a 64 | make install PREFIX=$(CACHE_DIR) 65 | 66 | # libtiff 67 | 68 | LIBTIFF_SOURCE=tiff-$(LIBTIFF_VERSION).tar.gz 69 | 70 | $(LIBTIFF_SOURCE): 71 | curl -LO http://download.osgeo.org/libtiff/$(LIBTIFF_SOURCE) 72 | 73 | $(CACHE_DIR)/lib/libtiff.a: $(LIBTIFF_SOURCE) $(CACHE_DIR)/lib/libjpeg.a 74 | tar xf $< 75 | cd tiff-* 76 | $(CONFIGURE) 77 | make 78 | make install 79 | 80 | # libwebp 81 | 82 | LIBWEBP_SOURCE=libwebp-$(LIBWEBP_VERSION).tar.gz 83 | 84 | $(LIBWEBP_SOURCE): 85 | curl -L https://github.com/webmproject/libwebp/archive/v$(LIBWEBP_VERSION).tar.gz -o $(LIBWEBP_SOURCE) 86 | 87 | $(CACHE_DIR)/lib/libwebp.a: $(LIBWEBP_SOURCE) 88 | tar xf $< 89 | cd libwebp-* 90 | sh autogen.sh 91 | $(CONFIGURE) 92 | make 93 | make install 94 | 95 | ## libopenjp2 96 | 97 | OPENJP2_SOURCE=openjp2-$(OPENJP2_VERSION).tar.gz 98 | 99 | $(OPENJP2_SOURCE): 100 | curl -L https://github.com/uclouvain/openjpeg/archive/v$(OPENJP2_VERSION).tar.gz -o $(OPENJP2_SOURCE) 101 | 102 | 103 | $(CACHE_DIR)/lib/libopenjp2.a: $(OPENJP2_SOURCE) $(CACHE_DIR)/lib/libpng.a $(CACHE_DIR)/lib/libtiff.a 104 | tar xf $< 105 | cd openjpeg-* 106 | mkdir -p build 107 | cd build 108 | PKG_CONFIG_PATH=$(CACHE_DIR)/lib/pkgconfig cmake .. \ 109 | -DCMAKE_BUILD_TYPE=Release \ 110 | -DCMAKE_INSTALL_PREFIX=$(CACHE_DIR) \ 111 | -DBUILD_SHARED_LIBS:bool=off \ 112 | -DBUILD_CODEC:bool=off 113 | make clean 114 | make install 115 | 116 | 117 | ## ImageMagick 118 | 119 | IMAGE_MAGICK_SOURCE=ImageMagick-$(IMAGEMAGICK_VERSION).tar.gz 120 | 121 | $(IMAGE_MAGICK_SOURCE): 122 | curl -L https://github.com/ImageMagick/ImageMagick/archive/refs/tags/$(IMAGEMAGICK_VERSION).tar.gz -o $(IMAGE_MAGICK_SOURCE) 123 | 124 | 125 | LIBS:=$(CACHE_DIR)/lib/libjpeg.a \ 126 | $(CACHE_DIR)/lib/libpng.a \ 127 | $(CACHE_DIR)/lib/libopenjp2.a \ 128 | $(CACHE_DIR)/lib/libtiff.a \ 129 | $(CACHE_DIR)/lib/libbz2.a \ 130 | $(CACHE_DIR)/lib/libwebp.a 131 | 132 | $(TARGET_DIR)/bin/identify: $(IMAGE_MAGICK_SOURCE) $(LIBS) 133 | tar xf $< 134 | cd ImageMa* 135 | PKG_CONFIG_PATH=$(CACHE_DIR)/lib/pkgconfig \ 136 | ./configure \ 137 | CPPFLAGS=-I$(CACHE_DIR)/include \ 138 | LDFLAGS=-L$(CACHE_DIR)/lib \ 139 | --disable-dependency-tracking \ 140 | --disable-shared \ 141 | --enable-static \ 142 | --prefix=$(TARGET_DIR) \ 143 | --enable-delegate-build \ 144 | --without-modules \ 145 | --disable-docs \ 146 | --without-magick-plus-plus \ 147 | --without-perl \ 148 | --without-x \ 149 | --disable-openmp 150 | make clean 151 | make all 152 | make install-strip 153 | 154 | libs: $(LIBS) 155 | 156 | all: $(TARGET_DIR)/bin/identify -------------------------------------------------------------------------------- /source/new-image-handler/fonts/FangZhengHeiTi-GBK-1.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wchaws/serverless-image-handler/423c5e761af468ba2fa8eb9fbbd700573c614c0a/source/new-image-handler/fonts/FangZhengHeiTi-GBK-1.ttf -------------------------------------------------------------------------------- /source/new-image-handler/fonts/README.md: -------------------------------------------------------------------------------- 1 | # Usage Declaretion 2 | Here we use FangZhengHeiTi by default, please take a look the fellow link to make sure your usage is authorized: 3 | 4 |  https://www.foundertype.com/index.php/About/powerbus.html 5 | 6 | you can also use other font by changing the font file. -------------------------------------------------------------------------------- /source/new-image-handler/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "new-image-handler", 3 | "scripts": { 4 | "watch-server": "nodemon --ignore test/ --watch src -e ts,tsx --exec ts-node -P tsconfig.json --files src/index.ts", 5 | "serve": "node src/lib/index.js", 6 | "compile": "tsc --project tsconfig.json", 7 | "build": "yarn test && yarn compile", 8 | "build:docker": "docker build -t new-image-handler .", 9 | "build:docker.dev": "docker build -t new-image-handler.dev -f Dockerfile.dev .", 10 | "run:docker.dev": "yarn build:docker.dev && docker run --rm -ti -p 8080:8080 -e NODE_ENV=dev -v $PWD/src:/app/src -v $PWD/test:/app/test new-image-handler.dev", 11 | "run:local": "yarn watch-server", 12 | "test": "rm -fr lib/ && yarn test:compile && jest --passWithNoTests --all --coverageProvider=v8 && yarn eslint", 13 | "test:watch": "jest --watch", 14 | "test:update": "jest --updateSnapshot", 15 | "test:compile": "tsc --noEmit --project tsconfig.jest.json", 16 | "upgrade-dependencies": "export CI=0 && npm-check-updates --upgrade --target=minor --reject='projen' && yarn install --check-files && yarn upgrade", 17 | "watch": "tsc --project tsconfig.json -w", 18 | "benchmark": "ts-node test/bench/perf.ts", 19 | "eslint": "eslint --ext .js,.ts,.tsx --no-error-on-unmatched-pattern src test .projenrc.js" 20 | }, 21 | "devDependencies": { 22 | "@types/aws-lambda": "^8.10.95", 23 | "@types/benchmark": "^2.1.1", 24 | "@types/jest": "^26.0.24", 25 | "@types/koa": "^2.13.4", 26 | "@types/koa-bodyparser": "^4.3.7", 27 | "@types/koa-cash": "^4.1.0", 28 | "@types/koa-logger": "^3.1.2", 29 | "@types/koa-router": "^7.4.4", 30 | "@types/node": "^14.18.16", 31 | "@types/sharp": "^0.31.0", 32 | "@typescript-eslint/eslint-plugin": "^4.33.0", 33 | "@typescript-eslint/parser": "^4.33.0", 34 | "benchmark": "^2.1.4", 35 | "eslint": "^7.32.0", 36 | "eslint-import-resolver-node": "^0.3.6", 37 | "eslint-import-resolver-typescript": "^2.7.1", 38 | "eslint-plugin-import": "^2.26.0", 39 | "jest": "^27.5.1", 40 | "jest-junit": "^12", 41 | "jimp": "^0.16.1", 42 | "json-schema": "^0.4.0", 43 | "nodemon": "^2.0.16", 44 | "npm-check-updates": "^11", 45 | "ts-jest": "^27.1.4", 46 | "ts-node": "^10.7.0", 47 | "typescript": "^4.6.4" 48 | }, 49 | "dependencies": { 50 | "aws-sdk": "^2.1130.0", 51 | "html-entities": "^2.3.3", 52 | "http-errors": "^1.8.1", 53 | "koa": "^2.13.4", 54 | "koa-bodyparser": "^4.3.0", 55 | "koa-cash": "^4.1.1", 56 | "koa-logger": "^3.2.1", 57 | "koa-router": "^10.1.1", 58 | "lru-cache": "^10.0.0", 59 | "sharp": "^0.31.1" 60 | }, 61 | "bundledDependencies": [], 62 | "engines": { 63 | "node": ">= 12.0.0" 64 | }, 65 | "license": "Apache-2.0", 66 | "version": "0.0.0", 67 | "jest": { 68 | "testPathIgnorePatterns": [ 69 | "/node_modules/", 70 | "/cdk.out/" 71 | ], 72 | "watchPathIgnorePatterns": [ 73 | "/node_modules/", 74 | "/cdk.out/" 75 | ], 76 | "testMatch": [ 77 | "**/__tests__/**/*.ts?(x)", 78 | "**/?(*.)+(spec|test).ts?(x)" 79 | ], 80 | "clearMocks": true, 81 | "collectCoverage": true, 82 | "coverageReporters": [ 83 | "json", 84 | "lcov", 85 | "clover", 86 | "text" 87 | ], 88 | "coverageDirectory": "coverage", 89 | "coveragePathIgnorePatterns": [ 90 | "/node_modules/", 91 | "/cdk.out/" 92 | ], 93 | "reporters": [ 94 | "default", 95 | [ 96 | "jest-junit", 97 | { 98 | "outputDirectory": "test-reports" 99 | } 100 | ] 101 | ], 102 | "preset": "ts-jest", 103 | "globals": { 104 | "ts-jest": { 105 | "tsconfig": "tsconfig.jest.json" 106 | } 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /source/new-image-handler/src/config.ts: -------------------------------------------------------------------------------- 1 | const { 2 | REGION, 3 | AWS_REGION, 4 | NODE_ENV, 5 | BUCKET, 6 | SRC_BUCKET, 7 | STYLE_TABLE_NAME, 8 | AUTO_WEBP, 9 | SECRET_NAME, 10 | SHARP_QUEUE_LIMIT, 11 | CONFIG_JSON_PARAMETER_NAME, 12 | CACHE_TTL_SEC, 13 | CACHE_MAX_ITEMS, 14 | CACHE_MAX_SIZE_MB, 15 | } = process.env; 16 | 17 | export interface IConfig { 18 | port: number; 19 | region: string; 20 | isProd: boolean; 21 | srcBucket: string; 22 | styleTableName: string; 23 | autoWebp: boolean; 24 | secretName: string; 25 | sharpQueueLimit: number; 26 | configJsonParameterName: string; 27 | CACHE_TTL_SEC: number; 28 | CACHE_MAX_ITEMS: number; 29 | CACHE_MAX_SIZE_MB: number; 30 | } 31 | 32 | function parseInt(s: string) { 33 | return Number.parseInt(s, 10); 34 | } 35 | 36 | const conf: IConfig = { 37 | port: 8080, 38 | region: REGION ?? AWS_REGION ?? 'us-west-2', 39 | isProd: NODE_ENV === 'production', 40 | srcBucket: BUCKET || SRC_BUCKET || 'sih-input', 41 | styleTableName: STYLE_TABLE_NAME || 'style-table-name', 42 | autoWebp: ['yes', '1', 'true'].includes((AUTO_WEBP ?? '').toLowerCase()), 43 | secretName: SECRET_NAME ?? 'X-Client-Authorization', 44 | sharpQueueLimit: parseInt(SHARP_QUEUE_LIMIT ?? '1'), 45 | configJsonParameterName: CONFIG_JSON_PARAMETER_NAME ?? '', 46 | CACHE_TTL_SEC: parseInt(CACHE_TTL_SEC ?? '300'), 47 | CACHE_MAX_ITEMS: parseInt(CACHE_MAX_ITEMS ?? '10000'), 48 | CACHE_MAX_SIZE_MB: parseInt(CACHE_MAX_SIZE_MB ?? '1024'), 49 | }; 50 | 51 | export default conf; -------------------------------------------------------------------------------- /source/new-image-handler/src/debug.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os'; 2 | import { LRUCache } from 'lru-cache'; 3 | import * as sharp from 'sharp'; 4 | 5 | export interface ISharpInfo { 6 | cache: sharp.CacheResult; 7 | simd: boolean; 8 | counters: sharp.SharpCounters; 9 | concurrency: number; 10 | versions: { 11 | vips: string; 12 | cairo?: string; 13 | croco?: string; 14 | exif?: string; 15 | expat?: string; 16 | ffi?: string; 17 | fontconfig?: string; 18 | freetype?: string; 19 | gdkpixbuf?: string; 20 | gif?: string; 21 | glib?: string; 22 | gsf?: string; 23 | harfbuzz?: string; 24 | jpeg?: string; 25 | lcms?: string; 26 | orc?: string; 27 | pango?: string; 28 | pixman?: string; 29 | png?: string; 30 | svg?: string; 31 | tiff?: string; 32 | webp?: string; 33 | avif?: string; 34 | heif?: string; 35 | xml?: string; 36 | zlib?: string; 37 | }; 38 | } 39 | 40 | export interface IDebugInfo { 41 | os: { 42 | arch: string; 43 | cpus: number; 44 | loadavg: number[]; 45 | }; 46 | memory: { 47 | stats: string; 48 | free: number; 49 | total: number; 50 | usage: NodeJS.MemoryUsage; 51 | }; 52 | resource: { 53 | usage: NodeJS.ResourceUsage; 54 | }; 55 | lruCache?: { 56 | keys: number; 57 | sizeMB: number; 58 | ttlSec: number; 59 | }; 60 | sharp: ISharpInfo; 61 | } 62 | 63 | export default function debug(lruCache?: LRUCache): IDebugInfo { 64 | const ret: IDebugInfo = { 65 | os: { 66 | arch: os.arch(), 67 | cpus: os.cpus().length, 68 | loadavg: os.loadavg(), 69 | }, 70 | memory: { 71 | stats: `free: ${formatBytes(os.freemem())}, total: ${formatBytes(os.totalmem())}, usage ${((os.totalmem() - os.freemem()) / os.totalmem() * 100).toFixed(2)} %`, 72 | free: os.freemem(), 73 | total: os.totalmem(), 74 | usage: process.memoryUsage(), 75 | }, 76 | resource: { 77 | usage: process.resourceUsage(), 78 | }, 79 | sharp: { 80 | cache: sharp.cache(), 81 | simd: sharp.simd(), 82 | counters: sharp.counters(), 83 | concurrency: sharp.concurrency(), 84 | versions: sharp.versions, 85 | }, 86 | }; 87 | if (lruCache) { 88 | ret.lruCache = { 89 | keys: lruCache.size, 90 | sizeMB: Math.round(b2mb(lruCache.calculatedSize) * 100) / 100, 91 | ttlSec: Math.round(lruCache.ttl / 1000), 92 | }; 93 | } 94 | return ret; 95 | } 96 | 97 | function b2mb(v: number) { 98 | return v / 1048576; 99 | } 100 | 101 | function formatBytes(bytes: number) { 102 | const units = ['B', 'KB', 'MB', 'GB', 'TB']; 103 | let i = 0; 104 | for (; bytes >= 1024 && i < units.length - 1; i++) { 105 | bytes /= 1024; 106 | } 107 | return `${bytes.toFixed(2)} ${units[i]}`; 108 | }; 109 | -------------------------------------------------------------------------------- /source/new-image-handler/src/default.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { ParsedUrlQuery } from 'querystring'; 3 | import config from './config'; 4 | import { InvalidArgument, IProcessor } from './processor'; 5 | import { ImageProcessor } from './processor/image'; 6 | import { StyleProcessor } from './processor/style'; 7 | import { VideoProcessor } from './processor/video'; 8 | import { IBufferStore, S3Store, LocalStore, MemKVStore, DynamoDBStore, IKVStore } from './store'; 9 | import * as style from './style.json'; 10 | 11 | const PROCESSOR_MAP: { [key: string]: IProcessor } = { 12 | [ImageProcessor.getInstance().name]: ImageProcessor.getInstance(), 13 | [StyleProcessor.getInstance().name]: StyleProcessor.getInstance(kvstore()), 14 | [VideoProcessor.getInstance().name]: VideoProcessor.getInstance(), 15 | }; 16 | 17 | export function setMaxGifSizeMB(value: number) { 18 | ImageProcessor.getInstance().setMaxGifSizeMB(value); 19 | } 20 | 21 | export function setMaxGifPages(value: number) { 22 | ImageProcessor.getInstance().setMaxGifPages(value); 23 | } 24 | 25 | export function getProcessor(name: string): IProcessor { 26 | const processor = PROCESSOR_MAP[name]; 27 | if (!processor) { 28 | throw new InvalidArgument('Can Not find processor'); 29 | } 30 | return processor; 31 | } 32 | 33 | export function bufferStore(p?: string): IBufferStore { 34 | if (config.isProd) { 35 | if (!p) { p = config.srcBucket; } 36 | console.log(`use ${S3Store.name} s3://${p}`); 37 | return new S3Store(p); 38 | } else { 39 | if (!p) { p = path.join(__dirname, '../test/fixtures'); } 40 | console.log(`use ${LocalStore.name} file://${p}`); 41 | return new LocalStore(p); 42 | } 43 | } 44 | 45 | export function kvstore(): IKVStore { 46 | if (config.isProd) { 47 | console.log(`use ${DynamoDBStore.name}`); 48 | return new DynamoDBStore(config.styleTableName); 49 | } else { 50 | console.log(`use ${MemKVStore.name}`); 51 | return new MemKVStore(style); 52 | } 53 | } 54 | 55 | export function parseRequest(uri: string, query: ParsedUrlQuery): { uri: string; actions: string[] } { 56 | uri = uri.replace(/^\//, ''); // trim leading slash "/" 57 | const parts = uri.split(/@?!/, 2); 58 | if (parts.length === 1) { 59 | const x_oss_process = (query['x-oss-process'] as string) ?? ''; 60 | return { 61 | uri: uri, 62 | actions: x_oss_process.split('/').filter(x => x), 63 | }; 64 | } 65 | const stylename = (parts[1] ?? '').trim(); 66 | if (!stylename) { 67 | throw new InvalidArgument('Empty style name'); 68 | } 69 | return { 70 | uri: parts[0], 71 | actions: ['style', stylename], 72 | }; 73 | } -------------------------------------------------------------------------------- /source/new-image-handler/src/imagemagick.ts: -------------------------------------------------------------------------------- 1 | import * as child_process from 'child_process'; 2 | import { Readable } from 'stream'; 3 | 4 | // TODO: ImageMagick is slower than sharp for about 3x. Try removing ImageMagick later. 5 | 6 | const MAX_BUFFER = 1024 * 1024; 7 | 8 | // https://sourcegraph.com/github.com/nodejs/node@f7668fa2aa2781dc57d5423a0cfcfa933539779e/-/blob/lib/child_process.js?L279:10 9 | function _imagemagick(cmd: string, buffer: Buffer, args: readonly string[]) { 10 | const child = child_process.spawn(cmd, args); 11 | 12 | Readable.from(buffer).pipe(child.stdin); 13 | 14 | return new Promise((resolve, reject) => { 15 | const _stdout: any[] = []; 16 | let stdoutLen = 0; 17 | 18 | let killed = false; 19 | let exited = false; 20 | let ex: Error | null = null; 21 | 22 | function exithandler(code: number | null, signal: NodeJS.Signals | null) { 23 | if (exited) { return; } 24 | exited = true; 25 | 26 | // merge chunks 27 | const stdout = Buffer.concat(_stdout); 28 | 29 | if (!ex && code === 0 && signal === null) { 30 | resolve(stdout); 31 | return; 32 | } 33 | 34 | const _cmd = cmd + args.join(' '); 35 | if (!ex) { 36 | // eslint-disable-next-line no-restricted-syntax 37 | ex = new Error('Command failed: ' + _cmd + '\n'); 38 | (ex as any).killed = child.killed || killed; 39 | (ex as any).code = code; 40 | (ex as any).signal = signal; 41 | } 42 | (ex as any).cmd = _cmd; 43 | reject(ex); 44 | } 45 | 46 | function errorhandler(e: Error) { 47 | ex = e; 48 | if (child.stdout) { 49 | child.stdout.destroy(); 50 | } 51 | if (child.stderr) { 52 | child.stderr.destroy(); 53 | } 54 | exithandler(null, null); 55 | } 56 | 57 | function kill() { 58 | if (child.stdout) { 59 | child.stdout.destroy(); 60 | } 61 | if (child.stderr) { 62 | child.stderr.destroy(); 63 | } 64 | 65 | killed = true; 66 | try { 67 | child.kill('SIGTERM'); 68 | } catch (e) { 69 | ex = e as Error; 70 | exithandler(null, null); 71 | } 72 | } 73 | 74 | if (child.stdout) { 75 | child.stdout.on('data', function onChildStdout(chunk) { 76 | stdoutLen += chunk.length; 77 | if (stdoutLen > MAX_BUFFER) { 78 | ex = new Error('Exceed max buffer size'); 79 | kill(); 80 | } else { 81 | _stdout.push(chunk); 82 | } 83 | }); 84 | } else { 85 | reject(new Error('Can\'t create stdout')); 86 | return; 87 | } 88 | 89 | child.on('close', exithandler); 90 | child.on('error', errorhandler); 91 | }); 92 | } 93 | 94 | export function convert(buffer: Buffer, args: readonly string[]) { 95 | return _imagemagick('convert', buffer, ['-', ...args, '-']); 96 | } 97 | 98 | export function identify(buffer: Buffer, args: readonly string[]) { 99 | return _imagemagick('identify', buffer, [...args, '-']); 100 | } -------------------------------------------------------------------------------- /source/new-image-handler/src/index-lambda.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unresolved 2 | import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda'; 3 | import * as HttpErrors from 'http-errors'; 4 | import config from './config'; 5 | import debug from './debug'; 6 | import { bufferStore, getProcessor, parseRequest } from './default'; 7 | import * as is from './is'; 8 | import { Features } from './processor'; 9 | 10 | 11 | export const handler = WrapError(async (event: APIGatewayProxyEventV2): Promise => { 12 | console.log('event:', JSON.stringify(event)); 13 | 14 | if (event.rawPath === '/' || event.rawPath === '/ping') { 15 | return resp(200, 'ok'); 16 | } else if (event.rawPath === '/_debug') { 17 | console.log(JSON.stringify(debug())); 18 | return resp(400, 'Please check your server logs for more details!'); 19 | } 20 | 21 | const accept = event.headers.Accept ?? event.headers.accept ?? ''; 22 | const autoWebp = config.autoWebp && accept.includes('image/webp'); 23 | 24 | console.log('autoWebp:', autoWebp); 25 | 26 | const bs = getBufferStore(event); 27 | const { uri, actions } = parseRequest(event.rawPath, event.queryStringParameters ?? {}); 28 | 29 | if (actions.length > 1) { 30 | const processor = getProcessor(actions[0]); 31 | const context = await processor.newContext(uri, actions, bs); 32 | context.features[Features.AutoWebp] = autoWebp; 33 | const { data, type } = await processor.process(context); 34 | 35 | return resp(200, data, type, context.headers); 36 | } else { 37 | const { buffer, type, headers } = await bs.get(uri, bypass); 38 | 39 | return resp(200, buffer, type, headers); 40 | } 41 | }); 42 | 43 | function bypass() { 44 | // NOTE: This is intended to tell CloudFront to directly access the s3 object without through API GW. 45 | throw new HttpErrors[403]('Please visit s3 directly'); 46 | } 47 | 48 | function resp(code: number, body: any, type?: string, headers?: { [key: string]: any }): APIGatewayProxyResultV2 { 49 | const isBase64Encoded = Buffer.isBuffer(body); 50 | let data: string = ''; 51 | if (isBase64Encoded) { 52 | data = body.toString('base64'); 53 | } else if (is.string(body)) { 54 | data = body; 55 | type = 'text/plain'; 56 | } else { 57 | data = JSON.stringify(body); 58 | type = 'application/json'; 59 | } 60 | 61 | return { 62 | isBase64Encoded, 63 | statusCode: code, 64 | headers: Object.assign({ 'Content-Type': type ?? 'text/plain' }, headers), 65 | body: data, 66 | }; 67 | } 68 | 69 | interface LambdaHandlerFn { 70 | (event: APIGatewayProxyEventV2): Promise; 71 | } 72 | 73 | 74 | function WrapError(fn: LambdaHandlerFn): LambdaHandlerFn { 75 | return async (event: APIGatewayProxyEventV2): Promise => { 76 | try { 77 | return await fn(event); 78 | } catch (err: any) { 79 | console.error(err); 80 | // ENOENT support 81 | if (err.code === 'ENOENT') { 82 | err.status = 404; 83 | err.message = 'NotFound'; 84 | } 85 | const statusCode = err.statusCode ?? err.status ?? 500; 86 | const body = { 87 | status: statusCode, 88 | name: err.name, 89 | message: err.message, 90 | }; 91 | return resp(statusCode, body); 92 | } 93 | }; 94 | } 95 | 96 | const DefaultBufferStore = bufferStore(); 97 | 98 | function getBufferStore(event: APIGatewayProxyEventV2) { 99 | const bucket = event.headers['x-bucket']; 100 | if (bucket) { 101 | return bufferStore(bucket); 102 | } 103 | return DefaultBufferStore; 104 | } -------------------------------------------------------------------------------- /source/new-image-handler/src/is.ts: -------------------------------------------------------------------------------- 1 | export function inRange(val: number, min: number, max: number): boolean { 2 | return val >= min && val <= max; 3 | }; 4 | 5 | export function hexColor(c: string): boolean { 6 | const regex = /^#([\da-f]{3}){1,2}$|^#([\da-f]{4}){1,2}$/i; 7 | return !!(c && regex.test(c)); 8 | } 9 | 10 | 11 | export function defined(val: any) { 12 | return typeof val !== 'undefined' && val !== null; 13 | }; 14 | 15 | 16 | export function object(val: any) { 17 | return typeof val === 'object'; 18 | }; 19 | 20 | 21 | export function plainObject(val: any) { 22 | return Object.prototype.toString.call(val) === '[object Object]'; 23 | }; 24 | 25 | 26 | export function fn(val: any) { 27 | return typeof val === 'function'; 28 | }; 29 | 30 | 31 | export function bool(val: any) { 32 | return typeof val === 'boolean'; 33 | }; 34 | 35 | 36 | export function buffer(val: any) { 37 | return val instanceof Buffer; 38 | }; 39 | 40 | 41 | export function typedArray(val: any) { 42 | if (defined(val)) { 43 | switch (val.constructor) { 44 | case Uint8Array: 45 | case Uint8ClampedArray: 46 | case Int8Array: 47 | case Uint16Array: 48 | case Int16Array: 49 | case Uint32Array: 50 | case Int32Array: 51 | case Float32Array: 52 | case Float64Array: 53 | return true; 54 | } 55 | } 56 | 57 | return false; 58 | }; 59 | 60 | 61 | export function string(val: any) { 62 | return typeof val === 'string' && val.length > 0; 63 | }; 64 | 65 | 66 | export function number(val: any) { 67 | return typeof val === 'number' && !Number.isNaN(val); 68 | }; 69 | 70 | 71 | export function integer(val: any) { 72 | return Number.isInteger(val); 73 | }; 74 | 75 | export function inArray(val: any, list: any[]) { 76 | return list.includes(val); 77 | }; -------------------------------------------------------------------------------- /source/new-image-handler/src/processor/image/_base.ts: -------------------------------------------------------------------------------- 1 | import { IImageContext } from '.'; 2 | import { IAction, IActionOpts, IProcessContext, ReadOnly, IActionMask } from '..'; 3 | 4 | 5 | export abstract class BaseImageAction implements IAction { 6 | public name: string = 'unknown'; 7 | abstract validate(params: string[]): ReadOnly; 8 | abstract process(ctx: IProcessContext, params: string[]): Promise; 9 | public beforeNewContext(_1: IProcessContext, params: string[], _3: number): void { 10 | this.validate(params); 11 | } 12 | public beforeProcess(_1: IImageContext, _2: string[], _3: number): void { } 13 | } 14 | 15 | export class ActionMask implements IActionMask { 16 | private readonly _masks: boolean[]; 17 | 18 | public constructor(private readonly _actions: string[]) { 19 | this._masks = _actions.map(() => true); 20 | } 21 | 22 | public get length(): number { 23 | return this._actions.length; 24 | } 25 | 26 | private _check(index: number): void { 27 | if (!(0 <= index && index < this.length)) { 28 | throw new Error('Index out of range'); 29 | } 30 | } 31 | 32 | public getAction(index: number): string { 33 | this._check(index); 34 | return this._actions[index]; 35 | } 36 | 37 | public isEnabled(index: number): boolean { 38 | this._check(index); 39 | return this._masks[index]; 40 | } 41 | 42 | public isDisabled(index: number): boolean { 43 | this._check(index); 44 | return !this._masks[index]; 45 | } 46 | 47 | public enable(index: number) { 48 | this._check(index); 49 | this._masks[index] = true; 50 | } 51 | 52 | public disable(index: number) { 53 | this._check(index); 54 | this._masks[index] = false; 55 | } 56 | 57 | public disableAll() { 58 | for (let i = 0; i < this._masks.length; i++) { 59 | this._masks[i] = false; 60 | } 61 | } 62 | 63 | public filterEnabledActions(): string[] { 64 | return this._actions.filter((_, index) => this._masks[index]); 65 | } 66 | 67 | public forEachAction(cb: (action: string, enabled: boolean, index: number) => void): void { 68 | this._actions.forEach((action, index) => { 69 | cb(action, this.isEnabled(index), index); 70 | }); 71 | } 72 | } 73 | 74 | export function split1(s: string, sep: string = ',') { 75 | const split = s.split(sep, 1); 76 | return [split[0], s.substring(split[0].length + sep.length)]; 77 | } 78 | -------------------------------------------------------------------------------- /source/new-image-handler/src/processor/image/auto-orient.ts: -------------------------------------------------------------------------------- 1 | import { IImageContext } from '.'; 2 | import { IActionOpts, ReadOnly, InvalidArgument, IProcessContext, Features } from '..'; 3 | import { BaseImageAction } from './_base'; 4 | 5 | export interface AutoOrientOpts extends IActionOpts { 6 | auto: boolean; 7 | } 8 | 9 | export class AutoOrientAction extends BaseImageAction { 10 | public readonly name: string = 'auto-orient'; 11 | 12 | public beforeNewContext(ctx: IProcessContext, _: string[]): void { 13 | ctx.features[Features.AutoOrient] = false; 14 | } 15 | 16 | public beforeProcess(ctx: IImageContext, _2: string[], index: number): void { 17 | if ('gif' === ctx.metadata.format) { 18 | ctx.mask.disable(index); 19 | } 20 | } 21 | 22 | public validate(params: string[]): ReadOnly { 23 | const opt: AutoOrientOpts = { auto: false }; 24 | 25 | if (params.length !== 2) { 26 | throw new InvalidArgument('Auto-orient param error, e.g: auto-orient,1'); 27 | } 28 | if (params[1] === '1') { 29 | opt.auto = true; 30 | } else if (params[1] === '0') { 31 | opt.auto = false; 32 | } else { 33 | throw new InvalidArgument('Auto-orient param must be 0 or 1'); 34 | } 35 | return opt; 36 | } 37 | 38 | 39 | public async process(ctx: IImageContext, params: string[]): Promise { 40 | const opt = this.validate(params); 41 | if (opt.auto) { 42 | ctx.image.rotate(); 43 | } 44 | 45 | } 46 | } -------------------------------------------------------------------------------- /source/new-image-handler/src/processor/image/blur.ts: -------------------------------------------------------------------------------- 1 | import { IImageContext } from '.'; 2 | import { IActionOpts, ReadOnly, InvalidArgument } from '..'; 3 | import * as is from '../../is'; 4 | import { BaseImageAction } from './_base'; 5 | 6 | export interface BlurOpts extends IActionOpts { 7 | r: number; 8 | s: number; 9 | } 10 | 11 | export class BlurAction extends BaseImageAction { 12 | public readonly name: string = 'blur'; 13 | 14 | public validate(params: string[]): ReadOnly { 15 | let opt: BlurOpts = { r: 0, s: 0 }; 16 | 17 | if (params.length < 2) { 18 | throw new InvalidArgument('blur param error, e.g: blur,r_3,s_2'); 19 | } 20 | 21 | for (const param of params) { 22 | if ((this.name === param) || (!param)) { 23 | continue; 24 | } 25 | const [k, v] = param.split('_'); 26 | if (k === 'r') { 27 | const r = Number.parseInt(v, 10); 28 | if (is.inRange(r, 1, 50)) { 29 | opt.r = r; 30 | } else { 31 | throw new InvalidArgument('Blur param \'r\' must be between 0 and 50'); 32 | } 33 | } else if (k === 's') { 34 | const s = Number.parseInt(v, 10); 35 | if (is.inRange(s, 1, 50)) { 36 | opt.s = s; 37 | } else { 38 | throw new InvalidArgument('Blur param \'s\' must be between 0 and 50'); 39 | } 40 | } else { 41 | throw new InvalidArgument(`Unkown param: "${k}"`); 42 | } 43 | 44 | } 45 | return opt; 46 | } 47 | 48 | 49 | public async process(ctx: IImageContext, params: string[]): Promise { 50 | const opt = this.validate(params); 51 | const a = -0.0057; 52 | const b = 1.1787; 53 | const c = -0.0694; 54 | const sigma = a * opt.s * opt.s + b * opt.s + c; 55 | 56 | const sqrtln01 = 1.51743; // Sqrt(-ln(0.1)) 57 | const max_x = Math.floor(sigma * sqrtln01); 58 | const max_n = 2 * Math.max(max_x - 1, 0) + 1; // The max gauss kernel size 59 | const n = 2 * opt.r + 1; // The given gauss kernel size 60 | 61 | if ((n < max_n) && (n <= 51)) { // It will be really slow if n > 51 62 | console.log('Use manual blur'); 63 | ctx.image.convolve({ 64 | width: n, 65 | height: n, 66 | kernel: gaussmat(n, sigma), 67 | }); 68 | } else { 69 | console.log('Use built-in blur'); 70 | ctx.image.blur(sigma); 71 | } 72 | } 73 | } 74 | 75 | function gaussmat(n: number, sigma: number): ArrayLike { 76 | if (n % 2 === 0) { 77 | throw new Error('gaussmat kernel size must be odd'); 78 | } 79 | const mat = new Array(n * n); 80 | for (let y = 0; y < n; y++) { 81 | for (let x = 0; x < n; x++) { 82 | // eslint-disable-next-line no-bitwise 83 | let xo = x - (n >> 1); 84 | // eslint-disable-next-line no-bitwise 85 | let yo = y - (n >> 1); 86 | const distance = xo * xo + yo * yo; 87 | const v = Math.exp(-distance / (sigma * sigma)); 88 | mat[y * n + x] = v; 89 | } 90 | } 91 | return mat; 92 | } -------------------------------------------------------------------------------- /source/new-image-handler/src/processor/image/bright.ts: -------------------------------------------------------------------------------- 1 | import { IImageContext } from '.'; 2 | import { IActionOpts, ReadOnly, InvalidArgument } from '..'; 3 | import * as is from '../../is'; 4 | import { BaseImageAction } from './_base'; 5 | 6 | export interface BrightOpts extends IActionOpts { 7 | bright: number; 8 | } 9 | 10 | export class BrightAction extends BaseImageAction { 11 | public readonly name: string = 'bright'; 12 | 13 | public validate(params: string[]): ReadOnly { 14 | const opt: BrightOpts = { bright: 100 }; 15 | 16 | if (params.length !== 2) { 17 | throw new InvalidArgument('Bright param error, e.g: bright,50'); 18 | } 19 | const b = Number.parseInt(params[1], 10); 20 | if (is.inRange(b, -100, 100)) { 21 | opt.bright = b; 22 | } else { 23 | throw new InvalidArgument('Bright must be between -100 and 100'); 24 | } 25 | return opt; 26 | } 27 | 28 | 29 | public async process(ctx: IImageContext, params: string[]): Promise { 30 | const opt = this.validate(params); 31 | 32 | ctx.image.linear(1, opt.bright); 33 | } 34 | } -------------------------------------------------------------------------------- /source/new-image-handler/src/processor/image/cgif.ts: -------------------------------------------------------------------------------- 1 | import { IActionOpts, ReadOnly, Features, IProcessContext, InvalidArgument } from '..'; 2 | import * as is from '../../is'; 3 | import { BaseImageAction } from './_base'; 4 | 5 | 6 | export interface CgifOpts extends IActionOpts { 7 | s?: number; 8 | } 9 | 10 | export class CgifAction extends BaseImageAction { 11 | public readonly name: string = 'cgif'; 12 | 13 | public beforeNewContext(ctx: IProcessContext, params: string[]): void { 14 | ctx.features[Features.ReadAllAnimatedFrames] = false; 15 | if (params.length !== 2) { 16 | throw new InvalidArgument('Cut gif param error, e.g: cgif,s_1'); 17 | } 18 | const [k, v] = params[1].split('_'); 19 | if (k === 's') { 20 | if (!is.inRange(Number.parseInt(v, 10), 1, 1000)) { 21 | throw new InvalidArgument(`Unkown param: "${k}"`); 22 | } 23 | ctx.features[Features.LimitAnimatedFrames] = Number.parseInt(v, 10); 24 | } else { 25 | throw new InvalidArgument(`Unkown param: "${k}"`); 26 | } 27 | } 28 | 29 | public validate(): ReadOnly { 30 | let opt: CgifOpts = {}; 31 | return opt; 32 | } 33 | 34 | public async process(): Promise { 35 | } 36 | } -------------------------------------------------------------------------------- /source/new-image-handler/src/processor/image/circle.ts: -------------------------------------------------------------------------------- 1 | import * as sharp from 'sharp'; 2 | import { IImageContext } from '.'; 3 | import { IActionOpts, ReadOnly, InvalidArgument } from '..'; 4 | import * as is from '../../is'; 5 | import { BaseImageAction } from './_base'; 6 | 7 | export interface CircleOpts extends IActionOpts { 8 | r: number; 9 | } 10 | 11 | export class CircleAction extends BaseImageAction { 12 | public readonly name: string = 'circle'; 13 | 14 | public validate(params: string[]): ReadOnly { 15 | let opt: CircleOpts = { r: 1 }; 16 | 17 | if (params.length !== 2) { 18 | throw new InvalidArgument('Circle param error, e.g: circle,r_30'); 19 | } 20 | 21 | for (const param of params) { 22 | if ((this.name === param) || (!param)) { 23 | continue; 24 | } 25 | const [k, v] = param.split('_'); 26 | if (k === 'r') { 27 | const r = Number.parseInt(v, 10); 28 | if (is.inRange(r, 1, 4096)) { 29 | opt.r = r; 30 | } else { 31 | throw new InvalidArgument('Circle param \'r\' must be between 1 and 4096'); 32 | } 33 | } else { 34 | throw new InvalidArgument(`Unkown param: "${k}"`); 35 | } 36 | } 37 | 38 | return opt; 39 | } 40 | 41 | 42 | public async process(ctx: IImageContext, params: string[]): Promise { 43 | const opt = this.validate(params); 44 | const metadata = await sharp(await ctx.image.toBuffer()).metadata(); // https://github.com/lovell/sharp/issues/2959 45 | if (!(metadata.width && metadata.height)) { 46 | throw new InvalidArgument('Can\'t read image\'s width and height'); 47 | } 48 | 49 | const pages = metadata.pages ?? 1; 50 | const cx = metadata.width / 2; 51 | const cy = metadata.height / 2; 52 | const s = Math.min(metadata.width, metadata.height); // shorter side 53 | const r = Math.min(opt.r, s / 2); // radius 54 | const d = Math.min(Math.round(2 * r) + 1, s); // diameter 55 | 56 | const circles = Array.from({ length: pages }, (_, i) => 57 | ``, 58 | ); 59 | const mask = Buffer.from(` 60 | ${circles.join('\n')} 61 | `); 62 | 63 | const region = { 64 | left: Math.max(Math.round(cx - r), 0), 65 | top: Math.max(Math.round(cy - r), 0), 66 | width: d, 67 | height: d, 68 | }; 69 | 70 | ctx.image.extract(region).composite([ 71 | { input: mask, blend: 'dest-in' }, 72 | ]); 73 | } 74 | } -------------------------------------------------------------------------------- /source/new-image-handler/src/processor/image/contrast.ts: -------------------------------------------------------------------------------- 1 | import { IImageContext } from '.'; 2 | import { IActionOpts, ReadOnly, InvalidArgument } from '..'; 3 | import * as is from '../../is'; 4 | import { BaseImageAction } from './_base'; 5 | 6 | export interface ContrastOpts extends IActionOpts { 7 | contrast: number; 8 | } 9 | 10 | export class ContrastAction extends BaseImageAction { 11 | public readonly name: string = 'contrast'; 12 | 13 | public validate(params: string[]): ReadOnly { 14 | const opt: ContrastOpts = { contrast: -100 }; 15 | 16 | if (params.length !== 2) { 17 | throw new InvalidArgument('Contrast param error, e.g: contrast,-50'); 18 | } 19 | const b = Number.parseInt(params[1], 10); 20 | if (is.inRange(b, -100, 100)) { 21 | opt.contrast = b; 22 | } else { 23 | throw new InvalidArgument('Contrast must be between -100 and 100'); 24 | } 25 | return opt; 26 | } 27 | 28 | 29 | public async process(ctx: IImageContext, params: string[]): Promise { 30 | const opt = this.validate(params); 31 | 32 | if (opt.contrast > 0) { 33 | ctx.image.linear((2 * opt.contrast + 100) / 200 + 0.5); 34 | } else { 35 | ctx.image.linear((opt.contrast + 100) / 200 + 0.5); 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /source/new-image-handler/src/processor/image/crop.ts: -------------------------------------------------------------------------------- 1 | import * as sharp from 'sharp'; 2 | import { IImageContext } from '.'; 3 | import { IActionOpts, ReadOnly, InvalidArgument } from '..'; 4 | import { BaseImageAction } from './_base'; 5 | 6 | export interface CropOpts extends IActionOpts { 7 | w: number; 8 | h: number; 9 | x: number; 10 | y: number; 11 | g: 'nw' | 'north' | 'ne' | 'west' | 'center' | 'east' | 'sw' | 'south' | 'se'; 12 | } 13 | 14 | export class CropAction extends BaseImageAction { 15 | public readonly name: string = 'crop'; 16 | 17 | public validate(params: string[]): ReadOnly { 18 | let opt: CropOpts = { w: 0, h: 0, x: 0, y: 0, g: 'nw' }; 19 | 20 | if (params.length < 2) { 21 | throw new InvalidArgument('Crop param error, e.g: crop,x_100,y_50'); 22 | } 23 | 24 | for (const param of params) { 25 | if ((this.name === param) || (!param)) { 26 | continue; 27 | } 28 | const [k, v] = param.split('_'); 29 | if (k === 'w') { 30 | const w = Number.parseInt(v, 10); 31 | if (w > 0) { 32 | opt.w = w; 33 | } else { 34 | throw new InvalidArgument('Crop param w must be greater than 0'); 35 | } 36 | } else if (k === 'h') { 37 | const h = Number.parseInt(v, 10); 38 | if (h > 0) { 39 | opt.h = h; 40 | } else { 41 | throw new InvalidArgument('Crop param h must be greater than 0'); 42 | } 43 | } else if (k === 'x') { 44 | const x = Number.parseInt(v, 10); 45 | if (x >= 0) { 46 | opt.x = x; 47 | } else { 48 | throw new InvalidArgument('Crop param x must be greater than or equal to 0'); 49 | } 50 | } else if (k === 'y') { 51 | const y = Number.parseInt(v, 10); 52 | if (y >= 0) { 53 | opt.y = y; 54 | } else { 55 | throw new InvalidArgument('Crop param y must be greater than or equal to 0'); 56 | } 57 | } else if (k === 'g') { 58 | if (v === 'nw' || v === 'north' || v === 'ne' || 59 | v === 'west' || v === 'center' || v === 'east' || 60 | v === 'sw' || v === 'south' || v === 'se') { 61 | opt.g = v; 62 | } else { 63 | throw new InvalidArgument('Crop param g must be one of nw, north, ne, west, center, east, sw, south, se.'); 64 | } 65 | } else { 66 | throw new InvalidArgument(`Unkown param: "${k}"`); 67 | } 68 | 69 | } 70 | return opt; 71 | } 72 | 73 | 74 | public async process(ctx: IImageContext, params: string[]): Promise { 75 | const opt = this.validate(params); 76 | 77 | let height = opt.h; 78 | let width = opt.w; 79 | let x = opt.x; 80 | let y = opt.y; 81 | 82 | const metadata = await sharp(await ctx.image.toBuffer()).metadata(); 83 | if (metadata.height === undefined || metadata.width === undefined) { 84 | throw new InvalidArgument('Incorrect image format'); 85 | } 86 | 87 | if (opt.g === 'west' || opt.g === 'center' || opt.g === 'east') { 88 | y += Math.round(metadata.height / 3); 89 | } else if (opt.g === 'sw' || opt.g === 'south' || opt.g === 'se') { 90 | y += Math.round(metadata.height / 3) * 2; 91 | } 92 | 93 | if (opt.g === 'north' || opt.g === 'center' || opt.g === 'south') { 94 | x += Math.round(metadata.width / 3); 95 | } else if (opt.g === 'ne' || opt.g === 'east' || opt.g === 'se') { 96 | x += Math.round(metadata.width / 3) * 2; 97 | } 98 | 99 | if (x < 0 || x >= metadata.width) { 100 | throw new InvalidArgument(`Incorrect crop param, x value must be in [0, ${metadata.width}] `); 101 | } 102 | if (y < 0 || y >= metadata.height) { 103 | throw new InvalidArgument(`Incorrect crop param, y value must be in [0, ${metadata.height}] `); 104 | } 105 | 106 | // The width and height are not set in the parameter, modify them to reasonable values 107 | if (width === 0) { 108 | width = metadata.width - x; 109 | } 110 | if (height === 0) { 111 | height = metadata.height - y; 112 | } 113 | 114 | // The width and height of the set parameters exceed the size of the picture, modify them to reasonable values 115 | if (x + width > metadata.width) { 116 | width = metadata.width - x; 117 | } 118 | if (y + height > metadata.height) { 119 | height = metadata.height - y; 120 | } 121 | 122 | ctx.image = sharp(await ctx.image.extract({ 123 | left: x, 124 | top: y, 125 | width: width, 126 | height: height, 127 | }).toBuffer(), { animated: true }); 128 | } 129 | } -------------------------------------------------------------------------------- /source/new-image-handler/src/processor/image/format.ts: -------------------------------------------------------------------------------- 1 | import { IImageContext } from '.'; 2 | import { IActionOpts, ReadOnly, InvalidArgument, Features, IProcessContext } from '..'; 3 | import { BaseImageAction } from './_base'; 4 | 5 | export interface FormatOpts extends IActionOpts { 6 | format: string; 7 | } 8 | 9 | export class FormatAction extends BaseImageAction { 10 | public readonly name: string = 'format'; 11 | 12 | public beforeNewContext(ctx: IProcessContext, params: string[]): void { 13 | const opts = this.validate(params); 14 | if (['webp', 'gif'].includes(opts.format)) { 15 | ctx.features[Features.ReadAllAnimatedFrames] = true; 16 | } else { 17 | ctx.features[Features.ReadAllAnimatedFrames] = false; 18 | } 19 | } 20 | 21 | public beforeProcess(ctx: IImageContext, params: string[], index: number): void { 22 | const opts = this.validate(params); 23 | if (('gif' === ctx.metadata.format) && ('gif' === opts.format)) { 24 | ctx.mask.disable(index); 25 | } 26 | } 27 | 28 | public validate(params: string[]): ReadOnly { 29 | let opt: FormatOpts = { format: '' }; 30 | 31 | if (params.length !== 2) { 32 | throw new InvalidArgument(`Format param error, e.g: format,jpg (${SUPPORTED_FORMAT.toString()})`); 33 | } 34 | opt.format = params[1]; 35 | 36 | if (!SUPPORTED_FORMAT.includes(opt.format)) { 37 | throw new InvalidArgument(`Format must be one of ${SUPPORTED_FORMAT.toString()}`); 38 | } 39 | 40 | return opt; 41 | } 42 | 43 | 44 | public async process(ctx: IImageContext, params: string[]): Promise { 45 | if (ctx.features[Features.AutoWebp]) { 46 | ctx.features[Features.AutoWebp] = false; 47 | } 48 | 49 | const opt = this.validate(params); 50 | if ('gif' === opt.format) { 51 | return; // nothing to do 52 | } 53 | if (['jpeg', 'jpg'].includes(opt.format)) { 54 | ctx.metadata.format = 'jpeg'; 55 | ctx.image.jpeg(); 56 | } else if (opt.format === 'png') { 57 | ctx.metadata.format = 'png'; 58 | ctx.image.png({ effort: 2, quality: 80 }); 59 | } else if (opt.format === 'webp') { 60 | ctx.metadata.format = 'webp'; 61 | ctx.image.webp({ effort: 2, quality: 80 }); 62 | } 63 | 64 | } 65 | } 66 | 67 | const SUPPORTED_FORMAT = [ 68 | 'jpg', 69 | 'jpeg', 70 | 'png', 71 | 'webp', 72 | 'gif', 73 | ]; -------------------------------------------------------------------------------- /source/new-image-handler/src/processor/image/grey.ts: -------------------------------------------------------------------------------- 1 | import { IImageContext } from '.'; 2 | import { IActionOpts, ReadOnly, InvalidArgument } from '..'; 3 | import { BaseImageAction } from './_base'; 4 | 5 | export interface GreyOpts extends IActionOpts { 6 | grey: boolean; 7 | } 8 | 9 | export class GreyAction extends BaseImageAction { 10 | public readonly name: string = 'grey'; 11 | 12 | public validate(params: string[]): ReadOnly { 13 | let opt: GreyOpts = { grey: false }; 14 | 15 | if (params.length !== 2) { 16 | throw new InvalidArgument('Grey param error, e.g: grey,1'); 17 | } 18 | if (params[1] === '1') { 19 | opt.grey = true; 20 | } else if (params[1] === '0') { 21 | opt.grey = false; 22 | 23 | } else { 24 | throw new InvalidArgument('Grey must be 0 or 1'); 25 | } 26 | return opt; 27 | } 28 | 29 | 30 | public async process(ctx: IImageContext, params: string[]): Promise { 31 | const opt = this.validate(params); 32 | if (opt.grey) { 33 | ctx.image.greyscale(); 34 | } 35 | 36 | } 37 | } -------------------------------------------------------------------------------- /source/new-image-handler/src/processor/image/indexcrop.ts: -------------------------------------------------------------------------------- 1 | import * as sharp from 'sharp'; 2 | import { IImageContext } from '.'; 3 | import { IActionOpts, ReadOnly, InvalidArgument } from '..'; 4 | import { BaseImageAction } from './_base'; 5 | 6 | export interface IndexCropOpts extends IActionOpts { 7 | x: number; 8 | y: number; 9 | i: number; 10 | } 11 | 12 | export class IndexCropAction extends BaseImageAction { 13 | public readonly name: string = 'indexcrop'; 14 | 15 | public validate(params: string[]): ReadOnly { 16 | let opt: IndexCropOpts = { x: 0, y: 0, i: 0 }; 17 | 18 | if (params.length < 3) { 19 | throw new InvalidArgument('IndexCrop param error, e.g: indexcrop,x_100,i_0'); 20 | } 21 | 22 | for (const param of params) { 23 | if ((this.name === param) || (!param)) { 24 | continue; 25 | } 26 | const [k, v] = param.split('_'); 27 | if (k === 'x') { 28 | opt.x = Number.parseInt(v, 10); 29 | if (opt.x < 0) { 30 | throw new InvalidArgument('Param error: x value must be greater than 0'); 31 | } 32 | } else if (k === 'y') { 33 | opt.y = Number.parseInt(v, 10); 34 | if (opt.y < 0) { 35 | throw new InvalidArgument('Param error: y value must be greater than 0'); 36 | } 37 | } else if (k === 'i') { 38 | opt.i = Number.parseInt(v, 10); 39 | } else { 40 | throw new InvalidArgument(`Unkown param: "${k}"`); 41 | } 42 | } 43 | if (opt.x > 0 && opt.y > 0) { 44 | throw new InvalidArgument('Param error: Cannot enter x and y at the same time'); 45 | } 46 | 47 | return opt; 48 | } 49 | 50 | 51 | public async process(ctx: IImageContext, params: string[]): Promise { 52 | const opt = this.validate(params); 53 | 54 | let x = 0; 55 | let y = 0; 56 | let w = 0; 57 | let h = 0; 58 | let needCrop: boolean = true; 59 | 60 | const metadata = await sharp(await ctx.image.toBuffer()).metadata(); 61 | if (metadata.height === undefined || metadata.width === undefined) { 62 | throw new InvalidArgument('Incorrect image format'); 63 | } 64 | 65 | 66 | if (metadata.height === undefined || metadata.width === undefined) { 67 | throw new InvalidArgument('Incorrect image format'); 68 | } 69 | h = metadata.height; 70 | w = metadata.width; 71 | 72 | if (opt.x > 0) { 73 | if (opt.x > metadata.width) { 74 | needCrop = false; 75 | return; 76 | } 77 | const count = Math.floor(metadata.width / opt.x); 78 | if (opt.i + 1 > count) { 79 | needCrop = false; 80 | return; 81 | } 82 | x = opt.i * opt.x; 83 | w = opt.x; 84 | 85 | } else if (opt.y > 0) { 86 | 87 | if (opt.y > metadata.height) { 88 | needCrop = false; 89 | return; 90 | } 91 | 92 | const count = Math.floor(metadata.height / opt.y); 93 | if (opt.i + 1 > count) { 94 | needCrop = false; 95 | return; 96 | } 97 | y = opt.i * opt.y; 98 | h = opt.y; 99 | 100 | } 101 | 102 | if (needCrop) { 103 | ctx.image = sharp(await ctx.image.extract({ 104 | left: x, 105 | top: y, 106 | width: w, 107 | height: h, 108 | }).toBuffer(), { animated: true }); 109 | } 110 | 111 | } 112 | } -------------------------------------------------------------------------------- /source/new-image-handler/src/processor/image/info.ts: -------------------------------------------------------------------------------- 1 | import { IImageContext } from '.'; 2 | import { IActionOpts, ReadOnly, InvalidArgument, Features } from '..'; 3 | import { BaseImageAction } from './_base'; 4 | 5 | 6 | export class InfoAction extends BaseImageAction { 7 | public readonly name: string = 'info'; 8 | 9 | public beforeProcess(ctx: IImageContext, _2: string[], index: number): void { 10 | ctx.mask.disableAll(); 11 | ctx.mask.enable(index); 12 | } 13 | 14 | public validate(params: string[]): ReadOnly { 15 | if ((params.length !== 1) || (params[0] !== this.name)) { 16 | throw new InvalidArgument('Info param error'); 17 | } 18 | return {}; 19 | } 20 | 21 | public async process(ctx: IImageContext, params: string[]): Promise { 22 | this.validate(params); 23 | 24 | const metadata = await ctx.image.metadata(); 25 | ctx.info = { 26 | FileSize: { value: String(metadata.size) }, 27 | Format: { value: String(metadata.format === 'jpeg' ? 'jpg' : metadata.format) }, 28 | ImageHeight: { value: String(metadata.pageHeight ?? metadata.height) }, 29 | ImageWidth: { value: String(metadata.width) }, 30 | }; 31 | 32 | ctx.features[Features.ReturnInfo] = true; 33 | } 34 | } -------------------------------------------------------------------------------- /source/new-image-handler/src/processor/image/interlace.ts: -------------------------------------------------------------------------------- 1 | import { IImageContext } from '.'; 2 | import { IActionOpts, ReadOnly, InvalidArgument } from '..'; 3 | import { BaseImageAction } from './_base'; 4 | export interface InterlaceOpts extends IActionOpts { 5 | interlace: boolean; 6 | } 7 | 8 | export class InterlaceAction extends BaseImageAction { 9 | public readonly name: string = 'interlace'; 10 | 11 | public validate(params: string[]): ReadOnly { 12 | let opt: InterlaceOpts = { interlace: false }; 13 | 14 | if (params.length !== 2) { 15 | throw new InvalidArgument('Interlace param error, e.g: interlace,1'); 16 | } 17 | if (params[1] === '1') { 18 | opt.interlace = true; 19 | } else if (params[1] === '0') { 20 | opt.interlace = false; 21 | } else { 22 | throw new InvalidArgument('Interlace must be 0 or 1'); 23 | } 24 | return opt; 25 | } 26 | 27 | public beforeProcess(ctx: IImageContext, _2: string[], index: number): void { 28 | if ('gif' === ctx.metadata.format) { 29 | ctx.mask.disable(index); 30 | } 31 | } 32 | 33 | public async process(ctx: IImageContext, params: string[]): Promise { 34 | const opt = this.validate(params); 35 | const metadata = ctx.metadata; 36 | if (('jpg' === metadata.format || 'jpeg' === metadata.format) && opt.interlace) { 37 | ctx.image.jpeg({ progressive: true }); 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /source/new-image-handler/src/processor/image/quality.ts: -------------------------------------------------------------------------------- 1 | import * as sharp from 'sharp'; 2 | import { IImageContext } from '.'; 3 | import { IActionOpts, InvalidArgument, ReadOnly } from '..'; 4 | import * as is from '../../is'; 5 | import { BaseImageAction } from './_base'; 6 | import * as jpeg from './jpeg'; 7 | 8 | 9 | const JPG = 'jpg'; 10 | const JPEG = sharp.format.jpeg.id; 11 | const WEBP = sharp.format.webp.id; 12 | 13 | export interface QualityOpts extends IActionOpts { 14 | q?: number; 15 | Q?: number; 16 | } 17 | 18 | export class QualityAction extends BaseImageAction { 19 | public readonly name: string = 'quality'; 20 | 21 | public beforeProcess(ctx: IImageContext, _2: string[], index: number): void { 22 | if ('gif' === ctx.metadata.format) { 23 | ctx.mask.disable(index); 24 | } 25 | } 26 | 27 | public validate(params: string[]): ReadOnly { 28 | const opt: QualityOpts = {}; 29 | for (const param of params) { 30 | if ((this.name === param) || (!param)) { 31 | continue; 32 | } 33 | const [k, v] = param.split('_'); 34 | if (k === 'q') { 35 | const q = Number.parseInt(v, 10); 36 | if (is.inRange(q, 1, 100)) { 37 | opt.q = q; 38 | } else { 39 | throw new InvalidArgument('Quality must be between 1 and 100'); 40 | } 41 | } else if (k === 'Q') { 42 | const Q = Number.parseInt(v, 10); 43 | if (is.inRange(Q, 1, 100)) { 44 | opt.Q = Q; 45 | } else { 46 | throw new InvalidArgument('Quality must be between 1 and 100'); 47 | } 48 | } else { 49 | throw new InvalidArgument(`Unkown param: "${k}"`); 50 | } 51 | } 52 | return opt; 53 | } 54 | public async process(ctx: IImageContext, params: string[]): Promise { 55 | const opt = this.validate(params); 56 | const metadata = ctx.metadata; // If the format is changed before. 57 | if (JPEG === metadata.format || JPG === metadata.format) { 58 | let q = 72; 59 | if (opt.q) { 60 | const buffer = await ctx.image.toBuffer(); 61 | const estq = jpeg.decode(buffer).quality; 62 | q = Math.round(estq * opt.q / 100); 63 | } else if (opt.Q) { 64 | q = opt.Q; 65 | } 66 | ctx.image.jpeg({ quality: q }); 67 | } else if (WEBP === metadata.format) { 68 | ctx.image.webp({ quality: (opt.q ?? opt.Q) }); 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /source/new-image-handler/src/processor/image/resize.ts: -------------------------------------------------------------------------------- 1 | import * as sharp from 'sharp'; 2 | import { IImageContext } from '.'; 3 | import { IActionOpts, InvalidArgument, ReadOnly } from '..'; 4 | import * as is from '../../is'; 5 | import { BaseImageAction } from './_base'; 6 | 7 | export const enum Mode { 8 | LFIT = 'lfit', 9 | MFIT = 'mfit', 10 | FILL = 'fill', 11 | PAD = 'pad', 12 | FIXED = 'fixed' 13 | } 14 | 15 | export interface ResizeOpts extends IActionOpts { 16 | m?: Mode; 17 | w?: number; 18 | h?: number; 19 | l?: number; 20 | s?: number; 21 | limit?: boolean; 22 | color?: string; 23 | p?: number; 24 | } 25 | 26 | export class ResizeAction extends BaseImageAction { 27 | public readonly name: string = 'resize'; 28 | 29 | public validate(params: string[]): ReadOnly { 30 | const opt: ResizeOpts = { 31 | m: Mode.LFIT, 32 | limit: true, 33 | color: '#FFFFFF', 34 | }; 35 | for (const param of params) { 36 | if ((this.name === param) || (!param)) { 37 | continue; 38 | } 39 | const [k, v] = param.split('_'); 40 | if (k === 'w') { 41 | opt.w = Number.parseInt(v, 10); 42 | } else if (k === 'h') { 43 | opt.h = Number.parseInt(v, 10); 44 | } else if (k === 'l') { 45 | opt.l = Number.parseInt(v, 10); 46 | } else if (k === 's') { 47 | opt.s = Number.parseInt(v, 10); 48 | } else if (k === 'm') { 49 | if (v && ((v === Mode.LFIT) || (v === Mode.MFIT) || (v === Mode.FILL) || (v === Mode.PAD) || (v === Mode.FIXED))) { 50 | opt.m = v; 51 | } else { 52 | throw new InvalidArgument(`Unkown m: "${v}"`); 53 | } 54 | } else if (k === 'limit') { 55 | if (v && (v === '0' || v === '1')) { 56 | opt.limit = (v === '1'); 57 | } else { 58 | throw new InvalidArgument(`Unkown limit: "${v}"`); 59 | } 60 | } else if (k === 'color') { 61 | const color = '#' + v; 62 | if (is.hexColor(color)) { 63 | opt.color = color; 64 | } else { 65 | throw new InvalidArgument(`Unkown color: "${v}"`); 66 | } 67 | } else if (k === 'p') { 68 | const p = Number.parseInt(v, 10); 69 | if (is.inRange(p, 1, 1000)) { 70 | opt.p = p; 71 | } else { 72 | throw new InvalidArgument(`Unkown p: "${v}"`); 73 | } 74 | } else { 75 | throw new InvalidArgument(`Unkown param: "${k}"`); 76 | } 77 | } 78 | return opt; 79 | } 80 | 81 | public beforeProcess(ctx: IImageContext, params: string[], index: number): void { 82 | const metadata = ctx.metadata; 83 | if ('gif' === metadata.format) { 84 | const opt = buildSharpOpt(ctx, this.validate(params)); 85 | const isEnlargingWidth = (opt.width && metadata.width && opt.width > metadata.width); 86 | const isEnlargingHeight = (opt.height && metadata.pageHeight && (opt.height > metadata.pageHeight)); 87 | if (isEnlargingWidth || isEnlargingHeight) { 88 | ctx.mask.disable(index); 89 | } 90 | } 91 | } 92 | 93 | public async process(ctx: IImageContext, params: string[]): Promise { 94 | const opt = buildSharpOpt(ctx, this.validate(params)); 95 | ctx.image.resize(null, null, opt); 96 | } 97 | } 98 | 99 | function buildSharpOpt(ctx: IImageContext, o: ResizeOpts): sharp.ResizeOptions { 100 | const opt: sharp.ResizeOptions = { 101 | width: o.w, 102 | height: o.h, 103 | withoutEnlargement: o.limit, 104 | background: o.color, 105 | }; 106 | // Mode 107 | if (o.m === Mode.LFIT) { 108 | opt.fit = sharp.fit.inside; 109 | } else if (o.m === Mode.MFIT) { 110 | opt.fit = sharp.fit.outside; 111 | } else if (o.m === Mode.FILL) { 112 | opt.fit = sharp.fit.cover; 113 | } else if (o.m === Mode.PAD) { 114 | opt.fit = sharp.fit.contain; 115 | } else if (o.m === Mode.FIXED) { 116 | opt.fit = sharp.fit.fill; 117 | } 118 | const metadata = ctx.metadata; 119 | if (!(metadata.width && metadata.height)) { 120 | throw new InvalidArgument('Can\'t read image\'s width and height'); 121 | } 122 | 123 | if (o.p && (!o.w) && (!o.h)) { 124 | opt.withoutEnlargement = false; 125 | opt.width = Math.round(metadata.width * o.p * 0.01); 126 | } else { 127 | if (o.l) { 128 | if (metadata.width > metadata.height) { 129 | opt.width = o.l; 130 | } else { 131 | opt.height = o.l; 132 | } 133 | } 134 | if (o.s) { 135 | if (metadata.height < metadata.width) { 136 | opt.height = o.s; 137 | } else { 138 | opt.width = o.s; 139 | } 140 | } 141 | } 142 | return opt; 143 | } -------------------------------------------------------------------------------- /source/new-image-handler/src/processor/image/rotate.ts: -------------------------------------------------------------------------------- 1 | import * as sharp from 'sharp'; 2 | import { IImageContext } from '.'; 3 | import { IActionOpts, ReadOnly, InvalidArgument } from '..'; 4 | import * as is from '../../is'; 5 | import { BaseImageAction } from './_base'; 6 | 7 | export interface RotateOpts extends IActionOpts { 8 | degree: number; 9 | } 10 | 11 | export class RotateAction extends BaseImageAction { 12 | public readonly name: string = 'rotate'; 13 | 14 | public validate(params: string[]): ReadOnly { 15 | let opt: RotateOpts = { degree: 0 }; 16 | 17 | if (params.length !== 2) { 18 | throw new InvalidArgument('Rotate param error, e.g: rotate,90'); 19 | } 20 | const d = Number.parseInt(params[1], 10); 21 | if (is.inRange(d, 0, 360)) { 22 | opt.degree = d; 23 | } else { 24 | throw new InvalidArgument('Rotate must be between 0 and 360'); 25 | } 26 | return opt; 27 | } 28 | 29 | 30 | public async process(ctx: IImageContext, params: string[]): Promise { 31 | const opt = this.validate(params); 32 | ctx.image = sharp(await ctx.image.toBuffer()).rotate(opt.degree, { 33 | background: '#ffffff', 34 | }); 35 | } 36 | } -------------------------------------------------------------------------------- /source/new-image-handler/src/processor/image/rounded-corners.ts: -------------------------------------------------------------------------------- 1 | import * as sharp from 'sharp'; 2 | import { IImageContext } from '.'; 3 | import { IActionOpts, ReadOnly, InvalidArgument } from '..'; 4 | import * as is from '../../is'; 5 | import { BaseImageAction } from './_base'; 6 | 7 | export interface RoundedCornersOpts extends IActionOpts { 8 | r: number; 9 | } 10 | 11 | export class RoundedCornersAction extends BaseImageAction { 12 | public readonly name: string = 'rounded-corners'; 13 | 14 | public validate(params: string[]): ReadOnly { 15 | let opt: RoundedCornersOpts = { r: 1 }; 16 | 17 | if (params.length !== 2) { 18 | throw new InvalidArgument('RoundedCorners param error, e.g: rounded-corners,r_30'); 19 | } 20 | 21 | for (const param of params) { 22 | if ((this.name === param) || (!param)) { 23 | continue; 24 | } 25 | const [k, v] = param.split('_'); 26 | if (k === 'r') { 27 | const r = Number.parseInt(v, 10); 28 | if (is.inRange(r, 1, 4096)) { 29 | opt.r = r; 30 | } else { 31 | throw new InvalidArgument('RoundedCorners param \'r\' must be between 1 and 4096'); 32 | } 33 | } else { 34 | throw new InvalidArgument(`Unkown param: "${k}"`); 35 | } 36 | } 37 | 38 | return opt; 39 | } 40 | 41 | 42 | public async process(ctx: IImageContext, params: string[]): Promise { 43 | const opt = this.validate(params); 44 | const metadata = await sharp(await ctx.image.toBuffer()).metadata(); // https://github.com/lovell/sharp/issues/2959 45 | if (!(metadata.width && metadata.height)) { 46 | throw new InvalidArgument('Can\'t read image\'s width and height'); 47 | } 48 | 49 | const w = metadata.width; 50 | const h = metadata.height; 51 | const pages = metadata.pages ?? 1; 52 | const rects = Array.from({ length: pages }, (_, i) => 53 | ``, 54 | ); 55 | const mask = Buffer.from(` 56 | ${rects.join('\n')} 57 | `); 58 | 59 | ctx.image.composite([ 60 | { input: mask, blend: 'dest-in' }, 61 | ]); 62 | } 63 | } -------------------------------------------------------------------------------- /source/new-image-handler/src/processor/image/sharpen.ts: -------------------------------------------------------------------------------- 1 | import { IImageContext } from '.'; 2 | import { IActionOpts, ReadOnly, InvalidArgument } from '..'; 3 | import * as is from '../../is'; 4 | import { BaseImageAction } from './_base'; 5 | 6 | export interface SharpenOpts extends IActionOpts { 7 | sharpen: number; 8 | } 9 | 10 | export class SharpenAction extends BaseImageAction { 11 | public readonly name: string = 'sharpen'; 12 | 13 | public validate(params: string[]): ReadOnly { 14 | const opt: SharpenOpts = { sharpen: 0 }; 15 | 16 | if (params.length !== 2) { 17 | throw new InvalidArgument('Sharpen param error, e.g: sharpen,100'); 18 | } 19 | const s = Number.parseInt(params[1], 10); 20 | if (is.inRange(s, 50, 399)) { 21 | opt.sharpen = s; 22 | } else { 23 | throw new InvalidArgument('Sharpen be between 50 and 399'); 24 | } 25 | return opt; 26 | } 27 | 28 | 29 | public async process(ctx: IImageContext, params: string[]): Promise { 30 | const opt = this.validate(params); 31 | ctx.image.sharpen(opt.sharpen / 100, 0.5, 1); 32 | } 33 | } -------------------------------------------------------------------------------- /source/new-image-handler/src/processor/image/strip-metadata.ts: -------------------------------------------------------------------------------- 1 | import { ReadOnly, IActionOpts, IProcessContext } from '..'; 2 | import { BaseImageAction } from './_base'; 3 | 4 | export class StripMetadataAction extends BaseImageAction { 5 | public readonly name: string = 'strip-metadata'; 6 | 7 | validate(_: string[]): ReadOnly { 8 | return {}; 9 | } 10 | process(_1: IProcessContext, _2: string[]): Promise { 11 | return Promise.resolve(); 12 | } 13 | } -------------------------------------------------------------------------------- /source/new-image-handler/src/processor/image/threshold.ts: -------------------------------------------------------------------------------- 1 | import { IImageContext } from '.'; 2 | import { IActionOpts, ReadOnly, InvalidArgument } from '..'; 3 | import { BaseImageAction } from './_base'; 4 | 5 | 6 | export interface ThresholdOpts extends IActionOpts { 7 | threshold: number; 8 | } 9 | 10 | 11 | export class ThresholdAction extends BaseImageAction { 12 | public readonly name: string = 'threshold'; 13 | 14 | public beforeProcess(ctx: IImageContext, params: string[], _: number): void { 15 | const opts = this.validate(params); 16 | 17 | if (ctx.metadata.size && (ctx.metadata.size < opts.threshold)) { 18 | ctx.mask.disableAll(); 19 | } 20 | } 21 | 22 | public validate(params: string[]): ReadOnly { 23 | if (params.length !== 2) { 24 | throw new InvalidArgument(`Invalid ${this.name} params, incomplete param`); 25 | } 26 | const t = Number.parseInt(params[1], 10); 27 | if (t <= 0) { 28 | throw new InvalidArgument(`Invalid ${this.name} params, threshold must be greater than zero`); 29 | } 30 | return { 31 | threshold: t, 32 | }; 33 | } 34 | 35 | public async process(_1: IImageContext, _2: string[]): Promise { 36 | return Promise.resolve(); 37 | } 38 | } -------------------------------------------------------------------------------- /source/new-image-handler/src/processor/index.ts: -------------------------------------------------------------------------------- 1 | import * as HttpErrors from 'http-errors'; 2 | import { IBufferStore } from '../store'; 3 | 4 | /** 5 | * A utility to make an object immutable. 6 | */ 7 | export type ReadOnly = { 8 | readonly [K in keyof T]: ReadOnly; 9 | } 10 | 11 | export interface IActionMask { 12 | readonly length: number; 13 | getAction(index: number): string; 14 | isEnabled(index: number): boolean; 15 | isDisabled(index: number): boolean; 16 | enable(index: number): void; 17 | disable(index: number): void; 18 | disableAll(): void; 19 | filterEnabledActions(): string[]; 20 | forEachAction(cb: (action: string, enabled: boolean, index: number) => void): void; 21 | } 22 | 23 | /** 24 | * Context object for processor. 25 | */ 26 | export interface IProcessContext { 27 | /** 28 | * The context uri. e.g. 'a/b/example.jpg' 29 | */ 30 | readonly uri: string; 31 | 32 | /** 33 | * The actions. e.g 'image/resize,w_100/format,png'.split('/') 34 | */ 35 | readonly actions: string[]; 36 | 37 | readonly mask: IActionMask; 38 | 39 | /** 40 | * A abstract store to get file data. 41 | * It can either get from s3 or local filesystem. 42 | */ 43 | readonly bufferStore: IBufferStore; 44 | 45 | /** 46 | * Feature flags. 47 | */ 48 | readonly features: { [key: string]: any }; 49 | 50 | readonly headers: IHttpHeaders; 51 | } 52 | 53 | export interface IHttpHeaders { 54 | [key: string]: any; 55 | } 56 | 57 | export interface IProcessResponse { 58 | readonly data: any; 59 | readonly type: string; 60 | } 61 | 62 | /** 63 | * Processor interface. 64 | */ 65 | export interface IProcessor { 66 | 67 | /** 68 | * The name of the processor. 69 | */ 70 | readonly name: string; 71 | 72 | /** 73 | * Register action handlers for the processor. 74 | * 75 | * @param actions the action handlers 76 | */ 77 | register(...actions: IAction[]): void; 78 | 79 | /** 80 | * Create a new context. 81 | * @param uri e.g. 'a/b/example.jpg' 82 | * @param actions e.g. 'image/resize,w_100/format,png'.split('/') 83 | * @param bufferStore 84 | */ 85 | newContext(uri: string, actions: string[], bufferStore: IBufferStore): Promise; 86 | 87 | /** 88 | * Process each actions with a context. 89 | * 90 | * For example: 91 | * 92 | * ```ts 93 | * const bs = new SharpBufferStore(sharp({ 94 | * create: { 95 | * width: 50, 96 | * height: 50, 97 | * channels: 3, 98 | * background: { r: 255, g: 0, b: 0 }, 99 | * }, 100 | * }).png()); 101 | * const ctx = ImageProcessor.getInstance().newContext('example.jpg', 'image/resize,w_100,h_100,m_fixed,limit_0/'.split('/')); 102 | * await ImageProcessor.getInstance().process(ctx); 103 | * ``` 104 | * 105 | * @param ctx the context 106 | */ 107 | process(ctx: IProcessContext): Promise; 108 | } 109 | 110 | /** 111 | * An interface of action options. 112 | */ 113 | export interface IActionOpts { } 114 | 115 | /** 116 | * An interface of action. 117 | */ 118 | export interface IAction { 119 | 120 | /** 121 | * The name of the action. 122 | */ 123 | readonly name: string; 124 | 125 | /** 126 | * Validate parameters and return an action option object. 127 | * Throw an error if it's invalid. 128 | * 129 | * For example: 130 | * 131 | * ```ts 132 | * action.validate('resize,m_mfit,h_100,w_100,,'.split(','); 133 | * ```` 134 | * 135 | * @param params the parameters 136 | */ 137 | validate(params: string[]): ReadOnly; 138 | 139 | /** 140 | * Process the action with the given parameters. 141 | * 142 | * For example: 143 | * 144 | * ```ts 145 | * action.process(ctx, 'resize,w_10,h_10'.split(',')); 146 | * ``` 147 | * 148 | * @param ctx the context 149 | * @param params the parameters 150 | */ 151 | process(ctx: IProcessContext, params: string[]): Promise; 152 | 153 | /** 154 | * This function is called before processor new context. 155 | * 156 | * @param ctx the context 157 | * @param params the parameters 158 | */ 159 | beforeNewContext(ctx: IProcessContext, params: string[], index: number): void; 160 | 161 | beforeProcess(ctx: IProcessContext, params: string[], index: number): void; 162 | } 163 | 164 | /** 165 | * Invalid argument error (HTTP 400). 166 | */ 167 | export class InvalidArgument extends HttpErrors[400] { } 168 | 169 | 170 | export enum Features { 171 | AutoWebp = 'auto-webp', 172 | AutoOrient = 'auto-orient', 173 | ReturnInfo = 'return-info', 174 | ReadAllAnimatedFrames = 'read-all-animated-frames', 175 | LimitAnimatedFrames = 0, 176 | } -------------------------------------------------------------------------------- /source/new-image-handler/src/processor/style.ts: -------------------------------------------------------------------------------- 1 | import { IAction, InvalidArgument, IProcessContext, IProcessor, IProcessResponse } from '.'; 2 | import * as is from '../is'; 3 | import { IBufferStore, IKVStore, MemKVStore } from '../store'; 4 | import { ActionMask } from './image/_base'; 5 | import { ImageProcessor } from './image/index'; 6 | import { VideoProcessor } from './video'; 7 | 8 | 9 | const PROCESSOR_MAP: { [key: string]: IProcessor } = { 10 | [ImageProcessor.getInstance().name]: ImageProcessor.getInstance(), 11 | [VideoProcessor.getInstance().name]: VideoProcessor.getInstance(), 12 | }; 13 | 14 | 15 | export class StyleProcessor implements IProcessor { 16 | public static getInstance(kvstore?: IKVStore): StyleProcessor { 17 | if (!StyleProcessor._instance) { 18 | StyleProcessor._instance = new StyleProcessor(); 19 | } 20 | if (kvstore) { 21 | StyleProcessor._instance._kvstore = kvstore; 22 | } 23 | return StyleProcessor._instance; 24 | } 25 | private static _instance: StyleProcessor; 26 | 27 | public readonly name: string = 'style'; 28 | private _kvstore: IKVStore = new MemKVStore({}); 29 | 30 | private constructor() { } 31 | 32 | public async newContext(uri: string, actions: string[], bufferStore: IBufferStore): Promise { 33 | return Promise.resolve({ 34 | uri, 35 | actions, 36 | mask: new ActionMask(actions), 37 | bufferStore, 38 | headers: {}, 39 | features: {}, 40 | }); 41 | } 42 | 43 | // e.g. https://Host/ObjectName?x-oss-process=style/ 44 | public async process(ctx: IProcessContext): Promise { 45 | if (ctx.actions.length !== 2) { 46 | throw new InvalidArgument('Invalid style!'); 47 | } 48 | const stylename = ctx.actions[1]; 49 | if (!stylename.match(/^[\w\-_\.]{1,63}$/)) { 50 | throw new InvalidArgument('Invalid style name!'); 51 | } 52 | // { 53 | // "id": "stylename", 54 | // "style": "image/resize,w_100,h_100" 55 | // } 56 | const { style } = await this._kvstore.get(stylename); 57 | const param = style; // e.g. image/resize,w_100,h_100,m_fixed,limit_0/ 58 | if (is.string(param)) { 59 | const acts = param.split('/').filter((x: any) => x); 60 | const processor = PROCESSOR_MAP[acts[0]]; 61 | if (!processor) { 62 | throw new InvalidArgument('Can Not find processor'); 63 | } 64 | const context = await processor.newContext(ctx.uri, acts, ctx.bufferStore); 65 | return processor.process(context); 66 | } else { 67 | throw new InvalidArgument('Style not found'); 68 | } 69 | } 70 | 71 | public register(..._: IAction[]): void { } 72 | } 73 | -------------------------------------------------------------------------------- /source/new-image-handler/src/store.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import * as DynamoDB from 'aws-sdk/clients/dynamodb'; 4 | import * as S3 from 'aws-sdk/clients/s3'; 5 | import * as sharp from 'sharp'; 6 | import config from './config'; 7 | import { IHttpHeaders } from './processor'; 8 | 9 | /** 10 | * A abstract store to get file data. 11 | * It can either get from s3 or local filesystem. 12 | */ 13 | export interface IStore { 14 | 15 | /** 16 | * Read all buffer from underlying. 17 | * Return both the buffer and the s3 object/file type. 18 | * Usually the file type is the file's suffix. 19 | * 20 | * @param p the path of the s3 object or the file 21 | * @param beforeGetFunc a hook function that will be executed before get 22 | */ 23 | get(p: string, beforeGetFunc?: () => void): Promise; 24 | 25 | url(p: string): Promise; 26 | } 27 | 28 | export interface IKeyValue { 29 | [key: string]: any; 30 | } 31 | 32 | export interface IBufferStore extends IStore<{ buffer: Buffer; type: string; headers: IHttpHeaders }> { }; 33 | 34 | export interface IKVStore extends IStore { } 35 | 36 | /** 37 | * A local file system based store. 38 | */ 39 | export class LocalStore implements IBufferStore { 40 | public constructor(private root: string = '') { } 41 | public async get(p: string, _?: () => void): 42 | Promise<{ buffer: Buffer; type: string; headers: IHttpHeaders }> { 43 | p = path.join(this.root, p); 44 | return { 45 | buffer: await fs.promises.readFile(p), 46 | type: filetype(p), 47 | headers: { 48 | 'Etag': 'fake-etag', 49 | 'Last-Modified': 'Wed, 21 Oct 2014 07:28:00 GMT', 50 | 'Cache-Control': 'max-age', 51 | }, 52 | }; 53 | } 54 | 55 | public async url(p: string): Promise { 56 | return Promise.resolve(path.join(this.root, p)); 57 | } 58 | } 59 | 60 | /** 61 | * S3 based store. 62 | */ 63 | export class S3Store implements IBufferStore { 64 | private _s3: S3 = new S3({ region: config.region }); 65 | public constructor(public readonly bucket: string) { } 66 | public async get(p: string, beforeGetFunc?: () => void): 67 | Promise<{ buffer: Buffer; type: string; headers: IHttpHeaders }> { 68 | beforeGetFunc?.(); 69 | const res = await this._s3.getObject({ 70 | Bucket: this.bucket, 71 | Key: p, 72 | }).promise(); 73 | 74 | if (Buffer.isBuffer(res.Body)) { 75 | const headers: IHttpHeaders = {}; 76 | if (res.ETag) { headers.Etag = res.ETag; } 77 | if (res.LastModified) { headers['Last-Modified'] = res.LastModified; } 78 | if (res.CacheControl) { headers['Cache-Control'] = res.CacheControl; } 79 | return { 80 | buffer: res.Body as Buffer, 81 | type: res.ContentType ?? '', 82 | headers, 83 | }; 84 | }; 85 | throw new Error('S3 response body is not a Buffer type'); 86 | } 87 | 88 | public async url(p: string): Promise { 89 | return this._s3.getSignedUrlPromise('getObject', { 90 | Bucket: this.bucket, 91 | Key: p, 92 | Expires: 1200, 93 | }); 94 | } 95 | } 96 | 97 | /** 98 | * A fake store. Only for unit test. 99 | */ 100 | export class NullStore implements IBufferStore { 101 | public url(_: string): Promise { 102 | throw new Error('Method not implemented.'); 103 | } 104 | public async get(p: string, _?: () => void): Promise<{ buffer: Buffer; type: string; headers: IHttpHeaders }> { 105 | return Promise.resolve({ 106 | buffer: Buffer.from(p), 107 | type: '', 108 | headers: {}, 109 | }); 110 | } 111 | } 112 | 113 | /** 114 | * A sharp image store. Only for unit test. 115 | */ 116 | export class SharpBufferStore implements IBufferStore { 117 | constructor(private image: sharp.Sharp) { } 118 | public url(_: string): Promise { 119 | throw new Error('Method not implemented.'); 120 | } 121 | 122 | async get(_: string, __?: () => void): Promise<{ buffer: Buffer; type: string; headers: IHttpHeaders }> { 123 | const { data, info } = await this.image.toBuffer({ resolveWithObject: true }); 124 | return { buffer: data, type: info.format, headers: {} }; 125 | } 126 | } 127 | 128 | 129 | export class DynamoDBStore implements IKVStore { 130 | private _ddb = new DynamoDB.DocumentClient({ region: config.region }); 131 | public constructor(public readonly tableName: string) { } 132 | public url(_: string): Promise { 133 | throw new Error('Method not implemented.'); 134 | } 135 | public async get(key: string, _?: () => void): Promise { 136 | const data = await this._ddb.get({ 137 | TableName: this.tableName, 138 | Key: { id: key }, 139 | }).promise(); 140 | return data.Item ?? {}; 141 | } 142 | } 143 | 144 | export class MemKVStore implements IKVStore { 145 | public constructor(public readonly dict: IKeyValue) { } 146 | public url(_: string): Promise { 147 | throw new Error('Method not implemented.'); 148 | } 149 | 150 | public async get(key: string, _?: () => void): Promise { 151 | return Promise.resolve(this.dict[key] ?? {}); 152 | } 153 | } 154 | 155 | 156 | function filetype(file: string) { 157 | return path.extname(file).substring(1); 158 | } -------------------------------------------------------------------------------- /source/new-image-handler/src/style.json: -------------------------------------------------------------------------------- 1 | { 2 | "box100": { 3 | "style": "image/resize,w_100,h_100,m_fixed,limit_0/" 4 | } 5 | } -------------------------------------------------------------------------------- /source/new-image-handler/src/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module "style.json" { 2 | const value: { [key: string]: { [k: string]: string } }; 3 | export default value; 4 | } 5 | 6 | interface CacheObject { 7 | body: Buffer; 8 | } -------------------------------------------------------------------------------- /source/new-image-handler/test/bench/README.md: -------------------------------------------------------------------------------- 1 | # Benchmark test 2 | 3 | ## curl.sh: make http requests to urls in parallel 4 | 5 | ``` 6 | # edit urls.txt 7 | 8 | $ ./curl.sh urls.txt 9 | $ N=10 ./curl.sh urls.txt # make 10 http requests simultaneously 10 | ``` 11 | 12 | ## vegeta: http load test tools 13 | 14 | install https://github.com/tsenart/vegeta firstly 15 | 16 | ``` 17 | # edit vegeta-urls.txt 18 | cat vegeta-urls.txt | vegeta attack -duration=1m -rate=300 -timeout=300s -format=http | vegeta report 19 | ``` -------------------------------------------------------------------------------- /source/new-image-handler/test/bench/curl.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # ./curl.sh urls.txt 2 3 | 4 | URLS=$1 5 | N=${2:-2} 6 | 7 | echo "request ${N} urls in parallel" 8 | printf "time_total(sec)\thttp_code\tsize_download(bytes)\turl\n" 9 | grep -v '^#' ${URLS} | xargs -P ${N} -I {} curl -s "{}" -o /dev/null -w "%{time_total}\t%{http_code}\t%{size_download}\t{}\n" -------------------------------------------------------------------------------- /source/new-image-handler/test/bench/perf.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os'; 2 | import * as Benchmark from 'benchmark'; 3 | import * as sharp from 'sharp'; 4 | import { convert } from '../../src/imagemagick'; 5 | import { fixtureStore } from '../processor/image/utils'; 6 | 7 | sharp.simd(true); 8 | sharp.cache(false); 9 | sharp.concurrency(os.cpus().length); 10 | 11 | const suite = new Benchmark.Suite('sharp vs imagemagick'); 12 | 13 | suite 14 | .add('sharp', { 15 | defer: true, 16 | fn: async (deferred: any) => { 17 | const image = sharp((await fixtureStore.get('example.jpg')).buffer); 18 | image 19 | .resize(200, 200, { fit: 'inside' }) 20 | .blur(3) 21 | .jpeg({ quality: 80 }); 22 | 23 | await image.toBuffer(); 24 | 25 | deferred.resolve(); 26 | }, 27 | }) 28 | .add('imagemagick', { 29 | defer: true, 30 | fn: async (deferred: any) => { 31 | const buffer = (await fixtureStore.get('example.jpg')).buffer; 32 | 33 | await convert(buffer, [ 34 | '-resize', '200x200', 35 | '-blur', '3x3', 36 | '-quality', '80', 37 | ]); 38 | 39 | deferred.resolve(); 40 | }, 41 | }) 42 | .on('complete', () => { 43 | suite.each((each: Benchmark) => { 44 | console.log(each.name, 45 | precision(each.stats.mean * 1000) + ' ms', 46 | precision(each.hz) + ' ops/sec', 47 | each.stats.sample.length + ' samples'); 48 | }); 49 | console.log('Fastest is ' + suite.filter('fastest').map('name')); 50 | }) 51 | .run({ async: true }); 52 | 53 | 54 | function precision(v: number) { 55 | return Math.round(v * 100) / 100; 56 | } -------------------------------------------------------------------------------- /source/new-image-handler/test/bench/urls.txt: -------------------------------------------------------------------------------- 1 | # Each line must be a URL. 2 | # Comments for lines starting with #. 3 | # Example: 4 | # https:///example.jpg?x-oss-process=image/format,gif/quality,q_80 -------------------------------------------------------------------------------- /source/new-image-handler/test/bench/vegeta-urls.txt: -------------------------------------------------------------------------------- 1 | GET http:///example.jpg?x-oss-process=image/resize,s_300/quality,q_80/auto-orient,0/interlace,1/format,jpg -------------------------------------------------------------------------------- /source/new-image-handler/test/default.test.ts: -------------------------------------------------------------------------------- 1 | import { parseRequest, kvstore } from '../src/default'; 2 | 3 | test('parseActions empty', () => { 4 | expect(parseRequest('', {})).toEqual({ 5 | uri: '', 6 | actions: [], 7 | }); 8 | expect(parseRequest('/example.jpg', {})).toEqual({ 9 | uri: 'example.jpg', 10 | actions: [], 11 | }); 12 | }); 13 | 14 | test('parseActions x-oss-process', () => { 15 | expect(parseRequest('', { 16 | 'x-oss-process': '', 17 | })).toEqual({ 18 | uri: '', 19 | actions: [], 20 | }); 21 | expect(parseRequest('', { 22 | 'x-oss-process': '//image/resize,w_100,h_100,m_fixed,limit_0//quality,q_1//', 23 | })).toEqual({ 24 | uri: '', 25 | actions: ['image', 'resize,w_100,h_100,m_fixed,limit_0', 'quality,q_1'], 26 | }); 27 | }); 28 | 29 | test('parseActions custom delimiter', () => { 30 | expect(parseRequest('/example.jpg@!ABCabc.-_', {})).toEqual({ 31 | uri: 'example.jpg', 32 | actions: ['style', 'ABCabc.-_'], 33 | }); 34 | expect(parseRequest('/example.jpg!ABCabc.-_', {})).toEqual({ 35 | uri: 'example.jpg', 36 | actions: ['style', 'ABCabc.-_'], 37 | }); 38 | expect(() => parseRequest('/example.jpg@! ', {})).toThrowError(/Empty style name/); 39 | }); 40 | 41 | test('kvstore', async () => { 42 | const store = kvstore(); 43 | expect(await store.get('box100')).toEqual({ 44 | style: 'image/resize,w_100,h_100,m_fixed,limit_0/', 45 | }); 46 | }); -------------------------------------------------------------------------------- /source/new-image-handler/test/e2e/README.md: -------------------------------------------------------------------------------- 1 | # End 2 end test 2 | 3 | ## mkhtml.py is a side by side image comparison tools 4 | 5 | ``` 6 | $ chmod +x mkhtml.py 7 | $ B=http://HOST/ ./mkhtml.py 8 | html has been created at index.html 9 | 10 | # then open the index.html in browser 11 | ``` -------------------------------------------------------------------------------- /source/new-image-handler/test/e2e/mkhtml.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | 4 | TEMPLATE = r''' 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | {body} 19 | 20 |
NO.AB
21 | 22 | 23 | 24 | ''' 25 | PATHS = [ 26 | # resize 27 | 'example.jpg?x-oss-process=image/resize,w_100', 28 | 'example.jpg?x-oss-process=image/resize,h_50', 29 | 'example.jpg?x-oss-process=image/resize,w_100,m_lfit', 30 | 'example.jpg?x-oss-process=image/resize,w_100,m_mfit', 31 | 'example.jpg?x-oss-process=image/resize,w_100,h_67,m_fill', 32 | 'example.jpg?x-oss-process=image/resize,w_100,m_pad', 33 | 'example.jpg?x-oss-process=image/resize,h_100,w_100,m_fixed', 34 | 'example.jpg?x-oss-process=image/resize,h_100,m_lfit', 35 | 'example.jpg?x-oss-process=image/resize,l_100', 36 | 'example.jpg?x-oss-process=image/resize,m_fixed,h_100,w_100', 37 | 'example.jpg?x-oss-process=image/resize,m_fill,h_100,w_100', 38 | 'example.jpg?x-oss-process=image/resize,m_pad,h_100,w_100,color_FF0000', 39 | 'example.jpg?x-oss-process=image/resize,p_50', 40 | # circle 41 | 'example.jpg?x-oss-process=image/circle,r_100', 42 | # crop 43 | 'example.jpg?x-oss-process=image/crop,x_100,y_50', 44 | 'example.jpg?x-oss-process=image/crop,x_100,y_50,w_100,h_100', 45 | 'example.jpg?x-oss-process=image/crop,x_10,y_10,w_200,h_200,g_se', 46 | # indexcrop 47 | 'example.jpg?x-oss-process=image/indexcrop,x_100,i_0', 48 | # rounded-corners 49 | 'example.jpg?x-oss-process=image/rounded-corners,r_30', 50 | 'example.jpg?x-oss-process=image/crop,w_100,h_100/rounded-corners,r_10/format,png', 51 | # rotate 52 | 'example.jpg?x-oss-process=image/rotate,70', 53 | # blur 54 | 'example.jpg?x-oss-process=image/blur,r_3,s_2', 55 | # bright 56 | 'example.jpg?x-oss-process=image/bright,50', 57 | 'example.jpg?x-oss-process=image/bright,-50', 58 | # sharpen 59 | 'example.jpg?x-oss-process=image/sharpen,100', 60 | # contrast 61 | 'example.jpg?x-oss-process=image/contrast,-50', 62 | 'example.jpg?x-oss-process=image/contrast,50', 63 | # quality 64 | 'example.jpg?x-oss-process=image/resize,w_100/quality,q_30', 65 | 'example.jpg?x-oss-process=image/resize,w_100/quality,Q_30', 66 | # interlace 67 | 'example.jpg?x-oss-process=image/resize,w_200/interlace,1', 68 | # watermark 69 | 'example.jpg?x-oss-process=image/watermark,text_SGVsbG8gV29ybGQ', 70 | 'example.jpg?x-oss-process=image/watermark,text_SGVsbG8gV29ybGQ,g_nw', 71 | 'example.jpg?x-oss-process=image/watermark,text_SGVsbG8gV29ybGQ,g_north', 72 | 'example.jpg?x-oss-process=image/watermark,text_SGVsbG8gV29ybGQ,g_ne', 73 | 'example.jpg?x-oss-process=image/watermark,text_SGVsbG8gV29ybGQ,g_west', 74 | 'example.jpg?x-oss-process=image/watermark,text_SGVsbG8gV29ybGQ,g_center', 75 | 'example.jpg?x-oss-process=image/watermark,text_SGVsbG8gV29ybGQ,g_east', 76 | 'example.jpg?x-oss-process=image/watermark,text_SGVsbG8gV29ybGQ,g_sw', 77 | 'example.jpg?x-oss-process=image/watermark,text_SGVsbG8gV29ybGQ,g_south', 78 | 'example.jpg?x-oss-process=image/watermark,text_SGVsbG8gV29ybGQ,g_se', 79 | 'example.jpg?x-oss-process=image/watermark,text_SGVsbG8gV29ybGQ,size_100,color_FFFFFF,shadow_0', 80 | 'example.jpg?x-oss-process=image/watermark,text_SGVsbG8gV29ybGQ,size_100,color_FFFFFF,shadow_50', 81 | 'example.jpg?x-oss-process=image/watermark,text_SGVsbG8gV29ybGQ,size_100,color_FFFFFF,shadow_100', 82 | 'example.jpg?x-oss-process=image/watermark,text_SGVsbG8gV29ybGQ,size_30', 83 | 'example.jpg?x-oss-process=image/watermark,text_SGVsbG8gV29ybGQ,size_500', 84 | 'example.jpg?x-oss-process=image/watermark,text_SGVsbG8gV29ybGQ,size_1000', 85 | 'example.jpg?x-oss-process=image/watermark,text_SGVsbG8gV29ybGQ,rotate_45', 86 | 'example.jpg?x-oss-process=image/watermark,text_SGVsbG8gV29ybGQ,rotate_90', 87 | 'example.jpg?x-oss-process=image/watermark,text_SGVsbG8gV29ybGQ,rotate_180', 88 | 'example.jpg?x-oss-process=image/watermark,text_SGVsbG8gV29ybGQ,fill_1', 89 | 'example.jpg?x-oss-process=image/watermark,text_SGVsbG8gV29ybGQ,fill_0', 90 | 'example.jpg?x-oss-process=image/quality,q_70/watermark,text_4paI4paI4paI4paI,g_se,x_0,y_0,size_24,shadow_0,color_3E3E3E/watermark,text_5Yqo5Zu-,g_se,x_6,y_4,size_24,shadow_0,color_FFFFFF/resize,w_490', 91 | 'example.jpg?x-oss-process=image/watermark,text_SG9Zb0xBQkBXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1cyNA==,size_26,color_FFFFFF,shadow_50,t_70,g_se,x_37,y_77', 92 | ] 93 | EP_A = os.environ.get('A', 'https://image-demo-oss-zhangjiakou.oss-cn-zhangjiakou.aliyuncs.com/') 94 | EP_B = os.environ.get('B') 95 | 96 | 97 | def row(i, a, b): 98 | return '\n'.join([ 99 | '', 100 | f' {i}', 101 | f' {a}', 102 | f' {b}', 103 | '', 104 | ]) 105 | 106 | 107 | s = TEMPLATE.format(body='\n'.join([ 108 | row(i, os.path.join(EP_A, p), os.path.join(EP_B, p)) for i, p in enumerate(PATHS) 109 | ])) 110 | fname = 'index.html' 111 | 112 | with open(fname, 'w') as fp: 113 | fp.write(s) 114 | 115 | print(f'html has been created at {fname}') 116 | -------------------------------------------------------------------------------- /source/new-image-handler/test/fixtures/.gitignore: -------------------------------------------------------------------------------- 1 | *.gif 2 | *.png 3 | *.PNG 4 | *.jpeg 5 | *.jpg -------------------------------------------------------------------------------- /source/new-image-handler/test/fixtures/aws_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wchaws/serverless-image-handler/423c5e761af468ba2fa8eb9fbbd700573c614c0a/source/new-image-handler/test/fixtures/aws_logo.png -------------------------------------------------------------------------------- /source/new-image-handler/test/fixtures/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wchaws/serverless-image-handler/423c5e761af468ba2fa8eb9fbbd700573c614c0a/source/new-image-handler/test/fixtures/example.gif -------------------------------------------------------------------------------- /source/new-image-handler/test/fixtures/example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wchaws/serverless-image-handler/423c5e761af468ba2fa8eb9fbbd700573c614c0a/source/new-image-handler/test/fixtures/example.jpg -------------------------------------------------------------------------------- /source/new-image-handler/test/fixtures/example.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wchaws/serverless-image-handler/423c5e761af468ba2fa8eb9fbbd700573c614c0a/source/new-image-handler/test/fixtures/example.mp4 -------------------------------------------------------------------------------- /source/new-image-handler/test/fixtures/f.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wchaws/serverless-image-handler/423c5e761af468ba2fa8eb9fbbd700573c614c0a/source/new-image-handler/test/fixtures/f.jpg -------------------------------------------------------------------------------- /source/new-image-handler/test/fixtures/gray-200x100.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wchaws/serverless-image-handler/423c5e761af468ba2fa8eb9fbbd700573c614c0a/source/new-image-handler/test/fixtures/gray-200x100.jpg -------------------------------------------------------------------------------- /source/new-image-handler/test/imagemagick.test.ts: -------------------------------------------------------------------------------- 1 | import * as sharp from 'sharp'; 2 | import { convert, identify } from '../src/imagemagick'; 3 | import { fixtureStore } from './processor/image/utils'; 4 | 5 | test.skip('imagemagick convert', async () => { 6 | const { buffer } = await fixtureStore.get('example.jpg'); 7 | const bufout = await convert(buffer, ['-resize', '10%']); 8 | const metadata = await sharp(bufout).metadata(); 9 | 10 | expect(metadata.width).toBe(40); 11 | expect(metadata.height).toBe(27); 12 | }); 13 | 14 | test.skip('imagemagick identify', async () => { 15 | const { buffer } = await fixtureStore.get('example.jpg'); 16 | const bufout = await identify(buffer, ['-format', '%Q']); 17 | 18 | expect(bufout.toString()).toBe('82'); 19 | }); -------------------------------------------------------------------------------- /source/new-image-handler/test/index-lambda.test.ts: -------------------------------------------------------------------------------- 1 | import { URL, URLSearchParams } from 'url'; 2 | import * as sharp from 'sharp'; 3 | import { handler } from '../src/index-lambda'; 4 | 5 | function URLSearchParams2Obj(param: URLSearchParams) { 6 | const o: { [k: string]: string } = {}; 7 | for (const [key, value] of param.entries()) { 8 | o[key] = value; 9 | } 10 | return o; 11 | } 12 | 13 | function mkevt(p: string) { 14 | const u = new URL(p, 'http://test'); 15 | return { 16 | version: '2.0', 17 | routeKey: 'ANY /{proxy+}', 18 | rawPath: u.pathname, 19 | rawQueryString: u.search.substring(1), 20 | cookies: [ 21 | 's_fid=********', 22 | ], 23 | headers: { 24 | 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', 25 | 'accept-encoding': 'gzip, deflate, br', 26 | 'accept-language': 'en,zh-CN;q=0.9,zh;q=0.8', 27 | 'content-length': '0', 28 | 'host': '********.execute-api.us-west-2.amazonaws.com', 29 | 'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="100", "Google Chrome";v="100"', 30 | 'sec-ch-ua-mobile': '?0', 31 | 'sec-ch-ua-platform': '"macOS"', 32 | 'sec-fetch-dest': 'document', 33 | 'sec-fetch-mode': 'navigate', 34 | 'sec-fetch-site': 'none', 35 | 'sec-fetch-user': '?1', 36 | 'upgrade-insecure-requests': '1', 37 | 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.88 Safari/537.36', 38 | 'x-amzn-trace-id': 'Root=1-625b948f-514240be75d2c50924dcaf42', 39 | 'x-forwarded-for': '127.0.0.1', 40 | 'x-forwarded-port': '443', 41 | 'x-forwarded-proto': 'https', 42 | }, 43 | queryStringParameters: URLSearchParams2Obj(u.searchParams), 44 | requestContext: { 45 | accountId: '********', 46 | apiId: '********', 47 | domainName: '********.execute-api.us-west-2.amazonaws.com', 48 | domainPrefix: '********', 49 | http: { 50 | method: 'GET', 51 | path: u.pathname, 52 | protocol: 'HTTP/1.1', 53 | sourceIp: '127.0.0.1', 54 | userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.88 Safari/537.36', 55 | }, 56 | requestId: '*****', 57 | routeKey: 'ANY /{proxy+}', 58 | stage: '$default', 59 | time: '17/Apr/2022:04:16:15 +0000', 60 | timeEpoch: 1650168975606, 61 | }, 62 | pathParameters: { 63 | proxy: 'example.jpg', 64 | }, 65 | isBase64Encoded: false, 66 | }; 67 | } 68 | 69 | 70 | test('index-lambda.ts example.jpg?x-oss-process=image/resize,w_100/quality,q_50', async () => { 71 | const res: any = await handler(mkevt('example.jpg?x-oss-process=image/resize,w_100/quality,q_50')); 72 | 73 | expect(res.isBase64Encoded).toBeTruthy(); 74 | expect(res.statusCode).toBe(200); 75 | expect(res.headers['Content-Type']).toBe('image/jpeg'); 76 | expect(res.headers['Last-Modified']).toBe('Wed, 21 Oct 2014 07:28:00 GMT'); 77 | expect(res.headers['Cache-Control']).toBe('max-age'); 78 | 79 | const metadata = await sharp(Buffer.from(res.body, 'base64')).metadata(); 80 | 81 | expect(metadata.width).toBe(100); 82 | expect(metadata.height).toBe(67); 83 | expect(metadata.size).toBe(1352); 84 | expect(metadata.format).toBe('jpeg'); 85 | }); 86 | 87 | test('index-lambda.ts example.gif?x-oss-process=image/resize,w_100/quality,q_50', async () => { 88 | const res: any = await handler(mkevt('example.gif?x-oss-process=image/resize,w_100/quality,q_50')); 89 | 90 | expect(res.isBase64Encoded).toBeTruthy(); 91 | expect(res.statusCode).toBe(200); 92 | expect(res.headers['Content-Type']).toBe('image/gif'); 93 | 94 | const metadata = await sharp(Buffer.from(res.body, 'base64')).metadata(); 95 | 96 | expect(metadata.width).toBe(100); 97 | expect(metadata.height).toBe(60); 98 | expect(metadata.size).toBe(3544); 99 | expect(metadata.format).toBe('gif'); 100 | expect(metadata.pages).toBe(3); 101 | }); 102 | 103 | test('index-lambda.ts example.gif?x-oss-process=image/format,png', async () => { 104 | const res: any = await handler(mkevt('example.gif?x-oss-process=image/format,png')); 105 | 106 | expect(res.isBase64Encoded).toBeTruthy(); 107 | expect(res.statusCode).toBe(200); 108 | expect(res.headers['Content-Type']).toBe('image/png'); 109 | 110 | const metadata = await sharp(Buffer.from(res.body, 'base64')).metadata(); 111 | 112 | expect(metadata.width).toBe(500); 113 | expect(metadata.height).toBe(300); 114 | expect(metadata.format).toBe('png'); 115 | }); 116 | 117 | test('index-lambda.ts example.gif?x-oss-process=image/resize,w_1/info', async () => { 118 | const res: any = await handler(mkevt('example.gif?x-oss-process=image/resize,w_1/info')); 119 | 120 | expect(res.isBase64Encoded).toBeFalsy(); 121 | expect(res.statusCode).toBe(200); 122 | expect(res.headers['Content-Type']).toBe('application/json'); 123 | expect(res.body).toBe(JSON.stringify({ 124 | FileSize: { 125 | value: '21957', 126 | }, 127 | Format: { 128 | value: 'gif', 129 | }, 130 | ImageHeight: { 131 | value: '300', 132 | }, 133 | ImageWidth: { 134 | value: '500', 135 | }, 136 | })); 137 | }); -------------------------------------------------------------------------------- /source/new-image-handler/test/processor/image/_base.test.ts: -------------------------------------------------------------------------------- 1 | import { ActionMask, split1 } from '../../../src/processor/image/_base'; 2 | 3 | test(`${ActionMask.name} all enabled by default`, () => { 4 | const actions = '1 1 1 1 1 1'.split(' '); 5 | const s = new ActionMask(actions); 6 | 7 | expect(s.length).toBe(actions.length); 8 | expect(s.filterEnabledActions()).toEqual(actions); 9 | }); 10 | 11 | test(`${ActionMask.name} getAction`, () => { 12 | const actions = '1 2 3 4 5 6'.split(' '); 13 | const s = new ActionMask(actions); 14 | 15 | expect(s.length).toBe(actions.length); 16 | 17 | s.disable(0); 18 | s.enable(0); 19 | expect(s.isDisabled(0)).toBeFalsy(); 20 | 21 | s.forEachAction((action, enable, index) => { 22 | expect(s.getAction(index)).toEqual(action); 23 | expect(s.isEnabled(index)).toBe(enable); 24 | expect(action).toBe(`${1 + index}`); 25 | }); 26 | }); 27 | 28 | test(`${ActionMask.name} enable/disable`, () => { 29 | const actions = '1 2 3 4 5 6'.split(' '); 30 | const s = new ActionMask(actions); 31 | 32 | expect(s.length).toBe(actions.length); 33 | s.disable(0); 34 | s.disable(1); 35 | s.disable(2); 36 | expect(s.filterEnabledActions().length).toBe(3); 37 | expect(s.filterEnabledActions()).toEqual('4 5 6'.split(' ')); 38 | }); 39 | 40 | test(`${ActionMask.name} forEach enable/disable`, () => { 41 | const actions = '1 2 3 4 5 6'.split(' '); 42 | const s = new ActionMask(actions); 43 | 44 | expect(s.length).toBe(actions.length); 45 | s.forEachAction((_, enabled, index) => { 46 | expect(enabled).toBeTruthy(); 47 | s.disable(index); 48 | }); 49 | expect(s.filterEnabledActions()).toEqual([]); 50 | }); 51 | 52 | test(`${ActionMask.name} index out of range`, () => { 53 | const actions = '1 2 3 4 5 6'.split(' '); 54 | const s = new ActionMask(actions); 55 | 56 | expect(() => { 57 | s.enable(-1); 58 | }).toThrowError(/Index out of range/); 59 | }); 60 | 61 | test(`${split1.name} split`, () => { 62 | const s = 'text_SG9ZT0xBQkBvZmZjaWFsK0DvvIEjQO-8gSPvvIEj77-l77yBQO-_pe-8gUAj77-l77yB77-l77yBZQ=='; 63 | expect(split1(s, '_')).toEqual([ 64 | 'text', 65 | 'SG9ZT0xBQkBvZmZjaWFsK0DvvIEjQO-8gSPvvIEj77-l77yBQO-_pe-8gUAj77-l77yB77-l77yBZQ==', 66 | ]); 67 | }); 68 | 69 | 70 | -------------------------------------------------------------------------------- /source/new-image-handler/test/processor/image/auto-orient.test.ts: -------------------------------------------------------------------------------- 1 | import * as sharp from 'sharp'; 2 | import { AutoOrientAction } from '../../../src/processor/image/auto-orient'; 3 | import { mkctx } from './utils'; 4 | 5 | test('auto-orient action validate', () => { 6 | const action = new AutoOrientAction(); 7 | const param0 = action.validate('auto-orient,0'.split(',')); 8 | expect(param0).toEqual({ 9 | auto: false, 10 | }); 11 | const param1 = action.validate('auto-orient,1'.split(',')); 12 | expect(param1).toEqual({ 13 | auto: true, 14 | }); 15 | 16 | 17 | expect(() => { 18 | action.validate('auto-orient,1,2'.split(',')); 19 | }).toThrowError(/Auto-orient param error, e.g: auto-orient,1/); 20 | 21 | expect(() => { 22 | action.validate('auto-orient'.split(',')); 23 | }).toThrowError(/Auto-orient param error, e.g: auto-orient,1/); 24 | 25 | 26 | expect(() => { 27 | action.validate('auto-orient,xx'.split(',')); 28 | }).toThrowError(/Auto-orient param must be 0 or 1/); 29 | 30 | expect(() => { 31 | action.validate('auto-orient,20'.split(',')); 32 | }).toThrowError(/Auto-orient param must be 0 or 1/); 33 | 34 | }); 35 | 36 | 37 | test('quality action', async () => { 38 | const ctx = await mkctx('example.jpg'); 39 | const action = new AutoOrientAction(); 40 | await action.process(ctx, 'auto-orient,1'.split(',')); 41 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 42 | 43 | expect(info.format).toBe(sharp.format.jpeg.id); 44 | }); -------------------------------------------------------------------------------- /source/new-image-handler/test/processor/image/blur.test.ts: -------------------------------------------------------------------------------- 1 | import * as sharp from 'sharp'; 2 | import { BlurAction } from '../../../src/processor/image/blur'; 3 | import { mkctx } from './utils'; 4 | 5 | test('quality action validate', () => { 6 | const action = new BlurAction(); 7 | const param1 = action.validate('blur,r_3,s_2'.split(',')); 8 | 9 | expect(param1).toEqual({ 10 | r: 3, 11 | s: 2, 12 | }); 13 | expect(() => { 14 | action.validate('blur'.split(',')); 15 | }).toThrowError(/blur param error, e.g: blur,r_3,s_2/); 16 | expect(() => { 17 | action.validate('blur,xx'.split(',')); 18 | }).toThrowError(/Unkown param/); 19 | expect(() => { 20 | action.validate('blur,r_-1'.split(',')); 21 | }).toThrowError(/Blur param 'r' must be between 0 and 50/); 22 | expect(() => { 23 | action.validate('blur,s_51'.split(',')); 24 | }).toThrowError(/Blur param 's' must be between 0 and 50/); 25 | expect(() => { 26 | action.validate('blur,s_1111'.split(',')); 27 | }).toThrowError(/Blur param 's' must be between 0 and 50/); 28 | }); 29 | 30 | 31 | test('quality action', async () => { 32 | const ctx = await mkctx('example.jpg'); 33 | const action = new BlurAction(); 34 | await action.process(ctx, 'blur,r_5,s_2'.split(',')); 35 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 36 | 37 | expect(info.format).toBe(sharp.format.jpeg.id); 38 | }); -------------------------------------------------------------------------------- /source/new-image-handler/test/processor/image/bright.test.ts: -------------------------------------------------------------------------------- 1 | import * as sharp from 'sharp'; 2 | import { BrightAction } from '../../../src/processor/image/bright'; 3 | import { mkctx } from './utils'; 4 | 5 | test('bright action validate', () => { 6 | const action = new BrightAction(); 7 | const param1 = action.validate('bright,50'.split(',')); 8 | expect(param1).toEqual({ 9 | bright: 50, 10 | }); 11 | expect(() => { 12 | action.validate('bright'.split(',')); 13 | }).toThrowError(/Bright param error, e.g: bright,50/); 14 | 15 | expect(() => { 16 | action.validate('bright,23,32'.split(',')); 17 | }).toThrowError(/Bright param error, e.g: bright,50/); 18 | 19 | expect(() => { 20 | action.validate('bright,xx'.split(',')); 21 | }).toThrowError(/Bright must be between -100 and 100/); 22 | 23 | expect(() => { 24 | action.validate('bright,-101'.split(',')); 25 | }).toThrowError(/Bright must be between -100 and 100/); 26 | 27 | expect(() => { 28 | action.validate('bright,101'.split(',')); 29 | }).toThrowError(/Bright must be between -100 and 100/); 30 | 31 | }); 32 | 33 | 34 | test('bright action', async () => { 35 | const ctx = await mkctx('example.jpg'); 36 | const action = new BrightAction(); 37 | await action.process(ctx, 'bright,50'.split(',')); 38 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 39 | 40 | expect(info.format).toBe(sharp.format.jpeg.id); 41 | }); -------------------------------------------------------------------------------- /source/new-image-handler/test/processor/image/cgif.test.ts: -------------------------------------------------------------------------------- 1 | import * as sharp from 'sharp'; 2 | import { CgifAction } from '../../../src/processor/image/cgif'; 3 | import { mkctx } from './utils'; 4 | 5 | 6 | test('cgif,s_5', async () => { 7 | const ctx = await mkctx('example.gif'); 8 | const action = new CgifAction(); 9 | await action.beforeNewContext(ctx, 'cgif,s_5'.split(',')); 10 | const { info } = await ctx.image.gif().toBuffer({ resolveWithObject: true }); 11 | expect(info.format).toBe(sharp.format.gif.id); 12 | }); 13 | 14 | test('cgif beforeNewContext', async () => { 15 | const ctx = await mkctx('example.gif'); 16 | const action = new CgifAction(); 17 | 18 | expect(() => { 19 | action.beforeNewContext(ctx, 'cgif,s'.split(',')); 20 | }).toThrowError(/Unkown param: \"s\"/); 21 | 22 | expect(() => { 23 | action.beforeNewContext(ctx, 'cgif,s_d'.split(',')); 24 | }).toThrowError(/Unkown param: \"s\"/); 25 | 26 | expect(() => { 27 | action.beforeNewContext(ctx, 'cgif'.split(',')); 28 | }).toThrowError(/Cut gif param error, e.g: cgif,s_1/); 29 | }); -------------------------------------------------------------------------------- /source/new-image-handler/test/processor/image/circle.test.ts: -------------------------------------------------------------------------------- 1 | import * as sharp from 'sharp'; 2 | import { CircleAction } from '../../../src/processor/image/circle'; 3 | import { mkctx } from './utils'; 4 | 5 | test('circle,r_500', async () => { 6 | const ctx = await mkctx('example.jpg'); 7 | 8 | const action = new CircleAction(); 9 | await action.process(ctx, 'circle,r_500'.split(',')); 10 | 11 | const { info } = await ctx.image.png().toBuffer({ resolveWithObject: true }); 12 | 13 | expect(info.channels).toBe(4); 14 | expect(info.format).toBe(sharp.format.png.id); 15 | }); 16 | 17 | test('circle,r_100', async () => { 18 | const ctx = await mkctx('example.jpg'); 19 | 20 | const action = new CircleAction(); 21 | await action.process(ctx, 'circle,r_100'.split(',')); 22 | 23 | const { info } = await ctx.image.png().toBuffer({ resolveWithObject: true }); 24 | 25 | expect(info.channels).toBe(4); 26 | expect(info.format).toBe(sharp.format.png.id); 27 | expect(info.width).toBe(201); 28 | expect(info.height).toBe(201); 29 | }); 30 | 31 | test('quality action validate', () => { 32 | const action = new CircleAction(); 33 | const param1 = action.validate('circle,r_30'.split(',')); 34 | 35 | expect(param1).toEqual({ r: 30 }); 36 | expect(() => { 37 | action.validate('circle,r_'.split(',')); 38 | }).toThrowError(/must be between 1 and 4096/); 39 | expect(() => { 40 | action.validate('blur,xx'.split(',')); 41 | }).toThrowError(/Unkown param/); 42 | }); 43 | -------------------------------------------------------------------------------- /source/new-image-handler/test/processor/image/contrast.test.ts: -------------------------------------------------------------------------------- 1 | import * as sharp from 'sharp'; 2 | import { ContrastAction } from '../../../src/processor/image/contrast'; 3 | import { mkctx } from './utils'; 4 | 5 | test('quality action validate', () => { 6 | const action = new ContrastAction(); 7 | const param1 = action.validate('contrast,-50'.split(',')); 8 | expect(param1).toEqual({ 9 | contrast: -50, 10 | }); 11 | 12 | expect(() => { 13 | action.validate('contrast'.split(',')); 14 | }).toThrowError(/Contrast param error, e.g: contrast,-50/); 15 | 16 | expect(() => { 17 | action.validate('contrast,xx,22'.split(',')); 18 | }).toThrowError(/Contrast param error, e.g: contrast,-50/); 19 | 20 | expect(() => { 21 | action.validate('contrast,abc'.split(',')); 22 | }).toThrowError(/Contrast must be between -100 and 100/); 23 | 24 | 25 | expect(() => { 26 | action.validate('contrast,101'.split(',')); 27 | }).toThrowError(/Contrast must be between -100 and 100/); 28 | 29 | expect(() => { 30 | action.validate('contrast,-101'.split(',')); 31 | }).toThrowError(/Contrast must be between -100 and 100/); 32 | 33 | 34 | }); 35 | 36 | 37 | test('quality action', async () => { 38 | const ctx = await mkctx('example.jpg'); 39 | const action = new ContrastAction(); 40 | await action.process(ctx, 'contrast,-50'.split(',')); 41 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 42 | 43 | expect(info.format).toBe(sharp.format.jpeg.id); 44 | }); -------------------------------------------------------------------------------- /source/new-image-handler/test/processor/image/format.test.ts: -------------------------------------------------------------------------------- 1 | import * as sharp from 'sharp'; 2 | import { Features } from '../../../src/processor'; 3 | import { FormatAction } from '../../../src/processor/image/format'; 4 | import { mkctx } from './utils'; 5 | 6 | test('format action validate', () => { 7 | const action = new FormatAction(); 8 | const param1 = action.validate('format,jpg'.split(',')); 9 | expect(param1).toEqual({ 10 | format: 'jpg', 11 | }); 12 | 13 | expect(() => { 14 | action.validate('format'.split(',')); 15 | }).toThrowError(/Format param error, e.g: format,jpg/); 16 | 17 | 18 | expect(() => { 19 | action.validate('format,jpg,png'.split(',')); 20 | }).toThrowError(/Format param error, e.g: format,jpg/); 21 | 22 | expect(() => { 23 | action.validate('format,abc'.split(',')); 24 | }).toThrowError(/Format must be one of/); 25 | 26 | 27 | expect(() => { 28 | action.validate('format,12'.split(',')); 29 | }).toThrowError(/Format must be one of/); 30 | 31 | }); 32 | 33 | 34 | test('format action', async () => { 35 | const ctx = await mkctx('example.jpg'); 36 | const action = new FormatAction(); 37 | await action.process(ctx, 'format,png'.split(',')); 38 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 39 | expect(info.format).toBe(sharp.format.png.id); 40 | }); 41 | 42 | test('format action', async () => { 43 | const ctx = await mkctx('example.jpg'); 44 | const action = new FormatAction(); 45 | await action.process(ctx, 'format,webp'.split(',')); 46 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 47 | expect(info.format).toBe(sharp.format.webp.id); 48 | }); 49 | 50 | test('format action', async () => { 51 | const ctx = await mkctx('example.jpg'); 52 | const action = new FormatAction(); 53 | await action.process(ctx, 'format,jpg'.split(',')); 54 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 55 | expect(info.format).toBe(sharp.format.jpeg.id); 56 | }); 57 | 58 | test('format,jpeg', async () => { 59 | const ctx = await mkctx('example.jpg'); 60 | const action = new FormatAction(); 61 | await action.process(ctx, 'format,jpeg'.split(',')); 62 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 63 | expect(info.format).toBe(sharp.format.jpeg.id); 64 | }); 65 | 66 | test('format,gif', async () => { 67 | const ctx = await mkctx('example.gif'); 68 | const action = new FormatAction(); 69 | await action.process(ctx, 'format,gif'.split(',')); 70 | const { info, data } = await ctx.image.toBuffer({ resolveWithObject: true }); 71 | 72 | expect(info.format).toBe(sharp.format.gif.id); 73 | 74 | const metadata = await sharp(data, { animated: true }).metadata(); 75 | expect(metadata.pages).toBe(3); 76 | }); 77 | 78 | test(`format,png disable ${Features.ReadAllAnimatedFrames}`, async () => { 79 | const ctx = await mkctx('example.gif'); 80 | const action = new FormatAction(); 81 | action.beforeNewContext(ctx, 'format,png'.split(',')); 82 | expect(ctx.features[Features.ReadAllAnimatedFrames]).toBe(false); 83 | action.beforeNewContext(ctx, 'format,jpg'.split(',')); 84 | expect(ctx.features[Features.ReadAllAnimatedFrames]).toBe(false); 85 | action.beforeNewContext(ctx, 'format,jpeg'.split(',')); 86 | expect(ctx.features[Features.ReadAllAnimatedFrames]).toBe(false); 87 | }); 88 | 89 | 90 | test(`format,png enable ${Features.ReadAllAnimatedFrames}`, async () => { 91 | const ctx = await mkctx('example.gif'); 92 | const action = new FormatAction(); 93 | action.beforeNewContext(ctx, 'format,gif'.split(',')); 94 | expect(ctx.features[Features.ReadAllAnimatedFrames]).toBe(true); 95 | action.beforeNewContext(ctx, 'format,webp'.split(',')); 96 | expect(ctx.features[Features.ReadAllAnimatedFrames]).toBe(true); 97 | }); -------------------------------------------------------------------------------- /source/new-image-handler/test/processor/image/grey.test.ts: -------------------------------------------------------------------------------- 1 | import * as sharp from 'sharp'; 2 | import { GreyAction } from '../../../src/processor/image/grey'; 3 | import { mkctx } from './utils'; 4 | 5 | test('quality action validate', () => { 6 | const action = new GreyAction(); 7 | const param1 = action.validate('grey,1'.split(',')); 8 | expect(param1).toEqual({ 9 | grey: true, 10 | }); 11 | 12 | expect(() => { 13 | action.validate('grey'.split(',')); 14 | }).toThrowError(/Grey param error, e.g: grey,1/); 15 | 16 | expect(() => { 17 | action.validate('grey,xx,22'.split(',')); 18 | }).toThrowError(/Grey param error, e.g: grey,1/); 19 | 20 | expect(() => { 21 | action.validate('grey,ab'.split(',')); 22 | }).toThrowError(/Grey must be 0 or 1/); 23 | 24 | expect(() => { 25 | action.validate('grey,-1'.split(',')); 26 | }).toThrowError(/Grey must be 0 or 1/); 27 | 28 | 29 | }); 30 | 31 | test('quality action', async () => { 32 | const ctx = await mkctx('example.jpg'); 33 | const action = new GreyAction(); 34 | await action.process(ctx, 'grey,1'.split(',')); 35 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 36 | 37 | expect(info.format).toBe(sharp.format.jpeg.id); 38 | }); 39 | 40 | 41 | test('quality action', async () => { 42 | const ctx = await mkctx('example.jpg'); 43 | const action = new GreyAction(); 44 | await action.process(ctx, 'grey,0'.split(',')); 45 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 46 | 47 | expect(info.format).toBe(sharp.format.jpeg.id); 48 | }); -------------------------------------------------------------------------------- /source/new-image-handler/test/processor/image/indexcrop.test.ts: -------------------------------------------------------------------------------- 1 | import { IndexCropAction } from '../../../src/processor/image/indexcrop'; 2 | import { mkctx } from './utils'; 3 | 4 | 5 | test('indexcrop action validate', () => { 6 | const action = new IndexCropAction(); 7 | expect(() => { 8 | action.validate('indexcrop'.split(',')); 9 | }).toThrowError(/IndexCrop param error, e.g: indexcrop,x_100,i_0/); 10 | }); 11 | 12 | 13 | test('indexcrop action validate', () => { 14 | const action = new IndexCropAction(); 15 | const param1 = action.validate('indexcrop,x_100,i_0'.split(',')); 16 | expect(param1).toEqual({ 17 | x: 100, 18 | i: 0, 19 | y: 0, 20 | }); 21 | 22 | expect(() => { 23 | action.validate('indexcrop,x_-10,i_0'.split(',')); 24 | }).toThrowError(/Param error: x value must be greater than 0/); 25 | 26 | expect(() => { 27 | action.validate('indexcrop,y_-10,i_0'.split(',')); 28 | }).toThrowError(/Param error: y value must be greater than 0/); 29 | 30 | expect(() => { 31 | action.validate('indexcrop,i_10'.split(',')); 32 | }).toThrowError(/IndexCrop param error, e.g: indexcrop,x_100,i_0/); 33 | 34 | 35 | }); 36 | 37 | test('indexcrop action validate', () => { 38 | const action = new IndexCropAction(); 39 | expect(() => { 40 | action.validate('indexcrop,x_10,y_10'.split(',')); 41 | }).toThrowError(/Param error: Cannot enter x and y at the same time/); 42 | }); 43 | 44 | test('indexcrop action validate', () => { 45 | const action = new IndexCropAction(); 46 | expect(() => { 47 | action.validate('indexcrop,y_-10,i_0'.split(',')); 48 | }).toThrowError(/Param error: y value must be greater than 0/); 49 | }); 50 | 51 | 52 | test('indexcrop action validate', () => { 53 | const action = new IndexCropAction(); 54 | expect(() => { 55 | action.validate('indexcrop,x_10,i_0,abc'.split(',')); 56 | }).toThrowError(/Unkown param/); 57 | }); 58 | 59 | 60 | test('indexcrop action 01', async () => { 61 | const ctx = await mkctx('example.jpg'); 62 | const action = new IndexCropAction(); 63 | await action.process(ctx, 'indexcrop,x_100,i_0'.split(',')); 64 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 65 | expect(info.width).toBe(100); 66 | }); 67 | 68 | 69 | test('indexcrop action', async () => { 70 | const ctx = await mkctx('example.jpg'); 71 | const action = new IndexCropAction(); 72 | await action.process(ctx, 'indexcrop,y_100,i_0'.split(',')); 73 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 74 | expect(info.height).toBe(100); 75 | }); 76 | 77 | 78 | test('indexcrop action', async () => { 79 | const ctx = await mkctx('example.jpg'); 80 | const action = new IndexCropAction(); 81 | await action.process(ctx, 'indexcrop,y_10000,i_0'.split(',')); 82 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 83 | expect(info.height).toBeGreaterThan(100); 84 | }); 85 | 86 | test('indexcrop action', async () => { 87 | const ctx = await mkctx('example.jpg'); 88 | const action = new IndexCropAction(); 89 | await action.process(ctx, 'indexcrop,x_10000,i_0'.split(',')); 90 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 91 | expect(info.height).toBeGreaterThan(100); 92 | }); 93 | 94 | test('indexcrop action', async () => { 95 | const ctx = await mkctx('example.jpg'); 96 | const action = new IndexCropAction(); 97 | await action.process(ctx, 'indexcrop,x_100,i_100'.split(',')); 98 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 99 | expect(info.height).toBeGreaterThan(100); 100 | }); 101 | 102 | 103 | test('indexcrop action', async () => { 104 | const ctx = await mkctx('example.jpg'); 105 | const action = new IndexCropAction(); 106 | await action.process(ctx, 'indexcrop,y_100,i_100'.split(',')); 107 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 108 | expect(info.height).toBeGreaterThan(100); 109 | }); 110 | 111 | test('indexcrop action', async () => { 112 | const ctx = await mkctx('example.jpg'); 113 | const action = new IndexCropAction(); 114 | await action.process(ctx, 'indexcrop,x_250,i_1'.split(',')); 115 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 116 | expect(info.height).toBeGreaterThan(100); 117 | }); 118 | 119 | test('indexcrop action', async () => { 120 | const ctx = await mkctx('example.jpg'); 121 | const action = new IndexCropAction(); 122 | await action.process(ctx, 'indexcrop,y_200,i_1'.split(',')); 123 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 124 | expect(info.height).toBeGreaterThan(201); 125 | }); -------------------------------------------------------------------------------- /source/new-image-handler/test/processor/image/info.test.ts: -------------------------------------------------------------------------------- 1 | import { Features } from '../../../src/processor'; 2 | import { InfoAction } from '../../../src/processor/image/info'; 3 | import { mkctx } from './utils'; 4 | 5 | test('info action validate', () => { 6 | const action = new InfoAction(); 7 | 8 | expect(() => { 9 | action.validate('info,-1'.split(',')); 10 | }).toThrowError(/Info param error/); 11 | 12 | expect(() => { 13 | action.validate('infox'.split(',')); 14 | }).toThrowError(/Info param error/); 15 | 16 | expect(() => { 17 | action.validate('info'.split(',')); 18 | }).not.toThrowError(/Info param error/); 19 | }); 20 | 21 | test('info action', async () => { 22 | const ctx = await mkctx('example.jpg'); 23 | const action = new InfoAction(); 24 | await action.process(ctx, 'info'.split(',')); 25 | 26 | expect(ctx.features[Features.ReturnInfo]).toBeTruthy(); 27 | expect(ctx.info).toEqual({ 28 | FileSize: { value: '21839' }, 29 | Format: { value: 'jpg' }, 30 | ImageHeight: { value: '267' }, 31 | ImageWidth: { value: '400' }, 32 | }); 33 | }); 34 | 35 | test('info action on gif', async () => { 36 | const ctx = await mkctx('example.gif'); 37 | const action = new InfoAction(); 38 | await action.process(ctx, 'info'.split(',')); 39 | 40 | expect(ctx.features[Features.ReturnInfo]).toBeTruthy(); 41 | expect(ctx.info).toEqual({ 42 | FileSize: { value: '21957' }, 43 | Format: { value: 'gif' }, 44 | ImageHeight: { value: '300' }, 45 | ImageWidth: { value: '500' }, 46 | }); 47 | }); -------------------------------------------------------------------------------- /source/new-image-handler/test/processor/image/interlace.test.ts: -------------------------------------------------------------------------------- 1 | import * as sharp from 'sharp'; 2 | import { InterlaceAction } from '../../../src/processor/image/interlace'; 3 | import { mkctx } from './utils'; 4 | 5 | test('Interlace action validate', () => { 6 | const action = new InterlaceAction(); 7 | const param1 = action.validate('interlace,1'.split(',')); 8 | expect(param1).toEqual({ 9 | interlace: true, 10 | }); 11 | 12 | expect(() => { 13 | action.validate('interlace'.split(',')); 14 | }).toThrowError(/Interlace param error, e.g: interlace,1/); 15 | 16 | expect(() => { 17 | action.validate('interlace,xx,22'.split(',')); 18 | }).toThrowError(/Interlace param error, e.g: interlace,1/); 19 | 20 | expect(() => { 21 | action.validate('interlace,ab'.split(',')); 22 | }).toThrowError(/Interlace must be 0 or 1/); 23 | 24 | expect(() => { 25 | action.validate('interlace,-3'.split(',')); 26 | }).toThrowError(/Interlace must be 0 or 1/); 27 | 28 | expect(() => { 29 | action.validate('interlace,3'.split(',')); 30 | }).toThrowError(/Interlace must be 0 or 1/); 31 | 32 | }); 33 | 34 | 35 | test('interlace,1', async () => { 36 | const ctx = await mkctx('example.jpg'); 37 | const action = new InterlaceAction(); 38 | await action.process(ctx, 'interlace,1'.split(',')); 39 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 40 | expect(info.format).toBe(sharp.format.jpeg.id); 41 | }); 42 | 43 | 44 | test('interlace,0', async () => { 45 | const ctx = await mkctx('example.jpg'); 46 | const action = new InterlaceAction(); 47 | await action.process(ctx, 'interlace,0'.split(',')); 48 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 49 | expect(info.format).toBe(sharp.format.jpeg.id); 50 | }); 51 | 52 | test('interlace,1 for gif', async () => { 53 | const ctx = await mkctx('example.gif'); 54 | const action = new InterlaceAction(); 55 | await action.process(ctx, 'interlace,1'.split(',')); 56 | const { data, info } = await ctx.image.toBuffer({ resolveWithObject: true }); 57 | 58 | expect(info.format).toBe(sharp.format.gif.id); 59 | 60 | const metadata = await sharp(data, { animated: true }).metadata(); 61 | expect(metadata.pages).toBe(3); 62 | }); -------------------------------------------------------------------------------- /source/new-image-handler/test/processor/image/quality.test.ts: -------------------------------------------------------------------------------- 1 | import * as sharp from 'sharp'; 2 | import { FormatAction } from '../../../src/processor/image/format'; 3 | import * as jpeg from '../../../src/processor/image/jpeg'; 4 | import { QualityAction } from '../../../src/processor/image/quality'; 5 | import { fixtureStore, mkctx } from './utils'; 6 | 7 | test('quality action validate', () => { 8 | const action = new QualityAction(); 9 | const param1 = action.validate('quality,q_99,Q_77,,'.split(',')); 10 | 11 | expect(param1).toEqual({ 12 | q: 99, 13 | Q: 77, 14 | }); 15 | expect(() => { 16 | action.validate('quality,xx'.split(',')); 17 | }).toThrowError(/Unkown param/); 18 | expect(() => { 19 | action.validate('quality,q_0'.split(',')); 20 | }).toThrowError(/Quality must be between 1 and 100/); 21 | expect(() => { 22 | action.validate('quality,q_-1'.split(',')); 23 | }).toThrowError(/Quality must be between 1 and 100/); 24 | expect(() => { 25 | action.validate('quality,q_1111'.split(',')); 26 | }).toThrowError(/Quality must be between 1 and 100/); 27 | }); 28 | 29 | 30 | test('absolute quality action', async () => { 31 | const ctx = await mkctx('example.jpg'); 32 | const action = new QualityAction(); 33 | await action.process(ctx, 'quality,Q_1'.split(',')); 34 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 35 | 36 | expect(info.format).toBe(sharp.format.jpeg.id); 37 | }); 38 | 39 | test('relative quality action', async () => { 40 | const ctx = await mkctx('example.jpg'); 41 | const action = new QualityAction(); 42 | await action.process(ctx, 'quality,q_50'.split(',')); 43 | const { data, info } = await ctx.image.toBuffer({ resolveWithObject: true }); 44 | 45 | expect(info.format).toBe(sharp.format.jpeg.id); 46 | 47 | expect(jpeg.decode(data).quality).toBe(40); 48 | }); 49 | 50 | test('format to webp before quality action', async () => { 51 | const ctx = await mkctx('example.jpg'); 52 | const formatAction = new FormatAction(); 53 | await formatAction.process(ctx, 'format,webp'.split(',')); 54 | const qualityAction = new QualityAction(); 55 | await qualityAction.process(ctx, 'quality,q_50'.split(',')); 56 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 57 | expect(info.format).toBe(sharp.format.webp.id); 58 | }); 59 | 60 | test('estimate image quality', async () => { 61 | const buffer = (await fixtureStore.get('example.jpg')).buffer; 62 | const quality = jpeg.decode(buffer).quality; 63 | 64 | expect(quality).toBe(82); 65 | }); -------------------------------------------------------------------------------- /source/new-image-handler/test/processor/image/rotate.test.ts: -------------------------------------------------------------------------------- 1 | import * as sharp from 'sharp'; 2 | import { RotateAction } from '../../../src/processor/image/rotate'; 3 | import { mkctx } from './utils'; 4 | 5 | test('quality action validate', () => { 6 | const action = new RotateAction(); 7 | const param1 = action.validate('rotate,90'.split(',')); 8 | expect(param1).toEqual({ 9 | degree: 90, 10 | }); 11 | 12 | expect(() => { 13 | action.validate('rotate'.split(',')); 14 | }).toThrowError(/Rotate param error, e.g: rotate,90/); 15 | 16 | 17 | expect(() => { 18 | action.validate('rotate,33,abc'.split(',')); 19 | }).toThrowError(/Rotate param error, e.g: rotate,90/); 20 | 21 | expect(() => { 22 | action.validate('rotate,abc'.split(',')); 23 | }).toThrowError(/Rotate must be between 0 and 360/); 24 | expect(() => { 25 | action.validate('rotate,361'.split(',')); 26 | }).toThrowError(/Rotate must be between 0 and 360/); 27 | expect(() => { 28 | action.validate('rotate,-1'.split(',')); 29 | }).toThrowError(/Rotate must be between 0 and 360/); 30 | 31 | }); 32 | 33 | 34 | test('quality action', async () => { 35 | const ctx = await mkctx('example.jpg'); 36 | const action = new RotateAction(); 37 | await action.process(ctx, 'interlace,1'.split(',')); 38 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 39 | expect(info.format).toBe(sharp.format.jpeg.id); 40 | }); 41 | -------------------------------------------------------------------------------- /source/new-image-handler/test/processor/image/rounded-corners.test.ts: -------------------------------------------------------------------------------- 1 | import * as Jimp from 'jimp'; 2 | import * as sharp from 'sharp'; 3 | import { RoundedCornersAction } from '../../../src/processor/image/rounded-corners'; 4 | import { mkctx } from './utils'; 5 | 6 | test('rounded-corner validate', async () => { 7 | const ctx = await mkctx('example.jpg'); 8 | 9 | const action = new RoundedCornersAction(); 10 | await action.process(ctx, 'rounded-corners,r_100'.split(',')); 11 | 12 | const { data, info } = await ctx.image.png().toBuffer({ resolveWithObject: true }); 13 | 14 | const w = info.width; 15 | const h = info.height; 16 | 17 | expect(info.channels).toBe(4); 18 | expect(info.format).toBe(sharp.format.png.id); 19 | 20 | const pic = await Jimp.read(data); 21 | expect(pic.getPixelColor(0, 0)).toBe(0); 22 | expect(pic.getPixelColor(0, w)).toBe(0); 23 | expect(pic.getPixelColor(0, h)).toBe(0); 24 | expect(pic.getPixelColor(w, h)).toBe(0); 25 | }); 26 | 27 | test('quality action validate', () => { 28 | const action = new RoundedCornersAction(); 29 | const param1 = action.validate('rounded-corners,r_30'.split(',')); 30 | 31 | expect(param1).toEqual({ r: 30 }); 32 | expect(() => { 33 | action.validate('rounded-corners,r_'.split(',')); 34 | }).toThrowError(/must be between 1 and 4096/); 35 | expect(() => { 36 | action.validate('blur,xx'.split(',')); 37 | }).toThrowError(/Unkown param/); 38 | }); 39 | -------------------------------------------------------------------------------- /source/new-image-handler/test/processor/image/sharpen.test.ts: -------------------------------------------------------------------------------- 1 | import * as sharp from 'sharp'; 2 | import { SharpenAction } from '../../../src/processor/image/sharpen'; 3 | import { mkctx } from './utils'; 4 | 5 | test('quality action validate', () => { 6 | const action = new SharpenAction(); 7 | const param1 = action.validate('sharpen,50'.split(',')); 8 | expect(param1).toEqual({ 9 | sharpen: 50, 10 | }); 11 | 12 | expect(() => { 13 | action.validate('sharpen'.split(',')); 14 | }).toThrowError(/Sharpen param error, e.g: sharpen,100/); 15 | 16 | expect(() => { 17 | action.validate('sharpen,xx,22'.split(',')); 18 | }).toThrowError(/Sharpen param error, e.g: sharpen,100/); 19 | 20 | 21 | expect(() => { 22 | action.validate('sharpen,22'.split(',')); 23 | }).toThrowError(/Sharpen be between 50 and 399/); 24 | 25 | 26 | expect(() => { 27 | action.validate('contrast,49'.split(',')); 28 | }).toThrowError(/Sharpen be between 50 and 399/); 29 | 30 | expect(() => { 31 | action.validate('contrast,400'.split(',')); 32 | }).toThrowError(/Sharpen be between 50 and 399/); 33 | 34 | expect(() => { 35 | action.validate('contrast,100'.split(',')); 36 | }); 37 | 38 | expect(() => { 39 | action.validate('contrast,60'.split(',')); 40 | }); 41 | }); 42 | 43 | 44 | test('quality action', async () => { 45 | const ctx = await mkctx('example.jpg'); 46 | const action = new SharpenAction(); 47 | await action.process(ctx, 'sharpen,100'.split(',')); 48 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 49 | 50 | expect(info.format).toBe(sharp.format.jpeg.id); 51 | }); 52 | 53 | 54 | test('quality action', async () => { 55 | const ctx = await mkctx('example.jpg'); 56 | const action = new SharpenAction(); 57 | await action.process(ctx, 'sharpen,60'.split(',')); 58 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 59 | 60 | expect(info.format).toBe(sharp.format.jpeg.id); 61 | }); -------------------------------------------------------------------------------- /source/new-image-handler/test/processor/image/threshold.test.ts: -------------------------------------------------------------------------------- 1 | // import * as sharp from 'sharp'; 2 | import { ThresholdAction } from '../../../src/processor/image/threshold'; 3 | import { mkctx } from './utils'; 4 | 5 | test(`${ThresholdAction.name} action validate`, () => { 6 | const action = new ThresholdAction(); 7 | const param = action.validate('threshold,100'.split(',')); 8 | expect(param.threshold).toBe(100); 9 | 10 | expect(() => { 11 | action.validate('threshold'.split(',')); 12 | }).toThrow(/Invalid/); 13 | 14 | expect(() => { 15 | action.validate('threshold,-1'.split(',')); 16 | }).toThrow(/Invalid.*greater than zero/); 17 | 18 | }); 19 | 20 | 21 | test(`${ThresholdAction.name} beforeProcess mask disabled`, async () => { 22 | const ctx = await mkctx('example.jpg', ['threshold,23000']); 23 | const action = new ThresholdAction(); 24 | 25 | await action.beforeProcess(ctx, 'threshold,23000'.split(','), 0); 26 | expect(ctx.mask.isDisabled(0)).toBeTruthy(); 27 | }); 28 | 29 | 30 | test(`${ThresholdAction.name} beforeProcess mask enabled`, async () => { 31 | const ctx = await mkctx('example.jpg', ['threshold,100']); 32 | const action = new ThresholdAction(); 33 | 34 | await action.beforeProcess(ctx, 'threshold,100'.split(','), 0); 35 | expect(ctx.mask.isEnabled(0)).toBeTruthy(); 36 | }); 37 | -------------------------------------------------------------------------------- /source/new-image-handler/test/processor/image/utils.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as sharp from 'sharp'; 3 | import { IImageContext } from '../../../src/processor/image'; 4 | import { ActionMask } from '../../../src/processor/image/_base'; 5 | import { IBufferStore, LocalStore } from '../../../src/store'; 6 | 7 | export const fixtureStore = new LocalStore(path.join(__dirname, '../../fixtures')); 8 | 9 | export async function mkctx(name: string, actions?: string[], bufferStore?: IBufferStore): Promise { 10 | if (!bufferStore) { 11 | bufferStore = fixtureStore; 12 | } 13 | const image = sharp((await bufferStore.get(name)).buffer, { 14 | animated: (name.endsWith('.gif') || name.endsWith('.webp')), 15 | }); 16 | 17 | const actions2 = actions ?? []; 18 | const mask = new ActionMask(actions2); 19 | return { uri: name, actions: actions2, mask, image, bufferStore, features: {}, headers: {}, metadata: await image.metadata() }; 20 | } -------------------------------------------------------------------------------- /source/new-image-handler/test/processor/image/watermark.test.ts: -------------------------------------------------------------------------------- 1 | import * as sharp from 'sharp'; 2 | import { Features } from '../../../src/processor'; 3 | import { WatermarkAction } from '../../../src/processor/image/watermark'; 4 | import { mkctx } from './utils'; 5 | 6 | const testText = 'hello 世界 !'; 7 | const testImgFile = 'aws_logo.png'; 8 | 9 | const testTextbuff = Buffer.from(testText, 'utf-8'); 10 | const testImgbuff = Buffer.from(testImgFile, 'utf-8'); 11 | 12 | const base64Text = testTextbuff.toString('base64'); 13 | const base64ImgFile = testImgbuff.toString('base64'); 14 | 15 | const testTextParam = `watermark,text_${base64Text},rotate_25,g_se,t_70,color_ff9966`; 16 | const testImgParam = `watermark,image_${base64ImgFile},rotate_25,g_nw,t_70`; 17 | const testMixedParam = `watermark,image_${base64ImgFile},text_${base64Text},g_nw,t_20,align_2,interval_5,size_14`; 18 | 19 | test(testTextParam, async () => { 20 | const ctx = await mkctx('example.jpg'); 21 | 22 | const action = new WatermarkAction(); 23 | await action.process(ctx, testTextParam.split(',')); 24 | 25 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 26 | expect(info.format).toBe(sharp.format.jpeg.id); 27 | }); 28 | 29 | test(testImgParam, async () => { 30 | const ctx = await mkctx('example.jpg'); 31 | 32 | const action = new WatermarkAction(); 33 | await action.process(ctx, testImgParam.split(',')); 34 | 35 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 36 | expect(info.format).toBe(sharp.format.jpeg.id); 37 | }); 38 | 39 | test(testMixedParam, async () => { 40 | const ctx = await mkctx('example.jpg'); 41 | 42 | const action = new WatermarkAction(); 43 | await action.process(ctx, testMixedParam.split(',')); 44 | 45 | const { info } = await ctx.image.toBuffer({ resolveWithObject: true }); 46 | expect(info.format).toBe(sharp.format.jpeg.id); 47 | }); 48 | 49 | test(`disable ${Features.ReadAllAnimatedFrames}`, async () => { 50 | const ctx = await mkctx('example.gif'); 51 | const action = new WatermarkAction(); 52 | action.beforeNewContext(ctx, testMixedParam.split(',')); 53 | 54 | expect(ctx.features[Features.ReadAllAnimatedFrames]).toBe(false); 55 | }); -------------------------------------------------------------------------------- /source/new-image-handler/test/store.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as HttpErrors from 'http-errors'; 3 | import { DynamoDBStore, LocalStore, MemKVStore, S3Store } from '../src/store'; 4 | 5 | test('local store', async () => { 6 | const store = new LocalStore(); 7 | const { buffer } = await store.get(path.join(__dirname, 'fixtures/example.jpg')); 8 | 9 | expect(buffer.length).toBe(21839); 10 | }); 11 | 12 | test('local store shortcut', async () => { 13 | const fn = jest.fn(); 14 | const store = new LocalStore(); 15 | const { buffer } = await store.get(path.join(__dirname, 'fixtures/example.jpg'), fn); 16 | 17 | expect(buffer.length).toBe(21839); 18 | }); 19 | 20 | 21 | test('s3 store shortcut', async () => { 22 | const bypass = () => { 23 | // NOTE: This is intended to tell CloudFront to directly access the s3 object without through ECS cluster. 24 | throw new HttpErrors[403]('Please visit s3 directly'); 25 | }; 26 | const store = new S3Store('sih-input'); 27 | 28 | void expect(store.get('Sample-Small-Image-PNG-file-Download.png', bypass)) 29 | .rejects 30 | .toThrowError(/Please visit s3/); 31 | void expect(store.get('Sample-Small-Image-PNG-file-Download.png', bypass)) 32 | .rejects 33 | .toThrow(HttpErrors.HttpError); 34 | void expect(store.get('Sample-Small-Image-PNG-file-Download.png', bypass)) 35 | .rejects 36 | .toThrow(expect.objectContaining({ status: 403 })); 37 | }); 38 | 39 | 40 | test('MemKV Store', async () => { 41 | const store = new MemKVStore({ 42 | a: { id: 'a', value: 'a' }, 43 | b: { id: 'b', value: 'b' }, 44 | }); 45 | 46 | expect(await store.get('a')).toEqual({ id: 'a', value: 'a' }); 47 | expect(await store.get('123')).toEqual({}); 48 | }); 49 | 50 | 51 | test.skip('s3 store', async () => { 52 | const store = new S3Store('sih-input'); 53 | const { buffer, type } = await store.get('Sample-Small-Image-PNG-file-Download.png'); 54 | 55 | expect(type).toBe('image/png'); 56 | expect(buffer.length).toBe(2678371); 57 | }, 10 * 1000); 58 | 59 | 60 | test.skip('dynamodb store', async () => { 61 | const table = 'serverless-new-image-handler-stack-serverlessecrimagehandlerstackStyleTableE94C4297-PTLOYODP1J7E'; 62 | const ddbstore = new DynamoDBStore(table); 63 | 64 | console.log(await ddbstore.get('hello')); 65 | }); -------------------------------------------------------------------------------- /source/new-image-handler/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "declaration": true, 5 | "experimentalDecorators": true, 6 | "inlineSourceMap": true, 7 | "inlineSources": true, 8 | "lib": [ 9 | "es2018" 10 | ], 11 | "module": "CommonJS", 12 | "noEmitOnError": false, 13 | "noFallthroughCasesInSwitch": true, 14 | "noImplicitAny": true, 15 | "noImplicitReturns": true, 16 | "noImplicitThis": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "resolveJsonModule": true, 20 | "strict": true, 21 | "strictNullChecks": true, 22 | "strictPropertyInitialization": true, 23 | "stripInternal": true, 24 | "target": "ES2018", 25 | "allowJs": true 26 | }, 27 | "include": [ 28 | "src/**/*.js", 29 | "src/**/*.ts", 30 | "test/**/*.ts" 31 | ], 32 | "exclude": [ 33 | "node_modules" 34 | ], 35 | "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." 36 | } 37 | -------------------------------------------------------------------------------- /source/new-image-handler/tsconfig.jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "declaration": true, 5 | "experimentalDecorators": true, 6 | "inlineSourceMap": true, 7 | "inlineSources": true, 8 | "lib": [ 9 | "es2018" 10 | ], 11 | "module": "CommonJS", 12 | "noEmitOnError": false, 13 | "noFallthroughCasesInSwitch": true, 14 | "noImplicitAny": true, 15 | "noImplicitReturns": true, 16 | "noImplicitThis": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "resolveJsonModule": true, 20 | "strict": true, 21 | "strictNullChecks": true, 22 | "strictPropertyInitialization": true, 23 | "stripInternal": true, 24 | "target": "ES2018", 25 | "allowJs": true 26 | }, 27 | "include": [ 28 | "src/**/*.js", 29 | "src/**/*.ts", 30 | "src/**/*.d.ts", 31 | "test/**/*.ts" 32 | ], 33 | "exclude": [ 34 | "node_modules" 35 | ], 36 | "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." 37 | } 38 | -------------------------------------------------------------------------------- /source/new-image-handler/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "lib", 4 | "alwaysStrict": true, 5 | "declaration": true, 6 | "experimentalDecorators": true, 7 | "inlineSourceMap": true, 8 | "inlineSources": true, 9 | "lib": [ 10 | "es2018" 11 | ], 12 | "module": "CommonJS", 13 | "noEmitOnError": false, 14 | "noFallthroughCasesInSwitch": true, 15 | "noImplicitAny": true, 16 | "noImplicitReturns": true, 17 | "noImplicitThis": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "resolveJsonModule": true, 21 | "strict": true, 22 | "strictNullChecks": true, 23 | "strictPropertyInitialization": true, 24 | "stripInternal": true, 25 | "target": "ES2018", 26 | "allowJs": true 27 | }, 28 | "include": [ 29 | "src/**/*.js", 30 | "src/**/*.ts", 31 | "src/**/*.d.ts", 32 | "test/**/*.ts" 33 | ], 34 | "exclude": [], 35 | } --------------------------------------------------------------------------------