├── .editorconfig ├── .ember-cli ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc.js ├── .template-lintrc.js ├── .watchmanconfig ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── addon ├── -private │ ├── request.js │ └── uploader.js ├── .gitkeep ├── model │ └── blob.js ├── services │ └── active-storage.js └── utils │ ├── file-checksum.js │ └── tests.js ├── app ├── .gitkeep └── services │ └── active-storage.js ├── config ├── ember-try.js └── environment.js ├── ember-cli-build.js ├── index.js ├── jsconfig.json ├── package.json ├── testem.js ├── tests ├── dummy │ ├── app │ │ ├── app.js │ │ ├── components │ │ │ ├── .gitkeep │ │ │ ├── file-upload.hbs │ │ │ └── file-upload.js │ │ ├── controllers │ │ │ └── .gitkeep │ │ ├── helpers │ │ │ └── .gitkeep │ │ ├── index.html │ │ ├── models │ │ │ └── .gitkeep │ │ ├── resolver.js │ │ ├── router.js │ │ ├── routes │ │ │ └── .gitkeep │ │ ├── styles │ │ │ └── app.css │ │ └── templates │ │ │ ├── application.hbs │ │ │ └── index.hbs │ ├── config │ │ ├── ember-cli-update.json │ │ ├── environment.js │ │ ├── optional-features.json │ │ └── targets.js │ ├── mirage │ │ ├── config.js │ │ ├── scenarios │ │ │ └── default.js │ │ └── serializers │ │ │ └── application.js │ └── public │ │ ├── robots.txt │ │ └── sample.pdf ├── helpers │ ├── .gitkeep │ └── fetch-file.js ├── index.html ├── integration │ └── .gitkeep ├── test-helper.js └── unit │ ├── -private │ └── uploader-test.js │ ├── .gitkeep │ ├── models │ └── blob-test.js │ ├── services │ └── active-storage-test.js │ └── utils │ └── file-checksum-test.js ├── vendor └── .gitkeep └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.hbs] 16 | insert_final_newline = false 17 | 18 | [*.{diff,md}] 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /.ember-cli: -------------------------------------------------------------------------------- 1 | { 2 | /** 3 | Ember CLI sends analytics information by default. The data is completely 4 | anonymous, but there are times when you might want to disable this behavior. 5 | 6 | Setting `disableAnalytics` to true will prevent any data from being sent. 7 | */ 8 | "disableAnalytics": false 9 | } 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # unconventional js 2 | /blueprints/*/files/ 3 | /vendor/ 4 | 5 | # compiled output 6 | /dist/ 7 | /tmp/ 8 | 9 | # dependencies 10 | /bower_components/ 11 | /node_modules/ 12 | 13 | # misc 14 | /coverage/ 15 | !.* 16 | .*/ 17 | .eslintcache 18 | 19 | # ember-try 20 | /.node_modules.ember-try/ 21 | /bower.json.ember-try 22 | /package.json.ember-try 23 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | root: true, 5 | globals: { 6 | server: true, 7 | SparkMD5: true, 8 | }, 9 | parser: 'babel-eslint', 10 | parserOptions: { 11 | ecmaVersion: 2018, 12 | sourceType: 'module', 13 | ecmaFeatures: { 14 | legacyDecorators: true, 15 | }, 16 | }, 17 | plugins: ['ember'], 18 | extends: [ 19 | 'eslint:recommended', 20 | 'plugin:ember/recommended', 21 | 'plugin:prettier/recommended', 22 | ], 23 | env: { 24 | browser: true, 25 | }, 26 | rules: {}, 27 | overrides: [ 28 | // node files 29 | { 30 | files: [ 31 | './.eslintrc.js', 32 | './.prettierrc.js', 33 | './.template-lintrc.js', 34 | './ember-cli-build.js', 35 | './index.js', 36 | './testem.js', 37 | './blueprints/*/index.js', 38 | './config/**/*.js', 39 | './tests/dummy/config/**/*.js', 40 | ], 41 | parserOptions: { 42 | sourceType: 'script', 43 | }, 44 | env: { 45 | browser: false, 46 | node: true, 47 | }, 48 | plugins: ['node'], 49 | extends: ['plugin:node/recommended'], 50 | }, 51 | { 52 | // Test files: 53 | files: ['tests/**/*-test.{js,ts}'], 54 | extends: ['plugin:qunit/recommended'], 55 | }, 56 | ], 57 | }; 58 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | tags: 9 | - "*" 10 | schedule: 11 | - cron: "0 6 * * 0" 12 | 13 | env: 14 | CI: true 15 | 16 | jobs: 17 | test-locked-deps: 18 | name: Locked Deps 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout Code 22 | uses: actions/checkout@v1 23 | - name: Install Node 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: 14 27 | - name: Install Dependencies 28 | run: yarn install --frozen-lockfile 29 | - name: Lint 30 | run: yarn lint 31 | - name: Browser Tests 32 | run: yarn test:ember 33 | 34 | test-try: 35 | name: Ember Try 36 | runs-on: ubuntu-latest 37 | needs: [test-locked-deps] 38 | strategy: 39 | matrix: 40 | scenario: 41 | - ember-lts-3.16 42 | - ember-lts-3.20 43 | - ember-release 44 | - ember-beta 45 | - ember-canary 46 | - ember-default-with-jquery 47 | - ember-classic 48 | steps: 49 | - name: Checkout Code 50 | uses: actions/checkout@v1 51 | - name: Install Node 52 | uses: actions/setup-node@v1 53 | with: 54 | node-version: 14 55 | - name: Install Dependencies 56 | run: yarn install --frozen-lockfile 57 | - name: Try Scenario 58 | run: yarn ember try:one ${{ matrix.scenario }} 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist/ 5 | /tmp/ 6 | 7 | # dependencies 8 | /bower_components/ 9 | /node_modules/ 10 | 11 | # misc 12 | /.env* 13 | /.pnp* 14 | /.sass-cache 15 | /.eslintcache 16 | /connect.lock 17 | /coverage/ 18 | /libpeerconnection.log 19 | /npm-debug.log* 20 | /testem.log 21 | /yarn-error.log 22 | 23 | # ember-try 24 | /.node_modules.ember-try/ 25 | /bower.json.ember-try 26 | /package.json.ember-try 27 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist/ 3 | /tmp/ 4 | 5 | # dependencies 6 | /bower_components/ 7 | 8 | # misc 9 | /.bowerrc 10 | /.editorconfig 11 | /.ember-cli 12 | /.env* 13 | /.eslintcache 14 | /.eslintignore 15 | /.eslintrc.js 16 | /.git/ 17 | /.gitignore 18 | /.prettierignore 19 | /.prettierrc.js 20 | /.template-lintrc.js 21 | /.travis.yml 22 | /.watchmanconfig 23 | /bower.json 24 | /config/ember-try.js 25 | /CONTRIBUTING.md 26 | /ember-cli-build.js 27 | /testem.js 28 | /tests/ 29 | /yarn-error.log 30 | /yarn.lock 31 | .gitkeep 32 | 33 | # ember-try 34 | /.node_modules.ember-try/ 35 | /bower.json.ember-try 36 | /package.json.ember-try 37 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # unconventional js 2 | /blueprints/*/files/ 3 | /vendor/ 4 | 5 | # compiled output 6 | /dist/ 7 | /tmp/ 8 | 9 | # dependencies 10 | /bower_components/ 11 | /node_modules/ 12 | 13 | # misc 14 | /coverage/ 15 | !.* 16 | .eslintcache 17 | 18 | # ember-try 19 | /.node_modules.ember-try/ 20 | /bower.json.ember-try 21 | /package.json.ember-try 22 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | singleQuote: true, 5 | }; 6 | -------------------------------------------------------------------------------- /.template-lintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | extends: 'recommended', 5 | }; 6 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | { 2 | "ignore_dirs": ["tmp", "dist"] 3 | } 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How To Contribute 2 | 3 | ## Installation 4 | 5 | * `git clone ` 6 | * `cd @algonauti/ember-active-storage` 7 | * `yarn install` 8 | 9 | ## Linting 10 | 11 | * `yarn lint` 12 | * `yarn lint:fix` 13 | 14 | ## Running tests 15 | 16 | * `ember test` – Runs the test suite on the current Ember version 17 | * `ember test --server` – Runs the test suite in "watch mode" 18 | * `ember try:each` – Runs the test suite against multiple Ember versions 19 | 20 | ## Running the dummy application 21 | 22 | * `ember serve` 23 | * Visit the dummy application at [http://localhost:4200](http://localhost:4200). 24 | 25 | For more information on using ember-cli, visit [https://ember-cli.com/](https://ember-cli.com/). 26 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ember-active-storage 2 | 3 | [![CI](https://github.com/algonauti/ember-active-storage/workflows/CI/badge.svg)](https://github.com/algonauti/ember-active-storage/actions) 4 | [![Ember Observer Score](https://emberobserver.com/badges/-algonauti-ember-active-storage.svg)](https://emberobserver.com/addons/@algonauti/ember-active-storage) 5 | 6 | ## Installation 7 | 8 | ``` 9 | ember install @algonauti/ember-active-storage 10 | ``` 11 | 12 | ## Usage 13 | 14 | The addon provides an `activeStorage` service that allows you to: 15 | 16 | - send files to your Rails backend's direct upload controller; 17 | - listen to upload progress events. 18 | 19 | Assuming your template has a file input like: 20 | 21 | ```hbs 22 | 23 | ``` 24 | 25 | and your ember model has an `avatar` attribute defined as `has_one_attached :avatar` on its corresponding Active Record model, then in your component (or controller) the `upload` action would look like: 26 | 27 | ```javascript 28 | import Component from '@glimmer/component'; 29 | import { action } from '@ember/object'; 30 | import { tracked } from '@glimmer/tracking'; 31 | import { inject as service } from '@ember/service'; 32 | 33 | export default class UploadComponent extends Component { 34 | @service 35 | activeStorage; 36 | 37 | @tracked 38 | uploadProgress = 0; 39 | 40 | @action 41 | upload(event) { 42 | const files = event.target.files; 43 | if (files) { 44 | const directUploadURL = '/rails/active_storage/direct_uploads'; 45 | 46 | for (var i = 0; i < files.length; i++) { 47 | this.activeStorage 48 | .upload(files.item(i), directUploadURL, { 49 | onProgress: (progress, event) => { 50 | this.uploadProgress = progress; 51 | }, 52 | }) 53 | .then((blob) => { 54 | const signedId = blob.signedId; 55 | 56 | this.model.avatar = signedId; 57 | }); 58 | } 59 | } 60 | } 61 | } 62 | ``` 63 | 64 | - `directUploadURL` is the path referencing `ActiveStorage::DirectUploadsController` on your Rails backend (or a custom one built on top of that). 65 | - The `uploadProgress` property will hold a value between 0 and 100 that you might use in your template to show upload progress. 66 | - After the `upload` promise is resolved and `signedId` is set in your model, when a `model.save()` is triggered, the Rails backend will use such `signedId` to associate an `ActiveStorage::Attachment` record to your backend model's record. 67 | 68 | 69 | ### Events 70 | 71 | `loadstart`, `load`, `loadend`, `error`, `abort`, `timeout` events invokes `onLoadstart`, `onLoad`, `onLoadend`, `onError`, `onAbort`, `onTimeout` accordingly. For example; If you want to use the `loadend` event in your app, you can use like; 72 | 73 | ```javascript 74 | import Component from '@glimmer/component'; 75 | import { action } from '@ember/object'; 76 | import { tracked } from '@glimmer/tracking'; 77 | import { inject as service } from '@ember/service'; 78 | 79 | export default class UploadComponent extends Component { 80 | @service 81 | activeStorage; 82 | 83 | @tracked 84 | uploadProgress = 0; 85 | 86 | @action 87 | upload(event) { 88 | const files = event.target.files; 89 | if (files) { 90 | const directUploadURL = '/rails/active_storage/direct_uploads'; 91 | 92 | for (var i = 0; i < files.length; i++) { 93 | this.activeStorage 94 | .upload(files.item(i), directUploadURL, { 95 | onProgress: (progress, event) => { 96 | this.uploadProgress = progress; 97 | }, 98 | onLoadend: (event) => { 99 | debug(`Event captured ${event}`); // https://developer.mozilla.org/en-US/docs/Web/API/ProgressEvent 100 | }, 101 | }) 102 | .then((blob) => { 103 | const signedId = blob.signedId; 104 | 105 | this.model.avatar = signedId; 106 | }); 107 | } 108 | } 109 | } 110 | } 111 | ``` 112 | 113 | 114 | ### XHR object 115 | 116 | If you need the actual `XHR object` in your app, you can use the `onXHROpened` event. It returns the `XHR object` reference. For example: 117 | 118 | ```javascript 119 | import Component from '@glimmer/component'; 120 | import { action } from '@ember/object'; 121 | import { tracked } from '@glimmer/tracking'; 122 | import { inject as service } from '@ember/service'; 123 | 124 | export default class UploadComponent extends Component { 125 | @service 126 | activeStorage; 127 | 128 | @tracked 129 | uploadProgress = 0; 130 | 131 | @tracked 132 | xhrs = []; 133 | 134 | @action 135 | upload(event) { 136 | const files = event.target.files; 137 | if (files) { 138 | const directUploadURL = '/rails/active_storage/direct_uploads'; 139 | 140 | for (var i = 0; i < files.length; i++) { 141 | this.activeStorage 142 | .upload(files.item(i), directUploadURL, { 143 | onProgress: (progress, event) => { 144 | this.uploadProgress = progress; 145 | }, 146 | onXHROpened: (xhr) => { 147 | this.xhrs.push(xhr); // so you can loop over this.xhrs and invoke abort() 148 | }, 149 | }) 150 | .then((blob) => { 151 | const signedId = blob.signedId; 152 | 153 | this.model.avatar = signedId; 154 | }); 155 | } 156 | } 157 | } 158 | } 159 | ``` 160 | 161 | 162 | ### Metadata 163 | 164 | ActiveStorage supports metadata for direct uploads. That is a nice way to provide extra information to the rails app. 165 | 166 | ```ruby 167 | class DirectUploadsController < ActiveStorage::DirectUploadsController 168 | def create 169 | # blob_args[:metadata]['additional_type'] 170 | # => my_type 171 | blob = ActiveStorage::Blob.create_before_direct_upload!(**blob_args) 172 | render json: direct_upload_json(blob) 173 | end 174 | end 175 | ``` 176 | 177 | ```javascript 178 | import Component from '@glimmer/component'; 179 | import { action } from '@ember/object'; 180 | import { inject as service } from '@ember/service'; 181 | 182 | export default class UploadComponent extends Component { 183 | @service 184 | activeStorage; 185 | 186 | @action 187 | upload(event) { 188 | const files = event.target.files; 189 | if (files) { 190 | const directUploadURL = '/rails/active_storage/direct_uploads'; 191 | 192 | for (var i = 0; i < files.length; i++) { 193 | this.activeStorage 194 | .upload(files.item(i), directUploadURL, { 195 | metadata: { 196 | additional_type: 'my_type' 197 | }, 198 | }) 199 | .then((blob) => { 200 | const signedId = blob.signedId; 201 | 202 | this.model.avatar = signedId; 203 | }); 204 | } 205 | } 206 | } 207 | } 208 | ``` 209 | 210 | 211 | ### Configuration 212 | 213 | There is an `ember-active-storage` ENV config with only one parameter called `url`. With this config help, you can omit the upload url now. For example: 214 | 215 | ```javascript 216 | ENV['ember-active-storage'] = { 217 | url: 'http://your-domain/rails/active_storage/direct_uploads', 218 | }; 219 | ``` 220 | 221 | Now you can call the upload function without the upload url. 222 | 223 | ```javascript 224 | import Component from '@glimmer/component'; 225 | import { action } from '@ember/object'; 226 | import { tracked } from '@glimmer/tracking'; 227 | import { inject as service } from '@ember/service'; 228 | 229 | export default class UploadComponent extends Component { 230 | @service 231 | activeStorage; 232 | 233 | @tracked 234 | uploadProgress = 0; 235 | 236 | @action 237 | upload(event) { 238 | const files = event.target.files; 239 | if (files) { 240 | for (var i = 0; i < files.length; i++) { 241 | this.activeStorage 242 | .upload(files.item(i), { 243 | onProgress: (progress, event) => { 244 | this.uploadProgress = progress; 245 | }, 246 | }) 247 | .then((blob) => { 248 | const signedId = blob.signedId; 249 | 250 | this.model.avatar = signedId; 251 | }); 252 | } 253 | } 254 | } 255 | } 256 | ``` 257 | 258 | ### Sending authentication headers 259 | 260 | It's pretty common that you want to protect with authentication the direct uploads endpoint on your Rails backend. If that's the case, the `activeStorage` service will need to send authentication headers together with the direct upload request. 261 | 262 | To achieve that, you'll need to extend the `activeStorage` service provided by the addon and add a `headers` computed property. For example, if you're using [ember-simple-auth](/simplabs/ember-simple-auth), it will be a 2-steps process. First you'll need to define an `authenticatedHeaders` computed property in your `session` service, like this: 263 | 264 | ```javascript 265 | // app/services/session.js 266 | import Service from '@ember/service'; 267 | import { inject as service } from '@ember/service'; 268 | 269 | export default class MySessionService extends Service { 270 | @service 271 | session; 272 | 273 | get authenticatedHeaders() { 274 | const { access_token } = this.session.authenticated; 275 | 276 | return { Authorization: `Bearer ${access_token}` }; 277 | } 278 | } 279 | ``` 280 | 281 | Then, you will alias that property in your `activeStorage` service, like this: 282 | 283 | ```javascript 284 | // app/services/active-storage.js 285 | import ActiveStorage from '@algonauti/ember-active-storage/services/active-storage'; 286 | import { inject as service } from '@ember/service'; 287 | 288 | export default class ActiveStorageService extends ActiveStorage { 289 | @service('my-session') 290 | session; 291 | 292 | get headers() { 293 | this.session.authenticatedHeaders; 294 | } 295 | } 296 | ``` 297 | 298 | Also note: if the download endpoint is protected as well, and you're using an ajax request to download files, then don't forget to include the same headers in that request as well. 299 | 300 | ## Contributing 301 | 302 | ### Installation 303 | 304 | - `git clone ` 305 | - `cd ember-active-storage` 306 | - `yarn install` 307 | 308 | ### Linting 309 | 310 | - `yarn lint:js` 311 | - `yarn lint:js --fix` 312 | 313 | ### Running tests 314 | 315 | - `ember test` – Runs the test suite on the current Ember version 316 | - `ember test --server` – Runs the test suite in "watch mode" 317 | - `yarn test` – Runs `ember try:each` to test your addon against multiple Ember versions 318 | 319 | ### Running the dummy application 320 | 321 | - `ember serve` 322 | - Visit the dummy application at [http://localhost:4200](http://localhost:4200). 323 | 324 | For more information on using ember-cli, visit [https://ember-cli.com/](https://ember-cli.com/). 325 | 326 | ## License 327 | 328 | This project is licensed under the [MIT License](LICENSE.md). 329 | -------------------------------------------------------------------------------- /addon/-private/request.js: -------------------------------------------------------------------------------- 1 | import { isPresent } from '@ember/utils'; 2 | import { Promise } from 'rsvp'; 3 | 4 | export default function (xhr, url, options) { 5 | return new Promise((resolve, reject) => { 6 | xhr.open(options.method || 'GET', url); 7 | 8 | if (options.headers) { 9 | Object.keys(options.headers).forEach((key) => { 10 | xhr.setRequestHeader(key, options.headers[key]); 11 | }); 12 | } 13 | 14 | if (options.contentType) { 15 | xhr.setRequestHeader('Content-Type', options.contentType); 16 | } 17 | 18 | xhr.onload = () => { 19 | if (xhr.status >= 200 && xhr.status < 300) { 20 | let response; 21 | if (options.dataType === 'text') { 22 | response = xhr.responseText; 23 | } else if (isPresent(xhr.responseText)) { 24 | response = JSON.parse(xhr.responseText); 25 | } 26 | resolve(response); 27 | } else { 28 | reject(xhr.statusText); 29 | } 30 | }; 31 | 32 | xhr.onabort = () => reject(xhr.statusText); 33 | xhr.onerror = () => reject(xhr.statusText); 34 | 35 | xhr.send(options.data); 36 | 37 | xhr.dispatchEvent(new CustomEvent('XHROpened', { detail: xhr })); 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /addon/-private/uploader.js: -------------------------------------------------------------------------------- 1 | import { capitalize } from '@ember/string'; 2 | import request from '@algonauti/ember-active-storage/-private/request'; 3 | import { run } from '@ember/runloop'; 4 | import { setProperties } from '@ember/object'; 5 | 6 | export default class Uploader { 7 | constructor({ headers, metadata, ...events }) { 8 | this.headers = headers; 9 | this.metadata = metadata; 10 | this.events = events; 11 | } 12 | 13 | upload(blob, url, resolve, reject) { 14 | this._uploadTask(blob, url) 15 | .then((blob) => resolve(blob)) 16 | .catch((error) => reject(error)); 17 | } 18 | 19 | async _uploadTask(blob, url) { 20 | const response = await this._directUpload(blob, url); 21 | this._blobUpdate(blob, response); 22 | await this._blobUpload(blob); 23 | 24 | return blob; 25 | } 26 | 27 | _directUpload(blob, url) { 28 | const xhr = new XMLHttpRequest(); 29 | this._addCreatedListener(xhr); 30 | 31 | return request(xhr, url, { 32 | method: 'POST', 33 | headers: this.headers, 34 | contentType: 'application/json; charset=utf-8', 35 | data: JSON.stringify({ 36 | blob: { 37 | filename: blob.name, 38 | content_type: blob.type, 39 | byte_size: blob.size, 40 | checksum: blob.checksum, 41 | metadata: this.metadata, 42 | }, 43 | }), 44 | }); 45 | } 46 | 47 | _blobUpdate(blob, response) { 48 | setProperties(blob, { 49 | id: response.id, 50 | signedId: response.signed_id, 51 | key: response.key, 52 | directUploadData: response.direct_upload, 53 | }); 54 | } 55 | 56 | _blobUpload(blob) { 57 | const xhr = new XMLHttpRequest(); 58 | this._addListeners(xhr); 59 | this._addCreatedListener(xhr); 60 | xhr.upload.addEventListener('progress', (event) => { 61 | this._uploadRequestDidProgress(event); 62 | }); 63 | 64 | return request(xhr, blob.directUploadData.url, { 65 | method: 'PUT', 66 | headers: blob.directUploadData.headers, 67 | dataType: 'text', 68 | data: blob.slice(), 69 | }); 70 | } 71 | 72 | _addListeners(xhr) { 73 | ['loadstart', 'load', 'loadend', 'error', 'abort', 'timeout'].forEach( 74 | (name) => { 75 | xhr.addEventListener(name, (event) => { 76 | this._handleEvent(event); 77 | }); 78 | } 79 | ); 80 | } 81 | 82 | _addCreatedListener(xhr) { 83 | xhr.addEventListener('XHROpened', ({ detail }) => { 84 | this.events['onXHROpened']?.(detail); 85 | }); 86 | } 87 | 88 | _uploadRequestDidProgress(event) { 89 | const progress = Math.ceil((event.loaded / event.total) * 100); 90 | 91 | if (progress) { 92 | run(() => this.events.onProgress?.(progress, event)); 93 | } 94 | } 95 | 96 | _handleEvent(e) { 97 | this.events[`on${capitalize(e.type)}`]?.(e); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /addon/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algonauti/ember-active-storage/52ebc9f204eb10ce63ad6b3bdf8efb32fb91a7d9/addon/.gitkeep -------------------------------------------------------------------------------- /addon/model/blob.js: -------------------------------------------------------------------------------- 1 | import { Promise as EmberPromise } from 'rsvp'; 2 | import FileChecksum from '@algonauti/ember-active-storage/utils/file-checksum'; 3 | import { tracked } from '@glimmer/tracking'; 4 | 5 | export default class Blob { 6 | // Default Values 7 | @tracked file = null; 8 | checksum = null; 9 | id = null; 10 | signedId = null; 11 | key = null; 12 | directUploadData = null; 13 | 14 | constructor(file, checksum) { 15 | this.file = file; 16 | this.checksum = checksum; 17 | } 18 | 19 | // Getters 20 | get name() { 21 | return this.file.name; 22 | } 23 | 24 | get type() { 25 | return this.file.type; 26 | } 27 | 28 | get size() { 29 | return this.file.size; 30 | } 31 | 32 | toString() { 33 | return `Blob: ${this.name} with checksum ${this.checksum}`; 34 | } 35 | 36 | slice() { 37 | return this.file.slice(); 38 | } 39 | 40 | static build(file) { 41 | return new EmberPromise((resolve, reject) => { 42 | FileChecksum.MD5(file).then( 43 | (checksum) => { 44 | const blob = new Blob(file, checksum); 45 | resolve(blob); 46 | }, 47 | (error) => { 48 | reject(error); 49 | } 50 | ); 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /addon/services/active-storage.js: -------------------------------------------------------------------------------- 1 | import { isPresent, typeOf } from '@ember/utils'; 2 | 3 | import Blob from '@algonauti/ember-active-storage/model/blob'; 4 | import { Promise as EmberPromise } from 'rsvp'; 5 | import Service from '@ember/service'; 6 | import Uploader from '@algonauti/ember-active-storage/-private/uploader'; 7 | import { assert } from '@ember/debug'; 8 | import { getOwner } from '@ember/application'; 9 | 10 | export default class ActiveStorageService extends Service { 11 | get _config() { 12 | const config = 13 | getOwner(this).resolveRegistration('config:environment') || {}; 14 | 15 | return config['ember-active-storage'] || {}; 16 | } 17 | 18 | upload(file, urlOrOptions, options = {}) { 19 | let url; 20 | 21 | if (isPresent(urlOrOptions)) { 22 | if (typeOf(urlOrOptions) == 'string') { 23 | url = urlOrOptions; 24 | } else if (typeOf(urlOrOptions) == 'object') { 25 | assert( 26 | "If not explicitly passed, URL must be set on ENV['ember-active-storage'] = { url: '...' }", 27 | isPresent(this._config['url']) 28 | ); 29 | 30 | options = urlOrOptions; 31 | url = this._config['url']; 32 | } 33 | } else { 34 | assert( 35 | "If not explicitly passed, URL must be set on ENV['ember-active-storage'] = { url: '...' }", 36 | isPresent(this._config['url']) 37 | ); 38 | 39 | url = this._config['url']; 40 | } 41 | 42 | let { metadata, ...callbacks } = options; 43 | 44 | const uploader = new Uploader({ 45 | headers: this.headers, 46 | metadata: metadata, 47 | ...callbacks 48 | }); 49 | 50 | return new EmberPromise((resolve, reject) => { 51 | Blob.build(file).then((blob) => { 52 | uploader.upload(blob, url, resolve, reject); 53 | }); 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /addon/utils/file-checksum.js: -------------------------------------------------------------------------------- 1 | import { Promise as EmberPromise } from 'rsvp'; 2 | 3 | export default class FileChecksum { 4 | constructor(file) { 5 | this.file = file; 6 | this.chunkSize = 2097152; // 2MB 7 | this.chunkCount = Math.ceil(this.file.size / this.chunkSize); 8 | this.chunkIndex = 0; 9 | this.fileSlice = 10 | File.prototype.slice || 11 | File.prototype.mozSlice || 12 | File.prototype.webkitSlice; 13 | } 14 | 15 | createMD5() { 16 | return new EmberPromise((resolve, reject) => { 17 | this.md5Buffer = new SparkMD5.ArrayBuffer(); 18 | this.fileReader = new FileReader(); 19 | 20 | this.fileReader.onload = (event) => { 21 | this.md5Buffer.append(event.target.result); 22 | 23 | if (!this.readNextChunk()) { 24 | const binaryDigest = this.md5Buffer.end(true); 25 | const base64digest = btoa(binaryDigest); 26 | resolve(base64digest); 27 | } 28 | }; 29 | 30 | this.fileReader.onerror = (error) => { 31 | reject(error); 32 | }; 33 | 34 | this.readNextChunk(); 35 | }); 36 | } 37 | 38 | readNextChunk() { 39 | if (this.chunkIndex < this.chunkCount) { 40 | const start = this.chunkIndex * this.chunkSize; 41 | const end = Math.min(start + this.chunkSize, this.file.size); 42 | const bytes = this.fileSlice.call(this.file, start, end); 43 | this.fileReader.readAsArrayBuffer(bytes); 44 | this.chunkIndex++; 45 | return true; 46 | } else { 47 | return false; 48 | } 49 | } 50 | 51 | static MD5(file) { 52 | return new FileChecksum(file).createMD5(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /addon/utils/tests.js: -------------------------------------------------------------------------------- 1 | function uploadResponse(requestBody, options = {}) { 2 | const id = options.id || 123; 3 | const key = options.key || 'cwUyfscVbcMNdo26Fkn9uHrW'; 4 | const signedId = 5 | options.signedId || 6 | 'eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBCdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--d4c77635d82e4b007598f79bc3f785854eac27b9'; 7 | const directUploadURL = 8 | options.directUploadURL || '/api/attachments/direct-upload'; 9 | 10 | const blob = JSON.parse(requestBody).blob; 11 | 12 | const headers = { 13 | 'Content-Type': 'application/json; charset=utf-8', 14 | }; 15 | 16 | const body = { 17 | id: id, 18 | key: key, 19 | filename: blob.filename, 20 | content_type: blob.content_type, 21 | metadata: {}, 22 | byte_size: blob.byte_size, 23 | checksum: blob.checksum, 24 | created_at: new Date().toISOString(), 25 | signed_id: signedId, 26 | direct_upload: { 27 | url: directUploadURL, 28 | headers: { 29 | 'Content-Type': blob.content_type, 30 | }, 31 | }, 32 | }; 33 | 34 | return { headers, body }; 35 | } 36 | 37 | export { uploadResponse }; 38 | -------------------------------------------------------------------------------- /app/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algonauti/ember-active-storage/52ebc9f204eb10ce63ad6b3bdf8efb32fb91a7d9/app/.gitkeep -------------------------------------------------------------------------------- /app/services/active-storage.js: -------------------------------------------------------------------------------- 1 | export { default } from '@algonauti/ember-active-storage/services/active-storage'; 2 | -------------------------------------------------------------------------------- /config/ember-try.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const getChannelURL = require('ember-source-channel-url'); 4 | 5 | module.exports = async function () { 6 | return { 7 | useYarn: true, 8 | scenarios: [ 9 | { 10 | name: 'ember-lts-3.16', 11 | npm: { 12 | devDependencies: { 13 | 'ember-source': '~3.16.0', 14 | }, 15 | }, 16 | }, 17 | { 18 | name: 'ember-lts-3.20', 19 | npm: { 20 | devDependencies: { 21 | 'ember-source': '~3.20.5', 22 | }, 23 | }, 24 | }, 25 | { 26 | name: 'ember-release', 27 | npm: { 28 | devDependencies: { 29 | 'ember-source': await getChannelURL('release'), 30 | }, 31 | }, 32 | }, 33 | { 34 | name: 'ember-beta', 35 | npm: { 36 | devDependencies: { 37 | 'ember-source': await getChannelURL('beta'), 38 | }, 39 | }, 40 | }, 41 | { 42 | name: 'ember-canary', 43 | npm: { 44 | devDependencies: { 45 | 'ember-source': await getChannelURL('canary'), 46 | }, 47 | }, 48 | }, 49 | { 50 | name: 'ember-default-with-jquery', 51 | env: { 52 | EMBER_OPTIONAL_FEATURES: JSON.stringify({ 53 | 'jquery-integration': true, 54 | }), 55 | }, 56 | npm: { 57 | devDependencies: { 58 | '@ember/jquery': '^1.1.0', 59 | }, 60 | }, 61 | }, 62 | { 63 | name: 'ember-classic', 64 | env: { 65 | EMBER_OPTIONAL_FEATURES: JSON.stringify({ 66 | 'application-template-wrapper': true, 67 | 'default-async-observers': false, 68 | 'template-only-glimmer-components': false, 69 | }), 70 | }, 71 | npm: { 72 | ember: { 73 | edition: 'classic', 74 | }, 75 | }, 76 | }, 77 | ], 78 | }; 79 | }; 80 | -------------------------------------------------------------------------------- /config/environment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function (/* environment, appConfig */) { 4 | return {}; 5 | }; 6 | -------------------------------------------------------------------------------- /ember-cli-build.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const EmberAddon = require('ember-cli/lib/broccoli/ember-addon'); 4 | 5 | module.exports = function (defaults) { 6 | let app = new EmberAddon(defaults, { 7 | // Add options here 8 | }); 9 | 10 | /* 11 | This build file specifies the options for the dummy test app of this 12 | addon, located in `/tests/dummy` 13 | This build file does *not* influence how the addon or the app using it 14 | behave. You most likely want to be modifying `./index.js` or app's build file 15 | */ 16 | 17 | const { maybeEmbroider } = require('@embroider/test-setup'); 18 | return maybeEmbroider(app, { 19 | skipBabel: [ 20 | { 21 | package: 'qunit', 22 | }, 23 | ], 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | name: require('./package').name, 5 | 6 | included() { 7 | this._super.included.apply(this, arguments); 8 | this.importDependencies(); 9 | }, 10 | 11 | importDependencies() { 12 | this.import( 13 | { 14 | development: 'node_modules/spark-md5/spark-md5.js', 15 | production: 'node_modules/spark-md5/spark-md5.min.js', 16 | }, 17 | { prepend: true } 18 | ); 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { "target": "es6", "experimentalDecorators": true }, 3 | "exclude": [ 4 | "node_modules", 5 | "bower_components", 6 | "tmp", 7 | "vendor", 8 | ".git", 9 | "dist" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@algonauti/ember-active-storage", 3 | "version": "2.2.1", 4 | "description": "Power your ember.js application with activestorage", 5 | "keywords": [ 6 | "ember-addon", 7 | "activestorage" 8 | ], 9 | "license": "MIT", 10 | "homepage": "https://github.com/algonauti/ember-active-storage", 11 | "directories": { 12 | "doc": "doc", 13 | "test": "tests" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/algonauti/ember-active-storage.git" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/algonauti/ember-active-storage/issues" 21 | }, 22 | "scripts": { 23 | "build": "ember build --environment=production", 24 | "lint": "npm-run-all --aggregate-output --continue-on-error --parallel 'lint:!(fix)'", 25 | "lint:fix": "npm-run-all --aggregate-output --continue-on-error --parallel lint:*:fix", 26 | "lint:hbs": "ember-template-lint .", 27 | "lint:hbs:fix": "ember-template-lint . --fix", 28 | "lint:js": "eslint . --cache", 29 | "lint:js:fix": "eslint . --fix", 30 | "start": "ember serve", 31 | "test": "npm-run-all lint test:*", 32 | "test:ember": "ember test", 33 | "test:ember-compatibility": "ember try:each" 34 | }, 35 | "dependencies": { 36 | "ember-cli-babel": "^7.26.6", 37 | "ember-cli-htmlbars": "^5.7.2", 38 | "spark-md5": "^3.0.2" 39 | }, 40 | "devDependencies": { 41 | "@ember/optional-features": "^2.0.0", 42 | "@ember/test-helpers": "^2.6.0", 43 | "@embroider/test-setup": "^0.48.1", 44 | "@glimmer/component": "^1.0.4", 45 | "@glimmer/tracking": "^1.0.4", 46 | "babel-eslint": "^10.1.0", 47 | "broccoli-asset-rev": "^3.0.0", 48 | "ember-auto-import": "^2.2.4", 49 | "ember-cli": "~3.28.4", 50 | "ember-cli-dependency-checker": "^3.2.0", 51 | "ember-cli-inject-live-reload": "^2.1.0", 52 | "ember-cli-mirage": "^2.2.0", 53 | "ember-cli-sri": "^2.1.1", 54 | "ember-cli-terser": "^4.0.2", 55 | "ember-disable-prototype-extensions": "^1.1.3", 56 | "ember-export-application-global": "^2.0.1", 57 | "ember-load-initializers": "^2.1.2", 58 | "ember-maybe-import-regenerator": "^0.1.6", 59 | "ember-page-title": "^6.2.2", 60 | "ember-qunit": "^5.1.5", 61 | "ember-resolver": "^8.0.3", 62 | "ember-source": "~3.28.8", 63 | "ember-source-channel-url": "^3.0.0", 64 | "ember-template-lint": "^3.14.0", 65 | "ember-try": "^1.4.0", 66 | "eslint": "^7.32.0", 67 | "eslint-config-prettier": "^8.3.0", 68 | "eslint-plugin-ember": "^10.5.8", 69 | "eslint-plugin-node": "^11.1.0", 70 | "eslint-plugin-prettier": "^3.4.1", 71 | "eslint-plugin-qunit": "^6.2.0", 72 | "loader.js": "^4.7.0", 73 | "npm-run-all": "^4.1.5", 74 | "prettier": "^2.5.1", 75 | "qunit": "^2.17.2", 76 | "qunit-dom": "^1.6.0", 77 | "webpack": "^5.65.0" 78 | }, 79 | "engines": { 80 | "node": "12.* || 14.* || >= 16" 81 | }, 82 | "ember": { 83 | "edition": "octane" 84 | }, 85 | "ember-addon": { 86 | "configPath": "tests/dummy/config" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /testem.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | test_page: 'tests/index.html?hidepassed', 5 | disable_watching: true, 6 | launch_in_ci: ['Chrome'], 7 | launch_in_dev: ['Chrome'], 8 | browser_start_timeout: 120, 9 | browser_args: { 10 | Chrome: { 11 | ci: [ 12 | // --no-sandbox is needed when running Chrome inside a container 13 | process.env.CI ? '--no-sandbox' : null, 14 | '--headless', 15 | '--disable-dev-shm-usage', 16 | '--disable-software-rasterizer', 17 | '--mute-audio', 18 | '--remote-debugging-port=0', 19 | '--window-size=1440,900', 20 | ].filter(Boolean), 21 | }, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /tests/dummy/app/app.js: -------------------------------------------------------------------------------- 1 | import Application from '@ember/application'; 2 | import Resolver from 'ember-resolver'; 3 | import loadInitializers from 'ember-load-initializers'; 4 | import config from 'dummy/config/environment'; 5 | 6 | export default class App extends Application { 7 | modulePrefix = config.modulePrefix; 8 | podModulePrefix = config.podModulePrefix; 9 | Resolver = Resolver; 10 | } 11 | 12 | loadInitializers(App, config.modulePrefix); 13 | -------------------------------------------------------------------------------- /tests/dummy/app/components/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algonauti/ember-active-storage/52ebc9f204eb10ce63ad6b3bdf8efb32fb91a7d9/tests/dummy/app/components/.gitkeep -------------------------------------------------------------------------------- /tests/dummy/app/components/file-upload.hbs: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 |
12 |
13 | 14 |
15 |
16 | 17 | 20 | {{this.fileName}} 21 | 22 | -------------------------------------------------------------------------------- /tests/dummy/app/components/file-upload.js: -------------------------------------------------------------------------------- 1 | import Component from '@glimmer/component'; 2 | import { action } from '@ember/object'; 3 | import { debug } from '@ember/debug'; 4 | import { isPresent } from '@ember/utils'; 5 | import { tracked } from '@glimmer/tracking'; 6 | import { inject as service } from '@ember/service'; 7 | 8 | export default class FileUploadComponent extends Component { 9 | @service 10 | activeStorage; 11 | 12 | @tracked fileName = 'No files chosen'; 13 | 14 | @action 15 | setFileNameAndUpload(e) { 16 | this.setFileName(e); 17 | this.upload(e); 18 | } 19 | 20 | @action 21 | upload(e) { 22 | const files = e.target.files; 23 | const progressBarFill = document.querySelector('.progress-bar-fill'); 24 | progressBarFill.style = `width: 0%`; 25 | 26 | if (isPresent(files)) { 27 | this.activeStorage 28 | .upload(files[0], '/api/attachments/upload', { 29 | onProgress: (progress) => { 30 | progressBarFill.style = `width: ${progress}%`; 31 | }, 32 | onXHROpened: (xhr) => { 33 | debug(`XHR created ${xhr}`); 34 | }, 35 | }) 36 | .then((blob) => { 37 | progressBarFill.style = `width: 100%`; 38 | debug(`file upload completed ${blob.signedId}`); 39 | }); 40 | } 41 | } 42 | 43 | @action 44 | setFileName(e) { 45 | const fileName = e.target.value.split('\\').pop(); 46 | if (fileName) this.fileName = fileName; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/dummy/app/controllers/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algonauti/ember-active-storage/52ebc9f204eb10ce63ad6b3bdf8efb32fb91a7d9/tests/dummy/app/controllers/.gitkeep -------------------------------------------------------------------------------- /tests/dummy/app/helpers/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algonauti/ember-active-storage/52ebc9f204eb10ce63ad6b3bdf8efb32fb91a7d9/tests/dummy/app/helpers/.gitkeep -------------------------------------------------------------------------------- /tests/dummy/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dummy 7 | 8 | 9 | 10 | {{content-for "head"}} 11 | 12 | 13 | 14 | 15 | {{content-for "head-footer"}} 16 | 17 | 18 | {{content-for "body"}} 19 | 20 | 21 | 22 | 23 | {{content-for "body-footer"}} 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/dummy/app/models/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algonauti/ember-active-storage/52ebc9f204eb10ce63ad6b3bdf8efb32fb91a7d9/tests/dummy/app/models/.gitkeep -------------------------------------------------------------------------------- /tests/dummy/app/resolver.js: -------------------------------------------------------------------------------- 1 | import Resolver from 'ember-resolver'; 2 | 3 | export default Resolver; 4 | -------------------------------------------------------------------------------- /tests/dummy/app/router.js: -------------------------------------------------------------------------------- 1 | import EmberRouter from '@ember/routing/router'; 2 | import config from 'dummy/config/environment'; 3 | 4 | export default class Router extends EmberRouter { 5 | location = config.locationType; 6 | rootURL = config.rootURL; 7 | } 8 | 9 | Router.map(function () {}); 10 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algonauti/ember-active-storage/52ebc9f204eb10ce63ad6b3bdf8efb32fb91a7d9/tests/dummy/app/routes/.gitkeep -------------------------------------------------------------------------------- /tests/dummy/app/styles/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: blue; 3 | font-family: serif; 4 | font-size: 16px; 5 | line-height: 1.3; 6 | margin: 1rem 0; 7 | text-align: center; 8 | } 9 | 10 | .wrapper { 11 | background-color: #fff; 12 | border-radius: 1rem; 13 | margin: 40px auto 0 auto; 14 | max-width: 500px; 15 | padding: 2rem; 16 | width: 100%; 17 | } 18 | 19 | .footer { 20 | font-size: .8rem; 21 | margin-bottom: 0; 22 | margin-top: 3rem; 23 | } 24 | 25 | h1, 26 | p { 27 | margin-bottom: 2rem; 28 | } 29 | 30 | h1 { 31 | font-family: sans-serif; 32 | font-size: 1.7rem; 33 | } 34 | 35 | a { 36 | color: #31c1ef; 37 | text-decoration: none; 38 | } 39 | 40 | [type="file"] { 41 | height: 0; 42 | overflow: hidden; 43 | width: 0; 44 | } 45 | 46 | [type="file"] + label { 47 | background: #f15d22; 48 | border: none; 49 | border-radius: 5px; 50 | color: #fff; 51 | cursor: pointer; 52 | display: inline-block; 53 | font-family: 'Poppins', sans-serif; 54 | font-size: inherit; 55 | font-weight: 600; 56 | margin-bottom: 1rem; 57 | outline: none; 58 | padding: 1rem 50px; 59 | position: relative; 60 | transition: all 0.3s; 61 | vertical-align: middle; 62 | } 63 | 64 | [type="file"] + label:hover { 65 | background-color: #d3460d; 66 | } 67 | 68 | .progress-bar-wrapper { 69 | width: 500px; 70 | margin-top: 30px; 71 | } 72 | 73 | .progress-bar { 74 | width: 100%; 75 | background-color: #e0e0e0; 76 | padding: 3px; 77 | border-radius: 3px; 78 | box-shadow: inset 0 1px 3px rgba(0, 0, 0, .2); 79 | } 80 | 81 | .progress-bar-fill { 82 | width: 0; 83 | display: block; 84 | height: 22px; 85 | background-color: #000; 86 | border-radius: 3px; 87 | transition: width 500ms ease-in-out; 88 | } 89 | 90 | .filename { 91 | display: block; 92 | margin-top: 10px; 93 | } 94 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/application.hbs: -------------------------------------------------------------------------------- 1 | {{page-title "Dummy"}} 2 | 3 |
4 |

Upload your file!

5 | {{outlet}} 6 |
7 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/index.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/config/ember-cli-update.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": "1.0.0", 3 | "packages": [ 4 | { 5 | "name": "ember-cli", 6 | "version": "3.25.2", 7 | "blueprints": [ 8 | { 9 | "name": "addon", 10 | "outputRepo": "https://github.com/ember-cli/ember-addon-output", 11 | "codemodsSource": "ember-addon-codemods-manifest@1", 12 | "isBaseBlueprint": true, 13 | "options": [ 14 | "--yarn" 15 | ] 16 | } 17 | ] 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /tests/dummy/config/environment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function (environment) { 4 | let ENV = { 5 | modulePrefix: 'dummy', 6 | environment, 7 | rootURL: '/', 8 | locationType: 'auto', 9 | EmberENV: { 10 | FEATURES: { 11 | // Here you can enable experimental features on an ember canary build 12 | // e.g. EMBER_NATIVE_DECORATOR_SUPPORT: true 13 | }, 14 | EXTEND_PROTOTYPES: { 15 | // Prevent Ember Data from overriding Date.parse. 16 | Date: false, 17 | }, 18 | }, 19 | 20 | APP: { 21 | // Here you can pass flags/options to your application instance 22 | // when it is created 23 | }, 24 | }; 25 | 26 | if (environment === 'development') { 27 | // ENV.APP.LOG_RESOLVER = true; 28 | // ENV.APP.LOG_ACTIVE_GENERATION = true; 29 | // ENV.APP.LOG_TRANSITIONS = true; 30 | // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; 31 | // ENV.APP.LOG_VIEW_LOOKUPS = true; 32 | } 33 | 34 | if (environment === 'test') { 35 | // Testem prefers this... 36 | ENV.locationType = 'none'; 37 | 38 | // keep test console output quieter 39 | ENV.APP.LOG_ACTIVE_GENERATION = false; 40 | ENV.APP.LOG_VIEW_LOOKUPS = false; 41 | 42 | ENV.APP.rootElement = '#ember-testing'; 43 | ENV.APP.autoboot = false; 44 | 45 | ENV['ember-active-storage'] = { 46 | url: '/api/attachments/upload', 47 | }; 48 | } 49 | 50 | if (environment === 'production') { 51 | // here you can enable a production-specific feature 52 | } 53 | 54 | return ENV; 55 | }; 56 | -------------------------------------------------------------------------------- /tests/dummy/config/optional-features.json: -------------------------------------------------------------------------------- 1 | { 2 | "application-template-wrapper": false, 3 | "default-async-observers": true, 4 | "jquery-integration": false, 5 | "template-only-glimmer-components": true 6 | } 7 | -------------------------------------------------------------------------------- /tests/dummy/config/targets.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const browsers = [ 4 | 'last 1 Chrome versions', 5 | 'last 1 Firefox versions', 6 | 'last 1 Safari versions', 7 | ]; 8 | 9 | const isCI = Boolean(process.env.CI); 10 | const isProduction = process.env.EMBER_ENV === 'production'; 11 | 12 | if (isCI || isProduction) { 13 | browsers.push('ie 11'); 14 | } 15 | 16 | module.exports = { 17 | browsers, 18 | }; 19 | -------------------------------------------------------------------------------- /tests/dummy/mirage/config.js: -------------------------------------------------------------------------------- 1 | import Response from 'ember-cli-mirage/response'; 2 | import { uploadResponse } from '@algonauti/ember-active-storage/utils/tests'; 3 | 4 | export default function () { 5 | this.namespace = '/api'; 6 | 7 | this.post('/attachments/upload', (_, request) => { 8 | const response = uploadResponse(request.requestBody, { 9 | directUploadURL: '/api/attachments/direct-upload', 10 | }); 11 | return new Response(200, response.headers, response.body); 12 | }); 13 | 14 | this.put( 15 | '/attachments/direct-upload', 16 | () => { 17 | return new Response(204); 18 | }, 19 | { timing: 150 } 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /tests/dummy/mirage/scenarios/default.js: -------------------------------------------------------------------------------- 1 | export default function (/* server */) { 2 | /* 3 | Seed your development database using your factories. 4 | This data will not be loaded in your tests. 5 | */ 6 | // server.createList('post', 10); 7 | } 8 | -------------------------------------------------------------------------------- /tests/dummy/mirage/serializers/application.js: -------------------------------------------------------------------------------- 1 | import { Serializer } from 'ember-cli-mirage'; 2 | 3 | export default Serializer.extend({}); 4 | -------------------------------------------------------------------------------- /tests/dummy/public/robots.txt: -------------------------------------------------------------------------------- 1 | # http://www.robotstxt.org 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /tests/dummy/public/sample.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algonauti/ember-active-storage/52ebc9f204eb10ce63ad6b3bdf8efb32fb91a7d9/tests/dummy/public/sample.pdf -------------------------------------------------------------------------------- /tests/helpers/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algonauti/ember-active-storage/52ebc9f204eb10ce63ad6b3bdf8efb32fb91a7d9/tests/helpers/.gitkeep -------------------------------------------------------------------------------- /tests/helpers/fetch-file.js: -------------------------------------------------------------------------------- 1 | function _fetchFile(filePath) { 2 | return fetch(filePath) 3 | .then((response) => { 4 | return response.blob(); 5 | }) 6 | .then((blob) => { 7 | const fileName = filePath.substr(filePath.lastIndexOf('/') + 1); 8 | return new File([blob], fileName, { type: blob.type }); 9 | }); 10 | } 11 | 12 | export default async function fetchFile(filePath) { 13 | window.pretenderFetch = window.fetch; 14 | window.fetch = window.server.pretender._nativefetch; 15 | var res = await _fetchFile(filePath); 16 | window.fetch = window.pretenderFetch; 17 | return res; 18 | } 19 | -------------------------------------------------------------------------------- /tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dummy Tests 7 | 8 | 9 | 10 | {{content-for "head"}} 11 | {{content-for "test-head"}} 12 | 13 | 14 | 15 | 16 | 17 | {{content-for "head-footer"}} 18 | {{content-for "test-head-footer"}} 19 | 20 | 21 | {{content-for "body"}} 22 | {{content-for "test-body"}} 23 | 24 |
25 |
26 |
27 |
28 |
29 |
30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | {{content-for "body-footer"}} 38 | {{content-for "test-body-footer"}} 39 | 40 | 41 | -------------------------------------------------------------------------------- /tests/integration/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algonauti/ember-active-storage/52ebc9f204eb10ce63ad6b3bdf8efb32fb91a7d9/tests/integration/.gitkeep -------------------------------------------------------------------------------- /tests/test-helper.js: -------------------------------------------------------------------------------- 1 | import Application from 'dummy/app'; 2 | import config from 'dummy/config/environment'; 3 | import * as QUnit from 'qunit'; 4 | import { setApplication } from '@ember/test-helpers'; 5 | import { setup } from 'qunit-dom'; 6 | import { start } from 'ember-qunit'; 7 | 8 | setApplication(Application.create(config.APP)); 9 | 10 | setup(QUnit.assert); 11 | 12 | start(); 13 | -------------------------------------------------------------------------------- /tests/unit/-private/uploader-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | 3 | import Blob from '@algonauti/ember-active-storage/model/blob'; 4 | import Uploader from '@algonauti/ember-active-storage/-private/uploader'; 5 | import { set } from '@ember/object'; 6 | import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; 7 | import { setupTest } from 'ember-qunit'; 8 | 9 | module('Unit | -Private | uploader', function (hooks) { 10 | setupTest(hooks); 11 | setupMirage(hooks); 12 | 13 | let file, uploader, blob; 14 | 15 | hooks.beforeEach(async function () { 16 | file = new File(['foo'], 'foo.txt', { type: 'text/plain' }); 17 | uploader = new Uploader({}); 18 | blob = await Blob.build(file); 19 | }); 20 | 21 | test('_directUpload() sets correct attributes into POST request body', async function (assert) { 22 | let expectedAttributes = { 23 | blob: { 24 | byte_size: blob.size, 25 | checksum: blob.checksum, 26 | content_type: blob.type, 27 | filename: blob.name, 28 | }, 29 | }; 30 | let attributes; 31 | this.server.post('/attachments/upload', function (_db, request) { 32 | attributes = JSON.parse(request.requestBody); 33 | }); 34 | 35 | await uploader._directUpload(blob, '/api/attachments/upload'); 36 | assert.deepEqual(attributes, expectedAttributes); 37 | }); 38 | 39 | test('_blobUpdate() sets properties into blob from server response', function (assert) { 40 | const response = { 41 | id: 123, 42 | key: 'cwUyfscVbcMNdo26Fkn9uHrW', 43 | filename: blob.filename, 44 | content_type: blob.content_type, 45 | byte_size: blob.byte_size, 46 | checksum: blob.checksum, 47 | signed_id: 48 | 'eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBCdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--d4c77635d82e4b007598f79bc3f785854eac27b9', 49 | direct_upload: { 50 | url: '/api/attachments/direct-upload', 51 | headers: { 52 | 'Content-Type': blob.content_type, 53 | }, 54 | }, 55 | }; 56 | 57 | uploader._blobUpdate(blob, response); 58 | 59 | assert.equal(blob.id, response.id); 60 | assert.equal(blob.signedId, response.signed_id); 61 | assert.equal(blob.key, response.key); 62 | assert.deepEqual(blob.directUploadData, response.direct_upload); 63 | }); 64 | 65 | test('_blobUpload() sets correct attributes into PUT request body', async function (assert) { 66 | let expectedAttributes = blob.slice(); 67 | let attributes; 68 | this.server.put('/attachments/direct-upload', function (_db, request) { 69 | attributes = request.requestBody; 70 | }); 71 | set(blob, 'directUploadData', { 72 | url: '/api/attachments/direct-upload', 73 | headers: { 74 | 'Content-Type': blob.content_type, 75 | }, 76 | }); 77 | 78 | await uploader._blobUpload(blob); 79 | assert.deepEqual(attributes, expectedAttributes); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /tests/unit/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algonauti/ember-active-storage/52ebc9f204eb10ce63ad6b3bdf8efb32fb91a7d9/tests/unit/.gitkeep -------------------------------------------------------------------------------- /tests/unit/models/blob-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | 3 | import Blob from '@algonauti/ember-active-storage/model/blob'; 4 | import FileChecksum from '@algonauti/ember-active-storage/utils/file-checksum'; 5 | import { setupTest } from 'ember-qunit'; 6 | 7 | module('Unit | Model | blob', function (hooks) { 8 | setupTest(hooks); 9 | 10 | const file = new File(['foo'], 'foo.txt', { type: 'text/plain' }); 11 | const fileChecksum = new FileChecksum(file); 12 | 13 | test('is generated correctly', async function (assert) { 14 | const checksum = await fileChecksum.createMD5(); 15 | const blob = await Blob.build(file); 16 | assert.equal(blob.checksum, checksum); 17 | assert.equal(blob.file, file); 18 | assert.equal(blob.name, 'foo.txt'); 19 | assert.equal(blob.type, 'text/plain'); 20 | assert.equal(blob.size, 3); 21 | const blobToString = blob.toString(); 22 | assert.equal( 23 | blobToString, 24 | 'Blob: foo.txt with checksum rL0Y20zC+Fzt72VPzMSk2A==' 25 | ); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /tests/unit/services/active-storage-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | 3 | import fetchFile from 'dummy/tests/helpers/fetch-file'; 4 | import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; 5 | import { setupTest } from 'ember-qunit'; 6 | 7 | module('Unit | Service | active-storage', function (hooks) { 8 | setupTest(hooks); 9 | setupMirage(hooks); 10 | 11 | let service, file; 12 | 13 | hooks.beforeEach(async function () { 14 | service = this.owner.lookup('service:active-storage'); 15 | file = await fetchFile('/sample.pdf'); 16 | }); 17 | 18 | test('upload() returns blob model', async function (assert) { 19 | let blob = await service.upload(file, '/api/attachments/upload'); 20 | assert.equal(blob.name, 'sample.pdf'); 21 | assert.equal(blob.type, 'application/pdf'); 22 | assert.equal(blob.size, 6081); 23 | assert.equal(blob.id, 123); 24 | assert.ok(blob.signedId); 25 | assert.ok(blob.key); 26 | assert.equal(blob.directUploadData.url, '/api/attachments/direct-upload'); 27 | assert.equal( 28 | blob.directUploadData.headers['Content-Type'], 29 | 'application/pdf' 30 | ); 31 | }); 32 | 33 | test('upload() returns blob model without upload url', async function (assert) { 34 | let blob = await service.upload(file); 35 | assert.equal(blob.name, 'sample.pdf'); 36 | assert.equal(blob.type, 'application/pdf'); 37 | assert.equal(blob.size, 6081); 38 | assert.equal(blob.id, 123); 39 | assert.ok(blob.signedId); 40 | assert.ok(blob.key); 41 | assert.equal(blob.directUploadData.url, '/api/attachments/direct-upload'); 42 | assert.equal( 43 | blob.directUploadData.headers['Content-Type'], 44 | 'application/pdf' 45 | ); 46 | }); 47 | 48 | test('upload() invokes onLoadstart callback', async function (assert) { 49 | assert.expect(1); 50 | await service.upload(file, '/api/attachments/upload', { 51 | onLoadstart: (event) => { 52 | assert.strictEqual(event.type, 'loadstart'); 53 | }, 54 | }); 55 | }); 56 | 57 | test('upload() invokes onLoad callback', async function (assert) { 58 | assert.expect(1); 59 | await service.upload(file, '/api/attachments/upload', { 60 | onLoad: (event) => { 61 | assert.strictEqual(event.type, 'load'); 62 | }, 63 | }); 64 | }); 65 | 66 | test('upload() invokes onLoadend callback', async function (assert) { 67 | assert.expect(1); 68 | await service.upload(file, '/api/attachments/upload', { 69 | onLoadend: (event) => { 70 | assert.strictEqual(event.type, 'loadend'); 71 | }, 72 | }); 73 | }); 74 | 75 | /* eslint-disable qunit/require-expect */ 76 | test('upload() invokes onProgress callback', async function (assert) { 77 | let n = 0; 78 | await service.upload(file, '/api/attachments/upload', { 79 | onProgress: (progress) => { 80 | n++; 81 | assert.ok(progress > 0); 82 | assert.ok(progress <= 100); 83 | }, 84 | }); 85 | assert.ok(n > 0); 86 | }); 87 | /* eslint-enable qunit/require-expect */ 88 | 89 | test('upload() invokes onLoadstart callback without upload url', async function (assert) { 90 | assert.expect(1); 91 | await service.upload(file, { 92 | onLoadstart: (event) => { 93 | assert.strictEqual(event.type, 'loadstart'); 94 | }, 95 | }); 96 | }); 97 | 98 | test('upload() invokes onLoad callback without upload url', async function (assert) { 99 | assert.expect(1); 100 | await service.upload(file, { 101 | onLoad: (event) => { 102 | assert.strictEqual(event.type, 'load'); 103 | }, 104 | }); 105 | }); 106 | 107 | test('upload() invokes onLoadend callback without upload url', async function (assert) { 108 | assert.expect(1); 109 | await service.upload(file, { 110 | onLoadend: (event) => { 111 | assert.strictEqual(event.type, 'loadend'); 112 | }, 113 | }); 114 | }); 115 | 116 | /* eslint-disable qunit/require-expect */ 117 | test('upload() invokes onProgress callback without upload url', async function (assert) { 118 | let n = 0; 119 | await service.upload(file, { 120 | onProgress: (progress) => { 121 | n++; 122 | assert.ok(progress > 0); 123 | assert.ok(progress <= 100); 124 | }, 125 | }); 126 | assert.ok(n > 0); 127 | }); 128 | /* eslint-enable qunit/require-expect */ 129 | }); 130 | -------------------------------------------------------------------------------- /tests/unit/utils/file-checksum-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | 3 | import FileChecksum from '@algonauti/ember-active-storage/utils/file-checksum'; 4 | 5 | module('Unit | Utility | file-checksum', function () { 6 | const file = new File(['foo'], 'foo.txt', { type: 'text/plain' }); 7 | 8 | test('initializes correctly', function (assert) { 9 | const fileChecksum = new FileChecksum(file); 10 | assert.ok(fileChecksum); 11 | }); 12 | 13 | test('is generated correctly', async function (assert) { 14 | const fileChecksum = new FileChecksum(file); 15 | const checksum = await fileChecksum.createMD5(); 16 | const decodedChecksum = atob(checksum); 17 | assert.equal(checksum.length, 24); 18 | assert.equal(checksum, 'rL0Y20zC+Fzt72VPzMSk2A=='); 19 | assert.equal(typeof decodedChecksum, 'string'); 20 | assert.equal(decodedChecksum.indexOf('\uFFFD'), -1); 21 | assert.equal(decodedChecksum.length, 16); 22 | }); 23 | 24 | test('is encoded correctly', async function (assert) { 25 | const fileChecksum = new FileChecksum(file); 26 | const checksum = await fileChecksum.createMD5(); 27 | const base64Pattern = RegExp('^[a-zA-Z0-9+/]*={0,2}$'); 28 | assert.equal(checksum.length % 4, 0); 29 | assert.ok(base64Pattern.test(checksum)); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /vendor/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/algonauti/ember-active-storage/52ebc9f204eb10ce63ad6b3bdf8efb32fb91a7d9/vendor/.gitkeep --------------------------------------------------------------------------------