├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github ├── COMMIT_CONVENTION.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── lock.yml ├── stale.yml └── workflows │ └── test.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg └── pre-commit ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .yarn └── releases │ └── yarn-1.22.21.cjs ├── .yarnrc.yml ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg ├── Statue-of-Sardar-Vallabhbhai-Patel-150x100.jpg ├── Statue-of-Sardar-Vallabhbhai-Patel-825x550.jpg ├── adonis-typings ├── attachment.ts ├── container.ts ├── images.ts ├── index.ts └── validator.ts ├── assets └── Cover-Image-Adonis-Responsive-Attachment.jpg ├── config.json ├── examples └── index.ts ├── japaFile.js ├── nyc.config.js ├── package.json ├── providers └── ResponsiveAttachmentProvider.ts ├── src ├── Attachment │ ├── decorator.ts │ └── index.ts ├── Bindings │ └── Validator.ts └── Helpers │ └── image_manipulation_helper.ts ├── test-helpers ├── drive.ts └── index.ts ├── test ├── attachment-decorator.spec.ts ├── attachment.spec.ts └── validator.spec.ts ├── tsconfig.json ├── unallowed_file.pdf └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.json] 12 | insert_final_newline = ignore 13 | 14 | [**.min.js] 15 | indent_style = ignore 16 | insert_final_newline = ignore 17 | 18 | [MakeFile] 19 | indent_style = space 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build 2 | test-helpers/__app/** 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:adonis/typescriptPackage", 4 | "plugin:prettier/recommended" 5 | ], 6 | "plugins": [ 7 | "prettier" 8 | ], 9 | "rules": { 10 | "prettier/prettier": [ 11 | "error", 12 | { 13 | "endOfLine": "auto" 14 | } 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/COMMIT_CONVENTION.md: -------------------------------------------------------------------------------- 1 | ## Git Commit Message Convention 2 | 3 | > This is adapted from [Angular's commit convention](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular). 4 | 5 | Using conventional commit messages, we can automate the process of generating the CHANGELOG file. All commits messages will automatically be validated against the following regex. 6 | 7 | ``` js 8 | /^(revert: )?(feat|fix|docs|style|refactor|perf|test|workflow|ci|chore|types|build|improvement)((.+))?: .{1,50}/ 9 | ``` 10 | 11 | ## Commit Message Format 12 | A commit message consists of a **header**, **body** and **footer**. The header has a **type**, **scope** and **subject**: 13 | 14 | > The **scope** is optional 15 | 16 | ``` 17 | feat(router): add support for prefix 18 | 19 | Prefix makes it easier to append a path to a group of routes 20 | ``` 21 | 22 | 1. `feat` is type. 23 | 2. `router` is scope and is optional 24 | 3. `add support for prefix` is the subject 25 | 4. The **body** is followed by a blank line. 26 | 5. The optional **footer** can be added after the body, followed by a blank line. 27 | 28 | ## Types 29 | Only one type can be used at a time and only following types are allowed. 30 | 31 | - feat 32 | - fix 33 | - docs 34 | - style 35 | - refactor 36 | - perf 37 | - test 38 | - workflow 39 | - ci 40 | - chore 41 | - types 42 | - build 43 | 44 | If a type is `feat`, `fix` or `perf`, then the commit will appear in the CHANGELOG.md file. However if there is any BREAKING CHANGE, the commit will always appear in the changelog. 45 | 46 | ### Revert 47 | If the commit reverts a previous commit, it should begin with `revert:`, followed by the header of the reverted commit. In the body it should say: `This reverts commit `., where the hash is the SHA of the commit being reverted. 48 | 49 | ## Scope 50 | The scope could be anything specifying place of the commit change. For example: `router`, `view`, `querybuilder`, `database`, `model` and so on. 51 | 52 | ## Subject 53 | The subject contains succinct description of the change: 54 | 55 | - use the imperative, present tense: "change" not "changed" nor "changes". 56 | - don't capitalize first letter 57 | - no dot (.) at the end 58 | 59 | ## Body 60 | 61 | Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes". 62 | The body should include the motivation for the change and contrast this with previous behavior. 63 | 64 | ## Footer 65 | 66 | The footer should contain any information about **Breaking Changes** and is also the place to 67 | reference GitHub issues that this commit **Closes**. 68 | 69 | **Breaking Changes** should start with the word `BREAKING CHANGE:` with a space or two newlines. The rest of the commit message is then used for this. 70 | 71 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report identified bugs 4 | --- 5 | 6 | 7 | 8 | ## Prerequisites 9 | 10 | We do our best to reply to all the issues on time. If you will follow the given guidelines, the turn around time will be faster. 11 | 12 | - Lots of raised issues are directly not bugs but instead are design decisions taken by us. 13 | - Make use of our [forum](https://forum.adonisjs.com/), or [discord server](https://discord.me/adonisjs), if you are not sure that you are reporting a bug. 14 | - Ensure the issue isn't already reported. 15 | - Ensure you are reporting the bug in the correct repo. 16 | 17 | *Delete the above section and the instructions in the sections below before submitting* 18 | 19 | ## Package version 20 | 21 | 22 | ## Node.js and npm version 23 | 24 | 25 | ## Sample Code (to reproduce the issue) 26 | 27 | 28 | ## BONUS (a sample repo to reproduce the issue) 29 | 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Propose changes for adding a new feature 4 | --- 5 | 6 | 7 | 8 | ## Prerequisites 9 | 10 | We do our best to reply to all the issues on time. If you will follow the given guidelines, the turn around time will be faster. 11 | 12 | ## Consider an RFC 13 | 14 | Please create an [RFC](https://github.com/adonisjs/rfcs) instead, if 15 | 16 | - Feature introduces a breaking change 17 | - Demands lots of time and changes in the current code base. 18 | 19 | *Delete the above section and the instructions in the sections below before submitting* 20 | 21 | ## Why this feature is required (specific use-cases will be appreciated)? 22 | 23 | 24 | ## Have you tried any other work arounds? 25 | 26 | 27 | ## Are you willing to work on it with little guidance? 28 | 29 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Proposed changes 4 | 5 | Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue. 6 | 7 | ## Types of changes 8 | 9 | What types of changes does your code introduce? 10 | 11 | _Put an `x` in the boxes that apply_ 12 | 13 | - [ ] Bugfix (non-breaking change which fixes an issue) 14 | - [ ] New feature (non-breaking change which adds functionality) 15 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 16 | 17 | ## Checklist 18 | 19 | _Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code._ 20 | 21 | - [ ] I have read the [CONTRIBUTING](https://github.com/adonisjs/attachment-lite/blob/master/CONTRIBUTING.md) doc 22 | - [ ] Lint and unit tests pass locally with my changes 23 | - [ ] I have added tests that prove my fix is effective or that my feature works. 24 | - [ ] I have added necessary documentation (if appropriate) 25 | 26 | ## Further comments 27 | 28 | If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... 29 | -------------------------------------------------------------------------------- /.github/lock.yml: -------------------------------------------------------------------------------- 1 | # Configuration for Lock Threads - https://github.com/dessant/lock-threads-app 2 | 3 | # Number of days of inactivity before a closed issue or pull request is locked 4 | daysUntilLock: 60 5 | 6 | # Skip issues and pull requests created before a given timestamp. Timestamp must 7 | # follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable 8 | skipCreatedBefore: false 9 | 10 | # Issues and pull requests with these labels will be ignored. Set to `[]` to disable 11 | exemptLabels: ['Type: Security'] 12 | 13 | # Label to add before locking, such as `outdated`. Set to `false` to disable 14 | lockLabel: false 15 | 16 | # Comment to post before locking. Set to `false` to disable 17 | lockComment: > 18 | This thread has been automatically locked since there has not been 19 | any recent activity after it was closed. Please open a new issue for 20 | related bugs. 21 | 22 | # Assign `resolved` as the reason for locking. Set to `false` to disable 23 | setLockReason: false 24 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | 4 | # Number of days of inactivity before a stale issue is closed 5 | daysUntilClose: 7 6 | 7 | # Issues with these labels will never be considered stale 8 | exemptLabels: 9 | - 'Type: Security' 10 | 11 | # Label to use when marking an issue as stale 12 | staleLabel: 'Status: Abandoned' 13 | 14 | # Comment to post when marking an issue as stale. Set to `false` to disable 15 | markComment: > 16 | This issue has been automatically marked as stale because it has not had 17 | recent activity. It will be closed if no further activity occurs. Thank you 18 | for your contributions. 19 | 20 | # Comment to post when closing a stale issue. Set to `false` to disable 21 | closeComment: false 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | linux: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: 11 | - 16.x 12 | - 18.x 13 | - 20.x 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: Install 21 | run: yarn install 22 | - name: Run tests 23 | run: yarn test 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | test/__app 4 | test-helpers/__app 5 | .DS_STORE 6 | .nyc_output 7 | .idea 8 | .vscode/ 9 | *.sublime-project 10 | *.sublime-workspace 11 | *.log 12 | build 13 | dist 14 | shrinkwrap.yaml 15 | 16 | .pnp.* 17 | .yarn/* 18 | !.yarn/patches 19 | !.yarn/plugins 20 | !.yarn/releases 21 | !.yarn/sdks 22 | !.yarn/versions 23 | .eslintcache 24 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | HUSKY_GIT_PARAMS=$1 node ./node_modules/@adonisjs/mrm-preset/validate-commit/conventional/validate.js 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx yarn lint --fix --cache && git add . 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | message="chore(release): %s" 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | docs 3 | *.md 4 | config.json 5 | .eslintrc.json 6 | package.json 7 | *.html 8 | *.txt 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": false, 4 | "singleQuote": true, 5 | "useTabs": false, 6 | "quoteProps": "consistent", 7 | "bracketSpacing": true, 8 | "arrowParens": "always", 9 | "printWidth": 100 10 | } 11 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-1.22.21.cjs 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | AdonisJS is a community driven project. You are free to contribute in any of the following ways. 4 | 5 | - [Coding style](coding-style) 6 | - [Fix bugs by creating PR's](fix-bugs-by-creating-prs) 7 | - [Share an RFC for new features or big changes](share-an-rfc-for-new-features-or-big-changes) 8 | - [Report security issues](report-security-issues) 9 | - [Be a part of the community](be-a-part-of-community) 10 | 11 | ## Coding style 12 | 13 | Majority of AdonisJS core packages are written in Typescript. Having a brief knowledge of Typescript is required to contribute to the core. 14 | 15 | ## Fix bugs by creating PR's 16 | 17 | We appreciate every time you report a bug in the framework or related libraries. However, taking time to submit a PR can help us in fixing bugs quickly and ensure a healthy and stable eco-system. 18 | 19 | Go through the following points, before creating a new PR. 20 | 21 | 1. Create an issue discussing the bug or short-coming in the framework. 22 | 2. Once approved, go ahead and fork the REPO. 23 | 3. Make sure to start from the `develop`, since this is the upto date branch. 24 | 4. Make sure to keep commits small and relevant. 25 | 5. We follow [conventional-commits](https://github.com/conventional-changelog/conventional-changelog) to structure our commit messages. Instead of running `git commit`, you must run `npm commit`, which will show you prompts to create a valid commit message. 26 | 6. Once done with all the changes, create a PR against the `develop` branch. 27 | 28 | ## Share an RFC for new features or big changes 29 | 30 | Sharing PR's for small changes works great. However, when contributing big features to the framework, it is required to go through the RFC process. 31 | 32 | ### What is an RFC? 33 | 34 | RFC stands for **Request for Commits**, a standard process followed by many other frameworks including [Ember](https://github.com/emberjs/rfcs), [yarn](https://github.com/yarnpkg/rfcs) and [rust](https://github.com/rust-lang/rfcs). 35 | 36 | In brief, RFC process allows you to talk about the changes with everyone in the community and get a view of the core team before dedicating your time to work on the feature. 37 | 38 | The RFC proposals are created as Pull Request on [adonisjs/rfcs](https://github.com/adonisjs/rfcs) repo. Make sure to read the README to learn about the process in depth. 39 | 40 | ## Report security issues 41 | 42 | All of the security issues, must be reported via [email](mailto:virk@adonisjs.com) and not using any of the public channels. 43 | 44 | ## Be a part of community 45 | 46 | We welcome you to participate in [GitHub Discussion](https://github.com/adonisjs/core/discussions) and the AdonisJS [Discord Server](https://discord.gg/vDcEjq6). You are free to ask your questions and share your work or contributions made to AdonisJS eco-system. 47 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright 2021 Harminder Virk, contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Adonis Responsive Attachment 2 | 3 |
4 | 5 |
6 | 7 | --- 8 | 9 | 10 | [![github-actions-image]][github-actions-url] [![npm-image]][npm-url] [![license-image]][license-url] [![typescript-image]][typescript-url] 11 | 12 | The Adonis Responsive Attachment package allows you to generate and persist optimised responsive images from uploaded images. It integrates with AdonisJS Lucid by converting any column on your Lucid model to an image attachment data type via the `@responsiveAttachment` decorator. 13 | 14 | Adonis Responsive Attachment generates very detailed metadata of the original file and generated responsive images and persists the metadata to the `decorated` column within the database. It does not require any additional database tables and stores the file metadata as JSON within the same specified/decorated column. 15 | 16 | This add-on only accepts image files and is a fork of the [Attachment Lite](https://github.com/adonisjs/attachment-lite) add-on. The main difference between the `Adonis Responsive Attachment` and `Attachment Lite` is that `Attachment Lite` accepts all file types while `Adonis Responsive Attachment` only accepts image files. Also, `Attachment Lite` only persists the original uploaded file plus its metadata while `Adonis Responsive Attachment` persists the uploaded image and generated responsive images to disk and their metadata to the database. 17 | 18 | ## Why Use this Add-On? 19 | 20 | The ability of your application/website to serve different sizes of the same image across different devices is an important factor for improving the performance of your application/website. If your visitor is accessing your website with a mobile device whose screen width is less than 500px, it is performant and data-friendly to serve that device a banner which isn't wider than 500px. On the other hand, if a visitor is accessing your website with a laptop with a minimum screen size of 1400px, it makes sense not to serve that device a banner whose width is less than 1200px so that the image does not appear pixelated. 21 | 22 | The Adonis Responsive Attachment add-on provides the ability to generate unlimited number of responsive sizes from an uploaded image and utilise the `srcset` and `sizes` attributes to serve and render different sizes of the same image to a visitor based on the size of their screen. You should get familiar with this concept by studying the [Responsive Images topic on MDN](https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images). 23 | 24 | ## Use Case for this Add-On 25 | 26 | Let us assume you are developing a blog. On the article page, you need to upload a cover image. You also need to generate responsive sizes of the uploaded cover image so that you can serve different sizes to different devices based on their screen sizes. This add-on will optimise and persist the original cover image saving you up to 50% reduction is the file size. It will also generate and persist optimised responsive cover images at various breakpoints which you can customise. Additionally, for the original and responsive cover images, the add-on will generate detailed metadata of the images and persist the metadata as the value for the column within the database. 27 | 28 | On the frontend of your blog, you can use the `srcset` attribute of the `img` element to define and serve the different cover image sizes. You can also use the `picture` wrapper element with the `source` element to define and serve the responsive cover images. 29 | 30 | ## Features 31 | 32 | - Turn any column in your database to an image attachment data type. 33 | - No additional database tables are required. The metadata of the original and responsive images are stored as JSON within the same column. 34 | - Automatically removes the old images (original and generated responsive images) from the disk when a new image is assigned to the model. 35 | - Handles failure cases gracefully. No images will be stored if the model fails to persist. 36 | - Similarly, no old images are removed if the model fails to persist during an update or the deletion fails. 37 | - Provides detailed properties of the original and generated images including: `name`, `width`, `height`, `size`, `format`, `mimetype`, `extname`, and `url`. 38 | - Can auto-rotate images during the optimisation process. 39 | - Allows you to customise the breakpoints for generating the responsive images 40 | - Allows you to disable generation of responsive images. 41 | - Allows you to disable optimisation of images. 42 | - Converts images from one format to another. The following formats are supported: `jpeg`, `png`, `webp`, `tiff`, and `avif`. 43 | - Allows you to disable some breakpoints. 44 | - Allows you to disable the generation of the thumbnail image without affecting the generation of other responsive images. 45 | - Ability to create attachments from file buffers. This is very helpful when you want to persist images outside of the HTTP life-cycle. 46 | - Provides validation rules for checking image dimensions and aspect ratio. 47 | - Provides blurhash generation (https://blurha.sh/) 48 | 49 | ## Pre-requisites 50 | 51 | The `attachment-lite` package requires `@adonisjs/lucid >= v16.3.1` and `@adonisjs/core >= 5.3.4`. 52 | 53 | It relies on [AdonisJS drive](https://docs.adonisjs.com/guides/drive) for writing files on the disk. 54 | 55 | It also relies heavily on the [Sharp image manipulation library](https://sharp.pixelplumbing.com/) for performing image optimisations and generation of responsive images. 56 | 57 | ## Setup 58 | 59 | Install the package from the npm registry as follows. 60 | 61 | ```bash 62 | yarn add adonis-responsive-attachment 63 | ``` 64 | 65 | Next, configure the package by running the following ace command. 66 | 67 | ```bash 68 | node ace configure adonis-responsive-attachment 69 | ``` 70 | 71 | ## Usage 72 | 73 | First and very importantly, this addon generates a large metadata for the original and generated images which will persisted to the database. So, the column for storing the metadata must be a JSON data type. 74 | 75 | If you are creating the column for the first time, make sure that you use the JSON data type. Example: 76 | 77 | ```ts 78 | protected tableName = 'posts' 79 | 80 | public async up() { 81 | this.schema.createTable(this.tableName, (table) => { 82 | table.increments() 83 | table.json('cover_image') // <-- Use a JSON data type 84 | }) 85 | } 86 | ``` 87 | 88 | If you already have a column for storing image paths/URLs, you need to create a new migration and alter the column definition to a JSON data type. Example: 89 | 90 | ```bash 91 | node ace make:migration change_cover_image_column_to_json --table=posts 92 | ``` 93 | 94 | ```ts 95 | protected tableName = 'posts' 96 | 97 | public async up() { 98 | this.schema.alterTable(this.tableName, (table) => { 99 | table.json('cover_image').alter() // <-- Alter the column definition 100 | }) 101 | } 102 | ``` 103 | 104 | The next step is to import the `responsiveAttachment` decorator and the `ResponsiveAttachmentContract` interface from the `adonis-responsive-attachment` package. 105 | 106 | > Make sure NOT to use the `@column` decorator when using the `@responsiveAttachment` decorator. 107 | 108 | ```ts 109 | import { BaseModel } from '@ioc:Adonis/Lucid/Orm' 110 | import { 111 | responsiveAttachment, 112 | ResponsiveAttachmentContract 113 | } from '@ioc:Adonis/Addons/ResponsiveAttachment' 114 | 115 | class Post extends BaseModel { 116 | @responsiveAttachment() 117 | public coverImage: ResponsiveAttachmentContract 118 | } 119 | ``` 120 | 121 | There are two ways to create responsive attachments with the `Adonis Responsive Attachment` add-on: 122 | 123 | 1. The `fromFile` static method: 124 | 125 | The `fromFile` method allows you to create responsive images from images upload via HTTP requests. It takes one parameter which is the `file` output of the `request.file()` method 126 | 127 | 2. The `fromBuffer` static method: 128 | 129 | The `fromBuffer` method creates responsive images from (image) buffers. These images buffers can come from any source you prefer as long as they are of type `Buffer`. This allows you to create responsive images from outside the HTTP life-cycle. The `fromBuffer` method accepts one parameter which must be a `Buffer`. 130 | 131 | Both methods allow you to provide an optional file name as the second parameter. This replaces the use of input file name from the uploaded file. The add-on will replace all non-numeric and non-alphabet characters in the file name with the underscore character. 132 | 133 | The example below shows the use of the `fromFile` static method. 134 | 135 | ```ts 136 | import { ResponsiveAttachment } from '@ioc:Adonis/Addons/ResponsiveAttachment' 137 | 138 | class PostsController { 139 | public store({ request }: HttpContextContract) { 140 | const coverImage = request.file('coverImage')! 141 | const post = new Post() 142 | 143 | post.coverImage = coverImage ? await ResponsiveAttachment.fromFile(coverImage, 'My Great Name') : null 144 | await post.save() 145 | } 146 | } 147 | ``` 148 | 149 | The example below shows the use of the `fromBuffer` static method. 150 | 151 | ```ts 152 | import { ResponsiveAttachment } from '@ioc:Adonis/Addons/ResponsiveAttachment' 153 | import { readFile } from 'fs/promises' 154 | class UsersController { 155 | public store() { 156 | const buffer = await readFile(join(__dirname, '../me.jpeg')) 157 | const user = new User() 158 | user.avatar = await ResponsiveAttachment.fromBuffer(buffer, 'Awesome File Name') 159 | await user.save() 160 | } 161 | } 162 | ``` 163 | 164 | > NOTE: You should `await` the operation `ResponsiveAttachment.fromFile(coverImage)` as the uploaded image is being temporarily persisted during the `fromFile` operation. This is a bit different from the approach of the `attachment-lite` add-on. In order to offer a uniform syntax you are required to also await the method `ResponsiveAttachment.fromBuffer`. 165 | 166 | The `ResponsiveAttachment.fromFile` or `ResponsiveAttachment.fromBuffer` static method creates an instance of the `ResponsiveAttachment` class from the uploaded image or provider buffer. When you persist the model to the database, the `adonis-responsive-attachment` add-on will write the file or buffer to the disk and generate optimised responsive images and thumbnails from the original image. 167 | 168 | ### Handling updates 169 | You can update the property with a newly image, and the package will take care of removing the old images and generating and persisting new responsive images. 170 | 171 | ```ts 172 | import { ResponsiveAttachment } from '@ioc:Adonis/Addons/ResponsiveAttachment' 173 | 174 | class PostsController { 175 | public update({ request }: HttpContextContract) { 176 | const post = await Post.firstOrFail() 177 | const coverImage = request.file('coverImage')! 178 | 179 | post.coverImage = coverImage ? await ResponsiveAttachment.fromFile(coverImage) : null 180 | 181 | // Old file will be removed from the disk as well. 182 | await post.save() 183 | } 184 | } 185 | ``` 186 | 187 | Or using the `fromBuffer` method: 188 | 189 | ```ts 190 | import { ResponsiveAttachment } from '@ioc:Adonis/Addons/ResponsiveAttachment' 191 | import { readFile } from 'fs/promises' 192 | 193 | class UsersController { 194 | public store() { 195 | const buffer = await readFile(join(__dirname, '../me.jpeg')) 196 | 197 | const user = await User.firstOrFail() 198 | user.avatar = buffer ? await ResponsiveAttachment.fromBuffer(buffer) : null 199 | 200 | // Old file will be removed from the disk as well. 201 | await user.save() 202 | } 203 | } 204 | ``` 205 | 206 | Similarly, assign `null` value to the model property to delete the file without assigning a new file. 207 | 208 | Also, make sure you update the property type on the model to be `null` as well. 209 | 210 | ```ts 211 | class Post extends BaseModel { 212 | @responsiveAttachment() 213 | public coverImage: ResponsiveAttachmentContract | null 214 | } 215 | ``` 216 | 217 | ```ts 218 | const post = await Post.first() 219 | post.coverImage = null 220 | 221 | // Removes the original and responsive images from the disk 222 | await post.save() 223 | ``` 224 | 225 | ### Handling deletes 226 | Upon deleting the model instance, all the related original and responsive images will be removed from the disk. 227 | 228 | > Do note: For attachment lite to delete files, you will have to use the `modelInstance.delete` method. Using `delete` on the query builder will not work. 229 | 230 | ```ts 231 | const post = await Post.first() 232 | 233 | // Removes any image attachments related to this post 234 | await post.delete() 235 | ``` 236 | 237 | ## The `responsiveAttachment` Decorator Options 238 | 239 | The `responsiveAttachment` decorator accepts the following options: 240 | 241 | 1. `disk` - string, 242 | 2. `folder` - string, 243 | 3. `breakpoints` - object, 244 | 4. `forceFormat` - "jpeg" | "png" | "webp" | "tiff" | "avif", 245 | 5. `optimizeSize` - boolean, 246 | 6. `optimizeOrientation` - boolean, 247 | 7. `responsiveDimensions` - boolean | Option, 248 | 8. `preComputeUrls` - boolean, 249 | 9. `disableThumbnail` - boolean. 250 | 10. `keepOriginal` - boolean. 251 | 11. `blurhash` - Option 252 | 253 | Let's discuss these options 254 | 255 | ### 1. Specifying disk with the `disk` option 256 | 257 | By default, all images are written/deleted from the default disk. However, you can specify a custom disk at the time of using the `responsiveAttachment` decorator. 258 | 259 | > The `disk` property value is never persisted to the database. It means, if you first define the disk as `s3`, upload a few files and then change the disk value to `gcs`, the package will look for files using the `gcs` disk. 260 | 261 | ```ts 262 | class Post extends BaseModel { 263 | @responsiveAttachment({ disk: 's3' }) 264 | public coverImage: ResponsiveAttachmentContract 265 | } 266 | ``` 267 | 268 | ### 2. Specifying the Folder with the `folder` option 269 | 270 | You can also store files inside the subfolder by defining the `folder` property as follows. 271 | 272 | ```ts 273 | class Page extends BaseModel { 274 | @responsiveAttachment({ folder: 'cover-images/pages' }) 275 | public coverImage: ResponsiveAttachmentContract 276 | } 277 | 278 | // or 279 | class Post extends BaseModel { 280 | @responsiveAttachment({ folder: 'cover-images/posts' }) 281 | public coverImage: ResponsiveAttachmentContract 282 | } 283 | ``` 284 | 285 | ### 3. The `breakpoints` 286 | 287 | The `breakpoints` option accepts an object which contains the definition for the breakpoints for the generation of responsive images. By default, it has the following value: 288 | 289 | ```ts 290 | { 291 | large: 1000, 292 | medium: 750, 293 | small: 500, 294 | } 295 | ``` 296 | 297 | With the above default values, the `adonis-responsive-attachment` add-on will generate three (3) responsive images whose widths are exactly `1000px`, `750px`, and `500px`. 298 | 299 | In addition, the `adonis-responsive-attachment` add-on will generate a thumbnail width the following resize options: 300 | 301 | ```ts 302 | { 303 | width: 245, 304 | height: 156, 305 | fit: 'inside' as sharp.FitEnum['inside'], 306 | } 307 | ``` 308 | 309 | This means that if the width of the original image is greater than `245px` or the height of th original image is greater than `156px`, a thumbnail will be generated with the `inside` fit type. Learn more [here](https://sharp.pixelplumbing.com/api-resize#resize). 310 | 311 | If you need to customise the `breakpoints` options, you need to overwrite the default properties `large`, `medium`, and `small` with your own values. You can also add new properties to the default ones. 312 | 313 | ```ts 314 | class Post extends BaseModel { 315 | @responsiveAttachment( 316 | { 317 | breakpoints: { 318 | xlarge: 1400, // This is a custom/extra breakpoint 319 | large: 1050, // Make sure you overwrite `large` 320 | medium: 800, // Make sure you overwrite `medium` 321 | small: 550, // Make sure you overwrite `small` 322 | } 323 | } 324 | ) 325 | public coverImage: ResponsiveAttachmentContract 326 | } 327 | 328 | const post = await Post.findOrFail(1) 329 | post.coverImage.name // exists 330 | post.coverImage.breakpoints.thumbnail.name // exists 331 | post.coverImage.breakpoints.small.name // exists 332 | post.coverImage.breakpoints.medium.name // exists 333 | post.coverImage.breakpoints.large.name // exists 334 | post.coverImage.breakpoints.xlarge.name // extra breakpoint exists 335 | ``` 336 | 337 | You can also choose to cherry-pick which breakpoint image you want to generate 338 | 339 | ```ts 340 | class Post extends BaseModel { 341 | @responsiveAttachment( 342 | { 343 | breakpoints: { 344 | large: 'off', // Disable the `large` breakpoint 345 | medium: 'off', // Disable the `medium` breakpoint 346 | small: 550, // Make you overwrite `small` 347 | } 348 | } 349 | ) 350 | public coverImage: ResponsiveAttachmentContract 351 | } 352 | 353 | const post = await Post.findOrFail(1) 354 | post.coverImage.name // exists 355 | post.coverImage.breakpoints.thumbnail.name // exists 356 | post.coverImage.breakpoints.small.name // exists 357 | 358 | post.coverImage.breakpoints.medium // does not exist 359 | post.coverImage.breakpoints.large // does not exist 360 | ``` 361 | 362 | ### 4. The `forceFormat` Option 363 | 364 | The `forceFormat` option is used to change the image from one format to another. By default, the `adonis-responsive-attachment` addon will maintain the format of the uploaded image when persisting the original image and generating the responsive images. However, assuming you want to force the conversion of all supported formats to the `webp` format, you can do: 365 | 366 | ```ts 367 | class Post extends BaseModel { 368 | @responsiveAttachment({forceFormat: 'webp'}) 369 | public coverImage: ResponsiveAttachmentContract 370 | } 371 | ``` 372 | 373 | This will persist the original image and generated responsive images in the `webp` format. 374 | 375 | ```js 376 | { 377 | name: 'original_ckw5lpv7v0002egvobe1b0oav.webp', 378 | size: 291.69, 379 | width: 1500, 380 | format: 'webp', 381 | height: 1000, 382 | extname: 'webp', 383 | mimeType: 'image/webp', 384 | url: null, 385 | breakpoints: { 386 | thumbnail: { 387 | name: 'thumbnail_ckw5lpv7v0002egvobe1b0oav.webp', 388 | extname: 'webp', 389 | mimeType: 'image/webp', 390 | width: 234, 391 | height: 156, 392 | size: 7.96, 393 | }, 394 | large: { 395 | name: 'large_ckw5lpv7v0002egvobe1b0oav.webp', 396 | extname: 'webp', 397 | mimeType: 'image/webp', 398 | width: 1000, 399 | height: 667, 400 | size: 129.15, 401 | }, 402 | medium: { 403 | name: 'medium_ckw5lpv7v0002egvobe1b0oav.webp', 404 | extname: 'webp', 405 | mimeType: 'image/webp', 406 | width: 750, 407 | height: 500, 408 | size: 71.65, 409 | }, 410 | small: { 411 | name: 'small_ckw5lpv7v0002egvobe1b0oav.webp', 412 | extname: 'webp', 413 | mimeType: 'image/webp', 414 | width: 500, 415 | height: 333, 416 | size: 32.21, 417 | }, 418 | }, 419 | } 420 | ``` 421 | 422 | ### 5. The `optimizeSize` Option 423 | 424 | The `optimizeSize` option enables the optimisation of the uploaded image and then use the optimised version of the uploaded image to persist the original image and generate the responsive images. By default, this is set to `true`. However, you can disable this behaviour by setting `optimizeSize` to `false`: 425 | 426 | ```ts 427 | class Post extends BaseModel { 428 | @responsiveAttachment({optimizeSize: false}) 429 | public coverImage: ResponsiveAttachmentContract 430 | } 431 | ``` 432 | 433 | ### 6. The `optimizeOrientation` Option 434 | 435 | The `optimizeOrientation` option ensures that the orientation of the uploaded image is corrected through `auto-rotation` if the add-on detects that the orientation is not correct. This option is set to `true` by default but you can disable this behaviour by setting `optimizeOrientation` to `false`: 436 | 437 | ```ts 438 | class Post extends BaseModel { 439 | @responsiveAttachment({optimizeOrientation: false}) 440 | public coverImage: ResponsiveAttachmentContract 441 | } 442 | 443 | const post = await Post.findOrFail(1) 444 | post.coverImage.name // exists 445 | post.coverImage.breakpoints // undefined 446 | ``` 447 | 448 | ### 7. The `responsiveDimensions` Option 449 | 450 | The `responsiveDimensions` option allows the generation of the thumbnail and responsive images from the uploaded image. This option is set to `true` by default but if you do not need to generate responsive images, you can disable this behaviour by setting `responsiveDimensions` to `false`: 451 | 452 | ```ts 453 | class Post extends BaseModel { 454 | @responsiveAttachment({responsiveDimensions: false}) 455 | public coverImage: ResponsiveAttachmentContract 456 | } 457 | ``` 458 | 459 | ### 8. The `preComputeUrls` Option 460 | 461 | Read more about this option in this section: [Using the preComputeUrls Option](#using-the-precomputeurls-option). 462 | 463 | ### 9. The `disableThumbnail` Option 464 | 465 | The `disableThumbnail` option, if set to `true`, allows you to disable the generation of the thumbnail without affecting the generation of other breakpoint images. 466 | 467 | ```ts 468 | class Post extends BaseModel { 469 | @responsiveAttachment({disableThumbnail: true}) 470 | public coverImage: ResponsiveAttachmentContract 471 | } 472 | 473 | const post = await Post.findOrFail(1) 474 | post.coverImage.name // exists 475 | post.coverImage.breakpoints.small.name // exists 476 | post.coverImage.breakpoints.medium.name // exists 477 | post.coverImage.breakpoints.large.name // exists 478 | 479 | post.coverImage.breakpoints.thumbnail // does not exist 480 | ``` 481 | 482 | ### 10. The `keepOriginal` Option 483 | 484 | The `keepOriginal` option allows you to decide whether to keep the original uploaded image or not. If you do not have any need for the original image in the future, there should be no need to keep it. By default `keepOriginal` is `true` but you can disable it by setting it to `false`. 485 | 486 | ```ts 487 | class Post extends BaseModel { 488 | @responsiveAttachment({keepOriginal: false}) 489 | public coverImage: ResponsiveAttachmentContract 490 | } 491 | 492 | const post = await Post.findOrFail(1) 493 | post.coverImage.name // does not exist 494 | post.coverImage.width // does not exist 495 | post.coverImage.format // does not exist 496 | post.coverImage.height // does not exist 497 | post.coverImage.extname // does not exist 498 | post.coverImage.mimeType // does not exist 499 | 500 | post.coverImage.breakpoints.small.name // exists 501 | post.coverImage.breakpoints.medium.name // exists 502 | post.coverImage.breakpoints.large.name // exists 503 | post.coverImage.breakpoints.thumbnail.name // exists 504 | ``` 505 | 506 | ### 11. The `blurhash` Option 507 | 508 | The `blurhash` option is used to enable, disable, and customise the generation of blurhashes (https://blurha.sh/) for the generated responsive formats. Blurhash generation is disabled by default. 509 | 510 | Below is the type for the blurhash option. 511 | 512 | ```typescript 513 | type BlurhashOptions = { 514 | enabled: boolean 515 | componentX?: number 516 | componentY?: number 517 | } 518 | ``` 519 | For more about `componentX` and `componentY` properties [read here](https://github.com/woltapp/blurhash?tab=readme-ov-file#how-do-i-pick-the-number-of-x-and-y-components). 520 | 521 | A responsive format with blurhash looks like this: 522 | 523 | ```javascript 524 | { 525 | name: 'small_avatar_clt8v5bva00267fi1542b3axb.jpg', 526 | extname: 'jpg', 527 | mimeType: 'image/jpeg', 528 | format: 'jpeg', 529 | width: 500, 530 | height: 333, 531 | size: 36.98, 532 | blurhash: 'LnEM,?t7RPbIt:axadj[M|WAj[j[' 533 | } 534 | ``` 535 | 536 | > [!TIP] 537 | > When blurhash is disabled, the `blurhash` property will be `undefined` before serialisation and missing after serialisation. 538 | 539 | ### 12. The `persistentFileNames` Option 540 | 541 | When enabled, the `persistentFileNames` option ensures that filenames of attachments remain constant across updates. This is very useful for public images such as OG images for websites where the names of the images should remain constant across updates to avoid breaking previews of your contents across platforms where they have been previously shared. 542 | 543 | ```ts 544 | class Post extends BaseModel { 545 | @responsiveAttachment({persistentFileNames: true, folder: 'post_images'}) 546 | public ogImage: ResponsiveAttachmentContract 547 | } 548 | 549 | const post = await Post.findOrFail(123) 550 | post.ogImage = await ResponsiveAttachment.fromBuffer(ogImageBuffer1, `post_${post.id}`) 551 | await post.save() 552 | await post.refresh() 553 | 554 | assert.equal(post.ogImage.name, 'post_images/original_post_123.jpg') // true 555 | 556 | post.ogImage = await ResponsiveAttachment.fromBuffer(ogImageBuffer2, `post_${post.id}`) 557 | await post.save() 558 | await post.refresh() 559 | 560 | assert.equal(post.ogImage.name, 'post_images/original_post_123.jpg') // true 561 | assert.isTrue(await Drive.exists(post.ogImage.name)) // true 562 | ``` 563 | 564 | > [!TIP] 565 | > When composing the folder and/or file names for persistent attachments ensure you use attributes of the resources which will not change such as the `id`. 566 | 567 | ## Generating URLs 568 | 569 | By default, the `adonis-responsive-attachment`, will not generate the URLs of the original and responsive images to the JSON metadata which is persisted to the database. This helps reduce the size of the JSON. The same of the JSON will look as shown below. Notice that the root `url` property (which is the URL of the original image) is null while the `url` property is missing in the metadata of the breakpoint images. 570 | 571 | ```js 572 | { 573 | name: 'original_ckw5lpv7v0002egvobe1b0oav.jpg', 574 | size: 291.69, 575 | width: 1500, 576 | format: 'jpeg', 577 | height: 1000, 578 | extname: 'jpg', 579 | mimeType: 'image/jpeg', 580 | url: null, 581 | breakpoints: { 582 | thumbnail: { 583 | name: 'thumbnail_ckw5lpv7v0002egvobe1b0oav.jpg', 584 | extname: 'jpg', 585 | mimeType: 'image/jpeg', 586 | width: 234, 587 | height: 156, 588 | size: 7.96, 589 | }, 590 | large: { 591 | name: 'large_ckw5lpv7v0002egvobe1b0oav.jpg', 592 | extname: 'jpg', 593 | mimeType: 'image/jpeg', 594 | width: 1000, 595 | height: 667, 596 | size: 129.15, 597 | }, 598 | medium: { 599 | name: 'medium_ckw5lpv7v0002egvobe1b0oav.jpg', 600 | extname: 'jpg', 601 | mimeType: 'image/jpeg', 602 | width: 750, 603 | height: 500, 604 | size: 71.65, 605 | }, 606 | small: { 607 | name: 'small_ckw5lpv7v0002egvobe1b0oav.jpg', 608 | extname: 'jpg', 609 | mimeType: 'image/jpeg', 610 | width: 500, 611 | height: 333, 612 | size: 32.21, 613 | }, 614 | }, 615 | } 616 | ``` 617 | 618 | If you want to enable the automatic generation of the URLs of the original and responsive images, you have two options: 619 | 620 | 1. Set `preComputeUrls` option to `true` in the `responsiveAttachment` decorator, 621 | 2. Call the `ResponsiveAttachment.getUrls` method. 622 | 623 | ### Using the `preComputeUrls` Option 624 | 625 | The `preComputeUrls` option when enabled (i.e. set to `true`) will pre-compute the URLs of the original and responsive images when you `find`, `fetch`, or `paginate` the model which the responsive attachment is defined within. For example: 626 | 627 | ```ts 628 | class Post extends BaseModel { 629 | @responsiveAttachment({ preComputeUrls: true }) 630 | public coverImage: ResponsiveAttachmentContract 631 | } 632 | ``` 633 | 634 | #### During a `Fetch` result 635 | 636 | ```ts 637 | const posts = await Post.all() 638 | posts[0].coverImage.url // pre-computed 639 | posts[0].coverImage.breakpoints.thumbnail.url // pre-computed 640 | posts[0].coverImage.breakpoints.small.url // pre-computed 641 | posts[0].coverImage.breakpoints.medium.url // pre-computed 642 | posts[0].coverImage.breakpoints.large.url // pre-computed 643 | posts[0].coverImage.urls // pre-computed 644 | ``` 645 | 646 | #### During a `Find` result 647 | 648 | ```ts 649 | const post = await Post.findOrFail(1) 650 | post.coverImage.url // pre-computed 651 | post.coverImage.breakpoints.thumbnail.url // pre-computed 652 | post.coverImage.breakpoints.small.url // pre-computed 653 | post.coverImage.breakpoints.medium.url // pre-computed 654 | post.coverImage.breakpoints.large.url // pre-computed 655 | posts.coverImage.urls // pre-computed 656 | ``` 657 | 658 | #### During a `Pagination` result 659 | 660 | ```ts 661 | const posts = await Post.query.paginate(1) 662 | posts[0].coverImage.url // pre-computed 663 | posts[0].coverImage.breakpoints.thumbnail.url // pre-computed 664 | posts[0].coverImage.breakpoints.small.url // pre-computed 665 | posts[0].coverImage.breakpoints.medium.url // pre-computed 666 | posts[0].coverImage.breakpoints.large.url // pre-computed 667 | posts[0].coverImage.urls // pre-computed 668 | ``` 669 | 670 | The `preComputeUrl` property will generate the URLs and set it on the ResponsiveAttachment class instance. Also, a signed URL is generated when the disk is **private**, and a normal URL is generated when the disk is **public**. 671 | 672 | Pre-computation stores a JSON with `url` properties for the original and responsive images to the database. Typically, the JSON will look like this: 673 | 674 | ```ts 675 | { 676 | name: 'original_ckw5lpv7v0002egvobe1b0oav.jpg', 677 | size: 291.69, 678 | width: 1500, 679 | format: 'jpeg', 680 | height: 1000, 681 | extname: 'jpg', 682 | mimeType: 'image/jpeg', 683 | url: '/uploads/original_ckw5lpv7v0002egvobe1b0oav.jpg?signature=eyJtZXNzYWdlIjoiL3VwbG9hZHMvb3JpZ2luYWxfY2t3NWxwdjd2MDAwMmVndm9iZTFiMG9hdi5qcGcifQ.ieXMlaRb8izlREvJ0E9iMY0I3iedalmv-pvOUIrfEZc', 684 | breakpoints: { 685 | thumbnail: { 686 | name: 'thumbnail_ckw5lpv7v0002egvobe1b0oav.jpg', 687 | extname: 'jpg', 688 | mimeType: 'image/jpeg', 689 | width: 234, 690 | height: 156, 691 | size: 7.96, 692 | url: '/uploads/thumbnail_ckw5lpv7v0002egvobe1b0oav.jpg?signature=eyJtZXNzYWdlIjoiL3VwbG9hZHMvdGh1bWJuYWlsX2NrdzVscHY3djAwMDJlZ3ZvYmUxYjBvYXYuanBnIn0.RGGimHh6NuyPrB2ZgmudE7rH4RRCT3NL7kex9EmSyIo', 693 | }, 694 | large: { 695 | name: 'large_ckw5lpv7v0002egvobe1b0oav.jpg', 696 | extname: 'jpg', 697 | mimeType: 'image/jpeg', 698 | width: 1000, 699 | height: 667, 700 | size: 129.15, 701 | url: '/uploads/large_ckw5lpv7v0002egvobe1b0oav.jpg?signature=eyJtZXNzYWdlIjoiL3VwbG9hZHMvbGFyZ2VfY2t3NWxwdjd2MDAwMmVndm9iZTFiMG9hdi5qcGcifQ.eNC8DaqYCYd4khKhqS7DKI66SsLpD-vyVIaP8rzMmAA', 702 | }, 703 | medium: { 704 | name: 'medium_ckw5lpv7v0002egvobe1b0oav.jpg', 705 | extname: 'jpg', 706 | mimeType: 'image/jpeg', 707 | width: 750, 708 | height: 500, 709 | size: 71.65, 710 | url: '/uploads/medium_ckw5lpv7v0002egvobe1b0oav.jpg?signature=eyJtZXNzYWdlIjoiL3VwbG9hZHMvbWVkaXVtX2NrdzVscHY3djAwMDJlZ3ZvYmUxYjBvYXYuanBnIn0.2ADmssxFC0vxmq4gJEgjb9Fxo1qcQ6tMVeKBqZ1ENkM', 711 | }, 712 | small: { 713 | name: 'small_ckw5lpv7v0002egvobe1b0oav.jpg', 714 | extname: 'jpg', 715 | mimeType: 'image/jpeg', 716 | width: 500, 717 | height: 333, 718 | size: 32.21, 719 | url: '/uploads/small_ckw5lpv7v0002egvobe1b0oav.jpg?signature=eyJtZXNzYWdlIjoiL3VwbG9hZHMvc21hbGxfY2t3NWxwdjd2MDAwMmVndm9iZTFiMG9hdi5qcGcifQ.I8fwMRwY5azvlS_8B0K40BWKQNLuS-HqCB_3RXryOok', 720 | }, 721 | }, 722 | } 723 | ``` 724 | 725 | ### Using the `ResponsiveAttachment.getUrls` Method 726 | 727 | If you manually generate signed or un-signed URLs for a given image attachment using the `getUrls` method. This method calls the `ResponsiveAttachment.preComputeUrls` method internally to compute the URLs of original and responsive images and returns the result as an object containing the various URLs. Please note that `getUrls` will not merge the returned URLs into the image attachment object. `getUrls` could be useful when you want to return just the URLs of the image attachment, that is, you do not want to return the parent model of the attachment column. 728 | 729 | ```ts 730 | // For unsigned URLs, do not pass in any options 731 | const urls = await post.coverImage.getUrls() 732 | // After 733 | urls.url // computed 734 | urls.breakpoints.thumbnail.url // computed 735 | urls.breakpoints.small.url // computed 736 | urls.breakpoints.medium.url // computed 737 | urls.breakpoints.large.url // computed 738 | ``` 739 | 740 | ```ts 741 | // For signed URLs, you can pass in signing options. 742 | // See the options at: https://docs.adonisjs.com/guides/drive#getsignedurl 743 | const urls = await post.coverImage.getUrls({ expiresIn: '30mins' }) 744 | // or 745 | const urls = await post.coverImage.getUrls({ 746 | contentType: 'application/json', 747 | contentDisposition: 'attachment', 748 | }) 749 | // After 750 | urls.url // computed as a signed URL 751 | urls.breakpoints.thumbnail.url // computed as a signed URL 752 | urls.breakpoints.small.url // computed as a signed URL 753 | urls.breakpoints.medium.url // computed as a signed URL 754 | urls.breakpoints.large.url // computed as a signed URL 755 | ``` 756 | 757 | To address this use case, you can opt for pre-computing URLs 758 | 759 | ### Pre-Compute URLs on Demand 760 | 761 | We recommend not enabling the `preComputeUrls` option when you need the URLs for just one or two queries and not within the rest of your application. 762 | 763 | For those couple of queries, you can manually compute the URLs within the controller. Here's a small helper method that you can drop on the model directly. 764 | 765 | ```ts 766 | class Post extends BaseModel { 767 | public static async preComputeUrls(models: Post | Post[]) { 768 | if (Array.isArray(models)) { 769 | await Promise.all(models.map((model) => this.preComputeUrls(model))) 770 | return 771 | } 772 | 773 | await models.avatar?.computeUrls() 774 | await models.coverImage?.computeUrls() 775 | } 776 | } 777 | ``` 778 | 779 | And now use it as follows. 780 | 781 | ```ts 782 | const posts = await Post.all() 783 | await Post.preComputeUrls(posts) 784 | 785 | return posts 786 | ``` 787 | 788 | Or for a single post 789 | 790 | ```ts 791 | const post = await Post.findOrFail(1) 792 | await Post.preComputeUrls(post) 793 | 794 | return post 795 | ``` 796 | 797 | ### Error Handling 798 | 799 | If you are using the Adonis Responsive Attachment with remote file storage services like Amazon S3, there would be occasions where your application will encounter errors from the remote service. Errors such as 404 errors when the image file is not found (maybe, due to expiration) is very common. The add-on gracefully handles such errors internally and logs the complete error object to the console via the Adonis Logger. Without error handling, the entire HTTP request will be aborted which leads to very poor experience for your users. 800 | 801 | If you had directly called the `computeUrls` method within your application, you should implement error handling around those calls. For example: 802 | 803 | ```typescript 804 | public static async preComputeUrls(models: UserProfile | UserProfile[]) { 805 | if (Array.isArray(models)) { 806 | await Promise.all(models.map((model) => this.preComputeUrls(model))) 807 | return 808 | } 809 | 810 | // Error handling around the `computeUrls` call 811 | await models.profile_picture_2?.computeUrls().catch((error) => { 812 | Logger.error('User Profile Picture error: %o', error) 813 | }) 814 | } 815 | ``` 816 | 817 | ### Image Dimensions Validations 818 | 819 | The Adonis Responsive Attachment add-on (from v1.6.0) comes with a set of validation rules for validating image dimensions. The validations hook into the Adonisjs Validation module so you can expect the same performance as other Adonisjs validation rules. 820 | 821 | The following validation rules are available: 822 | 823 | 1. `maxImageWidth` rule, 824 | 1. `maxImageHeight` rule, 825 | 1. `minImageWidth` rule, 826 | 1. `minImageHeight` rule, and 827 | 1. `imageAspectRatio` rule. 828 | 829 | #### Example usage 830 | 831 | Each rule expects a single `number` parameter to be provided. Each parameter is injected into the custom validation message via their respect rule names. See the custom message bag in the validation schema below. 832 | 833 | ```typescript 834 | await ctx.request.validate({ 835 | schema: schema.create({ 836 | avatar: schema.file(undefined, [ 837 | rules.maxImageWidth(1000), 838 | rules.maxImageHeight(520), 839 | ]) 840 | }), 841 | messages: { 842 | 'avatar.maxImageHeight': 'Maximum image height is {{ options.maxImageHeight }}', 843 | 'avatar.maxImageWidth': 'Maximum image width is {{ options.maxImageWidth }}', 844 | } 845 | }) 846 | ``` 847 | 848 | You are free to apply all the available rules to a single request key if your validation logic requires such strictness. 849 | 850 | 851 | [github-actions-image]: https://img.shields.io/github/workflow/status/ndianabasi/adonis-responsive-attachment/test?style=for-the-badge 852 | [github-actions-url]: https://github.com/ndianabasi/adonis-responsive-attachment/actions/workflows/test.yml "github-actions" 853 | 854 | [npm-image]: https://img.shields.io/npm/v/adonis-responsive-attachment.svg?style=for-the-badge&logo=npm 855 | [npm-url]: https://npmjs.org/package/adonis-responsive-attachment "npm" 856 | 857 | [license-image]: https://img.shields.io/npm/l/adonis-responsive-attachment?color=blueviolet&style=for-the-badge 858 | [license-url]: LICENSE.md "license" 859 | 860 | [typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript 861 | [typescript-url]: "typescript" 862 | -------------------------------------------------------------------------------- /Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ndianabasi/adonis-responsive-attachment/81b284979a04b1daf958d1fe0f151f99235f2ef1/Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg -------------------------------------------------------------------------------- /Statue-of-Sardar-Vallabhbhai-Patel-150x100.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ndianabasi/adonis-responsive-attachment/81b284979a04b1daf958d1fe0f151f99235f2ef1/Statue-of-Sardar-Vallabhbhai-Patel-150x100.jpg -------------------------------------------------------------------------------- /Statue-of-Sardar-Vallabhbhai-Patel-825x550.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ndianabasi/adonis-responsive-attachment/81b284979a04b1daf958d1fe0f151f99235f2ef1/Statue-of-Sardar-Vallabhbhai-Patel-825x550.jpg -------------------------------------------------------------------------------- /adonis-typings/attachment.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * adonis-responsive-attachment 3 | * 4 | * (c) Ndianabasi Udonkang 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | declare module '@ioc:Adonis/Addons/ResponsiveAttachment' { 11 | import type { ColumnOptions } from '@ioc:Adonis/Lucid/Orm' 12 | import type { LoggerContract } from '@ioc:Adonis/Core/Logger' 13 | import type { MultipartFileContract } from '@ioc:Adonis/Core/BodyParser' 14 | import type { 15 | DisksList, 16 | ContentHeaders, 17 | DriverContract, 18 | DriveManagerContract, 19 | } from '@ioc:Adonis/Core/Drive' 20 | 21 | export type Breakpoints = Partial<{ 22 | large: number | 'off' 23 | medium: number | 'off' 24 | small: number | 'off' 25 | }> & 26 | Record 27 | 28 | /** 29 | * Options used to persist the attachment to 30 | * the disk 31 | */ 32 | export type AttachmentOptions = { 33 | disk?: keyof DisksList 34 | folder?: string 35 | keepOriginal?: boolean 36 | breakpoints?: Breakpoints 37 | forceFormat?: 'jpeg' | 'png' | 'webp' | 'avif' | 'tiff' 38 | optimizeSize?: boolean 39 | optimizeOrientation?: boolean 40 | responsiveDimensions?: boolean 41 | disableThumbnail?: boolean 42 | preComputeUrls?: 43 | | boolean 44 | | ((disk: DriverContract, attachment: ResponsiveAttachmentContract) => Promise) 45 | /** 46 | * When enabled, blurhash will be generated for the images 47 | */ 48 | blurhash?: BlurhashOptions 49 | /** 50 | * When enabled, filenames of attachments will be persistent by not generating a random suffix during each upload 51 | */ 52 | persistentFileNames?: boolean 53 | } 54 | 55 | export type BlurhashOptions = { 56 | enabled: boolean 57 | componentX?: number 58 | componentY?: number 59 | } 60 | 61 | export interface ImageAttributes { 62 | /** 63 | * The name is available only when "isPersisted" is true. 64 | */ 65 | name?: string 66 | 67 | /** 68 | * The url is available only when "isPersisted" is true. 69 | */ 70 | url?: string 71 | 72 | /** 73 | * The file size in bytes 74 | */ 75 | size?: number 76 | 77 | /** 78 | * The file extname. Inferred from the BodyParser file extname 79 | * property 80 | */ 81 | extname?: string 82 | 83 | /** 84 | * The file mimetype. 85 | */ 86 | mimeType?: string 87 | 88 | /** 89 | * The width of the image 90 | */ 91 | width?: number 92 | 93 | /** 94 | * The height of the image 95 | */ 96 | height?: number 97 | 98 | /** 99 | * The blurhash of the image 100 | */ 101 | blurhash?: string 102 | 103 | /** 104 | * The format of the image 105 | */ 106 | format?: AttachmentOptions['forceFormat'] 107 | 108 | /** 109 | * The breakpoints object for the image 110 | */ 111 | breakpoints?: Record 112 | } 113 | 114 | /** 115 | * Attachment class represents an attachment data type 116 | * for Lucid models 117 | */ 118 | export interface ResponsiveAttachmentContract extends ImageAttributes { 119 | /** 120 | * The breakpoint objects 121 | */ 122 | breakpoints?: Record 123 | 124 | /** 125 | * The URLs object 126 | */ 127 | urls?: UrlRecords | null 128 | 129 | /** 130 | * "isLocal = true" means the instance is created locally 131 | * using the bodyparser file object 132 | */ 133 | isLocal: boolean 134 | 135 | /** 136 | * Find if the file has been persisted or not. 137 | */ 138 | isPersisted: boolean 139 | 140 | /** 141 | * Find if the file has been deleted or not 142 | */ 143 | isDeleted: boolean 144 | 145 | /** 146 | * Define persistence options 147 | */ 148 | setOptions(options?: AttachmentOptions): this 149 | 150 | /** 151 | * Get current state of attachment options within a responsive 152 | * attachment instance 153 | */ 154 | readonly getOptions: AttachmentOptions 155 | 156 | /** 157 | * Save responsive images to the disk. Results if noop when "this.isLocal = false" 158 | */ 159 | save(): Promise 160 | 161 | /** 162 | * Delete the responsive images from the disk 163 | */ 164 | delete(): Promise 165 | 166 | /** 167 | * Computes the URLs for the responsive images. 168 | * @param options 169 | * @param options.forced Force the URLs to be completed whether 170 | * `preComputedURLs` is true or not 171 | */ 172 | computeUrls( 173 | signedUrlOptions?: ContentHeaders & { expiresIn?: string | number } 174 | ): Promise 175 | 176 | /** 177 | * Returns the signed or unsigned URL for each responsive image 178 | */ 179 | getUrls( 180 | signingOptions?: ContentHeaders & { expiresIn?: string | number } 181 | ): Promise 182 | 183 | /** 184 | * Attachment attributes 185 | * Convert attachment to plain object to be persisted inside 186 | * the database 187 | */ 188 | toObject(): AttachmentAttributes 189 | 190 | /** 191 | * Attachment attributes + url 192 | * Convert attachment to JSON object to be sent over 193 | * the wire 194 | */ 195 | toJSON(): (AttachmentAttributes & (UrlRecords | null)) | null 196 | } 197 | 198 | /** 199 | * File attachment decorator 200 | */ 201 | export type ResponsiveAttachmentDecorator = ( 202 | options?: AttachmentOptions & Partial 203 | ) => ( 204 | target: TTarget, 205 | property: TKey 206 | ) => void 207 | 208 | /** 209 | * Attachment class constructor 210 | */ 211 | export interface AttachmentConstructorContract { 212 | new (attributes: ImageAttributes, file?: MultipartFileContract): ResponsiveAttachmentContract 213 | fromFile(file: MultipartFileContract, fileName?: string): Promise 214 | fromDbResponse(response: string): ResponsiveAttachmentContract 215 | fromBuffer(buffer: Buffer, fileName?: string): Promise 216 | getDrive(): DriveManagerContract 217 | setDrive(drive: DriveManagerContract): void 218 | setLogger(logger: LoggerContract): void 219 | getLogger(): LoggerContract 220 | } 221 | 222 | export const responsiveAttachment: ResponsiveAttachmentDecorator 223 | export const ResponsiveAttachment: AttachmentConstructorContract 224 | } 225 | -------------------------------------------------------------------------------- /adonis-typings/container.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * adonis-responsive-attachment 3 | * 4 | * (c) Ndianabasi Udonkang 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | declare module '@ioc:Adonis/Core/Application' { 11 | import ResponsiveAttachment from '@ioc:Adonis/Addons/ResponsiveAttachment' 12 | 13 | interface ContainerBindings { 14 | 'Adonis/Addons/ResponsiveAttachment': typeof ResponsiveAttachment 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /adonis-typings/images.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * adonis-responsive-attachment 3 | * 4 | * (c) Ndianabasi Udonkang 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | declare module '@ioc:Adonis/Addons/ResponsiveAttachment' { 11 | export type ImageBreakpoints = { 12 | thumbnail: ImageAttributes 13 | large: ImageAttributes 14 | medium: ImageAttributes 15 | small: ImageAttributes 16 | } & Record 17 | 18 | export type ImageInfo = { 19 | name?: string 20 | fileName?: string 21 | extname?: string 22 | mimeType?: string 23 | size?: number 24 | path?: string 25 | buffer?: Buffer 26 | width?: number 27 | height?: number 28 | blurhash?: string 29 | /** 30 | * The image format used by sharp to force conversion 31 | * to different file types/formats 32 | */ 33 | format?: AttachmentOptions['forceFormat'] 34 | breakpoints?: Record 35 | url?: string 36 | } 37 | 38 | export type ImageData = { 39 | data: { fileInfo: ImageInfo | ImageInfo[] } 40 | files: AttachedImage 41 | } 42 | 43 | export type AttachedImage = { filePath?: string; name?: string; type?: string; size: number } 44 | 45 | export type EnhancedImageInfo = { absoluteFilePath: string; type: string; size: number } 46 | 47 | export type OptimizedOutput = { 48 | buffer?: Buffer 49 | info?: { 50 | width: number 51 | height: number 52 | size: number 53 | format: AttachmentOptions['forceFormat'] 54 | mimeType: string 55 | extname: string 56 | } 57 | } 58 | 59 | export type ImageDimensions = { 60 | width?: number 61 | height?: number 62 | } 63 | 64 | export type BreakpointFormat = ({ key: keyof ImageBreakpoints } & { file: ImageInfo }) | null 65 | 66 | export type AttachmentAttributes = Partial 67 | 68 | export type FileDimensions = { 69 | width?: number 70 | height?: number 71 | } 72 | 73 | export type UrlRecords = { 74 | url?: string 75 | breakpoints?: Record 76 | } 77 | 78 | export type NameRecords = { 79 | name?: string 80 | breakpoints?: Record 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /adonis-typings/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * adonis-responsive-attachment 3 | * 4 | * (c) Ndianabasi Udonkang 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | /// 11 | /// 12 | /// 13 | /// 14 | -------------------------------------------------------------------------------- /adonis-typings/validator.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * adonis-responsive-attachment 3 | * 4 | * (c) Ndianabasi Udonkang 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | declare module '@ioc:Adonis/Core/Validator' { 11 | import { Rule } from '@ioc:Adonis/Core/Validator' 12 | 13 | export interface Rules { 14 | /** 15 | * Ensure image width does not exceed the specified width. 16 | * 17 | * Provided by the Adonis Responsive Attachment addon. 18 | */ 19 | maxImageWidth(value: number): Rule 20 | /** 21 | * Ensure image height does not exceed the specified height. 22 | * 23 | * Provided by the Adonis Responsive Attachment addon. 24 | */ 25 | maxImageHeight(value: number): Rule 26 | /** 27 | * Ensure image width is above the specified width. 28 | * 29 | * Provided by the Adonis Responsive Attachment addon. 30 | */ 31 | minImageWidth(value: number): Rule 32 | /** 33 | * Ensure image height is above the specified height. 34 | * 35 | * Provided by the Adonis Responsive Attachment addon. 36 | */ 37 | minImageHeight(value: number): Rule 38 | /** 39 | * Ensure image aspect ratio matches the specified aspect ratio 40 | * 41 | * Provided by the Adonis Responsive Attachment addon. 42 | */ 43 | imageAspectRatio(value: number): Rule 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /assets/Cover-Image-Adonis-Responsive-Attachment.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ndianabasi/adonis-responsive-attachment/81b284979a04b1daf958d1fe0f151f99235f2ef1/assets/Cover-Image-Adonis-Responsive-Attachment.jpg -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "core": true, 3 | "license": "MIT", 4 | "services": [ 5 | "github-actions" 6 | ], 7 | "minNodeVersion": "14.17.0", 8 | "probotApps": [ 9 | "stale", 10 | "lock" 11 | ], 12 | "runGhActionsOnWindows": true 13 | } 14 | -------------------------------------------------------------------------------- /examples/index.ts: -------------------------------------------------------------------------------- 1 | import { column, BaseModel } from '@ioc:Adonis/Lucid/Orm' 2 | import { 3 | responsiveAttachment, 4 | ResponsiveAttachmentContract, 5 | } from '@ioc:Adonis/Addons/ResponsiveAttachment' 6 | 7 | export class User extends BaseModel { 8 | @column() 9 | public id: number 10 | 11 | @column() 12 | public email: string 13 | 14 | @responsiveAttachment() 15 | public avatar: ResponsiveAttachmentContract 16 | } 17 | -------------------------------------------------------------------------------- /japaFile.js: -------------------------------------------------------------------------------- 1 | require('@adonisjs/require-ts/build/register') 2 | 3 | const { configure } = require('japa') 4 | 5 | configure({ 6 | files: ['test/**/*.spec.ts'], 7 | timeout: 10000, 8 | }) 9 | 10 | Error.stackTraceLimit = 200 11 | -------------------------------------------------------------------------------- /nyc.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const defaultExclude = require('@istanbuljs/schema/default-exclude') 4 | 5 | module.exports = { 6 | exclude: ['test-helpers/**'].concat(defaultExclude), 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "adonis-responsive-attachment", 3 | "version": "1.8.2", 4 | "description": "Generate and persist optimised and responsive breakpoint images on the fly in your AdonisJS application.", 5 | "main": "build/providers/ResponsiveAttachmentProvider.js", 6 | "files": [ 7 | "build/adonis-typings", 8 | "build/providers", 9 | "build/src" 10 | ], 11 | "typings": "./build/adonis-typings/index.d.ts", 12 | "scripts": { 13 | "mrm": "mrm --preset=@adonisjs/mrm-preset", 14 | "pretest": "npm run lint", 15 | "test": "nyc node japaFile.js", 16 | "clean": "del build", 17 | "compile": "npm run lint && npm run clean && tsc", 18 | "build": "npm run compile", 19 | "prepublishOnly": "npm run build", 20 | "lint": "eslint . --ext=.ts --fix", 21 | "format": "prettier --write .", 22 | "commit": "git-cz", 23 | "release": "np", 24 | "version": "npm run build", 25 | "sync-labels": "github-label-sync --labels ./node_modules/@adonisjs/mrm-preset/gh-labels.json ndianabasi/adonis-responsive-attachment" 26 | }, 27 | "keywords": [ 28 | "adonisjs", 29 | "lucid", 30 | "attachment", 31 | "adonis-responsive-attachment", 32 | "image-attachment", 33 | "image-manipulation", 34 | "responsive-images", 35 | "responsive-image", 36 | "resize-images", 37 | "optimize-images", 38 | "image", 39 | "sharp" 40 | ], 41 | "author": "Ndianabasi Udonkang", 42 | "license": "MIT", 43 | "devDependencies": { 44 | "@adonisjs/core": "^5.9.0", 45 | "@adonisjs/lucid": "^18.3.0", 46 | "@adonisjs/mrm-preset": "^5.0.3", 47 | "@adonisjs/require-ts": "^2.0.13", 48 | "@poppinss/dev-utils": "^2.0.3", 49 | "@types/lodash": "^4.14.191", 50 | "@types/node": "^18.11.10", 51 | "@types/sharp": "^0.31.0", 52 | "@types/supertest": "^2.0.12", 53 | "commitizen": "^4.2.5", 54 | "cz-conventional-changelog": "^3.3.0", 55 | "del-cli": "^5.0.0", 56 | "eslint": "^8.28.0", 57 | "eslint-config-prettier": "^9.1.0", 58 | "eslint-plugin-adonis": "^2.1.1", 59 | "eslint-plugin-prettier": "^5.2.1", 60 | "github-label-sync": "^2.2.0", 61 | "husky": "^8.0.2", 62 | "japa": "^4.0.0", 63 | "mrm": "^4.1.13", 64 | "mysql": "^2.18.1", 65 | "np": "^7.6.2", 66 | "nyc": "^15.1.0", 67 | "prettier": "^3.3.3", 68 | "reflect-metadata": "^0.1.13", 69 | "sqlite3": "^5.1.2", 70 | "supertest": "^6.3.1", 71 | "typescript": "^4.9.3" 72 | }, 73 | "peerDependencies": { 74 | "@adonisjs/core": "^5.9.0", 75 | "@adonisjs/lucid": "^18.3.0" 76 | }, 77 | "config": { 78 | "commitizen": { 79 | "path": "cz-conventional-changelog" 80 | } 81 | }, 82 | "np": { 83 | "yolo": true, 84 | "contents": ".", 85 | "anyBranch": false, 86 | "branch": "develop", 87 | "releaseDraft": true, 88 | "message": "chore: bump version to %s 🚀" 89 | }, 90 | "repository": { 91 | "type": "git", 92 | "url": "git+https://github.com/ndianabasi/adonis-responsive-attachment.git" 93 | }, 94 | "bugs": { 95 | "url": "https://github.com/ndianabasi/adonis-responsive-attachment/issues" 96 | }, 97 | "homepage": "https://github.com/ndianabasi/adonis-responsive-attachment#readme", 98 | "dependencies": { 99 | "@poppinss/utils": "^5.0.0", 100 | "blurhash": "^2.0.5", 101 | "detect-file-type": "^0.2.8", 102 | "lodash": "^4.17.21", 103 | "sharp": "^0.33.5" 104 | }, 105 | "adonisjs": { 106 | "types": "adonis-responsive-attachment", 107 | "providers": [ 108 | "adonis-responsive-attachment" 109 | ] 110 | }, 111 | "publishConfig": { 112 | "access": "public", 113 | "tag": "latest" 114 | }, 115 | "packageManager": "yarn@1.22.21" 116 | } 117 | -------------------------------------------------------------------------------- /providers/ResponsiveAttachmentProvider.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * adonis-responsive-attachment 3 | * 4 | * (c) Ndianabasi Udonkang 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { ApplicationContract } from '@ioc:Adonis/Core/Application' 11 | 12 | export default class ResponsiveAttachmentProvider { 13 | constructor(protected application: ApplicationContract) {} 14 | 15 | /** 16 | * Extends the validator by defining validation rules 17 | */ 18 | private defineValidationRules() { 19 | /** 20 | * Do not register validation rules in the "repl" environment 21 | */ 22 | if (this.application.environment === 'repl') { 23 | return 24 | } 25 | 26 | this.application.container.withBindings( 27 | ['Adonis/Core/Validator', 'Adonis/Core/Logger'], 28 | (Validator, Logger) => { 29 | const { extendValidator } = require('../src/Bindings/Validator') 30 | extendValidator(Validator.validator, Logger) 31 | } 32 | ) 33 | } 34 | 35 | public register() { 36 | this.application.container.bind('Adonis/Addons/ResponsiveAttachment', () => { 37 | const { ResponsiveAttachment } = require('../src/Attachment') 38 | const { responsiveAttachment } = require('../src/Attachment/decorator') 39 | 40 | return { 41 | ResponsiveAttachment: ResponsiveAttachment, 42 | responsiveAttachment: responsiveAttachment, 43 | } 44 | }) 45 | } 46 | 47 | public boot() { 48 | this.application.container.withBindings( 49 | ['Adonis/Addons/ResponsiveAttachment', 'Adonis/Core/Drive', 'Adonis/Core/Logger'], 50 | (ResponsiveAttachmentAddon, Drive, Logger) => { 51 | ResponsiveAttachmentAddon.ResponsiveAttachment.setDrive(Drive) 52 | ResponsiveAttachmentAddon.ResponsiveAttachment.setLogger(Logger) 53 | } 54 | ) 55 | 56 | this.defineValidationRules() 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Attachment/decorator.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * adonis-responsive-attachment 3 | * 4 | * (c) Ndianabasi Udonkang 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | /// 11 | 12 | import { merge } from 'lodash' 13 | import { ResponsiveAttachment } from './index' 14 | import type { LoggerContract } from '@ioc:Adonis/Core/Logger' 15 | import type { LucidModel, LucidRow } from '@ioc:Adonis/Lucid/Orm' 16 | import type { 17 | AttachmentOptions, 18 | ResponsiveAttachmentContract, 19 | ResponsiveAttachmentDecorator, 20 | } from '@ioc:Adonis/Addons/ResponsiveAttachment' 21 | import { getDefaultBlurhashOptions } from '../Helpers/image_manipulation_helper' 22 | 23 | /** 24 | * Default breakpoint options 25 | */ 26 | export const DEFAULT_BREAKPOINTS = { 27 | large: 1000, 28 | medium: 750, 29 | small: 500, 30 | } 31 | 32 | /** 33 | * Persist attachment for a given attachment property 34 | */ 35 | async function persistAttachment( 36 | modelInstance: LucidRow, 37 | property: string, 38 | options?: AttachmentOptions 39 | ) { 40 | const existingFile = modelInstance.$original[property] as ResponsiveAttachment 41 | const newFile = modelInstance[property] as ResponsiveAttachment 42 | 43 | /** 44 | * Skip when the attachment property hasn't been updated 45 | */ 46 | if (existingFile === newFile) { 47 | return 48 | } 49 | 50 | /** 51 | * There was an existing file, but there is no new file. Hence we must 52 | * remove the existing file. 53 | */ 54 | if (existingFile && !newFile) { 55 | existingFile.setOptions(merge(options, existingFile.getOptions)) 56 | modelInstance['attachments'].detached.push(existingFile) 57 | return 58 | } 59 | 60 | /** 61 | * If there is a new file and its local then we must save this 62 | * file. 63 | */ 64 | if (newFile && newFile.isLocal) { 65 | newFile.setOptions(merge(options, newFile.getOptions)) 66 | modelInstance['attachments'].attached.push(newFile) 67 | 68 | /** 69 | * If there was an existing file, then we must get rid of it 70 | */ 71 | if (existingFile && !newFile.getOptions?.persistentFileNames) { 72 | existingFile.setOptions(merge(options, newFile.getOptions)) 73 | modelInstance['attachments'].detached.push(existingFile) 74 | } 75 | 76 | /** 77 | * Also write the file to the disk right away 78 | */ 79 | const finalImageData = await newFile.save() 80 | 81 | /** 82 | * Use this `finalImageData` as the value to be persisted 83 | * on the column for the `property` 84 | */ 85 | modelInstance[property] = finalImageData 86 | } 87 | } 88 | 89 | /** 90 | * During commit, we should cleanup the old detached files 91 | */ 92 | async function commit(modelInstance: LucidRow) { 93 | await Promise.allSettled( 94 | modelInstance['attachments'].detached.map((attachment: ResponsiveAttachmentContract) => { 95 | return attachment.delete() 96 | }) 97 | ) 98 | } 99 | 100 | /** 101 | * During rollback we should remove the attached files. 102 | */ 103 | async function rollback(modelInstance: LucidRow) { 104 | await Promise.allSettled( 105 | modelInstance['attachments'].attached.map((attachment: ResponsiveAttachmentContract) => { 106 | return attachment.delete() 107 | }) 108 | ) 109 | } 110 | 111 | /** 112 | * Implementation of the model save method. 113 | */ 114 | async function saveWithAttachments() { 115 | this['attachments'] = this['attachments'] || { 116 | attached: [], 117 | detached: [], 118 | } 119 | 120 | /** 121 | * Persist attachments before saving the model to the database. This 122 | * way if file saving fails we will not write anything to the 123 | * database 124 | */ 125 | await Promise.all( 126 | this.constructor['attachments'].map( 127 | (attachmentField: { property: string; options?: AttachmentOptions }) => 128 | persistAttachment(this, attachmentField.property, attachmentField.options) 129 | ) 130 | ) 131 | 132 | try { 133 | await this['originalSave']() 134 | 135 | /** 136 | * If model is using transaction, then wait for the transaction 137 | * to settle 138 | */ 139 | if (this.$trx) { 140 | this.$trx!.after('commit', () => commit(this)) 141 | this.$trx!.after('rollback', () => rollback(this)) 142 | } else { 143 | await commit(this) 144 | } 145 | } catch (error) { 146 | await rollback(this) 147 | throw error 148 | } 149 | } 150 | 151 | /** 152 | * Implementation of the model delete method. 153 | */ 154 | async function deleteWithAttachments() { 155 | this['attachments'] = this['attachments'] || { 156 | attached: [], 157 | detached: [], 158 | } 159 | 160 | /** 161 | * Mark all attachments for deletion 162 | */ 163 | this.constructor['attachments'].forEach( 164 | (attachmentField: { property: string; options?: AttachmentOptions }) => { 165 | if (this[attachmentField.property]) { 166 | this['attachments'].detached.push(this[attachmentField.property]) 167 | } 168 | } 169 | ) 170 | 171 | await this['originalDelete']() 172 | 173 | /** 174 | * If model is using transaction, then wait for the transaction 175 | * to settle 176 | */ 177 | if (this.$trx) { 178 | this.$trx!.after('commit', () => commit(this)) 179 | } else { 180 | await commit(this) 181 | } 182 | } 183 | 184 | /** 185 | * Pre-compute URLs after a row has been fetched from the database 186 | */ 187 | async function afterFind(modelInstance: LucidRow) { 188 | await Promise.all( 189 | modelInstance.constructor['attachments'].map( 190 | (attachmentField: { property: string; options?: AttachmentOptions }) => { 191 | if (modelInstance[attachmentField.property]) { 192 | ;(modelInstance[attachmentField.property] as ResponsiveAttachment).setOptions( 193 | attachmentField.options 194 | ) 195 | if (modelInstance[attachmentField.property] instanceof ResponsiveAttachment) { 196 | return (modelInstance[attachmentField.property] as ResponsiveAttachment) 197 | .computeUrls() 198 | .catch((error) => { 199 | const logger: LoggerContract = 200 | modelInstance[attachmentField.property].loggerInstance 201 | logger.error('Adonis Responsive Attachment error: %o', error) 202 | }) 203 | } 204 | 205 | return null 206 | } 207 | } 208 | ) 209 | ) 210 | } 211 | 212 | /** 213 | * Pre-compute URLs after more than one rows are fetched 214 | */ 215 | async function afterFetch(modelInstances: LucidRow[]) { 216 | await Promise.all(modelInstances.map((row) => afterFind(row))) 217 | } 218 | 219 | /** 220 | * Attachment decorator 221 | */ 222 | export const responsiveAttachment: ResponsiveAttachmentDecorator = (options) => { 223 | return function (target, property) { 224 | const Model = target.constructor as LucidModel 225 | Model.boot() 226 | 227 | const enableBlurhash = getDefaultBlurhashOptions(options) 228 | 229 | /** 230 | * Separate attachment options from the column options 231 | */ 232 | const { 233 | disk, 234 | folder, 235 | keepOriginal = true, 236 | preComputeUrls = false, 237 | breakpoints = DEFAULT_BREAKPOINTS, 238 | forceFormat, 239 | optimizeOrientation = true, 240 | optimizeSize = true, 241 | responsiveDimensions = true, 242 | disableThumbnail = false, 243 | persistentFileNames = false, 244 | ...columnOptions 245 | } = options || {} 246 | 247 | /** 248 | * Define attachments array on the model constructor 249 | */ 250 | Model.$defineProperty('attachments' as any, [], 'inherit') 251 | 252 | /** 253 | * Push current column (the one using the @attachment decorator) to 254 | * the attachments array 255 | */ 256 | Model['attachments'].push({ 257 | property, 258 | options: { 259 | disk, 260 | folder, 261 | keepOriginal, 262 | preComputeUrls, 263 | breakpoints, 264 | forceFormat, 265 | optimizeOrientation, 266 | optimizeSize, 267 | responsiveDimensions, 268 | disableThumbnail, 269 | blurhash: enableBlurhash, 270 | persistentFileNames, 271 | }, 272 | }) 273 | 274 | /** 275 | * Define the property as a column too 276 | */ 277 | Model.$addColumn(property, { 278 | ...columnOptions, 279 | consume: (value) => (value ? ResponsiveAttachment.fromDbResponse(value) : null), 280 | prepare: (value) => (value ? JSON.stringify(value.toObject()) : null), 281 | serialize: (value) => (value ? value.toJSON() : null), 282 | }) 283 | 284 | /** 285 | * Overwrite the "save" method to save the model with attachments. We 286 | * will get rid of it once models will have middleware support 287 | */ 288 | if (!Model.prototype['originalSave']) { 289 | Model.prototype.originalSave = Model.prototype.save 290 | Model.prototype.save = saveWithAttachments 291 | } 292 | 293 | /** 294 | * Overwrite the "delete" method to delete files when row is removed. We 295 | * will get rid of it once models will have middleware support 296 | */ 297 | if (!Model.prototype['originalDelete']) { 298 | Model.prototype.originalDelete = Model.prototype.delete 299 | Model.prototype.delete = deleteWithAttachments 300 | } 301 | 302 | /** 303 | * Do not register hooks when "preComputeUrl" is not defined 304 | * inside the options 305 | */ 306 | if (!options?.preComputeUrls) { 307 | return 308 | } 309 | 310 | /** 311 | * Registering all hooks only once 312 | */ 313 | if (!Model.$hooks.has('after', 'find', afterFind)) { 314 | Model.after('find', afterFind) 315 | } 316 | if (!Model.$hooks.has('after', 'fetch', afterFetch)) { 317 | Model.after('fetch', afterFetch) 318 | } 319 | if (!Model.$hooks.has('after', 'paginate', afterFetch)) { 320 | Model.after('paginate', afterFetch) 321 | } 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /src/Attachment/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * adonis-responsive-attachment 3 | * 4 | * (c) Ndianabasi Udonkang 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | /// 11 | 12 | import detect from 'detect-file-type' 13 | import { readFile } from 'fs/promises' 14 | import { merge, isEmpty, assign, set } from 'lodash' 15 | import { LoggerContract } from '@ioc:Adonis/Core/Logger' 16 | import type { MultipartFileContract } from '@ioc:Adonis/Core/BodyParser' 17 | import { DriveManagerContract, ContentHeaders, Visibility } from '@ioc:Adonis/Core/Drive' 18 | import { 19 | allowedFormats, 20 | generateBreakpointImages, 21 | generateName, 22 | generateThumbnail, 23 | getDimensions, 24 | getMergedOptions, 25 | optimize, 26 | } from '../Helpers/image_manipulation_helper' 27 | import type { 28 | AttachmentOptions, 29 | ResponsiveAttachmentContract, 30 | AttachmentAttributes, 31 | AttachmentConstructorContract, 32 | ImageInfo, 33 | UrlRecords, 34 | ImageBreakpoints, 35 | ImageAttributes, 36 | } from '@ioc:Adonis/Addons/ResponsiveAttachment' 37 | 38 | export const tempUploadFolder = 'image_upload_tmp' 39 | 40 | /** 41 | * Attachment class represents an attachment data type 42 | * for Lucid models 43 | */ 44 | export class ResponsiveAttachment implements ResponsiveAttachmentContract { 45 | private static drive: DriveManagerContract 46 | private static logger: LoggerContract 47 | 48 | /** 49 | * Reference to the drive 50 | */ 51 | public static getDrive() { 52 | return this.drive 53 | } 54 | 55 | /** 56 | * Set the drive instance 57 | */ 58 | public static setDrive(drive: DriveManagerContract) { 59 | this.drive = drive 60 | } 61 | 62 | /** 63 | * Set the logger instance 64 | */ 65 | public static setLogger(logger: LoggerContract) { 66 | this.logger = logger 67 | } 68 | 69 | /** 70 | * Reference to the logger instance 71 | */ 72 | public static getLogger() { 73 | return this.logger 74 | } 75 | 76 | /** 77 | * Create attachment instance from the bodyparser 78 | * file 79 | */ 80 | public static async fromFile(file: MultipartFileContract, fileName?: string) { 81 | if (!file) { 82 | throw new SyntaxError('You should provide a non-falsy value') 83 | } 84 | 85 | if (allowedFormats.includes(file?.subtype as AttachmentOptions['forceFormat']) === false) { 86 | throw new RangeError( 87 | `[Adonis Responsive Attachment] Uploaded file is not an allowable image. Make sure that you uploaded only the following format: "jpeg", "png", "webp", "tiff", and "avif".` 88 | ) 89 | } 90 | 91 | if (!file.tmpPath) { 92 | throw new Error('[Adonis Responsive Attachment] Please provide a valid file') 93 | } 94 | 95 | // Get the file buffer 96 | const buffer = await readFile(file.tmpPath) 97 | 98 | const computedFileName = fileName ? fileName : file.fieldName 99 | 100 | const attributes = { 101 | extname: file.extname!, 102 | mimeType: `${file.type}/${file.subtype}`, 103 | size: file.size, 104 | fileName: computedFileName.replace(/[^\d\w]+/g, '_').toLowerCase(), 105 | } 106 | 107 | return new ResponsiveAttachment(attributes, buffer) as ResponsiveAttachmentContract 108 | } 109 | 110 | /** 111 | * Create attachment instance from the bodyparser via a buffer 112 | */ 113 | public static fromBuffer(buffer: Buffer, name?: string): Promise { 114 | return new Promise((resolve, reject) => { 115 | try { 116 | type BufferProperty = { ext: string; mime: string } 117 | 118 | let bufferProperty: BufferProperty | undefined 119 | 120 | detect.fromBuffer(buffer, function (err: Error | string, result: BufferProperty) { 121 | if (err) { 122 | throw new Error(err instanceof Error ? err.message : err) 123 | } 124 | if (!result) { 125 | throw new Error('Please provide a valid file buffer') 126 | } 127 | bufferProperty = result 128 | }) 129 | 130 | const { mime, ext } = bufferProperty! 131 | const subtype = mime.split('/').pop() 132 | 133 | if (allowedFormats.includes(subtype as AttachmentOptions['forceFormat']) === false) { 134 | throw new RangeError( 135 | `Uploaded file is not an allowable image. Make sure that you uploaded only the following format: "jpeg", "png", "webp", "tiff", and "avif".` 136 | ) 137 | } 138 | 139 | const attributes = { 140 | extname: ext, 141 | mimeType: mime, 142 | size: buffer.length, 143 | fileName: name?.replace(/[^\d\w]+/g, '_')?.toLowerCase() ?? '', 144 | } 145 | 146 | return resolve(new ResponsiveAttachment(attributes, buffer) as ResponsiveAttachmentContract) 147 | } catch (error) { 148 | return reject(error) 149 | } 150 | }) 151 | } 152 | 153 | /** 154 | * Create attachment instance from the database response 155 | */ 156 | public static fromDbResponse(response: string | ImageAttributes) { 157 | let attributes: ImageAttributes | null = null 158 | 159 | if (typeof response === 'string') { 160 | try { 161 | attributes = JSON.parse(response) as ImageAttributes 162 | } catch (error) { 163 | ResponsiveAttachment.logger.warn( 164 | '[Adonis Responsive Attachment] Incompatible image data skipped: %s', 165 | response 166 | ) 167 | attributes = null 168 | } 169 | } else { 170 | attributes = response as ImageAttributes 171 | } 172 | 173 | if (!attributes) return null 174 | 175 | const attachment = new ResponsiveAttachment(attributes) 176 | 177 | /** 178 | * Images fetched from DB are always persisted 179 | */ 180 | attachment.isPersisted = true 181 | return attachment 182 | } 183 | 184 | /** 185 | * Attachment options 186 | */ 187 | #options?: AttachmentOptions 188 | 189 | /** 190 | * The generated name of the original file. 191 | * Available only when "isPersisted" is true. 192 | */ 193 | public name?: string 194 | 195 | /** 196 | * The generated url of the original file. 197 | * Available only when "isPersisted" is true. 198 | */ 199 | public url?: string 200 | 201 | /** 202 | * The urls of the original and breakpoint files. 203 | * Available only when "isPersisted" is true. 204 | */ 205 | public urls?: UrlRecords 206 | 207 | /** 208 | * The image size of the original file in bytes 209 | */ 210 | public size?: number 211 | 212 | /** 213 | * The image extname. Inferred from the bodyparser file extname 214 | * property 215 | */ 216 | public extname?: string 217 | 218 | /** 219 | * The image mimetype. 220 | */ 221 | public mimeType?: string 222 | 223 | /** 224 | * The image width. 225 | */ 226 | public width?: number 227 | 228 | /** 229 | * The image height. 230 | */ 231 | public height?: number 232 | 233 | /** 234 | * The image's blurhash. 235 | */ 236 | public blurhash?: string 237 | 238 | /** 239 | * This file name. 240 | */ 241 | public fileName?: string 242 | 243 | /** 244 | * The format or filetype of the image. 245 | */ 246 | public format?: AttachmentOptions['forceFormat'] 247 | 248 | /** 249 | * The format or filetype of the image. 250 | */ 251 | public breakpoints?: Record 252 | 253 | /** 254 | * Find if the image has been persisted or not. 255 | */ 256 | public isPersisted = false 257 | 258 | /** 259 | * Find if the image has been deleted or not 260 | */ 261 | public isDeleted: boolean 262 | 263 | constructor( 264 | attributes: AttachmentAttributes & { fileName?: string }, 265 | private buffer?: Buffer 266 | ) { 267 | this.name = attributes.name 268 | this.size = attributes.size 269 | this.width = attributes.width 270 | this.format = attributes.format 271 | this.blurhash = attributes.blurhash 272 | this.height = attributes.height 273 | this.extname = attributes.extname 274 | this.mimeType = attributes.mimeType 275 | this.url = attributes.url ?? undefined 276 | this.breakpoints = attributes.breakpoints ?? undefined 277 | this.fileName = attributes.fileName ?? '' 278 | this.isLocal = !!this.buffer 279 | } 280 | 281 | public get attributes() { 282 | return { 283 | name: this.name, 284 | size: this.size, 285 | width: this.width, 286 | format: this.format, 287 | height: this.height, 288 | extname: this.extname, 289 | mimeType: this.mimeType, 290 | url: this.url, 291 | breakpoints: this.breakpoints, 292 | buffer: this.buffer, 293 | blurhash: this.blurhash, 294 | } 295 | } 296 | 297 | /** 298 | * "isLocal = true" means the instance is created locally 299 | * using the bodyparser file object 300 | */ 301 | public isLocal = !!this.buffer 302 | 303 | /** 304 | * Returns disk instance 305 | */ 306 | private getDisk() { 307 | const disk = this.#options?.disk 308 | const drive = (this.constructor as AttachmentConstructorContract).getDrive() 309 | return disk ? drive.use(disk) : drive.use() 310 | } 311 | 312 | public get getOptions() { 313 | return this.#options || {} 314 | } 315 | 316 | /** 317 | * Returns disk instance 318 | */ 319 | private get loggerInstance() { 320 | return (this.constructor as AttachmentConstructorContract).getLogger() 321 | } 322 | 323 | /** 324 | * Define persistance options 325 | */ 326 | public setOptions(options?: AttachmentOptions) { 327 | /** 328 | * CRITICAL: Don't set default values here. Only pass along 329 | * just the provided options. The decorator will handle merging 330 | * of this provided options with the decorator options appropriately. 331 | */ 332 | this.#options = options || {} 333 | return this 334 | } 335 | 336 | protected async enhanceFile(options: AttachmentOptions): Promise { 337 | // Optimise the image buffer and return the optimised buffer 338 | // and the info of the image 339 | const { buffer, info } = await optimize(this.buffer!, options) 340 | 341 | // Override the `imageInfo` object with the optimised `info` object 342 | // As the optimised `info` object is preferred 343 | // Also append the `buffer` 344 | return assign({ ...this.attributes }, info, { buffer }) 345 | } 346 | 347 | /** 348 | * Save image to the disk. Results in noop when "this.isLocal = false" 349 | */ 350 | public async save() { 351 | const options = getMergedOptions(this.#options || {}) 352 | 353 | try { 354 | /** 355 | * Do not persist already persisted image or if the 356 | * instance is not local 357 | */ 358 | if (!this.isLocal || this.isPersisted) { 359 | return this 360 | } 361 | 362 | /** 363 | * Optimise the original file and return the enhanced buffer and 364 | * information of the enhanced buffer 365 | */ 366 | const enhancedImageData = await this.enhanceFile(options) 367 | 368 | /** 369 | * Generate the name of the original image 370 | */ 371 | this.name = options.keepOriginal 372 | ? generateName({ 373 | extname: enhancedImageData.extname, 374 | options: options, 375 | prefix: 'original', 376 | fileName: this.fileName, 377 | }) 378 | : undefined 379 | 380 | /** 381 | * Update the local attributes with the attributes 382 | * of the optimised original file 383 | */ 384 | if (options.keepOriginal) { 385 | this.size = enhancedImageData.size 386 | this.width = enhancedImageData.width 387 | this.height = enhancedImageData.height 388 | this.format = enhancedImageData.format 389 | this.extname = enhancedImageData.extname 390 | this.mimeType = enhancedImageData.mimeType 391 | } 392 | 393 | /** 394 | * Inject the name into the `ImageInfo` 395 | */ 396 | enhancedImageData.name = this.name 397 | enhancedImageData.fileName = this.fileName 398 | 399 | /** 400 | * Write the optimised original image to the disk 401 | */ 402 | if (options.keepOriginal) { 403 | await this.getDisk().put(enhancedImageData.name!, enhancedImageData.buffer!) 404 | } 405 | 406 | /** 407 | * Generate image thumbnail data 408 | */ 409 | const thumbnailImageData = await generateThumbnail(enhancedImageData, options) 410 | 411 | if (thumbnailImageData) { 412 | // Set blurhash to top-level image data 413 | this.blurhash = thumbnailImageData.blurhash 414 | // Set the blurhash to the enhanced image data 415 | enhancedImageData.blurhash = thumbnailImageData.blurhash 416 | } 417 | 418 | const thumbnailIsRequired = options.responsiveDimensions && !options.disableThumbnail 419 | 420 | if (thumbnailImageData && thumbnailIsRequired) { 421 | /** 422 | * Write the thumbnail image to the disk 423 | */ 424 | await this.getDisk().put(thumbnailImageData.name!, thumbnailImageData.buffer!) 425 | /** 426 | * Delete buffer from `thumbnailImageData` 427 | */ 428 | delete thumbnailImageData.buffer 429 | 430 | set(enhancedImageData, 'breakpoints.thumbnail', thumbnailImageData) 431 | } 432 | 433 | /** 434 | * Generate breakpoint image data 435 | */ 436 | const breakpointFormats = await generateBreakpointImages(enhancedImageData, options) 437 | if (breakpointFormats && Array.isArray(breakpointFormats) && breakpointFormats.length > 0) { 438 | for (const format of breakpointFormats) { 439 | if (!format) continue 440 | 441 | const { key, file: breakpointImageData } = format 442 | 443 | /** 444 | * Write the breakpoint image to the disk 445 | */ 446 | await this.getDisk().put(breakpointImageData.name!, breakpointImageData.buffer!) 447 | 448 | /** 449 | * Delete buffer from `breakpointImageData` 450 | */ 451 | delete breakpointImageData.buffer 452 | 453 | set(enhancedImageData, ['breakpoints', key], breakpointImageData) 454 | } 455 | } 456 | 457 | const { width, height } = await getDimensions(enhancedImageData.buffer!) 458 | 459 | delete enhancedImageData.buffer 460 | 461 | assign(enhancedImageData, { 462 | width, 463 | height, 464 | }) 465 | 466 | /** 467 | * Update the width and height 468 | */ 469 | if (options.keepOriginal) { 470 | this.width = enhancedImageData.width 471 | this.height = enhancedImageData.height 472 | } 473 | 474 | /** 475 | * Update the local value of `breakpoints` 476 | */ 477 | this.breakpoints = enhancedImageData.breakpoints! 478 | 479 | /** 480 | * Images has been persisted 481 | */ 482 | this.isPersisted = true 483 | 484 | /** 485 | * Delete the temporary file 486 | */ 487 | if (this.buffer) { 488 | this.buffer = undefined 489 | } 490 | 491 | /** 492 | * Compute the URL 493 | */ 494 | await this.computeUrls().catch((error) => { 495 | this.loggerInstance.error('Adonis Responsive Attachment error: %o', error) 496 | }) 497 | 498 | return this 499 | } catch (error) { 500 | this.loggerInstance.fatal('Adonis Responsive Attachment error', error) 501 | throw error 502 | } 503 | } 504 | 505 | /** 506 | * Delete original and responsive images from the disk 507 | */ 508 | public async delete() { 509 | const options = getMergedOptions(this.#options || {}) 510 | 511 | try { 512 | if (!this.isPersisted) { 513 | return 514 | } 515 | 516 | /** 517 | * Delete the original image 518 | */ 519 | if (options.keepOriginal) await this.getDisk().delete(this.name!) 520 | /** 521 | * Delete the responsive images 522 | */ 523 | if (this.breakpoints) { 524 | for (const key in this.breakpoints) { 525 | if (Object.prototype.hasOwnProperty.call(this.breakpoints, key)) { 526 | const breakpointImage = this.breakpoints[key] as ImageAttributes 527 | await this.getDisk().delete(breakpointImage.name!) 528 | } 529 | } 530 | } 531 | 532 | this.isDeleted = true 533 | this.isPersisted = false 534 | } catch (error) { 535 | this.loggerInstance.fatal('Adonis Responsive Attachment error', error) 536 | throw error 537 | } 538 | } 539 | 540 | public async computeUrls(signedUrlOptions?: ContentHeaders & { expiresIn?: string | number }) { 541 | /** 542 | * Cannot compute url for a non persisted image 543 | */ 544 | if (!this.isPersisted) { 545 | return 546 | } 547 | 548 | /** 549 | * Compute urls when preComputeUrls is set to true 550 | * or the `preComputeUrls` function exists 551 | */ 552 | if (!this.#options?.preComputeUrls && this.isLocal) { 553 | return 554 | } 555 | 556 | const disk = this.getDisk() 557 | 558 | /** 559 | * Generate url using the user defined preComputeUrls method 560 | */ 561 | if (typeof this.#options?.preComputeUrls === 'function') { 562 | const urls = await this.#options.preComputeUrls(disk, this).catch((error) => { 563 | this.loggerInstance.error('Adonis Responsive Attachment error: %o', error) 564 | return null 565 | }) 566 | 567 | if (urls) { 568 | this.url = urls.url 569 | if (!this.urls) this.urls = {} as UrlRecords 570 | if (!this.urls.breakpoints) this.urls.breakpoints = {} as ImageBreakpoints 571 | for (const key in urls.breakpoints) { 572 | if (Object.prototype.hasOwnProperty.call(urls.breakpoints, key)) { 573 | if (!this.urls.breakpoints[key]) this.urls.breakpoints[key] = { url: '' } 574 | this.urls.breakpoints[key].url = urls.breakpoints[key].url 575 | } 576 | } 577 | return this.urls 578 | } 579 | } 580 | 581 | /** 582 | * Iterative URL-computation logic 583 | */ 584 | const { buffer, ...originalAttributes } = this.attributes 585 | const attachmentData = originalAttributes 586 | if (attachmentData) { 587 | if (!this.urls) this.urls = {} as UrlRecords 588 | 589 | for (const key in attachmentData) { 590 | if (['name', 'breakpoints'].includes(key) === false) { 591 | continue 592 | } 593 | 594 | const value: string | ImageBreakpoints = attachmentData[key] 595 | let url: string 596 | 597 | if (key === 'name') { 598 | if ((this.#options?.keepOriginal ?? true) === false || !this.name) { 599 | continue 600 | } 601 | 602 | const name = value as string 603 | 604 | let imageVisibility: Visibility 605 | try { 606 | imageVisibility = await disk.getVisibility(name) 607 | } catch (error) { 608 | this.loggerInstance.error('Adonis Responsive Attachment error: %s', error) 609 | continue 610 | } 611 | 612 | if (imageVisibility === 'private') { 613 | url = await disk.getSignedUrl(name, signedUrlOptions || undefined) 614 | } else { 615 | url = await disk.getUrl(name) 616 | } 617 | 618 | this.urls['url'] = url 619 | this.url = url 620 | } 621 | 622 | if (key === 'breakpoints') { 623 | if (isEmpty(value) === false) { 624 | if (!this.urls.breakpoints) { 625 | this.urls.breakpoints = {} as ImageBreakpoints 626 | } 627 | 628 | const breakpoints = value as ImageBreakpoints 629 | 630 | for (const breakpoint in breakpoints) { 631 | if (Object.prototype.hasOwnProperty.call(breakpoints, breakpoint)) { 632 | const breakpointImageData: Exclude = 633 | breakpoints?.[breakpoint] 634 | 635 | if (breakpointImageData) { 636 | const imageVisibility = await disk.getVisibility(breakpointImageData.name!) 637 | if (imageVisibility === 'private') { 638 | url = await disk.getSignedUrl( 639 | breakpointImageData.name!, 640 | signedUrlOptions || undefined 641 | ) 642 | } else { 643 | url = await disk.getUrl(breakpointImageData.name!) 644 | } 645 | this.urls['breakpoints'][breakpoint] = { url } 646 | } 647 | } 648 | } 649 | } 650 | } 651 | } 652 | } 653 | 654 | return this.urls 655 | } 656 | 657 | /** 658 | * Returns the signed or unsigned URL for each responsive image 659 | */ 660 | public async getUrls(signingOptions?: ContentHeaders & { expiresIn?: string | number }) { 661 | return this.computeUrls({ ...signingOptions }).catch((error) => { 662 | this.loggerInstance.error('Adonis Responsive Attachment error: %o', error) 663 | return undefined 664 | }) 665 | } 666 | 667 | /** 668 | * Convert attachment instance to object without the `url` property 669 | * for persistence to the database 670 | */ 671 | public toObject() { 672 | const { buffer, url, ...originalAttributes } = this.attributes 673 | 674 | return merge((this.#options?.keepOriginal ?? true) ? originalAttributes : {}, { 675 | breakpoints: this.breakpoints, 676 | }) 677 | } 678 | 679 | /** 680 | * Serialize attachment instance to JSON object to be sent over the wire 681 | */ 682 | public toJSON() { 683 | return merge(this.toObject(), this.urls ?? {}) 684 | } 685 | } 686 | -------------------------------------------------------------------------------- /src/Bindings/Validator.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * adonis-responsive-attachment 3 | * 4 | * (c) Ndianabasi Udonkang 5 | * 6 | * For the full copyright and license information, 7 | * please view the LICENSE file that was distributed with 8 | * this source code. 9 | */ 10 | 11 | import { readFile } from 'fs/promises' 12 | import { Exception } from '@poppinss/utils' 13 | import { LoggerContract } from '@ioc:Adonis/Core/Logger' 14 | import { ValidationRuntimeOptions, validator as validatorStatic } from '@ioc:Adonis/Core/Validator' 15 | import { getMetaData } from '../Helpers/image_manipulation_helper' 16 | import type { MultipartFileContract } from '@ioc:Adonis/Core/BodyParser' 17 | 18 | type NormalizedOptions = { validationValue: number } 19 | 20 | enum ImageDimensionsValidationRule { 21 | maxImageWidth = 'maxImageWidth', 22 | maxImageHeight = 'maxImageHeight', 23 | minImageWidth = 'minImageWidth', 24 | minImageHeight = 'minImageHeight', 25 | imageAspectRatio = 'imageAspectRatio', 26 | } 27 | 28 | /** 29 | * Ensure image is complaint with expected dimensions validations 30 | */ 31 | class ImageDimensionsCheck { 32 | constructor( 33 | public ruleName: ImageDimensionsValidationRule, 34 | protected logger: LoggerContract 35 | ) {} 36 | 37 | /** 38 | * Compile validation options 39 | */ 40 | public compile(validationValue: number) { 41 | /** 42 | * Ensure options are defined with table and column name 43 | */ 44 | if (!validationValue) { 45 | throw new Exception(`"${this.ruleName}" rule expects a "validationValue"`) 46 | } 47 | 48 | return { 49 | validationValue, 50 | } 51 | } 52 | 53 | /** 54 | * Validate the file 55 | */ 56 | public async validate( 57 | file: MultipartFileContract, 58 | { validationValue }: NormalizedOptions, 59 | { pointer, errorReporter, arrayExpressionPointer }: ValidationRuntimeOptions 60 | ) { 61 | if (!file) { 62 | return 63 | } 64 | 65 | if (!file.tmpPath) { 66 | throw new Error('[Adonis Responsive Attachment] File is invalid') 67 | } 68 | 69 | const imageBuffer = await readFile(file.tmpPath) 70 | const { width, height } = await getMetaData(imageBuffer) 71 | const reportError = () => { 72 | errorReporter.report( 73 | pointer, 74 | this.ruleName, 75 | `${this.ruleName} validation failure`, 76 | arrayExpressionPointer, 77 | { [this.ruleName]: validationValue } 78 | ) 79 | } 80 | 81 | if (this.ruleName === 'minImageWidth') { 82 | if (!width || width < validationValue) { 83 | reportError() 84 | } 85 | return 86 | } 87 | 88 | if (this.ruleName === 'minImageHeight') { 89 | if (!height || height < validationValue) { 90 | reportError() 91 | } 92 | return 93 | } 94 | 95 | if (this.ruleName === 'maxImageWidth') { 96 | if (!width || width > validationValue) { 97 | reportError() 98 | } 99 | return 100 | } 101 | 102 | if (this.ruleName === 'maxImageHeight') { 103 | if (!height || height > validationValue) { 104 | reportError() 105 | } 106 | return 107 | } 108 | 109 | if (this.ruleName === 'imageAspectRatio') { 110 | if (!height || !width || width / height !== validationValue) { 111 | reportError() 112 | } 113 | return 114 | } 115 | 116 | throw new Error('[Adonis Responsive Attachment] Invalid image validation operation') 117 | } 118 | } 119 | 120 | function throwCatchallError(error: Error) { 121 | if (error.message === '[Adonis Responsive Attachment] Invalid image validation operation') { 122 | throw error 123 | } 124 | } 125 | 126 | /** 127 | * Extends the validator by adding `unique` and `exists` 128 | */ 129 | export function extendValidator(validator: typeof validatorStatic, logger: LoggerContract) { 130 | const minImageWidthRuleChecker = new ImageDimensionsCheck( 131 | ImageDimensionsValidationRule.minImageWidth, 132 | logger 133 | ) 134 | 135 | validator.rule>( 136 | minImageWidthRuleChecker.ruleName, 137 | async (value: MultipartFileContract, compiledOptions, options) => { 138 | try { 139 | await minImageWidthRuleChecker.validate(value, compiledOptions, options) 140 | } catch (error) { 141 | throwCatchallError(error) 142 | 143 | logger.fatal( 144 | { err: error }, 145 | `"${minImageWidthRuleChecker.ruleName}" validation rule failed` 146 | ) 147 | 148 | return options.errorReporter.report( 149 | options.pointer, 150 | `${minImageWidthRuleChecker.ruleName}`, 151 | `${minImageWidthRuleChecker.ruleName} validation failure`, 152 | options.arrayExpressionPointer, 153 | { [minImageWidthRuleChecker.ruleName]: compiledOptions.validationValue } 154 | ) 155 | } 156 | }, 157 | (options) => { 158 | return { 159 | compiledOptions: minImageWidthRuleChecker.compile(options[0]), 160 | async: true, 161 | } 162 | } 163 | ) 164 | 165 | const minImageHeightRuleChecker = new ImageDimensionsCheck( 166 | ImageDimensionsValidationRule.minImageHeight, 167 | logger 168 | ) 169 | 170 | validator.rule>( 171 | minImageHeightRuleChecker.ruleName, 172 | async (value: MultipartFileContract, compiledOptions, options) => { 173 | try { 174 | await minImageHeightRuleChecker.validate(value, compiledOptions, options) 175 | } catch (error) { 176 | throwCatchallError(error) 177 | 178 | logger.fatal( 179 | { err: error }, 180 | `"${minImageHeightRuleChecker.ruleName}" validation rule failed` 181 | ) 182 | options.errorReporter.report( 183 | options.pointer, 184 | `${minImageHeightRuleChecker.ruleName}`, 185 | `${minImageHeightRuleChecker.ruleName} validation failure`, 186 | options.arrayExpressionPointer, 187 | { [minImageHeightRuleChecker.ruleName]: compiledOptions.validationValue } 188 | ) 189 | } 190 | }, 191 | (options) => { 192 | return { 193 | compiledOptions: minImageHeightRuleChecker.compile(options[0]), 194 | async: true, 195 | } 196 | } 197 | ) 198 | 199 | const maxImageWidthRuleChecker = new ImageDimensionsCheck( 200 | ImageDimensionsValidationRule.maxImageWidth, 201 | logger 202 | ) 203 | 204 | validator.rule>( 205 | maxImageWidthRuleChecker.ruleName, 206 | async (value: MultipartFileContract, compiledOptions, options) => { 207 | try { 208 | await maxImageWidthRuleChecker.validate(value, compiledOptions, options) 209 | } catch (error) { 210 | throwCatchallError(error) 211 | 212 | logger.fatal( 213 | { err: error }, 214 | `"${maxImageWidthRuleChecker.ruleName}" validation rule failed` 215 | ) 216 | options.errorReporter.report( 217 | options.pointer, 218 | `${maxImageWidthRuleChecker.ruleName}`, 219 | `${maxImageWidthRuleChecker.ruleName} validation failure`, 220 | options.arrayExpressionPointer, 221 | { [maxImageWidthRuleChecker.ruleName]: compiledOptions.validationValue } 222 | ) 223 | } 224 | }, 225 | (options) => { 226 | return { 227 | compiledOptions: maxImageWidthRuleChecker.compile(options[0]), 228 | async: true, 229 | } 230 | } 231 | ) 232 | 233 | const maxImageHeightRuleChecker = new ImageDimensionsCheck( 234 | ImageDimensionsValidationRule.maxImageHeight, 235 | logger 236 | ) 237 | 238 | validator.rule>( 239 | maxImageHeightRuleChecker.ruleName, 240 | async (value: MultipartFileContract, compiledOptions, options) => { 241 | try { 242 | await maxImageHeightRuleChecker.validate(value, compiledOptions, options) 243 | } catch (error) { 244 | throwCatchallError(error) 245 | 246 | logger.fatal( 247 | { err: error }, 248 | `"${maxImageHeightRuleChecker.ruleName}" validation rule failed` 249 | ) 250 | options.errorReporter.report( 251 | options.pointer, 252 | `${maxImageHeightRuleChecker.ruleName}`, 253 | `${maxImageHeightRuleChecker.ruleName} validation failure`, 254 | options.arrayExpressionPointer, 255 | { [maxImageHeightRuleChecker.ruleName]: compiledOptions.validationValue } 256 | ) 257 | } 258 | }, 259 | (options) => { 260 | return { 261 | compiledOptions: maxImageHeightRuleChecker.compile(options[0]), 262 | async: true, 263 | } 264 | } 265 | ) 266 | 267 | const aspectRatioRuleChecker = new ImageDimensionsCheck( 268 | ImageDimensionsValidationRule.imageAspectRatio, 269 | logger 270 | ) 271 | 272 | validator.rule>( 273 | aspectRatioRuleChecker.ruleName, 274 | async (value: MultipartFileContract, compiledOptions, options) => { 275 | try { 276 | await aspectRatioRuleChecker.validate(value, compiledOptions, options) 277 | } catch (error) { 278 | throwCatchallError(error) 279 | 280 | logger.fatal({ err: error }, `"${aspectRatioRuleChecker.ruleName}" validation rule failed`) 281 | options.errorReporter.report( 282 | options.pointer, 283 | `${aspectRatioRuleChecker.ruleName}`, 284 | `${aspectRatioRuleChecker.ruleName} validation failure`, 285 | options.arrayExpressionPointer, 286 | { [aspectRatioRuleChecker.ruleName]: compiledOptions.validationValue } 287 | ) 288 | } 289 | }, 290 | (options) => { 291 | return { 292 | compiledOptions: aspectRatioRuleChecker.compile(options[0]), 293 | async: true, 294 | } 295 | } 296 | ) 297 | } 298 | -------------------------------------------------------------------------------- /src/Helpers/image_manipulation_helper.ts: -------------------------------------------------------------------------------- 1 | import sharp from 'sharp' 2 | import { 3 | AttachmentOptions, 4 | ImageBreakpoints, 5 | ImageInfo, 6 | OptimizedOutput, 7 | BreakpointFormat, 8 | FileDimensions, 9 | BlurhashOptions, 10 | } from '@ioc:Adonis/Addons/ResponsiveAttachment' 11 | import { cuid } from '@poppinss/utils/build/helpers' 12 | import { merge, pickBy, isEmpty } from 'lodash' 13 | import { DEFAULT_BREAKPOINTS } from '../Attachment/decorator' 14 | import { encode } from 'blurhash' 15 | 16 | export const getMergedOptions = function (options: AttachmentOptions): AttachmentOptions { 17 | return merge( 18 | { 19 | preComputeUrls: false, 20 | breakpoints: DEFAULT_BREAKPOINTS, 21 | forceFormat: undefined, 22 | optimizeOrientation: true, 23 | optimizeSize: true, 24 | responsiveDimensions: true, 25 | disableThumbnail: false, 26 | blurhash: getDefaultBlurhashOptions(options), 27 | keepOriginal: true, 28 | persistentFileNames: false, 29 | } as AttachmentOptions, 30 | options 31 | ) 32 | } 33 | 34 | export const bytesToKBytes = (bytes: number) => Math.round((bytes / 1000) * 100) / 100 35 | 36 | export const getMetaData = async (buffer: Buffer) => 37 | await sharp(buffer, { failOnError: false }).metadata() 38 | 39 | export const getDimensions = async function (buffer: Buffer): Promise { 40 | return await getMetaData(buffer).then(({ width, height }) => ({ width, height })) 41 | } 42 | 43 | /** 44 | * Default thumbnail resize options 45 | */ 46 | export const THUMBNAIL_RESIZE_OPTIONS = { 47 | width: 245, 48 | height: 156, 49 | fit: 'inside' as sharp.FitEnum['inside'], 50 | } 51 | 52 | export const resizeTo = async function ( 53 | buffer: Buffer, 54 | options: AttachmentOptions, 55 | resizeOptions: sharp.ResizeOptions 56 | ) { 57 | const sharpInstance = options?.forceFormat 58 | ? sharp(buffer, { failOnError: false }).toFormat(options.forceFormat) 59 | : sharp(buffer, { failOnError: false }) 60 | 61 | return await sharpInstance 62 | .withMetadata() 63 | .resize(resizeOptions) 64 | .toBuffer() 65 | .catch(() => null) 66 | } 67 | 68 | export const breakpointSmallerThan = (breakpoint: number, { width, height }: FileDimensions) => 69 | breakpoint < width! || breakpoint < height! 70 | 71 | export const allowedFormats: Array = [ 72 | 'jpeg', 73 | 'png', 74 | 'webp', 75 | 'avif', 76 | 'tiff', 77 | ] 78 | 79 | export const canBeProcessed = async (buffer: Buffer) => { 80 | const { format } = await getMetaData(buffer) 81 | return format && allowedFormats.includes(format as AttachmentOptions['forceFormat']) 82 | } 83 | 84 | const getImageExtension = function (imageFormat: ImageInfo['format']) { 85 | return imageFormat === 'jpeg' ? 'jpg' : imageFormat! 86 | } 87 | 88 | export const generateBreakpoint = async ({ 89 | key, 90 | imageData, 91 | breakpoint, 92 | options, 93 | }: { 94 | key: keyof ImageBreakpoints | string 95 | imageData: ImageInfo 96 | breakpoint: number 97 | options: AttachmentOptions 98 | }): Promise => { 99 | const breakpointBuffer = await resizeTo(imageData.buffer!, options, { 100 | width: breakpoint, 101 | height: breakpoint, 102 | fit: 'inside', 103 | }) 104 | 105 | if (breakpointBuffer) { 106 | const { width, height, size, format } = await getMetaData(breakpointBuffer) 107 | 108 | const extname = getImageExtension(format as ImageInfo['format']) 109 | const breakpointFileName = generateName({ 110 | extname, 111 | options, 112 | prefix: key as keyof ImageBreakpoints, 113 | fileName: imageData.fileName, 114 | }) 115 | 116 | return { 117 | key: key as keyof ImageBreakpoints, 118 | file: { 119 | // Override attributes in `imageData` 120 | name: breakpointFileName, 121 | extname, 122 | mimeType: `image/${format}`, 123 | format: format as AttachmentOptions['forceFormat'], 124 | width: width, 125 | height: height, 126 | size: bytesToKBytes(size!), 127 | buffer: breakpointBuffer, 128 | blurhash: imageData.blurhash, 129 | }, 130 | } 131 | } else { 132 | return null 133 | } 134 | } 135 | 136 | /** 137 | * Generates the name for the attachment and prefixes 138 | * the folder (if defined) 139 | * @param payload 140 | * @param payload.extname The extension name for the image 141 | * @param payload.hash Hash string to use instead of a CUID 142 | * @param payload.prefix String to prepend to the filename 143 | * @param payload.options Attachment options 144 | */ 145 | export const generateName = function ({ 146 | extname, 147 | fileName, 148 | hash, 149 | prefix, 150 | options, 151 | }: { 152 | extname?: string 153 | fileName?: string 154 | hash?: string 155 | prefix: keyof ImageBreakpoints | 'original' 156 | options?: AttachmentOptions 157 | }): string { 158 | const usePersistentFileNames = options?.persistentFileNames ?? false 159 | hash = usePersistentFileNames ? '' : (hash ?? cuid()) 160 | 161 | return `${options?.folder ? `${options.folder}/` : ''}${prefix}${fileName ? `_${fileName}` : ''}${ 162 | hash ? `_${hash}` : '' 163 | }.${extname}` 164 | } 165 | 166 | export const optimize = async function ( 167 | buffer: Buffer, 168 | options?: AttachmentOptions 169 | ): Promise { 170 | const { optimizeOrientation, optimizeSize, forceFormat } = options || {} 171 | 172 | // Check if the image is in the right format or can be size optimised 173 | if (!optimizeSize || !(await canBeProcessed(buffer))) { 174 | return { buffer } 175 | } 176 | 177 | // Auto rotate the image if `optimizeOrientation` is true 178 | let sharpInstance = optimizeOrientation 179 | ? sharp(buffer, { failOnError: false }).rotate() 180 | : sharp(buffer, { failOnError: false }) 181 | 182 | // Force image to output to a specific format if `forceFormat` is true 183 | sharpInstance = forceFormat ? sharpInstance.toFormat(forceFormat) : sharpInstance 184 | 185 | return await sharpInstance 186 | .toBuffer({ resolveWithObject: true }) 187 | .then(({ data, info }) => { 188 | // The original buffer should not be smaller than the optimised buffer 189 | const outputBuffer = buffer.length < data.length ? buffer : data 190 | 191 | return { 192 | buffer: outputBuffer, 193 | info: { 194 | width: info.width, 195 | height: info.height, 196 | size: bytesToKBytes(outputBuffer.length), 197 | format: info.format as AttachmentOptions['forceFormat'], 198 | mimeType: `image/${info.format}`, 199 | extname: getImageExtension(info.format as ImageInfo['format']), 200 | }, 201 | } 202 | }) 203 | .catch(() => ({ buffer })) 204 | } 205 | 206 | export const generateThumbnail = async function ( 207 | imageData: ImageInfo, 208 | options: AttachmentOptions 209 | ): Promise { 210 | options = getMergedOptions(options) 211 | const blurhashEnabled = !!options.blurhash?.enabled 212 | let blurhash: string | undefined 213 | 214 | if (!(await canBeProcessed(imageData.buffer!))) { 215 | return null 216 | } 217 | 218 | if (!blurhashEnabled && (!options?.responsiveDimensions || options?.disableThumbnail)) { 219 | return null 220 | } 221 | 222 | const { width, height } = await getDimensions(imageData.buffer!) 223 | 224 | if (!width || !height) return null 225 | 226 | if (width > THUMBNAIL_RESIZE_OPTIONS.width || height > THUMBNAIL_RESIZE_OPTIONS.height) { 227 | const thumbnailBuffer = await resizeTo(imageData.buffer!, options, THUMBNAIL_RESIZE_OPTIONS) 228 | 229 | if (thumbnailBuffer) { 230 | const { 231 | width: thumbnailWidth, 232 | height: thumbnailHeight, 233 | size, 234 | format, 235 | } = await getMetaData(thumbnailBuffer) 236 | 237 | const extname = getImageExtension(format as ImageInfo['format']) 238 | 239 | const thumbnailFileName = generateName({ 240 | extname, 241 | options, 242 | prefix: 'thumbnail', 243 | fileName: imageData.fileName, 244 | }) 245 | 246 | const thumbnailImageData: ImageInfo = { 247 | name: thumbnailFileName, 248 | extname, 249 | mimeType: `image/${format}`, 250 | format: format as AttachmentOptions['forceFormat'], 251 | width: thumbnailWidth, 252 | height: thumbnailHeight, 253 | size: bytesToKBytes(size!), 254 | buffer: thumbnailBuffer, 255 | } 256 | 257 | // Generate blurhash 258 | if (blurhashEnabled) { 259 | blurhash = await encodeImageToBlurhash(options, thumbnailImageData.buffer) 260 | // Set the blurhash in the thumbnail data 261 | thumbnailImageData.blurhash = blurhash 262 | } 263 | 264 | return thumbnailImageData 265 | } 266 | } 267 | 268 | return null 269 | } 270 | 271 | export const generateBreakpointImages = async function ( 272 | imageData: ImageInfo, 273 | options: AttachmentOptions 274 | ) { 275 | options = getMergedOptions(options) 276 | /** 277 | * Noop if `responsiveDimensions` is falsy 278 | */ 279 | if (!options.responsiveDimensions) return [] 280 | 281 | /** 282 | * Noop if image format is not allowed 283 | */ 284 | if (!(await canBeProcessed(imageData.buffer!))) { 285 | return [] 286 | } 287 | 288 | const originalDimensions = await getDimensions(imageData.buffer!) 289 | 290 | const activeBreakpoints = pickBy(options.breakpoints, (value) => { 291 | return value !== 'off' 292 | }) 293 | 294 | if (isEmpty(activeBreakpoints)) return [] 295 | 296 | return Promise.all( 297 | Object.keys(activeBreakpoints).map((key) => { 298 | const breakpointValue = options.breakpoints?.[key] as number 299 | 300 | const isBreakpointSmallerThanOriginal = breakpointSmallerThan( 301 | breakpointValue, 302 | originalDimensions 303 | ) 304 | 305 | if (isBreakpointSmallerThanOriginal) { 306 | return generateBreakpoint({ key, imageData, breakpoint: breakpointValue, options }) 307 | } 308 | }) 309 | ) 310 | } 311 | 312 | export function getDefaultBlurhashOptions( 313 | options: AttachmentOptions | undefined 314 | ): Required { 315 | return { 316 | enabled: options?.blurhash?.enabled ?? false, 317 | componentX: options?.blurhash?.componentX ?? 4, 318 | componentY: options?.blurhash?.componentY ?? 3, 319 | } 320 | } 321 | 322 | export function encodeImageToBlurhash( 323 | options: AttachmentOptions, 324 | imageBuffer?: Buffer 325 | ): Promise { 326 | const { blurhash } = options 327 | const { componentX, componentY } = blurhash || {} 328 | 329 | if (!componentX || !componentY) { 330 | throw new Error('[Adonis Responsive Attachment] Ensure "componentX" and "componentY" are set') 331 | } 332 | if (!imageBuffer) { 333 | throw new Error('[Adonis Responsive Attachment] Ensure "buffer" is provided') 334 | } 335 | 336 | return new Promise(async (resolve, reject) => { 337 | try { 338 | // Convert buffer to pixels 339 | const { data: pixels, info: metadata } = await sharp(imageBuffer) 340 | .raw() 341 | .ensureAlpha() 342 | .toBuffer({ resolveWithObject: true }) 343 | 344 | return resolve( 345 | encode( 346 | new Uint8ClampedArray(pixels), 347 | metadata.width, 348 | metadata.height, 349 | componentX, 350 | componentY 351 | ) 352 | ) 353 | } catch (error) { 354 | return reject(error) 355 | } 356 | }) 357 | } 358 | -------------------------------------------------------------------------------- /test-helpers/drive.ts: -------------------------------------------------------------------------------- 1 | declare module '@ioc:Adonis/Core/Drive' { 2 | interface DisksList { 3 | local: { 4 | config: LocalDriverConfig 5 | implementation: LocalDriverContract 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test-helpers/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @ndianabasi/adonis-responsive-attachment 3 | * 4 | * (c) Ndianabasi Udonkang 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { join } from 'path' 11 | import { Filesystem } from '@poppinss/dev-utils' 12 | import { Application } from '@adonisjs/core/build/standalone' 13 | import { QueryClientContract } from '@ioc:Adonis/Lucid/Database' 14 | import { ApplicationContract } from '@ioc:Adonis/Core/Application' 15 | 16 | export const fs = new Filesystem(join(__dirname, '__app')) 17 | 18 | /** 19 | * Setup AdonisJS application 20 | */ 21 | export async function setupApplication( 22 | additionalProviders?: string[], 23 | environment: 'web' | 'repl' | 'test' = 'test' 24 | ) { 25 | await fs.add('.env', ``) 26 | await fs.add( 27 | 'config/app.ts', 28 | ` 29 | export const appKey = 'zgkDRQ21_1uznZp33C4sIGj1XUuIeIJd', 30 | export const http = { 31 | cookie: {}, 32 | trustProxy: () => true, 33 | } 34 | ` 35 | ) 36 | 37 | await fs.add( 38 | 'config/bodyparser.ts', 39 | ` 40 | const config = { 41 | whitelistedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'], 42 | json: { 43 | encoding: 'utf-8', 44 | limit: '1mb', 45 | strict: true, 46 | types: [ 47 | 'application/json', 48 | ], 49 | }, 50 | form: { 51 | encoding: 'utf-8', 52 | limit: '1mb', 53 | queryString: {}, 54 | types: ['application/x-www-form-urlencoded'], 55 | }, 56 | raw: { 57 | encoding: 'utf-8', 58 | limit: '1mb', 59 | queryString: {}, 60 | types: ['text/*'], 61 | }, 62 | multipart: { 63 | autoProcess: true, 64 | convertEmptyStringsToNull: true, 65 | processManually: [], 66 | encoding: 'utf-8', 67 | maxFields: 1000, 68 | limit: '20mb', 69 | types: ['multipart/form-data'], 70 | }, 71 | } 72 | 73 | export default config 74 | ` 75 | ) 76 | 77 | await fs.add( 78 | 'config/drive.ts', 79 | ` 80 | export const disk = 'local', 81 | export const disks = { 82 | local: { 83 | driver: 'local', 84 | visibility: 'private', 85 | root: '${join(fs.basePath, 'uploads').replace(/\\/g, '/')}', 86 | serveFiles: true, 87 | basePath: '/uploads' 88 | } 89 | } 90 | ` 91 | ) 92 | 93 | await fs.add( 94 | 'config/database.ts', 95 | ` 96 | const MYSQL_VARIABLES = { 97 | MYSQL_HOST: 'localhost', 98 | MYSQL_PORT: 3306, 99 | MYSQL_USER: 'adonis', 100 | MYSQL_PASSWORD: 'IGj1XUuIeIJd', 101 | MYSQL_DB_NAME: 'adonis-responsive-attachment', 102 | }; 103 | 104 | const databaseConfig = { 105 | connection: 'sqlite', 106 | connections: { 107 | sqlite: { 108 | client: 'sqlite3', 109 | connection: { 110 | filename: '${join(fs.basePath, 'db.sqlite3').replace(/\\/g, '/')}', 111 | }, 112 | }, 113 | mysql: { 114 | client: 'mysql', 115 | connection: { 116 | host: MYSQL_VARIABLES.MYSQL_HOST, 117 | port: MYSQL_VARIABLES.MYSQL_PORT, 118 | user: MYSQL_VARIABLES.MYSQL_USER, 119 | password: MYSQL_VARIABLES.MYSQL_PASSWORD, 120 | database: MYSQL_VARIABLES.MYSQL_DB_NAME, 121 | }, 122 | migrations: { 123 | naturalSort: true, 124 | }, 125 | healthCheck: false, 126 | debug: false, 127 | }, 128 | } 129 | } 130 | export default databaseConfig` 131 | ) 132 | 133 | const app = new Application(fs.basePath, environment, { 134 | providers: ['@adonisjs/core', '@adonisjs/lucid'].concat(additionalProviders || []), 135 | }) 136 | 137 | await app.setup() 138 | await app.registerProviders() 139 | await app.bootProviders() 140 | 141 | return app 142 | } 143 | 144 | /** 145 | * Create users table 146 | */ 147 | async function createUsersTable(client: QueryClientContract) { 148 | await client.schema.dropTableIfExists('users') 149 | 150 | await client.schema.createTable('users', (table) => { 151 | table.increments('id').notNullable().primary() 152 | table.string('username').notNullable().unique() 153 | table.json('avatar').nullable() 154 | table.string('cover_image').nullable() 155 | }) 156 | } 157 | 158 | /** 159 | * Setup for tests 160 | */ 161 | export async function setup(application: ApplicationContract) { 162 | const db = application.container.use('Adonis/Lucid/Database') 163 | await createUsersTable(db.connection()) 164 | } 165 | 166 | /** 167 | * Performs cleanup 168 | */ 169 | export async function cleanup(application: ApplicationContract) { 170 | const db = application.container.use('Adonis/Lucid/Database') 171 | await rollbackDB(application) 172 | await db.manager.closeAll() 173 | await fs.cleanup() 174 | } 175 | 176 | /** 177 | * Rollback DB 178 | */ 179 | 180 | export async function rollbackDB(application: ApplicationContract) { 181 | const db = application.container.use('Adonis/Lucid/Database') 182 | await db.connection().schema.dropTableIfExists('users') 183 | } 184 | -------------------------------------------------------------------------------- /test/attachment.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * adonis-responsive-attachment 3 | * 4 | * (c) Ndianabasi Udonkang 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import 'reflect-metadata' 11 | 12 | import test from 'japa' 13 | import { join } from 'path' 14 | import supertest from 'supertest' 15 | import { createServer } from 'http' 16 | import { readFile } from 'fs/promises' 17 | import { ResponsiveAttachment } from '../src/Attachment/index' 18 | import { setup, cleanup, setupApplication, rollbackDB } from '../test-helpers' 19 | import { ApplicationContract } from '@ioc:Adonis/Core/Application' 20 | import { responsiveAttachment as Attachment } from '../src/Attachment/decorator' 21 | import { getDimensions } from '../src/Helpers/image_manipulation_helper' 22 | import { BodyParserMiddleware } from '@adonisjs/bodyparser/build/src/BodyParser' 23 | import { ResponsiveAttachmentContract } from '@ioc:Adonis/Addons/ResponsiveAttachment' 24 | import { isBlurhashValid } from 'blurhash' 25 | import { readFileSync } from 'fs' 26 | 27 | let app: ApplicationContract 28 | 29 | const samplePersistedImageData = { 30 | isPersisted: true, 31 | isLocal: false, 32 | name: 'original_ckw5lpv7v0002egvobe1b0oav.jpg', 33 | size: 291.69, 34 | width: 1500, 35 | format: 'jpeg', 36 | height: 1000, 37 | extname: 'jpg', 38 | mimeType: 'image/jpeg', 39 | breakpoints: { 40 | thumbnail: { 41 | name: 'thumbnail_ckw5lpv7v0002egvobe1b0oav.jpg', 42 | extname: 'jpg', 43 | mimeType: 'image/jpeg', 44 | width: 234, 45 | height: 156, 46 | size: 7.96, 47 | }, 48 | large: { 49 | name: 'large_ckw5lpv7v0002egvobe1b0oav.jpg', 50 | extname: 'jpg', 51 | mimeType: 'image/jpeg', 52 | width: 1000, 53 | height: 667, 54 | size: 129.15, 55 | }, 56 | medium: { 57 | name: 'medium_ckw5lpv7v0002egvobe1b0oav.jpg', 58 | extname: 'jpg', 59 | mimeType: 'image/jpeg', 60 | width: 750, 61 | height: 500, 62 | size: 71.65, 63 | }, 64 | small: { 65 | name: 'small_ckw5lpv7v0002egvobe1b0oav.jpg', 66 | extname: 'jpg', 67 | mimeType: 'image/jpeg', 68 | width: 500, 69 | height: 333, 70 | size: 32.21, 71 | }, 72 | }, 73 | } 74 | 75 | test.group('ResponsiveAttachment | fromDbResponse', (group) => { 76 | group.before(async () => { 77 | app = await setupApplication() 78 | app.container.resolveBinding('Adonis/Core/Route').commit() 79 | ResponsiveAttachment.setDrive(app.container.resolveBinding('Adonis/Core/Drive')) 80 | ResponsiveAttachment.setLogger(app.container.resolveBinding('Adonis/Core/Logger')) 81 | }) 82 | 83 | group.beforeEach(async () => { 84 | await setup(app) 85 | }) 86 | 87 | group.afterEach(async () => { 88 | await rollbackDB(app) 89 | }) 90 | 91 | group.after(async () => { 92 | await cleanup(app) 93 | }) 94 | 95 | test('create image attachment instance from db response', (assert) => { 96 | const responsiveAttachment = ResponsiveAttachment.fromDbResponse( 97 | JSON.stringify(samplePersistedImageData) 98 | ) 99 | 100 | assert.isTrue(responsiveAttachment?.isPersisted) 101 | assert.isFalse(responsiveAttachment?.isLocal) 102 | }) 103 | 104 | test('"setOptions" should properly override decorator options', async (assert) => { 105 | const { column, BaseModel } = app.container.use('Adonis/Lucid/Orm') 106 | 107 | class User extends BaseModel { 108 | @column({ isPrimary: true }) 109 | public id: string 110 | 111 | @column() 112 | public username: string 113 | 114 | @Attachment({ persistentFileNames: true, responsiveDimensions: false }) 115 | public avatar: ResponsiveAttachmentContract | null 116 | } 117 | 118 | const buffer = readFileSync( 119 | join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg') 120 | ) 121 | 122 | const user = new User() 123 | user.username = 'Ndianabasi' 124 | user.avatar = await ResponsiveAttachment.fromBuffer(buffer) 125 | user.avatar.setOptions({ blurhash: { enabled: true } }) 126 | await user.save() 127 | 128 | assert.deepNestedInclude(user.avatar.getOptions, { 129 | blurhash: { enabled: true, componentX: 4, componentY: 3 }, 130 | persistentFileNames: true, 131 | responsiveDimensions: false, 132 | }) 133 | }) 134 | 135 | test('save method should result in noop when image attachment is created from db response', async (assert) => { 136 | const responsiveAttachment = ResponsiveAttachment.fromDbResponse( 137 | JSON.stringify(samplePersistedImageData) 138 | ) 139 | 140 | await responsiveAttachment?.save() 141 | assert.equal(responsiveAttachment?.name, 'original_ckw5lpv7v0002egvobe1b0oav.jpg') 142 | assert.equal( 143 | responsiveAttachment?.breakpoints?.thumbnail.name, 144 | 'thumbnail_ckw5lpv7v0002egvobe1b0oav.jpg' 145 | ) 146 | assert.equal( 147 | responsiveAttachment?.breakpoints?.small.name, 148 | 'small_ckw5lpv7v0002egvobe1b0oav.jpg' 149 | ) 150 | assert.equal( 151 | responsiveAttachment?.breakpoints?.medium.name, 152 | 'medium_ckw5lpv7v0002egvobe1b0oav.jpg' 153 | ) 154 | assert.equal( 155 | responsiveAttachment?.breakpoints?.large.name, 156 | 'large_ckw5lpv7v0002egvobe1b0oav.jpg' 157 | ) 158 | }) 159 | 160 | test('delete persisted images', async (assert) => { 161 | const responsiveAttachment = ResponsiveAttachment.fromDbResponse( 162 | JSON.stringify(samplePersistedImageData) 163 | ) 164 | 165 | await responsiveAttachment?.delete() 166 | assert.isTrue(responsiveAttachment?.isDeleted) 167 | }) 168 | 169 | test('compute image urls', async (assert) => { 170 | const responsiveAttachment = ResponsiveAttachment.fromDbResponse( 171 | JSON.stringify(samplePersistedImageData) 172 | ) 173 | 174 | responsiveAttachment?.setOptions({ preComputeUrls: true }) 175 | 176 | const urls = await responsiveAttachment?.getUrls() 177 | 178 | assert.match(urls?.url!, /\/uploads\/original_ckw5lpv7v0002egvobe1b0oav\.jpg\?signature=/) 179 | assert.match( 180 | urls?.breakpoints?.thumbnail.url!, 181 | /\/uploads\/thumbnail_ckw5lpv7v0002egvobe1b0oav\.jpg\?signature=/ 182 | ) 183 | assert.match( 184 | urls?.breakpoints?.small.url!, 185 | /\/uploads\/small_ckw5lpv7v0002egvobe1b0oav\.jpg\?signature=/ 186 | ) 187 | assert.match( 188 | urls?.breakpoints?.large.url!, 189 | /\/uploads\/large_ckw5lpv7v0002egvobe1b0oav\.jpg\?signature=/ 190 | ) 191 | assert.match( 192 | urls?.breakpoints?.medium.url!, 193 | /\/uploads\/medium_ckw5lpv7v0002egvobe1b0oav\.jpg\?signature=/ 194 | ) 195 | }) 196 | 197 | test('compute image urls from a custom method', async (assert) => { 198 | const responsiveAttachment = ResponsiveAttachment.fromDbResponse( 199 | JSON.stringify(samplePersistedImageData) 200 | ) 201 | 202 | responsiveAttachment?.setOptions({ 203 | preComputeUrls: async (_, image: ResponsiveAttachment) => { 204 | return { 205 | url: `/custom_folder/${image.name!}?signature=eyJtZXNzYWdlIjoiL3VwbG9hZHMvb3JpZ2luYWxfY2t3NWxwdjd2MDAwMmVndm9iZTFiMG9hdi5qcGcifQ.ieXMlaRb8izlREvJ0E9iMY0I3iedalmv-pvOUIrfEZc`, 206 | breakpoints: { 207 | thumbnail: { 208 | url: `/custom_folder/${image.breakpoints?.thumbnail 209 | .name!}?signature=eyJtZXNzYWdlIjoiL3VwbG9hZHMvdGh1bWJuYWlsX2NrdzVscHY3djAwMDJlZ3ZvYmUxYjBvYXYuanBnIn0.RGGimHh6NuyPrB2ZgmudE7rH4RRCT3NL7kex9EmSyIo`, 210 | }, 211 | small: { 212 | url: `/custom_folder/${image.breakpoints?.small 213 | .name!}?signature=eyJtZXNzYWdlIjoiL3VwbG9hZHMvbGFyZ2VfY2t3NWxwdjd2MDAwMmVndm9iZTFiMG9hdi5qcGcifQ.eNC8DaqYCYd4khKhqS7DKI66SsLpD-vyVIaP8rzMmAA`, 214 | }, 215 | medium: { 216 | url: `/custom_folder/${image.breakpoints?.medium 217 | .name!}?signature=eyJtZXNzYWdlIjoiL3VwbG9hZHMvbWVkaXVtX2NrdzVscHY3djAwMDJlZ3ZvYmUxYjBvYXYuanBnIn0.2ADmssxFC0vxmq4gJEgjb9Fxo1qcQ6tMVeKBqZ1ENkM`, 218 | }, 219 | large: { 220 | url: `/custom_folder/${image.breakpoints?.large 221 | .name!}?signature=eyJtZXNzYWdlIjoiL3VwbG9hZHMvc21hbGxfY2t3NWxwdjd2MDAwMmVndm9iZTFiMG9hdi5qcGcifQ.I8fwMRwY5azvlS_8B0K40BWKQNLuS-HqCB_3RXryOok`, 222 | }, 223 | }, 224 | } 225 | }, 226 | }) 227 | 228 | await responsiveAttachment?.computeUrls() 229 | 230 | assert.match( 231 | responsiveAttachment?.url!, 232 | /\/custom_folder\/original_ckw5lpv7v0002egvobe1b0oav\.jpg\?signature=/ 233 | ) 234 | assert.match( 235 | responsiveAttachment?.urls?.breakpoints?.small.url!, 236 | /\/custom_folder\/small_ckw5lpv7v0002egvobe1b0oav\.jpg\?signature=/ 237 | ) 238 | assert.match( 239 | responsiveAttachment?.urls?.breakpoints?.large.url!, 240 | /\/custom_folder\/large_ckw5lpv7v0002egvobe1b0oav\.jpg\?signature=/ 241 | ) 242 | assert.match( 243 | responsiveAttachment?.urls?.breakpoints?.medium.url!, 244 | /\/custom_folder\/medium_ckw5lpv7v0002egvobe1b0oav\.jpg\?signature=/ 245 | ) 246 | }) 247 | }) 248 | 249 | test.group('ResponsiveAttachment | fromFile', (group) => { 250 | group.before(async () => { 251 | app = await setupApplication() 252 | await setup(app) 253 | 254 | app.container.resolveBinding('Adonis/Core/Route').commit() 255 | ResponsiveAttachment.setDrive(app.container.resolveBinding('Adonis/Core/Drive')) 256 | ResponsiveAttachment.setLogger(app.container.resolveBinding('Adonis/Core/Logger')) 257 | }) 258 | 259 | group.beforeEach(async () => { 260 | await setup(app) 261 | }) 262 | 263 | group.afterEach(async () => { 264 | await rollbackDB(app) 265 | }) 266 | 267 | group.after(async () => { 268 | await cleanup(app) 269 | }) 270 | 271 | test('create attachment from the user-uploaded image', async (assert) => { 272 | const Drive = app.container.resolveBinding('Adonis/Core/Drive') 273 | 274 | const server = createServer((req, res) => { 275 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 276 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 277 | const file = ctx.request.file('avatar')! 278 | const responsiveAttachment = await ResponsiveAttachment.fromFile(file) 279 | await responsiveAttachment?.save() 280 | 281 | assert.isTrue(responsiveAttachment?.isPersisted) 282 | assert.isTrue(responsiveAttachment?.isLocal) 283 | 284 | assert.isTrue(await Drive.exists(responsiveAttachment?.name!)) 285 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.thumbnail.name!)) 286 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.small.name!)) 287 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.medium.name!)) 288 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.large.name!)) 289 | 290 | ctx.response.send(responsiveAttachment) 291 | ctx.response.finish() 292 | }) 293 | }) 294 | 295 | const { body } = await supertest(server) 296 | .post('/') 297 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 298 | 299 | assert.isTrue(await Drive.exists(body.name)) 300 | assert.isTrue(await Drive.exists(body.breakpoints?.thumbnail.name)) 301 | assert.isTrue(await Drive.exists(body.breakpoints?.small.name)) 302 | assert.isTrue(await Drive.exists(body.breakpoints?.medium.name)) 303 | assert.isTrue(await Drive.exists(body.breakpoints?.large.name)) 304 | }) 305 | 306 | test('change the format of the user-uploaded image', async (assert) => { 307 | const server = createServer((req, res) => { 308 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 309 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 310 | const file = ctx.request.file('avatar')! 311 | const responsiveAttachment = await ResponsiveAttachment.fromFile(file) 312 | responsiveAttachment?.setOptions({ preComputeUrls: true, forceFormat: 'webp' }) 313 | await responsiveAttachment?.save() 314 | 315 | assert.isTrue(responsiveAttachment?.isPersisted) 316 | assert.isTrue(responsiveAttachment?.isLocal) 317 | 318 | assert.include(responsiveAttachment?.name!, 'webp') 319 | assert.include(responsiveAttachment?.breakpoints?.thumbnail.name!, 'webp') 320 | assert.include(responsiveAttachment?.breakpoints?.small.name!, 'webp') 321 | assert.include(responsiveAttachment?.breakpoints?.medium.name!, 'webp') 322 | assert.include(responsiveAttachment?.breakpoints?.large.name!, 'webp') 323 | 324 | assert.include(responsiveAttachment?.url!, 'webp') 325 | assert.include(responsiveAttachment?.urls?.breakpoints?.large.url!, 'webp') 326 | assert.include(responsiveAttachment?.urls?.breakpoints?.medium.url!, 'webp') 327 | assert.include(responsiveAttachment?.urls?.breakpoints?.small.url!, 'webp') 328 | assert.include(responsiveAttachment?.urls?.breakpoints?.thumbnail.url!, 'webp') 329 | 330 | ctx.response.send(responsiveAttachment) 331 | ctx.response.finish() 332 | }) 333 | }) 334 | 335 | await supertest(server) 336 | .post('/') 337 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 338 | }) 339 | 340 | test('pre-compute urls for newly-created images', async (assert) => { 341 | const Drive = app.container.resolveBinding('Adonis/Core/Drive') 342 | 343 | const server = createServer((req, res) => { 344 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 345 | 346 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 347 | const file = ctx.request.file('avatar')! 348 | const responsiveAttachment = await ResponsiveAttachment.fromFile(file) 349 | responsiveAttachment?.setOptions({ preComputeUrls: true }) 350 | await responsiveAttachment?.save() 351 | 352 | assert.isTrue(responsiveAttachment?.isPersisted) 353 | assert.isTrue(responsiveAttachment?.isLocal) 354 | 355 | assert.isNotEmpty(responsiveAttachment?.urls) 356 | 357 | assert.isDefined(responsiveAttachment?.url) 358 | assert.isDefined(responsiveAttachment?.urls?.breakpoints?.large.url) 359 | assert.isDefined(responsiveAttachment?.urls?.breakpoints?.medium.url) 360 | assert.isDefined(responsiveAttachment?.urls?.breakpoints?.small.url) 361 | assert.isDefined(responsiveAttachment?.urls?.breakpoints?.thumbnail.url) 362 | 363 | assert.isTrue(await Drive.exists(responsiveAttachment?.name!)) 364 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.large.name!)) 365 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.medium.name!)) 366 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.small.name!)) 367 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.thumbnail.name!)) 368 | 369 | assert.isTrue( 370 | responsiveAttachment?.breakpoints!.thumbnail.size! < 371 | responsiveAttachment?.breakpoints!.small.size! 372 | ) 373 | assert.isTrue( 374 | responsiveAttachment?.breakpoints!.small.size! < 375 | responsiveAttachment?.breakpoints!.medium.size! 376 | ) 377 | assert.isTrue( 378 | responsiveAttachment?.breakpoints!.medium.size! < 379 | responsiveAttachment?.breakpoints!.large.size! 380 | ) 381 | assert.isTrue(responsiveAttachment?.breakpoints!.large.size! < responsiveAttachment?.size!) 382 | 383 | ctx.response.send(responsiveAttachment) 384 | ctx.response.finish() 385 | }) 386 | }) 387 | 388 | await supertest(server) 389 | .post('/') 390 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 391 | }) 392 | 393 | test('delete local images', async (assert) => { 394 | const Drive = app.container.resolveBinding('Adonis/Core/Drive') 395 | 396 | const server = createServer((req, res) => { 397 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 398 | 399 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 400 | const file = ctx.request.file('avatar')! 401 | const responsiveAttachment = await ResponsiveAttachment.fromFile(file) 402 | responsiveAttachment?.setOptions({ preComputeUrls: true }) 403 | await responsiveAttachment?.save() 404 | await responsiveAttachment?.delete() 405 | 406 | assert.isFalse(responsiveAttachment?.isPersisted) 407 | assert.isTrue(responsiveAttachment?.isLocal) 408 | assert.isTrue(responsiveAttachment?.isDeleted) 409 | 410 | ctx.response.send(responsiveAttachment) 411 | ctx.response.finish() 412 | }) 413 | }) 414 | 415 | const { body } = await supertest(server) 416 | .post('/') 417 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 418 | 419 | assert.isDefined(body.url) 420 | assert.isDefined(body.breakpoints.large.url) 421 | assert.isDefined(body.breakpoints.medium.url) 422 | assert.isDefined(body.breakpoints.small.url) 423 | assert.isDefined(body.breakpoints.thumbnail.url) 424 | 425 | assert.isNotNull(body.url) 426 | assert.isNotNull(body.breakpoints.large.url) 427 | assert.isNotNull(body.breakpoints.medium.url) 428 | assert.isNotNull(body.breakpoints.small.url) 429 | assert.isNotNull(body.breakpoints.thumbnail.url) 430 | 431 | assert.isFalse(await Drive.exists(body.name)) 432 | assert.isFalse(await Drive.exists(body.breakpoints.large.name)) 433 | assert.isFalse(await Drive.exists(body.breakpoints.medium.name)) 434 | assert.isFalse(await Drive.exists(body.breakpoints.small.name)) 435 | assert.isFalse(await Drive.exists(body.breakpoints.thumbnail.name)) 436 | }) 437 | 438 | test('do not create any image when image is not attached', async (assert) => { 439 | const server = createServer((req, res) => { 440 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 441 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 442 | const file = ctx.request.file('avatar')! 443 | const responsiveAttachment = file ? await ResponsiveAttachment.fromFile(file) : null 444 | await responsiveAttachment?.save() 445 | 446 | assert.isNull(responsiveAttachment) 447 | 448 | ctx.response.send(responsiveAttachment) 449 | ctx.response.finish() 450 | }) 451 | }) 452 | 453 | const { body } = await supertest(server).post('/') 454 | 455 | assert.isEmpty(body) 456 | }) 457 | }) 458 | 459 | test.group('ImageManipulationHelper', (group) => { 460 | group.before(async () => { 461 | app = await setupApplication() 462 | await setup(app) 463 | 464 | app.container.resolveBinding('Adonis/Core/Route').commit() 465 | ResponsiveAttachment.setDrive(app.container.resolveBinding('Adonis/Core/Drive')) 466 | ResponsiveAttachment.setLogger(app.container.resolveBinding('Adonis/Core/Logger')) 467 | }) 468 | 469 | group.beforeEach(async () => { 470 | await setup(app) 471 | }) 472 | 473 | group.afterEach(async () => { 474 | await rollbackDB(app) 475 | }) 476 | 477 | group.after(async () => { 478 | await cleanup(app) 479 | }) 480 | 481 | test('getDimensions', async (assert) => { 482 | const Drive = app.container.resolveBinding('Adonis/Core/Drive') 483 | 484 | const server = createServer((req, res) => { 485 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 486 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 487 | const file = ctx.request.file('avatar')! 488 | await file.moveToDisk(app.tmpPath('uploads'), { name: file.clientName }) 489 | const buffer = await Drive.get(join(app.tmpPath('uploads'), file.clientName)) 490 | const { width, height } = await getDimensions(buffer) 491 | 492 | assert.equal(width, 1500) 493 | assert.equal(height, 1000) 494 | 495 | ctx.response.send({ width, height }) 496 | ctx.response.finish() 497 | }) 498 | }) 499 | 500 | const { 501 | body: { width, height }, 502 | } = await supertest(server) 503 | .post('/') 504 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 505 | 506 | assert.equal(width, 1500) 507 | assert.equal(height, 1000) 508 | }) 509 | }) 510 | 511 | test.group('Images below the thumbnail resize options', (group) => { 512 | group.before(async () => { 513 | app = await setupApplication() 514 | await setup(app) 515 | 516 | app.container.resolveBinding('Adonis/Core/Route').commit() 517 | ResponsiveAttachment.setDrive(app.container.resolveBinding('Adonis/Core/Drive')) 518 | ResponsiveAttachment.setLogger(app.container.resolveBinding('Adonis/Core/Logger')) 519 | }) 520 | 521 | group.beforeEach(async () => { 522 | await setup(app) 523 | }) 524 | 525 | group.afterEach(async () => { 526 | await rollbackDB(app) 527 | }) 528 | 529 | group.after(async () => { 530 | await cleanup(app) 531 | }) 532 | test('does not create thumbnail and responsive images for files below the THUMBNAIL_RESIZE_OPTIONS', async (assert) => { 533 | const Drive = app.container.resolveBinding('Adonis/Core/Drive') 534 | 535 | const server = createServer((req, res) => { 536 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 537 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 538 | const file = ctx.request.file('avatar')! 539 | const responsiveAttachment = await ResponsiveAttachment.fromFile(file) 540 | await responsiveAttachment?.save() 541 | 542 | assert.isTrue(responsiveAttachment?.isPersisted) 543 | assert.isTrue(responsiveAttachment?.isLocal) 544 | 545 | assert.isTrue(await Drive.exists(responsiveAttachment?.name!)) 546 | assert.isUndefined(responsiveAttachment?.breakpoints) 547 | 548 | ctx.response.send(responsiveAttachment) 549 | ctx.response.finish() 550 | }) 551 | }) 552 | 553 | await supertest(server) 554 | .post('/') 555 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-150x100.jpg')) 556 | }) 557 | 558 | test('pre-compute urls for newly created image below the THUMBNAIL_RESIZE_OPTIONS', async (assert) => { 559 | const Drive = app.container.resolveBinding('Adonis/Core/Drive') 560 | 561 | const server = createServer((req, res) => { 562 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 563 | 564 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 565 | const file = ctx.request.file('avatar')! 566 | const responsiveAttachment = await ResponsiveAttachment.fromFile(file) 567 | responsiveAttachment?.setOptions({ preComputeUrls: true }) 568 | await responsiveAttachment?.save() 569 | 570 | assert.isTrue(responsiveAttachment?.isPersisted) 571 | assert.isTrue(responsiveAttachment?.isLocal) 572 | 573 | assert.isNotEmpty(responsiveAttachment?.urls) 574 | 575 | assert.isDefined(responsiveAttachment?.url) 576 | assert.isNotNull(responsiveAttachment?.url) 577 | 578 | assert.isUndefined(responsiveAttachment?.urls?.breakpoints) 579 | 580 | assert.isTrue(await Drive.exists(responsiveAttachment?.name!)) 581 | 582 | ctx.response.send(responsiveAttachment) 583 | ctx.response.finish() 584 | }) 585 | }) 586 | 587 | await supertest(server) 588 | .post('/') 589 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-150x100.jpg')) 590 | }) 591 | }) 592 | 593 | test.group('Images below the large breakeven point', (group) => { 594 | group.before(async () => { 595 | app = await setupApplication() 596 | await setup(app) 597 | 598 | app.container.resolveBinding('Adonis/Core/Route').commit() 599 | ResponsiveAttachment.setDrive(app.container.resolveBinding('Adonis/Core/Drive')) 600 | ResponsiveAttachment.setLogger(app.container.resolveBinding('Adonis/Core/Logger')) 601 | }) 602 | 603 | group.beforeEach(async () => { 604 | await setup(app) 605 | }) 606 | 607 | group.afterEach(async () => { 608 | await rollbackDB(app) 609 | }) 610 | 611 | group.after(async () => { 612 | await cleanup(app) 613 | }) 614 | 615 | test('create attachment from the user uploaded image', async (assert) => { 616 | const Drive = app.container.resolveBinding('Adonis/Core/Drive') 617 | 618 | const server = createServer((req, res) => { 619 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 620 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 621 | const file = ctx.request.file('avatar')! 622 | const responsiveAttachment = await ResponsiveAttachment.fromFile(file) 623 | await responsiveAttachment?.save() 624 | 625 | assert.isTrue(responsiveAttachment?.isPersisted) 626 | assert.isTrue(responsiveAttachment?.isLocal) 627 | 628 | assert.isTrue(await Drive.exists(responsiveAttachment?.name!)) 629 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.thumbnail.name!)) 630 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.small.name!)) 631 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.medium.name!)) 632 | 633 | assert.isUndefined(responsiveAttachment?.breakpoints?.large) 634 | 635 | ctx.response.send(responsiveAttachment) 636 | ctx.response.finish() 637 | }) 638 | }) 639 | 640 | await supertest(server) 641 | .post('/') 642 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-825x550.jpg')) 643 | }) 644 | 645 | test('pre-compute urls for newly created images', async (assert) => { 646 | const Drive = app.container.resolveBinding('Adonis/Core/Drive') 647 | 648 | const server = createServer((req, res) => { 649 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 650 | 651 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 652 | const file = ctx.request.file('avatar')! 653 | const responsiveAttachment = await ResponsiveAttachment.fromFile(file) 654 | responsiveAttachment?.setOptions({ preComputeUrls: true }) 655 | await responsiveAttachment?.save() 656 | 657 | assert.isTrue(responsiveAttachment?.isPersisted) 658 | assert.isTrue(responsiveAttachment?.isLocal) 659 | 660 | assert.isDefined(responsiveAttachment?.url) 661 | assert.isUndefined(responsiveAttachment?.breakpoints?.medium.url) 662 | assert.isUndefined(responsiveAttachment?.breakpoints?.small.url) 663 | assert.isUndefined(responsiveAttachment?.breakpoints?.thumbnail.url) 664 | assert.isUndefined(responsiveAttachment?.breakpoints?.large) 665 | 666 | assert.isTrue(await Drive.exists(responsiveAttachment?.name!)) 667 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.medium.name!)) 668 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.small.name!)) 669 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.thumbnail.name!)) 670 | 671 | assert.isUndefined(responsiveAttachment?.breakpoints?.large) 672 | 673 | assert.isTrue( 674 | responsiveAttachment?.breakpoints!.thumbnail.size! < 675 | responsiveAttachment?.breakpoints!.small.size! 676 | ) 677 | assert.isTrue( 678 | responsiveAttachment?.breakpoints!.small.size! < 679 | responsiveAttachment?.breakpoints!.medium.size! 680 | ) 681 | assert.isTrue(responsiveAttachment?.breakpoints!.medium.size! < responsiveAttachment?.size!) 682 | 683 | ctx.response.send(responsiveAttachment) 684 | ctx.response.finish() 685 | }) 686 | }) 687 | 688 | await supertest(server) 689 | .post('/') 690 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-825x550.jpg')) 691 | }) 692 | }) 693 | 694 | test.group( 695 | 'Do not generate responsive images when `options.responsiveDimensions` is false', 696 | (group) => { 697 | group.before(async () => { 698 | app = await setupApplication() 699 | await setup(app) 700 | 701 | app.container.resolveBinding('Adonis/Core/Route').commit() 702 | ResponsiveAttachment.setDrive(app.container.resolveBinding('Adonis/Core/Drive')) 703 | ResponsiveAttachment.setLogger(app.container.resolveBinding('Adonis/Core/Logger')) 704 | }) 705 | 706 | group.beforeEach(async () => { 707 | await setup(app) 708 | }) 709 | 710 | group.afterEach(async () => { 711 | await rollbackDB(app) 712 | }) 713 | 714 | group.after(async () => { 715 | await cleanup(app) 716 | }) 717 | 718 | test('create attachment from the user uploaded image', async (assert) => { 719 | const Drive = app.container.resolveBinding('Adonis/Core/Drive') 720 | 721 | const server = createServer((req, res) => { 722 | const ctx = app.container 723 | .resolveBinding('Adonis/Core/HttpContext') 724 | .create('/', {}, req, res) 725 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 726 | const file = ctx.request.file('avatar')! 727 | const responsiveAttachment = await ResponsiveAttachment.fromFile(file) 728 | responsiveAttachment?.setOptions({ responsiveDimensions: false }) 729 | await responsiveAttachment?.save() 730 | 731 | assert.isTrue(responsiveAttachment?.isPersisted) 732 | assert.isTrue(responsiveAttachment?.isLocal) 733 | 734 | assert.isTrue(await Drive.exists(responsiveAttachment?.name!)) 735 | assert.isUndefined(responsiveAttachment?.breakpoints) 736 | 737 | ctx.response.send(responsiveAttachment) 738 | ctx.response.finish() 739 | }) 740 | }) 741 | 742 | await supertest(server) 743 | .post('/') 744 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-825x550.jpg')) 745 | }) 746 | 747 | test('pre-compute urls for newly created images', async (assert) => { 748 | const Drive = app.container.resolveBinding('Adonis/Core/Drive') 749 | 750 | const server = createServer((req, res) => { 751 | const ctx = app.container 752 | .resolveBinding('Adonis/Core/HttpContext') 753 | .create('/', {}, req, res) 754 | 755 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 756 | const file = ctx.request.file('avatar')! 757 | const responsiveAttachment = await ResponsiveAttachment.fromFile(file) 758 | responsiveAttachment?.setOptions({ preComputeUrls: true, responsiveDimensions: false }) 759 | await responsiveAttachment?.save() 760 | 761 | assert.isTrue(responsiveAttachment?.isPersisted) 762 | assert.isTrue(responsiveAttachment?.isLocal) 763 | 764 | assert.isNotEmpty(responsiveAttachment?.urls) 765 | 766 | assert.isDefined(responsiveAttachment?.url) 767 | assert.isNotNull(responsiveAttachment?.url) 768 | assert.isUndefined(responsiveAttachment?.urls?.breakpoints) 769 | 770 | assert.isTrue(await Drive.exists(responsiveAttachment?.name!)) 771 | 772 | ctx.response.send(responsiveAttachment) 773 | ctx.response.finish() 774 | }) 775 | }) 776 | 777 | await supertest(server) 778 | .post('/') 779 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-825x550.jpg')) 780 | }) 781 | } 782 | ) 783 | 784 | test.group('Do not generate thumbnail images when `options.disableThumbnail` is true', (group) => { 785 | group.before(async () => { 786 | app = await setupApplication() 787 | await setup(app) 788 | 789 | app.container.resolveBinding('Adonis/Core/Route').commit() 790 | ResponsiveAttachment.setDrive(app.container.resolveBinding('Adonis/Core/Drive')) 791 | ResponsiveAttachment.setLogger(app.container.resolveBinding('Adonis/Core/Logger')) 792 | }) 793 | 794 | group.beforeEach(async () => { 795 | await setup(app) 796 | }) 797 | 798 | group.afterEach(async () => { 799 | await rollbackDB(app) 800 | }) 801 | 802 | group.after(async () => { 803 | await cleanup(app) 804 | }) 805 | 806 | test('create attachment from the user uploaded image', async (assert) => { 807 | const Drive = app.container.resolveBinding('Adonis/Core/Drive') 808 | 809 | const server = createServer((req, res) => { 810 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 811 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 812 | const file = ctx.request.file('avatar')! 813 | const responsiveAttachment = await ResponsiveAttachment.fromFile(file) 814 | responsiveAttachment?.setOptions({ disableThumbnail: true }) 815 | await responsiveAttachment?.save() 816 | 817 | assert.isTrue(responsiveAttachment?.isPersisted) 818 | assert.isTrue(responsiveAttachment?.isLocal) 819 | 820 | assert.isTrue(await Drive.exists(responsiveAttachment?.name!)) 821 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.small.name!)) 822 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.medium.name!)) 823 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.large.name!)) 824 | 825 | assert.isUndefined(responsiveAttachment?.breakpoints?.thumbnail) 826 | 827 | ctx.response.send(responsiveAttachment) 828 | ctx.response.finish() 829 | }) 830 | }) 831 | 832 | await supertest(server) 833 | .post('/') 834 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 835 | }) 836 | 837 | test('pre-compute urls for newly created images', async (assert) => { 838 | const server = createServer((req, res) => { 839 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 840 | 841 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 842 | const file = ctx.request.file('avatar')! 843 | const responsiveAttachment = await ResponsiveAttachment.fromFile(file) 844 | responsiveAttachment?.setOptions({ preComputeUrls: true, disableThumbnail: true }) 845 | await responsiveAttachment?.save() 846 | 847 | assert.isTrue(responsiveAttachment?.isPersisted) 848 | assert.isTrue(responsiveAttachment?.isLocal) 849 | 850 | assert.isNotEmpty(responsiveAttachment?.urls) 851 | 852 | assert.isDefined(responsiveAttachment?.url) 853 | assert.isUndefined(responsiveAttachment?.breakpoints?.small.url!) 854 | assert.isUndefined(responsiveAttachment?.breakpoints?.medium.url!) 855 | assert.isUndefined(responsiveAttachment?.breakpoints?.large.url!) 856 | assert.isUndefined(responsiveAttachment?.breakpoints?.thumbnail) 857 | 858 | ctx.response.send(responsiveAttachment) 859 | ctx.response.finish() 860 | }) 861 | }) 862 | 863 | await supertest(server) 864 | .post('/') 865 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 866 | }) 867 | }) 868 | 869 | test.group('Do not generate responsive images when some default breakpoints are `off`', (group) => { 870 | group.before(async () => { 871 | app = await setupApplication() 872 | await setup(app) 873 | 874 | app.container.resolveBinding('Adonis/Core/Route').commit() 875 | ResponsiveAttachment.setDrive(app.container.resolveBinding('Adonis/Core/Drive')) 876 | ResponsiveAttachment.setLogger(app.container.resolveBinding('Adonis/Core/Logger')) 877 | }) 878 | 879 | group.beforeEach(async () => { 880 | await setup(app) 881 | }) 882 | 883 | group.afterEach(async () => { 884 | await rollbackDB(app) 885 | }) 886 | 887 | group.after(async () => { 888 | await cleanup(app) 889 | }) 890 | 891 | test('create attachment from the user uploaded image', async (assert) => { 892 | const Drive = app.container.resolveBinding('Adonis/Core/Drive') 893 | 894 | const server = createServer((req, res) => { 895 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 896 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 897 | const file = ctx.request.file('avatar')! 898 | const responsiveAttachment = await ResponsiveAttachment.fromFile(file) 899 | responsiveAttachment?.setOptions({ 900 | breakpoints: { medium: 'off', small: 'off' }, 901 | }) 902 | await responsiveAttachment?.save() 903 | 904 | assert.isTrue(responsiveAttachment?.isPersisted) 905 | assert.isTrue(responsiveAttachment?.isLocal) 906 | 907 | assert.isTrue(await Drive.exists(responsiveAttachment?.name!)) 908 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.large.name!)) 909 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.thumbnail.name!)) 910 | 911 | assert.isUndefined(responsiveAttachment?.breakpoints?.medium) 912 | assert.isUndefined(responsiveAttachment?.breakpoints?.small) 913 | 914 | assert.isTrue( 915 | responsiveAttachment?.breakpoints!.thumbnail.size! < 916 | responsiveAttachment?.breakpoints!.large.size! 917 | ) 918 | assert.isTrue(responsiveAttachment?.breakpoints!.large.size! < responsiveAttachment?.size!) 919 | 920 | ctx.response.send(responsiveAttachment) 921 | ctx.response.finish() 922 | }) 923 | }) 924 | 925 | await supertest(server) 926 | .post('/') 927 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 928 | }) 929 | 930 | test('pre-compute urls for newly created images', async (assert) => { 931 | const server = createServer((req, res) => { 932 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 933 | 934 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 935 | const file = ctx.request.file('avatar')! 936 | const responsiveAttachment = await ResponsiveAttachment.fromFile(file) 937 | responsiveAttachment?.setOptions({ 938 | preComputeUrls: true, 939 | breakpoints: { medium: 'off', small: 'off' }, 940 | }) 941 | await responsiveAttachment?.save() 942 | 943 | assert.isTrue(responsiveAttachment?.isPersisted) 944 | assert.isTrue(responsiveAttachment?.isLocal) 945 | 946 | assert.isNotEmpty(responsiveAttachment?.urls) 947 | 948 | assert.isDefined(responsiveAttachment?.url) 949 | assert.isUndefined(responsiveAttachment?.breakpoints?.large.url!) 950 | assert.isUndefined(responsiveAttachment?.breakpoints?.thumbnail.url!) 951 | 952 | ctx.response.send(responsiveAttachment) 953 | ctx.response.finish() 954 | }) 955 | }) 956 | 957 | await supertest(server) 958 | .post('/') 959 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 960 | }) 961 | }) 962 | 963 | test.group('Manual generation of URLs', (group) => { 964 | group.before(async () => { 965 | app = await setupApplication() 966 | await setup(app) 967 | 968 | app.container.resolveBinding('Adonis/Core/Route').commit() 969 | ResponsiveAttachment.setDrive(app.container.resolveBinding('Adonis/Core/Drive')) 970 | ResponsiveAttachment.setLogger(app.container.resolveBinding('Adonis/Core/Logger')) 971 | }) 972 | 973 | group.beforeEach(async () => { 974 | await setup(app) 975 | }) 976 | 977 | group.afterEach(async () => { 978 | await rollbackDB(app) 979 | }) 980 | 981 | group.after(async () => { 982 | await cleanup(app) 983 | }) 984 | 985 | test('generate URLs for the images', async (assert) => { 986 | const server = createServer((req, res) => { 987 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 988 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 989 | const file = ctx.request.file('avatar')! 990 | const responsiveAttachment = await ResponsiveAttachment.fromFile(file) 991 | responsiveAttachment?.setOptions({ preComputeUrls: true }) 992 | await responsiveAttachment?.save() 993 | const urls = await responsiveAttachment?.getUrls() 994 | 995 | assert.isTrue(responsiveAttachment?.isPersisted) 996 | assert.isTrue(responsiveAttachment?.isLocal) 997 | 998 | assert.match(urls?.url!, /^\/uploads\/original.+\?signature=.+$/) 999 | 1000 | ctx.response.send(responsiveAttachment) 1001 | ctx.response.finish() 1002 | }) 1003 | }) 1004 | 1005 | await supertest(server) 1006 | .post('/') 1007 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 1008 | }) 1009 | }) 1010 | 1011 | test.group('ResponsiveAttachment | Custom breakpoints', (group) => { 1012 | group.before(async () => { 1013 | app = await setupApplication() 1014 | await setup(app) 1015 | 1016 | app.container.resolveBinding('Adonis/Core/Route').commit() 1017 | ResponsiveAttachment.setDrive(app.container.resolveBinding('Adonis/Core/Drive')) 1018 | ResponsiveAttachment.setLogger(app.container.resolveBinding('Adonis/Core/Logger')) 1019 | }) 1020 | 1021 | group.beforeEach(async () => { 1022 | await setup(app) 1023 | }) 1024 | 1025 | group.afterEach(async () => { 1026 | await rollbackDB(app) 1027 | }) 1028 | 1029 | group.after(async () => { 1030 | await cleanup(app) 1031 | }) 1032 | 1033 | test('create attachment from the user uploaded image', async (assert) => { 1034 | const Drive = app.container.resolveBinding('Adonis/Core/Drive') 1035 | 1036 | const server = createServer((req, res) => { 1037 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 1038 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 1039 | const file = ctx.request.file('avatar')! 1040 | const responsiveAttachment = await ResponsiveAttachment.fromFile(file) 1041 | responsiveAttachment?.setOptions({ 1042 | breakpoints: { small: 400, medium: 700, large: 1000, xlarge: 1200 }, 1043 | }) 1044 | await responsiveAttachment?.save() 1045 | 1046 | assert.isTrue(responsiveAttachment?.isPersisted) 1047 | assert.isTrue(responsiveAttachment?.isLocal) 1048 | 1049 | assert.isTrue(await Drive.exists(responsiveAttachment?.name!)) 1050 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.thumbnail.name!)) 1051 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.small.name!)) 1052 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.medium.name!)) 1053 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.large.name!)) 1054 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.xlarge.name!)) 1055 | 1056 | ctx.response.send(responsiveAttachment) 1057 | ctx.response.finish() 1058 | }) 1059 | }) 1060 | 1061 | const { body } = await supertest(server) 1062 | .post('/') 1063 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 1064 | 1065 | assert.isTrue(await Drive.exists(body.name)) 1066 | assert.isTrue(await Drive.exists(body.breakpoints?.thumbnail.name)) 1067 | assert.isTrue(await Drive.exists(body.breakpoints?.small.name)) 1068 | assert.isTrue(await Drive.exists(body.breakpoints?.medium.name)) 1069 | assert.isTrue(await Drive.exists(body.breakpoints?.large.name)) 1070 | assert.isTrue(await Drive.exists(body.breakpoints?.xlarge.name)) 1071 | }) 1072 | 1073 | test('pre-compute urls for newly created images', async (assert) => { 1074 | const Drive = app.container.resolveBinding('Adonis/Core/Drive') 1075 | 1076 | const server = createServer((req, res) => { 1077 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 1078 | 1079 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 1080 | const file = ctx.request.file('avatar')! 1081 | const responsiveAttachment = await ResponsiveAttachment.fromFile(file) 1082 | responsiveAttachment?.setOptions({ 1083 | breakpoints: { small: 400, medium: 700, large: 1000, xlarge: 1200 }, 1084 | preComputeUrls: true, 1085 | }) 1086 | await responsiveAttachment?.save() 1087 | 1088 | assert.isTrue(responsiveAttachment?.isPersisted) 1089 | assert.isTrue(responsiveAttachment?.isLocal) 1090 | 1091 | assert.isDefined(responsiveAttachment?.url) 1092 | assert.isUndefined(responsiveAttachment?.breakpoints?.xlarge.url) 1093 | assert.isUndefined(responsiveAttachment?.breakpoints?.large.url) 1094 | assert.isUndefined(responsiveAttachment?.breakpoints?.medium.url) 1095 | assert.isUndefined(responsiveAttachment?.breakpoints?.small.url) 1096 | assert.isUndefined(responsiveAttachment?.breakpoints?.thumbnail.url) 1097 | 1098 | assert.isTrue(await Drive.exists(responsiveAttachment?.name!)) 1099 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.xlarge.name!)) 1100 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.large.name!)) 1101 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.medium.name!)) 1102 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.small.name!)) 1103 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.thumbnail.name!)) 1104 | 1105 | assert.equal(responsiveAttachment?.width, 1500) 1106 | assert.equal(responsiveAttachment?.breakpoints?.xlarge.width!, 1200) 1107 | assert.equal(responsiveAttachment?.breakpoints?.large.width!, 1000) 1108 | assert.equal(responsiveAttachment?.breakpoints?.medium.width!, 700) 1109 | assert.equal(responsiveAttachment?.breakpoints?.small.width!, 400) 1110 | 1111 | assert.isTrue( 1112 | responsiveAttachment?.breakpoints?.thumbnail.size! < 1113 | responsiveAttachment?.breakpoints!.small.size! 1114 | ) 1115 | assert.isTrue( 1116 | responsiveAttachment?.breakpoints?.small.size! < 1117 | responsiveAttachment?.breakpoints!.medium.size! 1118 | ) 1119 | assert.isTrue( 1120 | responsiveAttachment?.breakpoints?.medium.size! < 1121 | responsiveAttachment?.breakpoints!.large.size! 1122 | ) 1123 | assert.isTrue( 1124 | responsiveAttachment?.breakpoints?.large.size! < 1125 | responsiveAttachment?.breakpoints?.xlarge.size! 1126 | ) 1127 | assert.isTrue(responsiveAttachment?.breakpoints!.xlarge.size! < responsiveAttachment?.size!) 1128 | 1129 | ctx.response.send(responsiveAttachment) 1130 | ctx.response.finish() 1131 | }) 1132 | }) 1133 | 1134 | await supertest(server) 1135 | .post('/') 1136 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 1137 | }) 1138 | }) 1139 | 1140 | test.group('ResponsiveAttachment | fromBuffer', (group) => { 1141 | group.before(async () => { 1142 | app = await setupApplication() 1143 | await setup(app) 1144 | 1145 | app.container.resolveBinding('Adonis/Core/Route').commit() 1146 | ResponsiveAttachment.setDrive(app.container.resolveBinding('Adonis/Core/Drive')) 1147 | ResponsiveAttachment.setLogger(app.container.resolveBinding('Adonis/Core/Logger')) 1148 | }) 1149 | 1150 | group.beforeEach(async () => { 1151 | await setup(app) 1152 | }) 1153 | 1154 | group.afterEach(async () => { 1155 | await rollbackDB(app) 1156 | }) 1157 | 1158 | group.after(async () => { 1159 | await cleanup(app) 1160 | }) 1161 | 1162 | test('create attachment from the user-provided buffer', async (assert) => { 1163 | const Drive = app.container.resolveBinding('Adonis/Core/Drive') 1164 | 1165 | const server = createServer((req, res) => { 1166 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 1167 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 1168 | const readableStream = await readFile( 1169 | join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg') 1170 | ) 1171 | const responsiveAttachment = await ResponsiveAttachment.fromBuffer(readableStream) 1172 | await responsiveAttachment.save() 1173 | 1174 | assert.isTrue(responsiveAttachment.isPersisted) 1175 | assert.isTrue(responsiveAttachment.isLocal) 1176 | 1177 | assert.isTrue(await Drive.exists(responsiveAttachment?.name!)) 1178 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.thumbnail.name!)) 1179 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.small.name!)) 1180 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.medium.name!)) 1181 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.large.name!)) 1182 | 1183 | ctx.response.send(responsiveAttachment) 1184 | ctx.response.finish() 1185 | }) 1186 | }) 1187 | 1188 | const { body } = await supertest(server).post('/') 1189 | 1190 | assert.isTrue(await Drive.exists(body.name)) 1191 | }) 1192 | 1193 | test('pre-compute url for newly-created images', async (assert) => { 1194 | const Drive = app.container.resolveBinding('Adonis/Core/Drive') 1195 | 1196 | const server = createServer((req, res) => { 1197 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 1198 | 1199 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 1200 | const readableStream = await readFile( 1201 | join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg') 1202 | ) 1203 | const responsiveAttachment = await ResponsiveAttachment.fromBuffer(readableStream) 1204 | responsiveAttachment.setOptions({ preComputeUrls: true }) 1205 | await responsiveAttachment.save() 1206 | 1207 | assert.isTrue(responsiveAttachment.isPersisted) 1208 | assert.isTrue(responsiveAttachment.isLocal) 1209 | 1210 | assert.isDefined(responsiveAttachment?.url) 1211 | assert.isUndefined(responsiveAttachment?.breakpoints?.large.url) 1212 | assert.isUndefined(responsiveAttachment?.breakpoints?.medium.url) 1213 | assert.isUndefined(responsiveAttachment?.breakpoints?.small.url) 1214 | assert.isUndefined(responsiveAttachment?.breakpoints?.thumbnail.url) 1215 | 1216 | assert.isTrue(await Drive.exists(responsiveAttachment?.name!)) 1217 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.large.name!)) 1218 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.medium.name!)) 1219 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.small.name!)) 1220 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.thumbnail.name!)) 1221 | 1222 | assert.isTrue( 1223 | responsiveAttachment?.breakpoints!.thumbnail.size! < 1224 | responsiveAttachment?.breakpoints!.small.size! 1225 | ) 1226 | assert.isTrue( 1227 | responsiveAttachment?.breakpoints!.small.size! < 1228 | responsiveAttachment?.breakpoints!.medium.size! 1229 | ) 1230 | assert.isTrue( 1231 | responsiveAttachment?.breakpoints!.medium.size! < 1232 | responsiveAttachment?.breakpoints!.large.size! 1233 | ) 1234 | assert.isTrue(responsiveAttachment?.breakpoints!.large.size! < responsiveAttachment?.size!) 1235 | 1236 | ctx.response.send(responsiveAttachment) 1237 | ctx.response.finish() 1238 | }) 1239 | }) 1240 | 1241 | const { body } = await supertest(server).post('/') 1242 | 1243 | assert.isDefined(body.url) 1244 | }) 1245 | 1246 | test('delete local images', async (assert) => { 1247 | const Drive = app.container.resolveBinding('Adonis/Core/Drive') 1248 | 1249 | const server = createServer((req, res) => { 1250 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 1251 | 1252 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 1253 | const readableStream = await readFile( 1254 | join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg') 1255 | ) 1256 | const responsiveAttachment = await ResponsiveAttachment.fromBuffer(readableStream) 1257 | responsiveAttachment.setOptions(undefined) 1258 | await responsiveAttachment.save() 1259 | await responsiveAttachment.delete() 1260 | 1261 | assert.isFalse(responsiveAttachment?.isPersisted) 1262 | assert.isTrue(responsiveAttachment?.isLocal) 1263 | assert.isTrue(responsiveAttachment?.isDeleted) 1264 | 1265 | ctx.response.send(responsiveAttachment) 1266 | ctx.response.finish() 1267 | }) 1268 | }) 1269 | 1270 | const { body } = await supertest(server).post('/') 1271 | 1272 | assert.isFalse(await Drive.exists(body.name)) 1273 | assert.isFalse(await Drive.exists(body.breakpoints.large.name)) 1274 | assert.isFalse(await Drive.exists(body.breakpoints.medium.name)) 1275 | assert.isFalse(await Drive.exists(body.breakpoints.small.name)) 1276 | assert.isFalse(await Drive.exists(body.breakpoints.thumbnail.name)) 1277 | }) 1278 | }) 1279 | 1280 | test.group('ResponsiveAttachment | errors', (group) => { 1281 | group.before(async () => { 1282 | app = await setupApplication() 1283 | await setup(app) 1284 | 1285 | app.container.resolveBinding('Adonis/Core/Route').commit() 1286 | ResponsiveAttachment.setDrive(app.container.resolveBinding('Adonis/Core/Drive')) 1287 | ResponsiveAttachment.setLogger(app.container.resolveBinding('Adonis/Core/Logger')) 1288 | }) 1289 | 1290 | group.beforeEach(async () => { 1291 | await setup(app) 1292 | }) 1293 | 1294 | group.afterEach(async () => { 1295 | await rollbackDB(app) 1296 | }) 1297 | 1298 | group.after(async () => { 1299 | await cleanup(app) 1300 | }) 1301 | 1302 | test('throw error if unallowed file type is provided to `fromBuffer` method', async (assert) => { 1303 | const server = createServer((req, res) => { 1304 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 1305 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 1306 | assert.plan(1) 1307 | 1308 | const readableStream = await readFile(join(__dirname, '../unallowed_file.pdf')) 1309 | try { 1310 | const responsiveAttachment = await ResponsiveAttachment.fromBuffer(readableStream) 1311 | ctx.response.send(responsiveAttachment) 1312 | ctx.response.finish() 1313 | } catch (error) { 1314 | assert.equal( 1315 | error.message, 1316 | `Uploaded file is not an allowable image. Make sure that you uploaded only the following format: "jpeg", "png", "webp", "tiff", and "avif".` 1317 | ) 1318 | ctx.response.send(error) 1319 | ctx.response.finish() 1320 | } 1321 | }) 1322 | }) 1323 | 1324 | await supertest(server).post('/') 1325 | }) 1326 | 1327 | test('throw error if unallowed file type is provided to `fromFile` method', async (assert) => { 1328 | const server = createServer((req, res) => { 1329 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 1330 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 1331 | assert.plan(1) 1332 | 1333 | const file = ctx.request.file('avatar')! 1334 | try { 1335 | const responsiveAttachment = await ResponsiveAttachment.fromFile(file) 1336 | ctx.response.send(responsiveAttachment) 1337 | ctx.response.finish() 1338 | } catch (error) { 1339 | assert.equal( 1340 | error.message, 1341 | `[Adonis Responsive Attachment] Uploaded file is not an allowable image. Make sure that you uploaded only the following format: "jpeg", "png", "webp", "tiff", and "avif".` 1342 | ) 1343 | ctx.response.send(error) 1344 | ctx.response.finish() 1345 | } 1346 | }) 1347 | }) 1348 | 1349 | await supertest(server).post('/').attach('avatar', join(__dirname, '../unallowed_file.pdf')) 1350 | }) 1351 | 1352 | test('throw error if a `falsy` value is provided to `fromFile` method', async (assert) => { 1353 | const server = createServer((req, res) => { 1354 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 1355 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 1356 | assert.plan(1) 1357 | 1358 | try { 1359 | const responsiveAttachment = await ResponsiveAttachment.fromFile(undefined!) 1360 | ctx.response.send(responsiveAttachment) 1361 | ctx.response.finish() 1362 | } catch (error) { 1363 | assert.equal(error.message, 'You should provide a non-falsy value') 1364 | ctx.response.send(error) 1365 | ctx.response.finish() 1366 | } 1367 | }) 1368 | }) 1369 | 1370 | await supertest(server).post('/') 1371 | }) 1372 | }) 1373 | 1374 | test.group('Do not generate save original image when `options.keepOriginal` is false', (group) => { 1375 | group.before(async () => { 1376 | app = await setupApplication() 1377 | app.container.resolveBinding('Adonis/Core/Route').commit() 1378 | ResponsiveAttachment.setDrive(app.container.resolveBinding('Adonis/Core/Drive')) 1379 | ResponsiveAttachment.setLogger(app.container.resolveBinding('Adonis/Core/Logger')) 1380 | }) 1381 | 1382 | group.beforeEach(async () => { 1383 | await setup(app) 1384 | }) 1385 | 1386 | group.afterEach(async () => { 1387 | await rollbackDB(app) 1388 | }) 1389 | 1390 | group.after(async () => { 1391 | await cleanup(app) 1392 | }) 1393 | 1394 | test('create attachment from the user uploaded image', async (assert) => { 1395 | const Drive = app.container.resolveBinding('Adonis/Core/Drive') 1396 | 1397 | const server = createServer((req, res) => { 1398 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 1399 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 1400 | const file = ctx.request.file('avatar')! 1401 | const responsiveAttachment = await ResponsiveAttachment.fromFile(file) 1402 | responsiveAttachment?.setOptions({ keepOriginal: false }) 1403 | await responsiveAttachment?.save() 1404 | 1405 | assert.isTrue(responsiveAttachment?.isPersisted) 1406 | assert.isTrue(responsiveAttachment?.isLocal) 1407 | 1408 | assert.isUndefined(responsiveAttachment?.name) 1409 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.thumbnail.name!)) 1410 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.small.name!)) 1411 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.medium.name!)) 1412 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.large.name!)) 1413 | 1414 | ctx.response.send(responsiveAttachment) 1415 | ctx.response.finish() 1416 | }) 1417 | }) 1418 | 1419 | const { body } = await supertest(server) 1420 | .post('/') 1421 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 1422 | 1423 | assert.notExists(body.name) 1424 | assert.notExists(body.size) 1425 | assert.notExists(body.width) 1426 | assert.notExists(body.format) 1427 | assert.notExists(body.height) 1428 | assert.notExists(body.extname) 1429 | assert.notExists(body.mimeType) 1430 | assert.notExists(body.url) 1431 | }) 1432 | 1433 | test('pre-compute urls for newly-created images', async (assert) => { 1434 | const Drive = app.container.resolveBinding('Adonis/Core/Drive') 1435 | 1436 | const server = createServer((req, res) => { 1437 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 1438 | 1439 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 1440 | const file = ctx.request.file('avatar')! 1441 | const responsiveAttachment = await ResponsiveAttachment.fromFile(file) 1442 | responsiveAttachment?.setOptions({ 1443 | preComputeUrls: true, 1444 | keepOriginal: false, 1445 | }) 1446 | await responsiveAttachment?.save() 1447 | 1448 | assert.isTrue(responsiveAttachment?.isPersisted) 1449 | assert.isTrue(responsiveAttachment?.isLocal) 1450 | 1451 | assert.isUndefined(responsiveAttachment?.url) 1452 | assert.isUndefined(responsiveAttachment?.name!) 1453 | 1454 | assert.isUndefined(responsiveAttachment?.breakpoints?.large.url) 1455 | assert.isUndefined(responsiveAttachment?.breakpoints?.medium.url) 1456 | assert.isUndefined(responsiveAttachment?.breakpoints?.small.url) 1457 | assert.isUndefined(responsiveAttachment?.breakpoints?.thumbnail.url) 1458 | 1459 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.large.name!)) 1460 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.medium.name!)) 1461 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.small.name!)) 1462 | assert.isTrue(await Drive.exists(responsiveAttachment?.breakpoints?.thumbnail.name!)) 1463 | 1464 | assert.isTrue( 1465 | responsiveAttachment?.breakpoints!.thumbnail.size! < 1466 | responsiveAttachment?.breakpoints!.small.size! 1467 | ) 1468 | assert.isTrue( 1469 | responsiveAttachment?.breakpoints!.small.size! < 1470 | responsiveAttachment?.breakpoints!.medium.size! 1471 | ) 1472 | assert.isTrue( 1473 | responsiveAttachment?.breakpoints!.medium.size! < 1474 | responsiveAttachment?.breakpoints!.large.size! 1475 | ) 1476 | 1477 | ctx.response.send(responsiveAttachment) 1478 | ctx.response.finish() 1479 | }) 1480 | }) 1481 | 1482 | const { body } = await supertest(server) 1483 | .post('/') 1484 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 1485 | 1486 | assert.notExists(body.name) 1487 | assert.notExists(body.size) 1488 | assert.notExists(body.width) 1489 | assert.notExists(body.format) 1490 | assert.notExists(body.height) 1491 | assert.notExists(body.extname) 1492 | assert.notExists(body.mimeType) 1493 | assert.notExists(body.url) 1494 | }) 1495 | }) 1496 | 1497 | test.group('Other checks', (group) => { 1498 | group.before(async () => { 1499 | app = await setupApplication() 1500 | app.container.resolveBinding('Adonis/Core/Route').commit() 1501 | ResponsiveAttachment.setDrive(app.container.resolveBinding('Adonis/Core/Drive')) 1502 | ResponsiveAttachment.setLogger(app.container.resolveBinding('Adonis/Core/Logger')) 1503 | }) 1504 | 1505 | group.beforeEach(async () => { 1506 | await setup(app) 1507 | }) 1508 | 1509 | group.afterEach(async () => { 1510 | await rollbackDB(app) 1511 | }) 1512 | 1513 | group.after(async () => { 1514 | await cleanup(app) 1515 | }) 1516 | 1517 | test('change the folder for an upload at run-time', async (assert) => { 1518 | const server = createServer((req, res) => { 1519 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 1520 | const { column, BaseModel } = app.container.use('Adonis/Lucid/Orm') 1521 | 1522 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 1523 | class User extends BaseModel { 1524 | @column({ isPrimary: true }) 1525 | public id: string 1526 | 1527 | @column() 1528 | public username: string 1529 | 1530 | @Attachment({ folder: 'a' }) 1531 | public avatar: ResponsiveAttachmentContract | null 1532 | } 1533 | 1534 | const file = ctx.request.file('avatar')! 1535 | const user = new User() 1536 | user.username = 'Ndianabasi' 1537 | user.avatar = await ResponsiveAttachment.fromFile(file) 1538 | user.avatar.setOptions({ folder: 'a/b/c' }) 1539 | await user.save() 1540 | 1541 | assert.include(user.avatar?.name!, 'a/b/c') 1542 | assert.include(user.avatar?.breakpoints?.thumbnail.name!, 'a/b/c') 1543 | assert.include(user.avatar?.breakpoints?.small.name!, 'a/b/c') 1544 | assert.include(user.avatar?.breakpoints?.medium.name!, 'a/b/c') 1545 | assert.include(user.avatar?.breakpoints?.large.name!, 'a/b/c') 1546 | 1547 | ctx.response.send(user) 1548 | ctx.response.finish() 1549 | }) 1550 | }) 1551 | 1552 | await supertest(server) 1553 | .post('/') 1554 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 1555 | }) 1556 | 1557 | test('ensure urls are return when "preComputeUrls" is "true"', async (assert) => { 1558 | const server = createServer((req, res) => { 1559 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 1560 | const { column, BaseModel } = app.container.use('Adonis/Lucid/Orm') 1561 | 1562 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 1563 | class User extends BaseModel { 1564 | @column({ isPrimary: true }) 1565 | public id: string 1566 | 1567 | @column() 1568 | public username: string 1569 | 1570 | @Attachment({ folder: 'a' }) 1571 | public avatar: ResponsiveAttachmentContract | null 1572 | } 1573 | 1574 | const file = ctx.request.file('avatar')! 1575 | const user = new User() 1576 | user.username = 'Ndianabasi' 1577 | user.avatar = await ResponsiveAttachment.fromFile(file) 1578 | user.avatar.setOptions({ folder: 'a/b/c', preComputeUrls: true }) 1579 | await user.save() 1580 | 1581 | ctx.response.send(user) 1582 | ctx.response.finish() 1583 | }) 1584 | }) 1585 | 1586 | const response = await supertest(server) 1587 | .post('/') 1588 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 1589 | 1590 | const body = response.body 1591 | 1592 | assert.isDefined(body.avatar.url) 1593 | assert.isDefined(body.avatar.breakpoints.thumbnail.url) 1594 | assert.isDefined(body.avatar.breakpoints.large.url) 1595 | assert.isDefined(body.avatar.breakpoints.medium.url) 1596 | assert.isDefined(body.avatar.breakpoints.small.url) 1597 | }) 1598 | 1599 | test('ensure urls are not persisted to the database', async (assert) => { 1600 | const { column, BaseModel } = app.container.use('Adonis/Lucid/Orm') 1601 | 1602 | class User extends BaseModel { 1603 | @column({ isPrimary: true }) 1604 | public id: string 1605 | 1606 | @column() 1607 | public username: string 1608 | 1609 | @Attachment({ folder: 'a' }) 1610 | public avatar: ResponsiveAttachmentContract | null 1611 | } 1612 | 1613 | const server = createServer((req, res) => { 1614 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 1615 | 1616 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 1617 | const file = ctx.request.file('avatar')! 1618 | const user = new User() 1619 | user.username = 'Ndianabasi' 1620 | user.avatar = await ResponsiveAttachment.fromFile(file) 1621 | user.avatar.setOptions({ folder: 'a/b/c', preComputeUrls: true }) 1622 | await user.save() 1623 | 1624 | ctx.response.send(user) 1625 | ctx.response.finish() 1626 | }) 1627 | }) 1628 | 1629 | const response = await supertest(server) 1630 | .post('/') 1631 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 1632 | 1633 | const createdUser = await User.findOrFail(response.body.id) 1634 | 1635 | assert.isUndefined(createdUser.avatar!.url) 1636 | assert.isUndefined(createdUser.avatar!.breakpoints!.thumbnail.url) 1637 | assert.isUndefined(createdUser.avatar!.breakpoints!.large.url) 1638 | assert.isUndefined(createdUser.avatar!.breakpoints!.medium.url) 1639 | assert.isUndefined(createdUser.avatar!.breakpoints!.small.url) 1640 | }) 1641 | 1642 | test('ensure urls can be computed with `getUrls()`', async (assert) => { 1643 | const { column, BaseModel } = app.container.use('Adonis/Lucid/Orm') 1644 | 1645 | class User extends BaseModel { 1646 | @column({ isPrimary: true }) 1647 | public id: string 1648 | 1649 | @column() 1650 | public username: string 1651 | 1652 | @Attachment() 1653 | public avatar: ResponsiveAttachmentContract | null 1654 | } 1655 | 1656 | const server = createServer((req, res) => { 1657 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 1658 | 1659 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 1660 | const file = ctx.request.file('avatar')! 1661 | const user = new User() 1662 | user.username = 'Ndianabasi' 1663 | user.avatar = await ResponsiveAttachment.fromFile(file) 1664 | await user.save() 1665 | 1666 | ctx.response.send(user) 1667 | ctx.response.finish() 1668 | }) 1669 | }) 1670 | 1671 | const response = await supertest(server) 1672 | .post('/') 1673 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 1674 | 1675 | const createdUser = await User.findOrFail(response.body.id) 1676 | const urls = await createdUser.avatar!.getUrls() 1677 | 1678 | assert.isDefined(urls!.url) 1679 | assert.isDefined(urls!.breakpoints!.thumbnail.url) 1680 | assert.isDefined(urls!.breakpoints!.large.url) 1681 | assert.isDefined(urls!.breakpoints!.medium.url) 1682 | assert.isDefined(urls!.breakpoints!.small.url) 1683 | }) 1684 | 1685 | test('should not include original attributes and url when "keepOriginal" is "false"', async (assert) => { 1686 | const { column, BaseModel } = app.container.use('Adonis/Lucid/Orm') 1687 | 1688 | class User extends BaseModel { 1689 | @column({ isPrimary: true }) 1690 | public id: string 1691 | 1692 | @column() 1693 | public username: string 1694 | 1695 | @Attachment({ folder: 'a', keepOriginal: false }) 1696 | public avatar: ResponsiveAttachmentContract | null 1697 | } 1698 | 1699 | const server = createServer((req, res) => { 1700 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 1701 | 1702 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 1703 | const file = ctx.request.file('avatar')! 1704 | const user = new User() 1705 | user.username = 'Ndianabasi' 1706 | user.avatar = await ResponsiveAttachment.fromFile(file) 1707 | await user.save() 1708 | await user.refresh() 1709 | 1710 | ctx.response.send(user) 1711 | ctx.response.finish() 1712 | }) 1713 | }) 1714 | 1715 | const response = await supertest(server) 1716 | .post('/') 1717 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 1718 | 1719 | const createdUser = await User.findOrFail(response.body.id) 1720 | let avatar = createdUser.toJSON().avatar 1721 | assert.isUndefined(avatar!.url) 1722 | assert.isUndefined(avatar!.breakpoints!.thumbnail.url) 1723 | assert.isUndefined(avatar!.breakpoints!.large.url) 1724 | assert.isUndefined(avatar!.breakpoints!.medium.url) 1725 | assert.isUndefined(avatar!.breakpoints!.small.url) 1726 | 1727 | await createdUser.avatar!.getUrls() 1728 | avatar = createdUser.toJSON().avatar 1729 | 1730 | assert.isUndefined(avatar!.url) 1731 | assert.isUndefined(avatar!.name) 1732 | assert.isDefined(avatar!.breakpoints!.thumbnail.url) 1733 | assert.isDefined(avatar!.breakpoints!.large.url) 1734 | assert.isDefined(avatar!.breakpoints!.medium.url) 1735 | assert.isDefined(avatar!.breakpoints!.small.url) 1736 | }) 1737 | 1738 | test('accept custom file name in the "fromFile" method', async (assert) => { 1739 | const server = createServer((req, res) => { 1740 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 1741 | const { column, BaseModel } = app.container.use('Adonis/Lucid/Orm') 1742 | 1743 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 1744 | class User extends BaseModel { 1745 | @column({ isPrimary: true }) 1746 | public id: string 1747 | 1748 | @column() 1749 | public username: string 1750 | 1751 | @Attachment() 1752 | public avatar: ResponsiveAttachmentContract | null 1753 | } 1754 | 1755 | const file = ctx.request.file('avatar')! 1756 | const user = new User() 1757 | user.username = 'Ndianabasi' 1758 | user.avatar = await ResponsiveAttachment.fromFile(file, 'Ndianabasi Udonkang') 1759 | await user.save() 1760 | 1761 | assert.include(user.avatar?.name!, 'ndianabasi_udonkang') 1762 | assert.include(user.avatar?.breakpoints?.thumbnail.name!, 'ndianabasi_udonkang') 1763 | assert.include(user.avatar?.breakpoints?.small.name!, 'ndianabasi_udonkang') 1764 | assert.include(user.avatar?.breakpoints?.medium.name!, 'ndianabasi_udonkang') 1765 | assert.include(user.avatar?.breakpoints?.large.name!, 'ndianabasi_udonkang') 1766 | 1767 | ctx.response.send(user) 1768 | ctx.response.finish() 1769 | }) 1770 | }) 1771 | 1772 | await supertest(server) 1773 | .post('/') 1774 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 1775 | }) 1776 | 1777 | test('accept custom file name in the "fromBuffer" method', async (assert) => { 1778 | const server = createServer((req, res) => { 1779 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 1780 | const { column, BaseModel } = app.container.use('Adonis/Lucid/Orm') 1781 | 1782 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 1783 | class User extends BaseModel { 1784 | @column({ isPrimary: true }) 1785 | public id: string 1786 | 1787 | @column() 1788 | public username: string 1789 | 1790 | @Attachment() 1791 | public avatar: ResponsiveAttachmentContract | null 1792 | } 1793 | 1794 | const readableStream = await readFile( 1795 | join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg') 1796 | ) 1797 | 1798 | const user = new User() 1799 | user.username = 'Ndianabasi' 1800 | user.avatar = await ResponsiveAttachment.fromBuffer(readableStream, 'Ndianabasi Udonkang') 1801 | await user.save() 1802 | 1803 | assert.include(user.avatar?.name!, 'ndianabasi_udonkang') 1804 | assert.include(user.avatar?.breakpoints?.thumbnail.name!, 'ndianabasi_udonkang') 1805 | assert.include(user.avatar?.breakpoints?.small.name!, 'ndianabasi_udonkang') 1806 | assert.include(user.avatar?.breakpoints?.medium.name!, 'ndianabasi_udonkang') 1807 | assert.include(user.avatar?.breakpoints?.large.name!, 'ndianabasi_udonkang') 1808 | 1809 | ctx.response.send(user) 1810 | ctx.response.finish() 1811 | }) 1812 | }) 1813 | 1814 | await supertest(server).post('/') 1815 | }) 1816 | }) 1817 | 1818 | test.group('ResponsiveAttachment | Blurhash', (group) => { 1819 | group.before(async () => { 1820 | app = await setupApplication() 1821 | await setup(app) 1822 | 1823 | app.container.resolveBinding('Adonis/Core/Route').commit() 1824 | ResponsiveAttachment.setDrive(app.container.resolveBinding('Adonis/Core/Drive')) 1825 | ResponsiveAttachment.setLogger(app.container.resolveBinding('Adonis/Core/Logger')) 1826 | }) 1827 | 1828 | group.beforeEach(async () => { 1829 | await setup(app) 1830 | }) 1831 | 1832 | group.afterEach(async () => { 1833 | await rollbackDB(app) 1834 | }) 1835 | 1836 | group.after(async () => { 1837 | await cleanup(app) 1838 | }) 1839 | 1840 | test('should create attachment with blurhash string in all responsive formats when enabled', async (assert) => { 1841 | const server = createServer((req, res) => { 1842 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 1843 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 1844 | const file = ctx.request.file('avatar')! 1845 | const responsiveAttachment = await ResponsiveAttachment.fromFile(file) 1846 | responsiveAttachment?.setOptions({ blurhash: { enabled: true } }) 1847 | await responsiveAttachment?.save() 1848 | 1849 | assert.isNotEmpty(responsiveAttachment.blurhash) 1850 | assert.isNotEmpty(responsiveAttachment.breakpoints?.small.blurhash) 1851 | assert.isNotEmpty(responsiveAttachment.breakpoints?.large.blurhash) 1852 | assert.isNotEmpty(responsiveAttachment.breakpoints?.medium.blurhash) 1853 | assert.isNotEmpty(responsiveAttachment.breakpoints?.thumbnail.blurhash) 1854 | 1855 | ctx.response.send(responsiveAttachment) 1856 | ctx.response.finish() 1857 | }) 1858 | }) 1859 | 1860 | const { body } = await supertest(server) 1861 | .post('/') 1862 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 1863 | 1864 | assert.isNotEmpty(body.blurhash) 1865 | assert.isNotEmpty(body.breakpoints?.small.blurhash) 1866 | assert.isNotEmpty(body.breakpoints?.large.blurhash) 1867 | assert.isNotEmpty(body.breakpoints?.medium.blurhash) 1868 | assert.isNotEmpty(body.breakpoints?.thumbnail.blurhash) 1869 | // Check that blurhash is valid 1870 | assert.isTrue(isBlurhashValid(body.blurhash).result) 1871 | // All blurhashes should be the same 1872 | assert.isTrue( 1873 | body.blurhash === body.breakpoints?.small.blurhash && 1874 | body.blurhash === body.breakpoints?.medium.blurhash && 1875 | body.blurhash === body.breakpoints?.large.blurhash && 1876 | body.blurhash === body.breakpoints?.thumbnail.blurhash 1877 | ) 1878 | }) 1879 | 1880 | test('should not create attachment with blurhash string when disabled', async (assert) => { 1881 | const server = createServer((req, res) => { 1882 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 1883 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 1884 | const file = ctx.request.file('avatar')! 1885 | const responsiveAttachment = await ResponsiveAttachment.fromFile(file) 1886 | // `blurhash` generation should be disabled if not specified 1887 | // at all 1888 | responsiveAttachment?.setOptions({}) 1889 | await responsiveAttachment?.save() 1890 | 1891 | assert.isUndefined(responsiveAttachment.blurhash) 1892 | assert.isUndefined(responsiveAttachment.breakpoints?.small.blurhash) 1893 | assert.isUndefined(responsiveAttachment.breakpoints?.large.blurhash) 1894 | assert.isUndefined(responsiveAttachment.breakpoints?.medium.blurhash) 1895 | assert.isUndefined(responsiveAttachment.breakpoints?.thumbnail.blurhash) 1896 | 1897 | ctx.response.send(responsiveAttachment) 1898 | ctx.response.finish() 1899 | }) 1900 | }) 1901 | 1902 | const { body } = await supertest(server) 1903 | .post('/') 1904 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 1905 | 1906 | assert.isUndefined(body.blurhash) 1907 | assert.isUndefined(body.breakpoints?.small.blurhash) 1908 | assert.isUndefined(body.breakpoints?.large.blurhash) 1909 | assert.isUndefined(body.breakpoints?.medium.blurhash) 1910 | assert.isUndefined(body.breakpoints?.thumbnail.blurhash) 1911 | }) 1912 | }) 1913 | -------------------------------------------------------------------------------- /test/validator.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * adonis-responsive-attachment 3 | * 4 | * (c) Ndianabasi Udonkang 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import 'reflect-metadata' 11 | 12 | import test from 'japa' 13 | import { join } from 'path' 14 | import supertest from 'supertest' 15 | import { createServer } from 'http' 16 | import { ResponsiveAttachment } from '../src/Attachment/index' 17 | import { setup, cleanup, setupApplication, rollbackDB } from '../test-helpers' 18 | import { ApplicationContract } from '@ioc:Adonis/Core/Application' 19 | import { BodyParserMiddleware } from '@adonisjs/bodyparser/build/src/BodyParser' 20 | import { extendValidator } from '../src/Bindings/Validator' 21 | import { validator } from '@adonisjs/validator/build/src/Validator' 22 | 23 | let app: ApplicationContract 24 | 25 | test.group('ResponsiveAttachment | Validator | Failures', (group) => { 26 | group.before(async () => { 27 | app = await setupApplication() 28 | app.container.resolveBinding('Adonis/Core/Route').commit() 29 | ResponsiveAttachment.setDrive(app.container.resolveBinding('Adonis/Core/Drive')) 30 | ResponsiveAttachment.setLogger(app.container.resolveBinding('Adonis/Core/Logger')) 31 | extendValidator(validator, app.logger) 32 | }) 33 | 34 | group.beforeEach(async () => { 35 | await setup(app) 36 | }) 37 | 38 | group.afterEach(async () => { 39 | await rollbackDB(app) 40 | }) 41 | 42 | group.after(async () => { 43 | await cleanup(app) 44 | }) 45 | 46 | const dataset = ['default_message', 'custom_message'] as const 47 | 48 | test('should return validation error if image is below the minimum image width', async (assert) => { 49 | for (const condition of dataset) { 50 | const useDefaultMessage = condition === 'default_message' 51 | 52 | const server = createServer((req, res) => { 53 | assert.plan(2) 54 | 55 | const ctx = app.container 56 | .resolveBinding('Adonis/Core/HttpContext') 57 | .create('/', {}, req, res) 58 | 59 | const { rules, schema } = app.container.resolveBinding('Adonis/Core/Validator') 60 | 61 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 62 | try { 63 | await ctx.request.validate({ 64 | schema: schema.create({ avatar: schema.file(undefined, [rules.minImageWidth(520)]) }), 65 | messages: useDefaultMessage 66 | ? undefined 67 | : { 68 | 'avatar.minImageWidth': 'Minimum image width is {{ options.minImageWidth }}px', 69 | }, 70 | }) 71 | } catch (error) { 72 | assert.deepEqual(error.messages, { 73 | avatar: [ 74 | useDefaultMessage 75 | ? 'minImageWidth validation failure' 76 | : 'Minimum image width is 520px', 77 | ], 78 | }) 79 | } 80 | ctx.response.finish() 81 | }) 82 | }) 83 | 84 | await supertest(server) 85 | .post('/') 86 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-150x100.jpg')) 87 | } 88 | }) 89 | 90 | test('should return validation error if image is below the minimum image height', async (assert) => { 91 | for (const condition of dataset) { 92 | const useDefaultMessage = condition === 'default_message' 93 | 94 | const server = createServer((req, res) => { 95 | assert.plan(2) 96 | 97 | const ctx = app.container 98 | .resolveBinding('Adonis/Core/HttpContext') 99 | .create('/', {}, req, res) 100 | 101 | const { rules, schema } = app.container.resolveBinding('Adonis/Core/Validator') 102 | 103 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 104 | try { 105 | await ctx.request.validate({ 106 | schema: schema.create({ 107 | avatar: schema.file(undefined, [rules.minImageHeight(720)]), 108 | }), 109 | messages: useDefaultMessage 110 | ? undefined 111 | : { 112 | 'avatar.minImageHeight': 113 | 'Minimum image height is {{ options.minImageHeight }}px', 114 | }, 115 | }) 116 | } catch (error) { 117 | assert.deepEqual(error.messages, { 118 | avatar: [ 119 | useDefaultMessage 120 | ? 'minImageHeight validation failure' 121 | : 'Minimum image height is 720px', 122 | ], 123 | }) 124 | } 125 | ctx.response.finish() 126 | }) 127 | }) 128 | 129 | await supertest(server) 130 | .post('/') 131 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-150x100.jpg')) 132 | } 133 | }) 134 | 135 | test('should return validation error if image is above the maximum image width', async (assert) => { 136 | for (const condition of dataset) { 137 | const useDefaultMessage = condition === 'default_message' 138 | 139 | const server = createServer((req, res) => { 140 | assert.plan(2) 141 | 142 | const ctx = app.container 143 | .resolveBinding('Adonis/Core/HttpContext') 144 | .create('/', {}, req, res) 145 | 146 | const { rules, schema } = app.container.resolveBinding('Adonis/Core/Validator') 147 | 148 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 149 | try { 150 | await ctx.request.validate({ 151 | schema: schema.create({ avatar: schema.file(undefined, [rules.maxImageWidth(720)]) }), 152 | messages: useDefaultMessage 153 | ? undefined 154 | : { 155 | 'avatar.maxImageWidth': 'Maximum image width is {{ options.maxImageWidth }}px', 156 | }, 157 | }) 158 | } catch (error) { 159 | assert.deepEqual(error.messages, { 160 | avatar: [ 161 | useDefaultMessage 162 | ? 'maxImageWidth validation failure' 163 | : 'Maximum image width is 720px', 164 | ], 165 | }) 166 | } 167 | ctx.response.finish() 168 | }) 169 | }) 170 | 171 | await supertest(server) 172 | .post('/') 173 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 174 | } 175 | }) 176 | 177 | test('should return validation error if image is above the maximum image height', async (assert) => { 178 | for (const condition of dataset) { 179 | const useDefaultMessage = condition === 'default_message' 180 | 181 | const server = createServer((req, res) => { 182 | assert.plan(2) 183 | 184 | const ctx = app.container 185 | .resolveBinding('Adonis/Core/HttpContext') 186 | .create('/', {}, req, res) 187 | 188 | const { rules, schema } = app.container.resolveBinding('Adonis/Core/Validator') 189 | 190 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 191 | try { 192 | await ctx.request.validate({ 193 | schema: schema.create({ 194 | avatar: schema.file(undefined, [rules.maxImageHeight(720)]), 195 | }), 196 | messages: useDefaultMessage 197 | ? undefined 198 | : { 199 | 'avatar.maxImageHeight': 200 | 'Maximum image height is {{ options.maxImageHeight }}px', 201 | }, 202 | }) 203 | } catch (error) { 204 | assert.deepEqual(error.messages, { 205 | avatar: [ 206 | useDefaultMessage 207 | ? 'maxImageHeight validation failure' 208 | : 'Maximum image height is 720px', 209 | ], 210 | }) 211 | } 212 | ctx.response.finish() 213 | }) 214 | }) 215 | 216 | await supertest(server) 217 | .post('/') 218 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 219 | } 220 | }) 221 | 222 | test('should return validation error if image does not match the aspect ratio', async (assert) => { 223 | for (const condition of dataset) { 224 | const useDefaultMessage = condition === 'default_message' 225 | 226 | const server = createServer((req, res) => { 227 | assert.plan(2) 228 | 229 | const ctx = app.container 230 | .resolveBinding('Adonis/Core/HttpContext') 231 | .create('/', {}, req, res) 232 | 233 | const { rules, schema } = app.container.resolveBinding('Adonis/Core/Validator') 234 | 235 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 236 | try { 237 | await ctx.request.validate({ 238 | schema: schema.create({ 239 | avatar: schema.file(undefined, [rules.imageAspectRatio(2.45)]), 240 | }), 241 | messages: useDefaultMessage 242 | ? undefined 243 | : { 244 | 'avatar.imageAspectRatio': 245 | 'Required image aspect-ratio is {{ options.imageAspectRatio }}', 246 | }, 247 | }) 248 | } catch (error) { 249 | assert.deepEqual(error.messages, { 250 | avatar: [ 251 | useDefaultMessage 252 | ? 'imageAspectRatio validation failure' 253 | : 'Required image aspect-ratio is 2.45', 254 | ], 255 | }) 256 | } 257 | ctx.response.finish() 258 | }) 259 | }) 260 | 261 | await supertest(server) 262 | .post('/') 263 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 264 | } 265 | }) 266 | 267 | test('should return validation error if validation value is not provided', async (assert) => { 268 | const server = createServer((req, res) => { 269 | assert.plan(1) 270 | 271 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 272 | 273 | const { rules, schema } = app.container.resolveBinding('Adonis/Core/Validator') 274 | 275 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 276 | try { 277 | await ctx.request.validate({ 278 | schema: schema.create({ 279 | // @ts-expect-error 280 | avatar: schema.file(undefined, [rules.imageAspectRatio()]), 281 | }), 282 | }) 283 | } catch (error) { 284 | assert.equal(error.message, '"imageAspectRatio" rule expects a "validationValue"') 285 | } 286 | ctx.response.finish() 287 | }) 288 | }) 289 | 290 | await supertest(server) 291 | .post('/') 292 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 293 | }) 294 | }) 295 | 296 | test.group('ResponsiveAttachment | Validator | Successes', (group) => { 297 | group.before(async () => { 298 | app = await setupApplication() 299 | app.container.resolveBinding('Adonis/Core/Route').commit() 300 | ResponsiveAttachment.setDrive(app.container.resolveBinding('Adonis/Core/Drive')) 301 | ResponsiveAttachment.setLogger(app.container.resolveBinding('Adonis/Core/Logger')) 302 | extendValidator(validator, app.logger) 303 | }) 304 | 305 | group.beforeEach(async () => { 306 | await setup(app) 307 | }) 308 | 309 | group.afterEach(async () => { 310 | await rollbackDB(app) 311 | }) 312 | 313 | group.after(async () => { 314 | await cleanup(app) 315 | }) 316 | 317 | test('should not throw validation error if image is above the minimum width', async (assert) => { 318 | const server = createServer((req, res) => { 319 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 320 | 321 | const { rules, schema } = app.container.resolveBinding('Adonis/Core/Validator') 322 | 323 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 324 | const payload = await ctx.request.validate({ 325 | schema: schema.create({ avatar: schema.file(undefined, [rules.minImageWidth(520)]) }), 326 | }) 327 | assert.isDefined(payload.avatar) 328 | ctx.response.finish() 329 | }) 330 | }) 331 | 332 | await supertest(server) 333 | .post('/') 334 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 335 | }) 336 | 337 | test('should not throw validation error if image is above the minimum image height', async (assert) => { 338 | const server = createServer((req, res) => { 339 | assert.plan(1) 340 | 341 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 342 | 343 | const { rules, schema } = app.container.resolveBinding('Adonis/Core/Validator') 344 | 345 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 346 | const payload = await ctx.request.validate({ 347 | schema: schema.create({ avatar: schema.file(undefined, [rules.minImageHeight(520)]) }), 348 | }) 349 | assert.isDefined(payload.avatar) 350 | ctx.response.finish() 351 | }) 352 | }) 353 | 354 | await supertest(server) 355 | .post('/') 356 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 357 | }) 358 | 359 | test('should not throw validation error if image is below the maximum image width', async (assert) => { 360 | const server = createServer((req, res) => { 361 | assert.plan(1) 362 | 363 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 364 | 365 | const { rules, schema } = app.container.resolveBinding('Adonis/Core/Validator') 366 | 367 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 368 | const payload = await ctx.request.validate({ 369 | schema: schema.create({ avatar: schema.file(undefined, [rules.maxImageWidth(520)]) }), 370 | }) 371 | assert.isDefined(payload.avatar) 372 | ctx.response.finish() 373 | }) 374 | }) 375 | 376 | await supertest(server) 377 | .post('/') 378 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-150x100.jpg')) 379 | }) 380 | 381 | test('should not throw validation error if image is below the maximum image height', async (assert) => { 382 | const server = createServer((req, res) => { 383 | assert.plan(1) 384 | 385 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 386 | 387 | const { rules, schema } = app.container.resolveBinding('Adonis/Core/Validator') 388 | 389 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 390 | const payload = await ctx.request.validate({ 391 | schema: schema.create({ avatar: schema.file(undefined, [rules.maxImageHeight(520)]) }), 392 | }) 393 | assert.isDefined(payload.avatar) 394 | ctx.response.finish() 395 | }) 396 | }) 397 | 398 | await supertest(server) 399 | .post('/') 400 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-150x100.jpg')) 401 | }) 402 | 403 | test('should not throw validation error if image matches the expected aspect ratio', async (assert) => { 404 | const server = createServer((req, res) => { 405 | assert.plan(1) 406 | 407 | const ctx = app.container.resolveBinding('Adonis/Core/HttpContext').create('/', {}, req, res) 408 | 409 | const { rules, schema } = app.container.resolveBinding('Adonis/Core/Validator') 410 | 411 | app.container.make(BodyParserMiddleware).handle(ctx, async () => { 412 | const payload = await ctx.request.validate({ 413 | schema: schema.create({ avatar: schema.file(undefined, [rules.imageAspectRatio(1.5)]) }), 414 | }) 415 | assert.isDefined(payload.avatar) 416 | ctx.response.finish() 417 | }) 418 | }) 419 | 420 | await supertest(server) 421 | .post('/') 422 | .attach('avatar', join(__dirname, '../Statue-of-Sardar-Vallabhbhai-Patel-1500x1000.jpg')) 423 | }) 424 | }) 425 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/@adonisjs/mrm-preset/_tsconfig", 3 | "compilerOptions": { 4 | "skipLibCheck": true, 5 | "types": ["@types/node", "@adonisjs/core", "@adonisjs/lucid"], 6 | "experimentalDecorators": true, 7 | "emitDecoratorMetadata": true, 8 | "esModuleInterop": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /unallowed_file.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ndianabasi/adonis-responsive-attachment/81b284979a04b1daf958d1fe0f151f99235f2ef1/unallowed_file.pdf --------------------------------------------------------------------------------