├── .editorconfig
├── .github
└── workflows
│ ├── doc-deploy.yml
│ ├── issue.yml
│ ├── npm-publish.yml
│ ├── pull_request.yml
│ └── release.yml
├── .gitignore
├── .prettierignore
├── LICENSE
├── README.md
├── bin
└── test.ts
├── commands
└── make
│ └── converter.ts
├── configure.ts
├── docs
├── .vitepress
│ └── config.ts
├── changelog.md
├── guide
│ ├── advanced_usage
│ │ ├── custom-converter.md
│ │ ├── exceptions.md
│ │ ├── pre-compile-on-demand.md
│ │ ├── queue.md
│ │ └── regenerate-variant.md
│ ├── basic_usage
│ │ ├── attachment-object.md
│ │ ├── controller-setup.md
│ │ ├── migration-setup.md
│ │ ├── model-setup.md
│ │ └── view-setup.md
│ ├── converters
│ │ ├── document-thumbnail.md
│ │ ├── image.md
│ │ ├── pdf-thumbnail.md
│ │ └── video-thumbnail.md
│ ├── essentials
│ │ ├── configuration.md
│ │ ├── installation.md
│ │ └── introduction.md
│ ├── partials
│ │ ├── install-document.md
│ │ ├── install-image.md
│ │ ├── install-pdf.md
│ │ ├── install-video.md
│ │ └── table-converter.md
│ ├── start-here.md
│ └── use-cases
│ │ └── picture.md
├── index.md
├── public
│ ├── dark-convert.svg
│ ├── dark-upload.svg
│ ├── dark-view.svg
│ ├── light-convert.svg
│ ├── light-upload.svg
│ └── light-view.svg
├── structure-data-json.md
├── v2
│ └── guide
│ │ ├── advanced_usage
│ │ ├── custom-converter.md
│ │ ├── exceptions.md
│ │ ├── pre-compile-on-demand.md
│ │ └── queue.md
│ │ ├── basic_usage
│ │ ├── controller-setup.md
│ │ ├── migration-setup.md
│ │ ├── model-setup.md
│ │ └── view-setup.md
│ │ ├── converters
│ │ ├── document-thumbnail.md
│ │ ├── image.md
│ │ ├── pdf-thumbnail.md
│ │ └── video-thumbnail.md
│ │ ├── essentials
│ │ ├── configuration.md
│ │ ├── installation.md
│ │ └── introduction.md
│ │ ├── partials
│ │ ├── install-document.md
│ │ ├── install-image.md
│ │ ├── install-pdf.md
│ │ ├── install-video.md
│ │ └── table-converter.md
│ │ ├── start-here.md
│ │ └── use-cases
│ │ └── picture.md
└── v3
│ └── guide
│ ├── advanced_usage
│ ├── custom-converter.md
│ ├── exceptions.md
│ ├── pre-compile-on-demand.md
│ └── queue.md
│ ├── basic_usage
│ ├── controller-setup.md
│ ├── migration-setup.md
│ ├── model-setup.md
│ └── view-setup.md
│ ├── converters
│ ├── document-thumbnail.md
│ ├── image.md
│ ├── pdf-thumbnail.md
│ └── video-thumbnail.md
│ ├── essentials
│ ├── configuration.md
│ ├── installation.md
│ └── introduction.md
│ ├── partials
│ ├── install-document.md
│ ├── install-image.md
│ ├── install-pdf.md
│ ├── install-video.md
│ └── table-converter.md
│ ├── start-here.md
│ └── use-cases
│ └── picture.md
├── index.ts
├── package-lock.json
├── package.json
├── providers
└── attachment_provider.ts
├── services
├── main.ts
└── regenerate_service.ts
├── src
├── adapters
│ ├── blurhash.ts
│ ├── exif.ts
│ └── meta.ts
├── attachment_manager.ts
├── attachments
│ ├── attachment.ts
│ ├── attachment_base.ts
│ └── variant_attachment.ts
├── converter_manager.ts
├── converters
│ ├── autodetect_converter.ts
│ ├── converter.ts
│ ├── document_thumbnail_converter.ts
│ ├── image_converter.ts
│ ├── pdf_thumbnail_converter.ts
│ └── video_thumbnail_converter.ts
├── decorators
│ └── attachment.ts
├── define_config.ts
├── errors.ts
├── services
│ └── record_with_attachment.ts
├── types
│ ├── attachment.ts
│ ├── config.ts
│ ├── converter.ts
│ ├── index.ts
│ ├── input.ts
│ ├── mixin.ts
│ ├── regenerate.ts
│ └── service.ts
└── utils
│ ├── default_values.ts
│ ├── helpers.ts
│ ├── hooks.ts
│ └── symbols.ts
├── stubs
├── config.stub
├── main.ts
└── make
│ └── converter
│ └── main.stub
├── tests
├── attachment-create-by.spec.ts
├── attachment-manager.spec.ts
├── attachment-svg.spec.ts
├── attachment-variants-regenerate.spec.ts
├── attachment-variants.spec.ts
├── attachment.spec.ts
├── commands.spec.ts
├── fixtures
│ ├── converters
│ │ └── image_converter.ts
│ ├── factories
│ │ └── user.ts
│ ├── images
│ │ ├── adonis.svg
│ │ ├── adonisjs.svg
│ │ └── img.jpg
│ └── migrations
│ │ └── create_users_table.ts
├── helpers
│ ├── app.ts
│ └── index.ts
└── options.spec.ts
├── tsconfig.json
└── tsnode.esm.js
/.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 = unset
13 |
14 | [**.min.js]
15 | indent_style = unset
16 | insert_final_newline = unset
17 |
18 | [MakeFile]
19 | indent_style = space
20 |
21 | [*.md]
22 | trim_trailing_whitespace = false
23 |
--------------------------------------------------------------------------------
/.github/workflows/doc-deploy.yml:
--------------------------------------------------------------------------------
1 | name: Deploy Adonis Attachment site to Pages
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | workflow_dispatch:
7 |
8 | env:
9 | success: ✅
10 | failure: 🔴
11 | cancelled: ❌
12 | skipped: ⭕
13 |
14 | permissions:
15 | contents: read
16 | pages: write
17 | id-token: write
18 |
19 | concurrency:
20 | group: pages
21 | cancel-in-progress: false
22 |
23 | jobs:
24 | build:
25 | runs-on: ubuntu-latest
26 | steps:
27 | - name: Checkout
28 | uses: actions/checkout@v4
29 | with:
30 | fetch-depth: 0
31 | - name: Setup Node
32 | uses: actions/setup-node@v4
33 | with:
34 | node-version: 20
35 | cache: npm
36 | - name: Setup Pages
37 | uses: actions/configure-pages@v4
38 | - name: Install dependencies
39 | run: npm ci
40 | - name: Build with VitePress
41 | run: npm run docs:build
42 | - name: Upload artifact
43 | uses: actions/upload-pages-artifact@v3
44 | with:
45 | path: docs/.vitepress/dist
46 |
47 | # Deployment job
48 | deploy:
49 | environment:
50 | name: github-pages
51 | url: ${{ steps.deployment.outputs.page_url }}
52 | needs: build
53 | runs-on: ubuntu-latest
54 | name: Deploy
55 | steps:
56 | - name: Deploy to GitHub Pages
57 | id: deployment
58 | uses: actions/deploy-pages@v4
59 |
60 | - name: Notification
61 | if: ${{ always() }}
62 | uses: appleboy/telegram-action@master
63 | with:
64 | token: ${{ secrets.BOT_TOKEN }}
65 | to: ${{ secrets.CHAT_ID }}
66 | message: |
67 | Repository: ${{ github.repository }}
68 | Doc publish : ${{ env[job.status] }}
69 |
--------------------------------------------------------------------------------
/.github/workflows/issue.yml:
--------------------------------------------------------------------------------
1 | name: Notify on Issue or Comment
2 |
3 | on:
4 | issues:
5 | types: [opened, edited]
6 | issue_comment:
7 | types: [created, edited]
8 |
9 | jobs:
10 | notify-discord:
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - name: Notification
15 | uses: appleboy/telegram-action@master
16 | with:
17 | token: ${{ secrets.BOT_TOKEN }}
18 | to: ${{ secrets.CHAT_ID }}
19 | message: |
20 | Repository: ${{ github.repository }}
21 | Event: ${{ github.event_name }}
22 | Action: ${{ github.event.action }}
23 |
24 | Issue Title: ${{ github.event.issue.title }}
25 | Issue URL: ${{ github.event.issue.html_url }}
26 |
27 | User: ${{ github.event.issue.user.login }}
28 | Comment: ${{ github.event.comment.body || 'No comment' }}
29 |
--------------------------------------------------------------------------------
/.github/workflows/npm-publish.yml:
--------------------------------------------------------------------------------
1 | name: NPM publish
2 |
3 | on:
4 | push:
5 | branches:
6 | - 'main'
7 |
8 | env:
9 | success: ✅
10 | failure: 🔴
11 | cancelled: ❌
12 | skipped: ⭕
13 | node_version: 20
14 | true: ✅
15 | false: 🔴
16 |
17 | jobs:
18 | test:
19 | name: Test
20 | runs-on: ubuntu-24.04
21 | steps:
22 | - name: Update packages
23 | run: sudo apt-get update
24 |
25 | - name: Checkout
26 | uses: actions/checkout@v3
27 |
28 | - name: Setup node
29 | uses: actions/setup-node@v3
30 | with:
31 | node-version: ${{ env.node_version }}
32 | registry-url: https://registry.npmjs.org/
33 | cache: 'npm'
34 |
35 | - name: Install Dependencies
36 | run: npm ci
37 |
38 | - name: Run tests
39 | run: npm test
40 |
41 | - name: Notification
42 | if: ${{ always() }}
43 | uses: appleboy/telegram-action@master
44 | with:
45 | token: ${{ secrets.BOT_TOKEN }}
46 | to: ${{ secrets.CHAT_ID }}
47 | disable_web_page_preview: true
48 | message: |
49 | Test ${{ env[job.status] }} by ${{ github.actor }} for commit in ${{ github.ref_name }} branch.
50 | Comment: ${{ github.event.commits[0].message }}
51 |
52 | Repository: ${{ github.repository }}
53 | See changes: https://github.com/${{ github.repository }}/commit/${{ github.sha }}
54 | Github action: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
55 |
56 | release:
57 | needs: test
58 | runs-on: ubuntu-latest
59 | steps:
60 | - uses: actions/checkout@v3
61 |
62 | - id: release
63 | uses: halvardssm/github-action-tag-release@1.0.0
64 | with:
65 | token: ${{ secrets.GITHUB_TOKEN }}
66 | path: './package.json' # optional, will use ./package.json by default
67 |
68 | - run: |
69 | echo 'Release created: ${{steps.release.outputs.release_created}}' # 'true' or 'false'
70 | echo 'Release exists: ${{steps.release.outputs.release_exists}}' # 'true' or 'false'
71 | echo 'Release tag: ${{steps.version.outputs.release_tag}}' # The tag from package.json
72 |
73 | - name: Notification
74 | if: ${{ always() }}
75 | uses: appleboy/telegram-action@master
76 | with:
77 | token: ${{ secrets.BOT_TOKEN }}
78 | to: ${{ secrets.CHAT_ID }}
79 | message: |
80 | Repository: ${{ github.repository }}
81 | Release : ${{ env[steps.release.outputs.release_created] }}
82 | Tag: ${{ steps.version.outputs.release_tag }}
83 |
84 | publish-npm:
85 | needs: release
86 | runs-on: ubuntu-latest
87 | steps:
88 | - uses: actions/checkout@v3
89 |
90 | - uses: actions/setup-node@v3
91 | with:
92 | node-version: 20
93 | registry-url: https://registry.npmjs.org/
94 |
95 | - run: npm ci
96 | - run: npm publish
97 | env:
98 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
99 |
100 | - name: Notification
101 | if: ${{ always() }}
102 | uses: appleboy/telegram-action@master
103 | with:
104 | token: ${{ secrets.BOT_TOKEN }}
105 | to: ${{ secrets.CHAT_ID }}
106 | message: |
107 | Repository: ${{ github.repository }}
108 | npm publish : ${{ env[job.status] }}
109 |
--------------------------------------------------------------------------------
/.github/workflows/pull_request.yml:
--------------------------------------------------------------------------------
1 | name: Notify on Pull Request
2 |
3 | on:
4 | pull_request:
5 | types: [opened, edited, closed]
6 |
7 | jobs:
8 | notify-telegram:
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - name: Send Telegram Notification
13 | uses: appleboy/telegram-action@master
14 | with:
15 | token: ${{ secrets.BOT_TOKEN }}
16 | to: ${{ secrets.CHAT_ID }}
17 | message: |
18 | Repository: ${{ github.repository }}
19 | Event Type: ${{ github.event_name }}
20 | Action: ${{ github.event.action }}
21 | User: ${{ github.event.sender.login }}
22 |
23 | Pull Request Title: ${{ github.event.pull_request.title }}
24 | Pull Request URL: ${{ github.event.pull_request.html_url }}
25 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Notify Discord on Release
2 |
3 | on:
4 | release:
5 | types: [published]
6 | workflow_dispatch:
7 |
8 | jobs:
9 | notify-discord:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - name: Send notification to Discord
14 | env:
15 | DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }}
16 | uses: Ilshidur/action-discord@0.3.2
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 | .pnpm-debug.log*
9 |
10 | build
11 |
12 | # Diagnostic reports (https://nodejs.org/api/report.html)
13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
14 |
15 | # Runtime data
16 | pids
17 | *.pid
18 | *.seed
19 | *.pid.lock
20 |
21 | # Directory for instrumented libs generated by jscoverage/JSCover
22 | lib-cov
23 |
24 | # Coverage directory used by tools like istanbul
25 | coverage
26 | *.lcov
27 |
28 | # nyc test coverage
29 | .nyc_output
30 |
31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
32 | .grunt
33 |
34 | # Bower dependency directory (https://bower.io/)
35 | bower_components
36 |
37 | # node-waf configuration
38 | .lock-wscript
39 |
40 | # Compiled binary addons (https://nodejs.org/api/addons.html)
41 | build/Release
42 |
43 | # Dependency directories
44 | node_modules/
45 | jspm_packages/
46 |
47 | # Snowpack dependency directory (https://snowpack.dev/)
48 | web_modules/
49 |
50 | # TypeScript cache
51 | *.tsbuildinfo
52 |
53 | # Optional npm cache directory
54 | .npm
55 |
56 | # Optional eslint cache
57 | .eslintcache
58 |
59 | # Optional stylelint cache
60 | .stylelintcache
61 |
62 | # Microbundle cache
63 | .rpt2_cache/
64 | .rts2_cache_cjs/
65 | .rts2_cache_es/
66 | .rts2_cache_umd/
67 |
68 | # Optional REPL history
69 | .node_repl_history
70 |
71 | # Output of 'npm pack'
72 | *.tgz
73 |
74 | # Yarn Integrity file
75 | .yarn-integrity
76 |
77 | # dotenv environment variable files
78 | .env
79 | .env.development.local
80 | .env.test.local
81 | .env.production.local
82 | .env.local
83 |
84 | # parcel-bundler cache (https://parceljs.org/)
85 | .cache
86 | .parcel-cache
87 |
88 | # Next.js build output
89 | .next
90 | out
91 |
92 | # Nuxt.js build / generate output
93 | .nuxt
94 | dist
95 |
96 | # Gatsby files
97 | .cache/
98 | # Comment in the public line in if your project uses Gatsby and not Next.js
99 | # https://nextjs.org/blog/next-9-1#public-directory-support
100 | # public
101 |
102 | # vuepress build output
103 | .vuepress/dist
104 |
105 | # vuepress v2.x temp and cache directory
106 | .temp
107 | .cache
108 |
109 | # Docusaurus cache and generated files
110 | .docusaurus
111 |
112 | # Serverless directories
113 | .serverless/
114 |
115 | # FuseBox cache
116 | .fusebox/
117 |
118 | # DynamoDB Local files
119 | .dynamodb/
120 |
121 | # TernJS port file
122 | .tern-port
123 |
124 | # Stores VSCode versions used for testing VSCode extensions
125 | .vscode-test
126 |
127 | # yarn v2
128 | .yarn/cache
129 | .yarn/unplugged
130 | .yarn/build-state.yml
131 | .yarn/install-state.gz
132 | .pnp.*
133 |
134 | # vitepress
135 | docs/.vitepress/dist
136 | docs/.vitepress/cache
137 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | build
2 | docs
3 | *.md
4 | config.json
5 | .eslintrc.json
6 | package.json
7 | *.html
8 | *.txt
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Jeremy Chaufourier
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # AdonisJS attachment
2 |
3 | This package is currently development and will replace [attachment-advanced](https://github.com/batosai/attachment-advanced) for AdonisJS 6.
4 |
5 | ## Links
6 |
7 | [View documentation](https://adonis-attachment.jrmc.dev/)
8 |
9 | [ChangeLog](https://adonis-attachment.jrmc.dev/changelog.html)
10 |
11 | [Discord](https://discord.gg/89eMn2vB)
12 |
13 | Project sample : [adonis-starter-kit](https://github.com/batosai/adonis-starter-kit)
14 |
15 | ## Roadmap
16 |
17 | - [x] attachment file by file system
18 | - [x] attachment file by buffer
19 | - [x] attachment file by path
20 | - [x] attachment file by url
21 | - [x] attachment file by stream
22 | - [x] attachment files
23 | - [x] save meta data
24 | - [x] variantes
25 | - [x] images
26 | - [x] [blurhash](https://blurha.sh/)
27 | - [x] documents thumbnail
28 | - [x] videos thumbnail
29 | - [x] command regenerate
30 | - [x] command make:convert
31 | - [x] adonis-drive/flydrive
32 | - [x] jobs queue
33 | - [x] serialize
34 |
35 |
36 | ## Setup
37 |
38 | Install and configure the package:
39 |
40 | ```sh
41 | node ace add @jrmc/adonis-attachment
42 | ```
43 |
44 | ## Sample
45 |
46 | Simple upload file
47 |
48 | ```ts
49 | // app/models/user.ts
50 | import { BaseModel } from '@adonisjs/lucid/orm'
51 | import { compose } from '@adonisjs/core/helpers'
52 | import { attachment, Attachmentable } from '@jrmc/adonis-attachment'
53 | import type { Attachment } from '@jrmc/adonis-attachment/types/attachment'
54 |
55 | class User extends compose(BaseModel, Attachmentable) {
56 | @attachment()
57 | declare avatar: Attachment
58 | }
59 | ```
60 |
61 | ---
62 |
63 | ```ts
64 | // app/controllers/users_controller.ts
65 | import { attachmentManager } from '@jrmc/adonis-attachment'
66 |
67 | class UsersController {
68 | public store({ request }: HttpContext) {
69 | const avatar = request.file('avatar')!
70 | const user = new User()
71 |
72 | user.avatar = await attachmentManager.createFromFile(avatar)
73 | await user.save()
74 | }
75 | }
76 | ```
77 |
78 | ---
79 |
80 | ```edge
81 |
82 | ```
83 |
84 | Read [documentation](https://adonis-attachment.jrmc.dev/) for advanced usage(thumbnail video/pdf/doc, create from buffer/base64...)
85 |
--------------------------------------------------------------------------------
/bin/test.ts:
--------------------------------------------------------------------------------
1 | import { assert } from '@japa/assert'
2 | import { expectTypeOf } from '@japa/expect-type'
3 | import { processCLIArgs, configure, run } from '@japa/runner'
4 | import { createApp, initializeDatabase, removeDatabase } from '../tests/helpers/app.js'
5 | import { fileSystem } from '@japa/file-system'
6 | import app from '@adonisjs/core/services/app'
7 | import { ApplicationService } from '@adonisjs/core/types'
8 | import { BASE_URL } from '../tests/helpers/index.js'
9 |
10 | let testApp: ApplicationService
11 | processCLIArgs(process.argv.slice(2))
12 | configure({
13 | files: ['tests/**/*.spec.ts'],
14 | plugins: [assert(), fileSystem({ basePath: BASE_URL }), expectTypeOf()],
15 | setup: [
16 | async () => {
17 | testApp = await createApp()
18 | await initializeDatabase(testApp)
19 | },
20 | ],
21 | teardown: [
22 | async () => {
23 | await app.terminate()
24 | await testApp.terminate()
25 | await removeDatabase()
26 | },
27 | ],
28 | })
29 |
30 | /*
31 | |--------------------------------------------------------------------------
32 | | Run tests
33 | |--------------------------------------------------------------------------
34 | |
35 | | The following "run" method is required to execute all the tests.
36 | |
37 | */
38 | run()
39 |
--------------------------------------------------------------------------------
/commands/make/converter.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import { stubsRoot } from '../../stubs/main.js'
9 | import { args, BaseCommand } from '@adonisjs/core/ace'
10 |
11 | /**
12 | * The make controller command to create an HTTP controller
13 | */
14 | export default class MakeConverter extends BaseCommand {
15 | static commandName = 'make:converter'
16 | static description = 'Create a new media converter class'
17 |
18 | @args.string({ description: 'The name of the converter' })
19 | declare name: string
20 |
21 | protected stubPath: string = 'make/converter/main.stub'
22 |
23 | async run() {
24 | const codemods = await this.createCodemods()
25 | await codemods.makeUsingStub(stubsRoot, this.stubPath, {
26 | name: this.name,
27 | })
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/configure.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import type Configure from '@adonisjs/core/commands/configure'
9 | import { stubsRoot } from './stubs/main.js'
10 |
11 | export async function configure(command: Configure) {
12 | const codemods = await command.createCodemods()
13 |
14 | /**
15 | * Create default config file
16 | */
17 | await codemods.makeUsingStub(stubsRoot, 'config.stub', {})
18 |
19 | /**
20 | * Register provider
21 | */
22 | await codemods.updateRcFile((rcFile) => {
23 | rcFile.addProvider('@jrmc/adonis-attachment/attachment_provider')
24 | rcFile.addCommand('@jrmc/adonis-attachment/commands')
25 | })
26 | }
27 |
--------------------------------------------------------------------------------
/docs/guide/advanced_usage/custom-converter.md:
--------------------------------------------------------------------------------
1 | # Custom converter
2 |
3 | ⚠️ [avalable in v2.4.0](/changelog#_2-4-0)
4 |
5 | ## Make converter
6 |
7 | The converts are stored within the ./app/converters directory. You may create a new converter by running the following command.
8 |
9 | ```sh
10 | node ace make:converter gif2webp
11 | ```
12 |
13 | - Form: `singular`
14 | - Suffix: `converter`
15 | - Class name example: `Gif2WebpConverter`
16 | - File name example: `gif_2_webp_converter.ts`
17 |
18 | ## Samples animate gif to webp
19 |
20 | ```ts
21 | import type { ConverterAttributes } from '@jrmc/adonis-attachment/types/converter'
22 | import type { Input } from '@jrmc/adonis-attachment/types/input'
23 |
24 | import Converter from '@jrmc/adonis-attachment/converters/converter'
25 | import sharp from 'sharp'
26 |
27 | export default class Gif2WebpConverter extends Converter {
28 | async handle({ input }: ConverterAttributes): Promise {
29 | const sharpImage = sharp(input, { animated: true, pages: -1 })
30 |
31 | const imageMeta = await sharpImage.metadata()
32 | const { loop, delay } = imageMeta
33 |
34 | const options = {
35 | webp: {
36 | loop,
37 | delay,
38 | }
39 | }
40 |
41 | const buffer = await sharpImage
42 | .withMetadata()
43 | .webp(options.webp)
44 | .toBuffer()
45 |
46 | return buffer
47 | }
48 | }
49 | ```
50 |
51 |
52 | ## Samples video to animate gif
53 |
54 | ```ts
55 | import type { ConverterAttributes } from '@jrmc/adonis-attachment/types/converter'
56 | import type { Input } from '@jrmc/adonis-attachment/types/input'
57 |
58 | import os from 'node:os'
59 | import path from 'node:path'
60 | import fs from 'fs/promises'
61 | import { cuid } from '@adonisjs/core/helpers'
62 | import Converter from '@jrmc/adonis-attachment/converters/converter'
63 | import ffmpeg from 'fluent-ffmpeg'
64 |
65 | export default class Video2GifConverter extends Converter {
66 | async handle({ input }: ConverterAttributes): Promise {
67 | return await this.videoToGif(ffmpeg, input)
68 | }
69 |
70 | async videoToGif(ffmpeg: Function, input: Input) {
71 | let file = input
72 |
73 | if (Buffer.isBuffer(input)) {
74 | file = await this.bufferToTempFile(input)
75 | }
76 |
77 | return new Promise((resolve, reject) => {
78 | const folder = os.tmpdir()
79 | const filename = `${cuid()}.png`
80 | const destination = path.join(folder, filename)
81 |
82 |
83 | const ff = ffmpeg(file)
84 |
85 | if (this.binPaths) {
86 | if (this.binPaths.ffmpegPath) {
87 | ff.setFfmpegPath(this.binPaths.ffmpegPath)
88 | }
89 | }
90 |
91 | ff
92 | .withOptions([
93 | '-ss 1',
94 | `-i ${file}`,
95 | `-filter_complex [0:v]trim=duration=3;`,
96 | '-f gif'
97 | ])
98 | .on('end', () => {
99 | resolve(destination)
100 | })
101 | .on('error', (err: Error) => {
102 | reject(err)
103 | })
104 | .output(destination)
105 | .run()
106 | })
107 | }
108 |
109 | async bufferToTempFile(input: Buffer) {
110 | const folder = os.tmpdir()
111 | const tempFilePath = path.join(folder, `tempfile-${Date.now()}.tmp`)
112 | await fs.writeFile(tempFilePath, input)
113 | return tempFilePath
114 | }
115 | }
116 | ```
117 |
--------------------------------------------------------------------------------
/docs/guide/advanced_usage/exceptions.md:
--------------------------------------------------------------------------------
1 |
2 | # Exceptions
3 |
4 | |Code |Description | Origin |
5 | | -------------------------- | ----------------------------------------------- | ------- |
6 | | E_MISSING_PACKAGE | Missing package | |
7 | | E_CANNOT_CREATE_ATTACHMENT | Unable to create Attachment Object | |
8 | | E_CANNOT_CREATE_VARIANT | Unable to create variant | |
9 | | E_CANNOT_PATH_BY_CONVERTER | Missing path by converter | |
10 | | E_ISNOT_BUFFER | Is not a Buffer | |
11 | | E_ISNOT_BASE64 | Is not a Base64 | |
12 | | ENOENT | Unable to read file | |
13 | | E_CANNOT_WRITE_FILE | Unable to write file to the destination | Drive |
14 | | E_CANNOT_READ_FILE | Unable to read file | Drive |
15 | | E_CANNOT_DELETE_FILE | Unable to delete file | Drive |
16 | | E_CANNOT_SET_VISIBILITY | Unable to set file visibility | Drive |
17 | | E_CANNOT_GENERATE_URL | Unable to generate URL for a file | Drive |
18 | | E_UNALLOWED_CHARACTERS | The file key has unallowed set of characters | Drive |
19 | | E_INVALID_KEY | Key post normalization leads to an empty string | Drive |
20 |
21 | [Adonis documentation exception](https://docs.adonisjs.com/guides/basics/exception-handling)
22 |
23 | ## Handling exceptions
24 |
25 | If you want to handle a specific exception differently, you can do that inside the `handle` method. Make sure to use the `ctx.response.send` method to send a response, since the return value from the `handle` method is discarded.
26 |
27 | ::: code-group
28 |
29 | ```typescript [API]
30 | import { errors } from '@jrmc/adonis-attachment'
31 |
32 | export default class HttpExceptionHandler extends ExceptionHandler {
33 | async handle(error: unknown, ctx: HttpContext) {
34 | if (error instanceof errors.E_CANNOT_WRITE_FILE) {
35 | const err = error as errors.E_CANNOT_WRITE_FILE
36 | ctx.response.status(422).send(err.messages)
37 | return
38 | }
39 |
40 | return super.handle(error, ctx)
41 | }
42 | }
43 | ```
44 |
45 | ```typescript [web]
46 | import { errors } from '@jrmc/adonis-attachment'
47 |
48 | export default class HttpExceptionHandler extends ExceptionHandler {
49 | async handle(error: unknown, ctx: HttpContext) {
50 | if (error instanceof errors.E_CANNOT_WRITE_FILE) {
51 | ctx.session.flash('notification', {
52 | type: 'error',
53 | message: err.message,
54 | })
55 |
56 | return ctx.response.redirect('back')
57 | }
58 |
59 | return super.handle(error, ctx)
60 | }
61 | }
62 |
63 | ```
64 |
65 | :::
66 |
--------------------------------------------------------------------------------
/docs/guide/advanced_usage/pre-compile-on-demand.md:
--------------------------------------------------------------------------------
1 | # Pre compute on demand
2 |
3 | We recommend not enabling the preComputeUrl option when you need the URL for just one or two queries and not within the rest of your application.
4 |
5 | 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.
6 |
7 | ```ts
8 | import type { Attachment } from '@jrmc/adonis-attachment/types/attachment'
9 | import { attachment, attachmentManager } from '@jrmc/adonis-attachment'
10 |
11 | class User extends BaseModel {
12 | static async preComputeUrls(models: User | User[]) {
13 | if (Array.isArray(models)) {
14 | await Promise.all(models.map((model) => this.preComputeUrls(model)))
15 | return
16 | }
17 |
18 | // compute url for original file
19 | await attachmentManager.computeUrl(models.avatar)
20 |
21 | // compute url for thumbnail variant
22 | const thumb = models.avatar.getVariant('thumbnail')
23 | await attachmentManager.computeUrl(thumb)
24 |
25 | // compute url for medium variant with expiration time option
26 | const medium = models.avatar.getVariant('medium')
27 | await attachmentManager.computeUrl(medium, {
28 | expiresIn: '30 mins',
29 | })
30 | }
31 |
32 | @attachment({
33 | variants: ['thumbnail', 'medium', 'large']
34 | })
35 | declare avatar: Attachment
36 | }
37 | ```
38 |
39 | computeUrl method create automatically creates a signed or unsigned url depending on Drive's configuration.
40 |
41 | it's possible to pass specific options to the signed url.
42 | options params accepts `expiresIn`, `contentType` et `contentDisposition`.
43 |
44 | [More informations](https://flydrive.dev/docs/disk_api#getsignedurl)
45 |
46 | ---
47 |
48 | And now use it as follows.
49 |
50 | ```ts
51 | const users = await User.all()
52 | await User.preComputeUrls(users)
53 |
54 | return users
55 | ```
56 |
57 | Or for a single user
58 |
59 | ```ts
60 | const user = await User.findOrFail(1)
61 | await User.preComputeUrls(user)
62 |
63 | return user
64 | ```
65 |
--------------------------------------------------------------------------------
/docs/guide/advanced_usage/queue.md:
--------------------------------------------------------------------------------
1 | # Queue
2 |
3 | The media transforms are carried out int the queue, grouped by model attribut with the [@poppinss/defer](https://github.com/poppinss/defer) library.
4 |
5 | ## Events
6 |
7 | Create your preload file for events catch:
8 |
9 | ```sh
10 | node ace make:preload queue
11 | ```
12 |
13 | ```ts
14 | import { attachmentManager } from '@jrmc/adonis-attachment'
15 | import logger from '@adonisjs/core/services/logger'
16 |
17 | attachmentManager.queue.onError = function (error, task) {
18 | logger.info(`${task.name} task failed with the following error`)
19 | logger.error(error.message)
20 | }
21 |
22 | attachmentManager.queue.taskCompleted = function (task) {
23 | logger.info(`${task.name} completed. ${attachmentManager.queue.size()} tasks left`)
24 | }
25 |
26 | attachmentManager.queue.drained = function () {
27 | logger.info('Processed last task in the queue')
28 | }
29 |
30 | ```
31 |
32 | ## Synchrone mode
33 |
34 | If you need to wait for all processes to complete, for example, for unit testing:
35 |
36 |
37 | ```ts
38 | const notifier = new Promise((resolve) => {
39 | attachmentManager.queue.drained = resolve
40 | })
41 |
42 | generateUserWithAttachment()
43 |
44 | await notifier
45 |
46 | /**
47 | * Tasks have been processed.
48 | */
49 | ```
50 |
--------------------------------------------------------------------------------
/docs/guide/advanced_usage/regenerate-variant.md:
--------------------------------------------------------------------------------
1 | # Regeneration of variants
2 |
3 | You can regenerate the different variants using the `RegenerateService`.
4 |
5 | ⚠️ [change in v4.0.0](/changelog#_4-0-0)
6 |
7 | ## Regeneration by Row
8 |
9 | ```ts
10 | @inject()
11 | async regenerate(regenerate: RegenerateService) {
12 | const user = await User.first()
13 |
14 | if (user) {
15 | await regenerate.row(user).run()
16 | }
17 | }
18 | ```
19 |
20 | ## Regeneration by Model
21 |
22 | ```ts
23 | @inject()
24 | async regenerate(regenerate: RegenerateService) {
25 | if (user) {
26 | await regenerate.model(User).run()
27 | }
28 | }
29 | ```
30 |
31 | ## Options
32 |
33 |
34 | | Option | Description |
35 | | ---------- | ---------------------------- |
36 | | variants | Specify the variant names |
37 | | attributes | Specify the attribute names. |
38 |
39 |
40 |
41 | ```ts
42 | await regenerate.row(user, {
43 | variants: [ 'thumbnail' ],
44 | attributes: [ 'avatar', 'files' ]
45 | }).run()
46 |
47 | await regenerate.model(User, {
48 | variants: [ 'thumbnail' ],
49 | attributes: [ 'avatar', 'files' ]
50 | }).run()
51 | ```
52 |
53 |
54 |
--------------------------------------------------------------------------------
/docs/guide/basic_usage/attachment-object.md:
--------------------------------------------------------------------------------
1 | # Attachment Object
2 |
3 | The Attachment object is the main class that handles attached files in your application.
4 |
5 | ## Properties
6 |
7 | ### Base Properties
8 | - `drive`: Drive service used for file management
9 | - `name`: File name
10 | - `originalName`: Original file name
11 | - `folder`: Storage folder
12 | - `path`: Complete file path
13 | - `size`: File size in bytes
14 | - `extname`: File extension
15 | - `mimeType`: File MIME type
16 | - `meta`: File metadata (EXIF)
17 | - `originalPath`: Original file path
18 | - `url`: File URL
19 | - `variants`: Array of file variants
20 |
21 | ## Methods
22 |
23 | ### URL Management
24 | - `getUrl(variantName?: string): Promise`: Gets the file URL or a variant URL
25 | - `getSignedUrl(variantNameOrOptions?: string | SignedURLOptions, signedUrlOptions?: SignedURLOptions): Promise`: Gets a signed URL
26 |
27 | ### File Management
28 | - `getDisk(): Disk`: Gets the storage disk
29 | - `getBytes(): Promise`: Gets file content as bytes
30 | - `getBuffer(): Promise`: Gets file content as buffer
31 | - `getStream(): Promise`: Gets file content as stream
32 |
33 | ## Usage Examples
34 |
35 | ### URL Management
36 | ```typescript
37 | // Getting the URL
38 | const url = await attachment.getUrl();
39 |
40 | // Getting a variant URL
41 | const variantUrl = await attachment.getUrl('thumbnail');
42 |
43 | // Getting a signed URL
44 | const signedUrl = await attachment.getSignedUrl({
45 | expiresIn: '30m'
46 | });
47 |
48 | // Getting a signed URL for a variant
49 | const signedVariantUrl = await attachment.getSignedUrl('thumbnail', {
50 | expiresIn: '30m'
51 | });
52 | ```
53 |
54 | ### File Management
55 | ```typescript
56 | // Getting the storage disk
57 | const disk = attachment.getDisk();
58 |
59 | // Getting file content as bytes
60 | const bytes = await attachment.getBytes();
61 |
62 | // Getting file content as buffer
63 | const buffer = await attachment.getBuffer();
64 |
65 | // Getting file content as stream
66 | const stream = await attachment.getStream();
67 | stream.pipe(fs.createWriteStream('output.jpg'));
68 | ```
--------------------------------------------------------------------------------
/docs/guide/basic_usage/migration-setup.md:
--------------------------------------------------------------------------------
1 | # Migration setup
2 |
3 | Often times, the size of the image metadata could exceed the allowable length of an SQL `String` data type. So, it is recommended to create/modify the column which will hold the metadata to use a `JSON` data type.
4 |
5 | If you are creating the column for the first time, make sure that you use the JSON data type. Example:
6 |
7 | ```ts
8 | // Within the migration file
9 |
10 | protected tableName = 'users'
11 |
12 | public async up() {
13 | this.schema.createTable(this.tableName, (table) => {
14 | table.increments()
15 | table.json('avatar') // <-- Use a JSON data type
16 | })
17 | }
18 | ```
19 |
20 | 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:
21 |
22 | ```bash
23 | # Create a new migration file
24 | node ace make:migration change_avatar_column_to_json --table=users
25 | ```
26 |
27 | ```ts
28 | // Within the migration file
29 |
30 | protected tableName = 'users'
31 |
32 | public async up() {
33 | this.schema.alterTable(this.tableName, (table) => {
34 | table.json('avatar').alter() // <-- Alter the column definition
35 | })
36 | }
37 | ```
38 |
--------------------------------------------------------------------------------
/docs/guide/basic_usage/view-setup.md:
--------------------------------------------------------------------------------
1 | # View setup
2 |
3 | Now all you have to do is display your images in your view.
4 |
5 | ## URLs for edge template
6 |
7 | ```ts
8 | await user.avatar.getUrl()
9 | await user.avatar.getUrl('thumbnail')
10 | // or await user.avatar.getVariant('thumbnail').getUrl()
11 |
12 | await user.avatar.getSignedUrl()
13 | await user.avatar.getSignedUrl('thumbnail')
14 | // or await user.avatar.getVariant('thumbnail').getSignedUrl()
15 | ```
16 |
17 | ```edge
18 |
19 |
20 |
21 |
24 |
25 |
26 |
29 | ```
30 |
31 | getSignedUrl options params accepts `expiresIn`, `contentType` et `contentDisposition`. [More informations](https://flydrive.dev/docs/disk_api#getsignedurl)
32 |
33 | ### If preComputeUrl is enabled
34 |
35 | ```edge
36 |
37 |
38 | ```
39 |
40 |
41 | ## URLs for Inertia template
42 |
43 | ::: code-group
44 | ```js [react]
45 |
46 | ```
47 |
48 | ```vue
49 |
50 | ```
51 |
52 | ```svelte
53 |
54 | ```
55 | :::
56 |
57 | preComputeUrl is required.
58 |
--------------------------------------------------------------------------------
/docs/guide/converters/document-thumbnail.md:
--------------------------------------------------------------------------------
1 |
2 | # Document thumbnail converter
3 |
4 | ⚠️ [avalable in v2.3.0](/changelog#_2-3-0)
5 |
6 |
7 |
8 | ## Configuration
9 |
10 | ```typescript
11 | // config/attachment.ts // [!code focus:1]
12 | const attachmentConfig = defineConfig({
13 | converters: {
14 | preview: { // [!code focus:3]
15 | converter: () => import('@jrmc/adonis-attachment/converters/document_thumbnail_converter'),
16 | }
17 | }
18 | })
19 | ```
20 |
21 | By default, image format is `JPEG` and size is video size. `options` attribute use ***[image_converter](/guide/converters/image)***
22 |
23 | Sample:
24 |
25 | ```typescript{6-9}
26 | const attachmentConfig = defineConfig({
27 | converters: {
28 | preview: { // [!code focus:7]
29 | converter: () => import('@jrmc/adonis-attachment/converters/document_thumbnail_converter'),
30 | options: {
31 | format: 'webp',
32 | resize: 720
33 | }
34 | }
35 | }
36 | })
37 | ```
38 |
--------------------------------------------------------------------------------
/docs/guide/converters/image.md:
--------------------------------------------------------------------------------
1 | # Image converter
2 |
3 |
4 |
5 | ## Configuration
6 |
7 | ```typescript
8 | // config/attachment.ts
9 | const attachmentConfig = defineConfig({
10 | converters: {
11 | large: { // [!code focus:6]
12 | converter: () => import('@jrmc/adonis-attachment/converters/image_converter'),
13 | options: {
14 | resize: 1280,
15 | }
16 | }
17 | }
18 | })
19 | ```
20 |
21 | ## Format
22 |
23 | The default format is `webp`, for change, use options format:
24 |
25 | ```typescript
26 | const attachmentConfig = defineConfig({
27 | converters: {
28 | thumbnail: { // [!code focus:7]
29 | converter: () => import('@jrmc/adonis-attachment/converters/image_converter'),
30 | options: {
31 | resize: 300,
32 | format: 'jpeg', // [!code highlight]
33 | }
34 | }
35 | }
36 | })
37 | ```
38 |
39 | Options format is `string` or `object` [ format, options ] details in documentation : [sharp api outpout](https://sharp.pixelplumbing.com/api-output#toformat)
40 |
41 |
42 | Sample for personalize image quality:
43 |
44 | ```typescript{8-13}
45 | const attachmentConfig = defineConfig({
46 | converters: {
47 | thumbnail: { // [!code focus:12]
48 | converter: () => import('@jrmc/adonis-attachment/converters/image_converter'),
49 | options: {
50 | resize: 300,
51 | format: {
52 | format: 'jpeg',
53 | options: {
54 | quality: 80
55 | }
56 | }
57 | }
58 | }
59 | }
60 | })
61 | ```
62 |
63 | ## ReSize
64 |
65 | Options resize is `number` or `object`(options) details in documentation : [sharp api resize](https://sharp.pixelplumbing.com/api-resize)
66 |
67 | Sample:
68 |
69 | ```typescript{11-16}
70 | import { defineConfig } from '@jrmc/adonis-attachment'
71 | import { InferConverters } from '@jrmc/adonis-attachment/types/config'
72 | import sharp from 'sharp'
73 |
74 | const attachmentConfig = defineConfig({
75 | converters: {
76 | thumbnail: {
77 | converter: () => import('@jrmc/adonis-attachment/converters/image_converter'),
78 | options: {
79 | format: 'jpeg',
80 | resize: { // https://sharp.pixelplumbing.com/api-resize
81 | width: 400,
82 | height: 400,
83 | fit: sharp.fit.cover,
84 | position: 'top'
85 | },
86 | }
87 | }
88 | }
89 | })
90 |
91 | export default attachmentConfig
92 |
93 | declare module '@jrmc/adonis-attachment' {
94 | interface AttachmentVariants extends InferConverters {}
95 | }
96 | ```
97 |
98 | ## BlurHash
99 |
100 | The blurhash option is used to enable, disable, and customise the generation of blurhashes ([https://blurha.sh/](https://blurha.sh/)) for the variants. Blurhash generation is disabled by default.
101 |
102 | ```typescript
103 | const attachmentConfig = defineConfig({
104 | converters: {
105 | thumbnail: { // [!code focus:10]
106 | converter: () => import('@jrmc/adonis-attachment/converters/image_converter'),
107 | options: {
108 | blurhash: true
109 | // or
110 | // blurhash: {
111 | // enabled: true,
112 | // componentX: 4,
113 | // componentY: 4
114 | // }
115 | }
116 | }
117 | }
118 | })
119 | ```
120 |
121 | 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).
122 |
--------------------------------------------------------------------------------
/docs/guide/converters/pdf-thumbnail.md:
--------------------------------------------------------------------------------
1 |
2 | # PDF thumbnail converter
3 |
4 | ⚠️ [avalable in v2.3.0](/changelog#_2-3-0)
5 |
6 |
7 |
8 | ## Configuration
9 |
10 | ```typescript
11 | // config/attachment.ts // [!code focus:1]
12 | const attachmentConfig = defineConfig({
13 | converters: {
14 | preview: { // [!code focus:3]
15 | converter: () => import('@jrmc/adonis-attachment/converters/pdf_thumbnail_converter'),
16 | }
17 | }
18 | })
19 | ```
20 |
21 | By default, image format is `JPEG` and size is video size. `options` attribute use ***[image_converter](/guide/converters/image)***
22 |
23 | Sample:
24 |
25 | ```typescript{6-9}
26 | const attachmentConfig = defineConfig({
27 | converters: {
28 | preview: { // [!code focus:7]
29 | converter: () => import('@jrmc/adonis-attachment/converters/pdf_thumbnail_converter'),
30 | options: {
31 | format: 'webp',
32 | resize: 720
33 | }
34 | }
35 | }
36 | })
37 | ```
38 |
--------------------------------------------------------------------------------
/docs/guide/converters/video-thumbnail.md:
--------------------------------------------------------------------------------
1 |
2 | # Video thumbnail converter
3 |
4 |
5 |
6 |
7 | ## Configuration
8 |
9 | ```typescript
10 | // config/attachment.ts // [!code focus:1]
11 | const attachmentConfig = defineConfig({
12 | converters: {
13 | preview: { // [!code focus:3]
14 | converter: () => import('@jrmc/adonis-attachment/converters/video_thumbnail_converter'),
15 | }
16 | }
17 | })
18 | ```
19 |
20 | By default, image format is `PNG` and size is video size. `options` attribute use ***[image_converter](/guide/converters/image)***
21 |
22 | Sample:
23 |
24 | ```typescript{6-9}
25 | const attachmentConfig = defineConfig({
26 | converters: {
27 | preview: { // [!code focus:7]
28 | converter: () => import('@jrmc/adonis-attachment/converters/video_thumbnail_converter'),
29 | options: {
30 | format: 'jpeg',
31 | resize: 720
32 | }
33 | }
34 | }
35 | })
36 | ```
37 |
--------------------------------------------------------------------------------
/docs/guide/essentials/installation.md:
--------------------------------------------------------------------------------
1 | # Installation
2 |
3 | The Adonis Attachment package is available on [npm](https://www.npmjs.com/package/@jrmc/adonis-attachment).
4 |
5 | It's required [Adonis Drive](https://docs.adonisjs.com/guides/digging-deeper/drive), please look at the documentation if this is not installed.
6 |
7 | You can install it using the following ace command to automagically configure it:
8 | ```sh
9 | node ace add @jrmc/adonis-attachment
10 | ```
11 |
12 | Alternatively, you can install it manually using your favorite package manager and running the configure command:
13 | ::: code-group
14 |
15 | ```sh [npm]
16 | npm install @jrmc/adonis-attachment
17 | node ace configure @jrmc/adonis-attachment
18 | ```
19 | ```sh [pnpm]
20 | pnpm install @jrmc/adonis-attachment
21 | node ace configure @jrmc/adonis-attachment
22 | ```
23 | ```sh [yarn]
24 | yarn add @jrmc/adonis-attachment
25 | node ace configure @jrmc/adonis-attachment
26 | ```
27 | :::
28 |
29 |
30 | ## Additional install
31 |
32 |
33 |
34 | ---
35 |
36 |
37 |
38 | ---
39 |
40 |
41 |
42 | ---
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/docs/guide/essentials/introduction.md:
--------------------------------------------------------------------------------
1 | # Introduction
2 |
3 | The `adonis-attachment` package was designed to simplify file upload management. It allows you to create alternative files, called `variants`, with options for optimization, resizing, and format changes. Additionally, it automatically generates image thumbnails for document and video files.
4 |
5 | The creation of variants is handled through Converters.
6 |
7 |
8 |
9 | Project sample : [adonis-starter-kit](https://github.com/batosai/adonis-starter-kit)
10 |
--------------------------------------------------------------------------------
/docs/guide/partials/install-document.md:
--------------------------------------------------------------------------------
1 | Variants images for thumbnail Document are generates by [libreoffice-file-converter](https://www.npmjs.com/package/libreoffice-file-converter). Make sure you have [LibreOffice](https://fr.libreoffice.org/download/telecharger-libreoffice/) installed on your system.
2 |
3 | It is possible to specify the [path of binaries](/guide/essentials/configuration.html#bin-optional). Useful if your installations are specific or if you are dropping off precompiled versions.
4 |
5 | Installation required:
6 |
7 | ::: code-group
8 | ```sh [npm]
9 | npm install libreoffice-file-converter
10 | ```
11 | ```sh [pnpm]
12 | pnpm install libreoffice-file-converter
13 | ```
14 | ```sh [yarn]
15 | yarn add libreoffice-file-converter
16 | ```
17 | :::
18 |
--------------------------------------------------------------------------------
/docs/guide/partials/install-image.md:
--------------------------------------------------------------------------------
1 | Variants images are generates by [sharp module](https://sharp.pixelplumbing.com) and require installation:
2 |
3 | ::: code-group
4 | ```sh [npm]
5 | npm install sharp
6 | ```
7 | ```sh [pnpm]
8 | pnpm install sharp
9 | ```
10 | ```sh [yarn]
11 | yarn add sharp
12 | ```
13 | :::
14 |
--------------------------------------------------------------------------------
/docs/guide/partials/install-pdf.md:
--------------------------------------------------------------------------------
1 | Variants images for thumbnail PDF are generates by [node-poppler](https://www.npmjs.com/package/node-poppler). Make sure you have [poppler](https://poppler.freedesktop.org)(pdftocairo) installed on your system.
2 |
3 | It is possible to specify the [path of binaries](/guide/essentials/configuration.html#bin-optional). Useful if your installations are specific or if you are dropping off precompiled versions.
4 |
5 | Installation required:
6 |
7 | ::: code-group
8 | ```sh [npm]
9 | npm install node-poppler
10 | ```
11 | ```sh [pnpm]
12 | pnpm install node-poppler
13 | ```
14 | ```sh [yarn]
15 | yarn add node-poppler
16 | ```
17 | :::
18 |
--------------------------------------------------------------------------------
/docs/guide/partials/install-video.md:
--------------------------------------------------------------------------------
1 | Variants images for thumbnail video are generates by [fluent-ffmpeg](https://www.npmjs.com/package/fluent-ffmpeg). Make sure you have [ffmpeg](https://ffmpeg.org) installed on your system (including all necessary encoding libraries like libmp3lame or libx264).
2 |
3 | It is possible to specify the [path of binaries](/guide/essentials/configuration.html#bin-optional). Useful if your installations are specific or if you are dropping off precompiled versions.
4 |
5 | Installation required:
6 |
7 | ::: code-group
8 | ```sh [npm]
9 | npm install fluent-ffmpeg
10 | ```
11 | ```sh [pnpm]
12 | pnpm install fluent-ffmpeg
13 | ```
14 | ```sh [yarn]
15 | yarn add fluent-ffmpeg
16 | ```
17 | :::
18 |
--------------------------------------------------------------------------------
/docs/guide/partials/table-converter.md:
--------------------------------------------------------------------------------
1 | |Converter |File type |Description|Required |
2 | | ------------- | :-----------: | -------- | -------- |
3 | |[image_converter](/guide/converters/image)|JPEG, PNG, WebP, GIF and AVIF | Generate other image and change format/size etc...|[sharp](https://sharp.pixelplumbing.com/)|
4 | |[pdf_thumbnail_converter](/guide/converters/pdf-thumbnail)|PDF |Generate thumbnail image of PDF|[poppler(pdftocairo)](https://www.npmjs.com/package/node-poppler)|
5 | |[document_thumbnail_converter](/guide/converters/document-thumbnail)|PDF, ODT, ODS, DOCX, DOC, NUMBERS, PAGES, XLSX, XLS, CSV, RTF, TXT |Generate thumbnail image of document|[libreoffice-file-converter](https://www.npmjs.com/package/libreoffice-file-converter)|
6 | |[video_thumbnail_converter](/guide/converters/video-thumbnail)|MP4, MOV, AVI, FLV, MKV |Generate thumbnail image of video|[fluent-ffmpeg](https://www.npmjs.com/package/fluent-ffmpeg)|
7 |
--------------------------------------------------------------------------------
/docs/guide/start-here.md:
--------------------------------------------------------------------------------
1 | # Start Here
2 |
3 | Simple upload file
4 |
5 | ```ts
6 | // app/models/user.ts
7 | import { BaseModel } from '@adonisjs/lucid/orm'
8 | import { attachment } from '@jrmc/adonis-attachment'
9 | import type { Attachment } from '@jrmc/adonis-attachment/types/attachment' // [!code highlight]
10 |
11 | class User extends BaseModel {
12 | @attachment() // [!code highlight]
13 | declare avatar: Attachment // [!code highlight]
14 | }
15 | ```
16 |
17 | ---
18 |
19 | ```ts
20 | // app/controllers/users_controller.ts
21 | import { attachmentManager } from '@jrmc/adonis-attachment' // [!code focus]
22 |
23 | class UsersController {
24 | public store({ request }: HttpContext) {
25 | const avatar = request.file('avatar')! // [!code focus]
26 | const user = new User()
27 |
28 | user.avatar = await attachmentManager.createFromFile(avatar) // [!code focus]
29 | await user.save()
30 | }
31 | }
32 | ```
33 |
34 | ---
35 |
36 | ```edge
37 |
38 | ```
39 |
--------------------------------------------------------------------------------
/docs/guide/use-cases/picture.md:
--------------------------------------------------------------------------------
1 | # Picture
2 |
3 | Using your variants with `` for create a component.
4 |
5 | ::: code-group
6 |
7 | ```js [edge]
8 | // picture.edge
9 |
10 |
11 |
12 |
13 |
14 |
15 | ```
16 | ```js [react]
17 | // picture.jsx
18 | import React from 'react';
19 |
20 | const Picture = ({ source, alt }) => {
21 | return (
22 |
23 |
24 |
25 |
26 |
27 |
28 | )
29 | }
30 | ```
31 | ```svelte [vue]
32 | // picture.vue
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
48 | ```
49 | ```svelte
50 | // picture.svelte
51 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | ```
63 | :::
64 |
65 | Use
66 |
67 | ::: code-group
68 |
69 | ```edge [edge]
70 | @!picture({
71 | source: article.image,
72 | alt: "Image alt"
73 | })
74 | ```
75 | ```js [react]
76 |
80 | ```
81 | ```svelte [vue]
82 |
86 | ```
87 | ```svelte
88 |
92 | ```
93 | :::
94 |
95 |
96 | Configuration
97 |
98 | ::: code-group
99 |
100 | ```ts [config/attachment.ts]
101 | import { defineConfig } from '@jrmc/adonis-attachment'
102 |
103 | export default defineConfig({
104 | preComputeUrl: true,
105 | converters: [
106 | {
107 | key: 'small',
108 | converter: () => import('@jrmc/adonis-attachment/converters/image_converter'),
109 | options: {
110 | resize: 480,
111 | }
112 | },
113 | {
114 | key: 'medium',
115 | converter: () => import('@jrmc/adonis-attachment/converters/image_converter'),
116 | options: {
117 | resize: 768,
118 | }
119 | },
120 | {
121 | key: 'large',
122 | converter: () => import('@jrmc/adonis-attachment/converters/image_converter'),
123 | options: {
124 | resize: 1200,
125 | }
126 | }
127 | ]
128 | })
129 | ```
130 | ```ts [app/models/article.ts]
131 | import { BaseModel } from '@adonisjs/lucid/orm'
132 | import { attachment } from '@jrmc/adonis-attachment'
133 | import type { Attachment } from '@jrmc/adonis-attachment/types/attachment'
134 |
135 | class User extends BaseModel {
136 | @attachment({
137 | variants: ['small', 'medium', 'large']
138 | })
139 | declare image: Attachment
140 | }
141 | ```
142 |
143 | :::
144 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | # https://vitepress.dev/reference/default-theme-home-page
3 | layout: home
4 |
5 | hero:
6 | name: "Adonis Attachment"
7 | text: "Simple upload for Lucid models."
8 | tagline: "Automatically generating various sizes and formats."
9 | actions:
10 | - theme: brand
11 | text: Getting started
12 | link: /guide/start-here
13 | - theme: alt
14 | text: View on GitHub
15 | link: https://github.com/batosai/adonis-attachment
16 |
17 | features:
18 | - icon:
19 | light: /light-upload.svg
20 | dark: /dark-upload.svg
21 | title: Upload
22 | details: Simplifying upload management.
23 | - icon:
24 | light: /light-convert.svg
25 | dark: /dark-convert.svg
26 | title: Convert
27 | details: Optimise, convert, crop image, thumbnail video etc.
28 | - icon:
29 | light: /light-view.svg
30 | dark: /dark-view.svg
31 | title: Edge & inertia compliant
32 | details: Go to attachment in your favorite frontend
33 | ---
34 |
35 |
--------------------------------------------------------------------------------
/docs/public/dark-convert.svg:
--------------------------------------------------------------------------------
1 |
2 |
8 |
--------------------------------------------------------------------------------
/docs/public/dark-upload.svg:
--------------------------------------------------------------------------------
1 |
2 |
6 |
--------------------------------------------------------------------------------
/docs/public/dark-view.svg:
--------------------------------------------------------------------------------
1 |
2 |
6 |
--------------------------------------------------------------------------------
/docs/public/light-convert.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/docs/public/light-upload.svg:
--------------------------------------------------------------------------------
1 |
2 |
6 |
--------------------------------------------------------------------------------
/docs/public/light-view.svg:
--------------------------------------------------------------------------------
1 |
2 |
6 |
--------------------------------------------------------------------------------
/docs/structure-data-json.md:
--------------------------------------------------------------------------------
1 | # Struture data JSON
2 |
3 | Meta data list (if available):
4 |
5 | - name
6 | - original name
7 | - extension name
8 | - size
9 | - path
10 | - dimension (width, height)
11 | - created date
12 | - orientation
13 | - mime type
14 | - gps
15 | - url
16 |
17 | Sample struture JSON in database
18 |
19 | ## For image
20 |
21 | ```json
22 | {
23 | "name":"xjfpa4tuxh66p6s3hrgl0pzn.jpg",
24 | "extname":"jpg",
25 | "size":28267,
26 | "meta":{
27 | "date":"2010:01:15 18:51:34",
28 | "host":"Adobe Photoshop CS3 Windows",
29 | "dimension":{
30 | "width":346,
31 | "height":346
32 | },
33 | "gps":{
34 | "latitude":48.862725,
35 | "longitude":2.287592,
36 | "altitude":2.287592
37 | },
38 | "orientation":{
39 | "value":1,
40 | "description":"top-left"
41 | }
42 | },
43 | "mimeType":"image/jpeg",
44 | "path":"uploads/avatars/xjfpa4tuxh66p6s3hrgl0pzn.jpg",
45 | "originalName":"aco_bot.jpg",
46 | "url": "/uploads/avatars/[...]",
47 | "variants":[
48 | {
49 | "key":"thumbnail",
50 | "folder":"uploads/avatars/variants/xjfpa4tuxh66p6s3hrgl0pzn.jpg",
51 | "name":"ajtugq7224qp9moqyi216vur.webp",
52 | "extname":"webp",
53 | "size":14860,
54 | "meta":{
55 | "date":"2010:01:15 18:51:34",
56 | "host":"Adobe Photoshop CS3 Windows",
57 | "dimension":{
58 | "width":300,
59 | "height":300
60 | },
61 | "orientation":{
62 | "value":1,
63 | "description":"top-left"
64 | }
65 | },
66 | "mimeType":"image/webp",
67 | "path":"uploads/avatars/variants/xjfpa4tuxh66p6s3hrgl0pzn.jpg/ajtugq7224qp9moqyi216vur.webp",
68 | "url": "/uploads/avatars/variants/[...]",
69 | "blurhash": "UDIMp4S~0L0f01M{~B^j0gn*NGxG^Ot7M{NH"
70 | }
71 | ]
72 | }
73 | ```
74 |
75 | ## For video
76 |
77 | ```json
78 | {
79 | "name":"rp1xsz3mz3o70190qnmaptyb.mkv",
80 | "extname":"mkv",
81 | "size":1677679979,
82 | "meta":{
83 | "dimension":{
84 | "width":1912,
85 | "height":812
86 | }
87 | },
88 | "mimeType":"video/x-matroska",
89 | "path":"videos/rp1xsz3mz3o70190qnmaptyb.mkv",
90 | "originalName":"The.Legend.of.Kunlun.2022.FRENCH.1080p.WEBRip.x264.AAC-MULTiViSiON.mkv",
91 | "variants":[
92 | {
93 | "key":"preview",
94 | "folder":"videos/variants/rp1xsz3mz3o70190qnmaptyb.mkv",
95 | "name":"o8lechg4cuy8psth0b2uebq4.jpg",
96 | "extname":"jpg",
97 | "size":31150,
98 | "meta": {
99 | "dimension":{
100 | "width":720,
101 | "height":306
102 | },
103 | "orientation":{
104 | "value":1,
105 | "description":"top-left"
106 | }
107 | },
108 | "mimeType":"image/jpeg",
109 | "path":"videos/variants/rp1xsz3mz3o70190qnmaptyb.mkv/o8lechg4cuy8psth0b2uebq4.jpg"
110 | }
111 | ]
112 | }
113 | ```
114 |
--------------------------------------------------------------------------------
/docs/v2/guide/advanced_usage/custom-converter.md:
--------------------------------------------------------------------------------
1 | # Custom converter
2 |
3 | ⚠️ [avalable in v2.4.0](/changelog#_2-4-0)
4 |
5 | ## Make converter
6 |
7 | The converts are stored within the ./app/converters directory. You may create a new converter by running the following command.
8 |
9 | ```sh
10 | node ace make:converter gif2webp
11 | ```
12 |
13 | - Form: `singular`
14 | - Suffix: `converter`
15 | - Class name example: `Gif2WebpConverter`
16 | - File name example: `gif_2_webp_converter.ts`
17 |
18 | ## Samples animate gif to webp
19 |
20 | ```ts
21 | import type { ConverterAttributes } from '@jrmc/adonis-attachment/types/converter'
22 | import type { Input } from '@jrmc/adonis-attachment/types/input'
23 |
24 | import Converter from '@jrmc/adonis-attachment/converters/converter'
25 | import sharp from 'sharp'
26 |
27 | export default class Gif2WebpConverter extends Converter {
28 | async handle({ input }: ConverterAttributes): Promise {
29 | const sharpImage = sharp(input, { animated: true, pages: -1 })
30 |
31 | const imageMeta = await sharpImage.metadata()
32 | const { loop, delay } = imageMeta
33 |
34 | const options = {
35 | webp: {
36 | loop,
37 | delay,
38 | }
39 | }
40 |
41 | const buffer = await sharpImage
42 | .withMetadata()
43 | .webp(options.webp)
44 | .toBuffer()
45 |
46 | return buffer
47 | }
48 | }
49 | ```
50 |
51 |
52 | ## Samples video to animate gif
53 |
54 | ```ts
55 | import type { ConverterAttributes } from '@jrmc/adonis-attachment/types/converter'
56 | import type { Input } from '@jrmc/adonis-attachment/types/input'
57 |
58 | import os from 'node:os'
59 | import path from 'node:path'
60 | import fs from 'fs/promises'
61 | import { cuid } from '@adonisjs/core/helpers'
62 | import Converter from '@jrmc/adonis-attachment/converters/converter'
63 | import ffmpeg from 'fluent-ffmpeg'
64 |
65 | export default class Video2GifConverter extends Converter {
66 | async handle({ input }: ConverterAttributes): Promise {
67 | return await this.videoToGif(ffmpeg, input)
68 | }
69 |
70 | async videoToGif(ffmpeg: Function, input: Input) {
71 | let file = input
72 |
73 | if (Buffer.isBuffer(input)) {
74 | file = await this.bufferToTempFile(input)
75 | }
76 |
77 | return new Promise((resolve, reject) => {
78 | const folder = os.tmpdir()
79 | const filename = `${cuid()}.png`
80 | const destination = path.join(folder, filename)
81 |
82 |
83 | const ff = ffmpeg(file)
84 |
85 | if (this.binPaths) {
86 | if (this.binPaths.ffmpegPath) {
87 | ff.setFfmpegPath(this.binPaths.ffmpegPath)
88 | }
89 | }
90 |
91 | ff
92 | .withOptions([
93 | '-ss 1',
94 | `-i ${file}`,
95 | `-filter_complex [0:v]trim=duration=3;`,
96 | '-f gif'
97 | ])
98 | .on('end', () => {
99 | resolve(destination)
100 | })
101 | .on('error', (err: Error) => {
102 | reject(err)
103 | })
104 | .output(destination)
105 | .run()
106 | })
107 | }
108 |
109 | async bufferToTempFile(input: Buffer) {
110 | const folder = os.tmpdir()
111 | const tempFilePath = path.join(folder, `tempfile-${Date.now()}.tmp`)
112 | await fs.writeFile(tempFilePath, input)
113 | return tempFilePath
114 | }
115 | }
116 | ```
117 |
--------------------------------------------------------------------------------
/docs/v2/guide/advanced_usage/exceptions.md:
--------------------------------------------------------------------------------
1 |
2 | # Exceptions
3 |
4 | |Code |Description | Origin |
5 | | -------------------------- | ----------------------------------------------- | ------- |
6 | | E_MISSING_PACKAGE | Missing package | |
7 | | E_CANNOT_CREATE_ATTACHMENT | Unable to create Attachment Object | |
8 | | E_CANNOT_CREATE_VARIANT | Unable to create variant | |
9 | | E_CANNOT_PATH_BY_CONVERTER | Missing path by converter | |
10 | | E_ISNOT_BUFFER | Is not a Buffer | |
11 | | E_ISNOT_BASE64 | Is not a Base64 | |
12 | | ENOENT | Unable to read file | |
13 | | E_CANNOT_WRITE_FILE | Unable to write file to the destination | Drive |
14 | | E_CANNOT_READ_FILE | Unable to read file | Drive |
15 | | E_CANNOT_DELETE_FILE | Unable to delete file | Drive |
16 | | E_CANNOT_SET_VISIBILITY | Unable to set file visibility | Drive |
17 | | E_CANNOT_GENERATE_URL | Unable to generate URL for a file | Drive |
18 | | E_UNALLOWED_CHARACTERS | The file key has unallowed set of characters | Drive |
19 | | E_INVALID_KEY | Key post normalization leads to an empty string | Drive |
20 |
21 | [Adonis documentation exception](https://docs.adonisjs.com/guides/basics/exception-handling)
22 |
23 | ## Handling exceptions
24 |
25 | If you want to handle a specific exception differently, you can do that inside the `handle` method. Make sure to use the `ctx.response.send` method to send a response, since the return value from the `handle` method is discarded.
26 |
27 | ::: code-group
28 |
29 | ```typescript [API]
30 | import { errors } from '@jrmc/adonis-attachment'
31 |
32 | export default class HttpExceptionHandler extends ExceptionHandler {
33 | async handle(error: unknown, ctx: HttpContext) {
34 | if (error instanceof errors.E_CANNOT_WRITE_FILE) {
35 | const err = error as errors.E_CANNOT_WRITE_FILE
36 | ctx.response.status(422).send(err.messages)
37 | return
38 | }
39 |
40 | return super.handle(error, ctx)
41 | }
42 | }
43 | ```
44 |
45 | ```typescript [web]
46 | import { errors } from '@jrmc/adonis-attachment'
47 |
48 | export default class HttpExceptionHandler extends ExceptionHandler {
49 | async handle(error: unknown, ctx: HttpContext) {
50 | if (error instanceof errors.E_CANNOT_WRITE_FILE) {
51 | ctx.session.flash('notification', {
52 | type: 'error',
53 | message: err.message,
54 | })
55 |
56 | return ctx.response.redirect('back')
57 | }
58 |
59 | return super.handle(error, ctx)
60 | }
61 | }
62 |
63 | ```
64 |
65 | :::
66 |
--------------------------------------------------------------------------------
/docs/v2/guide/advanced_usage/pre-compile-on-demand.md:
--------------------------------------------------------------------------------
1 | # Pre compute on demand
2 |
3 | We recommend not enabling the preComputeUrl option when you need the URL for just one or two queries and not within the rest of your application.
4 |
5 | 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.
6 |
7 | ```ts
8 | import type { Attachment } from '@jrmc/adonis-attachment/types/attachment'
9 | import { attachment, Attachmentable, attachmentManager } from '@jrmc/adonis-attachment'
10 |
11 | class User extends compose(BaseModel, Attachmentable) {
12 | static async preComputeUrls(models: User | User[]) {
13 | if (Array.isArray(models)) {
14 | await Promise.all(models.map((model) => this.preComputeUrls(model)))
15 | return
16 | }
17 |
18 | // compute url for original file
19 | await attachmentManager.computeUrl(models.avatar)
20 |
21 | // compute url for thumbnail variant
22 | const thumb = models.avatar.getVariant('thumbnail')
23 | await attachmentManager.computeUrl(thumb)
24 |
25 | // compute url for medium variant with expiration time option
26 | const medium = models.avatar.getVariant('medium')
27 | await attachmentManager.computeUrl(medium, {
28 | expiresIn: '30 mins',
29 | })
30 | }
31 |
32 | @attachment({
33 | variants: ['thumbnail', 'medium', 'large']
34 | })
35 | declare avatar: Attachment
36 | }
37 | ```
38 |
39 | computeUrl method create automatically creates a signed or unsigned url depending on Drive's configuration.
40 |
41 | it's possible to pass specific options to the signed url.
42 | options params accepts `expiresIn`, `contentType` et `contentDisposition`.
43 |
44 | [More informations](https://flydrive.dev/docs/disk_api#getsignedurl)
45 |
46 | ---
47 |
48 | And now use it as follows.
49 |
50 | ```ts
51 | const users = await User.all()
52 | await User.preComputeUrls(users)
53 |
54 | return users
55 | ```
56 |
57 | Or for a single user
58 |
59 | ```ts
60 | const user = await User.findOrFail(1)
61 | await User.preComputeUrls(user)
62 |
63 | return user
64 | ```
65 |
--------------------------------------------------------------------------------
/docs/v2/guide/advanced_usage/queue.md:
--------------------------------------------------------------------------------
1 | # Queue
2 |
3 | The media transforms are carried out int the queue, grouped by model attribut with the [@poppinss/defer](https://github.com/poppinss/defer) library.
4 |
5 | ## Events
6 |
7 | Create your preload file for events catch:
8 |
9 | ```sh
10 | node ace make:preload queue
11 | ```
12 |
13 | ```ts
14 | import { attachmentManager } from '@jrmc/adonis-attachment'
15 | import logger from '@adonisjs/core/services/logger'
16 |
17 | attachmentManager.queue.onError = function (error, task) {
18 | logger.info(`${task.name} task failed with the following error`)
19 | logger.error(error.message)
20 | }
21 |
22 | attachmentManager.queue.taskCompleted = function (task) {
23 | logger.info(`${task.name} completed. ${attachmentManager.queue.size()} tasks left`)
24 | }
25 |
26 | attachmentManager.queue.drained = function () {
27 | logger.info('Processed last task in the queue')
28 | }
29 |
30 | ```
31 |
32 |
--------------------------------------------------------------------------------
/docs/v2/guide/basic_usage/controller-setup.md:
--------------------------------------------------------------------------------
1 | # Controller setup
2 |
3 | ## From file
4 |
5 | Now you can create an attachment from the user uploaded file as follows.
6 |
7 | ```ts
8 | import { attachmentManager } from '@jrmc/adonis-attachment' // [!code focus]
9 |
10 | class UsersController {
11 | public store({ request }: HttpContext) {
12 | const avatar = request.file('avatar')! // [!code focus]
13 | const user = new User()
14 |
15 | user.avatar = await attachmentManager.createFromFile(avatar) // [!code focus]
16 | await user.save()
17 | }
18 | }
19 | ```
20 |
21 | ## From Buffer
22 |
23 | ```ts
24 | import { attachmentManager } from '@jrmc/adonis-attachment' // [!code focus]
25 | import app from '@adonisjs/core/services/app'
26 |
27 | class UsersController {
28 | public store({ request }: HttpContext) {
29 | const user = new User()
30 |
31 | const buffer = await readFile(app.makePath('me.jpg'))
32 |
33 | user.avatar = await attachmentManager.createFromBuffer(buffer, 'photo.jpg') // [!code focus]
34 | await user.save()
35 | }
36 | }
37 | ```
38 |
39 | ## From Base64
40 |
41 | ⚠️ [avalable in v2.3.0](/changelog#_2-3-0)
42 |
43 | ```ts
44 | import { attachmentManager } from '@jrmc/adonis-attachment' // [!code focus]
45 | import app from '@adonisjs/core/services/app'
46 |
47 | class UsersController {
48 | public store({ request }: HttpContext) {
49 | const user = new User()
50 |
51 | const b64 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII="
52 |
53 | user.avatar = await attachmentManager.createFromBase64(b64, 'photo.jpg') // [!code focus]
54 | await user.save()
55 | }
56 | }
57 | ```
58 |
59 | ## Delete Attachment
60 |
61 |
62 | ::: code-group
63 | ```ts [model]
64 | class User extends compose(BaseModel, Attachmentable) {
65 | @attachment()
66 | declare avatar: Attachment | null
67 | }
68 | ```
69 |
70 | ```ts [controller]
71 | user.avatar = null
72 | await user.save()
73 | ```
74 |
75 | :::
76 |
--------------------------------------------------------------------------------
/docs/v2/guide/basic_usage/migration-setup.md:
--------------------------------------------------------------------------------
1 | # Migration setup
2 |
3 | Often times, the size of the image metadata could exceed the allowable length of an SQL `String` data type. So, it is recommended to create/modify the column which will hold the metadata to use a `JSON` data type.
4 |
5 | If you are creating the column for the first time, make sure that you use the JSON data type. Example:
6 |
7 | ```ts
8 | // Within the migration file
9 |
10 | protected tableName = 'users'
11 |
12 | public async up() {
13 | this.schema.createTable(this.tableName, (table) => {
14 | table.increments()
15 | table.json('avatar') // <-- Use a JSON data type
16 | })
17 | }
18 | ```
19 |
20 | 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:
21 |
22 | ```bash
23 | # Create a new migration file
24 | node ace make:migration change_avatar_column_to_json --table=users
25 | ```
26 |
27 | ```ts
28 | // Within the migration file
29 |
30 | protected tableName = 'users'
31 |
32 | public async up() {
33 | this.schema.alterTable(this.tableName, (table) => {
34 | table.json('avatar').alter() // <-- Alter the column definition
35 | })
36 | }
37 | ```
38 |
--------------------------------------------------------------------------------
/docs/v2/guide/basic_usage/model-setup.md:
--------------------------------------------------------------------------------
1 | # Model setup
2 |
3 | Next, in the model, import the `attachment` decorator, `Attachmentable` mixin and the `Attachment` type from the package.
4 |
5 | > Make sure NOT to use the `@column` decorator when using the `@attachment` decorator.
6 |
7 | ```ts
8 | import { BaseModel } from '@adonisjs/lucid/orm'
9 | import { compose } from '@adonisjs/core/helpers'
10 | import { attachment, Attachmentable } from '@jrmc/adonis-attachment'
11 | import type { Attachment } from '@jrmc/adonis-attachment/types/attachment' // [!code highlight]
12 |
13 | class User extends compose(BaseModel, Attachmentable) { // [!code highlight]
14 | @attachment() // [!code highlight]
15 | declare avatar: Attachment // [!code highlight]
16 | }
17 | ```
18 |
19 | ## Specifying subfolder
20 |
21 | You can also store files inside the subfolder by defining the `folder` property as follows.
22 |
23 | ```ts
24 | class User extends BaseModel {
25 | @attachment({ folder: 'uploads/avatars' }) // [!code highlight]
26 | declare avatar: Attachment
27 | }
28 | ```
29 |
30 | ## Specifying variants
31 |
32 | Generate variants
33 |
34 | ```ts
35 | class User extends BaseModel {
36 | @attachment({
37 | variants: ['thumbnail', 'medium', 'large'] // [!code highlight]
38 | })
39 | declare avatar: Attachment
40 | }
41 | ```
42 |
43 | ## Specifying disk
44 |
45 | You can specify type of disk to use, default is defined in default adonis/drive config
46 |
47 | ```ts
48 | class User extends BaseModel {
49 | @attachment({ disk: 's3' }) // [!code highlight]
50 | declare avatar: Attachment
51 | }
52 | ```
53 |
54 | ## Specifying preComputeUrl
55 |
56 | ⚠️ [avalable in v2.2.0](/changelog#_2-2-0)
57 |
58 | You can enabled pre compute the URLs after SELECT queries, default is false
59 |
60 | ```ts
61 | class User extends BaseModel {
62 | @attachment({ preComputeUrl: true }) // [!code highlight]
63 | declare avatar: Attachment
64 | }
65 | ```
66 |
67 |
68 | ## Specifying meta
69 |
70 | ⚠️ [avalable in v2.1.0](/changelog#_2-1-0)
71 |
72 | You can disabled meta generation, default is true
73 |
74 | ```ts
75 | class User extends BaseModel {
76 | @attachment({ meta: false }) // [!code highlight]
77 | declare avatar: Attachment
78 | }
79 | ```
80 |
81 | ## Specifying rename
82 |
83 | ⚠️ [avalable in v2.1.0](/changelog#_2-1-0)
84 |
85 | You can disabled rename file, default is true
86 |
87 | ```ts
88 | class User extends BaseModel {
89 | @attachment({ rename: false }) // [!code highlight]
90 | declare avatar: Attachment
91 | }
92 | ```
93 |
94 |
95 |
--------------------------------------------------------------------------------
/docs/v2/guide/basic_usage/view-setup.md:
--------------------------------------------------------------------------------
1 | # View setup
2 |
3 | Now all you have to do is display your images in your view.
4 |
5 | ## URLs for edge template
6 |
7 | ```ts
8 | await user.avatar.getUrl()
9 | await user.avatar.getUrl('thumbnail')
10 | // or await user.avatar.getVariant('thumbnail').getUrl()
11 |
12 | await user.avatar.getSignedUrl()
13 | await user.avatar.getSignedUrl('thumbnail')
14 | // or await user.avatar.getVariant('thumbnail').getSignedUrl()
15 | ```
16 |
17 | ```edge
18 |
19 |
20 |
21 |
24 |
25 |
26 |
29 | ```
30 |
31 | getSignedUrl options params accepts `expiresIn`, `contentType` et `contentDisposition`. [More informations](https://flydrive.dev/docs/disk_api#getsignedurl)
32 |
33 | ### If preComputeUrl is enabled
34 |
35 | ```edge
36 |
37 |
38 | ```
39 |
40 |
41 | ## URLs for Inertia template
42 |
43 | ::: code-group
44 | ```js [react]
45 |
46 | ```
47 |
48 | ```vue
49 |
50 | ```
51 |
52 | ```svelte
53 |
54 | ```
55 | :::
56 |
57 | preComputeUrl is required.
58 |
--------------------------------------------------------------------------------
/docs/v2/guide/converters/document-thumbnail.md:
--------------------------------------------------------------------------------
1 |
2 | # Document thumbnail converter
3 |
4 | ⚠️ [avalable in v2.3.0](/changelog#_2-3-0)
5 |
6 |
7 |
8 | ## Configuration
9 |
10 | ```typescript
11 | // config/attachment.ts // [!code focus:1]
12 | export default defineConfig({
13 | converters: [
14 | { // [!code focus:4]
15 | key: 'preview',
16 | converter: () => import('@jrmc/adonis-attachment/converters/document_thumbnail_converter'),
17 | }
18 | ]
19 | })
20 | ```
21 |
22 | By default, image format is `JPEG` and size is video size. `options` attribute use ***[image_converter](/v2/guide/converters/image)***
23 |
24 | Sample:
25 |
26 | ```typescript{6-9}
27 | export default defineConfig({
28 | converters: [
29 | { // [!code focus:8]
30 | key: 'preview',
31 | converter: () => import('@jrmc/adonis-attachment/converters/document_thumbnail_converter'),
32 | options: {
33 | format: 'webp',
34 | resize: 720
35 | }
36 | }
37 | ]
38 | })
39 | ```
40 |
--------------------------------------------------------------------------------
/docs/v2/guide/converters/image.md:
--------------------------------------------------------------------------------
1 | # Image converter
2 |
3 |
4 |
5 | ## Configuration
6 |
7 | ```typescript
8 | // config/attachment.ts // [!code focus:1]
9 | export default defineConfig({
10 | converters: [
11 | { // [!code focus:7]
12 | key: 'large',
13 | converter: () => import('@jrmc/adonis-attachment/converters/image_converter'),
14 | options: {
15 | resize: 1280,
16 | }
17 | }
18 | ]
19 | })
20 | ```
21 |
22 | ## Format
23 |
24 | The default format is `webp`, for change, use options format:
25 |
26 | ```typescript
27 | export default defineConfig({
28 | converters: [
29 | { // [!code focus:8]
30 | key: 'thumbnail',
31 | converter: () => import('@jrmc/adonis-attachment/converters/image_converter'),
32 | options: {
33 | resize: 300,
34 | format: 'jpeg', // [!code highlight]
35 | }
36 | }
37 | ]
38 | })
39 | ```
40 |
41 | Options format is `string` or `object` [ format, options ] details in documentation : [sharp api outpout](https://sharp.pixelplumbing.com/api-output#toformat)
42 |
43 |
44 | Sample for personalize image quality:
45 |
46 | ```typescript{8-13}
47 | export default defineConfig({
48 | converters: [
49 | { // [!code focus:13]
50 | key: 'thumbnail',
51 | converter: () => import('@jrmc/adonis-attachment/converters/image_converter'),
52 | options: {
53 | resize: 300,
54 | format: {
55 | format: 'jpeg',
56 | options: {
57 | quality: 80
58 | }
59 | }
60 | }
61 | }
62 | ]
63 | })
64 | ```
65 |
66 | ## ReSize
67 |
68 | Options resize is `number` or `object`(options) details in documentation : [sharp api resize](https://sharp.pixelplumbing.com/api-resize)
69 |
70 | Sample:
71 |
72 | ```typescript{11-16}
73 | import { defineConfig } from '@jrmc/adonis-attachment'
74 | import sharp from 'sharp'
75 |
76 | export default defineConfig({
77 | converters: [
78 | {
79 | key: 'thumbnail',
80 | converter: () => import('@jrmc/adonis-attachment/converters/image_converter'),
81 | options: {
82 | format: 'jpeg',
83 | resize: { // https://sharp.pixelplumbing.com/api-resize
84 | width: 400,
85 | height: 400,
86 | fit: sharp.fit.cover,
87 | position: 'top'
88 | },
89 | }
90 | }
91 | ]
92 | })
93 | ```
94 |
95 |
96 |
97 |
98 |
--------------------------------------------------------------------------------
/docs/v2/guide/converters/pdf-thumbnail.md:
--------------------------------------------------------------------------------
1 |
2 | # PDF thumbnail converter
3 |
4 | ⚠️ [avalable in v2.3.0](/changelog#_2-3-0)
5 |
6 |
7 |
8 | ## Configuration
9 |
10 | ```typescript
11 | // config/attachment.ts // [!code focus:1]
12 | export default defineConfig({
13 | converters: [
14 | { // [!code focus:4]
15 | key: 'preview',
16 | converter: () => import('@jrmc/adonis-attachment/converters/pdf_thumbnail_converter'),
17 | }
18 | ]
19 | })
20 | ```
21 |
22 | By default, image format is `JPEG` and size is video size. `options` attribute use ***[image_converter](/v2/guide/converters/image)***
23 |
24 | Sample:
25 |
26 | ```typescript{6-9}
27 | export default defineConfig({
28 | converters: [
29 | { // [!code focus:8]
30 | key: 'preview',
31 | converter: () => import('@jrmc/adonis-attachment/converters/pdf_thumbnail_converter'),
32 | options: {
33 | format: 'webp',
34 | resize: 720
35 | }
36 | }
37 | ]
38 | })
39 | ```
40 |
--------------------------------------------------------------------------------
/docs/v2/guide/converters/video-thumbnail.md:
--------------------------------------------------------------------------------
1 |
2 | # Video thumbnail converter
3 |
4 |
5 |
6 |
7 | ## Configuration
8 |
9 | ```typescript
10 | // config/attachment.ts // [!code focus:1]
11 | export default defineConfig({
12 | converters: [
13 | { // [!code focus:4]
14 | key: 'preview',
15 | converter: () => import('@jrmc/adonis-attachment/converters/video_thumbnail_converter'),
16 | }
17 | ]
18 | })
19 | ```
20 |
21 | By default, image format is `PNG` and size is video size. `options` attribute use ***[image_converter](/v2/guide/converters/image)***
22 |
23 | Sample:
24 |
25 | ```typescript{6-9}
26 | export default defineConfig({
27 | converters: [
28 | { // [!code focus:8]
29 | key: 'preview',
30 | converter: () => import('@jrmc/adonis-attachment/converters/video_thumbnail_converter'),
31 | options: {
32 | format: 'jpeg',
33 | resize: 720
34 | }
35 | }
36 | ]
37 | })
38 | ```
39 |
--------------------------------------------------------------------------------
/docs/v2/guide/essentials/configuration.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # General configuration
4 |
5 | Attachment configuration is located in the `config/attachment.ts` file. By default, the file looks like this:
6 |
7 | ```typescript
8 | import { defineConfig } from '@jrmc/adonis-attachment'
9 |
10 | export default defineConfig({
11 | converters: [
12 | {
13 | key: 'thumbnail',
14 | converter: () => import('@jrmc/adonis-attachment/converters/image_converter'),
15 | options: {
16 | resize: 300,
17 | }
18 | }
19 | ]
20 | })
21 | ```
22 |
23 | ## converters
24 |
25 | |OPTIONS: | DESCRIPTIONS: |
26 | | -------- | ------------------------ |
27 | |key |Variant name |
28 | |converter |Class for generate variant|
29 | |options |Options converter |
30 |
31 | ---
32 |
33 | ## preComputeUrl (optional, default false)
34 |
35 | enable the preComputeUrl flag to pre compute the URLs after SELECT queries.
36 |
37 | ```typescript
38 | export default defineConfig({
39 | preComputeUrl: true, // [!code focus]
40 | converters: [
41 | // ...
42 | ]
43 | })
44 | ```
45 |
46 | ## meta (optional, default true)
47 |
48 | you can set the default meta generation or not
49 |
50 | ```typescript
51 | export default defineConfig({
52 | meta: false, // [!code focus]
53 | converters: [
54 | // ...
55 | ]
56 | })
57 | ```
58 |
59 | ---
60 |
61 | ## rename (optional, default true)
62 |
63 | You can define by default if the files are renamed or not.
64 |
65 | ```typescript
66 | export default defineConfig({
67 | rename: false, // [!code focus]
68 | converters: [
69 | // ...
70 | ]
71 | })
72 | ```
73 |
74 | ---
75 |
76 | ## bin (optional)
77 |
78 | You may set the ffmpeg and ffprobe binary paths manually:
79 |
80 | ```typescript
81 | export default defineConfig({
82 | bin: { // [!code focus:8]
83 | ffmpegPath: 'ffmpeg_path', // the full path of the binary
84 | ffprobePath: 'ffprobe_path', // the full path of the binary
85 | pdftocairoBasePath: 'base_dir_path' // the path of the directory containing the binary
86 | libreofficePaths: [
87 | 'libreoffice_path', // the full path of the binary
88 | ] // Array of paths to LibreOffice binary executables.
89 | },
90 | converters: [
91 | // ...
92 | ]
93 | })
94 | ```
95 |
96 |
97 | |OPSTIONS |DESCRIPTIONS: |
98 | | ----------------- | ---------------------------------------------------------------------------------- |
99 | |ffmpegPath |Argument `path` is a string with the full path to the ffmpeg binary |
100 | |ffprobePath |Argument `path` is a string with the full path to the ffprobe binary |
101 | |pdftocairoBasePath |Argument `path` is a string with the path of the directory to the pdftocairo binary |
102 | |libreofficePaths |Array of `paths` to LibreOffice binary executables |
103 |
104 |
105 | ### sample
106 |
107 | [download ffmpeg](https://ffbinaries.com/downloads) and ffprobe binary in /bin. Add execution right.
108 |
109 | ```sh
110 | cd bin
111 | chmod +x ffmpeg
112 | chmod +x ffprobe
113 | ```
114 |
115 | ```typescript
116 | import app from '@adonisjs/core/services/app'
117 | import { defineConfig } from '@jrmc/adonis-attachment'
118 |
119 | export default defineConfig({
120 | bin: { // [!code focus:4]
121 | ffmpegPath: app.makePath('bin/ffmpeg'),
122 | ffprobePath: app.makePath('bin/ffprobe'),
123 | },
124 | converters: [
125 | // ...
126 | ]
127 | })
128 | ```
129 |
130 | ## queue concurrency (optional)
131 |
132 | The convert processing is carried out async in a queue
133 |
134 | By default, 1 task is processed concurrently. 1 task corresponds to a model attribute. For example, if a model has a logo attribute and an avatar attribute, this represents 2 tasks whatever the number of concert per attribute.
135 |
136 | ```typescript
137 | import app from '@adonisjs/core/services/app'
138 | import { defineConfig } from '@jrmc/adonis-attachment'
139 |
140 | export default defineConfig({
141 | queue: { // [!code focus:3]
142 | concurrency: 2
143 | },
144 | converters: [
145 | // ...
146 | ]
147 | })
148 | ```
149 |
--------------------------------------------------------------------------------
/docs/v2/guide/essentials/installation.md:
--------------------------------------------------------------------------------
1 | # Installation
2 |
3 | The Adonis Attachment package is available on [npm](https://www.npmjs.com/package/@jrmc/adonis-attachment).
4 |
5 | It's required [Adonis Drive](https://docs.adonisjs.com/guides/digging-deeper/drive), please look at the documentation if this is not installed.
6 |
7 | You can install it using the following ace command to automagically configure it:
8 | ```sh
9 | node ace add @jrmc/adonis-attachment
10 | ```
11 |
12 | Alternatively, you can install it manually using your favorite package manager and running the configure command:
13 | ::: code-group
14 |
15 | ```sh [npm]
16 | npm install @jrmc/adonis-attachment
17 | node ace configure @jrmc/adonis-attachment
18 | ```
19 | ```sh [pnpm]
20 | pnpm install @jrmc/adonis-attachment
21 | node ace configure @jrmc/adonis-attachment
22 | ```
23 | ```sh [yarn]
24 | yarn add @jrmc/adonis-attachment
25 | node ace configure @jrmc/adonis-attachment
26 | ```
27 | :::
28 |
29 |
30 | ## Additional install
31 |
32 |
33 |
34 | ---
35 |
36 |
37 |
38 | ---
39 |
40 |
41 |
42 | ---
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/docs/v2/guide/essentials/introduction.md:
--------------------------------------------------------------------------------
1 | # Introduction
2 |
3 | The `adonis-attachment` package was designed to simplify file upload management. It allows you to create alternative files, called `variants`, with options for optimization, resizing, and format changes. Additionally, it automatically generates image thumbnails for document and video files.
4 |
5 | The creation of variants is handled through Converters.
6 |
7 |
8 |
9 | Project sample : [adonis-starter-kit](https://github.com/batosai/adonis-starter-kit)
10 |
--------------------------------------------------------------------------------
/docs/v2/guide/partials/install-document.md:
--------------------------------------------------------------------------------
1 | Variants images for thumbnail Document are generates by [libreoffice-file-converter](https://www.npmjs.com/package/libreoffice-file-converter). Make sure you have [LibreOffice](https://fr.libreoffice.org/download/telecharger-libreoffice/) installed on your system.
2 |
3 | It is possible to specify the [path of binaries](/v2/guide/essentials/configuration.html#bin-optional). Useful if your installations are specific or if you are dropping off precompiled versions.
4 |
5 | Installation required:
6 |
7 | ::: code-group
8 | ```sh [npm]
9 | npm install libreoffice-file-converter
10 | ```
11 | ```sh [pnpm]
12 | pnpm install libreoffice-file-converter
13 | ```
14 | ```sh [yarn]
15 | yarn add libreoffice-file-converter
16 | ```
17 | :::
18 |
--------------------------------------------------------------------------------
/docs/v2/guide/partials/install-image.md:
--------------------------------------------------------------------------------
1 | Variants images are generates by [sharp module](https://sharp.pixelplumbing.com) and require installation:
2 |
3 | ::: code-group
4 | ```sh [npm]
5 | npm install sharp
6 | ```
7 | ```sh [pnpm]
8 | pnpm install sharp
9 | ```
10 | ```sh [yarn]
11 | yarn add sharp
12 | ```
13 | :::
14 |
--------------------------------------------------------------------------------
/docs/v2/guide/partials/install-pdf.md:
--------------------------------------------------------------------------------
1 | Variants images for thumbnail PDF are generates by [node-poppler](https://www.npmjs.com/package/node-poppler). Make sure you have [poppler](https://poppler.freedesktop.org)(pdftocairo) installed on your system.
2 |
3 | It is possible to specify the [path of binaries](/v2/guide/essentials/configuration.html#bin-optional). Useful if your installations are specific or if you are dropping off precompiled versions.
4 |
5 | Installation required:
6 |
7 | ::: code-group
8 | ```sh [npm]
9 | npm install node-poppler
10 | ```
11 | ```sh [pnpm]
12 | pnpm install node-poppler
13 | ```
14 | ```sh [yarn]
15 | yarn add node-poppler
16 | ```
17 | :::
18 |
--------------------------------------------------------------------------------
/docs/v2/guide/partials/install-video.md:
--------------------------------------------------------------------------------
1 | Variants images for thumbnail video are generates by [fluent-ffmpeg](https://www.npmjs.com/package/fluent-ffmpeg). Make sure you have [ffmpeg](https://ffmpeg.org) installed on your system (including all necessary encoding libraries like libmp3lame or libx264).
2 |
3 | It is possible to specify the [path of binaries](/v2/guide/essentials/configuration.html#bin-optional). Useful if your installations are specific or if you are dropping off precompiled versions.
4 |
5 | Installation required:
6 |
7 | ::: code-group
8 | ```sh [npm]
9 | npm install fluent-ffmpeg
10 | ```
11 | ```sh [pnpm]
12 | pnpm install fluent-ffmpeg
13 | ```
14 | ```sh [yarn]
15 | yarn add fluent-ffmpeg
16 | ```
17 | :::
18 |
--------------------------------------------------------------------------------
/docs/v2/guide/partials/table-converter.md:
--------------------------------------------------------------------------------
1 | |Converter |File type |Description|Required |
2 | | ------------- | :-----------: | -------- | -------- |
3 | |[image_converter](/v2/guide/converters/image)|JPEG, PNG, WebP, GIF and AVIF | Generate other image and change format/size etc...|[sharp](https://sharp.pixelplumbing.com/)|
4 | |[pdf_thumbnail_converter](/v2/guide/converters/pdf-thumbnail)|PDF |Generate thumbnail image of PDF|[poppler(pdftocairo)](https://www.npmjs.com/package/node-poppler)|
5 | |[document_thumbnail_converter](/v2/guide/converters/document-thumbnail)|PDF, ODT, ODS, DOCX, DOC, NUMBERS, PAGES, XLSX, XLS, CSV, RTF, TXT |Generate thumbnail image of document|[libreoffice-file-converter](https://www.npmjs.com/package/libreoffice-file-converter)|
6 | |[video_thumbnail_converter](/v2/guide/converters/video-thumbnail)|MP4, MOV, AVI, FLV, MKV |Generate thumbnail image of video|[fluent-ffmpeg](https://www.npmjs.com/package/fluent-ffmpeg)|
7 |
--------------------------------------------------------------------------------
/docs/v2/guide/start-here.md:
--------------------------------------------------------------------------------
1 | # Start Here
2 |
3 | Simple upload file
4 |
5 | ```ts
6 | // app/models/user.ts
7 | import { BaseModel } from '@adonisjs/lucid/orm'
8 | import { compose } from '@adonisjs/core/helpers'
9 | import { attachment, Attachmentable } from '@jrmc/adonis-attachment'
10 | import type { Attachment } from '@jrmc/adonis-attachment/types/attachment' // [!code highlight]
11 |
12 | class User extends compose(BaseModel, Attachmentable) { // [!code highlight]
13 | @attachment() // [!code highlight]
14 | declare avatar: Attachment // [!code highlight]
15 | }
16 | ```
17 |
18 | ---
19 |
20 | ```ts
21 | // app/controllers/users_controller.ts
22 | import { attachmentManager } from '@jrmc/adonis-attachment' // [!code focus]
23 |
24 | class UsersController {
25 | public store({ request }: HttpContext) {
26 | const avatar = request.file('avatar')! // [!code focus]
27 | const user = new User()
28 |
29 | user.avatar = await attachmentManager.createFromFile(avatar) // [!code focus]
30 | await user.save()
31 | }
32 | }
33 | ```
34 |
35 | ---
36 |
37 | ```edge
38 |
39 | ```
40 |
--------------------------------------------------------------------------------
/docs/v2/guide/use-cases/picture.md:
--------------------------------------------------------------------------------
1 | # Picture
2 |
3 | Using your variants with `` for create a component.
4 |
5 | ::: code-group
6 |
7 | ```js [edge]
8 | // picture.edge
9 |
10 |
11 |
12 |
13 |
14 |
15 | ```
16 | ```js [react]
17 | // picture.jsx
18 | import React from 'react';
19 |
20 | const Picture = ({ source, alt }) => {
21 | return (
22 |
23 |
24 |
25 |
26 |
27 |
28 | )
29 | }
30 | ```
31 | ```svelte [vue]
32 | // picture.vue
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
48 | ```
49 | ```svelte
50 | // picture.svelte
51 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | ```
63 | :::
64 |
65 | Use
66 |
67 | ::: code-group
68 |
69 | ```edge [edge]
70 | @!picture({
71 | source: article.image,
72 | alt: "Image alt"
73 | })
74 | ```
75 | ```js [react]
76 |
80 | ```
81 | ```svelte [vue]
82 |
86 | ```
87 | ```svelte
88 |
92 | ```
93 | :::
94 |
95 |
96 | Configuration
97 |
98 | ::: code-group
99 |
100 | ```ts [config/attachment.ts]
101 | import { defineConfig } from '@jrmc/adonis-attachment'
102 |
103 | export default defineConfig({
104 | preComputeUrl: true,
105 | converters: [
106 | {
107 | key: 'small',
108 | converter: () => import('@jrmc/adonis-attachment/converters/image_converter'),
109 | options: {
110 | resize: 480,
111 | }
112 | },
113 | {
114 | key: 'medium',
115 | converter: () => import('@jrmc/adonis-attachment/converters/image_converter'),
116 | options: {
117 | resize: 768,
118 | }
119 | },
120 | {
121 | key: 'large',
122 | converter: () => import('@jrmc/adonis-attachment/converters/image_converter'),
123 | options: {
124 | resize: 1200,
125 | }
126 | }
127 | ]
128 | })
129 | ```
130 | ```ts [app/models/article.ts]
131 | import { BaseModel } from '@adonisjs/lucid/orm'
132 | import { compose } from '@adonisjs/core/helpers'
133 | import { attachment, Attachmentable } from '@jrmc/adonis-attachment'
134 | import type { Attachment } from '@jrmc/adonis-attachment/types/attachment'
135 |
136 | class User extends compose(BaseModel, Attachmentable) {
137 | @attachment({
138 | variants: ['small', 'medium', 'large']
139 | })
140 | declare image: Attachment
141 | }
142 | ```
143 |
144 | :::
145 |
--------------------------------------------------------------------------------
/docs/v3/guide/advanced_usage/custom-converter.md:
--------------------------------------------------------------------------------
1 | # Custom converter
2 |
3 | ⚠️ [avalable in v2.4.0](/changelog#_2-4-0)
4 |
5 | ## Make converter
6 |
7 | The converts are stored within the ./app/converters directory. You may create a new converter by running the following command.
8 |
9 | ```sh
10 | node ace make:converter gif2webp
11 | ```
12 |
13 | - Form: `singular`
14 | - Suffix: `converter`
15 | - Class name example: `Gif2WebpConverter`
16 | - File name example: `gif_2_webp_converter.ts`
17 |
18 | ## Samples animate gif to webp
19 |
20 | ```ts
21 | import type { ConverterAttributes } from '@jrmc/adonis-attachment/types/converter'
22 | import type { Input } from '@jrmc/adonis-attachment/types/input'
23 |
24 | import Converter from '@jrmc/adonis-attachment/converters/converter'
25 | import sharp from 'sharp'
26 |
27 | export default class Gif2WebpConverter extends Converter {
28 | async handle({ input }: ConverterAttributes): Promise {
29 | const sharpImage = sharp(input, { animated: true, pages: -1 })
30 |
31 | const imageMeta = await sharpImage.metadata()
32 | const { loop, delay } = imageMeta
33 |
34 | const options = {
35 | webp: {
36 | loop,
37 | delay,
38 | }
39 | }
40 |
41 | const buffer = await sharpImage
42 | .withMetadata()
43 | .webp(options.webp)
44 | .toBuffer()
45 |
46 | return buffer
47 | }
48 | }
49 | ```
50 |
51 |
52 | ## Samples video to animate gif
53 |
54 | ```ts
55 | import type { ConverterAttributes } from '@jrmc/adonis-attachment/types/converter'
56 | import type { Input } from '@jrmc/adonis-attachment/types/input'
57 |
58 | import os from 'node:os'
59 | import path from 'node:path'
60 | import fs from 'fs/promises'
61 | import { cuid } from '@adonisjs/core/helpers'
62 | import Converter from '@jrmc/adonis-attachment/converters/converter'
63 | import ffmpeg from 'fluent-ffmpeg'
64 |
65 | export default class Video2GifConverter extends Converter {
66 | async handle({ input }: ConverterAttributes): Promise {
67 | return await this.videoToGif(ffmpeg, input)
68 | }
69 |
70 | async videoToGif(ffmpeg: Function, input: Input) {
71 | let file = input
72 |
73 | if (Buffer.isBuffer(input)) {
74 | file = await this.bufferToTempFile(input)
75 | }
76 |
77 | return new Promise((resolve, reject) => {
78 | const folder = os.tmpdir()
79 | const filename = `${cuid()}.png`
80 | const destination = path.join(folder, filename)
81 |
82 |
83 | const ff = ffmpeg(file)
84 |
85 | if (this.binPaths) {
86 | if (this.binPaths.ffmpegPath) {
87 | ff.setFfmpegPath(this.binPaths.ffmpegPath)
88 | }
89 | }
90 |
91 | ff
92 | .withOptions([
93 | '-ss 1',
94 | `-i ${file}`,
95 | `-filter_complex [0:v]trim=duration=3;`,
96 | '-f gif'
97 | ])
98 | .on('end', () => {
99 | resolve(destination)
100 | })
101 | .on('error', (err: Error) => {
102 | reject(err)
103 | })
104 | .output(destination)
105 | .run()
106 | })
107 | }
108 |
109 | async bufferToTempFile(input: Buffer) {
110 | const folder = os.tmpdir()
111 | const tempFilePath = path.join(folder, `tempfile-${Date.now()}.tmp`)
112 | await fs.writeFile(tempFilePath, input)
113 | return tempFilePath
114 | }
115 | }
116 | ```
117 |
--------------------------------------------------------------------------------
/docs/v3/guide/advanced_usage/exceptions.md:
--------------------------------------------------------------------------------
1 |
2 | # Exceptions
3 |
4 | |Code |Description | Origin |
5 | | -------------------------- | ----------------------------------------------- | ------- |
6 | | E_MISSING_PACKAGE | Missing package | |
7 | | E_CANNOT_CREATE_ATTACHMENT | Unable to create Attachment Object | |
8 | | E_CANNOT_CREATE_VARIANT | Unable to create variant | |
9 | | E_CANNOT_PATH_BY_CONVERTER | Missing path by converter | |
10 | | E_ISNOT_BUFFER | Is not a Buffer | |
11 | | E_ISNOT_BASE64 | Is not a Base64 | |
12 | | ENOENT | Unable to read file | |
13 | | E_CANNOT_WRITE_FILE | Unable to write file to the destination | Drive |
14 | | E_CANNOT_READ_FILE | Unable to read file | Drive |
15 | | E_CANNOT_DELETE_FILE | Unable to delete file | Drive |
16 | | E_CANNOT_SET_VISIBILITY | Unable to set file visibility | Drive |
17 | | E_CANNOT_GENERATE_URL | Unable to generate URL for a file | Drive |
18 | | E_UNALLOWED_CHARACTERS | The file key has unallowed set of characters | Drive |
19 | | E_INVALID_KEY | Key post normalization leads to an empty string | Drive |
20 |
21 | [Adonis documentation exception](https://docs.adonisjs.com/guides/basics/exception-handling)
22 |
23 | ## Handling exceptions
24 |
25 | If you want to handle a specific exception differently, you can do that inside the `handle` method. Make sure to use the `ctx.response.send` method to send a response, since the return value from the `handle` method is discarded.
26 |
27 | ::: code-group
28 |
29 | ```typescript [API]
30 | import { errors } from '@jrmc/adonis-attachment'
31 |
32 | export default class HttpExceptionHandler extends ExceptionHandler {
33 | async handle(error: unknown, ctx: HttpContext) {
34 | if (error instanceof errors.E_CANNOT_WRITE_FILE) {
35 | const err = error as errors.E_CANNOT_WRITE_FILE
36 | ctx.response.status(422).send(err.messages)
37 | return
38 | }
39 |
40 | return super.handle(error, ctx)
41 | }
42 | }
43 | ```
44 |
45 | ```typescript [web]
46 | import { errors } from '@jrmc/adonis-attachment'
47 |
48 | export default class HttpExceptionHandler extends ExceptionHandler {
49 | async handle(error: unknown, ctx: HttpContext) {
50 | if (error instanceof errors.E_CANNOT_WRITE_FILE) {
51 | ctx.session.flash('notification', {
52 | type: 'error',
53 | message: err.message,
54 | })
55 |
56 | return ctx.response.redirect('back')
57 | }
58 |
59 | return super.handle(error, ctx)
60 | }
61 | }
62 |
63 | ```
64 |
65 | :::
66 |
--------------------------------------------------------------------------------
/docs/v3/guide/advanced_usage/pre-compile-on-demand.md:
--------------------------------------------------------------------------------
1 | # Pre compute on demand
2 |
3 | We recommend not enabling the preComputeUrl option when you need the URL for just one or two queries and not within the rest of your application.
4 |
5 | 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.
6 |
7 | ```ts
8 | import type { Attachment } from '@jrmc/adonis-attachment/types/attachment'
9 | import { attachment, Attachmentable, attachmentManager } from '@jrmc/adonis-attachment'
10 |
11 | class User extends compose(BaseModel, Attachmentable) {
12 | static async preComputeUrls(models: User | User[]) {
13 | if (Array.isArray(models)) {
14 | await Promise.all(models.map((model) => this.preComputeUrls(model)))
15 | return
16 | }
17 |
18 | // compute url for original file
19 | await attachmentManager.computeUrl(models.avatar)
20 |
21 | // compute url for thumbnail variant
22 | const thumb = models.avatar.getVariant('thumbnail')
23 | await attachmentManager.computeUrl(thumb)
24 |
25 | // compute url for medium variant with expiration time option
26 | const medium = models.avatar.getVariant('medium')
27 | await attachmentManager.computeUrl(medium, {
28 | expiresIn: '30 mins',
29 | })
30 | }
31 |
32 | @attachment({
33 | variants: ['thumbnail', 'medium', 'large']
34 | })
35 | declare avatar: Attachment
36 | }
37 | ```
38 |
39 | computeUrl method create automatically creates a signed or unsigned url depending on Drive's configuration.
40 |
41 | it's possible to pass specific options to the signed url.
42 | options params accepts `expiresIn`, `contentType` et `contentDisposition`.
43 |
44 | [More informations](https://flydrive.dev/docs/disk_api#getsignedurl)
45 |
46 | ---
47 |
48 | And now use it as follows.
49 |
50 | ```ts
51 | const users = await User.all()
52 | await User.preComputeUrls(users)
53 |
54 | return users
55 | ```
56 |
57 | Or for a single user
58 |
59 | ```ts
60 | const user = await User.findOrFail(1)
61 | await User.preComputeUrls(user)
62 |
63 | return user
64 | ```
65 |
--------------------------------------------------------------------------------
/docs/v3/guide/advanced_usage/queue.md:
--------------------------------------------------------------------------------
1 | # Queue
2 |
3 | The media transforms are carried out int the queue, grouped by model attribut with the [@poppinss/defer](https://github.com/poppinss/defer) library.
4 |
5 | ## Events
6 |
7 | Create your preload file for events catch:
8 |
9 | ```sh
10 | node ace make:preload queue
11 | ```
12 |
13 | ```ts
14 | import { attachmentManager } from '@jrmc/adonis-attachment'
15 | import logger from '@adonisjs/core/services/logger'
16 |
17 | attachmentManager.queue.onError = function (error, task) {
18 | logger.info(`${task.name} task failed with the following error`)
19 | logger.error(error.message)
20 | }
21 |
22 | attachmentManager.queue.taskCompleted = function (task) {
23 | logger.info(`${task.name} completed. ${attachmentManager.queue.size()} tasks left`)
24 | }
25 |
26 | attachmentManager.queue.drained = function () {
27 | logger.info('Processed last task in the queue')
28 | }
29 |
30 | ```
31 |
32 |
--------------------------------------------------------------------------------
/docs/v3/guide/basic_usage/controller-setup.md:
--------------------------------------------------------------------------------
1 | # Controller setup
2 |
3 | ## From file
4 |
5 | Now you can create an attachment from the user uploaded file as follows.
6 |
7 | ```ts
8 | import { attachmentManager } from '@jrmc/adonis-attachment' // [!code focus]
9 |
10 | class UsersController {
11 | public store({ request }: HttpContext) {
12 | const avatar = request.file('avatar')! // [!code focus]
13 | const user = new User()
14 |
15 | user.avatar = await attachmentManager.createFromFile(avatar) // [!code focus]
16 | await user.save()
17 | }
18 | }
19 | ```
20 |
21 | ## From Buffer
22 |
23 | ```ts
24 | import { attachmentManager } from '@jrmc/adonis-attachment' // [!code focus]
25 | import app from '@adonisjs/core/services/app'
26 |
27 | class UsersController {
28 | public store({ request }: HttpContext) {
29 | const user = new User()
30 |
31 | const buffer = await readFile(app.makePath('me.jpg')) // [!code focus]
32 |
33 | user.avatar = await attachmentManager.createFromBuffer(buffer, 'photo.jpg') // [!code focus]
34 | await user.save()
35 | }
36 | }
37 | ```
38 |
39 | ## From Base64
40 |
41 | ⚠️ [avalable in v2.3.0](/changelog#_2-3-0)
42 |
43 | ```ts
44 | import { attachmentManager } from '@jrmc/adonis-attachment' // [!code focus]
45 |
46 | class UsersController {
47 | public store({ request }: HttpContext) {
48 | const user = new User()
49 |
50 | const b64 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=" // [!code focus]
51 |
52 | user.avatar = await attachmentManager.createFromBase64(b64, 'photo.jpg') // [!code focus]
53 | await user.save()
54 | }
55 | }
56 | ```
57 |
58 | ## From Path
59 |
60 | ⚠️ [avalable in v3.1.0](/changelog#_3-1-0)
61 |
62 | ```ts
63 | import { attachmentManager } from '@jrmc/adonis-attachment' // [!code focus]
64 | import app from '@adonisjs/core/services/app'
65 |
66 | class UsersController {
67 | public store({ request }: HttpContext) {
68 | const user = new User()
69 |
70 | const path = app.makePath('me.jpg') // [!code focus]
71 |
72 | user.avatar = await attachmentManager.createFromPath(path, 'photo.jpg') // [!code focus]
73 | await user.save()
74 | }
75 | }
76 | ```
77 |
78 | ## From Url
79 |
80 | ⚠️ [avalable in v3.1.0](/changelog#_3-1-0)
81 |
82 | ```ts
83 | import { attachmentManager } from '@jrmc/adonis-attachment' // [!code focus]
84 |
85 | class UsersController {
86 | public store({ request }: HttpContext) {
87 | const user = new User()
88 |
89 | const url = new URL('https://site.com/picture.jpg') // [!code focus]
90 |
91 | user.avatar = await attachmentManager.createFromUrl(url, 'photo.jpg') // [!code focus]
92 | await user.save()
93 | }
94 | }
95 | ```
96 |
97 | ## From Stream
98 |
99 | ⚠️ [avalable in v3.1.0](/changelog#_3-1-0)
100 |
101 | ```ts
102 | import { attachmentManager } from '@jrmc/adonis-attachment' // [!code focus]
103 | import fs from 'node:fs'
104 |
105 | class UsersController {
106 | public store({ request }: HttpContext) {
107 | const user = new User()
108 |
109 | const videoStream = fs.createReadStream(app.makePath('path/video.mkv')) // [!code focus]
110 |
111 | user.avatar = await attachmentManager.createFromStream(videoStream, 'name.mkv') // [!code focus]
112 | await user.save()
113 | }
114 | }
115 | ```
116 |
117 | ## Delete Attachment
118 |
119 |
120 | ::: code-group
121 | ```ts [model]
122 | class User extends compose(BaseModel, Attachmentable) {
123 | @attachment()
124 | declare avatar: Attachment | null
125 | }
126 | ```
127 |
128 | ```ts [controller]
129 | user.avatar = null
130 | await user.save()
131 | ```
132 |
133 | :::
134 |
--------------------------------------------------------------------------------
/docs/v3/guide/basic_usage/migration-setup.md:
--------------------------------------------------------------------------------
1 | # Migration setup
2 |
3 | Often times, the size of the image metadata could exceed the allowable length of an SQL `String` data type. So, it is recommended to create/modify the column which will hold the metadata to use a `JSON` data type.
4 |
5 | If you are creating the column for the first time, make sure that you use the JSON data type. Example:
6 |
7 | ```ts
8 | // Within the migration file
9 |
10 | protected tableName = 'users'
11 |
12 | public async up() {
13 | this.schema.createTable(this.tableName, (table) => {
14 | table.increments()
15 | table.json('avatar') // <-- Use a JSON data type
16 | })
17 | }
18 | ```
19 |
20 | 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:
21 |
22 | ```bash
23 | # Create a new migration file
24 | node ace make:migration change_avatar_column_to_json --table=users
25 | ```
26 |
27 | ```ts
28 | // Within the migration file
29 |
30 | protected tableName = 'users'
31 |
32 | public async up() {
33 | this.schema.alterTable(this.tableName, (table) => {
34 | table.json('avatar').alter() // <-- Alter the column definition
35 | })
36 | }
37 | ```
38 |
--------------------------------------------------------------------------------
/docs/v3/guide/basic_usage/model-setup.md:
--------------------------------------------------------------------------------
1 | # Model setup
2 |
3 | Next, in the model, import the `attachment` decorator, `Attachmentable` mixin and the `Attachment` type from the package.
4 |
5 | ⚠️ [The "Attachmentable" mixin is deprecated in 3.2.0](/changelog#_3-2-0)
6 |
7 | > Make sure NOT to use the `@column` decorator when using the `@attachment` decorator.
8 |
9 | ```ts
10 | import { BaseModel } from '@adonisjs/lucid/orm'
11 | import { compose } from '@adonisjs/core/helpers'
12 | import { attachment, Attachmentable } from '@jrmc/adonis-attachment'
13 | import type { Attachment } from '@jrmc/adonis-attachment/types/attachment' // [!code highlight]
14 |
15 | class User extends compose(BaseModel, Attachmentable) { // [!code highlight]
16 | @attachment() // [!code highlight]
17 | declare avatar: Attachment // [!code highlight]
18 | }
19 | ```
20 |
21 | ## Specifying subfolder
22 |
23 | You can also store files inside the subfolder by defining the `folder` property as follows.
24 |
25 | ```ts
26 | class User extends BaseModel {
27 | @attachment({ folder: 'uploads/avatars' }) // [!code highlight]
28 | declare avatar: Attachment
29 | }
30 | ```
31 |
32 | ## Specifying variants
33 |
34 | Generate variants
35 |
36 | ```ts
37 | class User extends BaseModel {
38 | @attachment({
39 | variants: ['thumbnail', 'medium', 'large'] // [!code highlight]
40 | })
41 | declare avatar: Attachment
42 | }
43 | ```
44 |
45 | ## Specifying disk
46 |
47 | You can specify type of disk to use, default is defined in default adonis/drive config
48 |
49 | ```ts
50 | class User extends BaseModel {
51 | @attachment({ disk: 's3' }) // [!code highlight]
52 | declare avatar: Attachment
53 | }
54 | ```
55 |
56 | ## Specifying preComputeUrl
57 |
58 | ⚠️ [avalable in v2.2.0](/changelog#_2-2-0)
59 |
60 | You can enabled pre compute the URLs after SELECT queries, default is false
61 |
62 | ```ts
63 | class User extends BaseModel {
64 | @attachment({ preComputeUrl: true }) // [!code highlight]
65 | declare avatar: Attachment
66 | }
67 | ```
68 |
69 |
70 | ## Specifying meta
71 |
72 | ⚠️ [avalable in v2.1.0](/changelog#_2-1-0)
73 |
74 | You can disabled meta generation, default is true
75 |
76 | ```ts
77 | class User extends BaseModel {
78 | @attachment({ meta: false }) // [!code highlight]
79 | declare avatar: Attachment
80 | }
81 | ```
82 |
83 | ## Specifying rename
84 |
85 | ⚠️ [avalable in v2.1.0](/changelog#_2-1-0)
86 |
87 | You can disabled rename file, default is true
88 |
89 | ```ts
90 | class User extends BaseModel {
91 | @attachment({ rename: false }) // [!code highlight]
92 | declare avatar: Attachment
93 | }
94 | ```
95 |
96 |
97 |
--------------------------------------------------------------------------------
/docs/v3/guide/basic_usage/view-setup.md:
--------------------------------------------------------------------------------
1 | # View setup
2 |
3 | Now all you have to do is display your images in your view.
4 |
5 | ## URLs for edge template
6 |
7 | ```ts
8 | await user.avatar.getUrl()
9 | await user.avatar.getUrl('thumbnail')
10 | // or await user.avatar.getVariant('thumbnail').getUrl()
11 |
12 | await user.avatar.getSignedUrl()
13 | await user.avatar.getSignedUrl('thumbnail')
14 | // or await user.avatar.getVariant('thumbnail').getSignedUrl()
15 | ```
16 |
17 | ```edge
18 |
19 |
20 |
21 |
24 |
25 |
26 |
29 | ```
30 |
31 | getSignedUrl options params accepts `expiresIn`, `contentType` et `contentDisposition`. [More informations](https://flydrive.dev/docs/disk_api#getsignedurl)
32 |
33 | ### If preComputeUrl is enabled
34 |
35 | ```edge
36 |
37 |
38 | ```
39 |
40 |
41 | ## URLs for Inertia template
42 |
43 | ::: code-group
44 | ```js [react]
45 |
46 | ```
47 |
48 | ```vue
49 |
50 | ```
51 |
52 | ```svelte
53 |
54 | ```
55 | :::
56 |
57 | preComputeUrl is required.
58 |
--------------------------------------------------------------------------------
/docs/v3/guide/converters/document-thumbnail.md:
--------------------------------------------------------------------------------
1 |
2 | # Document thumbnail converter
3 |
4 | ⚠️ [avalable in v2.3.0](/changelog#_2-3-0)
5 |
6 |
7 |
8 | ## Configuration
9 |
10 | ```typescript
11 | // config/attachment.ts // [!code focus:1]
12 | const attachmentConfig = defineConfig({
13 | converters: {
14 | preview: { // [!code focus:3]
15 | converter: () => import('@jrmc/adonis-attachment/converters/document_thumbnail_converter'),
16 | }
17 | }
18 | })
19 | ```
20 |
21 | By default, image format is `JPEG` and size is video size. `options` attribute use ***[image_converter](/guide/converters/image)***
22 |
23 | Sample:
24 |
25 | ```typescript{6-9}
26 | const attachmentConfig = defineConfig({
27 | converters: {
28 | preview: { // [!code focus:7]
29 | converter: () => import('@jrmc/adonis-attachment/converters/document_thumbnail_converter'),
30 | options: {
31 | format: 'webp',
32 | resize: 720
33 | }
34 | }
35 | }
36 | })
37 | ```
38 |
--------------------------------------------------------------------------------
/docs/v3/guide/converters/image.md:
--------------------------------------------------------------------------------
1 | # Image converter
2 |
3 |
4 |
5 | ## Configuration
6 |
7 | ```typescript
8 | // config/attachment.ts // [!code focus:1]
9 | const attachmentConfig = defineConfig({
10 | converters: {
11 | large: { // [!code focus:6]
12 | converter: () => import('@jrmc/adonis-attachment/converters/image_converter'),
13 | options: {
14 | resize: 1280,
15 | }
16 | }
17 | }
18 | })
19 | ```
20 |
21 | ## Format
22 |
23 | The default format is `webp`, for change, use options format:
24 |
25 | ```typescript
26 | const attachmentConfig = defineConfig({
27 | converters: {
28 | thumbnail: { // [!code focus:7]
29 | converter: () => import('@jrmc/adonis-attachment/converters/image_converter'),
30 | options: {
31 | resize: 300,
32 | format: 'jpeg', // [!code highlight]
33 | }
34 | }
35 | }
36 | })
37 | ```
38 |
39 | Options format is `string` or `object` [ format, options ] details in documentation : [sharp api outpout](https://sharp.pixelplumbing.com/api-output#toformat)
40 |
41 |
42 | Sample for personalize image quality:
43 |
44 | ```typescript{8-13}
45 | const attachmentConfig = defineConfig({
46 | converters: {
47 | thumbnail: { // [!code focus:12]
48 | converter: () => import('@jrmc/adonis-attachment/converters/image_converter'),
49 | options: {
50 | resize: 300,
51 | format: {
52 | format: 'jpeg',
53 | options: {
54 | quality: 80
55 | }
56 | }
57 | }
58 | }
59 | }
60 | })
61 | ```
62 |
63 | ## ReSize
64 |
65 | Options resize is `number` or `object`(options) details in documentation : [sharp api resize](https://sharp.pixelplumbing.com/api-resize)
66 |
67 | Sample:
68 |
69 | ```typescript{11-16}
70 | import { defineConfig } from '@jrmc/adonis-attachment'
71 | import { InferConverters } from '@jrmc/adonis-attachment/types/config'
72 | import sharp from 'sharp'
73 |
74 | const attachmentConfig = defineConfig({
75 | converters: {
76 | thumbnail: {
77 | converter: () => import('@jrmc/adonis-attachment/converters/image_converter'),
78 | options: {
79 | format: 'jpeg',
80 | resize: { // https://sharp.pixelplumbing.com/api-resize
81 | width: 400,
82 | height: 400,
83 | fit: sharp.fit.cover,
84 | position: 'top'
85 | },
86 | }
87 | }
88 | }
89 | })
90 |
91 | export default attachmentConfig
92 |
93 | declare module '@jrmc/adonis-attachment' {
94 | interface AttachmentVariants extends InferConverters {}
95 | }
96 | ```
97 |
98 |
99 |
100 |
101 |
--------------------------------------------------------------------------------
/docs/v3/guide/converters/pdf-thumbnail.md:
--------------------------------------------------------------------------------
1 |
2 | # PDF thumbnail converter
3 |
4 | ⚠️ [avalable in v2.3.0](/changelog#_2-3-0)
5 |
6 |
7 |
8 | ## Configuration
9 |
10 | ```typescript
11 | // config/attachment.ts // [!code focus:1]
12 | const attachmentConfig = defineConfig({
13 | converters: {
14 | preview: { // [!code focus:3]
15 | converter: () => import('@jrmc/adonis-attachment/converters/pdf_thumbnail_converter'),
16 | }
17 | }
18 | })
19 | ```
20 |
21 | By default, image format is `JPEG` and size is video size. `options` attribute use ***[image_converter](/guide/converters/image)***
22 |
23 | Sample:
24 |
25 | ```typescript{6-9}
26 | const attachmentConfig = defineConfig({
27 | converters: {
28 | preview: { // [!code focus:7]
29 | converter: () => import('@jrmc/adonis-attachment/converters/pdf_thumbnail_converter'),
30 | options: {
31 | format: 'webp',
32 | resize: 720
33 | }
34 | }
35 | }
36 | })
37 | ```
38 |
--------------------------------------------------------------------------------
/docs/v3/guide/converters/video-thumbnail.md:
--------------------------------------------------------------------------------
1 |
2 | # Video thumbnail converter
3 |
4 |
5 |
6 |
7 | ## Configuration
8 |
9 | ```typescript
10 | // config/attachment.ts // [!code focus:1]
11 | const attachmentConfig = defineConfig({
12 | converters: {
13 | preview: { // [!code focus:3]
14 | converter: () => import('@jrmc/adonis-attachment/converters/video_thumbnail_converter'),
15 | }
16 | }
17 | })
18 | ```
19 |
20 | By default, image format is `PNG` and size is video size. `options` attribute use ***[image_converter](/guide/converters/image)***
21 |
22 | Sample:
23 |
24 | ```typescript{6-9}
25 | const attachmentConfig = defineConfig({
26 | converters: {
27 | preview: { // [!code focus:7]
28 | converter: () => import('@jrmc/adonis-attachment/converters/video_thumbnail_converter'),
29 | options: {
30 | format: 'jpeg',
31 | resize: 720
32 | }
33 | }
34 | }
35 | })
36 | ```
37 |
--------------------------------------------------------------------------------
/docs/v3/guide/essentials/installation.md:
--------------------------------------------------------------------------------
1 | # Installation
2 |
3 | The Adonis Attachment package is available on [npm](https://www.npmjs.com/package/@jrmc/adonis-attachment).
4 |
5 | It's required [Adonis Drive](https://docs.adonisjs.com/guides/digging-deeper/drive), please look at the documentation if this is not installed.
6 |
7 | You can install it using the following ace command to automagically configure it:
8 | ```sh
9 | node ace add @jrmc/adonis-attachment
10 | ```
11 |
12 | Alternatively, you can install it manually using your favorite package manager and running the configure command:
13 | ::: code-group
14 |
15 | ```sh [npm]
16 | npm install @jrmc/adonis-attachment
17 | node ace configure @jrmc/adonis-attachment
18 | ```
19 | ```sh [pnpm]
20 | pnpm install @jrmc/adonis-attachment
21 | node ace configure @jrmc/adonis-attachment
22 | ```
23 | ```sh [yarn]
24 | yarn add @jrmc/adonis-attachment
25 | node ace configure @jrmc/adonis-attachment
26 | ```
27 | :::
28 |
29 |
30 | ## Additional install
31 |
32 |
33 |
34 | ---
35 |
36 |
37 |
38 | ---
39 |
40 |
41 |
42 | ---
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/docs/v3/guide/essentials/introduction.md:
--------------------------------------------------------------------------------
1 | # Introduction
2 |
3 | The `adonis-attachment` package was designed to simplify file upload management. It allows you to create alternative files, called `variants`, with options for optimization, resizing, and format changes. Additionally, it automatically generates image thumbnails for document and video files.
4 |
5 | The creation of variants is handled through Converters.
6 |
7 |
8 |
9 | Project sample : [adonis-starter-kit](https://github.com/batosai/adonis-starter-kit)
10 |
--------------------------------------------------------------------------------
/docs/v3/guide/partials/install-document.md:
--------------------------------------------------------------------------------
1 | Variants images for thumbnail Document are generates by [libreoffice-file-converter](https://www.npmjs.com/package/libreoffice-file-converter). Make sure you have [LibreOffice](https://fr.libreoffice.org/download/telecharger-libreoffice/) installed on your system.
2 |
3 | It is possible to specify the [path of binaries](/guide/essentials/configuration.html#bin-optional). Useful if your installations are specific or if you are dropping off precompiled versions.
4 |
5 | Installation required:
6 |
7 | ::: code-group
8 | ```sh [npm]
9 | npm install libreoffice-file-converter
10 | ```
11 | ```sh [pnpm]
12 | pnpm install libreoffice-file-converter
13 | ```
14 | ```sh [yarn]
15 | yarn add libreoffice-file-converter
16 | ```
17 | :::
18 |
--------------------------------------------------------------------------------
/docs/v3/guide/partials/install-image.md:
--------------------------------------------------------------------------------
1 | Variants images are generates by [sharp module](https://sharp.pixelplumbing.com) and require installation:
2 |
3 | ::: code-group
4 | ```sh [npm]
5 | npm install sharp
6 | ```
7 | ```sh [pnpm]
8 | pnpm install sharp
9 | ```
10 | ```sh [yarn]
11 | yarn add sharp
12 | ```
13 | :::
14 |
--------------------------------------------------------------------------------
/docs/v3/guide/partials/install-pdf.md:
--------------------------------------------------------------------------------
1 | Variants images for thumbnail PDF are generates by [node-poppler](https://www.npmjs.com/package/node-poppler). Make sure you have [poppler](https://poppler.freedesktop.org)(pdftocairo) installed on your system.
2 |
3 | It is possible to specify the [path of binaries](/guide/essentials/configuration.html#bin-optional). Useful if your installations are specific or if you are dropping off precompiled versions.
4 |
5 | Installation required:
6 |
7 | ::: code-group
8 | ```sh [npm]
9 | npm install node-poppler
10 | ```
11 | ```sh [pnpm]
12 | pnpm install node-poppler
13 | ```
14 | ```sh [yarn]
15 | yarn add node-poppler
16 | ```
17 | :::
18 |
--------------------------------------------------------------------------------
/docs/v3/guide/partials/install-video.md:
--------------------------------------------------------------------------------
1 | Variants images for thumbnail video are generates by [fluent-ffmpeg](https://www.npmjs.com/package/fluent-ffmpeg). Make sure you have [ffmpeg](https://ffmpeg.org) installed on your system (including all necessary encoding libraries like libmp3lame or libx264).
2 |
3 | It is possible to specify the [path of binaries](/guide/essentials/configuration.html#bin-optional). Useful if your installations are specific or if you are dropping off precompiled versions.
4 |
5 | Installation required:
6 |
7 | ::: code-group
8 | ```sh [npm]
9 | npm install fluent-ffmpeg
10 | ```
11 | ```sh [pnpm]
12 | pnpm install fluent-ffmpeg
13 | ```
14 | ```sh [yarn]
15 | yarn add fluent-ffmpeg
16 | ```
17 | :::
18 |
--------------------------------------------------------------------------------
/docs/v3/guide/partials/table-converter.md:
--------------------------------------------------------------------------------
1 | |Converter |File type |Description|Required |
2 | | ------------- | :-----------: | -------- | -------- |
3 | |[image_converter](/guide/converters/image)|JPEG, PNG, WebP, GIF and AVIF | Generate other image and change format/size etc...|[sharp](https://sharp.pixelplumbing.com/)|
4 | |[pdf_thumbnail_converter](/guide/converters/pdf-thumbnail)|PDF |Generate thumbnail image of PDF|[poppler(pdftocairo)](https://www.npmjs.com/package/node-poppler)|
5 | |[document_thumbnail_converter](/guide/converters/document-thumbnail)|PDF, ODT, ODS, DOCX, DOC, NUMBERS, PAGES, XLSX, XLS, CSV, RTF, TXT |Generate thumbnail image of document|[libreoffice-file-converter](https://www.npmjs.com/package/libreoffice-file-converter)|
6 | |[video_thumbnail_converter](/guide/converters/video-thumbnail)|MP4, MOV, AVI, FLV, MKV |Generate thumbnail image of video|[fluent-ffmpeg](https://www.npmjs.com/package/fluent-ffmpeg)|
7 |
--------------------------------------------------------------------------------
/docs/v3/guide/start-here.md:
--------------------------------------------------------------------------------
1 | # Start Here
2 |
3 | Simple upload file
4 |
5 | ```ts
6 | // app/models/user.ts
7 | import { BaseModel } from '@adonisjs/lucid/orm'
8 | import { compose } from '@adonisjs/core/helpers'
9 | import { attachment, Attachmentable } from '@jrmc/adonis-attachment'
10 | import type { Attachment } from '@jrmc/adonis-attachment/types/attachment' // [!code highlight]
11 |
12 | class User extends compose(BaseModel, Attachmentable) { // [!code highlight]
13 | @attachment() // [!code highlight]
14 | declare avatar: Attachment // [!code highlight]
15 | }
16 | ```
17 |
18 | ---
19 |
20 | ```ts
21 | // app/controllers/users_controller.ts
22 | import { attachmentManager } from '@jrmc/adonis-attachment' // [!code focus]
23 |
24 | class UsersController {
25 | public store({ request }: HttpContext) {
26 | const avatar = request.file('avatar')! // [!code focus]
27 | const user = new User()
28 |
29 | user.avatar = await attachmentManager.createFromFile(avatar) // [!code focus]
30 | await user.save()
31 | }
32 | }
33 | ```
34 |
35 | ---
36 |
37 | ```edge
38 |
39 | ```
40 |
--------------------------------------------------------------------------------
/docs/v3/guide/use-cases/picture.md:
--------------------------------------------------------------------------------
1 | # Picture
2 |
3 | Using your variants with `` for create a component.
4 |
5 | ::: code-group
6 |
7 | ```js [edge]
8 | // picture.edge
9 |
10 |
11 |
12 |
13 |
14 |
15 | ```
16 | ```js [react]
17 | // picture.jsx
18 | import React from 'react';
19 |
20 | const Picture = ({ source, alt }) => {
21 | return (
22 |
23 |
24 |
25 |
26 |
27 |
28 | )
29 | }
30 | ```
31 | ```svelte [vue]
32 | // picture.vue
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
48 | ```
49 | ```svelte
50 | // picture.svelte
51 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | ```
63 | :::
64 |
65 | Use
66 |
67 | ::: code-group
68 |
69 | ```edge [edge]
70 | @!picture({
71 | source: article.image,
72 | alt: "Image alt"
73 | })
74 | ```
75 | ```js [react]
76 |
80 | ```
81 | ```svelte [vue]
82 |
86 | ```
87 | ```svelte
88 |
92 | ```
93 | :::
94 |
95 |
96 | Configuration
97 |
98 | ::: code-group
99 |
100 | ```ts [config/attachment.ts]
101 | import { defineConfig } from '@jrmc/adonis-attachment'
102 |
103 | export default defineConfig({
104 | preComputeUrl: true,
105 | converters: [
106 | {
107 | key: 'small',
108 | converter: () => import('@jrmc/adonis-attachment/converters/image_converter'),
109 | options: {
110 | resize: 480,
111 | }
112 | },
113 | {
114 | key: 'medium',
115 | converter: () => import('@jrmc/adonis-attachment/converters/image_converter'),
116 | options: {
117 | resize: 768,
118 | }
119 | },
120 | {
121 | key: 'large',
122 | converter: () => import('@jrmc/adonis-attachment/converters/image_converter'),
123 | options: {
124 | resize: 1200,
125 | }
126 | }
127 | ]
128 | })
129 | ```
130 | ```ts [app/models/article.ts]
131 | import { BaseModel } from '@adonisjs/lucid/orm'
132 | import { compose } from '@adonisjs/core/helpers'
133 | import { attachment, Attachmentable } from '@jrmc/adonis-attachment'
134 | import type { Attachment } from '@jrmc/adonis-attachment/types/attachment'
135 |
136 | class User extends compose(BaseModel, Attachmentable) {
137 | @attachment({
138 | variants: ['small', 'medium', 'large']
139 | })
140 | declare image: Attachment
141 | }
142 | ```
143 |
144 | :::
145 |
--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------
1 | import attachmentManager from './services/main.js'
2 | import RegenerateService from './services/regenerate_service.js'
3 |
4 | export { configure } from './configure.js'
5 | export { Attachment } from './src/attachments/attachment.js'
6 | export { attachment } from './src/decorators/attachment.js'
7 | export { attachments } from './src/decorators/attachment.js'
8 | export { defineConfig } from './src/define_config.js'
9 | export * as errors from './src/errors.js'
10 | export { attachmentManager }
11 | export { RegenerateService }
12 | export { type AttachmentVariants } from './src/types/config.js'
13 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@jrmc/adonis-attachment",
3 | "version": "4.0.1",
4 | "type": "module",
5 | "description": "Turn any field on your Lucid model to an attachment data type",
6 | "engines": {
7 | "node": ">=20.12.0"
8 | },
9 | "main": "build/index.js",
10 | "repository": {
11 | "type": "git",
12 | "url": "https://github.com/batosai/adonis-attachment.git"
13 | },
14 | "homepage": "https://adonis-attachment.jrmc.dev",
15 | "author": "Jeremy Chaufourier jeremy@chaufourier.fr",
16 | "license": "MIT",
17 | "keywords": [
18 | "adonisjs",
19 | "lucid",
20 | "attachment",
21 | "attachment-advanced",
22 | "image-attachment",
23 | "image-manipulation",
24 | "responsive-images",
25 | "upload",
26 | "resize-images",
27 | "optimize-images",
28 | "image",
29 | "pdf",
30 | "video",
31 | "document",
32 | "word",
33 | "sharp"
34 | ],
35 | "files": [
36 | "build"
37 | ],
38 | "scripts": {
39 | "typecheck": "tsc --noEmit",
40 | "build": "npm run clean && tsc",
41 | "postbuild": "npm run copyfiles && npm run index:commands",
42 | "prepublishOnly": "npm run build",
43 | "clean": "del-cli build",
44 | "copyfiles": "copyfiles \"stubs/**/**/*.stub\" build",
45 | "index:commands": "adonis-kit index build/commands",
46 | "format": "prettier --write .",
47 | "docs:dev": "vitepress dev docs",
48 | "docs:build": "vitepress build docs",
49 | "docs:preview": "vitepress preview docs",
50 | "test": "c8 npm run quick:test",
51 | "quick:test": "node --import=./tsnode.esm.js --enable-source-maps bin/test.ts"
52 | },
53 | "exports": {
54 | ".": "./build/index.js",
55 | "./types/*": "./build/src/types/*.js",
56 | "./mixins": "./build/src/mixins/attachmentable.js",
57 | "./decorators": "./build/src/decorators/attachment.js",
58 | "./services/*": "./build/services/*.js",
59 | "./converters/*": "./build/src/converters/*.js",
60 | "./providers/*": "./build/providers/*.js",
61 | "./commands": "./build/commands/main.js",
62 | "./commands/*": "./build/commands/*.js",
63 | "./attachment_service": "./build/services/attachment_service.js",
64 | "./attachment_provider": "./build/providers/attachment_provider.js"
65 | },
66 | "dependencies": {
67 | "@poppinss/defer": "^1.1.1",
68 | "blurhash": "^2.0.5",
69 | "exifreader": "^4.26.0",
70 | "file-type": "^19.6.0",
71 | "mime-types": "^2.1.35"
72 | },
73 | "devDependencies": {
74 | "@adonisjs/assembler": "^7.8.2",
75 | "@adonisjs/core": "^6.17.1",
76 | "@adonisjs/drive": "^3.2.0",
77 | "@adonisjs/lucid": "^21.6.0",
78 | "@adonisjs/prettier-config": "^1.4.0",
79 | "@adonisjs/tsconfig": "^1.4.0",
80 | "@japa/assert": "^4.0.1",
81 | "@japa/expect-type": "^2.0.3",
82 | "@japa/file-system": "^2.3.2",
83 | "@japa/plugin-adonisjs": "^4.0.0",
84 | "@japa/runner": "^4.1.0",
85 | "@poppinss/utils": "^6.9.2",
86 | "@swc/core": "^1.10.7",
87 | "@types/luxon": "^3.4.2",
88 | "@types/mime-types": "^2.1.4",
89 | "@types/node": "^22.10.7",
90 | "@types/sinon": "^17.0.3",
91 | "better-sqlite3": "^11.8.0",
92 | "c8": "^10.1.3",
93 | "copyfiles": "^2.4.1",
94 | "del-cli": "^6.0.0",
95 | "flydrive": "^1.1.0",
96 | "luxon": "^3.5.0",
97 | "prettier": "^3.4.2",
98 | "sharp": "^0.33.5",
99 | "sinon": "^19.0.2",
100 | "ts-node": "^10.9.2",
101 | "typescript": "^5.7.3",
102 | "vitepress": "^1.5.0"
103 | },
104 | "peerDependencies": {
105 | "@adonisjs/core": "^6.12.1",
106 | "@adonisjs/drive": "^3.2.0",
107 | "@adonisjs/lucid": "^20.6.0 || ^21.0.0"
108 | },
109 | "prettier": "@adonisjs/prettier-config",
110 | "c8": {
111 | "reporter": [
112 | "text",
113 | "html"
114 | ],
115 | "exclude": [
116 | "tests/**"
117 | ]
118 | },
119 | "publishConfig": {
120 | "access": "public",
121 | "tag": "latest"
122 | },
123 | "volta": {
124 | "node": "20.17.0"
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/providers/attachment_provider.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import type { ApplicationService } from '@adonisjs/core/types'
9 |
10 | import { configProvider } from '@adonisjs/core'
11 | import { RuntimeException } from '@poppinss/utils'
12 | import type { AttachmentService } from '../src/types/config.js'
13 |
14 | declare module '@adonisjs/core/types' {
15 | export interface ContainerBindings {
16 | 'jrmc.attachment': AttachmentService
17 | }
18 | }
19 |
20 | export default class AttachmentProvider {
21 | #manager: AttachmentService | null = null
22 |
23 | constructor(protected app: ApplicationService) {}
24 |
25 | register() {
26 | this.app.container.singleton('jrmc.attachment', async () => {
27 | const { AttachmentManager } = await import('../src/attachment_manager.js')
28 |
29 | const attachmentConfig = this.app.config.get('attachment')
30 | const config = await configProvider.resolve(this.app, attachmentConfig)
31 | const drive = await this.app.container.make('drive.manager')
32 |
33 | if (!config) {
34 | throw new RuntimeException(
35 | 'Invalid config exported from "config/attachment.ts" file. Make sure to use the defineConfig method'
36 | )
37 | }
38 |
39 | this.#manager = new AttachmentManager(config, drive)
40 |
41 | return this.#manager
42 | })
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/services/main.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import app from '@adonisjs/core/services/app'
9 | import { AttachmentService } from '../src/types/config.js'
10 |
11 | let manager: AttachmentService
12 |
13 | await app.booted(async () => {
14 | manager = await app.container.make('jrmc.attachment')
15 | })
16 |
17 | export { manager as default }
18 |
--------------------------------------------------------------------------------
/services/regenerate_service.ts:
--------------------------------------------------------------------------------
1 | import type { LucidRow, LucidModel } from '@adonisjs/lucid/types/model'
2 | import type { RowWithAttachment } from '../src/types/mixin.js'
3 | import type { RegenerateOptions } from '../src/types/regenerate.js'
4 |
5 | import RecordWithAttachment from '../src/services/record_with_attachment.js'
6 |
7 | export default class RegenerateService {
8 | #Model?: LucidModel
9 | #row?: RowWithAttachment
10 | #options?: RegenerateOptions
11 |
12 | model(Model: LucidModel, options: RegenerateOptions = {}) {
13 | this.#Model = Model
14 | this.#options = options
15 |
16 | return this
17 | }
18 |
19 | row(row: LucidRow, options: RegenerateOptions = {}) {
20 | this.#row = row as RowWithAttachment
21 | this.#options = options
22 |
23 | return this
24 | }
25 |
26 | async run() {
27 | if (this.#row) {
28 | const record = new RecordWithAttachment(this.#row)
29 | return record.regenerateVariants(this.#options)
30 | }
31 | else if (this.#Model) {
32 | const entities = await this.#Model.all() as RowWithAttachment[]
33 |
34 | return Promise.all(
35 | entities.map(async (entity) => {
36 | const record = new RecordWithAttachment(entity)
37 | return record.regenerateVariants(this.#options)
38 | })
39 | )
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/adapters/blurhash.ts:
--------------------------------------------------------------------------------
1 | import { encode } from 'blurhash'
2 |
3 | export default {
4 | async encode(
5 | pixels: Uint8ClampedArray,
6 | width: number,
7 | height: number,
8 | componentX: number,
9 | componentY: number
10 | ): Promise {
11 | return encode(pixels, width, height, componentX, componentY)
12 | },
13 | }
14 |
--------------------------------------------------------------------------------
/src/adapters/exif.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import type { Exif, Input } from '../types/input.js'
9 |
10 | import fs from 'node:fs/promises'
11 | import ExifReader from 'exifreader'
12 | import logger from '@adonisjs/core/services/logger'
13 | import { fileTypeFromBuffer, fileTypeFromFile } from 'file-type'
14 | import { bufferToTempFile, cleanObject, use } from '../utils/helpers.js'
15 | import { ResolvedAttachmentConfig } from '../define_config.js'
16 | import { Converter } from '../types/converter.js'
17 |
18 | type KnownConverters = Record
19 |
20 | export default {
21 | async exif(
22 | input: Input,
23 | config: ResolvedAttachmentConfig
24 | ): Promise {
25 | return exif(input, config)
26 | },
27 | }
28 |
29 | const exif = async (
30 | input: Input,
31 | config: ResolvedAttachmentConfig
32 | ): Promise => {
33 | let fileType
34 | let buffer
35 |
36 | if (Buffer.isBuffer(input)) {
37 | fileType = await fileTypeFromBuffer(input)
38 |
39 | if (fileType?.mime.includes('image')) {
40 | buffer = input
41 | }
42 | } else {
43 | fileType = await fileTypeFromFile(input)
44 |
45 | if (fileType?.mime.includes('image')) {
46 | buffer = await fs.readFile(input)
47 | }
48 | }
49 |
50 | if (fileType?.mime.includes('video')) {
51 | return videoExif(input, config)
52 | }
53 |
54 | if (buffer && fileType?.mime.includes('image')) {
55 | return imageExif(buffer)
56 | }
57 |
58 | return undefined
59 | }
60 |
61 | async function imageExif(buffer: Buffer) {
62 | const tags = await ExifReader.load(buffer, { expanded: true })
63 | const data: Exif = {}
64 |
65 | if (tags.exif) {
66 | if (tags.exif['DateTime']) data.date = tags.exif['DateTime'].description
67 | if (tags.exif['Software']) data.host = tags.exif['Software'].description
68 |
69 | if (tags.exif['PixelXDimension'] && tags.exif['PixelYDimension']) {
70 | data.dimension = {
71 | width: tags.exif['PixelXDimension'].value,
72 | height: tags.exif['PixelYDimension'].value,
73 | }
74 | }
75 |
76 | if (tags.exif['Orientation']) {
77 | data.orientation = {
78 | value: tags.exif['Orientation'].value,
79 | description: tags.exif['Orientation'].description,
80 | }
81 | }
82 | }
83 |
84 | if (tags.gps) {
85 | data.gps = {
86 | latitude: tags.gps['Latitude'],
87 | longitude: tags.gps['Longitude'],
88 | altitude: tags.gps['Altitude'],
89 | }
90 | }
91 |
92 | if (tags.png) {
93 | if (tags.png['Image Width'] && tags.png['Image Height']) {
94 | data.dimension = {
95 | width: tags.png['Image Width'].value,
96 | height: tags.png['Image Height'].value,
97 | }
98 | }
99 | if (tags.png['Software']) data.host = tags.png['Software'].description
100 | if (tags.png['Creation Time']) data.date = tags.png['Creation Time'].description
101 | }
102 |
103 | if (tags.pngFile) {
104 | if (tags.pngFile['Image Width'] && tags.pngFile['Image Height']) {
105 | data.dimension = {
106 | width: tags.pngFile['Image Width'].value,
107 | height: tags.pngFile['Image Height'].value,
108 | }
109 | }
110 | }
111 |
112 | if (tags.file) {
113 | if (tags.file['Image Width'] && tags.file['Image Height']) {
114 | data.dimension = {
115 | width: tags.file['Image Width'].value,
116 | height: tags.file['Image Height'].value,
117 | }
118 | }
119 | }
120 |
121 | if (tags.icc) {
122 | if (tags.icc['Software']) data.host = tags.icc['Software'].description
123 | if (tags.icc['Creation Time']) data.date = tags.icc['Creation Time'].description
124 |
125 | if (tags.icc['Image Width'] && tags.icc['Image Height']) {
126 | data.dimension = {
127 | width: parseInt(tags.icc['Image Width'].value),
128 | height: parseInt(tags.icc['Image Height'].value),
129 | }
130 | }
131 | }
132 |
133 | return cleanObject(data)
134 | }
135 |
136 | async function videoExif(input: Input, config: ResolvedAttachmentConfig) {
137 | return new Promise(async (resolve) => {
138 | const ffmpeg = await use('fluent-ffmpeg')
139 |
140 | let file = input
141 | if (Buffer.isBuffer(input)) {
142 | file = await bufferToTempFile(input)
143 | }
144 |
145 | const ff = ffmpeg(file)
146 |
147 | if (config.bin) {
148 | if (config.bin.ffprobePath) {
149 | ff.setFfprobePath(config.bin.ffprobePath)
150 | }
151 | }
152 |
153 | ff.ffprobe(0, (err: unknown, data: any) => {
154 | if (err) {
155 | logger.error({ err })
156 | }
157 |
158 | resolve({
159 | dimension: {
160 | width: data.streams[0].width,
161 | height: data.streams[0].height,
162 | },
163 | })
164 | })
165 | })
166 | }
167 |
--------------------------------------------------------------------------------
/src/adapters/meta.ts:
--------------------------------------------------------------------------------
1 | import { Meta } from '../types/input.js'
2 |
3 | import path from 'node:path'
4 | import fs from 'node:fs/promises'
5 | import { fileTypeFromBuffer, fileTypeFromFile } from 'file-type'
6 | import mime from 'mime-types'
7 |
8 | function getFileExtension(filename: string) {
9 | if (!filename) {
10 | return ''
11 | }
12 |
13 | const ext = path.extname(filename).slice(1)
14 | return ext && /^[a-zA-Z0-9]+$/.test(ext) ? ext : ''
15 | }
16 |
17 | function metaByFileName(filename: string) {
18 | return {
19 | ext: getFileExtension(filename),
20 | mime: mime.lookup(filename) || '',
21 | }
22 | }
23 |
24 | export async function metaFormBuffer(input: Buffer): Promise {
25 | const fileType = await fileTypeFromBuffer(input)
26 |
27 | return {
28 | extname: fileType?.ext || '',
29 | mimeType: fileType?.mime || '',
30 | size: input.length,
31 | }
32 | }
33 |
34 | export async function metaFormFile(input: string, filename: string): Promise {
35 | let fileType
36 | let size = 0
37 |
38 | fileType = metaByFileName(filename)
39 | if (fileType.ext === '' || fileType.mime === '') {
40 | fileType = await fileTypeFromFile(input)
41 | }
42 |
43 | const stats = await fs.stat(input)
44 | size = stats.size
45 |
46 | return {
47 | extname: fileType?.ext || '',
48 | mimeType: fileType?.mime || '',
49 | size,
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/attachments/attachment_base.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import type { LucidRow } from '@adonisjs/lucid/types/model'
9 | import string from '@adonisjs/core/helpers/string'
10 | import type { DriveService, SignedURLOptions } from '@adonisjs/drive/types'
11 | import type {
12 | LucidOptions,
13 | AttachmentBaseAttributes,
14 | AttachmentBase as AttachmentBaseInterface,
15 | } from '../types/attachment.js'
16 | import type { Exif, Input } from '../types/input.js'
17 |
18 | import path from 'node:path'
19 | import { cuid } from '@adonisjs/core/helpers'
20 | import { defaultOptionsDecorator } from '../utils/default_values.js'
21 | import { extractPathParameters } from '../utils/helpers.js'
22 |
23 | export class AttachmentBase implements AttachmentBaseInterface {
24 | drive: DriveService
25 |
26 | input?: Input
27 |
28 | #name: string
29 | #folder?: string
30 |
31 | size: number
32 | extname: string
33 | mimeType: string
34 | meta?: Exif
35 | originalPath?: string
36 | url?: string
37 |
38 | options: LucidOptions
39 |
40 | constructor(drive: DriveService, attributes: AttachmentBaseAttributes, input?: Input) {
41 | this.input = input
42 |
43 | this.size = attributes.size
44 | this.meta = attributes.meta
45 | this.extname = attributes.extname
46 | this.mimeType = attributes.mimeType
47 | this.originalPath = attributes.path
48 |
49 | this.#folder = attributes.folder
50 | this.setOptions({ folder: attributes.folder })
51 |
52 | if (attributes.name) {
53 | this.#name = attributes.name
54 | } else {
55 | this.#name = `${cuid()}.${this.extname}`
56 | }
57 |
58 | this.options = defaultOptionsDecorator
59 | this.drive = drive
60 | }
61 |
62 | /**
63 | * Getters
64 | */
65 |
66 | get name(): string {
67 | return this.#name
68 | }
69 |
70 | get folder(): string | undefined {
71 | if (this.#folder) {
72 | return this.#folder
73 | }
74 |
75 | if (typeof this.options.folder === 'string') {
76 | const parameters = extractPathParameters(this.options.folder)
77 | if (!parameters.length) {
78 | return this.options.folder
79 | }
80 | }
81 | }
82 |
83 | get path(): string {
84 | if (!this.folder && this.originalPath) {
85 | return this.originalPath
86 | }
87 |
88 | return path.join(this.folder!, this.name)
89 | }
90 |
91 | /**
92 | * Methods
93 | */
94 |
95 | getDisk() {
96 | return this.drive.use(this.options?.disk)
97 | }
98 |
99 | getBytes() {
100 | return this.getDisk().getBytes(this.path)
101 | }
102 |
103 | async getBuffer() {
104 | const arrayBuffer = await this.getBytes()
105 | return Buffer.from(arrayBuffer)
106 | }
107 |
108 | getStream() {
109 | return this.getDisk().getStream(this.path)
110 | }
111 |
112 | getUrl() {
113 | return this.getDisk().getUrl(this.path)
114 | }
115 |
116 | getSignedUrl(signedUrlOptions?: SignedURLOptions) {
117 | return this.getDisk().getSignedUrl(this.path, signedUrlOptions)
118 | }
119 |
120 | setOptions(options: LucidOptions) {
121 | this.options = {
122 | ...this.options,
123 | ...options,
124 | }
125 | return this
126 | }
127 |
128 | makeFolder(record?: LucidRow) {
129 | if (typeof this.options.folder === 'function' && record) {
130 | this.#folder = this.options.folder(record)
131 | } else if (this.options.folder) {
132 | this.#folder = this.options.folder as string
133 | }
134 |
135 | if (this.#folder && record) {
136 | const parameters = extractPathParameters(this.#folder)
137 |
138 | if (parameters) {
139 | parameters.forEach((parameter) => {
140 | const attribute = record.$attributes[parameter]
141 | if (typeof attribute === 'string') {
142 | const name = string.slug(string.noCase(string.escapeHTML(attribute.toLowerCase())))
143 | this.#folder = this.#folder?.replace(`:${parameter}`, name)
144 | }
145 | })
146 | }
147 | }
148 |
149 | return this
150 | }
151 |
152 | /**
153 | *
154 | */
155 |
156 | toObject(): AttachmentBaseAttributes {
157 | return {
158 | name: this.name,
159 | extname: this.extname,
160 | size: this.size,
161 | meta: this.meta,
162 | mimeType: this.mimeType,
163 | path: this.path,
164 | }
165 | }
166 |
167 | toJSON(): Object {
168 | if (this.url) {
169 | return {
170 | ...this.toObject(),
171 | url: this.url,
172 | }
173 | }
174 |
175 | return this.toObject()
176 | }
177 | }
178 |
--------------------------------------------------------------------------------
/src/attachments/variant_attachment.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import type { DriveService } from '@adonisjs/drive/types'
9 | import type { VariantAttributes, Variant as VariantInterface } from '../types/attachment.js'
10 | import type { Input } from '../types/input.js'
11 |
12 | import { AttachmentBase } from './attachment_base.js'
13 | import { BlurhashOptions } from '../types/converter.js'
14 | import { imageToBlurhash } from '../utils/helpers.js'
15 |
16 | export class Variant extends AttachmentBase implements VariantInterface {
17 | key: string
18 | #folder: string
19 | blurhash?: string
20 |
21 | constructor(drive: DriveService, attributes: VariantAttributes, input?: Input) {
22 | super(drive, attributes, input)
23 |
24 | this.key = attributes.key
25 | this.#folder = attributes.folder!
26 | this.blurhash = attributes.blurhash
27 | }
28 |
29 | async generateBlurhash(options?: BlurhashOptions) {
30 | this.blurhash = await imageToBlurhash(this.input!, options)
31 | }
32 |
33 | /**
34 | * Getters
35 | */
36 |
37 | get folder(): string {
38 | return this.#folder
39 | }
40 |
41 | /**
42 | *
43 | */
44 |
45 | toObject(): VariantAttributes {
46 | return {
47 | key: this.key,
48 | folder: this.folder!,
49 | name: this.name,
50 | blurhash: this.blurhash,
51 | ...super.toObject(),
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/converters/autodetect_converter.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import type { ConverterAttributes } from '../types/converter.js'
9 | import type { Input } from '../types/input.js'
10 |
11 | import { fileTypeFromBuffer, fileTypeFromFile } from 'file-type'
12 | import Converter from './converter.js'
13 | import ImageConverter from './image_converter.js'
14 | import VideoThumnailConverter from './video_thumbnail_converter.js'
15 |
16 | export default class AutodetectConverter extends Converter {
17 | async handle({ input, options }: ConverterAttributes): Promise {
18 | let converter
19 | let fileType
20 |
21 | if (Buffer.isBuffer(input)) {
22 | fileType = await fileTypeFromBuffer(input)
23 | } else {
24 | fileType = await fileTypeFromFile(input)
25 | }
26 |
27 | if (fileType?.mime.includes('video')) {
28 | converter = new VideoThumnailConverter(options, this.binPaths)
29 | } else {
30 | converter = new ImageConverter(options, this.binPaths)
31 | }
32 |
33 | return converter.handle({
34 | input,
35 | options,
36 | })
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/converters/converter.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import type { BinPaths } from '../types/config.js'
9 | import type { Converter as ConverterInterface, ConverterOptions } from '../types/converter.js'
10 |
11 | export default class Converter implements ConverterInterface {
12 | options?: ConverterOptions
13 | binPaths?: BinPaths
14 |
15 | constructor(options?: ConverterOptions, binPaths?: BinPaths) {
16 | this.options = options
17 | this.binPaths = binPaths
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/converters/document_thumbnail_converter.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import type { ConverterAttributes } from '../types/converter.js'
9 | import type { Input } from '../types/input.js'
10 |
11 | import Converter from './converter.js'
12 | import ImageConverter from './image_converter.js'
13 | import { use } from '../utils/helpers.js'
14 |
15 | export default class DocumentThumbnailConverter extends Converter {
16 | async handle({ input, options }: ConverterAttributes): Promise {
17 | const lib = await use('libreoffice-file-converter')
18 | const LibreOfficeFileConverter = lib.LibreOfficeFileConverter
19 | const outputBuffer = await this.documentToImage(LibreOfficeFileConverter, input)
20 |
21 | if (options && outputBuffer) {
22 | const converter = new ImageConverter()
23 | return await converter.handle({
24 | input: outputBuffer,
25 | options,
26 | })
27 | }
28 |
29 | return outputBuffer
30 | }
31 |
32 | async documentToImage(LibreOfficeFileConverter: any, input: Input) {
33 | let binaryPaths = undefined
34 | if (this.binPaths && this.binPaths.libreofficePaths) {
35 | binaryPaths = this.binPaths.libreofficePaths
36 | }
37 |
38 | const libreOfficeFileConverter = new LibreOfficeFileConverter({
39 | childProcessOptions: {
40 | timeout: 60 * 1000,
41 | },
42 | binaryPaths,
43 | })
44 |
45 | if (Buffer.isBuffer(input)) {
46 | const output = await libreOfficeFileConverter.convert({
47 | buffer: input,
48 | input: 'buffer',
49 | output: 'buffer',
50 | format: 'jpeg',
51 | })
52 |
53 | return output
54 | } else {
55 | const output = await libreOfficeFileConverter.convert({
56 | inputPath: input,
57 | input: 'file',
58 | output: 'buffer',
59 | format: 'jpeg',
60 | })
61 |
62 | return output
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/converters/image_converter.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import type { ConverterAttributes } from '../types/converter.js'
9 | import type { Input } from '../types/input.js'
10 |
11 | import Converter from './converter.js'
12 | import { use } from '../utils/helpers.js'
13 |
14 | export default class ImageConverter extends Converter {
15 | async handle({ input, options }: ConverterAttributes): Promise {
16 | const sharp = await use('sharp')
17 | const resize = options?.resize || {}
18 | let format = options?.format || 'webp'
19 | let formatoptions = {}
20 |
21 | if (typeof format !== 'string') {
22 | formatoptions = format?.options
23 | format = format.format
24 | }
25 |
26 | const buffer: Input = await sharp(input)
27 | .withMetadata()
28 | .resize(resize)
29 | .toFormat(format, formatoptions)
30 | .toBuffer()
31 |
32 | return buffer
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/converters/pdf_thumbnail_converter.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import type { ConverterAttributes } from '../types/converter.js'
9 | import type { Input } from '../types/input.js'
10 |
11 | import os from 'node:os'
12 | import path from 'node:path'
13 | import { cuid } from '@adonisjs/core/helpers'
14 | import Converter from './converter.js'
15 | import ImageConverter from './image_converter.js'
16 | import { use } from '../utils/helpers.js'
17 |
18 | export default class PdfThumbnailConverter extends Converter {
19 | async handle({ input, options }: ConverterAttributes): Promise {
20 | const nodePoppler = await use('node-poppler')
21 | const Poppler = nodePoppler.Poppler
22 | const filePath = await this.pdfToImage(Poppler, input)
23 |
24 | if (options && filePath) {
25 | const converter = new ImageConverter()
26 | return await converter.handle({
27 | input: filePath,
28 | options,
29 | })
30 | }
31 |
32 | return filePath
33 | }
34 |
35 | async pdfToImage(Poppler: any, input: Input) {
36 | let binPath = null
37 |
38 | if (this.binPaths && this.binPaths.pdftocairoBasePath) {
39 | binPath = this.binPaths.pdftocairoBasePath
40 | }
41 |
42 | const poppler = new Poppler(binPath)
43 |
44 | const pdfInfo = await poppler.pdfInfo(input)
45 | const pagesMatch = pdfInfo.match(/Pages:\s*(\d+)/)
46 | const pageCount = pagesMatch ? parseInt(pagesMatch[1]) : 1
47 |
48 | const pageNumberFormat = '0'.repeat(String(pageCount).length - 1)
49 |
50 | const options = {
51 | // firstPageToConvert: 1,
52 | lastPageToConvert: 1,
53 | jpegFile: true,
54 | }
55 | const filePath = path.join(os.tmpdir(), cuid())
56 |
57 | await poppler.pdfToCairo(input, filePath, options)
58 |
59 | return `${filePath}-${pageNumberFormat}1.jpg`
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/converters/video_thumbnail_converter.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import type { ConverterAttributes } from '../types/converter.js'
9 | import type { Input } from '../types/input.js'
10 |
11 | import os from 'node:os'
12 | import path from 'node:path'
13 | import { cuid } from '@adonisjs/core/helpers'
14 | import Converter from './converter.js'
15 | import ImageConverter from './image_converter.js'
16 | import { bufferToTempFile, use } from '../utils/helpers.js'
17 |
18 | export default class VideoThumbnailConvert extends Converter {
19 | async handle({ input, options }: ConverterAttributes): Promise {
20 | const ffmpeg = await use('fluent-ffmpeg')
21 | const filePath = await this.videoToImage(ffmpeg, input)
22 |
23 | if (options && filePath) {
24 | const converter = new ImageConverter()
25 | return converter.handle({
26 | input: filePath,
27 | options,
28 | })
29 | } else {
30 | return filePath
31 | }
32 | }
33 |
34 | async videoToImage(ffmpeg: Function, input: Input) {
35 | let file = input
36 |
37 | if (Buffer.isBuffer(input)) {
38 | file = await bufferToTempFile(input)
39 | }
40 |
41 | return new Promise((resolve, reject) => {
42 | const folder = os.tmpdir()
43 | const filename = `${cuid()}.png`
44 |
45 | const ff = ffmpeg(file)
46 |
47 | if (this.binPaths) {
48 | if (this.binPaths.ffmpegPath) {
49 | ff.setFfmpegPath(this.binPaths.ffmpegPath)
50 | }
51 | }
52 |
53 | ff.screenshots({
54 | count: 1,
55 | filename,
56 | folder,
57 | })
58 | .on('end', () => {
59 | resolve(path.join(folder, filename))
60 | })
61 | .on('error', (err: Error) => {
62 | reject(err)
63 | })
64 | })
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/decorators/attachment.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import type { LucidModel } from '@adonisjs/lucid/types/model'
9 | import type { Attachment, LucidOptions } from '../types/attachment.js'
10 |
11 | import attachmentManager from '../../services/main.js'
12 | import { optionsSym } from '../utils/symbols.js'
13 | import { defaultOptionsDecorator } from '../utils/default_values.js'
14 | import type { AttributeOfRowWithAttachment } from '../types/mixin.js'
15 |
16 | import {
17 | afterFindHook,
18 | afterFetchHook,
19 | beforeSaveHook,
20 | afterSaveHook,
21 | beforeDeleteHook,
22 | } from '../utils/hooks.js'
23 |
24 | import { defaultStateAttributeMixin } from '../utils/default_values.js'
25 |
26 | export const bootModel = (
27 | model: LucidModel & {
28 | $attachments: AttributeOfRowWithAttachment
29 | }
30 | ) => {
31 | model.boot()
32 |
33 | model.$attachments = structuredClone(defaultStateAttributeMixin)
34 |
35 | /**
36 | * Registering all hooks only once
37 | */
38 | if (!model.$hooks.has('find', afterFindHook)) {
39 | model.after('find', afterFindHook)
40 | }
41 | if (!model.$hooks.has('fetch', afterFetchHook)) {
42 | model.after('fetch', afterFetchHook)
43 | }
44 | if (!model.$hooks.has('paginate', afterFetchHook)) {
45 | model.after('paginate', afterFetchHook)
46 | }
47 | if (!model.$hooks.has('save', beforeSaveHook)) {
48 | model.before('save', beforeSaveHook)
49 | }
50 | if (!model.$hooks.has('save', afterSaveHook)) {
51 | model.after('save', afterSaveHook)
52 | }
53 | if (!model.$hooks.has('delete', beforeDeleteHook)) {
54 | model.before('delete', beforeDeleteHook)
55 | }
56 | }
57 |
58 | const makeColumnOptions = (options?: LucidOptions) => {
59 | const { disk, folder, variants, meta, rename, ...columnOptions } = {
60 | ...defaultOptionsDecorator,
61 | ...options,
62 | }
63 |
64 | return {
65 | consume: (value?: string | JSON) => {
66 | if (value) {
67 | const attachment = attachmentManager.createFromDbResponse(value)
68 | attachment?.setOptions({ disk, folder, variants })
69 |
70 | if (options && options?.meta !== undefined) {
71 | attachment?.setOptions({ meta: options!.meta })
72 | }
73 | if (options && options?.rename !== undefined) {
74 | attachment?.setOptions({ rename: options!.rename })
75 | }
76 | if (options && options?.preComputeUrl !== undefined) {
77 | attachment?.setOptions({ preComputeUrl: options!.preComputeUrl })
78 | }
79 | return attachment
80 | } else {
81 | return null
82 | }
83 | },
84 | prepare: (value?: Attachment) => (value ? JSON.stringify(value.toObject()) : null),
85 | serialize: options?.serialize !== undefined ? options?.serialize : (value?: Attachment) => (value ? value.toJSON() : null),
86 | ...columnOptions,
87 | }
88 | }
89 |
90 | const makeAttachmentDecorator =
91 | (columnOptionsTransformer?: (columnOptions: any) => any) => (options?: LucidOptions) => {
92 | return function (target: any, attributeName: string) {
93 | if (!target[optionsSym]) {
94 | target[optionsSym] = {}
95 | }
96 |
97 | target[optionsSym][attributeName] = options
98 |
99 | const Model = target.constructor as LucidModel & {
100 | $attachments: AttributeOfRowWithAttachment
101 | }
102 |
103 | bootModel(Model)
104 |
105 | const columnOptions = makeColumnOptions(options)
106 | const transformedColumnOptions = columnOptionsTransformer
107 | ? columnOptionsTransformer(columnOptions)
108 | : columnOptions
109 | Model.$addColumn(attributeName, transformedColumnOptions)
110 | }
111 | }
112 |
113 | export const attachment = makeAttachmentDecorator()
114 | export const attachments = makeAttachmentDecorator((columnOptions) => ({
115 | consume: (value?: string | JSON) => {
116 | if (value) {
117 | const data = typeof value === 'string' ? JSON.parse(value) : value
118 | return data.map(columnOptions.consume)
119 | }
120 | return null
121 | },
122 | prepare: (value?: Attachment[]) =>
123 | value ? JSON.stringify(value.map((v) => v.toObject())) : null,
124 | serialize: (value?: Attachment[]) => (value ? value.map(columnOptions.serialize) : null),
125 | }))
126 |
--------------------------------------------------------------------------------
/src/define_config.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import type { AttachmentConfig, BinPaths, ConverterConfig, Queue } from './types/config.js'
9 |
10 | import { ConfigProvider } from '@adonisjs/core/types'
11 | import { configProvider } from '@adonisjs/core'
12 | import BaseConverter from './converters/converter.js'
13 | import { Converter } from './types/converter.js'
14 |
15 | /**
16 | * Config resolved by the "defineConfig" method
17 | */
18 | export type ResolvedAttachmentConfig> = {
19 | bin?: BinPaths
20 | meta?: boolean
21 | rename?: boolean
22 | preComputeUrl?: boolean
23 | converters?: { [K in keyof KnownConverters]: KnownConverters[K] }
24 | queue?: Queue
25 | }
26 |
27 | export function defineConfig>(
28 | config: AttachmentConfig
29 | ): ConfigProvider> {
30 | return configProvider.create(async (_app) => {
31 | const convertersList = Object.keys(config.converters || {})
32 | const converters: Record = {}
33 |
34 | if (config.converters) {
35 | for (let converterName of convertersList) {
36 | const converter = config.converters[converterName]
37 | const binConfig = config.bin
38 |
39 | try {
40 | const { default: value } = await converter.converter()
41 | const Converter = value as typeof BaseConverter
42 |
43 | converters[converterName] = new Converter(converter.options, binConfig)
44 | } catch (error) {
45 | console.error(`Failed to load converter ${converterName}:`, error)
46 | }
47 | }
48 | }
49 |
50 | return {
51 | ...config,
52 | converters,
53 | } as ResolvedAttachmentConfig
54 | })
55 | }
56 |
--------------------------------------------------------------------------------
/src/errors.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import { createError } from '@adonisjs/core/exceptions'
9 | import { errors } from 'flydrive'
10 |
11 | /**
12 | * Unable to write file to the destination
13 | */
14 | export const E_CANNOT_WRITE_FILE = errors.E_CANNOT_WRITE_FILE
15 |
16 | /**
17 | * Unable to read file
18 | */
19 | export const E_CANNOT_READ_FILE = errors.E_CANNOT_READ_FILE
20 |
21 | /**
22 | * Unable to delete file
23 | */
24 | export const E_CANNOT_DELETE_FILE = errors.E_CANNOT_DELETE_FILE
25 |
26 | /**
27 | * Unable to set file visibility
28 | */
29 | export const E_CANNOT_SET_VISIBILITY = errors.E_CANNOT_SET_VISIBILITY
30 |
31 | /**
32 | * Unable to generate URL for a file
33 | */
34 | export const E_CANNOT_GENERATE_URL = errors.E_CANNOT_GENERATE_URL
35 |
36 | /**
37 | * Unable to generate temp file
38 | */
39 | export const E_CANNOT_GENERATE_TEMP_FILE = createError<[key: string]>(
40 | 'Cannot generate temp file "%s"',
41 | 'E_CANNOT_GENERATE_TEMP_FILE'
42 | )
43 |
44 | /**
45 | * The file key has unallowed set of characters
46 | */
47 | export const E_UNALLOWED_CHARACTERS = errors.E_UNALLOWED_CHARACTERS
48 |
49 | /**
50 | * Key post normalization leads to an empty string
51 | */
52 | export const E_INVALID_KEY = errors.E_INVALID_KEY
53 |
54 | /**
55 | * Missing package
56 | */
57 | export const E_MISSING_PACKAGE = createError<[key: string]>(
58 | 'Missing package, please install "%s"',
59 | 'E_MISSING_PACKAGE'
60 | )
61 |
62 | /**
63 | * Unable to create Attachment Object
64 | */
65 | export const E_CANNOT_CREATE_ATTACHMENT = createError<[key: string]>(
66 | 'Cannot create attachment from database response. Missing attribute "%s"',
67 | 'E_CANNOT_CREATE_ATTACHMENT'
68 | )
69 |
70 | /**
71 | * Unable to create variant
72 | */
73 | export const E_CANNOT_CREATE_VARIANT = createError<[key: string]>(
74 | 'Cannot create variant. "%s"',
75 | 'E_CANNOT_CREATE_VARIANT'
76 | )
77 |
78 | /**
79 | * Missing path
80 | */
81 | export const E_CANNOT_PATH_BY_CONVERTER = createError(
82 | 'Path not found',
83 | 'E_CANNOT_PATH_BY_CONVERTER'
84 | )
85 |
86 | /**
87 | * Is not a Buffer
88 | */
89 | export const E_ISNOT_BUFFER = createError('Is not a Buffer', 'E_ISNOT_BUFFER')
90 |
91 | /**
92 | * Is not a Base64
93 | */
94 | export const E_ISNOT_BASE64 = createError('Is not a Base64', 'E_ISNOT_BASE64')
95 |
96 | /**
97 | * Unable to read file
98 | */
99 | export const ENOENT = createError('File not found', 'ENOENT')
100 |
--------------------------------------------------------------------------------
/src/types/attachment.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import type { LucidRow } from '@adonisjs/lucid/types/model'
9 | import type { DriveService } from '@adonisjs/drive/types'
10 | import type { Exif, Input } from './input.js'
11 | import type { Disk } from '@adonisjs/drive'
12 | import type { SignedURLOptions } from '@adonisjs/drive/types'
13 | import type { AttachmentVariants } from '@jrmc/adonis-attachment'
14 | import { BlurhashOptions } from './converter.js'
15 |
16 | export type AttachmentBase = {
17 | drive: DriveService
18 |
19 | input?: Input
20 |
21 | name: string
22 | folder?: string
23 | path?: string
24 |
25 | size: number
26 | extname: string
27 | mimeType: string
28 | meta?: Exif
29 | originalPath?: string
30 | url?: string
31 |
32 | options: LucidOptions
33 |
34 | makeFolder(record?: LucidRow): void
35 | getDisk(): Disk
36 | getBytes(): Promise
37 | getBuffer(): Promise
38 | getStream(): Promise
39 | getUrl(): Promise
40 | getSignedUrl(signedUrlOptions?: SignedURLOptions): Promise
41 |
42 | setOptions(options: LucidOptions): AttachmentBase
43 |
44 | toObject(): AttachmentBaseAttributes
45 | toJSON(): Object
46 | }
47 |
48 | export type Attachment = AttachmentBase & {
49 | originalName: string
50 | variants?: Variant[]
51 |
52 | createVariant(key: string, input: Input): Promise
53 | getVariant(variantName: string): Variant | undefined
54 | getUrl(variantName?: string): Promise
55 | getSignedUrl(
56 | variantNameOrOptions?: string | SignedURLOptions,
57 | signedUrlOptions?: SignedURLOptions
58 | ): Promise
59 | toObject(): AttachmentAttributes
60 | }
61 |
62 | export type Variant = AttachmentBase & {
63 | key: string
64 | folder: string
65 | blurhash?: string
66 |
67 | generateBlurhash(options?: BlurhashOptions): void
68 | toObject(): VariantAttributes
69 | }
70 |
71 | export type LucidOptions = {
72 | disk?: string
73 | folder?: string | ((record?: LucidRow) => string)
74 | preComputeUrl?: boolean
75 | variants?: (keyof AttachmentVariants)[]
76 | rename?: boolean
77 | meta?: boolean
78 | serialize?: (value?: Attachment) => unknown
79 | serializeAs?: string | null
80 | }
81 |
82 | export type AttachmentBaseAttributes = {
83 | name?: string
84 | size: number
85 | meta?: Exif
86 | extname: string
87 | mimeType: string
88 | folder?: string
89 | path?: string
90 | }
91 |
92 | export type AttachmentAttributes = AttachmentBaseAttributes & {
93 | variants?: VariantAttributes[]
94 | originalName: string
95 | }
96 |
97 | export type VariantAttributes = AttachmentBaseAttributes & {
98 | key: string
99 | folder: string
100 | blurhash?: string
101 | }
102 |
--------------------------------------------------------------------------------
/src/types/config.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import type { Converter, ConverterOptions } from './converter.js'
9 |
10 | import { ConfigProvider } from '@adonisjs/core/types'
11 | import { AttachmentManager } from '../attachment_manager.js'
12 |
13 | type ImportConverter = {
14 | default: unknown
15 | }
16 |
17 | export interface ConverterConfig {
18 | converter: () => Promise
19 | options?: ConverterOptions
20 | }
21 |
22 | export interface Queue {
23 | concurrency: number
24 | }
25 |
26 | export type BinPaths = {
27 | ffmpegPath?: string
28 | ffprobePath?: string
29 | pdftocairoBasePath?: string
30 | libreofficePaths?: Array
31 | }
32 |
33 | export type AttachmentConfig> = {
34 | bin?: BinPaths
35 | meta?: boolean
36 | rename?: boolean
37 | preComputeUrl?: boolean
38 | converters?: {
39 | [K in keyof KnownConverter]: KnownConverter[K]
40 | }
41 | queue?: Queue
42 | }
43 |
44 | export interface AttachmentVariants {}
45 |
46 | export type InferConverters<
47 | Config extends ConfigProvider<{
48 | bin?: unknown
49 | meta?: unknown
50 | rename?: unknown
51 | preComputeUrl?: unknown
52 | converters?: unknown
53 | queue?: unknown
54 | }>,
55 | > = Exclude>['converters'], undefined>
56 |
57 | export interface AttachmentService
58 | extends AttachmentManager<
59 | AttachmentVariants extends Record ? AttachmentVariants : never
60 | > {}
61 |
--------------------------------------------------------------------------------
/src/types/converter.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import type { RecordWithAttachment } from './service.js'
9 | import type { BinPaths } from './config.js'
10 | import type { Input } from './input.js'
11 | import type { LucidOptions } from './attachment.js'
12 |
13 | export type Converter = {
14 | options?: ConverterOptions
15 | binPaths?: BinPaths
16 | handle?: (attributes: ConverterAttributes) => Promise
17 | }
18 |
19 | export type ConverterInitializeAttributes = {
20 | record: RecordWithAttachment
21 | attributeName: string
22 | options: LucidOptions
23 | filters?: {
24 | variants?: string[]
25 | }
26 | }
27 |
28 | export type ConverterAttributes = {
29 | input: Input
30 | options: ConverterOptions
31 | }
32 |
33 | export type BlurhashOptions = {
34 | enabled: boolean
35 | componentX: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
36 | componentY: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
37 | }
38 |
39 | type jpeg = {
40 | format: 'jpeg'
41 | options: {
42 | quality?: number
43 | progressive?: boolean
44 | chromaSubsampling?: string
45 | optimiseCoding?: boolean
46 | optimizeCoding?: boolean
47 | mozjpeg?: boolean
48 | trellisQuantisation?: boolean
49 | overshootDeringing?: boolean
50 | optimiseScans?: boolean
51 | optimizeScans?: boolean
52 | quantisationTable?: number
53 | quantizationTable?: number
54 | force?: boolean
55 | }
56 | }
57 |
58 | type png = {
59 | format: 'png'
60 | options: {
61 | quality?: number
62 | progressive?: boolean
63 | compressionLevel?: number
64 | adaptiveFiltering?: boolean
65 | palette?: boolean
66 | effort?: number
67 | colours?: number
68 | colors?: number
69 | dither?: number
70 | force?: boolean
71 | }
72 | }
73 |
74 | type gif = {
75 | format: 'gif'
76 | options: {
77 | reuse?: boolean
78 | progressive?: boolean
79 | colours?: number
80 | colors?: number
81 | effort?: number
82 | dither?: number
83 | interFrameMaxError?: number
84 | interPaletteMaxError?: number
85 | loop?: number
86 | delay?: number | number[]
87 | force?: boolean
88 | }
89 | }
90 |
91 | type webp = {
92 | format: 'webp'
93 | options: {
94 | quality?: number
95 | alphaQuality?: number
96 | lossless?: boolean
97 | nearLossless?: boolean
98 | smartSubsample?: boolean
99 | preset?: string
100 | effort?: number
101 | loop?: number
102 | delay?: number | number[]
103 | minSize?: boolean
104 | mixed?: boolean
105 | force?: boolean
106 | }
107 | }
108 |
109 | type avif = {
110 | format: 'avif'
111 | options: {
112 | quality?: number
113 | lossless?: boolean
114 | effort?: number
115 | chromaSubsampling?: string
116 | bitdepth?: number
117 | }
118 | }
119 |
120 | type heif = {
121 | format: 'heif'
122 | options: {
123 | compression?: string
124 | quality?: number
125 | lossless?: boolean
126 | effort?: number
127 | chromaSubsampling?: string
128 | bitdepth?: number
129 | }
130 | }
131 |
132 | export type ConverterOptions = {
133 | resize?:
134 | | number
135 | | {
136 | width?: number
137 | height?: number
138 | fit?: string
139 | position?: string
140 | background?:
141 | | string
142 | | {
143 | r: number
144 | g: number
145 | b: number
146 | alpha: number
147 | }
148 | kernel?: string
149 | withoutEnlargement?: boolean
150 | withoutReduction?: boolean
151 | fastShrinkOnLoad?: boolean
152 | }
153 | format?:
154 | | 'jpeg'
155 | | 'jpg'
156 | | 'png'
157 | | 'gif'
158 | | 'webp'
159 | | 'avif'
160 | | 'heif'
161 | | 'tiff'
162 | | 'raw'
163 | | jpeg
164 | | png
165 | | gif
166 | | webp
167 | | avif
168 | | heif
169 | blurhash?: boolean | BlurhashOptions
170 | }
171 |
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export * from './attachment.js'
2 | export * from './config.js'
3 | export * from './converter.js'
4 | export * from './input.js'
5 | export * from './mixin.js'
6 | export * from './regenerate.js'
7 | export * from './service.js'
8 |
--------------------------------------------------------------------------------
/src/types/input.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | export type Exif = {
9 | orientation?: {
10 | value: number
11 | description?: string
12 | }
13 | date?: string
14 | host?: string
15 | gps?: {
16 | latitude?: number
17 | longitude?: number
18 | altitude?: number
19 | }
20 | dimension?: {
21 | width: number
22 | height: number
23 | }
24 | }
25 |
26 | export type Meta = {
27 | extname: string
28 | mimeType: string
29 | size: number
30 | }
31 |
32 | export type Input = Buffer | string
33 |
--------------------------------------------------------------------------------
/src/types/mixin.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import type { LucidRow } from '@adonisjs/lucid/types/model'
9 | import type { Attachment } from './attachment.js'
10 |
11 | export type AttributeOfRowWithAttachment = {
12 | attached: Attachment[]
13 | detached: Attachment[]
14 | dirtied: string[]
15 | }
16 |
17 | export type RowWithAttachment = LucidRow & {
18 | $attachments: AttributeOfRowWithAttachment
19 | }
20 |
--------------------------------------------------------------------------------
/src/types/regenerate.ts:
--------------------------------------------------------------------------------
1 | import type { AttachmentVariants } from '@jrmc/adonis-attachment'
2 |
3 | export type RegenerateOptions = {
4 | attributes?: string[],
5 | variants?: (keyof AttachmentVariants)[]
6 | }
7 |
--------------------------------------------------------------------------------
/src/types/service.ts:
--------------------------------------------------------------------------------
1 | import type { Attachment } from './attachment.js'
2 | import type { RowWithAttachment } from './mixin.js'
3 |
4 | export interface RecordWithAttachment {
5 | row: RowWithAttachment
6 | commit(): Promise
7 | rollback(): Promise
8 | persist(): Promise
9 | transaction(options?: { enabledRollback: boolean }): Promise
10 | preComputeUrl(): Promise
11 | generateVariants(): Promise
12 | getAttachments(options: { attributeName: string, requiredOriginal?: boolean, requiredDirty?: boolean }): Attachment[]
13 | }
14 |
--------------------------------------------------------------------------------
/src/utils/default_values.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | export const defaultOptionsDecorator = {
9 | disk: undefined,
10 | folder: 'uploads',
11 | preComputeUrl: false,
12 | variants: [],
13 | meta: true,
14 | rename: true,
15 | }
16 |
17 | export const defaultStateAttributeMixin = {
18 | attached: [],
19 | detached: [],
20 | dirtied: [],
21 | }
22 |
--------------------------------------------------------------------------------
/src/utils/helpers.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import type { Input } from '../types/input.js'
9 | import type { BlurhashOptions } from '../types/converter.js'
10 |
11 | import os from 'node:os'
12 | import path from 'node:path'
13 | import https from 'node:https'
14 | import fs from 'node:fs/promises'
15 | import { pipeline } from 'node:stream'
16 | import { promisify } from 'node:util'
17 | import { createWriteStream, WriteStream } from 'node:fs'
18 | import BlurhashAdapter from '../adapters/blurhash.js'
19 | import * as errors from '../errors.js'
20 |
21 | const streamPipeline = promisify(pipeline)
22 |
23 | export function cleanObject(obj: any) {
24 | if (obj === null || typeof obj !== 'object') {
25 | return obj
26 | }
27 |
28 | const cleanedObj: any = Array.isArray(obj) ? [] : {}
29 |
30 | for (const key in obj) {
31 | if (obj.hasOwnProperty(key)) {
32 | const cleanedValue = cleanObject(obj[key])
33 |
34 | if (
35 | cleanedValue !== null &&
36 | cleanedValue !== undefined &&
37 | cleanedValue !== 0 &&
38 | cleanedValue !== ''
39 | ) {
40 | cleanedObj[key] = cleanedValue
41 | }
42 | }
43 | }
44 |
45 | return cleanedObj
46 | }
47 |
48 | export async function use(module: string) {
49 | try {
50 | const result = await import(module)
51 |
52 | if (result.default) {
53 | return result.default
54 | }
55 |
56 | return result
57 | } catch (err) {
58 | throw new errors.E_MISSING_PACKAGE([module], { cause: err })
59 | }
60 | }
61 |
62 | export async function bufferToTempFile(input: Buffer) {
63 | const folder = os.tmpdir()
64 | const tempFilePath = path.join(folder, `tempfile-${Date.now()}.tmp`)
65 | await fs.writeFile(tempFilePath, input)
66 | return tempFilePath
67 | }
68 |
69 | export async function streamToTempFile(input: NodeJS.ReadableStream): Promise {
70 | const folder = os.tmpdir()
71 | const tempFilePath = path.join(folder, `tempfile-${Date.now()}.tmp`)
72 |
73 | const writeStream: WriteStream = createWriteStream(tempFilePath)
74 |
75 | try {
76 | await streamPipeline(input, writeStream)
77 | return tempFilePath
78 | } catch (err) {
79 | throw new errors.E_CANNOT_GENERATE_TEMP_FILE([err.message])
80 | }
81 | }
82 |
83 | export async function downloadToTempFile(input: URL): Promise {
84 | return await new Promise((resolve) => {
85 | https
86 | .get(input, (response) => {
87 | if (response.statusCode === 200) {
88 | resolve(streamToTempFile(response))
89 | } else {
90 | // reject(`${response.statusCode}`)
91 | throw new errors.E_CANNOT_GENERATE_TEMP_FILE([''])
92 | }
93 | })
94 | .on('error', (err) => {
95 | throw new errors.E_CANNOT_GENERATE_TEMP_FILE([err.message])
96 | })
97 | })
98 | }
99 |
100 | export function isBase64(str: string) {
101 | const base64Regex = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
102 |
103 | if (!base64Regex.test(str)) {
104 | return false
105 | }
106 |
107 | try {
108 | Buffer.from(str, 'base64').toString()
109 | return true
110 | } catch (err) {
111 | return false
112 | }
113 | }
114 |
115 | export function imageToBlurhash(input: Input, options?: BlurhashOptions): Promise {
116 | const { componentX, componentY } = options || { componentX: 4, componentY: 4 }
117 |
118 | return new Promise(async (resolve, reject) => {
119 | try {
120 | const sharp = await use('sharp')
121 | // Convert input to pixels
122 | const { data: pixels, info: metadata } = await sharp(input)
123 | .raw()
124 | .ensureAlpha()
125 | .toBuffer({ resolveWithObject: true })
126 |
127 | const blurhash = BlurhashAdapter.encode(
128 | new Uint8ClampedArray(pixels),
129 | metadata.width,
130 | metadata.height,
131 | componentX,
132 | componentY
133 | )
134 |
135 | return resolve(blurhash)
136 | } catch (error) {
137 | return reject(error)
138 | }
139 | })
140 | }
141 |
142 | export function extractPathParameters(path: string): string[] {
143 | const paramRegex = /:(\w+)/g
144 | const parameters: string[] = []
145 | let match
146 |
147 | while ((match = paramRegex.exec(path)) !== null) {
148 | parameters.push(match[1])
149 | }
150 |
151 | return parameters
152 | }
153 |
--------------------------------------------------------------------------------
/src/utils/hooks.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import type { RowWithAttachment } from '../types/mixin.js'
9 | import RecordWithAttachment from '../services/record_with_attachment.js'
10 |
11 | // @afterFind()
12 | export const afterFindHook = async (instance: unknown) => {
13 | const modelInstance = instance as RowWithAttachment
14 | const model = new RecordWithAttachment(modelInstance)
15 | await model.preComputeUrl()
16 | }
17 |
18 | // @afterFetch()
19 | // @afterPaginate()
20 | export const afterFetchHook = async (instance: unknown) => {
21 | const modelInstances = instance as RowWithAttachment[]
22 | await Promise.all(modelInstances.map((row) => afterFindHook(row)))
23 | }
24 |
25 | // @beforeSave()
26 | export const beforeSaveHook = async (instance: unknown) => {
27 | const modelInstance = instance as RowWithAttachment
28 | const model = new RecordWithAttachment(modelInstance)
29 | await model.detach()
30 | await model.persist()
31 | await model.transaction()
32 | }
33 |
34 | // @afterSave()
35 | export const afterSaveHook = async (instance: unknown) => {
36 | const modelInstance = instance as RowWithAttachment
37 | const model = new RecordWithAttachment(modelInstance)
38 | await model.generateVariants()
39 | }
40 |
41 | // @beforeDelete()
42 | export const beforeDeleteHook = async (instance: unknown) => {
43 | const modelInstance = instance as RowWithAttachment
44 | const model = new RecordWithAttachment(modelInstance)
45 | await model.detachAll()
46 | await model.transaction({ enabledRollback: false })
47 | }
48 |
--------------------------------------------------------------------------------
/src/utils/symbols.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | export const optionsSym = Symbol('optionsAttachments')
9 |
--------------------------------------------------------------------------------
/stubs/config.stub:
--------------------------------------------------------------------------------
1 | {{{
2 | exports({ to: app.configPath('attachment.ts') })
3 | }}}
4 | import { defineConfig } from '@jrmc/adonis-attachment'
5 | import { InferConverters } from '@jrmc/adonis-attachment/types/config'
6 |
7 | const attachmentConfig = defineConfig({
8 | converters: {
9 | thumbnail: {
10 | converter: () => import('@jrmc/adonis-attachment/converters/image_converter'),
11 | options: {
12 | resize: 300,
13 | }
14 | }
15 | }
16 | })
17 |
18 | export default attachmentConfig
19 |
20 | declare module '@jrmc/adonis-attachment' {
21 | interface AttachmentVariants extends InferConverters {}
22 | }
23 |
--------------------------------------------------------------------------------
/stubs/main.ts:
--------------------------------------------------------------------------------
1 | import { getDirname } from '@poppinss/utils'
2 |
3 | export const stubsRoot = getDirname(import.meta.url)
4 |
--------------------------------------------------------------------------------
/stubs/make/converter/main.stub:
--------------------------------------------------------------------------------
1 | {{#var entity = generators.createEntity(name)}}
2 | {{#var modelConverterName = string(entity.name).removeSuffix('converter').pascalCase().suffix(string.pascalCase('converter')).toString()}}
3 | {{#var modelConverterFileName = string(entity.name).snakeCase().suffix('_converter').ext('.ts').toString()}}
4 | {{{
5 | exports({ to: app.makePath('app/converters', entity.path, modelConverterFileName) })
6 | }}}
7 |
8 | import type { ConverterAttributes } from '@jrmc/adonis-attachment/types/converter'
9 | import type { Input } from '@jrmc/adonis-attachment/types/input'
10 |
11 | import logger from '@adonisjs/core/services/logger'
12 | import Converter from '@jrmc/adonis-attachment/converters/converter'
13 |
14 | export default class {{ modelConverterName }} extends Converter {
15 | async handle({ input }: ConverterAttributes): Promise {
16 | logger.info('Input path %s', input)
17 |
18 | return input
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/tests/attachment-manager.spec.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import { test } from '@japa/runner'
9 | import app from '@adonisjs/core/services/app'
10 | import drive from '@adonisjs/drive/services/main'
11 |
12 | test.group('attachment-manager', () => {
13 | test('save method - should result in noop when attachment is created from db response', async ({
14 | assert,
15 | }) => {
16 | const attachmentManager = await app.container.make('jrmc.attachment')
17 |
18 | const attachment = attachmentManager.createFromDbResponse(
19 | JSON.stringify({
20 | size: 1440,
21 | name: 'foo123.jpg',
22 | originalName: 'foo.jpg',
23 | extname: 'jpg',
24 | mimeType: 'image/jpg',
25 | })
26 | )
27 |
28 | assert.equal(attachment?.originalName, 'foo.jpg')
29 | })
30 |
31 | test('Attachment - should be null when db response is null', async ({ assert }) => {
32 | const attachmentManager = await app.container.make('jrmc.attachment')
33 |
34 | const attachment = attachmentManager.createFromDbResponse(null)
35 | assert.isNull(attachment)
36 | })
37 |
38 | test('Attachment path default is uploads', async ({ assert }) => {
39 | const attachmentManager = await app.container.make('jrmc.attachment')
40 |
41 | const attachment = attachmentManager.createFromDbResponse(
42 | JSON.stringify({
43 | size: 1440,
44 | name: 'foo.jpg',
45 | extname: 'jpg',
46 | mimeType: 'image/jpg',
47 | })
48 | )
49 |
50 | assert.equal(attachment?.path!, 'uploads/foo.jpg')
51 | })
52 |
53 | test('Attachment path - should be custom', async ({ assert }) => {
54 | const attachmentManager = await app.container.make('jrmc.attachment')
55 |
56 | const attachment = attachmentManager.createFromDbResponse(
57 | JSON.stringify({
58 | size: 1440,
59 | name: 'foo.jpg',
60 | extname: 'jpg',
61 | mimeType: 'image/jpg',
62 | })
63 | )
64 |
65 | attachment?.setOptions({ folder: 'avatar' })
66 |
67 | assert.equal(attachment?.path!, 'avatar/foo.jpg')
68 | })
69 |
70 | test('Attachment get url', async ({ assert, cleanup }) => {
71 | drive.fake('fs')
72 | cleanup(() => drive.restore('fs'))
73 |
74 | const attachmentManager = await app.container.make('jrmc.attachment')
75 | const attachment = attachmentManager.createFromDbResponse(
76 | JSON.stringify({
77 | size: 1440,
78 | name: 'foo.jpg',
79 | extname: 'jpg',
80 | mimeType: 'image/jpg',
81 | })
82 | )
83 |
84 | attachment?.setOptions({ folder: 'avatars' })
85 |
86 | const url = await attachment?.getUrl()
87 | const signedUrl = await attachment?.getSignedUrl()
88 |
89 | assert.match(url!, /\/drive\/fakes\/avatars\/foo\.jpg/)
90 | assert.match(signedUrl!, /\/drive\/fakes\/signed\/avatars\/foo\.jpg/)
91 | })
92 |
93 | test('Precompute file url', async ({ assert, cleanup }) => {
94 | drive.fake('fs')
95 | cleanup(() => drive.restore('fs'))
96 |
97 | const attachmentManager = await app.container.make('jrmc.attachment')
98 | const attachment = attachmentManager.createFromDbResponse(
99 | JSON.stringify({
100 | size: 1440,
101 | name: 'foo.jpg',
102 | extname: 'jpg',
103 | mimeType: 'image/jpg',
104 | })
105 | )
106 |
107 | attachment?.setOptions({ preComputeUrl: true, folder: 'avatars' })
108 | await attachmentManager.preComputeUrl(attachment!)
109 |
110 | assert.match(attachment?.url!, /\/drive\/fakes\/avatars\/foo\.jpg/)
111 | })
112 | })
113 |
--------------------------------------------------------------------------------
/tests/attachment.spec.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import { test } from '@japa/runner'
9 |
10 | import { UserFactory } from './fixtures/factories/user.js'
11 | import { createApp } from './helpers/app.js'
12 |
13 | test.group('attachment', () => {
14 | test('create', async ({ assert, cleanup }) => {
15 | const app = await createApp()
16 | cleanup(() => app.terminate())
17 |
18 | const user = await UserFactory.create()
19 |
20 | assert.exists(user.avatar)
21 | await assert.fileExists(user.avatar?.path!)
22 | })
23 | test('delete', async ({ assert, cleanup }) => {
24 | const app = await createApp()
25 | cleanup(() => app.terminate())
26 |
27 | const user = await UserFactory.create()
28 | user.avatar = null
29 | await user.save()
30 |
31 | assert.isNull(user.avatar)
32 | })
33 |
34 | test('delete file after removing', async ({ assert, cleanup }) => {
35 | const app = await createApp()
36 | cleanup(() => app.terminate())
37 |
38 | const user = await UserFactory.create()
39 | const path = user.avatar?.path
40 | await assert.fileExists(path!)
41 | user.avatar = null
42 | await user.save()
43 |
44 | await assert.fileNotExists(path!)
45 | })
46 |
47 | test('delete file after remove entity', async ({ assert, cleanup }) => {
48 | const app = await createApp()
49 | cleanup(() => app.terminate())
50 |
51 | const user = await UserFactory.create()
52 | const path = user.avatar?.path
53 | await assert.fileExists(path!)
54 | await user.delete()
55 |
56 | await assert.fileNotExists(path!)
57 | })
58 | })
59 |
60 | test.group('attachments', () => {
61 | test('create', async ({ assert, cleanup }) => {
62 | const app = await createApp()
63 | cleanup(() => app.terminate())
64 |
65 | const user = await UserFactory.create()
66 |
67 | assert.exists(user.weekendPics)
68 | assert.equal(user.weekendPics?.length, 2)
69 | for (const pic of user.weekendPics ?? []) {
70 | assert.equal(pic.originalName, 'avatar.jpg')
71 | assert.match(pic.name, /(.*).jpg$/)
72 | assert.equal(pic.mimeType, 'image/jpeg')
73 | assert.equal(pic.extname, 'jpg')
74 | }
75 | })
76 |
77 | test('delete files after removing', async ({ assert, cleanup }) => {
78 | const app = await createApp()
79 | cleanup(() => app.terminate())
80 |
81 | const user = await UserFactory.create()
82 | const paths = user.weekendPics?.map((p) => p.path)
83 |
84 | for (const path of paths ?? []) {
85 | await assert.fileExists(path!)
86 | }
87 |
88 | user.weekendPics = null
89 | await user.save()
90 |
91 | for (const path of paths ?? []) {
92 | await assert.fileNotExists(path!)
93 | }
94 | })
95 |
96 | test('delete files after remove entity', async ({ assert, cleanup }) => {
97 | const app = await createApp()
98 | cleanup(() => app.terminate())
99 |
100 | const user = await UserFactory.create()
101 | const paths = user.weekendPics?.map((p) => p.path)
102 |
103 | for (const path of paths ?? []) {
104 | await assert.fileExists(path!)
105 | }
106 |
107 | await user.delete()
108 |
109 | for (const path of paths ?? []) {
110 | await assert.fileNotExists(path!)
111 | }
112 | })
113 | })
114 |
--------------------------------------------------------------------------------
/tests/commands.spec.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import type { FileSystem } from '@japa/file-system'
9 |
10 | import { test } from '@japa/runner'
11 | import Configure from '@adonisjs/core/commands/configure'
12 | import { IgnitorFactory } from '@adonisjs/core/factories'
13 |
14 | import MakeConverter from '../commands/make/converter.js'
15 | import { BASE_URL } from './helpers/index.js'
16 |
17 | async function setupFakeAdonisProject(fs: FileSystem) {
18 | await Promise.all([
19 | fs.create('.env', ''),
20 | fs.createJson('tsconfig.json', {}),
21 | fs.create('adonisrc.ts', `export default defineConfig({})`),
22 | ])
23 | }
24 |
25 | async function setupApp() {
26 | const ignitor = new IgnitorFactory()
27 | .withCoreProviders()
28 | .withCoreConfig()
29 | .create(BASE_URL, {
30 | importer: (filePath) => {
31 | if (filePath.startsWith('./') || filePath.startsWith('../')) {
32 | return import(new URL(filePath, BASE_URL).href)
33 | }
34 |
35 | return import(filePath)
36 | },
37 | })
38 |
39 | const app = ignitor.createApp('web')
40 | await app.init()
41 | await app.boot()
42 |
43 | const ace = await app.container.make('ace')
44 | ace.ui.switchMode('raw')
45 |
46 | return { ace, app }
47 | }
48 |
49 | test.group('configure', (group) => {
50 | group.tap((t) => t.timeout(20_000))
51 | group.each.setup(async ({ context }) => setupFakeAdonisProject(context.fs))
52 |
53 | test('add provider, config file, and command', async ({ assert }) => {
54 | const { ace } = await setupApp()
55 |
56 | const command = await ace.create(Configure, ['../../configure.js'])
57 | await command.exec()
58 |
59 | command.assertSucceeded()
60 |
61 | await assert.fileExists('config/attachment.ts')
62 | await assert.fileExists('adonisrc.ts')
63 | await assert.fileContains('adonisrc.ts', '@jrmc/adonis-attachment/attachment_provider')
64 | await assert.fileContains('adonisrc.ts', '@jrmc/adonis-attachment/commands')
65 | })
66 |
67 | test('create converter', async ({ assert }) => {
68 | const { ace } = await setupApp()
69 |
70 | const command = await ace.create(MakeConverter, ['thumb'])
71 | await command.exec()
72 |
73 | command.assertSucceeded()
74 |
75 | await assert.fileExists('app/converters/thumb_converter.ts')
76 | })
77 | })
78 |
--------------------------------------------------------------------------------
/tests/fixtures/converters/image_converter.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import type { ConverterAttributes } from '../../../src/types/converter.js'
9 | import type { Input } from '../../../src/types/input.js'
10 |
11 | import Converter from '../../../src/converters/converter.js'
12 |
13 | export default class ImageConverter extends Converter {
14 | async handle({ input }: ConverterAttributes): Promise {
15 | return input
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/tests/fixtures/factories/user.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import Factory from '@adonisjs/lucid/factories'
9 | import { BaseModel, column } from '@adonisjs/lucid/orm'
10 | import { DateTime } from 'luxon'
11 |
12 | import { makeAttachment } from '../../helpers/index.js'
13 | import { attachment, attachments } from '../../../index.js'
14 | import type { Attachment } from '../../../src/types/attachment.js'
15 |
16 | export default class User extends BaseModel {
17 | @column({ isPrimary: true })
18 | declare id: string
19 |
20 | @column()
21 | declare name: string
22 |
23 | @attachment()
24 | declare avatar: Attachment | null
25 |
26 | @attachment({ disk: 's3', folder: 'avatar', preComputeUrl: true, meta: false, rename: false })
27 | declare avatar2: Attachment | null
28 |
29 | @attachments({ preComputeUrl: true })
30 | declare weekendPics: Attachment[] | null
31 |
32 | @column.dateTime({ autoCreate: true, serialize: (value: DateTime) => value.toUnixInteger() })
33 | declare createdAt: DateTime
34 |
35 | @column.dateTime({ autoCreate: true, autoUpdate: true })
36 | declare updatedAt: DateTime | null
37 | }
38 |
39 | export const UserFactory = Factory.define(User, async ({ faker }) => {
40 | return {
41 | name: faker.person.lastName(),
42 | avatar: await makeAttachment(),
43 | weekendPics: [await makeAttachment(), await makeAttachment()],
44 | }
45 | }).build()
46 |
--------------------------------------------------------------------------------
/tests/fixtures/images/adonis.svg:
--------------------------------------------------------------------------------
1 |
2 |
31 |
--------------------------------------------------------------------------------
/tests/fixtures/images/adonisjs.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/tests/fixtures/images/img.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/batosai/adonis-attachment/195d0b09b7fe31423d7354b27a4001c875a76bc8/tests/fixtures/images/img.jpg
--------------------------------------------------------------------------------
/tests/fixtures/migrations/create_users_table.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 | import { BaseSchema } from '@adonisjs/lucid/schema'
8 | export default class extends BaseSchema {
9 | protected tableName = 'users'
10 | async up() {
11 | this.schema.createTable(this.tableName, (table) => {
12 | table.increments('id')
13 | table.string('name')
14 | table.json('avatar')
15 | table.json('avatar_2')
16 | table.json('weekend_pics')
17 | table.timestamp('created_at')
18 | table.timestamp('updated_at')
19 | })
20 | }
21 | async down() {
22 | this.schema.dropTable(this.tableName)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/tests/helpers/app.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import type { ApplicationService } from '@adonisjs/core/types'
9 | import type { InferConverters } from '../../src/types/config.js'
10 |
11 | import { copyFile, mkdir, rm } from 'node:fs/promises'
12 | import { IgnitorFactory } from '@adonisjs/core/factories'
13 | import { defineConfig as defineLucidConfig } from '@adonisjs/lucid'
14 | import { defineConfig } from '../../src/define_config.js'
15 | import { defineConfig as defineDriveConfig, services } from '@adonisjs/drive'
16 |
17 | import { BASE_URL } from './index.js'
18 |
19 | const IMPORTER = (filePath: string) => {
20 | if (filePath.startsWith('./') || filePath.startsWith('../')) {
21 | return import(new URL(filePath, BASE_URL).href)
22 | }
23 | return import(filePath)
24 | }
25 |
26 | const attachmentConfig = defineConfig({
27 | converters: {
28 | thumbnail: {
29 | converter: () => import('../fixtures/converters/image_converter.js'),
30 | options: {
31 | resize: 300,
32 | blurhash: true,
33 | },
34 | },
35 | medium: {
36 | converter: () => import('../fixtures/converters/image_converter.js'),
37 | options: {
38 | resize: 600,
39 | blurhash: {
40 | enabled: true,
41 | componentX: 4,
42 | componentY: 4,
43 | },
44 | },
45 | },
46 | },
47 | })
48 |
49 | declare module '@jrmc/adonis-attachment' {
50 | interface AttachmentVariants extends InferConverters {}
51 | }
52 |
53 | export async function createApp(options?: Object) {
54 | const app = new IgnitorFactory()
55 | .merge({
56 | rcFileContents: {
57 | commands: [() => import('@adonisjs/lucid/commands')],
58 | providers: [
59 | () => import('@adonisjs/lucid/database_provider'),
60 | () => import('@adonisjs/drive/drive_provider'),
61 | () => import('../../providers/attachment_provider.js'),
62 | ],
63 | },
64 | config: {
65 | attachment: options
66 | ? defineConfig({
67 | ...options,
68 | converters: {
69 | thumbnail: {
70 | converter: () => import('../fixtures/converters/image_converter.js'),
71 | options: {
72 | resize: 300,
73 | },
74 | },
75 | },
76 | })
77 | : attachmentConfig,
78 | drive: defineDriveConfig({
79 | default: 'fs',
80 | services: {
81 | fs: services.fs({
82 | location: BASE_URL,
83 | serveFiles: true,
84 | routeBasePath: '/uploads',
85 | visibility: 'public',
86 | }),
87 | },
88 | }),
89 | database: defineLucidConfig({
90 | connection: 'sqlite',
91 | connections: {
92 | sqlite: {
93 | client: 'better-sqlite3',
94 | connection: {
95 | filename: decodeURIComponent(new URL('../db.sqlite', BASE_URL).pathname),
96 | },
97 | },
98 | },
99 | }),
100 | },
101 | })
102 | .withCoreConfig()
103 | .withCoreProviders()
104 | .create(BASE_URL, {
105 | importer: IMPORTER,
106 | })
107 | .createApp('web')
108 |
109 | await app.init()
110 | await app.boot()
111 |
112 | await mkdir(app.migrationsPath(), { recursive: true })
113 |
114 | await copyFile(
115 | new URL('../fixtures/migrations/create_users_table.ts', import.meta.url),
116 | app.migrationsPath('create_users_table.ts')
117 | )
118 |
119 | return app
120 | }
121 |
122 | export async function initializeDatabase(app: ApplicationService) {
123 | const ace = await app.container.make('ace')
124 | await ace.exec('migration:fresh', [])
125 | // await seedDatabase()
126 | }
127 |
128 | export async function removeDatabase() {
129 | await rm(decodeURIComponent(new URL('../db.sqlite', BASE_URL).pathname))
130 | }
131 |
132 | // async function seedDatabase() {
133 | // const { default: User } = await import('./fixtures/models/user.js')
134 | // await User.createMany([{ name: 'AdonisJS' }, { name: 'Jeremy' }])
135 | // }
136 |
--------------------------------------------------------------------------------
/tests/helpers/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @jrmc/adonis-attachment
3 | *
4 | * @license MIT
5 | * @copyright Jeremy Chaufourier
6 | */
7 |
8 | import type { LucidModel } from '@adonisjs/lucid/types/model'
9 |
10 | import { readFile } from 'node:fs/promises'
11 | import app from '@adonisjs/core/services/app'
12 |
13 | export const BASE_URL = new URL('../tmp/', import.meta.url)
14 |
15 | export async function makeAttachment() {
16 | const attachmentManager = await app.container.make('jrmc.attachment')
17 | const buffer = await readFile(app.makePath('../fixtures/images/img.jpg'))
18 | return attachmentManager.createFromBuffer(buffer, 'avatar.jpg')
19 | }
20 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@adonisjs/tsconfig/tsconfig.package.json",
3 | "compilerOptions": {
4 | "rootDir": "./",
5 | "outDir": "./build",
6 | "declaration": true,
7 | "declarationMap": true,
8 | "noEmitOnError": true,
9 | "incremental": true
10 | },
11 | "exclude": ["tests", "bin", "build"]
12 | }
13 |
--------------------------------------------------------------------------------
/tsnode.esm.js:
--------------------------------------------------------------------------------
1 | /*
2 | |--------------------------------------------------------------------------
3 | | TS-Node ESM hook
4 | |--------------------------------------------------------------------------
5 | |
6 | | Importing this file before any other file will allow you to run TypeScript
7 | | code directly using TS-Node + SWC. For example
8 | |
9 | | node --import="./tsnode.esm.js" bin/test.ts
10 | | node --import="./tsnode.esm.js" index.ts
11 | |
12 | |
13 | | Why not use "--loader=ts-node/esm"?
14 | | Because, loaders have been deprecated.
15 | */
16 |
17 | import { register } from 'node:module'
18 | register('ts-node/esm', import.meta.url)
19 |
--------------------------------------------------------------------------------