diff --git a/.eslintrc b/.eslintrc index f95d0673..10c91b22 100644 --- a/.eslintrc +++ b/.eslintrc @@ -9,6 +9,7 @@ "browser": false }, "globals": { + "Meteor": true, "Package": true, "Tinytest": true, "Map": true, @@ -18,6 +19,10 @@ "Worker": true, "navigator": true }, + "ignorePatterns": [ + "worker.*", + "node_modules" + ], "rules": { "strict": [2, "never"], "no-shadow": 2, @@ -126,4 +131,4 @@ }], "space-infix-ops": 2, } -} +} \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..6c0fc3db --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,27 @@ +name: Lint + +on: [push, pull_request] + +jobs: + lintcode: + name: lint + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: cache dependencies + uses: actions/cache@v3 + with: + path: ~/.npm + key: ${{ runner.os }}-node-22-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node-22- + + - run: npm install + - run: npm run lint \ No newline at end of file diff --git a/.github/workflows/testsuite.yml b/.github/workflows/testsuite.yml new file mode 100644 index 00000000..e9cf2579 --- /dev/null +++ b/.github/workflows/testsuite.yml @@ -0,0 +1,37 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: Test suite + +on: [push, pull_request] + +jobs: + tests: + name: Meteor package tests + runs-on: ubuntu-latest + # needs: [lintcode,lintstyle,lintdocs] # we could add prior jobs for linting, if desired + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: cache dependencies + uses: actions/cache@v3 + with: + path: ~/.npm + key: ${{ runner.os }}-node-22-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node-22- + + - name: Setup meteor + uses: meteorengineer/setup-meteor@v2 + with: + meteor-release: '3.1.2' + + - run: | + meteor npm install + meteor npm run test:mocha diff --git a/.npm/package/npm-shrinkwrap.json b/.npm/package/npm-shrinkwrap.json new file mode 100644 index 00000000..48582836 --- /dev/null +++ b/.npm/package/npm-shrinkwrap.json @@ -0,0 +1,139 @@ +{ + "lockfileVersion": 4, + "dependencies": { + "@sinonjs/commons": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", + "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "dependencies": { + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==" + } + } + }, + "@sinonjs/formatio": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-3.2.2.tgz", + "integrity": "sha512-B8SEsgd8gArBLMD6zpRw3juQ2FVSsmdd7qlevyDqzS9WTCtvF55/gAL+h6gue8ZvPYcdiPdvueM/qm//9XzyTQ==" + }, + "@sinonjs/samsam": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-3.3.3.tgz", + "integrity": "sha512-bKCMKZvWIjYD0BLGnNrxVuw4dkWCYsLqFOUWw8VgKF/+5Y+mE7LfHWPIYoDXowH+3a9LsWDMo0uAP8YDosPvHQ==" + }, + "@sinonjs/text-encoding": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", + "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==" + }, + "array-from": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/array-from/-/array-from-2.1.1.tgz", + "integrity": "sha512-GQTc6Uupx1FCavi5mPzBvVT7nEOeWMmUA9P95wpfpW1XwMSKs+KaymD5C2Up7KAUKg/mYwbsUYzdZWcoajlNZg==" + }, + "assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==" + }, + "chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==" + }, + "check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==" + }, + "deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==" + }, + "diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==" + }, + "eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" + }, + "get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==" + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" + }, + "just-extend": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", + "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==" + }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "lolex": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-4.2.0.tgz", + "integrity": "sha512-gKO5uExCXvSm6zbF562EvM+rd1kQDnB9AZBbiQVzf1ZmdDpxUSvpnAaVOP83N/31mRK8Ml8/VE8DMvsAZQ+7wg==" + }, + "loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==" + }, + "nise": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/nise/-/nise-1.5.3.tgz", + "integrity": "sha512-Ymbac/94xeIrMf59REBPOv0thr+CJVFMhrlAkW/gjCIE58BGQdCj0x7KRCb3yz+Ga2Rz3E9XXSvUyyxqqhjQAQ==", + "dependencies": { + "lolex": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-5.1.2.tgz", + "integrity": "sha512-h4hmjAvHTmd+25JSwrtTIuwbKdwg5NzZVRMLn9saij4SZaepCrTCxPr35H/3bjwfMJtN+t3CX8672UIkglz28A==" + } + } + }, + "path-to-regexp": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", + "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==" + }, + "pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==" + }, + "sinon": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-7.5.0.tgz", + "integrity": "sha512-AoD0oJWerp0/rY9czP/D6hDTTUYGpObhZjMpd7Cl/A6+j0xBE+ayL/ldfggkBXUs0IkvIiM1ljM8+WkOc5k78Q==" + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==" + }, + "type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==" + } + } +} diff --git a/.versions b/.versions index 980bb57b..67dae411 100644 --- a/.versions +++ b/.versions @@ -1,51 +1,56 @@ -allow-deny@1.1.1 -babel-compiler@7.10.3 -babel-runtime@1.5.1 -base64@1.0.12 -binary-heap@1.0.11 -boilerplate-generator@1.7.1 -callback-hook@1.5.0 -check@1.3.2 -ddp@1.4.1 -ddp-client@2.6.1 -ddp-common@1.4.0 -ddp-server@2.6.0 -diff-sequence@1.1.2 -dynamic-import@0.7.2 -ecmascript@0.16.6 -ecmascript-runtime@0.8.0 -ecmascript-runtime-client@0.12.1 -ecmascript-runtime-server@0.11.0 -ejson@1.1.3 -fetch@0.1.3 -geojson-utils@1.0.11 -id-map@1.1.1 -inter-process-messaging@0.1.1 -local-test:ostrio:files@2.3.3 -logging@1.3.2 -meteor@1.11.1 -minimongo@1.9.2 -modern-browsers@0.1.9 -modules@0.19.0 -modules-runtime@0.13.1 -mongo@1.16.5 -mongo-decimal@0.1.3 -mongo-dev-server@1.1.0 -mongo-id@1.0.8 -npm-mongo@4.14.0 -ordered-dict@1.1.0 -ostrio:cookies@2.7.2 -ostrio:files@2.3.3 -promise@0.12.2 -random@1.2.1 -react-fast-refresh@0.2.6 -reactive-var@1.0.12 -reload@1.3.1 -retry@1.1.0 -routepolicy@1.1.1 -socket-stream-client@0.5.0 -tinytest@1.2.2 -tracker@1.3.1 -underscore@1.0.12 -webapp@1.13.4 -webapp-hashing@1.1.1 +allow-deny@2.1.0 +babel-compiler@7.12.2 +babel-runtime@1.5.2 +base64@1.0.13 +binary-heap@1.0.12 +boilerplate-generator@2.0.2 +callback-hook@1.6.1 +check@1.4.4 +core-runtime@1.0.0 +ddp@1.4.2 +ddp-client@3.1.1 +ddp-common@1.4.4 +ddp-server@3.1.2 +diff-sequence@1.1.3 +dynamic-import@0.7.4 +ecmascript@0.16.13 +ecmascript-runtime@0.8.3 +ecmascript-runtime-client@0.12.3 +ecmascript-runtime-server@0.11.1 +ejson@1.1.5 +facts-base@1.0.2 +fetch@0.1.6 +geojson-utils@1.0.12 +id-map@1.2.0 +inter-process-messaging@0.1.2 +local-test:ostrio:files@3.0.0 +logging@1.3.6 +meteor@2.1.1 +meteortesting:browser-tests@1.8.0 +meteortesting:mocha@3.3.0 +meteortesting:mocha-core@8.2.0 +minimongo@2.0.4 +modern-browsers@0.2.3 +modules@0.20.3 +modules-runtime@0.13.2 +mongo@2.1.4 +mongo-decimal@0.2.0 +mongo-dev-server@1.1.1 +mongo-id@1.0.9 +npm-mongo@6.16.1 +ordered-dict@1.2.0 +ostrio:cookies@2.9.1 +ostrio:files@3.0.0 +promise@1.0.0 +random@1.2.2 +react-fast-refresh@0.2.9 +reactive-var@1.0.13 +reload@1.3.2 +retry@1.1.1 +routepolicy@1.1.2 +socket-stream-client@0.6.1 +tinytest@1.3.2 +tracker@1.3.4 +typescript@5.6.6 +webapp@2.0.7 +webapp-hashing@1.1.2 diff --git a/README.md b/README.md index 7c03721a..a7b2b5ec 100644 --- a/README.md +++ b/README.md @@ -2,42 +2,39 @@ [![support](https://img.shields.io/badge/support-PayPal-white)](https://paypal.me/veliovgroup) [![Mentioned in Awesome ostrio:files](https://awesome.re/mentioned-badge.svg)](https://project-awesome.org/Urigo/awesome-meteor#files) [![GitHub stars](https://img.shields.io/github/stars/veliovgroup/Meteor-Files.svg)](https://github.com/veliovgroup/Meteor-Files/stargazers) - - - +ostr.io +meteor-files.com # Files for Meteor.js -Stable, fast, robust, and well-maintained Meteor.js package for files management using MongoDB Collection API. What does exactly this means? Calling [`.insert()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/insert.md) method would initiate a file upload and then insert new record into collection. Calling [`.remove()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/remove.md) method would erase stored file and record from MongoDB Collection. And so on, no need to learn new APIs. It's flavored with extra low-level methods like [`.unlink()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/unlink.md) and [`.write()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/write.md) for complex integrations. +Stable, fast, robust, and well-maintained Meteor.js package for files management using MongoDB Collection API. Call [`.insertAsync()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/insertAsync.md) method to initiate a file upload and to insert new record into MongoDB collection after upload is complete. Calling [`.removeAsync()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/removeAsync.md) method would erase stored file and record from MongoDB Collection. And so on, no need to learn new APIs. Hackable via hooks and events. Supports uploads to AWS:S3, GridFS, Google Storage, DropBox, and other 3rd party storage. ## ToC: - [πŸ“” Documentation](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/toc.md) - Docs, API, Demos, Examples +- [✨ Key features](https://github.com/veliovgroup/Meteor-Files#key-features) +- [πŸ“” API Documentation](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/readme.md) - __⚑️ Quick start__: - [πŸ”§ Installation](https://github.com/veliovgroup/Meteor-Files#installation) - - [πŸ‘¨β€πŸ’» API examples](https://github.com/veliovgroup/Meteor-Files#api-overview): - - [Initialize Collection](https://github.com/veliovgroup/Meteor-Files#new-filescollectionconfig-isomorphic) - - [Upload file](https://github.com/veliovgroup/Meteor-Files#insertsettings-autostart-client) - - [Stream files](https://github.com/veliovgroup/Meteor-Files#stream-files) - - [Download Button](https://github.com/veliovgroup/Meteor-Files#download-button) -- [πŸ“¦ Related Packages](https://github.com/veliovgroup/Meteor-Files#related-packages) -- [✨ Key features](https://github.com/veliovgroup/Meteor-Files#key-features) -- [πŸ™‹β€β™‚οΈ Help / Support](https://github.com/veliovgroup/Meteor-Files#get-support) + - [πŸ‘¨β€πŸ’» Usage example](https://github.com/veliovgroup/Meteor-Files#api-overview) - [πŸ€” FAQ](https://github.com/veliovgroup/Meteor-Files#faq) - [πŸ… Awards](https://github.com/veliovgroup/Meteor-Files#awards) -- [🀝 Help & Contribute](https://github.com/veliovgroup/Meteor-Files#contribution) +- [πŸ™‹β€β™‚οΈ Get Help](https://github.com/veliovgroup/Meteor-Files#get-support) +- [πŸ—‚οΈ Demo applications](https://github.com/veliovgroup/Meteor-Files#demo-applications) +- [πŸ“¦ Related Packages](https://github.com/veliovgroup/Meteor-Files#related-packages) - [πŸŽ— Support this project](https://github.com/veliovgroup/Meteor-Files#support-meteor-files-project) +- [πŸ‘¨β€πŸ’» Contribute to this project](https://github.com/veliovgroup/Meteor-Files#contribution) - [πŸ™ Supporters](https://github.com/veliovgroup/Meteor-Files#supporters) ## Key features - Compatible with all front-end frameworks from Blaze to [React](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/react-example.md) - Upload via `HTTP` and `DDP` transports, [read about difference](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/about-transports.md) -- Sustainable and "resumable" uploads will resume upon connection interruption or server reboot +- Sustainable and "resumable" uploads will auto-resume when connection interrupted or server rebooted - Upload files through computing cloud without persistent File System, like Heroku (*"resumable" uploads are not supported on Heroku and alike*) - Use *[GridFS](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/gridfs-bucket-integration.md#use-gridfs-with-gridfsbucket-as-a-storage)*, *[AWS S3](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/aws-s3-integration.md)*, *[Google Storage](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/google-cloud-storage-integration.md)* or *[DropBox](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/dropbox-integration.md)* and other (*[3rd-party storage](hhttps://github.com/veliovgroup/Meteor-Files/blob/master/docs/3rd-party-storage.md)*) - APIs for checking file mime-type, size, extension, an other file's properties before upload using *[`onBeforeUpload` hook](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/constructor.md)* -- APIs for [resizing images](https://github.com/veliovgroup/meteor-files-website/blob/master/imports/server/image-processing.js#L19), *[subversions management](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/file-subversions.md)*, and other post-processing tasks using *[`onAfterUpload` hook](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/constructor.md)* +- APIs for [resizing images](https://github.com/veliovgroup/meteor-files-website/blob/master/imports/server/image-processing.js#L19), *[subversions management](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/file-subversions.md)*, and other post-processing tasks using *[`onAfterUpload`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/constructor.md)* and *[`onAfterRemove`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/constructor.md)* hooks ## Installation: @@ -59,24 +56,31 @@ import { FilesCollection } from 'meteor/ostrio:files'; For detailed docs, examples, and API β€” read [documentation section](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/readme.md). -- [`FilesCollection` Constructor](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/constructor.md) [*Isomorphic*] - Initialize FilesCollection -- [`insert()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/insert.md) [*Client*] - Upload file(s) from client to server -- [`find()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/find.md) [*Isomorphic*] - Create cursor for FilesCollection -- [`remove()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/remove.md) [*Isomorphic*] - Remove files from FilesCollection and "unlink" (e.g. remove) from FS -- [`findOne()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/findOne.md) [*Isomorphic*] - Find one file in FilesCollection -- [`write()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/write.md) [*Server*] - Write `Buffer` to FS and FilesCollection -- [`load()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/load.md) [*Server*] - Write file to FS and FilesCollection from remote URL +__Main methods:__ + +- [`FilesCollection` Constructor](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/constructor.md) [*Anywhere*] - Initialize FilesCollection +- [`insertAsync()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/insertAsync.md) [*Client*] - Upload a file to server, returns `FileUpload` instance +- [`link()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/link.md) [*Anywhere*] - Generate downloadable link +- [`find()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/find.md) [*Anywhere*] - Find all files matching selector, returns [`FilesCursor` instance](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/FilesCursor.md) +- [`findOneAsync()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/findOneAsync.md) [*Anywhere*] - Find a single file record matching selector, returns [`FileCursor` instance](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/FileCursor.md) +- [`removeAsync()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/removeAsync.md) [*Anywhere*] - Asynchronously remove files from FilesCollection and "unlink" (e.g. remove) from Server - [`addFile()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/addFile.md) [*Server*] - Add local file to FilesCollection from FS -- [`unlink()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/unlink.md) [*Server*] - "Unlink" (e.g. remove) file from FS -- [`link()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/link.md) [*Isomorphic*] - Generate downloadable link +- [`loadAsync()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/loadAsync.md) [*Server*] - Write file to FS and FilesCollection from remote URL +- [`writeAsync()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/writeAsync.md) [*Server*] - Write `Buffer` to FS and FilesCollection -### `new FilesCollection([config])` [*Isomorphic*] +### Constructor -Read full docs for [`FilesCollection` Constructor](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/constructor.md) +__[*Anywhere*]__. Initiate file's collection in the similar way to `Mongo.Collection` with optional settings related to file-uploads. Read full docs for [`FilesCollection` Constructor in the API documentation](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/constructor.md). -Shared code: +```js +import { FilesCollection } from 'meteor/ostrio:files'; +new FilesCollection(FilesCollectionConfig); +``` + +Pass additional options to control upload-flow ```js +// shared: /imports/lib/collections/images.collection.js import { Meteor } from 'meteor/meteor'; import { FilesCollection } from 'meteor/ostrio:files'; @@ -93,19 +97,27 @@ const imagesCollection = new FilesCollection({ }); if (Meteor.isClient) { + // SUBSCRIBE TO ALL UPLOADED FILES ON THE CLIENT Meteor.subscribe('files.images.all'); } if (Meteor.isServer) { + // PUBLISH ALL UPLOADED FILES ON THE SERVER Meteor.publish('files.images.all', function () { - return imagesCollection.find().cursor; + return imagesCollection.collection.find(); }); } ``` -### `insert(settings[, autoStart])` [*Client*] +### Upload a file -Read full docs for [`insert()` method](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/insert.md) +```ts +import { FilesCollection } from 'meteor/ostrio:files'; +const files = new FilesCollection(FilesCollectionConfig); +files.insertAsync(config: InsertOptions, autoStart?: boolean): Promise; +``` + +Read full docs for [`insertAsync()` method](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/insertAsync.md) Upload form (template): @@ -144,11 +156,11 @@ Template.uploadForm.helpers({ }); Template.uploadForm.events({ - 'change #fileInput'(e, template) { + async 'change #fileInput'(e, template) { if (e.currentTarget.files && e.currentTarget.files[0]) { // We upload only one file, in case // multiple files were selected - const upload = imagesCollection.insert({ + const upload = await imagesCollection.insertAsync({ file: e.currentTarget.files[0], chunkSize: 'dynamic' }, false); @@ -166,7 +178,7 @@ Template.uploadForm.events({ template.currentUpload.set(false); }); - upload.start(); + await upload.start(); } } }); @@ -178,14 +190,14 @@ Upload base64 string (*introduced in v1.7.1*): ```js // As dataURI -imagesCollection.insert({ +await imagesCollection.insertAsync({ file: 'data:image/png,base64str…', isBase64: true, // <β€” Mandatory fileName: 'pic.png' // <β€” Mandatory }); // As plain base64: -imagesCollection.insert({ +await imagesCollection.insertAsync({ file: 'base64str…', isBase64: true, // <β€” Mandatory fileName: 'pic.png', // <β€” Mandatory @@ -197,7 +209,7 @@ For more expressive example see [Upload demo app](https://github.com/veliovgroup ### Stream files -To display files you can use `fileURL` template helper or `.link()` method of `FileCursor`. +To display files you can use [`fileURL`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/template-helper.md) template helper or [`link()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/link.md) method of [`FileCursor` instance](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/FileCursor.md). Template: @@ -221,26 +233,26 @@ Shared code: import { Meteor } from 'meteor/meteor'; import { FilesCollection } from 'meteor/ostrio:files'; -const imagesCollection = new FilesCollection({collectionName: 'images'}); -const videosCollection = new FilesCollection({collectionName: 'videos'}); +const imagesCollection = new FilesCollection({ collectionName: 'images' }); +const videosCollection = new FilesCollection({ collectionName: 'videos' }); if (Meteor.isServer) { // Upload sample files on server's startup: - Meteor.startup(() => { - imagesCollection.load('https://raw.githubusercontent.com/veliovgroup/Meteor-Files/master/logo.png', { + Meteor.startup(async () => { + await imagesCollection.loadAsync('https://raw.githubusercontent.com/veliovgroup/Meteor-Files/master/logo.png', { fileName: 'logo.png' }); - videosCollection.load('http://www.sample-videos.com/video/mp4/240/big_buck_bunny_240p_5mb.mp4', { + await videosCollection.loadAsync('http://www.sample-videos.com/video/mp4/240/big_buck_bunny_240p_5mb.mp4', { fileName: 'Big-Buck-Bunny.mp4' }); }); Meteor.publish('files.images.all', function () { - return imagesCollection.find().cursor; + return imagesCollection.collection.find(); }); Meteor.publish('files.videos.all', function () { - return videosCollection.find().cursor; + return videosCollection.collection.find(); }); } else { // Subscribe to file's collections on Client @@ -252,6 +264,10 @@ if (Meteor.isServer) { Client's code: ```js +// imports/client/file/file.js +import '/imports/client/file/file.html'; +import imagesCollection from '/imports/lib/collections/images.collection.js'; + Template.file.helpers({ imageFile() { return imagesCollection.findOne(); @@ -266,34 +282,24 @@ For more expressive example see [Streaming demo app](https://github.com/veliovgr ### Download button -Template: - -```html - -``` - -Shared code: +Create collection available to Client and Server ```js +// imports/lib/collections/images.collection.js import { Meteor } from 'meteor/meteor'; import { FilesCollection } from 'meteor/ostrio:files'; -const imagesCollection = new FilesCollection({collectionName: 'images'}); +const imagesCollection = new FilesCollection({ collectionName: 'images' }); if (Meteor.isServer) { // Load sample image into FilesCollection on server's startup: - Meteor.startup(function () { - imagesCollection.load('https://raw.githubusercontent.com/veliovgroup/Meteor-Files/master/logo.png', { + Meteor.startup(async () => { + await imagesCollection.loadAsync('https://raw.githubusercontent.com/veliovgroup/Meteor-Files/master/logo.png', { fileName: 'logo.png', - meta: {} }); }); Meteor.publish('files.images.all', function () { - return imagesCollection.find().cursor; + return imagesCollection.collection.find(); }); } else { // Subscribe on the client @@ -301,11 +307,26 @@ if (Meteor.isServer) { } ``` -Client's code: +Create template, call `.link` method on the `FileCursor` returned from `file` helper + +```html + + +``` + +Create controller for `file` template with `file` helper that returns `FileCursor` with `.link()` method ```js +// imports/client/file/file.js +import '/imports/client/file/file.html'; +import imagesCollection from '/imports/lib/collections/images.collection.js'; + Template.file.helpers({ - fileRef() { + file() { return imagesCollection.findOne(); } }); @@ -315,22 +336,22 @@ For more expressive example see [Download demo](https://github.com/veliovgroup/M ## FAQ: -1. __Where are files stored by default?__: by default if `config.storagePath` isn't passed into [*Constructor*](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/constructor.md) it's equals to `assets/app/uploads` and relative to running script: +1. __Where are files stored by default?__: by default if `config.storagePath` isn't set in [*Constructor* options](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/constructor.md) it's equals to `assets/app/uploads` and relative to running script: - __a.__ On `development` stage: `yourDevAppDir/.meteor/local/build/programs/server`. __Note: All files will be removed as soon as your application rebuilds__ or you run `meteor reset`. To keep your storage persistent during development use an absolute path *outside of your project folder*, e.g. `/data` directory. - __b.__ On `production`: `yourProdAppDir/programs/server`. __Note: If using MeteorUp (MUP), Docker volumes must to be added to__ `mup.json`, see [MUP usage](hhttps://github.com/veliovgroup/Meteor-Files/blob/master/docs/meteorup-usage.md) 2. __Cordova usage and development__: With support of community we do regular testing on virtual and real devices. To make sure `Meteor-Files` library runs smoothly in Cordova environment β€” enable [withCredentials](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials); enable `{allowQueryStringCookies: true}` and `{allowedOrigins: true}` on both `Client` and `Server`. For more details read [Cookie's repository FAQ](https://github.com/veliovgroup/Meteor-Cookies#faq) -3. __meteor-desktop usage and development__: Meteor-Files can be used in [meteor-desktop](https://github.com/Meteor-Community-Packages/meteor-desktop) projects as well. As meteor-desktop works exactly like Cordova, all Cordova requirements and recommendations apply. -4. __How to pause/continue upload and get progress/speed/remaining time?__: see *Object* returned from [`insert` method](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/insert.md) -5. When using any of `accounts` packages - package `accounts-base` must be explicitly added to `.meteor/packages` above `ostrio:files` +3. __meteor-desktop usage and development__: Meteor-Files can be used in [meteor-desktop](https://github.com/Meteor-Community-Packages/meteor-desktop) projects as well. As meteor-desktop works exactly like Cordova, all Cordova requirements and recommendations apply +4. __How to pause/continue upload and get progress/speed/remaining time?__: see *FileUpload* instance returned from [`insertAsync` method](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/insertAsync.md) +5. When using any of `accounts` packages - package `accounts-base` must be explicitly added to `.meteor/packages` __above__ `ostrio:files` 6. __cURL/POST uploads__ - Take a look on [POST-Example](https://github.com/noris666/Meteor-Files-POST-Example) by [@noris666](https://github.com/noris666) -7. In __Safari__ (Mobile and Desktop) for `DDP` chunk size is reduced by algorithm, due to error thrown if frame is too big. Limit simultaneous uploads to `6` is recommended for Safari. This issue should be fixed in Safari 11. Switching to `http` transport (*which has no such issue*) is recommended for Safari. See [#458](https://github.com/veliovgroup/Meteor-Files/issues/458) +7. In __Safari__ (Mobile and Desktop) for `DDP` chunk size is reduced by algorithm, due to error thrown if frame is too big. This issue should be fixed in Safari 11. Switching to `http` transport (*which has no such issue*) is recommended for Safari. See [#458](https://github.com/veliovgroup/Meteor-Files/issues/458) 8. Make sure you're using single domain for the Meteor app, and the same domain for hosting Meteor-Files endpoints, see [#737](https://github.com/veliovgroup/Meteor-Files/issues/737) for details -9. When proxying requests to Meteor-Files endpoint make sure protocol `http/1.1` is used, see [#742](https://github.com/veliovgroup/Meteor-Files/issues/742) for details +9. When requests are proxied to `FilesCollection` endpoint make sure protocol `http/1.1` is used, see [#742](https://github.com/veliovgroup/Meteor-Files/issues/742) for details ## Awards: - + GCAA award ## Get Support: @@ -339,12 +360,18 @@ For more expressive example see [Download demo](https://github.com/veliovgroup/M - [Releases / Changelog / History](https://github.com/veliovgroup/Meteor-Files/releases) - For more docs and examples [read wiki](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/readme.md) -## Demo application: +## Demo applications: -Fully-featured file-sharing app +Fully-featured file-sharing app: - [Live: __files.veliov.com__](https://files.veliov.com) -- [Source Code Rep](https://github.com/veliovgroup/meteor-files-website#file-sharing-web-app) +- [Source Code Rep](https://github.com/veliovgroup/meteor-files-website) + +Other demos: + +- [Simplest Upload demo app](https://github.com/veliovgroup/Meteor-Files-Demos/tree/master/demo-simplest-upload) +- [Simplest Download demo](https://github.com/veliovgroup/Meteor-Files-Demos/tree/master/demo-simplest-download-button) +- [Simplest Streaming demo app](https://github.com/veliovgroup/Meteor-Files-Demos/tree/master/demo-simplest-streaming) ## Related Packages: @@ -353,12 +380,12 @@ Fully-featured file-sharing app ## Support Meteor-Files project: -- Star on [GitHub](https://github.com/veliovgroup/Meteor-Files) -- Star on [Atmosphere](https://atmospherejs.com/ostrio/files) -- Share via [Facebook](https://www.facebook.com/sharer.php?u=https%3A%2F%2Fgithub.com%2Fveliovgroup%2FMeteor-Files) and [Twitter](https://twitter.com/share?url=https%3A%2F%2Fgithub.com%2Fveliovgroup%2FMeteor-Files) -- [Sponsor via GitHub](https://github.com/sponsors/dr-dimitru) -- [Support via PayPal](https://paypal.me/veliovgroup) β€” support my open source contributions once or on regular basis -- Use [ostr.io](https://ostr.io) β€” [Monitoring](https://snmp-monitoring.com), [Analytics](https://ostr.io/info/web-analytics), [WebSec](https://domain-protection.info), [Web-CRON](https://web-cron.info) and [Pre-rendering](https://prerendering.com) for a website +- πŸ—ƒοΈ Upload and share files using [meteor-files.com](https://meteor-files.com/?ref=github-files-repo-footer) β€” Continue interrupted file uploads without losing any progress. There is nothing that will stop Meteor from delivering your file to the desired destination +- πŸ‘¨β€πŸ’» Improve your project using [ostr.io](https://ostr.io?ref=github-files-repo-footer) for [Server Monitoring](https://snmp-monitoring.com), [Web Analytics](https://ostr.io/info/web-analytics?ref=github-files-repo-footer), [WebSec](https://domain-protection.info), [Web-CRON](https://web-cron.info) and [SEO Pre-rendering](https://prerendering.com) +- πŸ’΅ [Sponsor via GitHub](https://github.com/sponsors/dr-dimitru) +- πŸ’΅ [Support via PayPal](https://paypal.me/veliovgroup) +- ⭐️ Star on [GitHub](https://github.com/veliovgroup/Meteor-Files) +- ⭐️ Star on [Atmosphere](https://atmospherejs.com/ostrio/files) ## Contribution: diff --git a/client.js b/client.js index a5f0f467..8a578fab 100644 --- a/client.js +++ b/client.js @@ -5,34 +5,32 @@ import { Cookies } from 'meteor/ostrio:cookies'; import { check, Match } from 'meteor/check'; import { UploadInstance } from './upload.js'; import FilesCollectionCore from './core.js'; -import { formatFleURL, helpers } from './lib.js'; +import { formatFileURL, helpers } from './lib.js'; const NOOP = () => { }; const allowedParams = ['allowClientCode', 'allowQueryStringCookies', 'chunkSize', 'collection', 'collectionName', 'ddp', 'debug', 'disableSetTokenCookie', 'disableUpload', 'downloadRoute', 'namingFunction', 'onBeforeUpload', 'onbeforeunloadMessage', 'public', 'sanitize', 'schema']; /** - * @locus Anywhere + * @locus Client * @class FilesCollection - * @param config {Object} - [Both] Configuration object with next properties: - * @param config.debug {Boolean} - [Both] Turn on/of debugging and extra logging - * @param config.ddp {Object} - [Client] Custom DDP connection. Object returned form `DDP.connect()` - * @param config.schema {Object} - [Both] Collection Schema - * @param config.public {Boolean} - [Both] Store files in folder accessible for proxy servers, for limits, and more - read docs` - * @param config.chunkSize {Number} - [Both] Upload chunk size, default: 524288 bytes (0,5 Mb) - * @param config.downloadRoute {String} - [Both] Server Route used to retrieve files - * @param config.collection {Mongo.Collection} - [Both] Mongo Collection Instance - * @param config.collectionName {String} - [Both] Collection name - * @param config.namingFunction {Function}- [Both] Function which returns `String` - * @param config.onBeforeUpload {Function}- [Both] Function which executes on server after receiving each chunk and on client right before beginning upload. Function context is `File` - so you are able to check for extension, mime-type, size and etc. - * return `true` to continue - * return `false` or `String` to abort upload - * @param config.allowClientCode {Boolean} - [Both] Allow to run `remove` from client - * @param config.onbeforeunloadMessage {String|Function} - [Client] Message shown to user when closing browser's window or tab while upload process is running - * @param config.disableUpload {Boolean} - Disable file upload, useful for server only solutions - * @param config.disableSetTokenCookie {Boolean} - Disable cookie setting. Useful when you use multiple file collections or when you want to implement your own authorization. - * @param config.allowQueryStringCookies {Boolean} - Allow passing Cookies in a query string (in URL). Primary should be used only in Cordova environment. Note: this option will be used only on Cordova. Default: `false` - * @param config.sanitize {Function} - Override default sanitize function - * @summary Create new instance of FilesCollection + * @param config {FilesCollectionConfig} - [anywhere] configuration object with the following properties: + * @param config.debug {boolean} - [anywhere] turn on/off debugging and extra logging + * @param config.ddp {DDP.DDPStatic} - [client] custom DDP connection; object returned from `DDP.connect()` + * @param config.schema {object} - [anywhere] collection schema + * @param config.public {boolean} - [anywhere] store files in folder accessible for proxy servers, for limits, etc. + * @param config.chunkSize {number} - [anywhere] upload chunk size, default: 524288 bytes (0.5 mb) + * @param config.downloadRoute {string} - [anywhere] server route used to retrieve files + * @param config.collection {Mongo.Collection} - [anywhere] mongo collection instance + * @param config.collectionName {string} - [anywhere] collection name + * @param config.namingFunction {function} - [anywhere] function that returns a string + * @param config.onBeforeUpload {function} - [anywhere] function executed on server after receiving each chunk and on client before starting upload; return `true` to continue, `false` or `string` (error message) to abort + * @param config.allowClientCode {boolean} - [anywhere] allow to run remove from client + * @param config.onbeforeunloadMessage {string|function} - [client] message shown to user when closing window/tab during upload + * @param config.disableUpload {boolean} - disable file upload; useful for server-only solutions + * @param config.disableSetTokenCookie {boolean} - disable cookie setting; useful when using multiple file collections or custom authorization + * @param config.allowQueryStringCookies {boolean} - allow passing cookies in query string (primarily in cordova); default: false + * @param config.sanitize {function} - override default sanitize function + * @summary Creates a new instance of FilesCollection */ class FilesCollection extends FilesCollectionCore { constructor(config) { @@ -47,8 +45,9 @@ class FilesCollection extends FilesCollectionCore { const self = this; const cookie = new Cookies({ - allowQueryStringCookies: this.allowQueryStringCookies + allowQueryStringCookies: this.allowQueryStringCookies, }); + if (!helpers.isBoolean(this.debug)) { this.debug = false; } @@ -116,7 +115,7 @@ class FilesCollection extends FilesCollectionCore { if (!config.disableSetTokenCookie) { const setTokenCookie = () => { if (Meteor.connection._lastSessionId) { - cookie.set('x_mtok', Meteor.connection._lastSessionId, { path: '/', sameSite: 'Lax' }); + cookie.set('x_mtok', Meteor.connection._lastSessionId, { path: '/', sameSite: 'Lax', secure: Meteor.isProduction }); if ((Meteor.isCordova || Meteor.isDesktop) && this.allowQueryStringCookies) { cookie.send(); } @@ -133,6 +132,7 @@ class FilesCollection extends FilesCollectionCore { } } + // eslint-disable-next-line new-cap check(this.onbeforeunloadMessage, Match.OneOf(String, Function)); try { @@ -161,8 +161,10 @@ class FilesCollection extends FilesCollectionCore { check(this.chunkSize, Number); check(this.downloadRoute, String); check(this.disableUpload, Boolean); + /* eslint-disable new-cap */ check(this.namingFunction, Match.OneOf(false, Function)); check(this.onBeforeUpload, Match.OneOf(false, Function)); + /* eslint-enable new-cap */ check(this.allowClientCode, Boolean); check(this.ddp, Match.Any); @@ -175,12 +177,12 @@ class FilesCollection extends FilesCollectionCore { } /** + * Returns file's mime-type. * @locus Anywhere * @memberOf FilesCollection * @name _getMimeType - * @param {Object} fileData - File Object - * @summary Returns file's mime-type - * @returns {String} + * @param {FileData} fileData - file object + * @returns {string} */ _getMimeType(fileData) { let mime; @@ -188,7 +190,6 @@ class FilesCollection extends FilesCollectionCore { if (helpers.isObject(fileData)) { mime = fileData.type; } - if (!mime || !helpers.isString(mime)) { mime = 'application/octet-stream'; } @@ -196,11 +197,12 @@ class FilesCollection extends FilesCollectionCore { } /** + * Returns an object with user's information. * @locus Anywhere * @memberOf FilesCollection * @name _getUser - * @summary Returns object with `userId` and `user()` method which return user's object - * @returns {Object} + * @summary Returns an object with userId and a user() method that returns the user object + * @returns {ContextUser} */ _getUser() { const result = { @@ -219,58 +221,96 @@ class FilesCollection extends FilesCollectionCore { } /** + * Uploads a file to the server over DDP or HTTP. * @locus Client * @memberOf FilesCollection * @name insert * @see https://developer.mozilla.org/en-US/docs/Web/API/FileReader - * @param {Object} config - Configuration object with next properties: - * {File|Object} file - HTML5 `files` item, like in change event: `e.currentTarget.files[0]` - * {String} fileId - Optional `fileId` used at insert - * {Object} meta - Additional data as object, use later for search - * {Boolean} allowWebWorkers- Allow/Deny WebWorkers usage - * {Number|dynamic} chunkSize - Chunk size for upload - * {String} transport - Upload transport `http` or `ddp` - * {Object} ddp - Custom DDP connection. Object returned form `DDP.connect()` - * {Function} onUploaded - Callback triggered when upload is finished, with two arguments `error` and `fileRef` - * {Function} onStart - Callback triggered when upload is started after all successful validations, with two arguments `error` (always null) and `fileRef` - * {Function} onError - Callback triggered on error in upload and/or FileReader, with two arguments `error` and `fileData` - * {Function} onProgress - Callback triggered when chunk is sent, with only argument `progress` - * {Function} onBeforeUpload - Callback triggered right before upload is started: - * return true to continue - * return false to abort upload - * @param {Boolean} autoStart - Start upload immediately. If set to false, you need manually call .start() method on returned class. Useful to set EventListeners. - * @summary Upload file to server over DDP or HTTP - * @returns {UploadInstance} Instance. UploadInstance has next properties: - * {ReactiveVar} onPause - Is upload process on the pause? - * {ReactiveVar} state - active|paused|aborted|completed - * {ReactiveVar} progress - Current progress in percentage - * {Function} pause - Pause upload process - * {Function} continue - Continue paused upload process - * {Function} toggle - Toggle continue/pause if upload process - * {Function} abort - Abort upload - * {Function} readAsDataURL - Current file as data URL, use to create image preview and etc. Be aware of big files, may lead to browser crash + * @param {InsertOptions} config - configuration object with properties: + * {File} file - HTML5 file object (e.g. from e.currentTarget.files[0]) + * {string} fileId - optional fileId used at insert + * {MetadataType} meta - additional data as an object, used later for search + * {boolean} allowWebWorkers - allow/deny use of web workers + * {number|string} chunkSize - chunk size for upload (or 'dynamic') + * {MeteorFilesTransportType} transport - upload transport ('http' or 'ddp') + * {DDP.DDPStatic} ddp - custom DDP connection (returned from DDP.connect()) + * {function} onUploaded - callback triggered when upload finishes; receives (error, fileObj) + * {function} onStart - callback triggered when upload starts; receives (error, fileObj) (error always null) + * {function} onError - callback triggered on error during upload/FileReader; receives (error, fileData) + * {function} onProgress - callback triggered when a chunk is sent; receives (progress) + * {function} onBeforeUpload - callback triggered before upload starts; return true to continue, false or string to abort + * @param {boolean} [autoStart=true] - whether to start upload immediately (if false, call .start() manually) + * @summary Uploads a file to the server over DDP or HTTP + * @returns {FileUpload|UploadInstance} An instance with properties: + * {ReactiveVar} onPause - whether the upload is paused + * {ReactiveVar} state - 'active' | 'paused' | 'aborted' | 'completed' + * {ReactiveVar} progress - upload progress (percentage) + * {function} pause - pauses the upload + * {function} continue - continues a paused upload + * {function} toggle - toggles pause/continue + * {function} abort - aborts the upload + * {function} readAsDataURL - returns the file as a data URL (for preview); note: big files may crash the browser */ insert(config, autoStart = true) { + this._debug('[FilesCollection] [insert()]', config, { autoStart }); if (this.disableUpload) { - Meteor._debug('[FilesCollection] [insert()] Upload is disabled with [disableUpload]!'); - return {}; + this._debug('[FilesCollection] [insert()] Upload is disabled with [disableUpload]!'); + config.disableUpload = true; } - return (new UploadInstance(config, this))[autoStart ? 'start' : 'manual'](); + + const uploadInstance = new UploadInstance(config, this); + if (autoStart) { + uploadInstance.start().catch((error) => { + uploadInstance.emit('error', new Meteor.Error(500, '[FilesCollection] [insert] Error starting upload:', error)); + }); + return uploadInstance; + } + + return uploadInstance.manual(); } /** - * @locus Anywhere + * Asynchronously uploads a file to the server over DDP or HTTP. + * @locus Client + * @memberOf FilesCollection + * @name insertAsync + * @param {InsertOptions} config - configuration object with properties: + * @param {boolean} [autoStart=true] - whether to start upload immediately (if false, call .start() manually) + * @returns {Promise} + * @see FilesCollection#insert for usage + */ + async insertAsync(config, autoStart = true) { + this._debug('[FilesCollection] [insertAsync()]', config, { autoStart }); + if (this.disableUpload) { + this._debug('[FilesCollection] [insertAsync()] Upload is disabled with [disableUpload]!'); + config.disableUpload = true; + } + + const uploadInstance = new UploadInstance(config, this); + if (autoStart) { + await uploadInstance.start(); + return uploadInstance; + } + + return uploadInstance.manual(); + } + + /** + * Removes documents from the collection. + * @locus Client * @memberOf FilesCollection * @name remove - * @param {String|Object} selector - Mongo-Style selector (http://docs.meteor.com/api/collections.html#selectors) - * @param {Function} callback - Callback with one `error` argument - * @summary Remove documents from the collection + * @param {MeteorFilesSelector} selector - mongo-style selector (see http://docs.meteor.com/api/collections.html#selectors) + * @param {function(error, number): void} callback - callback with (error, number) arguments + * @summary Removes documents from the collection * @returns {FilesCollection} Instance */ remove(selector = {}, callback) { this._debug(`[FilesCollection] [remove(${JSON.stringify(selector)})]`); + /* eslint-disable new-cap */ check(selector, Match.OneOf(Object, String)); check(callback, Match.Optional(Function)); + /* eslint-enable new-cap */ if (this.allowClientCode) { this.ddp.call(this._methodNames._Remove, selector, (callback || NOOP)); @@ -281,30 +321,41 @@ class FilesCollection extends FilesCollectionCore { return this; } + + /** + * Removes documents from the collection asynchronously. + * @locus Anywhere + * @memberOf FilesCollection + * @name removeAsync + * @param {MeteorFilesSelector} selector - mongo-style selector (see http://docs.meteor.com/api/collections.html#selectors) + * @summary Removes documents from the collection + * @returns {Promise} number of matched and removed files/records + */ + async removeAsync(selector = {}) { + this._debug(`[FilesCollection] [removeAsync(${JSON.stringify(selector)})]`); + // eslint-disable-next-line new-cap + check(selector, Match.OneOf(Object, String)); + + if (this.allowClientCode) { + return await this.ddp.callAsync(this._methodNames._Remove, selector); + } + + this._debug('[FilesCollection] [removeAsync] Run code from client is not allowed!'); + return 0; + } } -/* - * @locus Client - * @TemplateHelper - * @name fileURL - * @param {Object} fileRef - File reference object - * @param {String} version - [Optional] Version of file you would like to request - * @param {String} uriBase - [Optional] URI base, see - https://github.com/veliovgroup/Meteor-Files/issues/626 - * @summary Get download URL for file by fileRef, even without subscription - * @example {{fileURL fileRef}} - * @returns {String} - */ Meteor.startup(() => { const _template = (Package && Package.templating && Package.templating.Template) ? Package.templating.Template : undefined; if (_template) { - _template.registerHelper('fileURL', (fileRef, _version = 'original', _uriBase) => { - if (!helpers.isObject(fileRef)) { + _template.registerHelper('fileURL', (fileObj, _version = 'original', _uriBase) => { + if (!helpers.isObject(fileObj)) { return ''; } const version = (!helpers.isString(_version)) ? 'original' : _version; const uriBase = (!helpers.isString(_uriBase)) ? void 0 : _uriBase; - return formatFleURL(fileRef, version, uriBase); + return formatFileURL(fileObj, version, uriBase); }); } }); diff --git a/core.js b/core.js index dd803d35..c32f234d 100644 --- a/core.js +++ b/core.js @@ -1,6 +1,6 @@ import { EventEmitter } from 'eventemitter3'; import { check, Match } from 'meteor/check'; -import { formatFleURL, helpers } from './lib.js'; +import { formatFileURL, helpers } from './lib.js'; import { FilesCursor, FileCursor } from './cursor.js'; export default class FilesCollectionCore extends EventEmitter { @@ -96,57 +96,58 @@ export default class FilesCollectionCore extends EventEmitter { } }; - /* + /** + * Print logs in debug mode. * @locus Anywhere * @memberOf FilesCollectionCore - * @name _debug - * @summary Print logs in debug mode * @returns {void} */ - _debug() { + _debug(...args) { if (this.debug) { - (console.info || console.log || function () { }).apply(void 0, arguments); + // eslint-disable-next-line no-console + (console.info || console.log || function () {}).apply(undefined, args); } } - /* + /** + * Returns file's name. * @locus Anywhere * @memberOf FilesCollectionCore - * @name _getFileName - * @param {Object} fileData - File Object - * @summary Returns file's name - * @returns {String} + * @param {FileData} fileData - File data object + * @returns {string} The sanitized file name */ _getFileName(fileData) { const fileName = fileData.name || fileData.fileName; - if (helpers.isString(fileName) && (fileName.length > 0)) { - return (fileData.name || fileData.fileName).replace(/^\.\.+/, '').replace(/\.{2,}/g, '.').replace(/\//g, ''); + if (helpers.isString(fileName) && fileName.length > 0) { + return fileName.replace(/^\.\.+/, '').replace(/\.{2,}/g, '.').replace(/\//g, ''); } return ''; } - /* + /** + * Extracts extension information from a file name. * @locus Anywhere * @memberOf FilesCollectionCore - * @name _getExt - * @param {String} FileName - File name - * @summary Get extension from FileName - * @returns {Object} + * @param {string} fileName - The file name + * @returns {Partial} An object with properties: ext, extension, and extensionWithDot */ _getExt(fileName) { if (fileName.includes('.')) { - const extension = (fileName.split('.').pop().split('?')[0] || '').toLowerCase().replace(/([^a-z0-9\-\_\.]+)/gi, '').substring(0, 20); + const extension = (fileName.split('.').pop().split('?')[0] || '') + .toLowerCase() + .replace(/([^a-z0-9\-\_\.]+)/gi, '') + .substring(0, 20); return { ext: extension, extension, extensionWithDot: `.${extension}` }; } return { ext: '', extension: '', extensionWithDot: '' }; } - /* + /** + * Classifies the file based on its MIME type. * @locus Anywhere * @memberOf FilesCollectionCore - * @name _updateFileTypes - * @param {Object} data - File data - * @summary Internal method. Classify file based on 'type' field + * @param {Partial} data - File data object + * @returns {void} */ _updateFileTypes(data) { data.isVideo = /^video\//i.test(data.type); @@ -157,13 +158,12 @@ export default class FilesCollectionCore extends EventEmitter { data.isPDF = /^application\/(x-)?pdf$/i.test(data.type); } - /* + /** + * Builds an object that conforms to the default schema from file data. * @locus Anywhere * @memberOf FilesCollectionCore - * @name _dataToSchema - * @param {Object} data - File data - * @summary Internal method. Build object in accordance with default schema from File data - * @returns {Object} + * @param {FileData & Partial} data - File data combined with optional FileObj properties + * @returns {Partial} The schema-compliant file object */ _dataToSchema(data) { const ds = { @@ -190,7 +190,7 @@ export default class FilesCollectionCore extends EventEmitter { _collectionName: data._collectionName || this.collectionName }; - //Optional fileId + // Optional fileId if (data.fileId) { ds._id = data.fileId; } @@ -200,74 +200,143 @@ export default class FilesCollectionCore extends EventEmitter { return ds; } - /* + /** + * Finds and returns a FileCursor for a matching document asynchronously. * @locus Anywhere * @memberOf FilesCollectionCore - * @name findOne - * @param {String|Object} selector - Mongo-Style selector (http://docs.meteor.com/api/collections.html#selectors) - * @param {Object} options - Mongo-Style selector Options (http://docs.meteor.com/api/collections.html#sortspecifiers) - * @summary Find and return Cursor for matching document Object - * @returns {FileCursor} Instance + * @param {MeteorFilesSelector} [selector={}] - Mongo-style selector + * @param {MeteorFilesOptions} [options] - Mongo query options + * @returns {Promise} A FileCursor instance, or null if not found + */ + async findOneAsync(selector = {}, options) { + this._debug(`[FilesCollection] [findOneAsync(${JSON.stringify(selector)}, ${JSON.stringify(options)})]`); + /* eslint-disable new-cap */ + check(selector, Match.Optional(Match.OneOf(Object, String, Boolean, Number, null))); + check(options, Match.Optional(Object)); + /* eslint-enable new-cap */ + + const doc = await this.collection.findOneAsync(selector, options); + if (doc) { + return new FileCursor(doc, this); + } + return null; + } + + /** + * Finds and returns a FileCursor for a matching document (client only). + * @locus Client + * @memberOf FilesCollectionCore + * @param {MeteorFilesSelector} [selector={}] - Mongo-style selector + * @param {MeteorFilesOptions} [options] - Mongo query options + * @returns {FileCursor|null} A FileCursor instance, or null if not found + * @throws {Meteor.Error} If called on the server */ findOne(selector = {}, options) { - this._debug(`[FilesCollection] [findOne(${JSON.stringify(selector)}, ${JSON.stringify(options)})]`); + this._debug(`[FilesCollection] [findOne(${JSON.stringify(selector)}, ${JSON.stringify(options)})]`, Meteor.isServer); + if (Meteor.isServer) { + throw new Meteor.Error(404, 'FilesCollection#findOne() not available in server! Use .findOneAsync instead'); + } + /* eslint-disable new-cap */ check(selector, Match.Optional(Match.OneOf(Object, String, Boolean, Number, null))); check(options, Match.Optional(Object)); + /* eslint-enable new-cap */ const doc = this.collection.findOne(selector, options); if (doc) { return new FileCursor(doc, this); } - return doc; + return null; } - /* + /** + * Finds and returns a FilesCursor for matching documents. * @locus Anywhere * @memberOf FilesCollectionCore - * @name find - * @param {String|Object} selector - Mongo-Style selector (http://docs.meteor.com/api/collections.html#selectors) - * @param {Object} options - Mongo-Style selector Options (http://docs.meteor.com/api/collections.html#sortspecifiers) - * @summary Find and return Cursor for matching documents - * @returns {FilesCursor} Instance + * @param {MeteorFilesSelector} [selector={}] - Mongo-style selector + * @param {MeteorFilesOptions} [options] - Mongo query options + * @returns {FilesCursor} A FilesCursor instance */ find(selector = {}, options) { this._debug(`[FilesCollection] [find(${JSON.stringify(selector)}, ${JSON.stringify(options)})]`); + /* eslint-disable new-cap */ check(selector, Match.Optional(Match.OneOf(Object, String, Boolean, Number, null))); check(options, Match.Optional(Object)); + /* eslint-enable new-cap */ return new FilesCursor(selector, options, this); } - /* - * @locus Anywhere + /** + * Links to the underlying Mongo.Collection update method. + * @locus Client * @memberOf FilesCollectionCore - * @name update - * @see http://docs.meteor.com/#/full/update - * @summary link Mongo.Collection update method - * @returns {Mongo.Collection} Instance + * @param {...*} args - Update method arguments + * @returns {Mongo.Collection} The underlying collection */ - update() { - this.collection.update.apply(this.collection, arguments); + update(...args) { + this.collection.update.apply(this.collection, args); return this.collection; } - /* + /** + * Links to the underlying Mongo.Collection updateAsync method. + * @locus Anywhere + * @memberOf FilesCollectionCore + * @param {...*} args - Update method arguments + * @returns {Promise} The number of updated records + */ + async updateAsync(...args) { + return await this.collection.updateAsync.apply(this.collection, args); + } + + /** + * Counts records matching a selector. + * @locus Anywhere + * @memberOf FilesCollectionCore + * @param {MeteorFilesSelector} [_selector={}] - Mongo-style selector + * @param {Mongo.CountDocumentsOptions} [options] - Mongo's CountDocumentsOptions + * @returns {Promise} The number of matching records + */ + async countDocuments(_selector = {}, options) { + this._debug(`[FilesCollection] [countDocuments(${JSON.stringify(_selector)}, ${JSON.stringify(options)})]`); + /* eslint-disable new-cap */ + check(_selector, Match.Optional(Match.OneOf(Object, String))); + check(options, Match.Optional(Object)); + /* eslint-enable new-cap */ + const selector = typeof _selector === 'string' && _selector.length ? { _id: _selector } : _selector; + return await this.collection.countDocuments(selector, options); + } + + /** + * Asynchronously returns the number of estimated documents qty in the Collection + * @locus Anywhere + * @memberOf FilesCollectionCore + * @param {Mongo.EstimatedDocumentCountOptions} [options] - EstimatedDocumentCountOptions + * @returns {Promise} + */ + async estimatedDocumentCount(options) { + /* eslint-disable-next-line new-cap */ + check(options, Match.Optional(Object)); + this._collection._debug('[FilesCollection] [estimatedDocumentCount()]'); + return await this.collection.estimatedDocumentCount(options); + } + + /** + * Returns a downloadable URL for a file. * @locus Anywhere * @memberOf FilesCollectionCore - * @name link - * @param {Object} fileRef - File reference object - * @param {String} version - Version of file you would like to request - * @param {String} uriBase - [Optional] URI base, see - https://github.com/veliovgroup/Meteor-Files/issues/626 - * @summary Returns downloadable URL - * @returns {String} Empty string returned in case if file not found in DB + * @param {Partial} fileObj - A partial file object reference + * @param {string} [version='original'] - The file version + * @param {string} [uriBase] - Optional URI base + * @returns {string} The download URL, or an empty string if the file is not found */ - link(fileRef, version = 'original', uriBase) { - this._debug(`[FilesCollection] [link(${(helpers.isObject(fileRef) ? fileRef._id : void 0)}, ${version})]`); - check(fileRef, Object); + link(fileObj, version = 'original', uriBase) { + this._debug(`[FilesCollection] [link(${helpers.isObject(fileObj) ? fileObj._id : undefined}, ${version})]`); + check(fileObj, Object); - if (!fileRef) { + if (!fileObj) { return ''; } - return formatFleURL(fileRef, version, uriBase); + return formatFileURL(fileObj, version, uriBase); } } diff --git a/cursor.js b/cursor.js index d7583db8..868f4a93 100644 --- a/cursor.js +++ b/cursor.js @@ -1,31 +1,33 @@ import { Meteor } from 'meteor/meteor'; -/* +/** + * @class FileCursor * @private * @locus Anywhere - * @class FileCursor - * @param _fileRef {Object} - Mongo-Style selector (http://docs.meteor.com/api/collections.html#selectors) - * @param _collection {FilesCollection} - FilesCollection Instance - * @summary Internal class, represents each record in `FilesCursor.each()` or document returned from `.findOne()` method + * @param {FileObj} _fileRef - Mongo–style file document or selector. + * @param {FilesCollection} _collection - FilesCollection instance. + * @summary Internal class representing a single file document (as returned from `.findOne()` or iterated via `.each()`). */ export class FileCursor { constructor(_fileRef, _collection) { + if (!_fileRef) { + throw new Meteor.Error(404, 'No file reference provided'); + } this._fileRef = _fileRef; this._collection = _collection; + // Merge file document properties into this instance. Object.assign(this, _fileRef); } - /* - * @locus Anywhere - * @memberOf FileCursor - * @name remove - * @param callback {Function} - Triggered asynchronously after item is removed or failed to be removed - * @summary Remove document + /** + * Remove document, client only + * @locus Client + * @param {function} [callback] - Triggered after item is removed or failed to be removed * @returns {FileCursor} */ remove(callback) { this._collection._debug('[FilesCollection] [FileCursor] [remove()]'); - if (this._fileRef) { + if (this._fileRef && this._fileRef._id) { this._collection.remove(this._fileRef._id, callback); } else { callback && callback(new Meteor.Error(404, 'No such file')); @@ -33,30 +35,42 @@ export class FileCursor { return this; } - /* + /** + * Remove document asynchronously. * @locus Anywhere - * @memberOf FileCursor - * @name link - * @param version {String} - Name of file's subversion - * @param uriBase {String} - [Optional] URI base, see - https://github.com/veliovgroup/Meteor-Files/issues/626 - * @summary Returns downloadable URL to File - * @returns {String} + * @returns {Promise} + * @throws {Meteor.Error} If no file reference is provided. + */ + async removeAsync() { + this._collection._debug('[FilesCollection] [FileCursor] [removeAsync()]'); + if (this._fileRef && this._fileRef._id) { + await this._collection.removeAsync(this._fileRef._id); + } else { + throw new Meteor.Error(404, 'No such file'); + } + return this; + } + + /** + * Returns a downloadable URL to the file. + * @locus Anywhere + * @param {string} [version='original'] - Name of the file’s subversion. + * @param {string} [uriBase] - Optional URI base. + * @returns {string} */ link(version = 'original', uriBase) { this._collection._debug(`[FilesCollection] [FileCursor] [link(${version})]`); - if (this._fileRef) { + if (this._fileRef && this._fileRef._id) { return this._collection.link(this._fileRef, version, uriBase); } return ''; } - /* + /** + * Returns the underlying file document (or the value of a specified property). * @locus Anywhere - * @memberOf FileCursor - * @name get - * @param property {String} - Name of sub-object property - * @summary Returns current document as a plain Object, if `property` is specified - returns value of sub-object property - * @returns {Object|mix} + * @param {string} [property] - Name of the property to return. + * @returns {FileObj | any} */ get(property) { this._collection._debug(`[FilesCollection] [FileCursor] [get(${property})]`); @@ -66,220 +80,385 @@ export class FileCursor { return this._fileRef; } - /* + /** + * Returns the file document wrapped in an array. * @locus Anywhere - * @memberOf FileCursor - * @name fetch - * @summary Returns document as plain Object in Array - * @returns {[Object]} + * @returns {Array} */ fetch() { this._collection._debug('[FilesCollection] [FileCursor] [fetch()]'); return [this._fileRef]; } - /* + /** + * Asynchronously returns the file document wrapped in an array. * @locus Anywhere - * @memberOf FileCursor - * @name with - * @summary Returns reactive version of current FileCursor, useful to use with `{{#with}}...{{/with}}` block template helper - * @returns {[Object]} + * @returns {Promise>} + */ + async fetchAsync() { + this._collection._debug('[FilesCollection] [FileCursor] [fetchAsync()]'); + return [this._fileRef]; + } + + /** + * Returns a reactive version of the current FileCursor by merging in reactive fields. + * Useful for Blaze template helpers (e.g. `{{#with}}`). + * @locus Client + * @returns {FileCursor} */ with() { this._collection._debug('[FilesCollection] [FileCursor] [with()]'); - return Object.assign(this, this._collection.collection.findOne(this._fileRef._id)); + const reactiveProps = this._collection.collection.findOne(this._fileRef._id); + Object.assign(this, reactiveProps); + return this; + } + + /** + * Returns a reactive version of the current FileCursor by merging in reactive fields. + * Useful for Blaze template helpers (e.g. `{{#with}}`). + * @locus Anywhere + * @returns {Promise} + */ + async withAsync() { + this._collection._debug('[FilesCollection] [FileCursor] [withAsync()]'); + const reactiveProps = await this._collection.collection.findOneAsync(this._fileRef._id); + Object.assign(this, reactiveProps); + return this; } } -/* +/** + * @class FilesCursor * @private * @locus Anywhere - * @class FilesCursor - * @param _selector {String|Object} - Mongo-Style selector (http://docs.meteor.com/api/collections.html#selectors) - * @param options {Object} - Mongo-Style selector Options (http://docs.meteor.com/api/collections.html#selectors) - * @param _collection {FilesCollection} - FilesCollection Instance - * @summary Implementation of Cursor for FilesCollection + * @param {Mongo.Selector | Mongo.ObjectID | string} _selector - Mongo–style selector for the query. + * @param {Mongo.Options} options - Query options. + * @param {FilesCollection} _collection - FilesCollection instance. + * @summary Implementation of a cursor for FilesCollection. */ export class FilesCursor { - constructor(_selector = {}, options, _collection) { + constructor(_selector = {}, options = {}, _collection) { this._collection = _collection; this._selector = _selector; + // Initialize the current index for iteration. this._current = -1; + // Underlying Mongo cursor. this.cursor = this._collection.collection.find(this._selector, options); } - /* + /** + * Returns all matching file documents as an array. + * Alias of `.fetch()`. * @locus Anywhere - * @memberOf FilesCursor - * @name get - * @summary Returns all matching document(s) as an Array. Alias of `.fetch()` - * @returns {[Object]} + * @returns {Array} */ get() { this._collection._debug('[FilesCollection] [FilesCursor] [get()]'); - return this.cursor.fetch(); + return this.fetch(); + } + + /** + * Asynchronously returns all matching file documents as an array. + * @locus Anywhere + * @returns {Promise>} + */ + async getAsync() { + this._collection._debug('[FilesCollection] [FilesCursor] [getAsync()]'); + return await this.fetchAsync(); } - /* + /** + * Returns `true` if there is a next item available. * @locus Anywhere - * @memberOf FilesCursor - * @name hasNext - * @summary Returns `true` if there is next item available on Cursor - * @returns {Boolean} + * @deprecated since v3.0.0. use {@link FilesCursor#hasNextAsync} instead. + * @returns {boolean} */ hasNext() { this._collection._debug('[FilesCollection] [FilesCursor] [hasNext()]'); - return this._current < (this.cursor.count() - 1); + Meteor.deprecate('FilesCursor#hasNext() is deprecated! Use `hasNextAsync` instead'); + return this._current < this.count() - 1; } - /* + /** + * Asynchronously returns `true` if there is a next item available. * @locus Anywhere - * @memberOf FilesCursor - * @name next - * @summary Returns next item on Cursor, if available - * @returns {Object|undefined} + * @returns {Promise} + */ + async hasNextAsync() { + this._collection._debug('[FilesCollection] [FilesCursor] [hasNextAsync()]'); + const count = await this.countDocuments(); + return this._current < count - 1; + } + + /** + * Returns the next file document, if available. + * @locus Anywhere + * @returns {FileObj | undefined} */ next() { this._collection._debug('[FilesCollection] [FilesCursor] [next()]'); - this.cursor.fetch()[++this._current]; + const allFiles = this.fetch(); + this._current++; + return allFiles[this._current]; } - /* + /** + * Asynchronously returns the next file document, if available. * @locus Anywhere - * @memberOf FilesCursor - * @name hasPrevious - * @summary Returns `true` if there is previous item available on Cursor - * @returns {Boolean} + * @returns {Promise} + */ + async nextAsync() { + this._collection._debug('[FilesCollection] [FilesCursor] [nextAsync()]'); + const allFiles = await this.fetchAsync(); + this._current++; + return allFiles[this._current]; + } + + /** + * Returns `true` if there is a previous item available. + * @locus Anywhere + * @returns {boolean} */ hasPrevious() { this._collection._debug('[FilesCollection] [FilesCursor] [hasPrevious()]'); - return this._current !== -1; + return this._current > 0; + } + + /** + * Asynchronously returns `true` if there is a previous item available. + * @locus Anywhere + * @returns {Promise} + */ + async hasPreviousAsync() { + this._collection._debug('[FilesCollection] [FilesCursor] [hasPreviousAsync()]'); + return this._current > 0; } - /* + /** + * Returns the previous file document, if available. * @locus Anywhere - * @memberOf FilesCursor - * @name previous - * @summary Returns previous item on Cursor, if available - * @returns {Object|undefined} + * @returns {FileObj | undefined} */ previous() { this._collection._debug('[FilesCollection] [FilesCursor] [previous()]'); - this.cursor.fetch()[--this._current]; + this._current = Math.max(this._current - 1, 0); + return this.fetch()[this._current]; } - /* + /** + * Asynchronously returns the previous file document, if available. * @locus Anywhere - * @memberOf FilesCursor - * @name fetch - * @summary Returns all matching document(s) as an Array. - * @returns {[Object]} + * @returns {Promise} + */ + async previousAsync() { + this._collection._debug('[FilesCollection] [FilesCursor] [previousAsync()]'); + this._current = Math.max(this._current - 1, 0); + const allFiles = await this.fetchAsync(); + return allFiles[this._current]; + } + + /** + * Returns all matching file documents as an array. + * @locus Anywhere + * @returns {Array} */ fetch() { this._collection._debug('[FilesCollection] [FilesCursor] [fetch()]'); return this.cursor.fetch() || []; } - /* + /** + * Asynchronously returns all matching file documents as an array. * @locus Anywhere - * @memberOf FilesCursor - * @name first - * @summary Returns first item on Cursor, if available - * @returns {Object|undefined} + * @returns {Promise>} + */ + async fetchAsync() { + this._collection._debug('[FilesCollection] [FilesCursor] [fetchAsync()]'); + return (await this.cursor.fetchAsync()) || []; + } + + /** + * Returns the first file document, if available. + * @locus Anywhere + * @returns {FileObj | undefined} */ first() { this._collection._debug('[FilesCollection] [FilesCursor] [first()]'); this._current = 0; - return this.fetch()[this._current]; + const allFiles = this.fetch(); + return allFiles[this._current]; } - /* + /** + * Asynchronously returns the first file document, if available. * @locus Anywhere - * @memberOf FilesCursor - * @name last - * @summary Returns last item on Cursor, if available - * @returns {Object|undefined} + * @returns {Promise} + */ + async firstAsync() { + this._collection._debug('[FilesCollection] [FilesCursor] [firstAsync()]'); + this._current = 0; + const allFiles = await this.fetchAsync(); + return allFiles[this._current]; + } + + /** + * Returns the last file document, if available. + * @locus Anywhere + * @returns {FileObj | undefined} */ last() { this._collection._debug('[FilesCollection] [FilesCursor] [last()]'); - this._current = this.count() - 1; - return this.fetch()[this._current]; + const count = this.count(); + this._current = count - 1; + const allFiles = this.fetch(); + return count > 0 ? allFiles[this._current] : undefined; } - /* + /** + * Asynchronously returns the last file document, if available. * @locus Anywhere - * @memberOf FilesCursor - * @name count - * @summary Returns the number of documents that match a query - * @returns {Number} + * @returns {Promise} + */ + async lastAsync() { + this._collection._debug('[FilesCollection] [FilesCursor] [lastAsync()]'); + const count = await this.countDocuments(); + this._current = count - 1; + const allFiles = await this.fetchAsync(); + return count > 0 ? allFiles[this._current] : undefined; + } + + /** + * Returns the number of file documents that match the query. + * @locus Anywhere + * @deprecated since v3.0.0. use {@link FilesCursor#countDocuments} instead. + * @returns {number} */ count() { this._collection._debug('[FilesCollection] [FilesCursor] [count()]'); + Meteor.deprecate('FilesCursor#count() is deprecated! Use `countDocuments` instead'); return this.cursor.count(); } - /* + /** + * Asynchronously returns the number of file documents that match the query. + * @locus Anywhere + * @deprecated since v3.0.0. use {@link FilesCursor#countDocuments} instead. + * @returns {Promise} + */ + async countAsync() { + this._collection._debug('[FilesCollection] [FilesCursor] [countAsync()]'); + Meteor.deprecate('FilesCursor#countAsync() is deprecated! Use `countDocuments` instead'); + return await this.cursor.countAsync(); + } + + /** + * Asynchronously returns the number of file documents that match the query. * @locus Anywhere - * @memberOf FilesCursor - * @name remove - * @param callback {Function} - Triggered asynchronously after item is removed or failed to be removed - * @summary Removes all documents that match a query + * @param {Mongo.CountDocumentsOptions} [options] - CountDocumentsOptions + * @returns {Promise} + */ + async countDocuments(options) { + this._collection._debug('[FilesCollection] [FilesCursor] [countDocuments()]'); + return await this._collection.countDocuments(this._selector, options); + } + + /** + * Removes all file documents that match the query. + * @locus Client + * @param {function} [callback=() => {}] - Callback with error and number of removed records. * @returns {FilesCursor} */ - remove(callback) { + remove(callback = () => {}) { this._collection._debug('[FilesCollection] [FilesCursor] [remove()]'); this._collection.remove(this._selector, callback); return this; } - /* + /** + * Asynchronously removes all file documents that match the query. * @locus Anywhere - * @memberOf FilesCursor - * @name forEach - * @param callback {Function} - Function to call. It will be called with three arguments: the `file`, a 0-based index, and cursor itself - * @param context {Object} - An object which will be the value of `this` inside `callback` - * @summary Call `callback` once for each matching document, sequentially and synchronously. - * @returns {undefined} + * @returns {Promise} + */ + async removeAsync() { + this._collection._debug('[FilesCollection] [FilesCursor] [removeAsync()]'); + return await this._collection.removeAsync(this._selector); + } + + /** + * Synchronously iterates over each matching file document. + * @locus Anywhere + * @param {function} callback - Function invoked with (file, index, cursor). + * @param {Object} [context={}] - The context for the callback. + * @returns {FilesCursor} */ forEach(callback, context = {}) { this._collection._debug('[FilesCollection] [FilesCursor] [forEach()]'); this.cursor.forEach(callback, context); + return this; } - /* + /** + * Asynchronously iterates over each matching file document. * @locus Anywhere - * @memberOf FilesCursor - * @name each - * @summary Returns an Array of FileCursor made for each document on current cursor - * Useful when using in {{#each FilesCursor#each}}...{{/each}} block template helper - * @returns {[FileCursor]} + * @param {function} callback - Function invoked with (file, index, cursor). + * @param {Object} [context={}] - The context for the callback. + * @returns {Promise} + */ + async forEachAsync(callback, context = {}) { + this._collection._debug('[FilesCollection] [FilesCursor] [forEachAsync()]'); + await this.cursor.forEachAsync(callback, context); + return this; + } + + /** + * Returns an array of FileCursor instances (one per file document). + * Useful for Blaze’s `{{#each}}` helper. + * @locus Anywhere + * @returns {Array} */ each() { - return this.map((file) => { - return new FileCursor(file, this._collection); - }); + this._collection._debug('[FilesCollection] [FilesCursor] [each()]'); + return this.map((file) => new FileCursor(file, this._collection)); } - /* + /** + * Asynchronously returns an array of FileCursor instances (one per file document). * @locus Anywhere - * @memberOf FilesCursor - * @name map - * @param callback {Function} - Function to call. It will be called with three arguments: the `file`, a 0-based index, and cursor itself - * @param context {Object} - An object which will be the value of `this` inside `callback` - * @summary Map `callback` over all matching documents. Returns an Array. - * @returns {Array} + * @returns {Promise>} + */ + async eachAsync() { + this._collection._debug('[FilesCollection] [FilesCursor] [eachAsync()]'); + return await this.mapAsync((file) => new FileCursor(file, this._collection)); + } + + /** + * Synchronously maps a callback over all matching file documents. + * @locus Anywhere + * @param {function} callback - Function invoked with (file, index, cursor). + * @param {Object} [context={}] - The context for the callback. + * @returns {Array} */ map(callback, context = {}) { this._collection._debug('[FilesCollection] [FilesCursor] [map()]'); return this.cursor.map(callback, context); } - /* + /** + * Asynchronously maps a callback over all matching file documents. * @locus Anywhere - * @memberOf FilesCursor - * @name current - * @summary Returns current item on Cursor, if available - * @returns {Object|undefined} + * @param {function} callback - Function invoked with (file, index, cursor). + * @param {Object} [context={}] - The context for the callback. + * @returns {Promise>} + */ + async mapAsync(callback, context = {}) { + this._collection._debug('[FilesCollection] [FilesCursor] [mapAsync()]'); + return await this.cursor.mapAsync(callback, context); + } + + /** + * Returns the current file document in the cursor. + * @locus Anywhere + * @returns {FileObj | undefined} */ current() { this._collection._debug('[FilesCollection] [FilesCursor] [current()]'); @@ -289,31 +468,62 @@ export class FilesCursor { return this.fetch()[this._current]; } - /* + /** + * Asynchronously returns the current file document in the cursor. * @locus Anywhere - * @memberOf FilesCursor - * @name observe - * @param callbacks {Object} - Functions to call to deliver the result set as it changes - * @summary Watch a query. Receive callbacks as the result set changes. - * @url http://docs.meteor.com/api/collections.html#Mongo-Cursor-observe - * @returns {Object} - live query handle + * @returns {Promise} + */ + async currentAsync() { + this._collection._debug('[FilesCollection] [FilesCursor] [currentAsync()]'); + if (this._current < 0) { + this._current = 0; + } + const files = await this.fetchAsync(); + return files[this._current]; + } + + /** + * Watches a query and receives callbacks as the result set changes. + * @locus Anywhere + * @param {Mongo.ObserveCallbacks} callbacks - The observe callbacks. + * @returns {Meteor.LiveQueryHandle} */ observe(callbacks) { this._collection._debug('[FilesCollection] [FilesCursor] [observe()]'); return this.cursor.observe(callbacks); } - /* + /** + * Asynchronously watches a query and receives callbacks as the result set changes. + * @locus Anywhere + * @param {Mongo.ObserveCallbacks} callbacks - The observe callbacks. + * @returns {Promise} + */ + async observeAsync(callbacks) { + this._collection._debug('[FilesCollection] [FilesCursor] [observeAsync()]'); + return await this.cursor.observeAsync(callbacks); + } + + /** + * Watches a query for changes (only the differences) and receives callbacks. * @locus Anywhere - * @memberOf FilesCursor - * @name observeChanges - * @param callbacks {Object} - Functions to call to deliver the result set as it changes - * @summary Watch a query. Receive callbacks as the result set changes. Only the differences between the old and new documents are passed to the callbacks. - * @url http://docs.meteor.com/api/collections.html#Mongo-Cursor-observeChanges - * @returns {Object} - live query handle + * @param {Mongo.ObserveChangesCallbacks} callbacks - The observeChanges callbacks. + * @returns {Meteor.LiveQueryHandle} */ observeChanges(callbacks) { this._collection._debug('[FilesCollection] [FilesCursor] [observeChanges()]'); return this.cursor.observeChanges(callbacks); } + + /** + * Asynchronously watches a query for changes (only the differences) and receives callbacks. + * @locus Anywhere + * @param {Mongo.ObserveChangesCallbacks} callbacks - The observeChanges callbacks. + * @returns {Promise} + */ + async observeChangesAsync(callbacks) { + this._collection._debug('[FilesCollection] [FilesCursor] [observeChangesAsync()]'); + return await this.cursor.observeChangesAsync(callbacks); + } } + diff --git a/docs/FileCursor.md b/docs/FileCursor.md index dc1b4a87..7ed97526 100644 --- a/docs/FileCursor.md +++ b/docs/FileCursor.md @@ -7,16 +7,19 @@ All document's original properties is available directly by name, like: `FileCur import { FilesCollection } from 'meteor/ostrio:files'; const imagesCollection = new FilesCollection(); -const cursor = imagesCollection.findOne(); // <-- Returns FileCursor Instance +const cursor = await imagesCollection.findOneAsync(); // <-- Returns FileCursor Instance ``` #### Methods: - `remove(callback)` - {*undefined*} - Remove document. Callback has `error` argument -- `link()` - {*String*} - Returns downloadable URL to File - - `link('version')` - {*String*} - Returns downloadable URL to File's subversion - - `link('original', 'https://other-domain.com/')` - {*String*} - Returns downloadable URL to File located on other domain - - `link('original', '/')` - {*String*} - Returns __relative__ downloadable URL to File -- `get(property)` - {*Object*|*mix*} - Returns current document as a plain Object, if `property` is specified - returns value of sub-object property -- `fetch()` - {*[Object]*}- Returns current document as plain Object in Array +- `removeAsync(callback)` - {*Promise*} - Remove document. Resolves into number of removed documents +- `link()` - {*string*} - Returns downloadable URL to File + - `link('version')` - {*string*} - Returns downloadable URL to File's subversion + - `link('original', 'https://other-domain.com/')` - {*string*} - Returns downloadable URL to File located on other domain + - `link('original', '/')` - {*string*} - Returns __relative__ downloadable URL to File +- `get(property)` - {*object*|*mix*} - Returns current document as a plain Object, if `property` is specified - returns value of sub-object property +- `fetch()` - {*object[]*} - Returns current document as plain Object in Array +- `fetchAsync()` - {*Promise*}- Returns current document as plain Object in Array - `with()` - {*FileCursor*} - Returns reactive version of current FileCursor, useful to use with `{{#with cursor.with}}...{{/with}}` block template helper +- `withAsync()` - {*Promise*} - Returns reactive version of current FileCursor diff --git a/docs/FilesCursor.md b/docs/FilesCursor.md index ac3ea2cb..8919c235 100644 --- a/docs/FilesCursor.md +++ b/docs/FilesCursor.md @@ -11,23 +11,40 @@ const cursor = imagesCollection.find(); // <-- Returns FilesCursor Instance #### Methods: -- `get()` - {*[Object]*} - Returns all matching document(s) as an Array. Alias of `.fetch()` -- `hasNext()`- {*Boolean*} - Returns `true` if there is next item available on Cursor -- `next()` - {*Object*|*undefined*} - Returns next available object on Cursor -- `hasPrevious()` - {*Boolean*} - Returns `true` if there is previous item available on Cursor -- `previous()` - {*Object*|*undefined*} - Returns previous object on Cursor -- `fetch()` - {*[Object]*} - Returns all matching document(s) as an Array -- `first()` - {*Object*|*undefined*} - Returns first item on Cursor, if available -- `last()` - {*Object*|*undefined*} - Returns last item on Cursor, if available -- `count()` - {*Number*} - Returns the number of documents that match a query +- `get()` - {*object[]*} - Returns all matching document(s) as an Array. Alias of `.fetch()` +- `getAsync()` - {*Promise*} - Resolves to matching document(s) as an Array. Alias of `.fetchAsync()` +- `hasNext()`- {*boolean*} - Returns `true` if there is next item available on Cursor +- `hasNextAsync()`- {*Promise*} - Resolves to `true` if there is next item available on Cursor +- `next()` - {*object*|*undefined*} - Returns next available object on Cursor +- `nextAsync()` - {*Promise*} - Resolves to next available object on Cursor +- `hasPrevious()` - {*boolean*} - Returns `true` if there is previous item available on Cursor +- `hasPreviousAsync()` - {*Promise*} - Resolves to `true` if there is previous item available on Cursor +- `previous()` - {*object*|*undefined*} - Returns previous object on Cursor +- `previousAsync()` - {*Promise*} - Resolves to previous object on Cursor +- `fetch()` - {*object[]*} - Returns all matching document(s) as an Array +- `fetchAsync()` - {*Promise*} - Resolves to array with all matching document(s) +- `first()` - {*object*|*undefined*} - Returns first item on Cursor, if available +- `firstAsync()` - {*Promise*} - Resolves to first item on Cursor, if available +- `last()` - {*object*|*undefined*} - Returns last item on Cursor, if available +- `lastAsync()` - {*Promise*} - Resolves to the last item on Cursor, if available +- `current()` - {*object*|*undefined*} - Returns current item on Cursor, if available +- `currentAsync()` - {*Promise*} - Resolves to current item on Cursor, if available +- `count()` - {*number*} - Returns the number of documents that match a query +- `countAsync()` - {*Promise*} - Resolves to the number of documents that match a query +- `countDocuments(opts: Mongo.CountDocumentsOptions)` - {*Promise*} - Resolves to the number of documents that match a query - `remove(callback)` - {*undefined*} - Removes all documents that match a query. Callback has `error` argument -- `forEach(callback, context)` - {*undefined*} - Call `callback` once for each matching document, sequentially and synchronously. +- `removeAsync()` - {*Promise*} - Removes all documents that match a query. Resolves into number of removed records +- `forEach(callback, context)` - {*undefined*} - *Same as `forEachAsync` in arguments and context* +- `forEachAsync(callback, context)` - {*undefined*} - Call `callback` once for each matching document, sequentially and synchronously. - `callback` - {*Function*} - Function to call. It will be called with three arguments: the `file`, a 0-based index, and cursor itself - - `context` - {*Object*} - An object which will be the value of `this` inside `callback` -- `each()` - {*[FileCursor]*} - Returns an Array of `FileCursor` made for each document on current Cursor. Useful when using in `{{#each cursor.each}}...{{/each}}` block template helper -- `map(callback, context)` - {*Array*} - Map `callback` over all matching documents. Returns an Array + - `context` - {*object*} - An object which will be the value of `this` inside `callback` +- `each()` - {*FileCursor[]*} - *Same as `eachAsync` in arguments and context* +- `eachAsync()` - {*Promise*} - Returns an Array of `FileCursor` made for each document on current Cursor. Useful when using in `{{#each cursor.each}}...{{/each}}` block template helper +- `map(callback, context)` - {*object[]*} - *Same as `mapAsync` in arguments and context* +- `mapAsync(callback, context)` - {*Promise*} - Map `callback` over all matching documents. Returns an Array - `callback` - {*Function*} - Function to call. It will be called with three arguments: the `file`, a 0-based index, and cursor itself - - `context` - {*Object*} - An object which will be the value of `this` inside `callback` -- `current()` - {*Object*|*undefined*} - Returns current item on Cursor, if available -- `observe(callbacks)` - {*Object*} - Functions to call to deliver the result set as it changes. Watch a query. Receive callbacks as the result set changes. Read more [here](http://docs.meteor.com/api/collections.html#Mongo-Cursor-observe) -- `observeChanges(callbacks)` - {*Object*} - Watch a query. Receive callbacks as the result set changes. Only the differences between the old and new documents are passed to the callbacks.. Read more [here](http://docs.meteor.com/api/collections.html#Mongo-Cursor-observeChanges) + - `context` - {*object*} - An object which will be the value of `this` inside `callback` +- `observe(callbacks: Mongo.ObserveCallbacks)` - {*Meteor.LiveQueryHandle*} - *Same as `observeAsync` in arguments and context* +- `observeAsync(callbacks: Mongo.ObserveCallbacks)` - {*Promise*} - Functions to call to deliver the result set as it changes. Watch a query. Receive callbacks as the result set changes. Read more [here](http://docs.meteor.com/api/collections.html#Mongo-Cursor-observe) +- `observeChanges(callbacks: Mongo.ObserveChangesCallbacks)` - {*Meteor.LiveQueryHandle*} - *Same as `observeChangesAsync` in arguments and context* +- `observeChangesAsync(callbacks: Mongo.ObserveChangesCallbacks)` - {*Promise*} - Watch a query. Receive callbacks as the result set changes. Only the differences between the old and new documents are passed to the callbacks.. Read more [here](http://docs.meteor.com/api/collections.html#Mongo-Cursor-observeChanges) diff --git a/docs/aws-s3-integration.md b/docs/aws-s3-integration.md index 7e601be5..322becd0 100644 --- a/docs/aws-s3-integration.md +++ b/docs/aws-s3-integration.md @@ -58,7 +58,7 @@ Instead of using `settings.json`, - environment variable can be used: import { Meteor } from 'meteor/meteor'; /** env.var example: S3='{"s3":{"key": "xxx", "secret": "xxx", "bucket": "xxx", "region": "xxx""}}' **/ if (process.env.S3) { - Meteor.settings.s3 = JSON.parse(process.env.S3).s3; + Meteor.settings.app.s3 = JSON.parse(process.env.S3).s3; } ``` @@ -72,188 +72,208 @@ import { Meteor } from 'meteor/meteor'; import { _ } from 'meteor/underscore'; import { Random } from 'meteor/random'; import { FilesCollection } from 'meteor/ostrio:files'; -import stream from 'stream'; +import stream from 'node:stream'; import S3 from 'aws-sdk/clients/s3'; /* http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html */ /* See fs-extra and graceful-fs NPM packages */ /* For better i/o performance */ -import fs from 'fs'; +import fs from 'node:fs'; /* Example: S3='{"s3":{"key": "xxx", "secret": "xxx", "bucket": "xxx", "region": "xxx""}}' meteor */ if (process.env.S3) { - Meteor.settings.s3 = JSON.parse(process.env.S3).s3; + Meteor.settings.app.s3 = JSON.parse(process.env.S3).s3; } -const s3Conf = Meteor.settings.s3 || {}; -const bound = Meteor.bindEnvironment((callback) => { - return callback(); -}); +const s3Conf = Meteor.settings.app.s3 || {}; /* Check settings existence in `Meteor.settings` */ /* This is the best practice for app security */ -if (s3Conf && s3Conf.key && s3Conf.secret && s3Conf.bucket && s3Conf.region) { - // Create a new S3 object - const s3 = new S3({ - secretAccessKey: s3Conf.secret, - accessKeyId: s3Conf.key, - region: s3Conf.region, - // sslEnabled: true, // optional - httpOptions: { - timeout: 6000, - agent: false - } - }); +if (!s3Conf || !s3Conf?.key || !s3Conf?.secret || !s3Conf?.bucket || !s3Conf?.region) { + throw new Meteor.Error(401, 'Missing Meteor file settings'); +} - // Declare the Meteor file collection on the Server - const UserFiles = new FilesCollection({ - debug: false, // Change to `true` for debugging - storagePath: 'assets/app/uploads/uploadedFiles', - collectionName: 'userFiles', - // Disallow Client to execute remove, use the Meteor.method - allowClientCode: false, - - // Start moving files to AWS:S3 - // after fully received by the Meteor server - onAfterUpload(fileRef) { - // Run through each of the uploaded file - _.each(fileRef.versions, (vRef, version) => { - // We use Random.id() instead of real file's _id - // to secure files from reverse engineering on the AWS client - const filePath = 'files/' + (Random.id()) + '-' + version + '.' + fileRef.extension; - - // Create the AWS:S3 object. - // Feel free to change the storage class from, see the documentation, - // `STANDARD_IA` is the best deal for low access files. - // Key is the file name we are creating on AWS:S3, so it will be like files/XXXXXXXXXXXXXXXXX-original.XXXX - // Body is the file stream we are sending to AWS - s3.putObject({ - // ServerSideEncryption: 'AES256', // Optional - StorageClass: 'STANDARD', - Bucket: s3Conf.bucket, - Key: filePath, - Body: fs.createReadStream(vRef.path), - ContentType: vRef.type, - }, (error) => { - bound(() => { - if (error) { - console.error(error); - } else { - // Update FilesCollection with link to the file at AWS - const upd = { $set: {} }; - upd['$set']['versions.' + version + '.meta.pipePath'] = filePath; - - this.collection.update({ - _id: fileRef._id - }, upd, (updError) => { - if (updError) { - console.error(updError); - } else { - // Unlink original files from FS after successful upload to AWS:S3 - this.unlink(this.collection.findOne(fileRef._id), version); - } - }); - } - }); - }); +// Create a new S3 object +const s3 = new S3({ + secretAccessKey: s3Conf.secret, + accessKeyId: s3Conf.key, + region: s3Conf.region, + // sslEnabled: true, // optional + httpOptions: { + timeout: 6000, + agent: false + } +}); + +// Declare the Meteor file collection on the Server +const UserFiles = new FilesCollection({ + debug: false, // Change to `true` for debugging + storagePath: 'assets/app/uploads/uploadedFiles', + collectionName: 'userFiles', + // Disallow Client to execute remove, use the Meteor.method + allowClientCode: false, + + // Start moving files to AWS:S3 + // after fully received by the Meteor server + onAfterUpload(fileRef) { + // Run through each of the uploaded file + for(let version in fileRef.versions) { + if (!fileRef.versions[version]) { + continue; + } + + // Clone version's object + const vRef = _.clone(fileRef.versions[version]); + + // We use Random.id() instead of real file's _id + // to secure files from reverse engineering + // As after viewing this code it will be easy + // to get access to unlisted and protected files + const filePath = `files/${Random.id()}-${version}.${fileRef.extension}`; + const stream = fs.createReadStream(vRef.path); + stream.on('error', (error) => { + console.error('[afterUpload] [createReadStream] [ERROR:] File was not uploaded to S3', fileRef._id, error); }); - }, + client.putObject({ + StorageClass: 'STANDARD', + Bucket: s3Conf.bucket, + Key: filePath, + Body: stream, + ContentType: vRef.type, + }, async (error) => { + if (error) { + console.error('[afterUpload] [putObject] Error:', fileRef._id, error); + return; + } - // Intercept access to the file - // And redirect request to AWS:S3 - interceptDownload(http, fileRef, version) { - let path; + try { + await this.collection.updateAsync({ _id: fileRef._id }, { + $set: { + [`versions.${version}.meta.pipePath`]: filePath + } + }); + // Unlink original file from FS + // after successful upload to AWS:S3 + await this.unlinkAsync(fileRef, version); + } catch (_unlinkError) { + // If file was removed before it was fully moved to S3 + // `.updateAsync()` or `.unlinkAsync()` with throw an error + // Then we will need to remove that file from S3 + client.deleteObject({ + Bucket: s3Conf.bucket, + Key: filePath, + }, (deleteError) => { + if (deleteError) { + console.error('[afterUpload] [deleteObject] Error:', fileRef._id, deleteError); + } else { + console.info('[afterUpload] [deleteObject] unlinked file successfully removed from S3', fileRef._id); + } + }); - if (fileRef && fileRef.versions && fileRef.versions[version] && fileRef.versions[version].meta && fileRef.versions[version].meta.pipePath) { - path = fileRef.versions[version].meta.pipePath; - } + } + }); + } + }, - if (path) { - // If file is successfully moved to AWS:S3 - // We will pipe request to AWS:S3 - // So, original link will stay always secure - - // To force ?play and ?download parameters - // and to keep original file name, content-type, - // content-disposition, chunked "streaming" and cache-control - // we're using low-level .serve() method - const opts = { - Bucket: s3Conf.bucket, - Key: path - }; - - if (http.request.headers.range) { - const vRef = fileRef.versions[version]; - let range = _.clone(http.request.headers.range); - const array = range.split(/bytes=([0-9]*)-([0-9]*)/); - const start = parseInt(array[1]); - let end = parseInt(array[2]); - if (isNaN(end)) { - // Request data from AWS:S3 by small chunks - end = (start + this.chunkSize) - 1; - if (end >= vRef.size) { - end = vRef.size - 1; + // Intercept calls to `.remove()` and `.removeAsync()` to remove file from S3 + // onAfterRemove called right after record is removed from the MongoDB Collection + // and before calling `.unlink()` or `.unlinkAsync()`, + // return `true` to prevent calling `.unlink()` or `.unlinkAsync()` + onAfterRemove(docs) { + for (let i = docs.length - 1; i >= 0; i--) { + if (docs[i].versions) { + for (let version in docs[i].versions) { + if (docs[i].versions[version]) { + const vRef = docs[i].versions[version]; + if (vRef?.meta?.pipePath) { + client.deleteObject({ + Bucket: s3Conf.bucket, + Key: vRef.meta.pipePath, + }, (error) => { + if (error) { + console.error('[onAfterRemove] [deleteObject] Error:', error); + } else { + console.info('[onAfterRemove] [deleteObject] Successfully removed from S3', vRef.path, vRef.meta.pipePath); + } + }); } } - opts.Range = `bytes=${start}-${end}`; - http.request.headers.range = `bytes=${start}-${end}`; } + } + } - const fileColl = this; - s3.getObject(opts, function (error) { - if (error) { - console.error(error); - if (!http.response.finished) { - http.response.end(); - } - } else { - if (http.request.headers.range && this.httpResponse.headers['content-range']) { - // Set proper range header in according to what is returned from AWS:S3 - http.request.headers.range = this.httpResponse.headers['content-range'].split('/')[0].replace('bytes ', 'bytes='); - } + if (docs.length === 1 && docs[0].versions.original?.meta?.pipePath) { + // RETURN true TO AVOID UNNECESSARY .unlinkAsync AS FILE WAS ALREADY REMOVED FROM FS AFTER IT WAS UPLOADED TO S3 + return true; + } - const dataStream = new stream.PassThrough(); - fileColl.serve(http, fileRef, fileRef.versions[version], version, dataStream); - dataStream.end(this.data.Body); - } - }); + return false; + }, - return true; - } + // Intercept access to the file + // And redirect request to AWS:S3 + interceptDownload(http, fileRef, version) { + let path; + + if (fileRef && fileRef.versions && fileRef.versions[version] && fileRef.versions[version].meta && fileRef.versions[version].meta.pipePath) { + path = fileRef.versions[version].meta.pipePath; + } + + if (!path) { // While file is not yet uploaded to AWS:S3 // It will be served file from FS return false; } - }); - // Intercept FilesCollection's remove method to remove file from AWS:S3 - const _origRemove = UserFiles.remove; - UserFiles.remove = function (selector, callback) { - const cursor = this.collection.find(selector); - cursor.forEach((fileRef) => { - _.each(fileRef.versions, (vRef) => { - if (vRef && vRef.meta && vRef.meta.pipePath) { - // Remove the object from AWS:S3 first, then we will call the original FilesCollection remove - s3.deleteObject({ - Bucket: s3Conf.bucket, - Key: vRef.meta.pipePath, - }, (error) => { - bound(() => { - if (error) { - console.error(error); - } - }); - }); + // If file is successfully moved to AWS:S3 + // We will pipe request to AWS:S3 + // So, original link will stay always secure + + // To force ?play and ?download parameters + // and to keep original file name, content-type, + // content-disposition, chunked "streaming" and cache-control + // we're using low-level .serve() method + const opts = { + Bucket: s3Conf.bucket, + Key: path + }; + + if (http.request.headers.range) { + const vRef = fileRef.versions[version]; + let range = _.clone(http.request.headers.range); + const array = range.split(/bytes=([0-9]*)-([0-9]*)/); + const start = parseInt(array[1]); + let end = parseInt(array[2]); + + if (isNaN(end)) { + // Request data from AWS:S3 by small chunks + end = (start + this.chunkSize) - 1; + if (end >= vRef.size) { + end = vRef.size - 1; } - }); - }); + } - // Remove original file from database - _origRemove.call(this, selector, callback); - }; -} else { - throw new Meteor.Error(401, 'Missing Meteor file settings'); -} + opts.Range = `bytes=${start}-${end}`; + http.request.headers.range = `bytes=${start}-${end}`; + } + + const responseEnd = (error) => { + if (error) { + console.error('[interceptDownload] [responseEnd]', error); + } + + if (!http.response.finished) { + http.response.end(); + } + }; + + const awsStream = client.getObject(opts).createReadStream(); + awsStream.on('error', responseEnd); + + this.serve(http, fileRef, fileRef.versions[version], version, awsStream); + return true; + } +}); ``` ## Further image (JPEG, PNG) processing with AWS Lambda diff --git a/docs/constructor.md b/docs/constructor.md index 8446e9d1..eb1dc04a 100644 --- a/docs/constructor.md +++ b/docs/constructor.md @@ -2,6 +2,9 @@ *Initialize FilesCollection collection.* +- [*FilesCollection* Instance Methods](#methods) +- [Examples](#examples) + @@ -336,7 +339,7 @@ this.response
  • - this.user() + this.userAsync()
  • this.userId @@ -376,7 +379,7 @@ this.response
  • - this.user() + this.userAsync()
  • this.userId @@ -454,7 +457,7 @@ this.file
  • - this.user() + this.userAsync()
  • this.userId @@ -503,7 +506,7 @@ this.file
  • - this.user() + this.userAsync()
  • this.userId @@ -538,7 +541,7 @@ Context:
    • - this.user() + this.userAsync()
    • this.userId @@ -853,8 +856,8 @@ Default function recognizing user based on x_mtok cookie.
  • @@ -965,7 +968,29 @@
    - Usefull when you want to auth user based on custom cookie (or other way). - Must return null or {userId: null || String, user: function=> null || user } + Useful when you want to auth user based on custom cookie (or other way). + Must return null or {userId: null|String, userAsync: function => Promise<e;null|user>e; }
    -### Examples: +### Methods + +List of available methods on `FilesCollection` instance: + +- [`insert()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/insert.md) [*Client*] - Upload file(s) from client to server +- [`insertAsync()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/insertAsync.md) [*Client*] - Upload file(s) from client to server + - [`FileUpload#pipe()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/insert.md#piping) +- `write()` β€” [*DEPRECTAED IN `v3.0.0`*] +- [`writeAsync()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/writeAsync.md) [*Server*] - Write `Buffer` to FS and FilesCollection +- `load()` - [*DEPRECATED IN `v3.0.0`*] +- [`loadAsync()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/loadAsync.md) [*Server*] - Write file to FS and FilesCollection from remote URL +- [`addFile()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/addFile.md) [*Server*] - Add local file to FilesCollection from FS +- `findOne()` β€” [*DEPRECATED IN `v3.0.0`*] +- [`findOneAsync()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/findOneAsync.md) [*Anywhere*] - Find one file in FilesCollection; Returns [`FileCursor`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/FileCursor.md) +- [`find()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/find.md) [*Anywhere*] - Create cursor for FilesCollection; Returns [`File__s__Cursor`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/FilesCursor.md) +- `remove()` β€” [*DEPRECATED IN `v3.0.0`*] +- [`removeAsync()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/removeAsync.md) [*Anywhere*] - Remove files from FilesCollection and "unlink" (e.g. remove) from FS +- `unlink()` - [*DEPRECATED IN `v3.0.0`*] +- [`unlinkASync()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/unlinkAsync.md) [*Server*] - "Unlink" (e.g. remove) file from FS +- [`link()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/link.md) [*Anywhere*] - Generate downloadable link +- [`collection` property](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/collection.md) [*Anywhere*] - `Meteor.Collection` instance + +### Examples ```js import { FilesCollection, helpers } from 'meteor/ostrio:files'; @@ -1123,9 +1148,9 @@ import { FilesCollection } from 'meteor/ostrio:files'; const imagesCollection = new FilesCollection({ collectionName: 'images', allowClientCode: true, - onBeforeUpload() { + async onBeforeUpload() { if (this.userId) { - const user = this.user(); + const user = await this.userAsync(); if (user.profile.role === 'admin') { // Allow upload only if // current user is signed-in @@ -1148,9 +1173,9 @@ import { FilesCollection } from 'meteor/ostrio:files'; const imagesCollection = new FilesCollection({ collectionName: 'images', allowClientCode: true, - onBeforeRemove() { + async onBeforeRemove() { if (this.userId) { - const user = this.user(); + const user = await this.userAsync(); if (user.profile.role === 'admin') { // Allow removal only if // current user is signed-in diff --git a/docs/convert-from-cfs-to-meteor-files.md b/docs/convert-from-cfs-to-meteor-files.md index a9410628..084c4cd9 100644 --- a/docs/convert-from-cfs-to-meteor-files.md +++ b/docs/convert-from-cfs-to-meteor-files.md @@ -17,18 +17,14 @@ __Note__: this creates copies of the files on your local server, make sure there I use docker containers, so the files get wiped out on the next container deployment which is why we don't bother deleting them. ```js -let bound = Meteor.bindEnvironment(function (callback) { - return callback(); -}); - process.env.NODE_TLS_REJECT_UNAUTHORIZED = 0; // s3 specific configuration, knox npm must be installed let client = knox.createClient({ - key: Meteor.settings.s3.key, - secret: Meteor.settings.s3.secret, - bucket: Meteor.settings.s3.bucket, - region: Meteor.settings.s3.region + key: Meteor.settings.app.s3.key, + secret: Meteor.settings.app.s3.secret, + bucket: Meteor.settings.app.s3.bucket, + region: Meteor.settings.app.s3.region }); @@ -84,34 +80,32 @@ Docs.find().forEach(function (fileObj) { let filePath = "files/" + (Random.id()) + "-" + version + "." + fileRef.extension; client.putFile(fileName, filePath, {"x-amz-server-side-encryption": "AES256"}, function (error, res) { - bound(function () { - let upd; - - if (error) { - console.error(error); - } else { - upd = { - $set: {} - }; - - upd['$set']["versions." + version + ".meta.pipeFrom"] = Meteor.settings.s3.cfdomain + '/' + filePath; - upd['$set']["versions." + version + ".meta.pipePath"] = filePath; - - // Update the location and unlink the file from the FS - UserFiles.update({ - _id: fileRef._id - }, upd, function (error) { - - if (error) { - console.error(error); - } else { - // Unlink original files from FS - // after successful upload to AWS:S3 - UserFiles.unlink(UserFiles.findOne(fileRef._id), version); - } - }); - } - }); + let upd; + + if (error) { + console.error(error); + } else { + upd = { + $set: {} + }; + + upd['$set'][`versions.${version}.meta.pipeFrom`] = Meteor.settings.app.s3.cfdomain + '/' + filePath; + upd['$set'][`versions.${version}.meta.pipePath`] = filePath; + + // Update the location and unlink the file from the FS + UserFiles.update({ + _id: fileRef._id + }, upd, function (error) { + + if (error) { + console.error(error); + } else { + // Unlink original files from FS + // after successful upload to AWS:S3 + UserFiles.unlink(UserFiles.findOne(fileRef._id), version); + } + }); + } }); } }); diff --git a/docs/findOneAsync.md b/docs/findOneAsync.md new file mode 100644 index 00000000..92cd410e --- /dev/null +++ b/docs/findOneAsync.md @@ -0,0 +1,34 @@ +### `findOne([selector, options])` [*Isomorphic*] + +Finds the first document that matches the selector, as ordered by sort and skip options. + +- `selector` {*String*|*Object*} - [Mongo-Style selector](http://docs.meteor.com/api/collections.html#selectors) +- `options` {*Object*} - [Mongo-Style selector Options](http://docs.meteor.com/api/collections.html#sortspecifiers) +- Returns {*Promise<[FileCursor](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/FileCursor.md))[]>*]} + +```js +import { FilesCollection } from 'meteor/ostrio:files'; + +const imagesCollection = new FilesCollection({collectionName: 'images'}); + +// Usage: +// Set cursor: +const file = await imagesCollection.findOneAsync({_id: 'Rfy2HLutYK4XWkwhm'}); +// Generate downloadable link: +file.link(); +// Get cursor's data as plain Object: +file.get(); +file.get('_id'); // <-- returns sub-property value, if exists +// Get cursor's data as reactive Object +await file.withAsync(); +// Get cursor as array: +await file.fetchAsync(); +// Remove record from collection and file from FS +await file.removeAsync(); + + +// Direct Collection usage: +await imagesCollection.collection.findOneAsync({_id: 'Rfy2HLutYK4XWkwhm'}); +// Remove record from collection: +await imagesCollection.collection.removeAsync({_id: 'Rfy2HLutYK4XWkwhm'}); +``` diff --git a/docs/gridfs-bucket-integration.md b/docs/gridfs-bucket-integration.md index 5e981a0f..be0f1329 100644 --- a/docs/gridfs-bucket-integration.md +++ b/docs/gridfs-bucket-integration.md @@ -16,7 +16,7 @@ is available via [`files-gridfs-autoform-example`](https://github.com/veliovgrou The [MongoDB documentation on GridFS](https://docs.mongodb.com/manual/core/gridfs/) defines it as the following: > GridFS is a specification for storing and retrieving files that exceed the BSON-document size limit of 16 MB. -> +> > Instead of storing a file in a single document, GridFS divides the file into parts, or chunks [1], and stores each chunk as a separate document. By default, GridFS uses a default chunk size of 255 kB; that is, GridFS divides a file into chunks of 255 kB with the exception of the last chunk. The last chunk is only as large as necessary. @@ -27,7 +27,7 @@ Please note - by default all files will be served with `200` response code, whic only with small files, or not planning to serve files back to users (*use only upload and storage*). For support of `206` partial content see [this article](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/gridfs-streaming.md). -## 1. Create a `GridFSBucket` factory +### 1. Create a `GridFSBucket` factory Before we can use a bucket, we need to define it with a given name. This is similar to creating a collection using a name for documents. @@ -40,7 +40,7 @@ import { MongoInternals } from 'meteor/mongo'; export const createBucket = (bucketName) => { const options = bucketName ? {bucketName} : (void 0); return new MongoInternals.NpmModules.mongodb.module.GridFSBucket(MongoInternals.defaultRemoteCollectionDriver().mongo.db, options); -} +}; ``` You could later create a bucket, say `allImages`, like so @@ -51,75 +51,62 @@ const imagesBucket = createBucket('allImages'); It will be used as target when moving images to your GridFS. -## 2. Create a Mongo Object Id handler +### 2. Create a Mongo Object Id handler -For compatibility reasons we need support native Mongo `ObjectId` values. In order to simplify this, -we also wrap this in a function: +For compatibility reasons we need support native Mongo `ObjectId` values. In order to simplify this, we also wrap this in a function: ```js import { MongoInternals } from 'meteor/mongo'; -export const createObjectId = ({ gridFsFileId }) => new MongoInternals.NpmModules.mongodb.module.ObjectID(gridFsFileId); +export const createObjectId = ({ gridFsFileId }) => new MongoInternals.NpmModules.mongodb.module.ObjectId(gridFsFileId); ``` -## 3. Create an upload handler for the bucket +### 3. Create an upload handler for the bucket -Our `FilesCollection` will move the files to the GridFS using the `onAfterUpload` handler. -In order to stay flexible enough in the choice of the bucket we use a factory function: +Our `FilesCollection` will move the files to the GridFS using the `onAfterUpload` handler. In order to stay flexible enough in the choice of the bucket we use a factory function: ```js import { Meteor } from 'meteor/meteor'; import fs from 'fs'; -export const createOnAfterUpload = bucket => - function onAfterUpload (file) { +export const createOnAfterUpload = (bucket) => { + return function onAfterUpload(file) { const self = this; - // here you could manipulate your file - // and create a new version, for example a scaled 'thumbnail' - // ... - - // then we read all versions we have got so far - Object.keys(file.versions).forEach(versionName => { + // Process all versions of the uploaded file + Object.keys(file.versions).forEach((versionName) => { const metadata = { ...file.meta, versionName, fileId: file._id }; - fs.createReadStream(file.versions[ versionName ].path) - - // this is where we upload the binary to the bucket using bucket.openUploadStream - // see http://mongodb.github.io/node-mongodb-native/3.6/api/GridFSBucket.html#openUploadStream - .pipe(bucket.openUploadStream( - file.name, - { - contentType: file.type || 'binary/octet-stream', - metadata - } - )) - - // and we unlink the file from the fs on any error - // that occurred during the upload to prevent zombie files - .on('error', err => { - console.error(err); - self.unlink(this.collection.findOne(file._id), versionName); // Unlink files from FS - }) - - // once we are finished, we attach the gridFS Object id on the - // FilesCollection document's meta section and finally unlink the - // upload file from the filesystem - .on('finish', Meteor.bindEnvironment(ver => { - const property = `versions.${versionName}.meta.gridFsFileId`; - - self.collection.update(file._id, { + const uploadStream = bucket.openUploadStream(file.name, { + contentType: file.type || 'binary/octet-stream', + metadata, + }).on('finish', async () => { + const property = `versions.${versionName}.meta.gridFsFileId` + + try { + await self.collection.updateAsync(file._id, { $set: { - [ property ]: ver._id.toHexString(), - } - }); - - self.unlink(this.collection.findOne(file._id), versionName); // Unlink files from FS - })); + [property]: uploadStream.id.toHexString(), + }, + }) + } catch (error) { + console.error(error); + } finally { + await self.unlinkAsync(await this.collection.findOneAsync(file._id), versionName); + } + }).on('error', async (err) => { + console.error(err); + await self.unlinkAsync(await this.collection.findOneAsync(file._id), versionName); + }); + + const readStream = fs.createReadStream(file.versions[versionName].path).on('open', () => { + readStream.pipe(uploadStream); + }); }); }; +}; ``` -## 4. Create download handler +### 4. Create download handler We also need to handle to retrieve files from GridFS when a download is initiated. We will use the same factory function as in step 3: @@ -127,8 +114,8 @@ factory function as in step 3: ```js import { createObjectId } from '../createObjectId'; -export const createInterceptDownload = bucket => - function interceptDownload (http, file, versionName) { +export const createInterceptDownload = (bucket) => { + return function interceptDownload (http, file, versionName) { const { gridFsFileId } = file.versions[ versionName ].meta || {}; if (gridFsFileId) { // opens the download stream using a given gfs id @@ -158,37 +145,39 @@ export const createInterceptDownload = bucket => http.response.setHeader('Content-Disposition', dispositionType + dispositionName + dispositionEncoding); } return Boolean(gridFsFileId); // Serve file from either GridFS or FS if it wasn't uploaded yet - } + }; +}; ``` -## 5. Create remove handler +### 5. Create remove handler -Finally we need a handler that removes the chunks from the respective GridFS bucket when the `FilesCollection` -is removing the file handle: +Finally we need a handler that removes the chunks from the respective GridFS bucket when the `FilesCollection` is removing the file handle: ```js import { createObjectId } from '../createObjectId' -export const createOnAfterRemove = bucket => - function onAfterRemove (files) { - files.forEach(file => { - Object.keys(file.versions).forEach(versionName => { +export const createOnAfterRemove = (bucket) => { + return function onAfterRemove (files) { + files.forEach((file) => { + Object.keys(file.versions).forEach((versionName) => { const gridFsFileId = (file.versions[ versionName ].meta || {}).gridFsFileId; if (gridFsFileId) { const gfsId = createObjectId({ gridFsFileId }); - bucket.delete(gfsId, err => { - if (err) console.error(err); + bucket.delete(gfsId, (err) => { + if (err) { + console.error(err); + } }); } }); }); - } + }; +}; ``` -## 6. Create `FilesCollection` +### 6. Create `FilesCollection` -With all our given factories we can flexibly Create a `FilesCollection` instance using a specific bucket. -Let's use the previously mentioned `allImages` bucket to create our `Images` collection: +With all our given factories we can flexibly Create a `FilesCollection` instance using a specific bucket. Let's use the previously mentioned `allImages` bucket to create our `Images` collection: ```js import { Meteor } from 'meteor/meteor'; @@ -225,18 +214,21 @@ if (Meteor.isClient) { } ``` -## 7. Upload images and Check your mongo shell +### 7. Upload images and Check your mongo shell -Consider you upload two images to the imagesCollection collection, you can open your mongo shell and check the `fs.` collections: +To check uploaded images β€” open MongoDB shell (`mongosh` or `meteor mongo`) and check the `fs.` collections: -```bash +```shell $ meteor mongo meteor:PRIMARY> db.Images.find().count() 2 # should be 2 after images have been uploaded + meteor:PRIMARY> db.fs.files.find().count() 0 # should be 0 because our bucket is not "fs" but "allImages" + meteor:PRIMARY> db.allImages.files.find().count() 2 # our bucket has received two images + meteor:PRIMARY> db.allImages.chunks.find().count() 6 # and some more chunk docs ``` diff --git a/docs/insert.md b/docs/insert.md index 6b55805f..3b25ce41 100644 --- a/docs/insert.md +++ b/docs/insert.md @@ -2,8 +2,8 @@ __Insert and upload are available only from a *Client*/*Browser*/*Cordova*/etc.__ -```js -FilesCollection#insert(settings, autoStart); //[*Client*] +```ts +const upload: UploadInstance = FilesCollection#insert(settings: InsertOptions, autoStart: boolean); //[*Client*] ``` Upload file to a Server via DDP or HTTP. @@ -25,7 +25,7 @@ Upload file to a Server via DDP or HTTP. - `settings` {*Object*} + `settings` {*InsertOptions*} [REQUIRED] @@ -55,7 +55,7 @@ Upload file to a Server via DDP or HTTP. Explicitly set the fileId for the file - This is an optionnal parameters `Random.id()` will be used otherwise + This is an optional parameters `Random.id()` will be used otherwise @@ -74,7 +74,7 @@ Upload file to a Server via DDP or HTTP. `settings.isBase64` {*Boolean*} - Upload as base64 string, useful for data taken from `canvas` + Upload as `base64` string. For example when image data read from `canvas` See Examples @@ -125,7 +125,7 @@ Upload file to a Server via DDP or HTTP. Arguments:
    • `error` - *Always* `null`
    • -
    • `fileData` {*Object*}
    • +
    • `fileData` {*FileData*}
    @@ -139,7 +139,7 @@ Upload file to a Server via DDP or HTTP. Arguments:
    • `error`
    • -
    • `fileRef` {*Object*} - File record from DB
    • +
    • `FileObj` {*FileObj*} - File record from DB
    @@ -152,7 +152,7 @@ Upload file to a Server via DDP or HTTP. Callback, triggered when `abort()` method is called
    Arguments:
      -
    • `fileData` {*Object*}
    • +
    • `fileData` {*FileData*}
    @@ -166,7 +166,7 @@ Upload file to a Server via DDP or HTTP. Arguments:
    • `error`
    • -
    • `fileData` {*Object*}
    • +
    • `fileData` {*FileData*}
    @@ -180,7 +180,7 @@ Upload file to a Server via DDP or HTTP. Arguments:
    • `progress` {*Number*} - Current progress from `0` to `100`
    • -
    • `fileData` {*Object*}
    • +
    • `fileData` {*FileData*}
    @@ -193,7 +193,7 @@ Upload file to a Server via DDP or HTTP. Callback, triggered right before upload is started
    Arguments:
      -
    • `fileData` {*Object*}
    • +
    • `fileData` {*FileData*}
    @@ -234,13 +234,15 @@ Upload file to a Server via DDP or HTTP. Start upload immediately - If set to `false`, you need manually call `.start()` method on returned class. Useful to set EventListeners, before starting upload. Default: `true` + If set to `false` `.insert()` method will return `FileUpload` class instance with `.start()` method that needs to get called to actually start an upload.
    + Set to `false` to set *EventListeners* before starting and upload. Default: `true`
    + When set to `true` (*default*) `.insert()` method will return `UploadInstance` class instance, where `UploadInstance.result` will be `FileUpload` class instance -`FilesCollection#insert()` method returns `FileUpload` class instance. __Note__: same instance is used *context* in all callback functions (*see above*) +`FilesCollection#insert().start()` method returns `FileUpload` class instance. __Note__: same instance is used *context* in all callback functions (*see above*) ## `FileUpload` @@ -317,7 +319,7 @@ Upload file to a Server via DDP or HTTP. - `abort()` {*Function*} + `async abort()` {*AsyncFunction*} Abort current upload, then trigger `onAbort` callback @@ -400,7 +402,7 @@ Upload file to a Server via DDP or HTTP. Arguments:
    • `error` - *Always* `null`
    • -
    • `fileData` {*Object*}
    • +
    • `fileData` {*FileData*}
    @@ -414,7 +416,7 @@ Upload file to a Server via DDP or HTTP. Arguments:
    • - `data` {*String*} - Base64 encoded chunk (DataURL) + `data` {*string*} - Base64 encoded chunk (DataURL)
    @@ -442,7 +444,7 @@ Upload file to a Server via DDP or HTTP. Arguments:
    • `progress` {*Number*} - Current progress from `0` to `100`
    • -
    • `fileData` {*Object*}
    • +
    • `fileData` {*FileData*}
    @@ -455,7 +457,7 @@ Upload file to a Server via DDP or HTTP. Triggered after upload process set to pause.
    Arguments:
      -
    • `fileData` {*Object*}
    • +
    • `fileData` {*FileData*}
    @@ -468,7 +470,7 @@ Upload file to a Server via DDP or HTTP. Triggered after upload process is continued from pause.
    Arguments:
      -
    • `fileData` {*Object*}
    • +
    • `fileData` {*FileData*}
    @@ -481,7 +483,7 @@ Upload file to a Server via DDP or HTTP. Triggered after upload is aborted.
    Arguments:
      -
    • `fileData` {*Object*}
    • +
    • `fileData` {*FileData*}
    @@ -495,7 +497,7 @@ Upload file to a Server via DDP or HTTP. Arguments:
    • `error`
    • -
    • `fileRef` {*Object*} - File record from DB
    • +
    • `FileObj` {*FileObj*} - File record from DB
    @@ -509,7 +511,7 @@ Upload file to a Server via DDP or HTTP. Arguments:
    • `error`
    • -
    • `fileData` {*Object*}
    • +
    • `fileData` {*FileData*}
    @@ -524,7 +526,7 @@ Upload file to a Server via DDP or HTTP. Arguments:
    • `error`
    • -
    • `fileRef` {*Object*} - File record from DB
    • +
    • `FileObj` {*FileObj*} - File record from DB
    @@ -575,8 +577,8 @@ Client's code: ```js import { ReactiveVar } from 'meteor/reactive-var'; -import { Template } from 'meteor/templating'; -import { imagesCollection } from '/imports/collections/images.js'; +import { Template } from 'meteor/templating'; +import { imagesCollection } from '/imports/collections/images.js'; Template.uploadForm.onCreated(function () { this.currentFile = new ReactiveVar(false); @@ -621,7 +623,7 @@ Events-driven upload ```js import { Template } from 'meteor/templating'; -import { imagesCollection } from '/imports/collections/images.js'; +import { imagesCollection } from '/imports/collections/images.js'; Template.uploadForm.events({ 'change #fileInput'(e, template) { @@ -652,7 +654,7 @@ Another way to upload using events: ```js import { Template } from 'meteor/templating'; -import { imagesCollection } from '/imports/collections/images.js'; +import { imagesCollection } from '/imports/collections/images.js'; Template.uploadForm.events({ 'change #fileInput'(e, template) { @@ -720,14 +722,14 @@ Note: data flow in `ddp` and `http` uses dataURI (e.g. *Base64*) ```js import { Template } from 'meteor/templating'; -import { imagesCollection } from '/imports/collections/images.js'; +import { imagesCollection } from '/imports/collections/images.js'; const encrypt = function encrypt(data) { - return someHowEncryptAndReturnAsBase64(data); + return encryptAndReturnAsBase64(data); }; const zip = function zip(data) { - return someHowZipAndReturnAsBase64(data); + return zipAndReturnAsBase64(data); }; Template.uploadForm.events({ diff --git a/docs/insertAsync.md b/docs/insertAsync.md new file mode 100644 index 00000000..9dfbede3 --- /dev/null +++ b/docs/insertAsync.md @@ -0,0 +1,760 @@ +# Insert; Upload + +Upload file to a Server via DDP or HTTP. __Insert and upload are available only from a *Client*/*Browser*/*Cordova*/etc.__ + +```ts +const upload: UploadInstance = await FilesCollection#insertAsync(settings: InsertOptions, autoStart: boolean); //[*Client*] +``` + +- [*InsertOptions* in detail](#insertoptions) +- [*FileUpload* instance methods and properties](#fileupload) +- [*FileUpload* events map](#fileupload-events-map) +- [Examples](#examples) + +## InsertOptions + +Configure upload behavior and adjust its settings via *InsertOptions* + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Param/Type + + Description + + Comment +
    + `settings` {*InsertOptions*} + + [REQUIRED] + + See all options below +
    + `settings.file` {*File*} or {*Object*} or {*String*} + + [REQUIRED] HTML5 `files` item + + Ex.: From event: `event.currentTarget.files[0]` +
    + Set to dataURI {*String*} for Base64 +
    + `settings.fileId` {*String*} + + Explicitly set the fileId for the file + + This is an optional parameters `Random.id()` will be used otherwise +
    + `settings.fileName` {*String*} + + [REQUIRED] only for `base64` uploads + + For regular uploads this option is [OPTIONAL], will replace default file's name provided in HTML5 `files` item +
    + `settings.isBase64` {*Boolean*} + + Upload as `base64` string. For example when image data read from `canvas` + + See Examples +
    + `settings.meta` {*Object*} + + Additional file-related data + + Ex.: `ownerId`, `postId`, etc. +
    + `settings.transport` {*String*} + + Must be set to + `http` or `ddp` + + Note: upload via `http` is at least twice faster. `HTTP` will properly work only with "sticky sessions" +
    + Default: `ddp` +
    + `settings.ddp` {*Object*} + + Custom DDP connection for upload. Object returned form `DDP.connect()` + + By default `Meteor` (The default DDP connection) +
    + `settings.onStart` {*Function*} + + Callback, triggered when upload is started and validations was successful
    + Arguments: +
      +
    • `error` - *Always* `null`
    • +
    • `fileData` {*FileData*}
    • +
    +
    + `settings.onUploaded` {*Function*} + + Callback, triggered when upload is finished
    + Arguments: +
      +
    • `error`
    • +
    • `FileObj` {*FileObj*} - File record from DB
    • +
    +
    + `settings.onAbort` {*Function*} + + Callback, triggered when `abort()` method is called
    + Arguments: +
      +
    • `fileData` {*FileData*}
    • +
    +
    + `settings.onError` {*Function*} + + Callback, triggered when upload finished with error
    + Arguments: +
      +
    • `error`
    • +
    • `fileData` {*FileData*}
    • +
    +
    + `settings.onProgress` {*Function*} + + Callback, triggered after chunk is sent
    + Arguments: +
      +
    • `progress` {*Number*} - Current progress from `0` to `100`
    • +
    • `fileData` {*FileData*}
    • +
    +
    + `settings.onBeforeUpload` {*Function*} + + Callback, triggered right before upload is started
    + Arguments: +
      +
    • `fileData` {*FileData*}
    • +
    +
    + Use to check file-type, extension, size, etc. +
      +
    • return `true` to continue
    • +
    • return `false` to abort or {*String*} to abort upload with message
    • +
    +
    + `settings.chunkSize` {*Number*|dynamic} + + Chunk size for upload + + `dynamic` is recommended +
    + `settings.allowWebWorkers` {*Boolean*} + + Use WebWorkers (*To avoid main thread blocking*) whenever feature is available in browser + + Default: `true` +
    + `autoStart` {*Boolean*} + + Start upload immediately + + If set to `false` `.insertAsync()` method will return `FileUpload` class instance with `.start()` method that needs to get called to actually start an upload.
    + Set to `false` to set *EventListeners* before starting and upload. Default: `true`
    + When set to `true` (*default*) `.insertAsync()` method will return `UploadInstance` class instance, where `UploadInstance.result` will be `FileUpload` class instance +
    + +`await FilesCollection#insertAsync().start()` method returns `FileUpload` class instance. __Note__: same instance is used *context* in all callback functions (*see above*) + +## `FileUpload` + +*FileUpload* instance properties and methods: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Name/Type + + Description + + Comment +
    + `file` {*File*} + + Source file passed into `insertAsync()` method +
    + `onPause` {*ReactiveVar*} + + Is upload process on the pause? +
    + `progress` {*ReactiveVar*} + + Upload progress in percents + + `0` - `100` +
    + `pause()` {*Function*} + + Pause upload process +
    + `continue()` {*Function*} + + Continue paused upload process +
    + `toggle()` {*Function*} + + Toggle `continue`/`pause` if upload in the progress +
    + `async abort()` {*AsyncFunction*} + + Abort current upload, then trigger `onAbort` callback +
    + `pipe()` {*Function*} + + Pipe data before upload + + All data must be in `data URI` scheme (*Base64*) +
    + `estimateTime` {*ReactiveVar*} + + Remaining upload time in milliseconds +
    + `estimateSpeed` {*ReactiveVar*} + + Current upload speed in bytes/second + + To convert into speed, take a look on filesize package, usage: `filesize(estimateSpeed, {bits: true}) + '/s';` +
    + `state` {*ReactiveVar*} + + String, indicates current state of the upload + +
      +
    • `active` - file is currently actively uploading
    • +
    • `paused` - file upload is paused
    • +
    • `aborted` - file upload has been aborted and can no longer be completed
    • +
    • `completed` - file has been successfully uploaded
    • +
    +
    + +### `FileUpload` events map + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Name + + Description + + Comment +
    + `start` + + Triggered when upload is started (*before sending first byte*) and validations was successful.
    + Arguments: +
      +
    • `error` - *Always* `null`
    • +
    • `fileData` {*FileData*}
    • +
    +
    + `data` + + Triggered after each chunk is read.
    + Arguments: +
      +
    • + `data` {*string*} - Base64 encoded chunk (DataURL) +
    • +
    +
    + Can be used to display previews or do something else with loaded file during upload. To get EOF use `readEnd` event +
    + `readEnd` + + Triggered after file is fully read by browser + + Has no arguments +
    + `progress` + + Triggered after each chunk is sent.
    + Arguments: +
      +
    • `progress` {*Number*} - Current progress from `0` to `100`
    • +
    • `fileData` {*FileData*}
    • +
    +
    + `pause` + + Triggered after upload process set to pause.
    + Arguments: +
      +
    • `fileData` {*FileData*}
    • +
    +
    + `continue` + + Triggered after upload process is continued from pause.
    + Arguments: +
      +
    • `fileData` {*FileData*}
    • +
    +
    + `abort` + + Triggered after upload is aborted.
    + Arguments: +
      +
    • `fileData` {*FileData*}
    • +
    +
    + `uploaded` + + Triggered when upload is finished.
    + Arguments: +
      +
    • `error`
    • +
    • `FileObj` {*FileObj*} - File record from DB
    • +
    +
    + `error` + + Triggered whenever upload has an error.
    + Arguments: +
      +
    • `error`
    • +
    • `fileData` {*FileData*}
    • +
    +
    + `end` + + Triggered at the very end of upload or by `.abort()`.
    + In case if `end` triggered by `.abort()`, the server could return a `408` response code.
    + Arguments: +
      +
    • `error`
    • +
    • `FileObj` {*FileObj*} - File record from DB
    • +
    +
    + +When `autoStart` *is* `false` *before calling* `.start()` you can "pipe" data through any function, data comes as Base64 string (DataURL). You must return Base64 string from piping function, for more info - see example below. __Do not forget to change file name, extension and mime-type if required__. + +The `fileData` object (*see above*): + +- `size` {*Number*} - File size in bytes +- `type` {*String*} +- `mime`, `mime-type` {*String*} +- `ext`, `extension` {*String*} +- `name` {*String*} - File name + +## Examples + +Upload form and `.insertasync()` method examples + +### Upload form: + +```handlebars + +``` + +Shared code: + +```js +// /imports/collections/images.js +import { FilesCollection } from 'meteor/ostrio:files'; + +const imagesCollection = new FilesCollection({collectionName: 'images'}); +// Export created instance of the FilesCollection +export { imagesCollection }; +``` + +Client's code: + +```js +import { ReactiveVar } from 'meteor/reactive-var'; +import { Template } from 'meteor/templating'; +import { imagesCollection } from '/imports/collections/images.js'; + +Template.uploadForm.onCreated(function () { + this.currentFile = new ReactiveVar(false); +}); + +Template.uploadForm.helpers({ + currentFile() { + Template.instance().currentFile.get(); + } +}); + +Template.uploadForm.events({ + async 'change #fileInput'(e, template) { + if (e.currentTarget.files && e.currentTarget.files[0]) { + // We upload only one file, in case + // there was multiple files selected + const file = e.currentTarget.files[0]; + + await imagesCollection.insertAsync({ + file: file, + onStart() { + template.currentFile.set(this); + }, + onUploaded(error, fileObj) { + if (error) { + alert('Error during upload: ' + error); + } else { + alert(`File "${fileObj.name}" successfully uploaded`); + } + template.currentFile.set(false); + }, + chunkSize: 'dynamic' + }); + } + } +}); +``` + +### Using events + +Events-driven upload + +```js +import { Template } from 'meteor/templating'; +import { imagesCollection } from '/imports/collections/images.js'; + +Template.uploadForm.events({ + async 'change #fileInput'(e, template) { + if (e.currentTarget.files && e.currentTarget.files[0]) { + // We upload only one file, in case + // multiple files were selected + const uploader = await imagesCollection.insertAsync({ + file: e.currentTarget.files[0], + chunkSize: 'dynamic' + }, false).on('start', function () { + template.currentFile.set(this); + }).on('end', function (error, fileObj) { + if (error) { + alert('Error during upload: ' + error); + } else { + alert(`File "${fileObj.name}" successfully uploaded`); + } + template.currentFile.set(false); + }); + + await uploader.start(); + } + } +}); +``` + +### Using events no.2 + +Another way to upload using events: + +```js +import { Template } from 'meteor/templating'; +import { imagesCollection } from '/imports/collections/images.js'; + +Template.uploadForm.events({ + async 'change #fileInput'(e, template) { + if (e.currentTarget.files && e.currentTarget.files[0]) { + const uploader = await imagesCollection.insertAsync({ + file: e.currentTarget.files[0], + chunkSize: 'dynamic' + }, false); + + uploader.on('start', function () { + template.currentFile.set(this); + }); + + uploader.on('end', function (error, fileObj) { + template.currentFile.set(false); + }); + + uploader.on('uploaded', function (error, fileObj) { + if (!error) { + alert(`File "${fileObj.name}" successfully uploaded`); + } + }); + + uploader.on('error', function (error, fileObj) { + alert('Error during upload: ' + error); + }); + + await uploader.start(); + } + } +}); +``` + +### Upload base64 String + +```js +import { imagesCollection } from '/imports/collections/images.js'; + +// As dataURI +await imagesCollection.insertAsync ({ + file: 'data:image/png,base64str…', + isBase64: true, // <β€” Mandatory + fileName: 'pic.png' // <β€” Mandatory +}); + +// As base64: +await imagesCollection.insertAsync({ + file: 'image/png,base64str…', + isBase64: true, // <β€” Mandatory + fileName: 'pic.png' // <β€” Mandatory +}); + +// As plain base64: +await imagesCollection.insertAsync({ + file: 'base64str…', + isBase64: true, // <β€” Mandatory + fileName: 'pic.png', // <β€” Mandatory + type: 'image/png' // <β€” Mandatory +}); +``` + +### Piping + +Note: data flow in `ddp` and `http` uses dataURI (e.g. *Base64*) + +```js +import { Template } from 'meteor/templating'; +import { imagesCollection } from '/imports/collections/images.js'; + +const encrypt = function encrypt(data) { + return encryptAndReturnAsBase64(data); +}; + +const zip = function zip(data) { + return zipAndReturnAsBase64(data); +}; + +Template.uploadForm.events({ + async 'change #fileInput'(e) { + if (e.currentTarget.files && e.currentTarget.files[0]) { + // We upload only one file, in case + // multiple files were selected + const uploader = await imagesCollection.insertAsync({ + file: e.currentTarget.files[0], + chunkSize: 'dynamic' + }, false).pipe(encrypt).pipe(zip); + + await uploader.start(); + } + } +}); +``` diff --git a/docs/link.md b/docs/link.md index ab3b69d1..eb84e42f 100644 --- a/docs/link.md +++ b/docs/link.md @@ -1,6 +1,6 @@ # Link; or get downloadable URL -Use [`fileUrl`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/template-helper.md) helper with Blaze to get *downloadable* URL to a file. +Use [`fileURL`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/template-helper.md) helper with Blaze to get *downloadable* URL to a file. There are two options to get *downloadable* URL to the uploaded file using `.link()` method: diff --git a/docs/load.md b/docs/load.md index 62a28122..48b4f4be 100644 --- a/docs/load.md +++ b/docs/load.md @@ -1,43 +1,3 @@ # Download remote file over HTTP -Download remote file over HTTP to the file system of a *Server*. Download is efficient, runs in chunks writing data as available directly to FS via stream without holding it in RAM. If error or timeout occurred β€” unfinished file will get removed from file system. - -```js -/* - * @locus Server - */ - -FilesCollection#load(url [, opts, callback, proceedAfterUpload]); -``` - -Write file to file system from remote URL (external resource) and add record to FilesCollection - -- `url` {*String*} - Full address to file, like `https://example.com/sample.png` -- `opts` {*Object*} - Recommended properties: - - `opts.fileName` {*String*} - File name with extension, like `name.ext` - - `opts.headers` {*Object*} - Request HTTP headers, to use when requesting the file - - `opts.meta` {*Object*} - Object with custom meta-data - - `opts.type` {*String*} - Mime-type, like `image/png`, if not set - mime-type will be taken from response headers - - `opts.size` {*Number*} - File size in bytes, if not set - file size will be taken from response headers - - `opts.userId` {*String*} - UserId, default: `null` - - `opts.fileId` {*String*} - id, optional - if not set - Random.id() will be used - - `opts.timeout` {*Number*} - timeout in milliseconds, default: `360000` (*6 mins*); Set to `0` to disable timeout; *Disabling timeout not recommended, sockets won't get closed until server rebooted* -- `callback` {*Function*} - Triggered after first byte is received. With `error`, and `fileRef`, where `fileRef` is a new record from DB -- `proceedAfterUpload` {*Boolean*} - Proceed `onAfterUpload` hook (*if defined*) after external source is loaded to FS -- Returns {*FilesCollection*} - Current FilesCollection instance - -```js -/* - * @locus Server - */ - -import { FilesCollection } from 'meteor/ostrio:files'; -const imagesCollection = new FilesCollection({collectionName: 'images'}); - -imagesCollection.load('https://raw.githubusercontent.com/veliovgroup/Meteor-Files/master/logo.png', { - fileName: 'logo.png', - fileId: 'abc123myId', //optional - timeout: 60000, // optional timeout - meta: {} -}); -``` +`FilesCollection#load` method is __DEPRECATED SINCE `v3.0.0`__ β€” Use [`loadAsync()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/loadAsync.md) instead diff --git a/docs/loadAsync.md b/docs/loadAsync.md new file mode 100644 index 00000000..6aca5830 --- /dev/null +++ b/docs/loadAsync.md @@ -0,0 +1,42 @@ +# Download remote file over HTTP + +Download remote file over HTTP to the file system of a *Server*. Download is efficient, runs in chunks writing data as available directly to FS via stream without holding it in RAM. If error or timeout occurred β€” unfinished file will get removed from file system. + +```js +/* + * @locus Server + */ + +await FilesCollection#loadAsync(url [, opts: LoadOpts, proceedAfterUpload: boolean]): Promise; +``` + +Write file to file system from remote URL (external resource) and add record to FilesCollection + +- `url` {*string*} - Full address to file, like `https://example.com/sample.png` +- `opts?` {*LoadOpts*} - *Optional* Recommended properties: + - `opts.fileName` {*string*} - File name with extension, like `name.ext` + - `opts.headers` {*object*} - Request HTTP headers, to use when requesting the file + - `opts.meta` {*object*} - Object with custom meta-data + - `opts.type` {*string*} - Mime-type, like `image/png`, if not set - mime-type will be taken from response headers + - `opts.size` {*number*} - File size in bytes, if not set - file size will be taken from response headers + - `opts.userId` {*string*} - UserId, default: `null` + - `opts.fileId` {*string*} - id, optional - if not set - Random.id() will be used + - `opts.timeout` {*number*} - timeout in milliseconds, default: `360000` (*6 mins*); Set to `0` to disable timeout; *Disabling timeout not recommended, sockets won't get closed until server rebooted* +- `proceedAfterUpload?` {*boolean*} - *Optional* Proceed `onAfterUpload` hook (*if defined*) after external source is loaded to FS +- Returns {*Promise*} - File Object from DB + +```js +/* + * @locus Server + */ + +import { FilesCollection } from 'meteor/ostrio:files'; +const imagesCollection = new FilesCollection({ collectionName: 'images' }); + +const fileObj = await imagesCollection.loadAsync('https://raw.githubusercontent.com/veliovgroup/Meteor-Files/master/logo.png', { + fileName: 'logo.png', + fileId: 'abc123myId', //optional + timeout: 60000, // optional timeout + meta: {} +}); +``` diff --git a/docs/mirgation-to-v3.md b/docs/mirgation-to-v3.md new file mode 100644 index 00000000..21800a88 --- /dev/null +++ b/docs/mirgation-to-v3.md @@ -0,0 +1,119 @@ +πŸ“¦ v3.0.0 + +- [πŸ“¦ Packosphere `@3.0.0`](https://packosphere.com/ostrio/files/3.0.0) +- [β˜„οΈ AtmosphereJS `@3.0.0`](https://atmospherejs.com/ostrio/files) + +### Summary + +- ✨ Refactor: Hook options: `protected`, `onBeforeRemove`, `onAfterRemove`, `onInitiateUpload`, `onAfterUpload`, `namingFunction` are now *async* +- 🀝 Refactor: Compatibility with `meteor@3` and other modern packages +- β˜„οΈ Refactor: Match `FilesCollection` APIs with new `*Async` methods of `Mongo.Collection`; Deprecate callback APIs on the Server +- πŸ‘¨β€πŸ’» Refactor: Utilize node's async APIs where suitable +- πŸ‘¨β€πŸ’» Refactor: Improve pause/resume logic on connection interruption/reconnect +- πŸ“” Docs: Updated and refactored docs with better examples +- πŸ“” Docs: Refactored JSDoc matching definitions in TypeScript +- πŸ€“ Dev: Improved TypeScript support +- πŸ‘·β€β™‚οΈ Dev: Improved debugging logs +- πŸ‘¨β€πŸ”¬ Tests: Improved test-suite +- πŸ‘·β€β™‚οΈ Git: CI GitHub Action Workflows for lint and build tests + +### Major changes + +__FilesCollection__: + +- ⚠️ `FilesCollection#remove()` β€” deprecated on server, use `FilesCollection#removeAsync` instead +- ⚠️ `FilesCollection#findOne()` β€” deprecated on server, use `FilesCollection#findOneAsync` instead +- ⚠️ `FilesCollection#unlink()` β€” deprecated on server, use `FilesCollection#unlinkAsync` instead +- ⚠️ `FilesCollection#write()` β€” deprecated on server, use `FilesCollection#writeAsync` instead +- ⚠️ `FilesCollection#load()` β€” deprecated on server, use `FilesCollection#loadAsync` instead + +__FileCursor__: + +- ⚠️ `FileCursor#remove()` β€” deprecated on server, use `FileCursor#removeAsync` instead + +__FilesCursor__: + +- ⚠️ `FilesCursor#remove()` β€” deprecated on server, use `FilesCursor#removeAsync` instead +- ⚠️ `FilesCursor#hasNext()` - deprecated, use `FilesCursor#hasNextAsync` instead +- ⚠️ `FilesCursor#count()` - deprecated, use `FilesCursor#countDocuments` instead +- ⚠️ `FilesCursor#countAsync()` - deprecated, use `FilesCursor#countDocuments` instead + +__FileUpload__: + +- ⚠️ `FileUpload#start()` is now *async*! + +__Callbacks and hooks__: + +- ⚠️ Anywhere: `this.user()` is deprecated, use `this.userAsync()` instead +- ⚠️ Client: `FileUpload` now always triggers `end` even in the case of successful and failed uploads; *Before: `end` event wasn't called under certain conditions* +- ⚠️ Client: All errors appeared during upload in all hooks and events of `FileUpload` are now instance of `Meteor.Error`; *Before: Errors had mixed type or were simply text* +- ⚠️ Client: Errors are the same now (type, code, text, reason, details) within DDP and HTTP protocols; *Before: DDP and HTTP protocols had different errors* +- ⚠️ Client: The next *private* events were removed from `UploadInstance` Class: `upload`, `sendEOF`, `prepare`, `sendChunk`, `proceedChunk` + +### New methods + +__FilesCollection__: + +- ✨ Client: `FilesCollection#insertAsync()` +- ✨ Anywhere: `FilesCollection#updateAsync()` +- ✨ Anywhere: `FilesCollection#removeAsync()` +- ✨ Anywhere: `FilesCollection#findOneAsync()` +- ✨ Anywhere: `FilesCollection#countDocuments()` +- ✨ Anywhere: `FilesCollection#estimatedDocumentCount()` +- ✨ Server: `FilesCollection#unlinkAsync()` +- ✨ Server: `FilesCollection#writeAsync()` +- ✨ Server: `FilesCollection#loadAsync()` + +__FileCursor__: + +- ✨ Anywhere: `FileCursor#removeAsync()` +- ✨ Anywhere: `FileCursor#fetchAsync()` +- ✨ Anywhere: `FileCursor#withAsync()` + +__FilesCursor__: + +- ✨ Anywhere: `FilesCursor#getAsync()` +- ✨ Anywhere: `FilesCursor#hasNextAsync()` +- ✨ Anywhere: `FilesCursor#nextAsync()` +- ✨ Anywhere: `FilesCursor#hasPreviousAsync()` +- ✨ Anywhere: `FilesCursor#previousAsync()` +- ✨ Anywhere: `FilesCursor#removeAsync()` +- ✨ Anywhere: `FilesCursor#fetchAsync()` +- ✨ Anywhere: `FilesCursor#firstAsync()` +- ✨ Anywhere: `FilesCursor#lastAsync()` +- ✨ Anywhere: `FilesCursor#countDocuments()` +- ✨ Anywhere: `FilesCursor#forEachAsync()` +- ✨ Anywhere: `FilesCursor#eachAsync()` +- ✨ Anywhere: `FilesCursor#mapAsync()` +- ✨ Anywhere: `FilesCursor#currentAsync()` +- ✨ Anywhere: `FilesCursor#observeAsync()` +- ✨ Anywhere: `FilesCursor#observeChangesAsync()` + +### New features + +- ✨ Client: `FileUpload#remainingTime` *ReactiveVar* β€” returns remaining upload time in `hh:mm:ss` format; + +### Other changes + +- 🐞 Bug: Fixed #885 β€” Upload empty file now `end` upload with error +- 🐞 Bug: Fixed #901 β€” Caused by #894 +- πŸ”§ Security: Fixed #894 β€” now `x_mtok` cookie is set with `secure` and `httpOnly` flags +- πŸ”§ Refactor: Fixed `FileCursor#with` implementation +- ✨ Refactor: `FileUpload#abort` is now `async` +- ✨ Server: `FilesCollection#addFile` is now *async* +- ✨ Server: `FilesCollection#download` is now *async* +- ✨ Server: `WriteStream` Class is now available for import +- βš™οΈ Client: `disableUpload` option processed on the client and returns error in the `end`/`error` events, and `onError` hooks. *Before β€” throws an error upon calling `.insert()` method* + +### Dependencies + +__release__: + +- `eventemitter3@5.0.1`, *was `4.0.7`* +- *removed:* `abort-controller`, now using native `AbortController` +- *removed:* `fs-extra`, now using native `fs` + +__dev__: + +- *added:* `chai@4.5.0` +- *added:* `sinon@7.5.0` diff --git a/docs/react-example.md b/docs/react-example.md index ee14a4bc..aed06558 100644 --- a/docs/react-example.md +++ b/docs/react-example.md @@ -57,7 +57,7 @@ class FileUploadComponent extends Component { if (e.currentTarget.files && e.currentTarget.files[0]) { // We upload only one file, in case // there was multiple files selected - var file = e.currentTarget.files[0]; + const file = e.currentTarget.files[0]; if (file) { let uploadInstance = UserFiles.insert({ diff --git a/docs/readme.md b/docs/readme.md index 524c42d3..3992ab64 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -56,18 +56,7 @@ Meteor-Files library features and highlights ### API: -- [`FilesCollection` Constructor](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/constructor.md) [*Isomorphic*] -- [`insert()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/insert.md) [*Client*] - Upload file(s) from client to server - - [`FileUpload#pipe()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/insert.md#piping) -- [`write()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/write.md) [*Server*] - Write `Buffer` to FS and FilesCollection -- [`load()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/load.md) [*Server*] - Write file to FS and FilesCollection from remote URL -- [`addFile()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/addFile.md) [*Server*] - Add local file to FilesCollection from FS -- [`findOne()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/findOne.md) [*Isomorphic*] - Find one file in FilesCollection; Returns [`FileCursor`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/FileCursor.md) -- [`find()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/find.md) [*Isomorphic*] - Create cursor for FilesCollection; Returns [`File__s__Cursor`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/FilesCursor.md) -- [`remove()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/remove.md) [*Isomorphic*] - Remove files from FilesCollection and "unlink" (e.g. remove) from FS -- [`unlink()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/unlink.md) [*Server*] - "Unlink" (e.g. remove) file from FS -- [`link()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/link.md) [*Isomorphic*] - Generate downloadable link -- [`collection`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/collection.md) [*Isomorphic*] - `Meteor.Collection` instance +- [`FilesCollection` Constructor](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/constructor.md) [*Anywhere*] - [Template helper `fileURL`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/template-helper.md) [*Client*] - Generate downloadable link in a template - Initialize FilesCollection - [SimpleSchema Integration](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/constructor.md#attach-schema-isomorphic) @@ -77,22 +66,22 @@ Meteor-Files library features and highlights - [Control remove access](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/constructor.md#use-onbeforeremove-to-avoid-unauthorized-remove) - [Custom response headers](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/custom-response-headers.md#custom-response-headers) for CORS or anything else - [`FileCursor` Class](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/FileCursor.md) - Instance of this class is returned from [`.findOne()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/findOne.md) method - - `remove(callback)` - {*undefined*} - Remove document - - `link()` - {*String*} - Returns downloadable URL to File - - `get(property)` - {*Object*|*mix*} - Returns current document as a plain Object - - `fetch()` - {[*Object*]}- Returns current document as plain Object in Array + - `removeAsync()` - {*Promise*} - Remove document, resolves to number of removed records + - `link()` - {*string*} - Returns downloadable URL to File + - `get(property)` - {*object*|*mix*} - Returns current document as a plain object + - `fetchAsync()` - {*Promise*} - Resolves to current document as plain object in Array - `with()` - {*FileCursor*} - Returns reactive version of current FileCursor + - [__See all *FileCursor* methods__](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/FileCursor.md) - [`FilesCursor` Class](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/FilesCursor.md) - Instance of this class is returned from [`.find()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/find.md) method - - `fetch()` - {*[Object]*} - Returns all matching document(s) as an Array - - `count()` - {*Number*} - Returns the number of documents that match a query - - `remove(callback)` - {*undefined*} - Removes all documents that match a query - - `forEach(callback, context)` - {*undefined*} - Call `callback` once for each matching document - - `each()` - {*[FileCursor]*} - Returns an Array of `FileCursor` made for each document on current Cursor - - `observe(callbacks)` - {*Object*} - Functions to call to deliver the result set as it changes - - `observeChanges(callbacks)` - {*Object*} - Watch a query. Receive callbacks as the result set changes - - [See all methods](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/FilesCursor.md) + - `fetchAsync()` - {*Promise*} - Returns all matching document(s) as an Array + - `countAsync()` - {*Promise*} - Returns the number of documents that match a query + - `removeAsync()` - {*Promise*} - Removes all documents that match a query, resolves to a number of removed records + - `forEachAsync(callback, context)` - {*undefined*} - Call `callback` once for each matching document + - `eachAsync()` - {*Promise*} - Resolves to Array of `FileCursor` made for each document on current Cursor + - `observeAsync(callbacks)` - {*Promise*} - Functions to call to deliver the result set as it changes + - `observeChangesAsync(callbacks)` - {*Promise*} - Watch a query. Receive callbacks as the result set changes + - [__See all *FilesCursor* methods__](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/FilesCursor.md) - [Default Collection Schema](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/schema.md#schema) - - [Attach SimpleSchema and Collection2](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/schema.md#attach-schema-recommended) - [Extend Schema](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/schema.md#extend-default-schema) - [Override Schema](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/schema.md#pass-your-own-schema-not-recommended) diff --git a/docs/remove.md b/docs/remove.md index 1d89a02d..748afb82 100644 --- a/docs/remove.md +++ b/docs/remove.md @@ -42,12 +42,12 @@ import { FilesCollection } from 'meteor/ostrio:files'; const imagesCollection = new FilesCollection({ collectionName: 'images', allowClientCode: true, - onBeforeRemove(cursor) { + async onBeforeRemove(cursor) { const records = cursor.fetch(); if (records && records.length) { if (this.userId) { - const user = this.user(); + const user = await this.userAsync(); // Assuming user.profile.docs is array // with file's records _id(s) diff --git a/docs/removeAsync.md b/docs/removeAsync.md new file mode 100644 index 00000000..b8713f3f --- /dev/null +++ b/docs/removeAsync.md @@ -0,0 +1,63 @@ +### `removeAsync` [*Anywhere*] + +```ts +FilesCollection#removeAsync(selector: MeteorFilesSelector): Promise +``` + +Remove records from FilesCollection and files from FS. + +- `selector` {*Object*} - See [Mongo Selectors](http://docs.meteor.com/#selectors) +- Returns {*Promise*} - Number of removed records + +```js +import { FilesCollection } from 'meteor/ostrio:files'; + +const imagesCollection = new FilesCollection({collectionName: 'images'}); + +// Usage: +// Drop collection's data and remove all associated files from FS +imagesCollection.removeAsync({}); +// Remove particular file +imagesCollection.removeAsync({_id: 'Rfy2HLutYK4XWkwhm'}); +// Equals to above +const file = await imagesCollection.findOneAsync({_id: 'Rfy2HLutYK4XWkwhm'}) +await file.removeAsync(); + + +// Direct Collection usage +// Remove record(s) ONLY from collection +await imagesCollection.collection.removeAsync({}); +``` + +*Use onBeforeRemove to avoid unauthorized actions, for more info see [`onBeforeRemove` callback](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/constructor.md#use-onbeforeremove-to-avoid-unauthorized-remove)* + +```js +import { FilesCollection } from 'meteor/ostrio:files'; + +const imagesCollection = new FilesCollection({ + collectionName: 'images', + allowClientCode: true, + async onBeforeRemove(cursor) { + const records = await cursor.fetchAsync(); + + if (records && records.length) { + if (this.userId) { + const user = await this.userAsync(); + + // Assuming user.profile.docs is array + // with file's records _id(s) + for (let i = 0, len = records.length; i < len; i++) { + const file = records[i]; + if (!~user.profile.docs.indexOf(file._id)) { + // Return false if at least one document + // is not owned by current user + return false; + } + } + } + } + + return true; + } +}); +``` diff --git a/docs/template-helper.md b/docs/template-helper.md index e86b10fe..50b1f7a3 100644 --- a/docs/template-helper.md +++ b/docs/template-helper.md @@ -1,21 +1,21 @@ ### Template Helper `fileURL` [*Client*] ```js -import { Meteor } from 'meteor/meteor'; -import { Template } from 'meteor/templating'; +import { Meteor } from 'meteor/meteor'; +import { Template } from 'meteor/templating'; import { FilesCollection } from 'meteor/ostrio:files'; -const Files = new FilesCollection({collectionName: 'Files'}); +const files = new FilesCollection({ collectionName: 'Files' }); if (Meteor.isClient) { Meteor.subscribe('files.all'); Template.example.helpers({ - fileRef: Files.collection.findOne({}) + fileRef: files.collection.findOne({}) }); } else { Meteor.publish('files.all', function () { - return Files.collection.find({}); + return files.collection.find({}); }); } ``` diff --git a/docs/toc.md b/docs/toc.md index 524c42d3..1ed94472 100644 --- a/docs/toc.md +++ b/docs/toc.md @@ -1,125 +1 @@ -# Meteor-Files Documentation - -Explore documentation and examples for files' upload and its custom integration into Meteor.js application - -## ToC: - -Browse [documentation directory](https://github.com/veliovgroup/Meteor-Files/tree/master/docs) or navigate using lost of links below. - -- [About Meteor-Files package](#about) -- [API](#api) -- [Examples](#examples) -- [Demos](#demos) -- [Related packages](#related-packages) - -## About: - -Meteor-Files library features and highlights - -- Event-driven API -- [TypeScript Definitions](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/typescript-definitions.md) -- Upload / Read files in Cordova app: __Cordova support__ (Any with support of `FileReader`) -- File upload: - - Upload via *HTTP* or *DDP*, [read about difference](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/about-transports.md#about-upload-transports) - - Ready for small and large files (optimized RAM usage) - - Pause / Resume upload - - Auto-pause when connection to server is interrupted - - Parallel multi-stream async upload (*faster than ever*) - - Support of non-Latin (non-Roman) file names -- [Use third-party storage](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/3rd-party-storage.md): - - [AWS S3 Bucket Integration](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/aws-s3-integration.md) - - [DropBox Integration](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/dropbox-integration.md) - - [GridFS using `GridFSBucket`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/gridfs-bucket-integration.md#use-gridfs-with-gridfsbucket-as-a-storage) - - [GridFS using `gridfs-stream` (legacy)](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/gridfs-integration.md) - - Google Drive - - [Google Cloud Storage Integration](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/google-cloud-storage-integration.md) - - any other with JS/REST API -- Get upload speed -- Get remaining upload time -- Serving files (download): - - Custom download `route` - - Download compatible with small and large files, including progressive (`chunked`) download - - Correct `mime-type` and `Content-Range` headers - - Correct `206` and `416` responses - - Following [RFC 2616](https://tools.ietf.org/html/rfc2616) - - Control access to files - - Files CRC check (*integrity check*) - - Serve public files with a server like __nginx__ -- Write to file system (`fs.`): - - Automatically writes files on FS and special Collection - - `path`, collection name, schema, chunk size and naming function is under your control - - Support for file subversions, like thumbnails, audio/video file formats, revisions, and etc. -- Store wherever you like: - - You may use `Meteor-Files` as temporary storage - - After file is uploaded and stored on FS you able to `mv` or `cp` its content, see [3rd-party storage integration](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/3rd-party-storage.md) examples -- Subscribe on files (*collections*) you need - -### API: - -- [`FilesCollection` Constructor](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/constructor.md) [*Isomorphic*] -- [`insert()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/insert.md) [*Client*] - Upload file(s) from client to server - - [`FileUpload#pipe()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/insert.md#piping) -- [`write()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/write.md) [*Server*] - Write `Buffer` to FS and FilesCollection -- [`load()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/load.md) [*Server*] - Write file to FS and FilesCollection from remote URL -- [`addFile()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/addFile.md) [*Server*] - Add local file to FilesCollection from FS -- [`findOne()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/findOne.md) [*Isomorphic*] - Find one file in FilesCollection; Returns [`FileCursor`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/FileCursor.md) -- [`find()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/find.md) [*Isomorphic*] - Create cursor for FilesCollection; Returns [`File__s__Cursor`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/FilesCursor.md) -- [`remove()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/remove.md) [*Isomorphic*] - Remove files from FilesCollection and "unlink" (e.g. remove) from FS -- [`unlink()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/unlink.md) [*Server*] - "Unlink" (e.g. remove) file from FS -- [`link()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/link.md) [*Isomorphic*] - Generate downloadable link -- [`collection`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/collection.md) [*Isomorphic*] - `Meteor.Collection` instance -- [Template helper `fileURL`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/template-helper.md) [*Client*] - Generate downloadable link in a template -- Initialize FilesCollection - - [SimpleSchema Integration](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/constructor.md#attach-schema-isomorphic) - - [Collection `deny` rules](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/constructor.md#deny-collection-interaction-on-client-server) - - [Collection `allow` rules](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/constructor.md#allow-collection-interaction-on-client-server) - - [Control upload access](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/constructor.md#use-onbeforeupload-to-avoid-unauthorized-upload) - - [Control remove access](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/constructor.md#use-onbeforeremove-to-avoid-unauthorized-remove) - - [Custom response headers](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/custom-response-headers.md#custom-response-headers) for CORS or anything else -- [`FileCursor` Class](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/FileCursor.md) - Instance of this class is returned from [`.findOne()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/findOne.md) method - - `remove(callback)` - {*undefined*} - Remove document - - `link()` - {*String*} - Returns downloadable URL to File - - `get(property)` - {*Object*|*mix*} - Returns current document as a plain Object - - `fetch()` - {[*Object*]}- Returns current document as plain Object in Array - - `with()` - {*FileCursor*} - Returns reactive version of current FileCursor -- [`FilesCursor` Class](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/FilesCursor.md) - Instance of this class is returned from [`.find()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/find.md) method - - `fetch()` - {*[Object]*} - Returns all matching document(s) as an Array - - `count()` - {*Number*} - Returns the number of documents that match a query - - `remove(callback)` - {*undefined*} - Removes all documents that match a query - - `forEach(callback, context)` - {*undefined*} - Call `callback` once for each matching document - - `each()` - {*[FileCursor]*} - Returns an Array of `FileCursor` made for each document on current Cursor - - `observe(callbacks)` - {*Object*} - Functions to call to deliver the result set as it changes - - `observeChanges(callbacks)` - {*Object*} - Watch a query. Receive callbacks as the result set changes - - [See all methods](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/FilesCursor.md) -- [Default Collection Schema](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/schema.md#schema) - - [Attach SimpleSchema and Collection2](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/schema.md#attach-schema-recommended) - - [Extend Schema](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/schema.md#extend-default-schema) - - [Override Schema](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/schema.md#pass-your-own-schema-not-recommended) - -### Demos: - -- [Simplest upload app](https://github.com/veliovgroup/Meteor-Files-Demos/tree/master/demo-simplest-upload) -- [Simplest streaming app](https://github.com/veliovgroup/Meteor-Files-Demos/tree/master/demo-simplest-streaming) -- [Simplest download button](https://github.com/veliovgroup/Meteor-Files-Demos/tree/master/demo-simplest-download-button) -- [Fully-featured file sharing app](https://github.com/veliovgroup/meteor-files-website#file-sharing-web-app) β€” [live: __files.veliov.com__](https://files.veliov.com) - -### Examples: - -- `docs` [Third-party storage (AWS S3, DropBox, GridFS and Google Storage)](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/3rd-party-storage.md) -- `code-sample` [File subversions](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/file-subversions.md) - Create video file with preview and multiple formats -- `code-sample repo` [cURL/POST upload](https://github.com/noris666/Meteor-Files-POST-Example) by [@noris666](https://github.com/noris666) -- `tutorial` [MUP/Docker Persistent Storage](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/meteorup-usage.md) - Deploy via MeteorUp to Docker container with persistent `storagePath` -- `tutorial` [React.js usage](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/react-example.md) - React with a progress bar and component with links to view, re-name, and delete the files -- `tutorial` [Migrating from CollectionFS/CFS](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/convert-from-cfs-to-meteor-files.md) - Live conversion from the depreciated CFS to Meteor-Files (*Amazon S3 specifically, but applies to all*) -- `tutorial` [Getting `FilesCollection` instance](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/collection-instances.md#filescollection-instances-and-mongocollection-instances) - Retrieve the *FilesCollection* by it's underlying `Mongo.Collection` instance -- `tutorial` [Migrating / moving GridFS stored files](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/gridfs-migration.md) - Three step way of moving/copying/syncing GridFS-stored files between multiple Meteor applications -- `tutorial` [GridFS streaming](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/gridfs-streaming.md) - Implement `206` partial content response -- __Post-processing:__ - - `tutorial` [Create Thumbnails](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/image-processing.md) - - `tutorial` [Image post-processing using AWS Lambda](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/aws-s3-integration.md#further-image-jpeg-png-processing-with-aws-lambda) - - `code-sample` [Resize, create thumbnail](https://github.com/veliovgroup/meteor-files-website/blob/master/imports/server/image-processing.js#L19) - -### Related packages: - -- [`pyfiles` (meteor-python-files)](https://github.com/veliovgroup/meteor-python-files) Python Client for Meteor-Files package -- [`meteor-autoform-file`](https://github.com/veliovgroup/meteor-autoform-file) - Upload and manage files with [autoForm](https://github.com/aldeed/meteor-autoform) +See [main README.md file](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/readme.md) \ No newline at end of file diff --git a/docs/typescript-definitions.md b/docs/typescript-definitions.md index e1d1f11d..ea523136 100644 --- a/docs/typescript-definitions.md +++ b/docs/typescript-definitions.md @@ -1,248 +1,3 @@ # TypeScript Definitions -```ts -declare module "meteor/ostrio:files" { - import { Meteor } from 'meteor/meteor'; - import { Mongo } from 'meteor/mongo'; - import { ReactiveVar } from 'meteor/reactive-var'; - import { SimpleSchemaDefinition } from 'simpl-schema'; - import * as http from 'http'; - import { IncomingMessage } from 'connect'; - - interface Params { - _id: string; - query: { [key: string]: string }; - name: string; - version: string; - } - - interface ContextHTTP { - request: IncomingMessage; - response: http.ServerResponse; - params: Params; - } - - interface ContextUser { - userId: string; - user: () => Meteor.User; - } - - interface ContextUpload { - file: object; - /** On server only. */ - chunkId?: number; - /** On server only. */ - eof?: boolean; - } - - interface Version { - extension: string; - meta: MetadataType; - path: string; - size: number; - type: string; - } - - class FileObj { - _id: string; - size: number; - name: string; - type: string; - path: string; - isVideo: boolean; - isAudio: boolean; - isImage: boolean; - isText: boolean; - isJSON: boolean; - isPDF: boolean; - ext?: string; - extension?: string; - extensionWithDot: string; - _storagePath: string; - _downloadRoute: string; - _collectionName: string; - public?: boolean; - meta?: MetadataType; - userId?: string; - updatedAt?: Date; - versions: { - [propName: string]: Version; - }; - mime: string; - "mime-type": string; - } - - class FileRef extends FileObj { - remove: (callback?: (error: Meteor.Error) => void) => void; - link: (version?: string, location?: string) => string; - get: (property?: string) => any; - fetch: () => Array>; - with: () => FileCursor; - } - - interface FileData { - size: number; - type: string; - mime: string; - "mime-type": string; - ext: string; - extension: string; - name: string; - meta: MetadataType; - } - - interface FilesCollectionConfig { - storagePath?: string | ((fileObj: FileObj) => string); - collection?: Mongo.Collection>; - collectionName?: string; - continueUploadTTL?: string; - ddp?: object; - cacheControl?: string; - responseHeaders?: { [x: string]: string } | ((responseCode?: string, fileRef?: FileRef, versionRef?: Version, version?: string) => { [x: string]: string }); - throttle?: number | boolean; - downloadRoute?: string; - schema?: SimpleSchemaDefinition; - chunkSize?: number; - namingFunction?: (fileObj: FileObj) => string; - permissions?: number; - parentDirPermissions?: number; - integrityCheck?: boolean; - strict?: boolean; - downloadCallback?: (this: ContextHTTP & ContextUser, fileObj: FileObj) => boolean; - protected?: boolean | ((this: ContextHTTP & ContextUser, fileObj: FileObj) => boolean | number); - public?: boolean; - onBeforeUpload?: (this: ContextUpload & ContextUser, fileData: FileData) => boolean | string; - onBeforeRemove?: (this: ContextUser, cursor: Mongo.Cursor>) => boolean; - onInitiateUpload?: (this: ContextUpload & ContextUser, fileData: FileData) => void; - onAfterUpload?: (fileRef: FileRef) => any; - onAfterRemove?: (files: ReadonlyArray>) => any; - onbeforeunloadMessage?: string | (() => string); - allowClientCode?: boolean; - debug?: boolean; - interceptDownload?: (http: object, fileRef: FileRef, version: string) => boolean; - } - - interface SearchOptions { - sort?: Mongo.SortSpecifier; - skip?: number; - limit?: number; - fields?: Mongo.FieldSpecifier; - reactive?: boolean; - transform?: (fileObj: FileObj) => FileObj & TransformAdditions; - } - - interface InsertOptions { - file: File | object | string; - fileId?: string; - fileName?: string; - isBase64?: boolean; - meta?: MetadataType; - transport?: 'ddp' | 'http'; - ddp?: object; - onStart?: (error: Meteor.Error, fileData: FileData) => any; - onUploaded?: (error: Meteor.Error, fileRef: FileRef) => any; - onAbort?: (fileData: FileData) => any; - onError?: (error: Meteor.Error, fileData: FileData) => any; - onProgress?: (progress: number, fileData: FileData) => any; - onBeforeUpload?: (fileData: FileData) => any; - chunkSize?: number | 'dynamic'; - allowWebWorkers?: boolean; - type?: string; - } - - interface LoadOptions { - fileName: string; - meta?: MetadataType; - type?: string; - size?: number; - userId?: string; - fileId?: string; - } - - class FileUpload { - file: File; - onPause: ReactiveVar; - progress: ReactiveVar; - estimateTime: ReactiveVar; - estimateSpeed: ReactiveVar; - state: ReactiveVar<'active' | 'paused' | 'aborted' | 'completed'>; - pause(): void; - continue(): void; - toggle(): void; - pipe(): void; - start(): void; - on(event: string, callback: () => void): void; - } - - class FileCursor extends FileRef { } - - class FilesCursor extends Mongo.Cursor> { - cursor: Mongo.Cursor>; // Refers to base cursor? Why is this existing? - - get(): Array & TransformAdditions>; - hasNext(): boolean; - next(): FileCursor & TransformAdditions; - hasPrevious(): boolean; - previous(): FileCursor & TransformAdditions; - first(): FileCursor & TransformAdditions; - last(): FileCursor & TransformAdditions; - remove(callback?: (err: object) => void): void; - each(callback: (cursor: FileCursor & TransformAdditions) => void): void; - current(): object | undefined; - } - - class FilesCollection { - collection: Mongo.Collection>; - schema: SimpleSchemaDefinition; - - constructor(config: FilesCollectionConfig) - - /** - * Find and return Cursor for matching documents. - * - * @param selector [[http://docs.meteor.com/api/collections.html#selectors | Mongo-Style selector]] - * @param options [[http://docs.meteor.com/api/collections.html#sortspecifiers | Mongo-Style selector Options]] - * - * @template TransformAdditions Additional properties provided by transforming a document with options.tranform(). - * Note that removing fields with a transform function is not currently supported as this may break - * functions defined on a FileRef or FileCursor. - */ - find( - selector?: Mongo.Selector>>, - options?: SearchOptions - ): FilesCursor; - - /** - * Finds the first document that matches the selector, as ordered by sort and skip options. - * - * @param selector [[http://docs.meteor.com/api/collections.html#selectors | Mongo-Style selector]] - * @param options [[http://docs.meteor.com/api/collections.html#sortspecifiers | Mongo-Style selector Options]] - * - * @template TransformAdditions Additional properties provided by transforming a document with options.tranform(). - * Note that removing fields with a transform function is not currently supported as this may break - * functions defined on a FileRef or FileCursor. - */ - findOne( - selector?: Mongo.Selector>> | string, - options?: SearchOptions - ): FileCursor & TransformAdditions; - - insert(settings: InsertOptions, autoStart?: boolean): FileUpload; - remove(select: Mongo.Selector> | string, callback?: (error: Meteor.Error) => void): FilesCollection; - update(select: Mongo.Selector> | string, modifier: Mongo.Modifier>, options?: { - multi?: boolean; - upsert?: boolean; - arrayFilters?: Array<{ [identifier: string]: any }>; - }, callback?: (error: Meteor.Error, insertedCount: number) => void): FilesCollection; - link(fileRef: FileRef, version?: string): string; - allow(options: Mongo.AllowDenyOptions): void; - deny(options: Mongo.AllowDenyOptions): void; - denyClient(): void; - on(event: string, callback: (fileRef: FileRef) => void): void; - unlink(fileRef: FileRef, version?: string): FilesCollection; - addFile(path: string, opts: LoadOptions, callback?: (err: any, fileRef: FileRef) => any, proceedAfterUpload?: boolean): FilesCollection; - load(url: string, opts: LoadOptions, callback?: (err: object, fileRef: FileRef) => any, proceedAfterUpload?: boolean): FilesCollection; - write(buffer: Buffer, opts: LoadOptions, callback?: (err: object, fileRef: FileRef) => any, proceedAfterUpload?: boolean): FilesCollection; - } -} -``` +TypeScript definitions located in [`index.d.ts`](https://github.com/veliovgroup/Meteor-Files/blob/master/index.d.ts) diff --git a/docs/unlink.md b/docs/unlink.md index a6f6a4e0..c2af0a6d 100644 --- a/docs/unlink.md +++ b/docs/unlink.md @@ -1,21 +1,3 @@ ### `unlink(fileRef [, version, callback])` [*Server*] -Unlink file an its subversions from FS. - -__This is low-level method. You shouldn't use it__, unless you know what you're doing. - -Unlike [`fs.remove`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/remove.md) if `callback` is not specified method wouldn't throw an exception on error. - -- `fileRef` {*Object*} - Full `fileRef` object, returned from `FilesCollection.findOne().get()` -- `version` {*String*} - [Optional] If specified, only subversion will be unlinked -- `callback` {*Function*} - [Optional] Triggered after file is removed. If cursor has multiple files, will be triggered for each file. If file has multiple subversions, will be triggered for each version. -- Returns {*FilesCollection*} - Current FilesCollection instance - -```js -import { FilesCollection } from 'meteor/ostrio:files'; -const imagesCollection = new FilesCollection({collectionName: 'images'}); -imagesCollection.unlink(Images.collection.findOne({})); -// OR: -imagesCollection.unlink(Images.collection.findOne({}), 'thumbnail'); -``` - +DEPRECATED SINCE `v3.0.0`, use [`unlinkASync()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/unlinkAsync.md) instead diff --git a/docs/unlinkAsync.md b/docs/unlinkAsync.md new file mode 100644 index 00000000..f0d3b204 --- /dev/null +++ b/docs/unlinkAsync.md @@ -0,0 +1,23 @@ +### `unlinkAsync` [*Server*] + +Unlink file an its subversions from FS. + +```ts +FilesCollection#unlinkAsync(fileRef: FileObj, version?: string): Promise +``` + +> [!WARNING] +> __This is low-level method. You shouldn't use it__, unless you know what you're doing. + +- `fileRef` {*object*} - Full `fileRef` object, returned from `(await FilesCollection.findOneAsync()).get()` +- `version` {*string*} - [Optional] If specified, only subversion will be unlinked +- Returns {*promise*} - Current FilesCollection instance + +```js +import { FilesCollection } from 'meteor/ostrio:files'; + +const imagesCollection = new FilesCollection({collectionName: 'images'}); +await imagesCollection.unlinkAsync(await Images.collection.findOneAsync({})); +// Unlink a version of the file: +await imagesCollection.unlinkAsync(await Images.collection.findOneAsync({}), 'thumbnail'); +``` diff --git a/docs/write.md b/docs/write.md index 5e5cd560..1dc00407 100644 --- a/docs/write.md +++ b/docs/write.md @@ -1,40 +1,3 @@ ### `write(buffer [, opts, callback, proceedAfterUpload])` [*Server*] -Write `Buffer` to FS and add record to Files collection. This function is asynchronous. - -- `buffer` {*Buffer*} - File data as `Buffer` -- `opts` {*Object*} - Recommended properties: - - `opts.fileName` {*String*} - File name with extension, like `name.ext` - - `opts.type` {*String*} - Mime-type, like `image/png` - - `opts.size` {*Number*} - File size in bytes, if not set file size will be calculated from `Buffer` - - `opts.meta` {*Object*} - Object with custom meta-data - - `opts.userId` {*String*} - UserId, default *null* - - `opts.fileId` {*String*} - id, optional - if not set - Random.id() will be used -- `callback` {*Function*} - Triggered after file is written to FS. With `error`, and `fileRef`, where `fileRef` is a new record from DB -- `proceedAfterUpload` {*Boolean*} - Proceed `onAfterUpload` hook (*if defined*) after `Buffer` is written to FS -- Returns {*Files*} - Current FilesCollection instance - -```js -import fs from 'fs'; -import { FilesCollection } from 'meteor/ostrio:files'; - -const imagesCollection = new FilesCollection({collectionName: 'images'}); - -fs.readFile('/data/imgs/sample.png', (error, data) => { - if (error) { - throw error; - } else { - imagesCollection.write(data, { - fileName: 'sample.png', - fileId: 'abc123myId', //optional - type: 'image/png' - }, (writeError, fileRef) => { - if (writeError) { - throw writeError; - } else { - console.log(`${fileRef.name} is successfully saved to FS. _id: ${fileRef._id}`); - } - }); - } -}); -``` +DEPRECATED SINCE `v3.0.0`, use [`writeAsync()`](https://github.com/veliovgroup/Meteor-Files/blob/master/docs/writeAsync.md) instead. diff --git a/docs/writeAsync.md b/docs/writeAsync.md new file mode 100644 index 00000000..be6a143b --- /dev/null +++ b/docs/writeAsync.md @@ -0,0 +1,37 @@ +### `writeAsync` [*Server*] + +Write `Buffer` to FS and add record to Files collection. This function is asynchronous. + +```ts +FilesCollection#writeAsync(buffer: Buffer, opts?: WriteOpts, proceedAfterUpload?: boolean): Promise; +``` + +- `buffer` {*Buffer*} - File data as `Buffer` +- `opts` {*object*} - Recommended properties: + - `opts.fileName` {*string*} - File name with extension, like `name.ext` + - `opts.type` {*string*} - Mime-type, like `image/png` + - `opts.size` {*number*} - File size in bytes, if not set file size will be calculated from `Buffer` + - `opts.meta` {*object*} - Object with custom meta-data + - `opts.userId` {*string*} - UserId, default *null* + - `opts.fileId` {*string*} - id, optional - if not set - Random.id() will be used +- `proceedAfterUpload` {*boolean*} - Proceed `onAfterUpload` hook (*if defined*) after `Buffer` is written to FS +- Returns {*Promise*} - newly created file's object from DB + +```js +import { readFile } from 'node:fs/promises'; +import { FilesCollection } from 'meteor/ostrio:files'; + +const imagesCollection = new FilesCollection({collectionName: 'images'}); + +try { + const data = await readFile('/data/imgs/sample.png'); + const fileRef = await imagesCollection.writeAsync(data, { + fileName: 'sample.png', + fileId: 'abc123myId', + type: 'image/png' + }); + console.log(`${fileRef.name} is successfully saved to FS. _id: ${fileRef._id}`); +} catch (error) { + console.error('Failed to save image:', error); +} +``` diff --git a/index.d.ts b/index.d.ts index 6c3024df..d1aa35bb 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,30 +1,35 @@ -declare module "meteor/ostrio:files" { - import { Meteor } from 'meteor/meteor'; - import { Mongo } from 'meteor/mongo'; - import { ReactiveVar } from 'meteor/reactive-var'; - import { SimpleSchemaDefinition } from 'simpl-schema'; - import * as http from 'http'; - import { IncomingMessage } from 'connect'; - - interface Params { +import { EventEmitter } from 'eventemitter3'; +import type { Meteor } from 'meteor/meteor'; +import type { Mongo } from 'meteor/mongo'; +import type { CountDocumentsOptions, EstimatedDocumentCountOptions } from 'mongodb'; +import type { ReactiveVar } from 'meteor/reactive-var'; +import type SimpleSchema from 'simpl-schema'; +import type * as http from 'node:http'; +import type { IncomingMessage } from 'connect'; +import type { DDP } from 'meteor/ddp'; + +declare module 'meteor/ostrio:files' { + export interface ParamsHTTP { _id: string; - query: { [key: string]: string }; + query: { + [key: string]: string + }; name: string; version: string; } - interface ContextHTTP { + export interface ContextHTTP { request: IncomingMessage; response: http.ServerResponse; - params: Params; + params: ParamsHTTP; } - interface ContextUser { + export interface ContextUser { userId: string; user: () => Meteor.User; } - interface ContextUpload { + export interface ContextUpload { file: object; /** On server only. */ chunkId?: number; @@ -32,213 +37,612 @@ declare module "meteor/ostrio:files" { eof?: boolean; } - interface Version { - extension: string; - meta: MetadataType; - path: string; - size: number; - type: string; - } - - class FileObj { - _id: string; - size: number; - name: string; - type: string; - path: string; - isVideo: boolean; - isAudio: boolean; - isImage: boolean; - isText: boolean; - isJSON: boolean; - isPDF: boolean; - ext?: string; - extension?: string; - extensionWithDot: string; - _storagePath: string; - _downloadRoute: string; - _collectionName: string; - public?: boolean; - meta?: MetadataType; - userId?: string; - updatedAt?: Date; - versions: { - [propName: string]: Version; - }; - mime: string; - "mime-type": string; - } - - class FileRef extends FileObj { - remove: (callback?: (error: Meteor.Error) => void) => void; - link: (version?: string, location?: string) => string; - get: (property?: string) => any; - fetch: () => Array>; - with: () => FileCursor; - } - - interface FileData { - size: number; - type: string; - mime: string; - "mime-type": string; - ext: string; - extension: string; - name: string; - meta: MetadataType; - } - - interface FilesCollectionConfig { - storagePath?: string | ((fileObj: FileObj) => string); - collection?: Mongo.Collection>; - collectionName?: string; - continueUploadTTL?: string; - ddp?: object; - cacheControl?: string; - responseHeaders?: { [x: string]: string } | ((responseCode?: string, fileRef?: FileRef, versionRef?: Version, version?: string) => { [x: string]: string }); - throttle?: number | boolean; - downloadRoute?: string; - schema?: SimpleSchemaDefinition; - chunkSize?: number; - namingFunction?: (fileObj: FileObj) => string; - permissions?: number; - parentDirPermissions?: number; - integrityCheck?: boolean; - strict?: boolean; - downloadCallback?: (this: ContextHTTP & ContextUser, fileObj: FileObj) => boolean; - protected?: boolean | ((this: ContextHTTP & ContextUser, fileObj: FileObj) => boolean | number); - public?: boolean; - onBeforeUpload?: (this: ContextUpload & ContextUser, fileData: FileData) => boolean | string; - onBeforeRemove?: (this: ContextUser, cursor: Mongo.Cursor>) => boolean; - onInitiateUpload?: (this: ContextUpload & ContextUser, fileData: FileData) => void; - onAfterUpload?: (fileRef: FileRef) => any; - onAfterRemove?: (files: ReadonlyArray>) => any; - onbeforeunloadMessage?: string | (() => string); - allowClientCode?: boolean; - debug?: boolean; - interceptDownload?: (http: object, fileRef: FileRef, version: string) => boolean; - } - - interface SearchOptions { - sort?: Mongo.SortSpecifier; - skip?: number; - limit?: number; - fields?: Mongo.FieldSpecifier; - reactive?: boolean; - transform?: (fileObj: FileObj) => FileObj & TransformAdditions; - } - - interface InsertOptions { - file: File | object | string; - fileId?: string; - fileName?: string; - isBase64?: boolean; - meta?: MetadataType; - transport?: 'ddp' | 'http'; - ddp?: object; - onStart?: (error: Meteor.Error, fileData: FileData) => any; - onUploaded?: (error: Meteor.Error, fileRef: FileRef) => any; - onAbort?: (fileData: FileData) => any; - onError?: (error: Meteor.Error, fileData: FileData) => any; - onProgress?: (progress: number, fileData: FileData) => any; - onBeforeUpload?: (fileData: FileData) => any; - chunkSize?: number | 'dynamic'; - allowWebWorkers?: boolean; - type?: string; - } - - interface LoadOptions { - fileName: string; - meta?: MetadataType; - type?: string; - size?: number; - userId?: string; - fileId?: string; - } - - class FileUpload { - file: File; - onPause: ReactiveVar; - progress: ReactiveVar; - estimateTime: ReactiveVar; - estimateSpeed: ReactiveVar; - state: ReactiveVar<'active' | 'paused' | 'aborted' | 'completed'>; - pause(): void; - continue(): void; - toggle(): void; - pipe(): void; - start(): void; - on(event: string, callback: () => void): void; - } - - class FileCursor extends FileRef { } - - class FilesCursor extends Mongo.Cursor> { - cursor: Mongo.Cursor>; // Refers to base cursor? Why is this existing? - - get(): Array & TransformAdditions>; - hasNext(): boolean; - next(): FileCursor & TransformAdditions; - hasPrevious(): boolean; - previous(): FileCursor & TransformAdditions; - first(): FileCursor & TransformAdditions; - last(): FileCursor & TransformAdditions; - remove(callback?: (err: object) => void): void; - each(callback: (cursor: FileCursor & TransformAdditions) => void): void; - current(): object | undefined; - } - - class FilesCollection { - collection: Mongo.Collection>; - schema: SimpleSchemaDefinition; - - constructor(config: FilesCollectionConfig) - - /** - * Find and return Cursor for matching documents. - * - * @param selector [[http://docs.meteor.com/api/collections.html#selectors | Mongo-Style selector]] - * @param options [[http://docs.meteor.com/api/collections.html#sortspecifiers | Mongo-Style selector Options]] - * - * @template TransformAdditions Additional properties provided by transforming a document with options.tranform(). - * Note that removing fields with a transform function is not currently supported as this may break - * functions defined on a FileRef or FileCursor. - */ - find( - selector?: Mongo.Selector>>, - options?: SearchOptions - ): FilesCursor; - - /** - * Finds the first document that matches the selector, as ordered by sort and skip options. - * - * @param selector [[http://docs.meteor.com/api/collections.html#selectors | Mongo-Style selector]] - * @param options [[http://docs.meteor.com/api/collections.html#sortspecifiers | Mongo-Style selector Options]] - * - * @template TransformAdditions Additional properties provided by transforming a document with options.tranform(). - * Note that removing fields with a transform function is not currently supported as this may break - * functions defined on a FileRef or FileCursor. - */ - findOne( - selector?: Mongo.Selector>> | string, - options?: SearchOptions - ): FileCursor & TransformAdditions; - - insert(settings: InsertOptions, autoStart?: boolean): FileUpload; - remove(select: Mongo.Selector> | string, callback?: (error: Meteor.Error) => void): FilesCollection; - update(select: Mongo.Selector> | string, modifier: Mongo.Modifier>, options?: { - multi?: boolean; - upsert?: boolean; - arrayFilters?: Array<{ [identifier: string]: any }>; - }, callback?: (error: Meteor.Error, insertedCount: number) => void): FilesCollection; - link(fileRef: FileRef, version?: string): string; - allow(options: Mongo.AllowDenyOptions): void; - deny(options: Mongo.AllowDenyOptions): void; - denyClient(): void; - on(event: string, callback: (fileRef: FileRef) => void): void; - unlink(fileRef: FileRef, version?: string): FilesCollection; - addFile(path: string, opts: LoadOptions, callback?: (err: any, fileRef: FileRef) => any, proceedAfterUpload?: boolean): FilesCollection; - load(url: string, opts: LoadOptions, callback?: (err: object, fileRef: FileRef) => any, proceedAfterUpload?: boolean): FilesCollection; - write(buffer: Buffer, opts: LoadOptions, callback?: (err: object, fileRef: FileRef) => any, proceedAfterUpload?: boolean): FilesCollection; + export type MeteorFilesTransportType = 'http' | 'ddp'; + export type MetadataType = Record | {}; + export type MeteorFilesSelector = Mongo.Selector | Mongo.ObjectID | string; + export type MeteorFilesOptions = Mongo.Options; + export type FileHandleCache = Map; + + export interface Version { + extension: string; + meta: MetadataType; + path: string; + size: number; + type: string; + } + + export interface FileObj { + _id: string; + size: number; + name: string; + type: string; + path: string; + isVideo: boolean; + isAudio: boolean; + isImage: boolean; + isText: boolean; + isJSON: boolean; + isPDF: boolean; + ext?: string; + extension?: string; + chunkSize?: number; + extensionWithDot: string; + _storagePath: string; + _downloadRoute: string; + _collectionName: string; + public?: boolean; + meta?: MetadataType; + userId?: string; + updatedAt?: Date; + versions: { + [propName: string]: Version; + }; + mime: string; + 'mime-type': string; + } + + export interface FileData { + size: number; + type: string; + mime: string; + 'mime-type': string; + ext: string; + extension: string; + name: string; + meta: MetadataType; + } + + /** + * A writable stream wrapper that ensures chunks are written in the correct order. + */ + export class WriteStream { + /** + * Creates a new WriteStream instance. + * @param path - The file system path where the file will be written. + * @param maxLength - The maximum number of chunks expected. + * @param file - An object containing file properties such as `size` and `chunkSize`. + * @param permissions - The file permissions (octal string) to use when opening the file (e.g., '611' or '0o777'). + */ + constructor(path: string, maxLength: number, file: FileObj, permissions: string); + + /** + * Initializes the WriteStream by ensuring the directory exists, creating the file, + * preallocating the file size, and caching the file handle. + * @returns A promise that resolves with this WriteStream instance. + */ + init(): Promise; + + /** + * Writes a chunk to the file at the specified chunk position. + * @param num - The 1-indexed position of the chunk. + * @param chunk - The buffer containing the chunk data. + * @returns A promise that resolves to true if the chunk was successfully written, or false if not. + */ + write(num: number, chunk: Buffer): Promise; + + /** + * Waits for all chunks to be written, polling for completion up to a timeout. + * @returns A promise that resolves to true if the file was fully written, or false if writing was aborted. + */ + waitForCompletion(): Promise; + + /** + * Finishes writing to the stream after ensuring that all chunks are written. + * @returns A promise that resolves to true if the stream is fully written, or false if it is still in progress. + */ + end(): Promise; + + /** + * Aborts the writing process and removes the created file. + * @returns A promise that resolves to true once the abort process is complete. + */ + abort(): Promise; + + /** + * Stops the writing process. + * @param isAborted - Indicates whether the stop is due to an abort. + * @returns A promise that resolves to true once the stream is stopped. + */ + stop(isAborted?: boolean): Promise; + } + + /** + * Core class for FilesCollection. Most other classes extend and build on this one. + */ + export class FilesCollectionCore extends EventEmitter { + // Instance properties that are used in the class: + collection: Mongo.Collection; + debug?: boolean; + downloadRoute?: string; + collectionName?: string; + storagePath: (data: Partial) => string; + + constructor(); + + /** Helper functions available as a static property */ + static __helpers: unknown; + + /** Default schema definition */ + static schema: { + _id: { type: string }; + size: { type: number }; + name: { type: string }; + type: { type: string }; + path: { type: string }; + isVideo: { type: boolean }; + isAudio: { type: boolean }; + isImage: { type: boolean }; + isText: { type: boolean }; + isJSON: { type: boolean }; + isPDF: { type: boolean }; + extension: { type: string; optional: true }; + ext: { type: string; optional: true }; + extensionWithDot: { type: string; optional: true }; + mime: { type: string; optional: true }; + 'mime-type': { type: string; optional: true }; + _storagePath: { type: string }; + _downloadRoute: { type: string }; + _collectionName: { type: string }; + public: { type: boolean; optional: true }; + meta: { type: Object; blackbox: true; optional: true }; + userId: { type: string; optional: true }; + updatedAt: { type: Date; optional: true }; + versions: { type: Object; blackbox: true }; + }; + + /** + * Print logs in debug mode. + * @param args - Arguments to log. + * @returns {void} + */ + _debug(...args: unknown[]): void; + + /** + * Get file name from file data. + * @param fileData - File data object. + * @returns {string} The sanitized file name. + */ + _getFileName(fileData: FileData): string; + + /** + * Get extension information from a file name. + * @param fileName - The file name. + * @returns {Partial} An object with ext, extension and extensionWithDot. + */ + _getExt(fileName: string): Partial; + + /** + * Update file type booleans based on the file's MIME type. + * @param data - File data object. + * @returns {void} + */ + _updateFileTypes(data: FileData): void; + + /** + * Convert raw file data to an object that conforms to the default schema. + * @param data - File data combined with partial FileObj properties. + * @returns {Partial} The schema-compliant file object. + */ + _dataToSchema(data: FileData & Partial): Partial; + + /** + * Find and return a FileCursor for a matching document asynchronously. + * @param selector - Mongo-style selector. + * @param options - Mongo query options. + * @returns {Promise} The FileCursor instance or null if not found. + */ + findOneAsync(selector?: MeteorFilesSelector, options?: MeteorFilesOptions): Promise; + + /** + * Find and return a FileCursor for a matching document (client only). + * @param selector - Mongo-style selector. + * @param options - Mongo query options. + * @returns {FileCursor | null} The FileCursor instance or null if not found. + * @throws {Meteor.Error} If called on the server. + */ + findOne(selector?: MeteorFilesSelector, options?: MeteorFilesOptions): FileCursor | null; + + /** + * Find and return a FilesCursor for matching documents. + * @param selector - Mongo-style selector. + * @param options - Mongo query options. + * @returns {FilesCursor} The FilesCursor instance. + */ + find(selector?: MeteorFilesSelector, options?: MeteorFilesOptions): FilesCursor; + + /** + * Update documents in the underlying collection. + * @param args - Arguments to pass to Mongo.Collection.update. + * @returns {Mongo.Collection} The collection instance. + */ + update(...args: unknown[]): Mongo.Collection; + + /** + * Asynchronously update documents in the underlying collection. + * @param args - Arguments to pass to Mongo.Collection.updateAsync. + * @returns {Promise} The number of updated records. + */ + updateAsync(...args: unknown[]): Promise; + + /** + * Count records matching a selector. + * @param selector - Mongo-style selector. + * @param options - Mongo's CountDocumentsOptions. + * @returns {Promise} The number of matching records. + */ + countDocuments(selector?: MeteorFilesSelector, options?: CountDocumentsOptions): Promise; + + /** + * Count all documents in the collection. + * @param options - Mongo's EstimatedDocumentCountOptions. + * @returns {Promise} The number of matching records. + */ + estimatedDocumentCount(options?: EstimatedDocumentCountOptions): Promise; + + /** + * Return a downloadable URL for the given file. + * @param fileRef - Partial file object reference. + * @param version - File version (default is 'original'). + * @param uriBase - Optional URI base. + * @returns {string} The download URL, or an empty string if the file is invalid. + */ + link(fileRef: Partial, version?: string, uriBase?: string): string; + } + + export interface FilesCollectionConfig { + storagePath?: string | ((fileObj: FileObj) => string); + collection?: Mongo.Collection; + collectionName?: string; + continueUploadTTL?: string; + ddp?: DDP.DDPStatic; + cacheControl?: string; + responseHeaders?: { [x: string]: string } | ((responseCode?: string, fileObj?: FileObj, versionRef?: Version, version?: string) => { [x: string]: string }); + throttle?: number | boolean; + downloadRoute?: string; + schema?: SimpleSchema | Record; + chunkSize?: number | 'dynamic'; + namingFunction?: (fileObj: FileObj) => string; + permissions?: number; + parentDirPermissions?: number; + integrityCheck?: boolean; + strict?: boolean; + downloadCallback?: (this: ContextHTTP & ContextUser, fileObj: FileObj) => boolean; + protected?: boolean | ((this: ContextHTTP & ContextUser, fileObj: FileObj) => boolean | number); + public?: boolean; + onBeforeUpload?: (this: ContextUpload & ContextUser, fileData: FileData) => boolean | string; + onBeforeRemove?: (this: ContextUser, cursor: Mongo.Cursor) => boolean; + onInitiateUpload?: (this: ContextUpload & ContextUser, fileData: FileData) => void; + onAfterUpload?: (fileObj: FileObj) => void; + onAfterRemove?: (files: ReadonlyArray) => void; + onbeforeunloadMessage?: string | (() => string); + allowClientCode?: boolean; + debug?: boolean; + interceptDownload?: (http: object, fileObj: FileObj, version: string) => boolean; + } + + export interface InsertOptions { + file: File | String; + fileId?: string; + fileName?: string; + isBase64?: boolean; + meta?: MetadataType; + transport?: MeteorFilesTransportType; + ddp?: DDP.DDPStatic; + onStart?: (error: Meteor.Error, fileData: FileData) => void; + onUploaded?: (error: Meteor.Error, fileObj: FileObj) => void; + onAbort?: (fileData: FileData) => void; + onError?: (error: Meteor.Error, fileData: FileData) => void; + onProgress?: (progress: number, fileData: FileData) => void; + onBeforeUpload?: (fileData: FileData) => boolean; + chunkSize?: number | 'dynamic'; + allowWebWorkers?: boolean; + type?: string; + } + + export interface FileUploadConfig { + _debug: (...args: unknown[]) => void; + file: File; + fileData: FileData; + isBase64?: boolean; + onAbort?: (this: FileUpload, file: FileData & Partial) => void; + beforeunload?: (e: BeforeUnloadEvent | Event) => string; + _onEnd?: () => void; + fileId?: string; + debug?: boolean; + ddp?: DDP.DDPStatic; + chunkSize?: number | 'dynamic'; + } + + /** + * FileUpload – an internal class returned by the .insert() method. + */ + export class FileUpload extends EventEmitter { + config: FileUploadConfig; + file: FileData & Partial; + state: ReactiveVar; + onPause: ReactiveVar; + progress: ReactiveVar; + continueFunc: () => void; + estimateTime: ReactiveVar; + estimateSpeed: ReactiveVar; + estimateTimer: number; + constructor(config: FileUploadConfig); + pause(): void; + continue(): void; + toggle(): void; + abort(): void; + } + + + export interface UploadInstanceConfig { + ddp?: DDP.DDPStatic; + file: File; + fileId?: string; + meta?: MetadataType; + type?: string; + onError?: (this: FileUpload, error: Meteor.Error, fileData: FileData) => void; + onAbort?: (this: FileUpload, file: FileData) => void; + onStart?: (this: FileUpload, error: Meteor.Error | null, fileData: FileData) => void; + fileName?: string; + isBase64?: boolean; + transport: MeteorFilesTransportType; + chunkSize: number | 'dynamic'; + onUploaded?: (this: FileUpload, error: Meteor.Error | null, data: FileObj) => void; + onProgress?: ( + this: FileUpload, + progress: number, + fileData: FileData, + info?: { chunksSent: number; chunksLength: number; bytesSent: number } + ) => void; + onBeforeUpload?: (this: FileUpload, fileData: FileData) => boolean | string | Promise; + allowWebWorkers: boolean; + disableUpload?: boolean; + _debug?: (...args: unknown[]) => void; + debug?: boolean; + } + + /** + * UploadInstance – internal class used for handling file uploads. + */ + export class UploadInstance extends EventEmitter { + config: UploadInstanceConfig; + collection: FilesCollection; + worker: Worker | null | false; + fetchControllers: { [uid: string]: AbortController }; + transferTime: number; + trackerComp: Tracker.Computation | null; + sentChunks: number; + fileLength: number; + startTime: { [chunkId: number]: number }; + EOFsent: boolean; + fileId: string; + FSName: string; + pipes: Array<(data: string) => string>; + fileData: FileData; + result: FileUpload; + beforeunload: (e: BeforeUnloadEvent | Event) => string; + _setProgress: (progress: number) => void; + constructor(config: UploadInstanceConfig, collection: FilesCollection); + error(error: Meteor.Error, data?: unknown): this; + end(error?: Meteor.Error, data?: unknown): FileUpload; + sendChunk(evt: { data: { bin: string; chunkId: number } }): void; + sendEOF(): void; + proceedChunk(chunkId: number): void; + upload(): this; + prepare(): void; + pipe(func: (data: string) => string): this; + start(): Promise | FileUpload; + manual(): FileUpload; + } + + /** + * FileCursor – internal class representing a single file document. + * Instances are returned from methods such as `.findOne()` or via iteration over a FilesCursor. + */ + export class FileCursor { + constructor(_fileRef: FileObj, _collection: FilesCollection); + _fileRef: FileObj; + _collection: FilesCollection; + remove(): FileCursor; + removeAsync(): Promise; + link(version?: string, uriBase?: string): string; + get(property?: string): FileObj | unknown; + fetch(): FileObj[]; + fetchAsync(): Promise; + with(): FileCursor; + withAsync(): Promise; + } + + /** + * FilesCursor – internal class representing a cursor over file documents. + */ + export class FilesCursor { + constructor( + _selector: MeteorFilesSelector, + options: MeteorFilesOptions, + _collection: FilesCollection + ); + _collection: FilesCollection; + _selector: MeteorFilesSelector; + _current: number; + cursor: Mongo.Cursor; + get(): FileObj[]; + getAsync(): Promise; + hasNext(): boolean; + hasNextAsync(): Promise; + next(): FileObj | undefined; + nextAsync(): Promise; + hasPrevious(): boolean; + hasPreviousAsync(): Promise; + previous(): FileObj | undefined; + previousAsync(): Promise; + fetch(): FileObj[]; + fetchAsync(): Promise; + first(): FileObj | undefined; + firstAsync(): Promise; + last(): FileObj | undefined; + lastAsync(): Promise; + count(): number; + countAsync(): Promise; + remove(callback?: Function): FilesCursor + removeAsync(): Promise; + forEach(callback: Function, context?: object): FilesCursor; + forEachAsync(callback: Function, context?: object): Promise>; + each(): FileCursor[]; + eachAsync(): Promise; + map(callback: Function, context?: object): FileObj[]; + mapAsync(callback: Function, context?: object): Promise; + current(): FileObj | undefined; + currentAsync(): Promise; + observe(callbacks: Mongo.ObserveCallbacks): Meteor.LiveQueryHandle; + observeAsync(callbacks: Mongo.ObserveCallbacks): Promise; + observeChanges(callbacks: Mongo.ObserveChangesCallbacks): Meteor.LiveQueryHandle; + observeChangesAsync(callbacks: Mongo.ObserveChangesCallbacks): Promise; + } + + export class FilesCollection extends FilesCollectionCore { + constructor(config: FilesCollectionConfig); + } + + // -------------------------------------------------------------------------- + // Client/Browser-specific overloads for FilesCollection + // -------------------------------------------------------------------------- + export interface FilesCollection { + /** + * Inserts a file into the collection and returns an instance of FileUpload/UploadInstance. + * @param config - The insert options. + * @param autoStart - Whether to start the upload immediately. + */ + insert(config: InsertOptions, autoStart?: boolean): FileUpload | UploadInstance; + + /** + * Asynchronously inserts a file into the collection. + * @param config - The insert options. + * @param autoStart - Whether to start the upload immediately. + */ + insertAsync(config: InsertOptions, autoStart?: boolean): Promise; + + /** + * Removes files/documents from the collection. + * @param selector - A Mongo-style selector. + * @param callback - Optional callback function. + */ + remove(selector: MeteorFilesSelector, callback?: Function): FilesCollection; + + /** + * Asynchronously removes files/documents from the collection. + * @param selector - A Mongo-style selector. + */ + removeAsync(selector: MeteorFilesSelector): Promise; + + /** + * Returns an object with the current user's information. + */ + _getUser(): ContextUser; + } + + export interface AddFileOpts { + type?: string; + meta?: MetadataType; + fileId?: string; + fileName?: string; + userId?: string; + } + + export interface WriteOpts { + name?: string; + type?: string; + meta?: MetadataType; + userId?: string; + fileId?: string; + } + + export interface LoadOpts { + headers?: Object; + name?: string; + type?: string; + meta?: Object; + userId?: string; + fileId?: string; + timeout?: Number; + } + + // -------------------------------------------------------------------------- + // Server-specific overloads for FilesCollection + // -------------------------------------------------------------------------- + export interface FilesCollection { + + /** + * Downloads a file by preparing HTTP response and piping file data. + * @param http - HTTP context. + * @param version - Requested version (default is 'original'). + * @param fileRef - The file object. + */ + download(http: ContextHTTP, version?: string, fileRef?: FileObj): Promise; + + /** + * Serves a file over HTTP. + * @param http - HTTP context. + * @param fileRef - The file object. + * @param vRef - The file version reference. + * @param version - Requested version. + * @param readableStream - Optional readable stream. + * @param _responseType - Optional response code. + * @param force200 - Whether to force a 200 response code. + */ + serve( + http: ContextHTTP, + fileRef: FileObj, + vRef: Partial, + version?: string, + readableStream?: NodeJS.ReadableStream | null, + _responseType?: string, + force200?: boolean + ): void; + + /** + * Adds an existing file on disk to the FilesCollection. + * @param path - The path to the file. + * @param opts - Optional file data options. + * @param proceedAfterUpload - Whether to trigger onAfterUpload hook. + */ + addFile(path: string, opts?: AddFileOpts, proceedAfterUpload?: boolean): Promise; + + /** + * Writes a file buffer to disk and inserts the file document into the collection. + * @param buffer - The file's buffer. + * @param opts - Optional file data options. + * @param proceedAfterUpload - Whether to trigger onAfterUpload hook. + */ + writeAsync(buffer: Buffer, opts?: WriteOpts, proceedAfterUpload?: boolean): Promise; + + /** + * Loads a file from a URL and inserts it into the collection. + * @param url - The URL to load. + * @param opts - Optional file data options. + * @param proceedAfterUpload - Whether to trigger onAfterUpload hook. + */ + loadAsync(url: string, opts?: LoadOpts, proceedAfterUpload?: boolean): Promise; + + /** + * Unlinks (removes) a file from the filesystem. + * @param fileRef - The file object. + * @param version - Optional file version. + * @param callback - Optional callback. + */ + unlink(fileRef: FileObj, version?: string, callback?: Function): FilesCollection; + + /** + * Asynchronously unlinks (removes) a file from the filesystem. + * @param fileRef - The file object. + * @param version - Optional file version. + */ + unlinkAsync(fileRef: FileObj, version?: string): Promise; + + /** + * Asynchronously removes files/documents from the collection. + * (Server override.) + */ + removeAsync(selector: MeteorFilesSelector): Promise; } } diff --git a/lib.js b/lib.js index 3d0431ee..f81897e2 100644 --- a/lib.js +++ b/lib.js @@ -8,11 +8,20 @@ const helpers = { return obj === void 0; }, isObject(obj) { - if (this.isArray(obj) || this.isFunction(obj)) { + if (obj === null || this.isArray(obj) || this.isFunction(obj)) { return false; } return obj === Object(obj); }, + isNumber(obj) { + return Object.prototype.toString.call(obj) === '[object Number]'; + }, + isDate(obj) { + return Object.prototype.toString.call(obj) === '[object Date]'; + }, + isString(obj) { + return Object.prototype.toString.call(obj) === '[object String]'; + }, isArray(obj) { return Array.isArray(obj); }, @@ -20,10 +29,11 @@ const helpers = { return obj === true || obj === false || Object.prototype.toString.call(obj) === '[object Boolean]'; }, isFunction(obj) { - return typeof obj === 'function' || false; - }, - isDate(date) { - return !Number.isNaN(new Date(date).getDate()); + if (this.isUndefined(obj)) { + return false; + } + const type = Object.prototype.toString.call(obj); + return type === '[object Function]' || type === '[object AsyncFunction]'; }, isEmpty(obj) { if (this.isDate(obj)) { @@ -38,8 +48,10 @@ const helpers = { return false; }, clone(obj) { - if (!this.isObject(obj)) return obj; - return this.isArray(obj) ? obj.slice() : Object.assign({}, obj); + if (!this.isObject(obj)) { + return obj; + } + return this.isArray(obj) ? [...obj] : { ...obj }; }, has(_obj, path) { let obj = _obj; @@ -117,15 +129,8 @@ const helpers = { } }; -const _helpers = ['String', 'Number', 'Date']; -for (let i = 0; i < _helpers.length; i++) { - helpers['is' + _helpers[i]] = function (obj) { - return Object.prototype.toString.call(obj) === `[object ${_helpers[i]}]`; - }; -} - -/* - * @const {Function} fixJSONParse - Fix issue with Date parse +/** + * @const {function} fixJSONParse - Fix issue with Date parse */ const fixJSONParse = function(obj) { for (let key in obj) { @@ -150,8 +155,8 @@ const fixJSONParse = function(obj) { return obj; }; -/* - * @const {Function} fixJSONStringify - Fix issue with Date stringify +/** + * @const {function} fixJSONStringify - Fix issue with Date stringify */ const fixJSONStringify = function(obj) { for (let key in obj) { @@ -174,22 +179,24 @@ const fixJSONStringify = function(obj) { return obj; }; -/* +/** * @locus Anywhere * @private - * @name formatFleURL - * @param {Object} fileRef - File reference object - * @param {String} version - [Optional] Version of file you would like build URL for - * @param {String} uriBase - [Optional] URI base, see - https://github.com/veliovgroup/Meteor-Files/issues/626 + * @name formatFileURL + * @param {Partial} fileRef - File reference object + * @param {string} [version] - [Optional] Version of file you would like build URL for + * @param {string} [uriBase] - [Optional] URI base, see - https://github.com/veliovgroup/Meteor-Files/issues/626 * @summary Returns formatted URL for file - * @returns {String} Downloadable link + * @returns {string} Downloadable link */ -const formatFleURL = (fileRef, version = 'original', _uriBase = (__meteor_runtime_config__ || {}).ROOT_URL) => { +// eslint-disable-next-line camelcase, no-undef +const formatFileURL = (fileRef, version = 'original', _uriBase = (__meteor_runtime_config__ || {}).ROOT_URL) => { check(fileRef, Object); check(version, String); let uriBase = _uriBase; if (!helpers.isString(uriBase)) { + // eslint-disable-next-line camelcase, no-undef uriBase = (__meteor_runtime_config__ || {}).ROOT_URL || '/'; } @@ -209,4 +216,4 @@ const formatFleURL = (fileRef, version = 'original', _uriBase = (__meteor_runtim return `${_root}${fileRef._downloadRoute}/${fileRef._collectionName}/${fileRef._id}/${version}/${fileRef._id}${ext}`; }; -export { fixJSONParse, fixJSONStringify, formatFleURL, helpers }; +export { fixJSONParse, fixJSONStringify, formatFileURL, helpers }; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..2e90e0ab --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4026 @@ +{ + "name": "ostrio-meteor-files", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ostrio-meteor-files", + "devDependencies": { + "@babel/core": "^7.26.10", + "@babel/eslint-parser": "^7.26.10", + "@typescript-eslint/eslint-plugin": "^8.27.0", + "@typescript-eslint/parser": "^8.27.0", + "chai": "^4.5.0", + "eslint": "^8.57.1", + "eventemitter3": "^5.0.1", + "mongodb": "^6.15.0", + "simpl-schema": "^3.4.6", + "sinon": "^7.5.0", + "tsd": "^0.31.2", + "tslint": "^5.20.1" + } + }, + "node_modules/@aashutoshrathi/word-wrap": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", + "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", + "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", + "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.10", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.10", + "@babel/parser": "^7.26.10", + "@babel/template": "^7.26.9", + "@babel/traverse": "^7.26.10", + "@babel/types": "^7.26.10", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/eslint-parser": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.26.10.tgz", + "integrity": "sha512-QsfQZr4AiLpKqn7fz+j7SN+f43z2DZCgGyYbNJ2vJOqKfG4E6MZer1+jqGZqKJaxq/gdO2DC/nUu45+pOL5p2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", + "eslint-visitor-keys": "^2.1.0", + "semver": "^6.3.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || >=14.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0", + "eslint": "^7.5.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/@babel/generator": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.10.tgz", + "integrity": "sha512-rRHT8siFIXQrAYOYqZQVsAr8vJ+cBNqcVAY6m5V8/4QqzaPl+zDBe6cLEPRDuNOUf3ww8RfJVlOyQMoSI+5Ang==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.26.10", + "@babel/types": "^7.26.10", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz", + "integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.26.5", + "@babel/helper-validator-option": "^7.25.9", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", + "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", + "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", + "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz", + "integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.10" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz", + "integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.10" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", + "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.10.tgz", + "integrity": "sha512-k8NuDrxr0WrPH5Aupqb2LCVURP/S0vBEn5mK6iH+GIYob66U5EtoZvcdudR2jQ4cmTwhEwW1DLB+Yyas9zjF6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.10", + "@babel/parser": "^7.26.10", + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.10", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz", + "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", + "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.2.0.tgz", + "integrity": "sha512-+ywrb0AqkfaYuhHs6LxKWgqbh3I72EpEgESCw37o+9qPx9WTCkgDm2B+eMrwehGtHBWHFU4GXvnSCNiFhhausg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { + "version": "5.1.1-v1", + "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", + "integrity": "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==", + "dev": true, + "dependencies": { + "eslint-scope": "5.1.1" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", + "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/formatio": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-3.2.2.tgz", + "integrity": "sha512-B8SEsgd8gArBLMD6zpRw3juQ2FVSsmdd7qlevyDqzS9WTCtvF55/gAL+h6gue8ZvPYcdiPdvueM/qm//9XzyTQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^1", + "@sinonjs/samsam": "^3.1.0" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-3.3.3.tgz", + "integrity": "sha512-bKCMKZvWIjYD0BLGnNrxVuw4dkWCYsLqFOUWw8VgKF/+5Y+mE7LfHWPIYoDXowH+3a9LsWDMo0uAP8YDosPvHQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^1.3.0", + "array-from": "^2.1.1", + "lodash": "^4.17.15" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", + "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", + "dev": true, + "license": "(Unlicense OR Apache-2.0)" + }, + "node_modules/@tsd/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/@tsd/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-saiCxzHRhUrRxQV2JhH580aQUZiKQUXI38FcAcikcfOomAil4G4lxT0RfrrKywoAYP/rqAdYXYmNRLppcd+hQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@types/eslint": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.29.0.tgz", + "integrity": "sha512-VNcvioYDH8/FxaeTKkM4/TiTwt6pBV9E3OfGmvaw8tPl0rrHCJ4Ll15HRT+pMiFAf/MLQvAzC+6RzUMEL9Ceng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.27.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.27.0.tgz", + "integrity": "sha512-4henw4zkePi5p252c8ncBLzLce52SEUz2Ebj8faDnuUXz2UuHEONYcJ+G0oaCF+bYCWVZtrGzq3FD7YXetmnSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.27.0", + "@typescript-eslint/type-utils": "8.27.0", + "@typescript-eslint/utils": "8.27.0", + "@typescript-eslint/visitor-keys": "8.27.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.27.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.27.0.tgz", + "integrity": "sha512-XGwIabPallYipmcOk45DpsBSgLC64A0yvdAkrwEzwZ2viqGqRUJ8eEYoPz0CWnutgAFbNMPdsGGvzjSmcWVlEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.27.0", + "@typescript-eslint/types": "8.27.0", + "@typescript-eslint/typescript-estree": "8.27.0", + "@typescript-eslint/visitor-keys": "8.27.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.27.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.27.0.tgz", + "integrity": "sha512-8oI9GwPMQmBryaaxG1tOZdxXVeMDte6NyJA4i7/TWa4fBwgnAXYlIQP+uYOeqAaLJ2JRxlG9CAyL+C+YE9Xknw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.27.0", + "@typescript-eslint/visitor-keys": "8.27.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.27.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.27.0.tgz", + "integrity": "sha512-wVArTVcz1oJOIEJxui/nRhV0TXzD/zMSOYi/ggCfNq78EIszddXcJb7r4RCp/oBrjt8n9A0BSxRMKxHftpDxDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.27.0", + "@typescript-eslint/utils": "8.27.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.27.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.27.0.tgz", + "integrity": "sha512-/6cp9yL72yUHAYq9g6DsAU+vVfvQmd1a8KyA81uvfDE21O2DwQ/qxlM4AR8TSdAu+kJLBDrEHKC5/W2/nxsY0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.27.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.27.0.tgz", + "integrity": "sha512-BnKq8cqPVoMw71O38a1tEb6iebEgGA80icSxW7g+kndx0o6ot6696HjG7NdgfuAVmVEtwXUr3L8R9ZuVjoQL6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.27.0", + "@typescript-eslint/visitor-keys": "8.27.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.27.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.27.0.tgz", + "integrity": "sha512-njkodcwH1yvmo31YWgRHNb/x1Xhhq4/m81PhtvmRngD8iHPehxffz1SNCO+kwaePhATC+kOa/ggmvPoPza5i0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.27.0", + "@typescript-eslint/types": "8.27.0", + "@typescript-eslint/typescript-estree": "8.27.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.27.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.27.0.tgz", + "integrity": "sha512-WsXQwMkILJvffP6z4U3FYJPlbf/j07HIxmDjZpbNvBJkMfvwXj5ACRkkHwBDvLBbDbtX5TdU64/rcvKJ/vuInQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.27.0", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true + }, + "node_modules/acorn": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", + "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/array-from": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/array-from/-/array-from-2.1.1.tgz", + "integrity": "sha512-GQTc6Uupx1FCavi5mPzBvVT7nEOeWMmUA9P95wpfpW1XwMSKs+KaymD5C2Up7KAUKg/mYwbsUYzdZWcoajlNZg==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", + "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bson": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.3.tgz", + "integrity": "sha512-MTxGsqgYTwfshYWTRdmZRC+M7FnG1b4y7RO7p2k3X24Wq0yv1m77Wsj0BzlPzd/IowgESfsruQCUToa7vbOpPQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/builtin-modules": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", + "integrity": "sha512-wxXCdllwGhI2kCC0MnvTGYTMvnVZTvqgypkiTI8Pa5tcz2i6VqsqwYGgqwXji+4RgCzms6EajE4IxiUH6HH8nQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-keys": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", + "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^5.3.1", + "map-obj": "^4.0.0", + "quick-lru": "^4.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001707", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001707.tgz", + "integrity": "sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chai/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decamelize-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", + "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "decamelize": "^1.1.0", + "map-obj": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decamelize-keys/node_modules/map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deep-eql": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", + "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "dev": true, + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.123", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.123.tgz", + "integrity": "sha512-refir3NlutEZqlKaBLK0tzlVLe5P2wDKS7UQt/3SpibizgsRAPOsqQC3ffw1nlv3ze5gjRQZYHoPymgVZkplFA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-formatter-pretty": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-formatter-pretty/-/eslint-formatter-pretty-4.1.0.tgz", + "integrity": "sha512-IsUTtGxF1hrH6lMWiSl1WbGaiP01eT6kzywdY1U+zLc0MP+nwEnUiS9UI8IaOTUhTeQJLlCEWIbXINBH4YJbBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "^7.2.13", + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.0", + "eslint-rule-docs": "^1.1.5", + "log-symbols": "^4.0.0", + "plur": "^4.0.0", + "string-width": "^4.2.0", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-formatter-pretty/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint-formatter-pretty/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint-formatter-pretty/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/eslint-formatter-pretty/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-formatter-pretty/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint-rule-docs": { + "version": "1.1.235", + "resolved": "https://registry.npmjs.org/eslint-rule-docs/-/eslint-rule-docs-1.1.235.tgz", + "integrity": "sha512-+TQ+x4JdTnDoFEXXb3fDvfGOwnyNV7duH8fXWTPD1ieaBmB8omj7Gw/pMBBu4uI2uJCCU8APDaQJzWuXnTsH4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/eslint/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/eslint/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esquery/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.16.0.tgz", + "integrity": "sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", + "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", + "dev": true + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/hard-rejection": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", + "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/irregular-plurals": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-3.5.0.tgz", + "integrity": "sha512-1ANGLZ+Nkv1ptFb2pa8oG8Lem4krflKuX/gINiHJHjJUKaJHk/SXk5x6K3J+39/p0h1RQ2saROclJJ+QLvETCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-diff/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-diff/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-diff/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/just-extend": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", + "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/log-symbols/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lolex": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-4.2.0.tgz", + "integrity": "sha512-gKO5uExCXvSm6zbF562EvM+rd1kQDnB9AZBbiQVzf1ZmdDpxUSvpnAaVOP83N/31mRK8Ml8/VE8DMvsAZQ+7wg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/map-obj": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "dev": true, + "license": "MIT" + }, + "node_modules/meow": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz", + "integrity": "sha512-+obSblOQmRhcyBt62furQqRAQpNyWXo8BuQ5bN7dG8wmwQ+vwHKp/rCFD4CrTP8CsDQD1sjoZ94K417XEUk8IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimist": "^1.2.0", + "camelcase-keys": "^6.2.2", + "decamelize": "^1.2.0", + "decamelize-keys": "^1.1.0", + "hard-rejection": "^2.1.0", + "minimist-options": "4.1.0", + "normalize-package-data": "^3.0.0", + "read-pkg-up": "^7.0.1", + "redent": "^3.0.0", + "trim-newlines": "^3.0.0", + "type-fest": "^0.18.0", + "yargs-parser": "^20.2.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/type-fest": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", + "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minimist-options": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", + "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0", + "kind-of": "^6.0.3" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mongo-object": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mongo-object/-/mongo-object-3.0.1.tgz", + "integrity": "sha512-EbiwWHvKOF9xhIzuwaqknwPISdkHMipjMs6DiJFicupgBBLEhUs0OOro9MuPkFogB17DZlsV4KJhhxfqZ7ZRMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16", + "npm": ">=8" + } + }, + "node_modules/mongodb": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.15.0.tgz", + "integrity": "sha512-ifBhQ0rRzHDzqp9jAQP6OwHSH7dbYIQjD3SbJs9YYk9AikKEettW/9s/tbSFDTpXcRbF+u1aLrhHxDFaYtZpFQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.9", + "bson": "^6.10.3", + "mongodb-connection-string-url": "^3.0.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/nise": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/nise/-/nise-1.5.3.tgz", + "integrity": "sha512-Ymbac/94xeIrMf59REBPOv0thr+CJVFMhrlAkW/gjCIE58BGQdCj0x7KRCb3yz+Ga2Rz3E9XXSvUyyxqqhjQAQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/formatio": "^3.2.1", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "lolex": "^5.0.1", + "path-to-regexp": "^1.7.0" + } + }, + "node_modules/nise/node_modules/lolex": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-5.1.2.tgz", + "integrity": "sha512-h4hmjAvHTmd+25JSwrtTIuwbKdwg5NzZVRMLn9saij4SZaepCrTCxPr35H/3bjwfMJtN+t3CX8672UIkglz28A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^1.7.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-package-data": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", + "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^4.0.1", + "is-core-module": "^2.5.0", + "semver": "^7.3.4", + "validate-npm-package-license": "^3.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/normalize-package-data/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", + "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "dev": true, + "dependencies": { + "@aashutoshrathi/word-wrap": "^1.2.3", + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", + "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "isarray": "0.0.1" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/plur": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/plur/-/plur-4.0.0.tgz", + "integrity": "sha512-4UGewrYgqDFw9vV6zNV+ADmPAUAfJPKtGvb/VdpQAx25X5f3xXdGdyOEVFwkl8Hl/tl7+xbeHqSEM+D5/TirUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "irregular-plurals": "^3.2.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/quick-lru": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", + "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg/node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/read-pkg/node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/read-pkg/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/simpl-schema": { + "version": "3.4.6", + "resolved": "https://registry.npmjs.org/simpl-schema/-/simpl-schema-3.4.6.tgz", + "integrity": "sha512-xgShTrNzktC1TTgizSjyDHrxs0bmZa1b9sso54cL8xwO2OloVhtHjfO73/dAK9OFzUIWCBTpKMpD12JPTgVimA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^2.1.2", + "mongo-object": "^3.0.1" + }, + "engines": { + "node": ">=14.16", + "npm": ">=8" + } + }, + "node_modules/sinon": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-7.5.0.tgz", + "integrity": "sha512-AoD0oJWerp0/rY9czP/D6hDTTUYGpObhZjMpd7Cl/A6+j0xBE+ayL/ldfggkBXUs0IkvIiM1ljM8+WkOc5k78Q==", + "deprecated": "16.1.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^1.4.0", + "@sinonjs/formatio": "^3.2.1", + "@sinonjs/samsam": "^3.3.3", + "diff": "^3.5.0", + "lolex": "^4.2.0", + "nise": "^1.5.2", + "supports-color": "^5.5.0" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.21", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", + "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-color/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tr46": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.0.tgz", + "integrity": "sha512-IUWnUK7ADYR5Sl1fZlO1INDUhVhatWl7BtJWsIhwJ0UAK7ilzzIa8uIqOO/aYVWHZPJkKbEL+362wrzoeRF7bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/trim-newlines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", + "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ts-api-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", + "integrity": "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsd": { + "version": "0.31.2", + "resolved": "https://registry.npmjs.org/tsd/-/tsd-0.31.2.tgz", + "integrity": "sha512-VplBAQwvYrHzVihtzXiUVXu5bGcr7uH1juQZ1lmKgkuGNGT+FechUCqmx9/zk7wibcqR2xaNEwCkDyKh+VVZnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tsd/typescript": "~5.4.3", + "eslint-formatter-pretty": "^4.1.0", + "globby": "^11.0.1", + "jest-diff": "^29.0.3", + "meow": "^9.0.0", + "path-exists": "^4.0.0", + "read-pkg-up": "^7.0.0" + }, + "bin": { + "tsd": "dist/cli.js" + }, + "engines": { + "node": ">=14.16" + } + }, + "node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tslint": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/tslint/-/tslint-5.20.1.tgz", + "integrity": "sha512-EcMxhzCFt8k+/UP5r8waCf/lzmeSyVlqxqMEDQE7rWYiQky8KpIBz1JAoYXfROHrPZ1XXd43q8yQnULOLiBRQg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "builtin-modules": "^1.1.1", + "chalk": "^2.3.0", + "commander": "^2.12.1", + "diff": "^4.0.1", + "glob": "^7.1.1", + "js-yaml": "^3.13.1", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.1", + "resolve": "^1.3.2", + "semver": "^5.3.0", + "tslib": "^1.8.0", + "tsutils": "^2.29.0" + }, + "bin": { + "tslint": "bin/tslint" + }, + "engines": { + "node": ">=4.8.0" + }, + "peerDependencies": { + "typescript": ">=2.3.0-dev || >=2.4.0-dev || >=2.5.0-dev || >=2.6.0-dev || >=2.7.0-dev || >=2.8.0-dev || >=2.9.0-dev || >=3.0.0-dev || >= 3.1.0-dev || >= 3.2.0-dev" + } + }, + "node_modules/tslint/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/tslint/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/tslint/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/tsutils": { + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-2.29.0.tgz", + "integrity": "sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^1.8.1" + }, + "peerDependencies": { + "typescript": ">=2.1.0 || >=2.1.0-dev || >=2.2.0-dev || >=2.3.0-dev || >=2.4.0-dev || >=2.5.0-dev || >=2.6.0-dev || >=2.7.0-dev || >=2.8.0-dev || >=2.9.0-dev || >= 3.0.0-dev || >= 3.1.0-dev" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package-types.json b/package-types.json new file mode 100644 index 00000000..925e1e90 --- /dev/null +++ b/package-types.json @@ -0,0 +1,3 @@ +{ + "typesEntry": "index.d.ts" +} diff --git a/package.js b/package.js index b8914f62..9822bbf5 100755 --- a/package.js +++ b/package.js @@ -1,29 +1,41 @@ Package.describe({ name: 'ostrio:files', - version: '2.3.3', + version: '3.0.0', summary: 'Upload files to a server or 3rd party storage: AWS:S3, GridFS, DropBox, and other', git: 'https://github.com/veliovgroup/Meteor-Files', documentation: 'README.md' }); Package.onUse((api) => { - api.versionsFrom('1.9'); + api.versionsFrom(['3.0.1']); api.use('webapp', 'server'); api.use(['reactive-var', 'tracker', 'ddp-client'], 'client'); - api.use(['mongo', 'check', 'random', 'ecmascript', 'fetch', 'ostrio:cookies@2.7.2'], ['client', 'server']); + api.use(['mongo', 'check', 'random', 'ecmascript', 'fetch', 'ostrio:cookies@2.9.1'], ['client', 'server']); api.addAssets('worker.min.js', 'client'); api.mainModule('server.js', 'server'); api.mainModule('client.js', 'client'); api.export('FilesCollection'); + + // TypeScript setup + api.use(['zodern:types@1.0.13', 'typescript'], ['client', 'server'], { weak: true }); + // For zodern:types to pick up our published types. + api.addAssets('index.d.ts', ['client', 'server']); + + Npm.depends({ + eventemitter3: '5.0.1', + }); }); Package.onTest((api) => { api.use('tinytest'); + api.use('meteortesting:mocha@3.3.0'); api.use(['ecmascript', 'ostrio:files'], ['client', 'server']); api.addFiles('tests/helpers.js', ['client', 'server']); -}); + api.mainModule('tests/server.js', 'server'); -Npm.depends({ - eventemitter3: '4.0.7', - 'abort-controller': '3.0.0' + Npm.depends({ + eventemitter3: '5.0.1', + chai: '4.5.0', + sinon: '7.5.0', + }); }); diff --git a/package.json b/package.json new file mode 100644 index 00000000..4068b044 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "ostrio-meteor-files", + "types": "index.d.ts", + "scripts": { + "test:tiny": "meteor test-packages ./", + "test:mocha": "meteor test-packages ./ --driver-package meteortesting:mocha --once", + "test:mocha:watch": "meteor test-packages ./ --driver-package meteortesting:mocha", + "lint": "eslint .", + "lint:fix": "eslint . --fix" + }, + "devDependencies": { + "@babel/core": "^7.26.10", + "@babel/eslint-parser": "^7.26.10", + "@typescript-eslint/eslint-plugin": "^8.27.0", + "@typescript-eslint/parser": "^8.27.0", + "chai": "^4.5.0", + "eslint": "^8.57.1", + "eventemitter3": "^5.0.1", + "mongodb": "^6.15.0", + "simpl-schema": "^3.4.6", + "sinon": "^7.5.0", + "tsd": "^0.31.2", + "tslint": "^5.20.1" + } +} diff --git a/server.js b/server.js index 2038a699..b6ed5559 100644 --- a/server.js +++ b/server.js @@ -10,16 +10,14 @@ import WriteStream from './write-stream.js'; import FilesCollectionCore from './core.js'; import { fixJSONParse, fixJSONStringify, helpers } from './lib.js'; -import AbortController from 'abort-controller'; -import fs from 'fs'; -import nodeQs from 'querystring'; -import nodePath from 'path'; +import fs from 'node:fs'; +import nodeQs from 'node:querystring'; +import nodePath from 'node:path'; +import { pipeline } from 'node:stream/promises'; /** - * @const {Object} bound - Meteor.bindEnvironment (Fiber wrapper) - * @const {Function} noop - No Operation function, placeholder for required callbacks + * @const {function} noop - No Operation function, placeholder for required callbacks */ -const bound = Meteor.bindEnvironment(callback => callback()); const noop = function noop () {}; /** @@ -72,50 +70,50 @@ const createIndex = async (_collection, keys, opts) => { }; /** - * @locus Anywhere + * @locus Server * @class FilesCollection - * @param config {Object} - [Both] Configuration object with next properties: - * @param config.debug {Boolean} - [Both] Turn on/of debugging and extra logging + * @param config {FilesCollectionConfig} - [Both] Configuration object with next properties: + * @param config.debug {boolean} - [Both] Turn on/of debugging and extra logging * @param config.schema {Object} - [Both] Collection Schema - * @param config.public {Boolean} - [Both] Store files in folder accessible for proxy servers, for limits, and more - read docs - * @param config.strict {Boolean} - [Server] Strict mode for partial content, if is `true` server will return `416` response code, when `range` is not specified, otherwise server return `206` - * @param config.protected {Function} - [Server] If `true` - files will be served only to authorized users, if `function()` - you're able to check visitor's permissions in your own way function's context has: + * @param config.public {boolean} - [Both] Store files in folder accessible for proxy servers, for limits, and more - read docs + * @param config.strict {boolean} - [Server] Strict mode for partial content, if is `true` server will return `416` response code, when `range` is not specified, otherwise server return `206` + * @param config.protected {function} - [Server] If `true` - files will be served only to authorized users, if `function()` - you're able to check visitor's permissions in your own way function's context has: * - `request` * - `response` - * - `user()` + * - `userAsync()` * - `userId` - * @param config.chunkSize {Number} - [Both] Upload chunk size, default: 524288 bytes (0,5 Mb) - * @param config.permissions {Number} - [Server] Permissions which will be set to uploaded files (octal), like: `511` or `0o755`. Default: 0644 - * @param config.parentDirPermissions {Number} - [Server] Permissions which will be set to parent directory of uploaded files (octal), like: `611` or `0o777`. Default: 0755 - * @param config.storagePath {String|Function} - [Server] Storage path on file system - * @param config.cacheControl {String} - [Server] Default `Cache-Control` header - * @param config.responseHeaders {Object|Function} - [Server] Custom response headers, if function is passed, must return Object - * @param config.throttle {Number} - [Server] DEPRECATED bps throttle threshold - * @param config.downloadRoute {String} - [Both] Server Route used to retrieve files + * @param config.chunkSize {number} - [Both] Upload chunk size, default: 524288 bytes (0,5 Mb) + * @param config.permissions {number} - [Server] Permissions which will be set to uploaded files (octal), like: `511` or `0o755`. Default: 0644 + * @param config.parentDirPermissions {number} - [Server] Permissions which will be set to parent directory of uploaded files (octal), like: `611` or `0o777`. Default: 0755 + * @param config.storagePath {string|function} - [Server] Storage path on file system + * @param config.cacheControl {string} - [Server] Default `Cache-Control` header + * @param config.responseHeaders {object|function} - [Server] Custom response headers, if function is passed, must return Object + * @param config.throttle {number} - [Server] DEPRECATED bps throttle threshold + * @param config.downloadRoute {string} - [Both] Server Route used to retrieve files * @param config.collection {Mongo.Collection} - [Both] Mongo Collection Instance - * @param config.collectionName {String} - [Both] Collection name - * @param config.namingFunction {Function}- [Both] Function which returns `String` - * @param config.integrityCheck {Boolean} - [Server] Check file's integrity before serving to users - * @param config.onAfterUpload {Function}- [Server] Called right after file is ready on FS. Use to transfer file somewhere else, or do other thing with file directly - * @param config.onAfterRemove {Function} - [Server] Called right after file is removed. Removed objects is passed to callback - * @param config.continueUploadTTL {Number} - [Server] Time in seconds, during upload may be continued, default 3 hours (10800 seconds) - * @param config.onBeforeUpload {Function}- [Both] Function which executes on server after receiving each chunk and on client right before beginning upload. Function context is `File` - so you are able to check for extension, mime-type, size and etc.: - * - return `true` to continue - * - return `false` or `String` to abort upload - * @param config.getUser {Function} - [Server] Replace default way of recognizing user, usefull when you want to auth user based on custom cookie (or other way). arguments {http: {request: {...}, response: {...}}}, need to return {userId: String, user: Function} - * @param config.onInitiateUpload {Function} - [Server] Function which executes on server right before upload is begin and right after `onBeforeUpload` hook. This hook is fully asynchronous. - * @param config.onBeforeRemove {Function} - [Server] Executes before removing file on server, so you can check permissions. Return `true` to allow action and `false` to deny. - * @param config.allowClientCode {Boolean} - [Both] Allow to run `remove` from client - * @param config.downloadCallback {Function} - [Server] Callback triggered each time file is requested, return truthy value to continue download, or falsy to abort - * @param config.interceptRequest {Function} - [Server] Intercept incoming HTTP request, so you can whatever you want, no checks or preprocessing, arguments {http: {request: {...}, response: {...}}, params: {...}} - * @param config.interceptDownload {Function} - [Server] Intercept download request, so you can serve file from third-party resource, arguments {http: {request: {...}, response: {...}}, fileRef: {...}} - * @param config.disableUpload {Boolean} - Disable file upload, useful for server only solutions - * @param config.disableDownload {Boolean} - Disable file download (serving), useful for file management only solutions - * @param config.allowedOrigins {Regex|Boolean} - [Server] Regex of Origins that are allowed CORS access or `false` to disable completely. Defaults to `/^http:\/\/localhost:12[0-9]{3}$/` for allowing Meteor-Cordova builds access - * @param config.allowQueryStringCookies {Boolean} - Allow passing Cookies in a query string (in URL). Primary should be used only in Cordova environment. Note: this option will be used only on Cordova. Default: `false` - * @param config.sanitize {Function} - Override default sanitize function + * @param config.collectionName {string} - [Both] Collection name + * @param config.namingFunction {function}- [Both] Function which returns `String` + * @param config.integrityCheck {boolean} - [Server] Check file's integrity before serving to users + * @param config.onAfterUpload {function}- [Server] Called right after file is ready on FS. Use to transfer file somewhere else, or do other thing with file directly + * @param config.onAfterRemove {function(fileObj[]): boolean} - [Server] Called with single argument with array of removed `fileObj[]` right after file(s) is removed. Return `true` to intercept `.unlinkAsync` method; return `false` to continue default behavior + * @param config.continueUploadTTL {number} - [Server] Time in seconds, during upload may be continued, default 3 hours (10800 seconds) + * @param config.onBeforeUpload {function}- [Both] Function which executes on server after receiving each chunk and on client right before beginning upload. Function context is `File` - so you are able to check for extension, mime-type, size and etc.: + * - return or resolve `true` to continue + * - return or resolve `false` or `String` to abort upload + * @param config.getUser {function} - [Server] Replace default way of recognizing user, useful when you want to auth user based on custom cookie (or other way). arguments {http: {request: {...}, response: {...}}}, need to return {userId: String, user: Function} + * @param config.onInitiateUpload {function} - [Server] Function which executes on server right before upload is begin and right after `onBeforeUpload` hook. This hook is fully asynchronous. + * @param config.onBeforeRemove {function} - [Server] Executes before removing file on server, so you can check permissions. Return `true` to allow physical file removal and `false` to deny. + * @param config.allowClientCode {boolean} - [Both] Allow to run `remove` from client + * @param config.downloadCallback {function} - [Server] Callback triggered each time file is requested, return truthy value to continue download, or falsy to abort + * @param config.interceptRequest {function} - [Server] Intercept incoming HTTP request, so you can whatever you want, no checks or preprocessing, arguments {http: {request: {...}, response: {...}}, params: {...}} + * @param config.interceptDownload {function} - [Server] Intercept download request, so you can serve file from third-party resource, arguments {http: {request: {...}, response: {...}}, fileRef: {...}} + * @param config.disableUpload {boolean} - Disable file upload, useful for server only solutions + * @param config.disableDownload {boolean} - Disable file download (serving), useful for file management only solutions + * @param config.allowedOrigins {Regex|boolean} - [Server] Regex of Origins that are allowed CORS access or `false` to disable completely. Defaults to `/^http:\/\/localhost:12[0-9]{3}$/` for allowing Meteor-Cordova builds access + * @param config.allowQueryStringCookies {boolean} - Allow passing Cookies in a query string (in URL). Primary should be used only in Cordova environment. Note: this option will be used only on Cordova. Default: `false` + * @param config.sanitize {function} - Override default sanitize function * @param config._preCollection {Mongo.Collection} - [Server] Mongo preCollection Instance - * @param config._preCollectionName {String} - [Server] preCollection name + * @param config._preCollectionName {string} - [Server] preCollection name * @summary Create new instance of FilesCollection */ class FilesCollection extends FilesCollectionCore { @@ -297,7 +295,7 @@ class FilesCollection extends FilesCollectionCore { } if (!helpers.isFunction(this.responseHeaders)) { - this.responseHeaders = (responseCode, fileRef, versionRef) => { + this.responseHeaders = (responseCode, _fileObj, versionRef) => { const headers = {}; switch (responseCode) { case '206': @@ -361,19 +359,21 @@ class FilesCollection extends FilesCollectionCore { check(this.permissions, Number); check(this.storagePath, Function); check(this.cacheControl, String); - check(this.onAfterRemove, Match.OneOf(false, Function)); - check(this.onAfterUpload, Match.OneOf(false, Function)); check(this.disableUpload, Boolean); check(this.integrityCheck, Boolean); - check(this.onBeforeRemove, Match.OneOf(false, Function)); check(this.disableDownload, Boolean); + check(this.continueUploadTTL, Number); + check(this.allowQueryStringCookies, Boolean); + /* eslint-disable new-cap */ + check(this.onAfterRemove, Match.OneOf(false, Function)); + check(this.onAfterUpload, Match.OneOf(false, Function)); + check(this.onBeforeRemove, Match.OneOf(false, Function)); check(this.downloadCallback, Match.OneOf(false, Function)); check(this.interceptRequest, Match.OneOf(false, Function)); check(this.interceptDownload, Match.OneOf(false, Function)); - check(this.continueUploadTTL, Number); check(this.responseHeaders, Match.OneOf(Object, Function)); check(this.allowedOrigins, Match.OneOf(Boolean, RegExp)); - check(this.allowQueryStringCookies, Boolean); + /* eslint-enable new-cap */ this._cookies = new Cookies({ allowQueryStringCookies: this.allowQueryStringCookies, @@ -401,49 +401,49 @@ class FilesCollection extends FilesCollectionCore { }); _preCollectionCursor.observe({ - changed(doc) { + async changed(doc) { if (doc.isFinished) { self._debug(`[FilesCollection] [_preCollectionCursor.observe] [changed]: ${doc._id}`); - self._preCollection.remove({_id: doc._id}, noop); + await self._preCollection.removeAsync({_id: doc._id}); } }, - removed(doc) { + async removed(doc) { // Free memory after upload is done // Or if upload is unfinished self._debug(`[FilesCollection] [_preCollectionCursor.observe] [removed]: ${doc._id}`); - if (helpers.isObject(self._currentUploads[doc._id])) { - self._currentUploads[doc._id].stop(); - self._currentUploads[doc._id].end(); + if (helpers.isObject(self._currentUploads[doc._id]) && !self._currentUploads[doc._id].ended && !self._currentUploads[doc._id].aborted) { + await self._currentUploads[doc._id].end(); // We can be unlucky to run into a race condition where another server removed this document before the change of `isFinished` is registered on this server. // Therefore it's better to double-check with the main collection if the file is referenced there. Issue: https://github.com/veliovgroup/Meteor-Files/issues/672 - if (!doc.isFinished && self.collection.find({ _id: doc._id }).count() === 0) { + if (!doc.isFinished && (await self.collection.countDocuments({ _id: doc._id })) === 0) { self._debug(`[FilesCollection] [_preCollectionCursor.observe] [removeUnfinishedUpload]: ${doc._id}`); - self._currentUploads[doc._id].abort(); + await self._currentUploads[doc._id].abort(); } - - delete self._currentUploads[doc._id]; } + delete self._currentUploads[doc._id]; } }); - this._createStream = (_id, path, opts) => { - this._currentUploads[_id] = new WriteStream(path, opts.fileLength, opts, this.permissions); + this._createStream = async (_id, path, opts) => { + const stream = new WriteStream(path, opts.fileLength, opts, this.permissions, this.parentDirPermissions); + this._currentUploads[_id] = await stream.init(); }; // This little function allows to continue upload // even after server is restarted (*not on dev-stage*) - this._continueUpload = (_id) => { + this._continueUpload = async (_id) => { if (this._currentUploads[_id] && this._currentUploads[_id].file) { if (!this._currentUploads[_id].aborted && !this._currentUploads[_id].ended) { return this._currentUploads[_id].file; } - this._createStream(_id, this._currentUploads[_id].file.file.path, this._currentUploads[_id].file); + await this._createStream(_id, this._currentUploads[_id].file.file.path, this._currentUploads[_id].file); return this._currentUploads[_id].file; } - const contUpld = this._preCollection.findOne({_id}); + + const contUpld = await this._preCollection.findOneAsync({_id}); if (contUpld) { - this._createStream(_id, contUpld.file.path, contUpld); + await this._createStream(_id, contUpld.file.path, contUpld); return this._currentUploads[_id].file; } return false; @@ -457,31 +457,33 @@ class FilesCollection extends FilesCollectionCore { check(this.debug, Boolean); check(this.schema, Object); check(this.public, Boolean); - check(this.getUser, Match.OneOf(false, Function)); - check(this.protected, Match.OneOf(Boolean, Function)); check(this.chunkSize, Number); check(this.downloadRoute, String); + check(this.allowClientCode, Boolean); + /* eslint-disable new-cap */ + check(this.getUser, Match.OneOf(false, Function)); + check(this.protected, Match.OneOf(Boolean, Function)); check(this.namingFunction, Match.OneOf(false, Function)); check(this.onBeforeUpload, Match.OneOf(false, Function)); check(this.onInitiateUpload, Match.OneOf(false, Function)); - check(this.allowClientCode, Boolean); + /* eslint-enable new-cap */ if (this.public && this.protected) { throw new Meteor.Error(500, `[FilesCollection.${this.collectionName}]: Files can not be public and protected at the same time!`); } - this._checkAccess = (http) => { + this._checkAccess = async (http) => { if (this.protected) { let result; - const {user, userId} = this._getUser(http); + const {userAsync, userId} = this._getUser(http); if (helpers.isFunction(this.protected)) { - let fileRef; + let fileObj; if (helpers.isObject(http.params) && http.params._id) { - fileRef = this.collection.findOne(http.params._id); + fileObj = await this.collection.findOneAsync(http.params._id); } - result = http ? this.protected.call(Object.assign(http, {user, userId}), (fileRef || null)) : this.protected.call({user, userId}, (fileRef || null)); + result = http ? await this.protected.call(Object.assign(http, {userAsync, userId}), (fileObj || null)) : await this.protected.call({userAsync, userId}, (fileObj || null)); } else { result = !!userId; } @@ -518,16 +520,12 @@ class FilesCollection extends FilesCollectionCore { _Remove: `_FilesCollectionRemove_${this.collectionName}` }; - this.on('_handleUpload', this._handleUpload); - this.on('_finishUpload', this._finishUpload); - this._handleUploadSync = Meteor.wrapAsync(this._handleUpload.bind(this)); - if (this.disableUpload && this.disableDownload) { return; } - WebApp.connectHandlers.use((httpReq, httpResp, next) => { + WebApp.connectHandlers.use(async (httpReq, httpResp, next) => { if (this.allowedOrigins && httpReq._parsedUrl.path.includes(`${this.downloadRoute}/`) && !httpResp.headersSent) { - if (this.allowedOrigins.test(httpReq.headers.origin)) { + if (httpReq.headers.origin && this.allowedOrigins.test(httpReq.headers.origin)) { httpResp.setHeader('Access-Control-Allow-Credentials', 'true'); httpResp.setHeader('Access-Control-Allow-Origin', httpReq.headers.origin); } @@ -543,7 +541,12 @@ class FilesCollection extends FilesCollectionCore { } } - if (!this.disableUpload && httpReq._parsedUrl.path.includes(`${this.downloadRoute}/${this.collectionName}/__upload`)) { + if (httpReq._parsedUrl.path.includes(`${this.downloadRoute}/${this.collectionName}/__upload`)) { + if (this.disableUpload) { + next(); + return; + } + if (httpReq.method !== 'POST') { next(); return; @@ -551,98 +554,42 @@ class FilesCollection extends FilesCollectionCore { const handleError = (_error) => { let error = _error; - Meteor._debug('[FilesCollection] [Upload] [HTTP] Exception:', error); + let errorCode = 500; + this._debug('[FilesCollection] [Upload] [HTTP] [handleError]', error); - if (!httpResp.headersSent) { - httpResp.writeHead(500); - } + if (helpers.isObject(error)) { + if (typeof error?.error === 'number') { + errorCode = error.error; + } - if (!httpResp.finished) { - if (helpers.isObject(error) && helpers.isFunction(error.toString)) { + if (helpers.isFunction(error?.toString)) { error = error.toString(); } + } - if (!helpers.isString(error)) { - error = 'Unexpected error!'; - } + if (!helpers.isString(error)) { + error = 'Unexpected error!'; + } + + if (!httpResp.headersSent) { + httpResp.writeHead(errorCode, { + 'Content-Type': 'application/json', + }); + } + if (!httpResp.finished && !httpResp.writableEnded) { httpResp.end(JSON.stringify({ error })); } }; let body = ''; - const handleData = () => { + const handleData = async () => { try { let opts; let result; let user = this._getUser({request: httpReq, response: httpResp}); - if (httpReq.headers['x-start'] !== '1') { - // CHUNK UPLOAD SCENARIO: - opts = { - fileId: this.sanitize(httpReq.headers['x-fileid'], 20, 'a') - }; - - if (httpReq.headers['x-eof'] === '1') { - opts.eof = true; - } else { - opts.binData = Buffer.from(body, 'base64'); - opts.chunkId = parseInt(httpReq.headers['x-chunkid']); - } - - const _continueUpload = this._continueUpload(opts.fileId); - if (!_continueUpload) { - throw new Meteor.Error(408, 'Can\'t continue upload, session expired. Start upload again.'); - } - - ({result, opts} = this._prepareUpload(Object.assign(opts, _continueUpload), user.userId, 'HTTP')); - - if (opts.eof) { - // FINISH UPLOAD SCENARIO: - this._handleUpload(result, opts, (_error) => { - let error = _error; - if (error) { - if (!httpResp.headersSent) { - httpResp.writeHead(500); - } - - if (!httpResp.finished) { - if (helpers.isObject(error) && helpers.isFunction(error.toString)) { - error = error.toString(); - } - - if (!helpers.isString(error)) { - error = 'Unexpected error!'; - } - - httpResp.end(JSON.stringify({ error })); - } - } - - if (!httpResp.headersSent) { - httpResp.writeHead(200); - } - - if (helpers.isObject(result.file) && result.file.meta) { - result.file.meta = fixJSONStringify(result.file.meta); - } - - if (!httpResp.finished) { - httpResp.end(JSON.stringify(result)); - } - }); - return; - } - - this.emit('_handleUpload', result, opts, noop); - - if (!httpResp.headersSent) { - httpResp.writeHead(204); - } - if (!httpResp.finished) { - httpResp.end(); - } - } else { + if (httpReq.headers['x-start'] === '1') { // START SCENARIO: try { opts = JSON.parse(body); @@ -665,24 +612,36 @@ class FilesCollection extends FilesCollectionCore { } opts.___s = true; - ({result} = this._prepareUpload(helpers.clone(opts), user.userId, 'HTTP Start Method')); - if (this.collection.findOne(result._id)) { + try { + ({result} = await this._prepareUpload(helpers.clone(opts), user.userId, 'HTTP Start Method')); + } catch (prepareError) { + handleError(prepareError); + return; + } + + let res; + res = await this.collection.findOneAsync(result._id); + + if (res) { throw new Meteor.Error(400, 'Can\'t start upload, data substitution detected!'); } opts._id = opts.fileId; opts.createdAt = new Date(); opts.maxLength = opts.fileLength; - this._preCollection.insert(helpers.omit(opts, '___s')); - this._createStream(result._id, result.path, helpers.omit(opts, '___s')); + + await this._preCollection.insertAsync(helpers.omit(opts, '___s')); + await this._createStream(result._id, result.path, helpers.omit(opts, '___s')); if (opts.returnMeta) { if (!httpResp.headersSent) { - httpResp.writeHead(200); + httpResp.writeHead(200, { + 'Content-Type': 'application/json', + }); } - if (!httpResp.finished) { + if (!httpResp.finished && !httpResp.writableEnded) { httpResp.end(JSON.stringify({ uploadRoute: `${this.downloadRoute}/${this.collectionName}/__upload`, file: result @@ -693,263 +652,361 @@ class FilesCollection extends FilesCollectionCore { httpResp.writeHead(204); } - if (!httpResp.finished) { + if (!httpResp.finished && !httpResp.writableEnded) { httpResp.end(); } } + return; + } + + // GET FILE'S UPLAOD META-DATA + opts = { + fileId: this.sanitize(httpReq.headers['x-fileid'], 20, 'a') + }; + + if (httpReq.headers['x-eof'] === '1') { + opts.eof = true; + } else { + opts.binData = Buffer.from(body, 'base64'); + opts.chunkId = parseInt(httpReq.headers['x-chunkid']); + } + + const _continueUpload = await this._continueUpload(opts.fileId); + if (!_continueUpload) { + throw new Meteor.Error(408, 'Can\'t continue upload, session expired. Start upload again.'); + } + + try { + ({result, opts} = await this._prepareUpload(Object.assign(opts, _continueUpload), user.userId, 'HTTP')); + } catch (prepareError) { + handleError(prepareError); + return; + } + + if (opts.eof) { + // FINISH UPLOAD SCENARIO: + try { + const isWritten = await this._handleUpload(result, opts); + + if (!isWritten) { + handleError(new Meteor.Error(503, 'Corrupted chunk. Try again')); + return; + } + + if (!httpResp.headersSent) { + httpResp.writeHead(200, { + 'Content-Type': 'application/json', + }); + } + + if (helpers.isObject(result.file) && result.file.meta) { + result.file.meta = fixJSONStringify(result.file.meta); + } + + if (!httpResp.finished && !httpResp.writableEnded) { + httpResp.end(JSON.stringify(result)); + } + } catch (handleUploadError) { + handleError(handleUploadError); + } + return; + } + + // CHUNK UPLOAD SCENARIO: + await self._handleUpload(result, opts); + + if (!httpResp.headersSent) { + httpResp.writeHead(204); + } + + if (!httpResp.finished && !httpResp.writableEnded) { + httpResp.end(); } } catch (httpRespErr) { handleError(httpRespErr); } }; - httpReq.setTimeout(20000, handleError); + httpReq.setTimeout(26000, () => { + handleError(new Meteor.Error(503, 'Timeout. Try again.')); + }); + + httpReq.on('error', (error) => { + handleError(new Meteor.Error(503, 'Request Error. Try again.', error?.toString?.() || '')); + }); + if (typeof httpReq.body === 'object' && Object.keys(httpReq.body).length !== 0) { body = JSON.stringify(httpReq.body); handleData(); } else { - httpReq.on('data', (data) => bound(() => { + httpReq.on('data', (data) => { body += data; - })); + }); - httpReq.on('end', () => bound(() => { + httpReq.on('end', () => { handleData(); - })); + }); } return; } - if (!this.disableDownload) { - let uri; - - if (!this.public) { - if (httpReq._parsedUrl.path.includes(`${this.downloadRoute}/${this.collectionName}`)) { - uri = httpReq._parsedUrl.path.replace(`${this.downloadRoute}/${this.collectionName}`, ''); - if (uri.indexOf('/') === 0) { - uri = uri.substring(1); - } + if (this.disableDownload) { + next(); + return; + } + let uri; - const uris = uri.split('/'); - if (uris.length === 3) { - const params = { - _id: uris[0], - query: httpReq._parsedUrl.query ? nodeQs.parse(httpReq._parsedUrl.query) : {}, - name: uris[2].split('?')[0], - version: uris[1] - }; - - const http = {request: httpReq, response: httpResp, params}; - if (this.interceptRequest && helpers.isFunction(this.interceptRequest) && this.interceptRequest(http) === true) { - return; - } + if (this.public) { + // HANDLE FILES UPLOADED TO DIRECTORY ACCESSIBLE BY WEB SERVER + // AND/OR WITHOUT PERMISSION CONTROL + if (!httpReq._parsedUrl.path.includes(`${this.downloadRoute}`)) { + next(); + return; + } + uri = httpReq._parsedUrl.path.replace(`${this.downloadRoute}`, ''); + if (uri.indexOf('/') === 0) { + uri = uri.substring(1); + } - if (this._checkAccess(http)) { - this.download(http, uris[1], this.collection.findOne(uris[0])); - } - } else { - next(); - } - } else { - next(); - } + const uris = uri.split('/'); + let _file = uris[uris.length - 1]; + if (!_file) { + next(); + return; + } + let version; + if (_file.includes('-')) { + version = _file.split('-')[0]; + _file = _file.split('-')[1].split('?')[0]; } else { - if (httpReq._parsedUrl.path.includes(`${this.downloadRoute}`)) { - uri = httpReq._parsedUrl.path.replace(`${this.downloadRoute}`, ''); - if (uri.indexOf('/') === 0) { - uri = uri.substring(1); - } + version = 'original'; + _file = _file.split('?')[0]; + } - const uris = uri.split('/'); - let _file = uris[uris.length - 1]; - if (_file) { - let version; - if (_file.includes('-')) { - version = _file.split('-')[0]; - _file = _file.split('-')[1].split('?')[0]; - } else { - version = 'original'; - _file = _file.split('?')[0]; - } + const params = { + query: httpReq._parsedUrl.query ? nodeQs.parse(httpReq._parsedUrl.query) : {}, + file: _file, + _id: _file.split('.')[0], + version, + name: _file + }; + const http = {request: httpReq, response: httpResp, params}; - const params = { - query: httpReq._parsedUrl.query ? nodeQs.parse(httpReq._parsedUrl.query) : {}, - file: _file, - _id: _file.split('.')[0], - version, - name: _file - }; - const http = {request: httpReq, response: httpResp, params}; - if (this.interceptRequest && helpers.isFunction(this.interceptRequest) && this.interceptRequest(http) === true) { - return; - } - this.download(http, version, this.collection.findOne(params._id)); - } else { - next(); - } - } else { - next(); - } + // CHECK IF SETUP HAS CUSTOM FUNCTION TO SERVE UPLOADED FILES VIA `interceptRequest` + if (this.interceptRequest && helpers.isFunction(this.interceptRequest) && (await this.interceptRequest(http)) === true) { + return; } + await this.download(http, version, await this.collection.findOneAsync(params._id)); return; } - next(); - }); - if (!this.disableUpload) { - const _methods = {}; - - // Method used to remove file - // from Client side - _methods[this._methodNames._Remove] = function (selector) { - check(selector, Match.OneOf(String, Object)); - self._debug(`[FilesCollection] [Unlink Method] [.remove(${selector})]`); - - if (self.allowClientCode) { - if (self.onBeforeRemove && helpers.isFunction(self.onBeforeRemove)) { - const userId = this.userId; - const userFuncs = { - userId: this.userId, - user() { - if (Meteor.users) { - return Meteor.users.findOne(userId); - } - return null; - } - }; + // HANDLE FILES UPLOADED TO OBFUSCATED STORAGE WITH PERMISSION CONTROL + if (!httpReq._parsedUrl.path.includes(`${this.downloadRoute}/${this.collectionName}`)) { + next(); + return; + } - if (!self.onBeforeRemove.call(userFuncs, (self.find(selector) || null))) { - throw new Meteor.Error(403, '[FilesCollection] [remove] Not permitted!'); - } - } + uri = httpReq._parsedUrl.path.replace(`${this.downloadRoute}/${this.collectionName}`, ''); + if (uri.indexOf('/') === 0) { + uri = uri.substring(1); + } - const cursor = self.find(selector); - if (cursor.count() > 0) { - self.remove(selector); - return true; - } - throw new Meteor.Error(404, 'Cursor is empty, no files is removed'); - } else { - throw new Meteor.Error(405, '[FilesCollection] [remove] Running code on a client is not allowed!'); - } + const uris = uri.split('/'); + if (uris.length !== 3) { + next(); + return; + } + + const params = { + _id: uris[0], + query: httpReq._parsedUrl.query ? nodeQs.parse(httpReq._parsedUrl.query) : {}, + name: uris[2].split('?')[0], + version: uris[1] }; + const http = {request: httpReq, response: httpResp, params}; - // Method used to receive "first byte" of upload - // and all file's meta-data, so - // it won't be transferred with every chunk - // Basically it prepares everything - // So user can pause/disconnect and - // continue upload later, during `continueUploadTTL` - _methods[this._methodNames._Start] = function (opts, returnMeta) { - check(opts, { - file: Object, - fileId: String, - FSName: Match.Optional(String), - chunkSize: Number, - fileLength: Number - }); + // CHECK IF SETUP HAS CUSTOM FUNCTION TO SERVE UPLOADED FILES VIA `interceptRequest` + if (this.interceptRequest && helpers.isFunction(this.interceptRequest) && (await this.interceptRequest(http)) === true) { + return; + } - check(returnMeta, Match.Optional(Boolean)); + if (await this._checkAccess(http)) { + await this.download(http, uris[1], await this.collection.findOneAsync(uris[0])); + } + return; + }); - opts.fileId = self.sanitize(opts.fileId, 20, 'a'); + this._debug('[FilesCollection] initiated', this); - self._debug(`[FilesCollection] [File Start Method] ${opts.file.name} - ${opts.fileId}`); - opts.___s = true; - const { result } = self._prepareUpload(helpers.clone(opts), this.userId, 'DDP Start Method'); + if (this.disableUpload) { + // SKIP REGISTERING SERVER METHODS WHEN {disableUpload: true} + return; + } + const _methods = {}; + // Method used to remove file + // from Client side + _methods[this._methodNames._Remove] = async function (selector) { + // eslint-disable-next-line new-cap + check(selector, Match.OneOf(String, Object)); + self._debug(`[FilesCollection] [Unlink Method] [.removeAsync(${selector})]`); + + if (self.allowClientCode) { + if (self.onBeforeRemove) { + const userId = this.userId; + const userFuncs = { + userId: this.userId, + async userAsync(){ + if (Meteor.users) { + return await Meteor.users.findOneAsync(userId); + } + return null; + } + }; - if (self.collection.findOne(result._id)) { - throw new Meteor.Error(400, 'Can\'t start upload, data substitution detected!'); + if (!(await self.onBeforeRemove.call(userFuncs, (self.find(selector) || null)))) { + throw new Meteor.Error(403, '[FilesCollection] [remove] Not permitted!'); + } } - opts._id = opts.fileId; - opts.createdAt = new Date(); - opts.maxLength = opts.fileLength; - try { - self._preCollection.insert(helpers.omit(opts, '___s')); - self._createStream(result._id, result.path, helpers.omit(opts, '___s')); - } catch (e) { - self._debug(`[FilesCollection] [File Start Method] [EXCEPTION:] ${opts.file.name} - ${opts.fileId}`, e); - throw new Meteor.Error(500, 'Can\'t start'); + const count = await self.countDocuments(selector); + if (count > 0) { + await self.removeAsync(selector); } + return count; + } - if (returnMeta) { - return { - uploadRoute: `${self.downloadRoute}/${self.collectionName}/__upload`, - file: result - }; - } - return true; - }; + throw new Meteor.Error(405, '[FilesCollection] [remove] Running code on a client is not allowed!'); + }; - // Method used to write file chunks - // it receives very limited amount of meta-data - // This method also responsible for EOF - _methods[this._methodNames._Write] = function (_opts) { - let opts = _opts; - let result; - check(opts, { - eof: Match.Optional(Boolean), - fileId: String, - binData: Match.Optional(String), - chunkId: Match.Optional(Number) - }); + // Method used to receive "first byte" of upload + // and all file's meta-data, so + // it won't be transferred with every chunk + // Basically it prepares everything + // So user can pause/disconnect and + // continue upload later, during `continueUploadTTL` + _methods[this._methodNames._Start] = async function (opts, returnMeta) { + /* eslint-disable new-cap */ + check(opts, { + file: Object, + fileId: String, + FSName: Match.Optional(String), + chunkSize: Number, + fileLength: Number + }); + check(returnMeta, Match.Optional(Boolean)); + /* eslint-enable new-cap */ + self._debug(`[FilesCollection] [File Start Method] ${opts.file.name} - ${opts.fileId}`); - opts.fileId = self.sanitize(opts.fileId, 20, 'a'); + opts.fileId = self.sanitize(opts.fileId, 20, 'a'); + opts.___s = true; + const { result } = await self._prepareUpload(helpers.clone(opts), this.userId, 'DDP Start Method'); - if (opts.binData) { - opts.binData = Buffer.from(opts.binData, 'base64'); - } + if (await self.collection.findOneAsync(result._id)) { + throw new Meteor.Error(400, 'Can\'t start upload, data substitution detected!'); + } - const _continueUpload = self._continueUpload(opts.fileId); - if (!_continueUpload) { - throw new Meteor.Error(408, 'Can\'t continue upload, session expired. Start upload again.'); - } + opts._id = opts.fileId; + opts.createdAt = new Date(); + opts.maxLength = opts.fileLength; + try { + await self._preCollection.insertAsync(helpers.omit(opts, '___s')); + await self._createStream(result._id, result.path, helpers.omit(opts, '___s')); + } catch (e) { + self._debug(`[FilesCollection] [File Start Method] [EXCEPTION:] ${opts.file.name} - ${opts.fileId}`, e); + throw new Meteor.Error(500, 'Can\'t start'); + } - this.unblock(); - ({result, opts} = self._prepareUpload(Object.assign(opts, _continueUpload), this.userId, 'DDP')); + if (returnMeta) { + return { + status: 204, + uploadRoute: `${self.downloadRoute}/${self.collectionName}/__upload`, + file: result, + }; + } + return { status: 204 }; + }; - if (opts.eof) { - try { - return self._handleUploadSync(result, opts); - } catch (handleUploadErr) { - self._debug('[FilesCollection] [Write Method] [DDP] Exception:', handleUploadErr); - throw handleUploadErr; - } - } else { - self.emit('_handleUpload', result, opts, noop); - } - return true; - }; - // Method used to Abort upload - // - Freeing memory by ending writableStreams - // - Removing temporary record from @_preCollection - // - Removing record from @collection - // - .unlink()ing chunks from FS - _methods[this._methodNames._Abort] = function (_id) { - check(_id, String); + // Method used to write file chunks + // it receives very limited amount of meta-data + // This method also responsible for EOF + _methods[this._methodNames._Write] = async function (_opts) { + let opts = _opts; + let result; + /* eslint-disable new-cap */ + check(opts, { + eof: Match.Optional(Boolean), + fileId: String, + binData: Match.Optional(String), + chunkId: Match.Optional(Number) + }); + /* eslint-enable new-cap */ + + self._debug('[FilesCollection] [Write Method] Chunk received', opts.fileId); - const _continueUpload = self._continueUpload(_id); - self._debug(`[FilesCollection] [Abort Method]: ${_id} - ${(helpers.isObject(_continueUpload.file) ? _continueUpload.file.path : '')}`); + opts.fileId = self.sanitize(opts.fileId, 20, 'a'); + if (opts.binData) { + opts.binData = Buffer.from(opts.binData, 'base64'); + } - if (self._currentUploads && self._currentUploads[_id]) { - self._currentUploads[_id].stop(); - self._currentUploads[_id].abort(); + const _continueUpload = await self._continueUpload(opts.fileId); + if (!_continueUpload) { + throw new Meteor.Error(408, 'Can\'t continue upload, session expired. Start upload again.'); + } + + ({result, opts} = await self._prepareUpload(Object.assign(opts, _continueUpload), this.userId, 'DDP')); + this.unblock(); + if (opts.eof) { + try { + const isWritten = await self._handleUpload(result, opts); + if (!isWritten) { + throw new Meteor.Error(503, 'try again'); + } + result.status = 200; + return result; + } catch (handleUploadErr) { + self._debug('[FilesCollection] [Write Method] [DDP] Exception:', handleUploadErr); + throw handleUploadErr; } + } else { + await self._handleUpload(result, opts); + } + return { status: 204 }; + }; + + // Method used to Abort upload + // - Freeing memory by ending writableStreams + // - Removing temporary record from @_preCollection + // - Removing record from @collection + // - .unlink()ing chunks from FS + _methods[this._methodNames._Abort] = async function (_id) { + check(_id, String); + self._debug(`[FilesCollection] [Abort Method]: ${_id}`, self._currentUploads[_id]); + this.unblock(); + + if (self._currentUploads) { + if (self._currentUploads[_id] && !self._currentUploads[_id].ended && !self._currentUploads[_id].aborted) { + await self._currentUploads[_id].abort(); + } + } else { + const _continueUpload = await self._continueUpload(_id); + self._debug(`[FilesCollection] [Abort Method]: ${_id} - ${(helpers.isObject(_continueUpload.file) ? _continueUpload.file.path : '')}`); if (_continueUpload) { - self._preCollection.remove({_id}); - self.remove({_id}); if (helpers.isObject(_continueUpload.file) && _continueUpload.file.path) { - self.unlink({_id, path: _continueUpload.file.path}); + await self.unlinkAsync({_id, path: _continueUpload.file.path}); } } - return true; - }; + } - Meteor.methods(_methods); - } + await self._preCollection.removeAsync({_id}); + await self.collection.removeAsync({_id}); + return { status: 499 }; + }; + + Meteor.methods(_methods); } /** @@ -957,9 +1014,9 @@ class FilesCollection extends FilesCollectionCore { * @memberOf FilesCollection * @name _prepareUpload * @summary Internal method. Used to optimize received data and check upload permission - * @returns {Object} + * @returns {Promise} */ - _prepareUpload(opts = {}, userId, transport) { + async _prepareUpload(opts = {}, userId, transport) { let ctx; if (!helpers.isBoolean(opts.eof)) { opts.eof = false; @@ -996,53 +1053,53 @@ class FilesCollection extends FilesCollectionCore { opts.FSName = this.sanitize(opts.FSName); if (this.namingFunction) { - opts.FSName = this.namingFunction(opts); + opts.FSName = await this.namingFunction(opts); } result.path = `${this.storagePath(result)}${nodePath.sep}${opts.FSName}${extensionWithDot}`; result = Object.assign(result, this._dataToSchema(result)); - if (this.onBeforeUpload && helpers.isFunction(this.onBeforeUpload)) { + if (this.onBeforeUpload) { ctx = Object.assign({ file: opts.file }, { chunkId: opts.chunkId, userId: result.userId, - user() { + async userAsync() { if (Meteor.users && result.userId) { - return Meteor.users.findOne(result.userId); + return await Meteor.users.findOneAsync(result.userId); } return null; }, eof: opts.eof }); - const isUploadAllowed = this.onBeforeUpload.call(ctx, result); + const isUploadAllowed = await this.onBeforeUpload.call(ctx, result); if (isUploadAllowed !== true) { throw new Meteor.Error(403, helpers.isString(isUploadAllowed) ? isUploadAllowed : '@onBeforeUpload() returned false'); } else { - if ((opts.___s === true) && this.onInitiateUpload && helpers.isFunction(this.onInitiateUpload)) { - this.onInitiateUpload.call(ctx, result); + if ((opts.___s === true) && this.onInitiateUpload) { + await this.onInitiateUpload.call(ctx, result); } } - } else if ((opts.___s === true) && this.onInitiateUpload && helpers.isFunction(this.onInitiateUpload)) { + } else if ((opts.___s === true) && this.onInitiateUpload) { ctx = Object.assign({ file: opts.file }, { chunkId: opts.chunkId, userId: result.userId, - user() { + async userAsync() { if (Meteor.users && result.userId) { - return Meteor.users.findOne(result.userId); + return await Meteor.users.findOneAsync(result.userId); } return null; }, eof: opts.eof }); - this.onInitiateUpload.call(ctx, result); + await this.onInitiateUpload.call(ctx, result); } - return {result, opts}; + return { result, opts }; } /** @@ -1050,34 +1107,35 @@ class FilesCollection extends FilesCollectionCore { * @memberOf FilesCollection * @name _finishUpload * @summary Internal method. Finish upload, close Writable stream, add record to MongoDB and flush used memory - * @returns {undefined} + * @returns {Promise} */ - _finishUpload(result, opts, cb) { - this._debug(`[FilesCollection] [Upload] [finish(ing)Upload] -> ${result.path}`); - fs.chmod(result.path, this.permissions, noop); + async _finishUpload(result, opts) { + this._debug(`[FilesCollection] [_finishUpload] [finish(ing)Upload] -> ${result.path}`); + await fs.promises.chmod(result.path, this.permissions); result.type = this._getMimeType(opts.file); result.public = this.public; this._updateFileTypes(result); - this.collection.insert(helpers.clone(result), (colInsert, _id) => { - if (colInsert) { - cb && cb(colInsert); - this._debug('[FilesCollection] [Upload] [_finishUpload] [insert] Error:', colInsert); - } else { - this._preCollection.update({_id: opts.fileId}, {$set: {isFinished: true}}, (preUpdateError) => { - if (preUpdateError) { - cb && cb(preUpdateError); - this._debug('[FilesCollection] [Upload] [_finishUpload] [update] Error:', preUpdateError); - } else { - result._id = _id; - this._debug(`[FilesCollection] [Upload] [finish(ed)Upload] -> ${result.path}`); - this.onAfterUpload && this.onAfterUpload.call(this, result); - this.emit('afterUpload', result); - cb && cb(null, result); - } - }); + let _id; + try { + _id = await this.collection.insertAsync(helpers.clone(result)); + try { + await this._preCollection.updateAsync({_id: opts.fileId}, {$set: {isFinished: true}}); + if (_id) { + result._id = _id; + } + + this._debug(`[FilesCollection] [_finishUpload] [finish(ed)Upload] -> ${result.path}`); + if (this.onAfterUpload) { + await this.onAfterUpload.call(this, result); + } + this.emit('afterUpload', result); + } catch (prrUpdateError) { + this._debug('[FilesCollection] [_finishUpload] [update] Error:', prrUpdateError); } - }); + } catch (colInsert){ + this._debug('[FilesCollection] [_finishUpload] [insert] Error:', colInsert); + } } /** @@ -1085,21 +1143,20 @@ class FilesCollection extends FilesCollectionCore { * @memberOf FilesCollection * @name _handleUpload * @summary Internal method to handle upload process, pipe incoming data to Writable stream - * @returns {undefined} + * @returns {Promise} - `true` if chunk was written as expected */ - _handleUpload(result, opts, cb) { - try { - if (opts.eof) { - this._currentUploads[result._id].end(() => { - this.emit('_finishUpload', result, opts, cb); - }); - } else { - this._currentUploads[result._id].write(opts.chunkId, opts.binData, cb); + async _handleUpload(result, opts) { + if (opts.eof) { + if (await this._currentUploads[result._id].end()) { + await this._finishUpload(result, opts); + return true; + } + } else { + if (await this._currentUploads[result._id].write(opts.chunkId, opts.binData)) { + return true; } - } catch (e) { - this._debug('[_handleUpload] [EXCEPTION:]', e); - cb && cb(e); } + return false; } /** @@ -1108,11 +1165,12 @@ class FilesCollection extends FilesCollectionCore { * @name _getMimeType * @param {Object} fileData - File Object * @summary Returns file's mime-type - * @returns {String} + * @returns {string} */ _getMimeType(fileData) { let mime; check(fileData, Object); + if (helpers.isObject(fileData) && fileData.type) { mime = fileData.type; } @@ -1128,10 +1186,12 @@ class FilesCollection extends FilesCollectionCore { * @memberOf FilesCollection * @name _getUserId * @summary Returns `userId` matching the xmtok token derived from Meteor.server.sessions - * @returns {String} + * @returns {string} */ _getUserId(xmtok) { - if (!xmtok) return null; + if (!xmtok) { + return null; + } // throw an error upon an unexpected type of Meteor.server.sessions in order to identify breaking changes if (!Meteor.server.sessions instanceof Map || !helpers.isObject(Meteor.server.sessions)) { @@ -1153,23 +1213,23 @@ class FilesCollection extends FilesCollectionCore { * @locus Anywhere * @memberOf FilesCollection * @name _getUser - * @summary Returns object with `userId` and `user()` method which return user's object + * @summary Returns object with `userId` and `userAsync()` method which return user's object * @returns {Object} */ _getUser() { - return this.getUser ? - this.getUser(...arguments) : this._getUserDefault(...arguments); + return this.getUser ? this.getUser(...arguments) : this._getUserDefault(...arguments); } /** * @locus Anywhere * @memberOf FilesCollection * @name _getUserDefault - * @summary Default way of recognising user based on 'x_mtok' cookie, can be replaced by 'config.getUser' if defnied. Returns object with `userId` and `user()` method which return user's object + * @summary Default way of recognizing user based on 'x_mtok' cookie, can be replaced by 'config.getUser' if defined. Returns object with `userId` and `userAsync()` method which return user's object * @returns {Object} */ _getUserDefault(http) { const result = { + async userAsync() { return null; }, user() { return null; }, userId: null }; @@ -1189,7 +1249,12 @@ class FilesCollection extends FilesCollectionCore { const userId = this._getUserId(mtok); if (userId) { - result.user = () => Meteor.users.findOne(userId); + result.userAsync = async () => { + if (Meteor.users) { + return await Meteor.users.findOneAsync(userId); + } + return null; + }; result.userId = userId; } } @@ -1201,42 +1266,36 @@ class FilesCollection extends FilesCollectionCore { /** * @locus Server * @memberOf FilesCollection - * @name write + * @name writAsync * @param {Buffer} buffer - Binary File's Buffer - * @param {Object} opts - Object with file-data - * @param {String} opts.name - File name, alias: `fileName` - * @param {String} opts.type - File mime-type + * @param {WriteOpts} [opts] - Object with file-data + * @param {string} opts.name - File name, alias: `fileName` + * @param {string} opts.type - File mime-type * @param {Object} opts.meta - File additional meta-data - * @param {String} opts.userId - UserId, default *null* - * @param {String} opts.fileId - _id, sanitized, max-length: 20; default *null* - * @param {Function} callback - function(error, fileObj){...} - * @param {Boolean} proceedAfterUpload - Proceed onAfterUpload hook + * @param {string} opts.userId - UserId, default *null* + * @param {string} opts.fileId - _id, sanitized, max-length: 20; default *null* + * @param {boolean} proceedAfterUpload - Proceed onAfterUpload hook * @summary Write buffer to FS and add to FilesCollection Collection - * @returns {FilesCollection} Instance + * @throws {Meteor.Error} If there is an error writing the file or inserting the document + * @returns {Promise} File Object from DB */ - write(buffer, _opts = {}, _callback, _proceedAfterUpload) { - this._debug('[FilesCollection] [write()]'); + async writeAsync(buffer, _opts = {}, _proceedAfterUpload) { + this._debug('[FilesCollection] [writeAsync()]'); let opts = _opts; - let callback = _callback; let proceedAfterUpload = _proceedAfterUpload; - if (helpers.isFunction(opts)) { - proceedAfterUpload = callback; - callback = opts; - opts = {}; - } else if (helpers.isBoolean(callback)) { - proceedAfterUpload = callback; - } else if (helpers.isBoolean(opts)) { + if (helpers.isBoolean(opts)) { proceedAfterUpload = opts; + opts = {}; } - + /* eslint-disable new-cap */ check(opts, Match.Optional(Object)); - check(callback, Match.Optional(Function)); check(proceedAfterUpload, Match.Optional(Boolean)); + /* eslint-enable new-cap */ opts.fileId = opts.fileId && this.sanitize(opts.fileId, 20, 'a'); const fileId = opts.fileId || Random.id(); - const fsName = this.namingFunction ? this.namingFunction(opts) : fileId; + const fsName = this.namingFunction ? await this.namingFunction(opts) : fileId; const fileName = (opts.name || opts.fileName) ? (opts.name || opts.fileName) : fsName; const {extension, extensionWithDot} = this._getExt(fileName); @@ -1263,81 +1322,86 @@ class FilesCollection extends FilesCollectionCore { result._id = fileId; - fs.stat(opts.path, (statError, stats) => { - bound(() => { - if (statError || !stats.isFile()) { - const paths = opts.path.split('/'); - paths.pop(); - fs.mkdirSync(paths.join('/'), { recursive: true }); - fs.writeFileSync(opts.path, ''); + let fileObj; + + let mustCreateFileFirst = false; + try { + const stats = await fs.promises.stat(opts.path); + if (!stats.isFile()) { + mustCreateFileFirst = true; + } + } catch (_statError) { + mustCreateFileFirst = true; + } + + if (mustCreateFileFirst) { + const paths = opts.path.split('/'); + paths.pop(); + await fs.promises.mkdir(paths.join('/'), { recursive: true, mode: this.parentDirPermissions, }); + await fs.promises.writeFile(opts.path, '', { mode: this.permissions, }); + } + + try { + const fh = await fs.promises.open(opts.path, fs.constants.O_WRONLY | fs.constants.O_CREAT, this.permissions); + await fh.write(buffer); + await fh.datasync(); + await fh.close(); + } catch (openWriteErr) { + this._debug(`[FilesCollection] [writeAsync] [open] [write] Error: ${fileName} -> ${this.collectionName}`, openWriteErr); + throw new Meteor.Error('writeAsync', openWriteErr); + } + + try { + const _id = await this.collection.insertAsync(result); + fileObj = await this.collection.findOneAsync(_id); + + if (proceedAfterUpload === true) { + if (this.onAfterUpload){ + await this.onAfterUpload.call(this, fileObj); } + this.emit('afterUpload', fileObj); + } + this._debug(`[FilesCollection] [write]: ${fileName} -> ${this.collectionName}`); + } catch (insertErr) { + this._debug(`[FilesCollection] [write] [insert] Error: ${fileName} -> ${this.collectionName}`, insertErr); + throw new Meteor.Error('writeAsync', insertErr); + } - const stream = fs.createWriteStream(opts.path, {flags: 'w', mode: this.permissions}); - stream.end(buffer, (streamErr) => { - bound(() => { - if (streamErr) { - callback && callback(streamErr); - } else { - this.collection.insert(result, (insertErr, _id) => { - if (insertErr) { - callback && callback(insertErr); - this._debug(`[FilesCollection] [write] [insert] Error: ${fileName} -> ${this.collectionName}`, insertErr); - } else { - const fileRef = this.collection.findOne(_id); - callback && callback(null, fileRef); - if (proceedAfterUpload === true) { - this.onAfterUpload && this.onAfterUpload.call(this, fileRef); - this.emit('afterUpload', fileRef); - } - this._debug(`[FilesCollection] [write]: ${fileName} -> ${this.collectionName}`); - } - }); - } - }); - }); - }); - }); - return this; + return fileObj; } /** * @locus Server * @memberOf FilesCollection - * @name load - * @param {String} url - URL to file - * @param {Object} [opts] - Object with file-data + * @name loadAsync + * @param {string} url - URL to file + * @param {LoadOpts} [opts] - Object with file-data * @param {Object} opts.headers - HTTP headers to use when requesting the file - * @param {String} opts.name - File name, alias: `fileName` - * @param {String} opts.type - File mime-type + * @param {string} opts.name - File name, alias: `fileName` + * @param {string} opts.type - File mime-type * @param {Object} opts.meta - File additional meta-data - * @param {String} opts.userId - UserId, default *null* - * @param {String} opts.fileId - _id, sanitized, max-length: 20; default *null* - * @param {Number} opts.timeout - Timeout in milliseconds, default: 360000 (6 mins) - * @param {Function} callback - function(error, fileObj){...} - * @param {Boolean} [proceedAfterUpload] - Proceed onAfterUpload hook + * @param {string} opts.userId - UserId, default *null* + * @param {string} opts.fileId - _id, sanitized, max-length: 20; default *null* + * @param {number} opts.timeout - Timeout in milliseconds, default: 360000 (6 mins) + * @param {boolean} [proceedAfterUpload] - Proceed onAfterUpload hook * @summary Download file over HTTP, write stream to FS, and add to FilesCollection Collection - * @returns {FilesCollection} Instance + * @returns {Promise} File Object from DB */ - load(url, _opts = {}, _callback, _proceedAfterUpload = false) { - this._debug(`[FilesCollection] [load(${url}, ${JSON.stringify(_opts)}, callback)]`); + async loadAsync(url, _opts = {}, _proceedAfterUpload = false) { + this._debug(`[FilesCollection] [loadAsync(${url}, ${JSON.stringify(_opts)}, callback)]`); let opts = _opts; - let callback = _callback; let proceedAfterUpload = _proceedAfterUpload; - if (helpers.isFunction(opts)) { - proceedAfterUpload = callback; - callback = opts; + if (helpers.isBoolean(_opts)) { + proceedAfterUpload = _opts; opts = {}; - } else if (helpers.isBoolean(callback)) { - proceedAfterUpload = callback; - } else if (helpers.isBoolean(opts)) { - proceedAfterUpload = opts; } check(url, String); + /* eslint-disable new-cap */ check(opts, Match.Optional(Object)); - check(callback, Match.Optional(Function)); check(proceedAfterUpload, Match.Optional(Boolean)); + /* eslint-enable new-cap */ if (!helpers.isObject(opts)) { opts = { @@ -1350,283 +1414,248 @@ class FilesCollection extends FilesCollectionCore { } const fileId = (opts.fileId && this.sanitize(opts.fileId, 20, 'a')) || Random.id(); - const fsName = this.namingFunction ? this.namingFunction(opts) : fileId; + const fsName = this.namingFunction ? await this.namingFunction(opts) : fileId; const pathParts = url.split('/'); const fileName = (opts.name || opts.fileName) ? (opts.name || opts.fileName) : pathParts[pathParts.length - 1].split('?')[0] || fsName; const {extension, extensionWithDot} = this._getExt(fileName); opts.path = `${this.storagePath(opts)}${nodePath.sep}${fsName}${extensionWithDot}`; - const storeResult = (result, cb) => { + // this will be the resolved fileObj + let fileObj; + + // storeResult is a function that will be called after the file is downloaded and stored in the database + // this might throw an error from collection.insertAsync or collection.findOneAsync + const storeResult = async (result) => { result._id = fileId; + const _id = await this.collection.insertAsync(result); - this.collection.insert(result, (error, _id) => { - if (error) { - cb && cb(error); - this._debug(`[FilesCollection] [load] [insert] Error: ${fileName} -> ${this.collectionName}`, error); - } else { - const fileRef = this.collection.findOne(_id); - cb && cb(null, fileRef); - if (proceedAfterUpload === true) { - this.onAfterUpload && this.onAfterUpload.call(this, fileRef); - this.emit('afterUpload', fileRef); - } - this._debug(`[FilesCollection] [load] [insert] ${fileName} -> ${this.collectionName}`); + fileObj = await this.collection.findOneAsync(_id); + if (proceedAfterUpload === true) { + if (this.onAfterUpload){ + await this.onAfterUpload.call(this, fileObj); } - }); + this.emit('afterUpload', fileObj); + } + this._debug(`[FilesCollection] [load] [insert] ${fileName} -> ${this.collectionName}`); }; - fs.stat(opts.path, (statError, stats) => { - bound(() => { - if (statError || !stats.isFile()) { - const paths = opts.path.split('/'); - paths.pop(); - fs.mkdirSync(paths.join('/'), { recursive: true }); - fs.writeFileSync(opts.path, ''); - } + // check if the file already exists, otherwise create it + let mustCreateFileFirst = false; + try { + const stats = await fs.promises.stat(opts.path); + if (!stats.isFile()) { + mustCreateFileFirst = true; + } + } catch (_statError) { + mustCreateFileFirst = true; + } - let isEnded = false; - let timer = null; - const wStream = fs.createWriteStream(opts.path, {flags: 'w', mode: this.permissions, autoClose: true, emitClose: false }); - const onEnd = (_error, response) => { - if (!isEnded) { - if (timer) { - Meteor.clearTimeout(timer); - timer = null; - } + if (mustCreateFileFirst) { + const paths = opts.path.split('/'); + paths.pop(); + await fs.promises.mkdir(paths.join('/'), { recursive: true, mode: this.parentDirPermissions, }); + await fs.promises.writeFile(opts.path, '', { mode: this.permissions, }); + } - isEnded = true; - if (response && response.status === 200) { - this._debug(`[FilesCollection] [load] Received: ${url}`); - const result = this._dataToSchema({ - name: fileName, - path: opts.path, - meta: opts.meta, - type: opts.type || response.headers.get('content-type') || this._getMimeType({path: opts.path}), - size: opts.size || parseInt(response.headers.get('content-length') || 0), - userId: opts.userId, - extension - }); - - if (!result.size) { - fs.stat(opts.path, (statErrorOnEnd, newStats) => { - bound(() => { - if (statErrorOnEnd) { - callback && callback(statErrorOnEnd); - } else { - result.versions.original.size = (result.size = newStats.size); - storeResult(result, callback); - } - }); - }); - } else { - storeResult(result, callback); - } - } else { - const error = _error || new Meteor.Error(response?.status || 408, response?.statusText || 'Bad response with empty details'); - this._debug(`[FilesCollection] [load] [fetch(${url})] Error:`, error); + const wStream = fs.createWriteStream(opts.path, { flags: fs.constants.O_WRONLY | fs.constants.O_CREAT, mode: this.permissions, autoClose: true, emitClose: false }); + const controller = new AbortController(); - if (!wStream.destroyed) { - wStream.destroy(); - } + try { + let timer; - fs.unlink(opts.path, (unlinkError) => { - bound(() => { - callback && callback(error); - if (unlinkError) { - this._debug(`[FilesCollection] [load] [fetch(${url})] [fs.unlink(${opts.path})] unlinkError:`, unlinkError); - } - }); - }); - } - } - }; + if (opts.timeout && opts.timeout > 0) { + timer = setTimeout(() => { + controller.abort(); + throw new Meteor.Error(408, `Request timeout after ${opts.timeout}ms`); + }, opts.timeout); + } - let resp = void 0; - wStream.on('error', (error) => { - bound(() => { - onEnd(error); - }); - }); - wStream.on('close', () => { - bound(() => { - onEnd(void 0, resp); - }); - }); - wStream.on('finish', () => { - bound(() => { - onEnd(void 0, resp); - }); - }); + const res = await fetch(url, { + headers: opts.headers || {}, + signal: controller.signal + }); - const controller = new AbortController(); - fetch(url, { - headers: opts.headers || {}, - signal: controller.signal - }).then((res) => { - resp = res; - res.body.on('error', (error) => { - bound(() => { - onEnd(error); - }); - }); - res.body.pipe(wStream); - }).catch((fetchError) => { - onEnd(fetchError); - }); + if (timer) { + clearTimeout(timer); + timer = null; + } - if (opts.timeout > 0) { - timer = Meteor.setTimeout(() => { - onEnd(new Meteor.Error(408, `Request timeout after ${opts.timeout}ms`)); - controller.abort(); - }, opts.timeout); - } + if (!res.ok) { + throw new Error(`Unexpected response ${res.statusText}`); + } + + await pipeline(res.body, wStream); + + const result = this._dataToSchema({ + name: fileName, + path: opts.path, + meta: opts.meta, + type: opts.type || res.headers.get('content-type') || this._getMimeType({path: opts.path}), + size: opts.size || parseInt(res.headers.get('content-length') || 0), + userId: opts.userId, + extension }); - }); - return this; + if (!result.size) { + const newStats = await fs.promises.stat(opts.path); + result.versions.original.size = (result.size = newStats.size); + await storeResult(result); + } else { + await storeResult(result); + } + res.body.pipe(wStream); + } catch(error){ + this._debug(`[FilesCollection] [loadAsync] [fetch(${url})] Error:`, error); + + if (fs.existsSync(opts.path)) { + await fs.promises.unlink(opts.path); + } + + throw error; + } + + + return fileObj; } /** * @locus Server * @memberOf FilesCollection * @name addFile - * @param {String} path - Path to file - * @param {String} opts - [Optional] Object with file-data - * @param {String} opts.type - [Optional] File mime-type + * @param {string} path - Path to file + * @param {AddFileOpts} [opts] - [Optional] Object with file-data + * @param {string} opts.type - [Optional] File mime-type * @param {Object} opts.meta - [Optional] File additional meta-data - * @param {String} opts.fileId - _id, sanitized, max-length: 20 symbols default *null* - * @param {Object} opts.fileName - [Optional] File name, if not specified file name and extension will be taken from path - * @param {String} opts.userId - [Optional] UserId, default *null* - * @param {Function} callback - [Optional] function(error, fileObj){...} - * @param {Boolean} proceedAfterUpload - Proceed onAfterUpload hook + * @param {string} opts.fileId - [optional] _id, sanitized, max-length: 20 symbols default *null* + * @param {string} opts.fileName - [Optional] File name, if not specified file name and extension will be taken from path + * @param {string} opts.userId - [Optional] UserId, default *null* + * @param {boolean} proceedAfterUpload - Proceed onAfterUpload hook * @summary Add file from FS to FilesCollection - * @returns {FilesCollection} Instance + * @throws {Meteor.Error} If file does not exist (400) or collection is public (403) + * @returns {Promise} Instance */ - addFile(path, _opts = {}, _callback, _proceedAfterUpload) { + async addFile(path, _opts = {}, _proceedAfterUpload) { this._debug(`[FilesCollection] [addFile(${path})]`); let opts = _opts; - let callback = _callback; let proceedAfterUpload = _proceedAfterUpload; - if (helpers.isFunction(opts)) { - proceedAfterUpload = callback; - callback = opts; - opts = {}; - } else if (helpers.isBoolean(callback)) { - proceedAfterUpload = callback; - } else if (helpers.isBoolean(opts)) { - proceedAfterUpload = opts; - } - if (this.public) { throw new Meteor.Error(403, 'Can not run [addFile] on public collection! Just Move file to root of your server, then add record to Collection'); } check(path, String); + /* eslint-disable new-cap */ check(opts, Match.Optional(Object)); - check(callback, Match.Optional(Function)); check(proceedAfterUpload, Match.Optional(Boolean)); + /* eslint-enable new-cap */ - fs.stat(path, (statErr, stats) => bound(() => { - if (statErr) { - callback && callback(statErr); - } else if (stats.isFile()) { - if (!helpers.isObject(opts)) { - opts = {}; - } - opts.path = path; + let stats; + try { + stats = await fs.promises.stat(path); + } catch (statErr) { + if (statErr.code === 'ENOENT') { + throw new Meteor.Error(400, `[FilesCollection] [addFile(${path})]: File does not exist`); + } + throw new Meteor.Error(statErr.code, statErr.message); + } - if (!opts.fileName) { - const pathParts = path.split(nodePath.sep); - opts.fileName = path.split(nodePath.sep)[pathParts.length - 1]; - } + if (!stats.isFile()) { + throw new Meteor.Error(400, `[FilesCollection] [addFile(${path})]: File does not exist`); + } - const {extension} = this._getExt(opts.fileName); + if (!helpers.isObject(opts)) { + opts = {}; + } + opts.path = path; - if (!helpers.isString(opts.type)) { - opts.type = this._getMimeType(opts); - } + if (!opts.fileName) { + const pathParts = path.split(nodePath.sep); + opts.fileName = path.split(nodePath.sep)[pathParts.length - 1]; + } - if (!helpers.isObject(opts.meta)) { - opts.meta = {}; - } + const { extension } = this._getExt(opts.fileName); - if (!helpers.isNumber(opts.size)) { - opts.size = stats.size; - } + if (!helpers.isString(opts.type)) { + opts.type = this._getMimeType(opts); + } - const result = this._dataToSchema({ - name: opts.fileName, - path, - meta: opts.meta, - type: opts.type, - size: opts.size, - userId: opts.userId, - extension, - _storagePath: path.replace(`${nodePath.sep}${opts.fileName}`, ''), - fileId: (opts.fileId && this.sanitize(opts.fileId, 20, 'a')) || null - }); + if (!helpers.isObject(opts.meta)) { + opts.meta = {}; + } + if (!helpers.isNumber(opts.size)) { + opts.size = stats.size; + } - this.collection.insert(result, (insertErr, _id) => { - if (insertErr) { - callback && callback(insertErr); - this._debug(`[FilesCollection] [addFile] [insert] Error: ${result.name} -> ${this.collectionName}`, insertErr); - } else { - const fileRef = this.collection.findOne(_id); - callback && callback(null, fileRef); - if (proceedAfterUpload === true) { - this.onAfterUpload && this.onAfterUpload.call(this, fileRef); - this.emit('afterUpload', fileRef); - } - this._debug(`[FilesCollection] [addFile]: ${result.name} -> ${this.collectionName}`); - } - }); - } else { - callback && callback(new Meteor.Error(400, `[FilesCollection] [addFile(${path})]: File does not exist`)); - } - })); - return this; + const result = this._dataToSchema({ + name: opts.fileName, + path, + meta: opts.meta, + type: opts.type, + size: opts.size, + userId: opts.userId, + extension, + _storagePath: path.replace(`${nodePath.sep}${opts.fileName}`, ''), + fileId: (opts.fileId && this.sanitize(opts.fileId, 20, 'a')) || null, + }); + + let _id; + try { + _id = await this.collection.insertAsync(result); + } catch (insertErr) { + this._debug(`[FilesCollection] [addFileAsync] [insertAsync] Error: ${result.name} -> ${this.collectionName}`, insertErr); + throw new Meteor.Error(insertErr.code, insertErr.message); + } + + const fileObj = await this.collection.findOneAsync(_id); + + if (proceedAfterUpload === true) { + this.onAfterUpload && await this.onAfterUpload.call(this, fileObj); + this.emit('afterUpload', fileObj); + } + this._debug(`[FilesCollection] [addFileAsync]: ${result.name} -> ${this.collectionName}`); + return fileObj; } /** * @locus Anywhere * @memberOf FilesCollection - * @name remove - * @param {String|Object} selector - Mongo-Style selector (http://docs.meteor.com/api/collections.html#selectors) - * @param {Function} callback - Callback with one `error` argument + * @name removeAsync + * @param {MeteorFilesSelector} [selector] - Mongo-Style selector (http://docs.meteor.com/api/collections.html#selectors) + * @throws {Meteor.Error} If cursor is empty * @summary Remove documents from the collection - * @returns {FilesCollection} Instance + * @returns {Promise} number of matched and removed files/records */ - remove(selector, callback) { - this._debug(`[FilesCollection] [remove(${JSON.stringify(selector)})]`); + async removeAsync(selector) { + this._debug(`[FilesCollection] [removeAsync(${JSON.stringify(selector)})]`); if (selector === void 0) { return 0; } - check(callback, Match.Optional(Function)); - const files = this.collection.find(selector); - if (files.count() > 0) { - files.forEach((file) => { - this.unlink(file); - }); - } else { - callback && callback(new Meteor.Error(404, 'Cursor is empty, no files is removed')); - return this; - } + const files = this.find(selector); + const count = await files.countDocuments(); - if (this.onAfterRemove) { - const docs = files.fetch(); - const self = this; - this.collection.remove(selector, function () { - callback && callback.apply(this, arguments); - self.onAfterRemove(docs); - }); - } else { - this.collection.remove(selector, (callback || noop)); + if (count > 0) { + if (this.onAfterRemove) { + const docs = await files.fetchAsync(); + await this.collection.removeAsync(selector); + + if (!(await this.onAfterRemove(docs))) { + let i = 0; + for (; i < docs.length; i++) { + await this.unlinkAsync(docs[i]); + } + } + } else { + await files.forEachAsync(async (file) => { + await this.unlinkAsync(file); + }); + await this.collection.removeAsync(selector); + } } - return this; + + return count; } /** @@ -1662,7 +1691,7 @@ class FilesCollection extends FilesCollectionCore { * @memberOf FilesCollection * @name denyClient * @see https://docs.meteor.com/api/collections.html#Mongo-Collection-deny - * @summary Shorthands for Mongo.Collection deny method + * @summary Shorthand for Mongo.Collection deny method * @returns {Mongo.Collection} Instance */ denyClient() { @@ -1679,7 +1708,7 @@ class FilesCollection extends FilesCollectionCore { * @memberOf FilesCollection * @name allowClient * @see https://docs.meteor.com/api/collections.html#Mongo-Collection-allow - * @summary Shorthands for Mongo.Collection allow method + * @summary Shorthand for Mongo.Collection allow method * @returns {Mongo.Collection} Instance */ allowClient() { @@ -1696,14 +1725,16 @@ class FilesCollection extends FilesCollectionCore { * @locus Server * @memberOf FilesCollection * @name unlink - * @param {Object} fileRef - fileObj - * @param {String} version - [Optional] file's version - * @param {Function} callback - [Optional] callback function + * @param {fileObj} fileRef - fileObj + * @param {string} [version] - [Optional] file's version + * @param {function} [callback] - [Optional] callback function * @summary Unlink files and it's versions from FS + * @deprecated since v3.0.0. use {@link FilesCollection#unlinkAsync} instead. * @returns {FilesCollection} Instance */ unlink(fileRef, version, callback) { this._debug(`[FilesCollection] [unlink(${fileRef._id}, ${version})]`); + Meteor.deprecate('FilesCollection#unlink() is deprecated! Use `unlinkAsync` instead'); if (version) { if (helpers.isObject(fileRef.versions) && helpers.isObject(fileRef.versions[version]) && fileRef.versions[version].path) { fs.unlink(fileRef.versions[version].path, (callback || noop)); @@ -1722,6 +1753,43 @@ class FilesCollection extends FilesCollectionCore { return this; } + /** + * @locus Server + * @memberOf FilesCollection + * @name unlinkAsync + * @param {fileObj} fileRef - fileObj + * @param {string} [version] - file's version + * @summary Remove files and all it's versions from FS, or only particular version of `version` param is passed + * @returns {Promise} Instance + */ + async unlinkAsync(fileRef, version) { + this._debug(`[FilesCollection] [unlinkAsync(${fileRef._id}, ${version})]`); + if (version) { + if (helpers.isObject(fileRef.versions) && helpers.isObject(fileRef.versions[version]) && fileRef.versions[version].path) { + await fs.promises.unlink(fileRef.versions[version].path); + } + } else { + if (helpers.isObject(fileRef.versions)) { + for(let vKey in fileRef.versions) { + if (fileRef.versions[vKey] && fileRef.versions[vKey].path) { + try { + await fs.promises.unlink(fileRef.versions[vKey].path); + } catch (unlinkError) { + this._debug('[FilesCollection] [unlinkAsync] [versions] Caught silent error', unlinkError); + } + } + } + } else { + try { + await fs.promises.unlink(fileRef.path); + } catch (unlinkError) { + this._debug('[FilesCollection] [unlinkAsync] Caught silent error', unlinkError); + } + } + } + return this; + } + /** * @locus Server * @memberOf FilesCollection @@ -1749,13 +1817,13 @@ class FilesCollection extends FilesCollectionCore { * @locus Server * @memberOf FilesCollection * @name download - * @param {Object} http - Server HTTP object - * @param {String} version - Requested file version - * @param {Object} fileRef - Requested file Object + * @param {ContextHTTP} http - Server HTTP object + * @param {string} version - Requested file version + * @param {fileObj} fileRef - Requested file Object * @summary Initiates the HTTP response - * @returns {undefined} + * @returns {Promise} */ - download(http, version = 'original', fileRef) { + async download(http, version = 'original', fileRef) { let vRef; this._debug(`[FilesCollection] [download(${http.request.originalUrl}, ${version})]`); @@ -1773,31 +1841,37 @@ class FilesCollection extends FilesCollectionCore { if (!vRef || !helpers.isObject(vRef)) { return this._404(http); } else if (fileRef) { - if (helpers.isFunction(this.downloadCallback) && !this.downloadCallback.call(Object.assign(http, this._getUser(http)), fileRef)) { + if (helpers.isFunction(this.downloadCallback) && !(await this.downloadCallback(Object.assign(http, this._getUser(http)), fileRef))) { return this._404(http); } - if (this.interceptDownload && helpers.isFunction(this.interceptDownload) && this.interceptDownload(http, fileRef, version) === true) { + if (this.interceptDownload && helpers.isFunction(this.interceptDownload) && (await this.interceptDownload(http, fileRef, version)) === true) { return void 0; } - fs.stat(vRef.path, (statErr, stats) => bound(() => { - let responseType; - if (statErr || !stats.isFile()) { + let stats; + + try { + stats = await fs.promises.stat(vRef.path); + } catch (statErr){ + if (statErr) { return this._404(http); } + } + if (!stats.isFile()) { + return this._404(http); + } + let responseType; - if ((stats.size !== vRef.size) && !this.integrityCheck) { - vRef.size = stats.size; - } + if (stats.size !== vRef.size && !this.integrityCheck) { + vRef.size = stats.size; + } - if ((stats.size !== vRef.size) && this.integrityCheck) { - responseType = '400'; - } + if (stats.size !== vRef.size && this.integrityCheck) { + responseType = '400'; + } - return this.serve(http, fileRef, vRef, version, null, (responseType || '200')); - })); - return void 0; + return this.serve(http, fileRef, vRef, version, null, responseType || '200'); } return this._404(http); } @@ -1806,18 +1880,18 @@ class FilesCollection extends FilesCollectionCore { * @locus Server * @memberOf FilesCollection * @name serve - * @param {Object} http - Server HTTP object - * @param {Object} fileRef - Requested file Object - * @param {Object} vRef - Requested file version Object - * @param {String} version - Requested file version + * @param {ContextHTTP} http - Server HTTP object + * @param {fileObj} fileRef - Requested file Object + * @param {Object} vRef - Requested file version Object + * @param {string} version - Requested file version * @param {stream.Readable|null} readableStream - Readable stream, which serves binary file data - * @param {String} responseType - Response code - * @param {Boolean} force200 - Force 200 response code over 206 + * @param {string} responseType - Response code + * @param {boolean} force200 - Force 200 response code over 206 * @summary Handle and reply to incoming request * @returns {undefined} */ serve(http, fileRef, vRef, version = 'original', readableStream = null, _responseType = '200', force200 = false) { - let partiral = false; + let partial = false; let reqRange = false; let dispositionType = ''; let start; @@ -1825,7 +1899,7 @@ class FilesCollection extends FilesCollectionCore { let take; let responseType = _responseType; - if (http.params.query.download && (http.params.query.download === 'true')) { + if (http.params?.query?.download && (http.params.query.download === 'true')) { dispositionType = 'attachment; '; } else { dispositionType = 'inline; '; @@ -1839,7 +1913,7 @@ class FilesCollection extends FilesCollectionCore { } if (http.request.headers.range && !force200) { - partiral = true; + partial = true; const array = http.request.headers.range.split(/bytes=([0-9]*)-([0-9]*)/); start = parseInt(array[1]); end = parseInt(array[2]); @@ -1853,7 +1927,7 @@ class FilesCollection extends FilesCollectionCore { take = vRef.size; } - if (partiral || (http.params.query.play && (http.params.query.play === 'true'))) { + if (partial || (http.params?.query?.play && (http.params.query.play === 'true'))) { reqRange = {start, end}; if (isNaN(start) && !isNaN(end)) { reqRange.start = end - take; @@ -1918,7 +1992,7 @@ class FilesCollection extends FilesCollectionCore { } else if (typeof stream.destroy === 'function') { stream.destroy('Got to close this stream', closeStreamCb); } - } catch (closeStreamError) { + } catch (_closeStreamError) { // Perhaps one of the method has thrown an error // or stream has been already ended/closed/exhausted } @@ -1958,7 +2032,7 @@ class FilesCollection extends FilesCollectionCore { switch (responseType) { case '400': this._debug(`[FilesCollection] [serve(${vRef.path}, ${version})] [400] Content-Length mismatch!`); - var text = 'Content-Length mismatch!'; + const text = 'Content-Length mismatch!'; if (!http.response.headersSent) { http.response.writeHead(400, { @@ -1988,7 +2062,7 @@ class FilesCollection extends FilesCollectionCore { if (!http.response.headersSent) { http.response.setHeader('Content-Range', `bytes ${reqRange.start}-${reqRange.end}/${vRef.size}`); } - respond(readableStream || fs.createReadStream(vRef.path, {start: reqRange.start, end: reqRange.end}), 206); + respond(readableStream || fs.createReadStream(vRef.path, { start: reqRange.start, end: reqRange.end }), 206); break; default: if (!http.response.headersSent) { @@ -2001,4 +2075,4 @@ class FilesCollection extends FilesCollectionCore { } } -export { FilesCollection, helpers }; +export { FilesCollection, WriteStream, helpers }; diff --git a/tests/core.test.js b/tests/core.test.js new file mode 100644 index 00000000..45eae1b6 --- /dev/null +++ b/tests/core.test.js @@ -0,0 +1,172 @@ +/* global describe, beforeEach, it */ +import { expect, assert } from 'chai'; +import FilesCollectionCore from '../core.js'; +import { FileCursor, FilesCursor } from '../cursor.js'; +import { FilesCollection } from '../server.js'; + +describe('FilesCollectionCore', function() { + let filesCollectionCore; + + beforeEach(function() { + filesCollectionCore = new FilesCollectionCore(); + }); + + describe('_getFileName', function() { + it('should return the correct file name', function() { + const fileData = { name: 'test.txt' }; + const result = filesCollectionCore._getFileName(fileData); + expect(result).to.equal('test.txt'); + }); + }); + + describe('_getExt', function() { + it('should return the correct file extension', function() { + const result = filesCollectionCore._getExt('test.txt'); + expect(result).to.deep.equal({ ext: 'txt', extension: 'txt', extensionWithDot: '.txt' }); + }); + }); + + describe('_updateFileTypes', function() { + it('should correctly classify file types', function() { + const data = { type: 'video/mp4' }; + filesCollectionCore._updateFileTypes(data); + expect(data.isVideo).to.be.true; + expect(data.isAudio).to.be.false; + expect(data.isImage).to.be.false; + expect(data.isText).to.be.false; + expect(data.isJSON).to.be.false; + expect(data.isPDF).to.be.false; + }); + }); + + describe('_dataToSchema', function() { + it('should create a schema object from the given data', function() { + const core = new FilesCollectionCore(); + const data = { + fileId: 'file1', + name: 'test', + extension: 'txt', + path: '/path/to/test', + meta: {}, + type: 'text/plain', + size: 100, + userId: 'user1', + _downloadRoute: '/download', + _collectionName: 'testCollection', + _storagePath: '/storage/path' + }; + + const expectedSchema = { + fileId: 'file1', + name: 'test', + extension: 'txt', + ext: 'txt', + extensionWithDot: '.txt', + path: '/path/to/test', + meta: {}, + type: 'text/plain', + mime: 'text/plain', + 'mime-type': 'text/plain', + size: 100, + userId: 'user1', + versions: { + original: { + path: '/path/to/test', + size: 100, + type: 'text/plain', + extension: 'txt', + }, + }, + _downloadRoute: '/download', + _collectionName: 'testCollection', + _id: 'file1', + _storagePath: '/storage/path', + }; + + const schema = core._dataToSchema(data); + assert.deepStrictEqual(schema, filesCollectionCore._dataToSchema(expectedSchema)); + }); + }); + + describe('#findOneAsync()', function() { + it('should find and return a FileCursor for matching document Object', async function() { + const core = new FilesCollectionCore(); + const selector = { name: 'test' }; + const options = {}; + + // Mock the collection.findOneAsync method to return a dummy document + core.collection = { + findOneAsync: async (sel, opts) => { + expect(sel).to.deep.equal(selector); + expect(opts).to.deep.equal(options); + return { name: 'test' }; + } + }; + + const doc = await core.findOneAsync(selector, options); + expect(doc).to.be.an.instanceof(FileCursor); + expect(doc).to.deep.equal(new FileCursor({ name: 'test' }, core)); + }); + + it('should return null if no document is found', async function() { + const core = new FilesCollectionCore(); + const selector = { name: 'nonexistent' }; + const options = {}; + + // Mock the collection.findOneAsync method to return null + core.collection = { + findOneAsync: async (sel, opts) => { + expect(sel).to.deep.equal(selector); + expect(opts).to.deep.equal(options); + return null; + } + }; + + const doc = await core.findOneAsync(selector, options); + expect(doc).to.be.null; + }); + }); + + describe('#find()', function() { + it('should find and return a FilesCursor for matching documents', function() { + // Testing with FilesCollectionCore instance only fails, due to lack of a + // underlying collection, so we use the FilesCollection class + + const collection = new FilesCollection({ collectionName: 'test' }); + const selector = { name: 'test' }; + const options = {}; + + const cursor = collection.find(selector, options); + expect(cursor).to.be.an.instanceof(FilesCursor); + }); + }); + + describe('#updateAsync()', function() { + it('should call the collection.updateAsync method with the given arguments', async function() { + const core = new FilesCollectionCore(); + const selector = { name: 'test' }; + const modifier = { $set: { name: 'newTest' } }; + + // Mock the collection.updateAsync method to check the arguments + core.collection = { + updateAsync: async (sel, mod) => { + expect(sel).to.deep.equal(selector); + expect(mod).to.deep.equal(modifier); + } + }; + + await core.updateAsync(selector, modifier); + }); + }); + + describe('#link()', function() { + it('should return a downloadable URL for the given file reference and version', function() { + const core = new FilesCollectionCore(); + const fileRef = { _id: 'test' }; + const version = 'original'; + + const url = core.link(fileRef, version); + expect(url).to.be.a('string'); + }); + }); +}); diff --git a/tests/cursor.test.js b/tests/cursor.test.js new file mode 100644 index 00000000..4c20e012 --- /dev/null +++ b/tests/cursor.test.js @@ -0,0 +1,343 @@ +/* global describe, beforeEach, after, before it, afterEach */ +import { expect } from 'chai'; +import sinon from 'sinon'; +import FilesCollectionCore from '../core.js'; +import { FileCursor, FilesCursor } from '../cursor.js'; +import { FilesCollection } from '../server.js'; +import fs from 'node:fs'; +import { MongoInternals } from 'meteor/mongo'; + + +describe('FileCursor', function() { + let collectionName = 'FileCursor'; + let filesCollection; + + before(function() { + filesCollection = new FilesCollection({ collectionName }); + }); + + after(async function() { + await MongoInternals.defaultRemoteCollectionDriver().mongo.db.collection(collectionName).drop(); + }); + + beforeEach(async function() { + await filesCollection.collection.rawCollection().deleteMany({}); + sinon.restore(); + }); + + afterEach(function() { + sinon.restore(); + }); + + describe('#removeAsync()', function() { + let sandbox; + beforeEach(function() { + sandbox = sinon.createSandbox(); + }); + afterEach(function() { + sandbox.restore(); + }); + it('should call the collection.removeAsync with the file ID and unlink method with the path', async function() { + const fileRef = { _id: 'test', path: '/tmp' }; + await filesCollection.collection.rawCollection().insertOne(fileRef); + + const removeAsync = sandbox.stub(filesCollection.collection, 'removeAsync').resolves('test'); + const unlink = sandbox.stub(fs.promises, 'unlink').resolves('test'); + + const cursor = new FileCursor(fileRef, filesCollection); + + await cursor.removeAsync(); + expect(removeAsync.calledWith(fileRef._id)).to.be.true; + expect(unlink.calledWith(fileRef.path)).to.be.true; + }); + + it('should throw an error if no file reference is provided', async function() { + const core = new FilesCollectionCore(); + const cursor = new FileCursor({}, core); + fs.writeFileSync('/tmp/test.txt', 'test'); + const opts = { _id: 'test' }; + await filesCollection.addFile('/tmp/test.txt', opts); + let error; + try { + await cursor.removeAsync(); + } catch (err) { + error = err; + } + expect(error).to.be.instanceOf(Meteor.Error); + expect(error.reason).to.equal('No such file'); + fs.unlinkSync('/tmp/test.txt'); + }); + }); + + describe('#link()', function() { + it('should call the collection.link method with the fileRef, version and uriBase', function() { + const fileRef = { _id: 'test' }; + const version = 'v1'; + const uriBase = 'https://test.com'; + + // Mock the collection.remove method to check the arguments + filesCollection.link = sinon.spy(); + + const cursor = new FileCursor(fileRef, filesCollection); + cursor.link(version, uriBase); + expect(filesCollection.link.calledWith(fileRef, version, uriBase)).to.be.true; + }); + + it('should return empty string if no file reference is provided', function() { + const cursor = new FileCursor({}, filesCollection); + const link = cursor.link(); + expect(link).to.equal(''); + }); + }); +}); + +describe('FilesCursor', function() { + let collectionName = 'FilesCursor'; + let filesCollection; + let sandbox; + + before(function() { + filesCollection = new FilesCollection({ collectionName }); + }); + + after(async function() { + await MongoInternals.defaultRemoteCollectionDriver().mongo.db.collection(collectionName).drop(); + }); + + beforeEach(async function() { + await filesCollection.collection.rawCollection().deleteMany({}); + sandbox = sinon.createSandbox(); + sinon.restore(); + }); + + afterEach(function() { + sinon.restore(); + sandbox.restore(); + }); + + describe('#get()', function() { + it('should return all matching documents as an array', async function() { + const documents = [{ _id: 'test1' }, { _id: 'test2' }]; + + await filesCollection.collection.rawCollection().insertMany(documents); + + const cursor = new FilesCursor({}, {}, filesCollection); + const fetched = await cursor.get(); + expect(fetched).to.deep.equal(documents); + }); + }); + + describe('#getAsync()', function() { + it('should return all matching documents as an array', async function() { + const documents = [{ _id: 'test1' }, { _id: 'test2' }]; + + await filesCollection.collection.rawCollection().insertMany(documents); + + const cursor = new FilesCursor({}, {}, filesCollection); + const fetched = await cursor.getAsync(); + expect(fetched).to.deep.equal(documents); + }); + }); + + + describe('#hasNextAsync()', function() { + it('should return true if there is a next item available on the cursor', async function() { + // Mock the collection.find method to return a cursor with a countDocuments method + sandbox.stub(filesCollection.collection, 'countDocuments').resolves(2); + + const cursor = new FilesCursor({}, {}, filesCollection); + const hasNext = await cursor.hasNextAsync(); + expect(hasNext).to.be.true; + }); + + it('should return false if there is no next item available on the cursor', async function() { + // Mock the collection.find method to return a cursor with a countDocuments method + sandbox.stub(filesCollection.collection, 'countDocuments').resolves(0); + + const cursor = new FilesCursor({}, {}, filesCollection); + const hasNext = await cursor.hasNextAsync(); + expect(hasNext).to.be.false; + }); + }); + + describe('#nextAsync()', function() { + it('should return the next item on the cursor', async function() { + const documents = [{ _id: 'test1' }, { _id: 'test2' }]; + await filesCollection.collection.rawCollection().insertMany(documents); + + const cursor = new FilesCursor({}, {}, filesCollection); + + let next = await cursor.nextAsync(); + expect(next).to.deep.equal(documents[0]); + + next = await cursor.nextAsync(); + expect(next).to.deep.equal(documents[1]); + }); + }); + + describe('#hasPrevious()', function() { + it('should return true if there is a previous item available on the cursor', function() { + const cursor = new FilesCursor({}, {}, filesCollection); + cursor._current = 1; + const hasPrevious = cursor.hasPrevious(); + expect(hasPrevious).to.be.true; + }); + + it('should return false if there is no previous item available on the cursor', function() { + const cursor = new FilesCursor({}, {}, filesCollection); + cursor._current = -1; + const hasPrevious = cursor.hasPrevious(); + expect(hasPrevious).to.be.false; + }); + }); + + describe('#previous()', function() { + it('should return the previous item on the cursor', function() { + const cursor = new FilesCursor({}, {}, filesCollection); + const documents = [{ _id: 'test1' }, { _id: 'test2' }]; + cursor._current = 1; + sandbox.stub(cursor.cursor, 'fetchAsync').resolves(documents); + cursor.previous(); + expect(cursor._current).to.equal(0); + }); + }); + + describe('#fetchAsync()', function() { + it('should return all matching documents as an array', async function() { + const cursor = new FilesCursor({}, {}, filesCollection); + const documents = [{ _id: 'test1' }, { _id: 'test2' }]; + sandbox.stub(cursor.cursor, 'fetchAsync').returns(Promise.resolve(documents)); + const result = await cursor.fetchAsync(); + expect(result).to.deep.equal(documents); + }); + + it('should return an empty array if no matching documents are found', async function() { + const cursor = new FilesCursor({}, {}, filesCollection); + sandbox.stub(cursor.cursor, 'fetchAsync').returns(Promise.resolve(null)); + const result = await cursor.fetchAsync(); + expect(result).to.deep.equal([]); + }); + }); + + describe('#lastAsync()', function() { + it('should return the last item on the cursor', async function() { + const cursor = new FilesCursor({}, {}, filesCollection); + const documents = [{ _id: 'test1' }, { _id: 'test2' }]; + await filesCollection.collection.rawCollection().insertMany(documents); + + const last = await cursor.lastAsync(); + expect(last).to.deep.equal(documents[1]); + }); + }); + + describe('#countAsync()', function() { + it('should return the number of documents that match a query', async function() { + const cursor = new FilesCursor({}, {}, filesCollection); + sandbox.stub(cursor.cursor, 'countAsync').returns(Promise.resolve(2)); + const count = await cursor.countAsync(); + expect(count).to.equal(2); + }); + }); + + describe('#countDocuments()', function() { + it('should return the number of documents that match a query', async function() { + const cursor = new FilesCursor({}, {}, filesCollection); + sandbox.stub(filesCollection.collection, 'countDocuments').returns(Promise.resolve(2)); + const count = await cursor.countDocuments(); + expect(count).to.equal(2); + }); + }); + + describe('#removeAsync()', function() { + it('should remove all matching documents', async function() { + const cursor = new FilesCursor({_id: 'test1'}, {}, filesCollection); + const documents = [{ _id: 'test1', path: '/tmp/random' }, { _id: 'test2', path: '/tmp/random' }]; + await filesCollection.collection.rawCollection().insertMany(documents); + + await cursor.removeAsync(); + + const result = await filesCollection.collection.rawCollection().find().toArray(); + expect(result).to.have.lengthOf(1); + }); + }); + + describe('#forEachAsync()', function() { + it('should call the callback for each matching document', async function() { + const cursor = new FilesCursor({}, {}, filesCollection); + const documents = [{ _id: 'test1' }, { _id: 'test2' }]; + await filesCollection.collection.rawCollection().insertMany(documents); + let count = 0; + await cursor.forEachAsync(() => { + count++; + }); + expect(count).to.equal(documents.length); + }); + }); + + describe('#each()', function() { + it('should return an array of FileCursor for each document', async function() { + const cursor = new FilesCursor({}, {}, filesCollection); + const documents = [{ _id: 'test1' }, { _id: 'test2' }]; + await filesCollection.collection.rawCollection().insertMany(documents); + + const result = await cursor.each(); + + expect(result).to.be.an('array'); + result.forEach((fileCursor, index) => { + expect(fileCursor).to.be.instanceOf(FileCursor); + expect(fileCursor._fileRef).to.deep.equal(documents[index]); + }); + }); + }); + + describe('#mapAsync()', function() { + it('should map callback over all matching documents', async function() { + const cursor = new FilesCursor({}, {}, filesCollection); + const documents = [{ _id: 'test1' }, { _id: 'test2' }]; + await filesCollection.collection.rawCollection().insertMany(documents); + + const result = await cursor.mapAsync((doc) => doc._id); + expect(result).to.deep.equal(documents.map((doc) => doc._id)); + }); + }); + + describe('#current()', function() { + it('should return the current item on the cursor', async function() { + const cursor = new FilesCursor({}, {}, filesCollection); + const documents = [{ _id: 'test1' }, { _id: 'test2' }]; + sandbox.stub(cursor, 'fetch').returns(documents); + const current = cursor.current(); + expect(current).to.deep.equal(documents[0]); + }); + }); + + describe('#currentAsync()', function() { + it('should return the current item on the cursor', async function() { + const cursor = new FilesCursor({}, {}, filesCollection); + const documents = [{ _id: 'test1' }, { _id: 'test2' }]; + sandbox.stub(cursor, 'fetchAsync').resolves(documents); + const current = await cursor.currentAsync(); + expect(current).to.deep.equal(documents[0]); + }); + }); + + describe('#observe()', function() { + it('should call observe on the cursor', function() { + const cursor = new FilesCursor({}, {}, filesCollection); + const observeStub = sandbox.stub(cursor.cursor, 'observe'); + const callbacks = {}; + cursor.observe(callbacks); + sinon.assert.calledWith(observeStub, callbacks); + }); + }); + + describe('#observeChanges()', function() { + it('should call observeChanges on the cursor', function() { + const cursor = new FilesCursor({}, {}, filesCollection); + const observeChangesStub = sandbox.stub(cursor.cursor, 'observeChanges'); + const callbacks = {}; + cursor.observeChanges(callbacks); + sinon.assert.calledWith(observeChangesStub, callbacks); + }); + }); +}); diff --git a/tests/server.js b/tests/server.js new file mode 100644 index 00000000..a3402b3c --- /dev/null +++ b/tests/server.js @@ -0,0 +1,4 @@ +import './helpers'; +import './core.test'; +import './cursor.test'; +import './server.test'; diff --git a/tests/server.test.js b/tests/server.test.js new file mode 100644 index 00000000..88a10914 --- /dev/null +++ b/tests/server.test.js @@ -0,0 +1,579 @@ +/* global describe, beforeEach, it, before, afterEach */ + +import { expect } from 'chai'; +import fs from 'node:fs'; +import sinon from 'sinon'; +import { FilesCollection } from '../server'; +import http from 'node:http'; +import { Readable } from 'node:stream'; + +describe('FilesCollection Constructor', function() { + describe('constructor', function() { + it('should create an instance of FilesCollection', async function() { + const filesCollection = new FilesCollection({ collectionName: 'test123'}); + expect(filesCollection instanceof FilesCollection).to.be.true; + }); + }); +}); + + +describe('FilesCollection', () => { + describe('#_prepareUpload', () => { + let filesCollection; + let opts; + let userId; + let transport; + let namingFunctionStub; + let onBeforeUploadStub; + let onInitiateUploadStub; + + before(() =>{ + filesCollection = new FilesCollection({ collectionName: 'testserver-prepareUpload', namingFunction: () => {}, onBeforeUpload: () => true, onInitiateUpload: () => {}}); + }); + + beforeEach(() => { + opts = { + file: { + name: 'testFile', + meta: {}, + }, + fileId: '123', + }; + userId = 'user1'; + transport = 'http'; + + // Stubbing the namingFunction method + namingFunctionStub = sinon.stub(filesCollection, 'namingFunction'); + namingFunctionStub.returns('newName'); + + // Stubbing the onBeforeUpload method + onBeforeUploadStub = sinon.stub(filesCollection, 'onBeforeUpload'); + onBeforeUploadStub.returns(true); + + // Stubbing the onInitiateUpload method + onInitiateUploadStub = sinon.stub(filesCollection, 'onInitiateUpload'); + }); + + afterEach(() => { + // Restore the stubbed methods after each test + sinon.restore(); + }); + + it('should prepare upload successfully', async () => { + const { result, opts: newOpts } = await filesCollection._prepareUpload(opts, userId, transport); + + expect(result).to.be.an('object'); + expect(newOpts).to.be.an('object'); + expect(namingFunctionStub.calledOnce).to.be.true; + expect(onBeforeUploadStub.calledOnce).to.be.true; + expect(onInitiateUploadStub.called).to.be.false; + }); + }); + + describe('#_finishUpload', () => { + let filesCollection; + let result; + let opts; + let chmodStub; + let insertAsyncStub; + let updateAsyncStub; + let onAfterUploadSpy; + + before(() => { + filesCollection = new FilesCollection({ collectionName: 'testserver-finishUpload'}); + }); + + beforeEach(() => { + result = { path: '~/data/path/to/file' }; + opts = { file: { name: 'testFile', meta: {} } }; + fs.mkdirSync(result.path, { recursive: true, flush: true, mode: 0o777 }); + fs.writeFileSync(result.path + '/' + opts.file.name, '', { mode: 0o777 }); + + // Stubbing the fs.chmod method + chmodStub = sinon.stub(fs.promises, 'chmod'); + chmodStub.callsFake(() => undefined); + + // Stubbing the collection.insert method + insertAsyncStub = sinon.stub(filesCollection.collection, 'insertAsync'); + + // Stubbing the _preCollection.update method + updateAsyncStub = sinon.stub(filesCollection._preCollection, 'updateAsync'); + + // Creating a spy for the onAfterUpload hook + onAfterUploadSpy = sinon.spy(); + filesCollection.onAfterUpload = onAfterUploadSpy; + }); + + afterEach(() => { + result = { path: '~/data/path/to/file' }; + opts = { file: { name: 'testFile', meta: {} } }; + // Restore the stubbed methods after each test + fs.unlinkSync(result.path + '/' + opts.file.name); + sinon.restore(); + }); + + it('should finish upload successfully', async () => { + await filesCollection._finishUpload(result, opts); + + expect(chmodStub.calledOnce).to.be.true; + expect(insertAsyncStub.calledOnce).to.be.true; + expect(updateAsyncStub.calledOnce).to.be.true; + }); + + it('should call callback with a single error argument if insert fails', async () => { + const error = new Meteor.Error(500, 'Insert failed'); + insertAsyncStub.throws(error); + + await filesCollection._finishUpload(result, opts); + + expect(chmodStub.calledOnce).to.be.true; + expect(insertAsyncStub.calledOnce).to.be.true; + expect(updateAsyncStub.called).to.be.false; + }); + + it('should call callback with a single error argument if update fails', async () => { + const error = new Meteor.Error(500, 'Update failed'); + updateAsyncStub.throws(error); + + await filesCollection._finishUpload(result, opts); + + expect(chmodStub.calledOnce).to.be.true; + expect(insertAsyncStub.calledOnce).to.be.true; + expect(updateAsyncStub.calledOnce).to.be.true; + }); + + it('should call onAfterUpload hook if it is a function', async () => { + await filesCollection._finishUpload(result, opts); + + expect(onAfterUploadSpy.calledOnce).to.be.true; + expect(onAfterUploadSpy.calledWith(result)).to.be.true; + }); + }); + + describe('#writeAsync()', function() { + let filesCollection; let collectionMock; + let fsPromiseStatStub; let fsPromisesMkdirStub; let fsPromiseWriteFileStub; + + before(function() { + filesCollection = new FilesCollection({ collectionName: 'testserver-writeAsync', storagePath: '~/data/write-async'}); + }); + + beforeEach(function() { + fsPromiseStatStub = sinon.stub(fs.promises, 'stat'); + fsPromisesMkdirStub = sinon.stub(fs.promises, 'mkdir'); + fsPromiseWriteFileStub = sinon.stub(fs.promises, 'writeFile'); + collectionMock = sinon.mock(filesCollection.collection); + }); + + afterEach(async function() { + fsPromiseStatStub.restore(); + fsPromisesMkdirStub.restore(); + fsPromiseWriteFileStub.restore(); + collectionMock.restore(); + await filesCollection.collection.removeAsync({}); + }); + + it('should write buffer to FS and add to FilesCollection Collection', async function() { + const buffer = Buffer.from('test data'); + const opts = { name: 'test.txt', type: 'text/plain', meta: {}, userId: 'user1', fileId: 'file1' }; + + fsPromiseStatStub.resolves({ isFile: () => true }); + fsPromiseWriteFileStub.resolves(); + + collectionMock.expects('insertAsync').resolves('file1'); + collectionMock.expects('findOneAsync').resolves({ _id: 'file1' }); + + const result = await filesCollection.writeAsync(buffer, opts, true); + + collectionMock.verify(); + expect(result).to.be.an('object'); + expect(result).to.have.property('_id', 'file1'); + }); + + it('should make all directories if not present, then write buffer to FS and then add to FilesCollection Collection', async function() { + const buffer = Buffer.from('test data'); + const opts = { name: 'test.txt', type: 'text/plain', meta: {}, userId: 'user1', fileId: 'file1' }; + + fsPromiseStatStub.resolves({ isFile: () => true }); + fsPromiseWriteFileStub.resolves(); + + collectionMock.expects('insertAsync').resolves('file1'); + collectionMock.expects('findOneAsync').resolves({ _id: 'file1' }); + + const result = await filesCollection.writeAsync(buffer, opts, true); + + collectionMock.verify(); + expect(result).to.be.an('object'); + expect(result).to.have.property('_id', 'file1'); + }); + + it('should throw error if file could not be written to FS', function(done) { + const buffer = Buffer.from('test data'); + const opts = { name: 'test.txt', type: 'text/plain', meta: {}, userId: 'user1', fileId: 'file1' }; + + fsPromiseStatStub.resolves({ isFile: () => false }); + fsPromiseWriteFileStub.rejects(); + + filesCollection.writeAsync(buffer, opts, true).catch((e) => { + expect(e).to.be.instanceOf(Error); + done(); + }); + }); + + it('should throw an error if file could not be added to FilesCollection Collection', function (done) { + const buffer = Buffer.from('test data'); + const opts = { name: 'test.txt', type: 'text/plain', meta: {}, userId: 'user1', fileId: 'file1' }; + + fsPromiseStatStub.resolves({ isFile: () => true }); + collectionMock.expects('insertAsync').rejects(new Error('Test Error')); + + filesCollection.writeAsync(buffer, opts, true).catch((e) => { + expect(e).to.be.instanceOf(Error); + done(); + }); + }); + + it('actually writes the file to the FS and to the db (no mocking)', async function() { + const testData = 'test data'; + const buffer = Buffer.from(testData); + + const opts = { name: 'test.txt', type: 'text/plain', meta: {}, userId: 'user1', fileId: 'file1' }; + + sinon.stub(filesCollection, 'storagePath').returns('~/data'); + fsPromiseStatStub.restore(); + fsPromiseWriteFileStub.restore(); + fsPromisesMkdirStub.restore(); + + const result = await filesCollection.writeAsync(buffer, opts, true); + + const file = await filesCollection.collection.findOneAsync({ _id: 'file1' }); + expect(file).to.be.an('object'); + expect(file).to.have.property('_id', 'file1'); + expect(file).to.have.property('name', 'test.txt'); + expect(file).to.have.property('size', 9); + expect(file).to.have.property('type', 'text/plain'); + expect(file).to.have.property('extension', 'txt'); + expect(file).to.have.property('path'); + expect(file).to.have.property('versions'); + expect(file.versions).to.have.property('original'); + expect(file.versions.original).to.have.property('path'); + expect(file.versions.original).to.have.property('size', 9); + expect(file.versions.original).to.have.property('type', 'text/plain'); + expect(file.versions.original).to.have.property('extension', 'txt'); + + const fileOnDisk = fs.readFileSync(file.versions.original.path, 'utf8'); + expect(fileOnDisk).to.deep.equal(testData); + + expect(result).to.be.an('object'); + expect(result).to.have.property('_id', 'file1'); + + // Cleanup∏ + await fs.promises.unlink(file.versions.original.path); + }); + }); + + describe('#loadAsync()', function() { + let filesCollection; + const testdata = 'test data'; + let port; + + before(function() { + filesCollection = new FilesCollection({ collectionName: 'testserver-loadAsync', storagePath: '~/data/load-async'}); + + const server = http.createServer((_req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end(testdata); + }); + + server.listen(undefined, '127.0.0.1', () => { + port = server.address().port; + }); + }); + + beforeEach(function() { + }); + + afterEach(async function() { + await filesCollection.collection.removeAsync({}); + }); + + it('should download file over HTTP, write stream to FS, and add to FilesCollection Collection', async function() { + const url = 'http://127.0.0.1:' + port; + const opts = { name: 'test.txt', type: 'text/plain', meta: {}, userId: 'user1', fileId: 'file1', timeout: 360000 }; + + const result = await filesCollection.loadAsync(url, opts, true); + + expect(result).to.be.an('object'); + + const file = await filesCollection.collection.findOneAsync({ _id: result._id }); + expect(file).to.be.an('object'); + }); + }); + + describe('#addFile', () => { + let filesCollection; + let path; + let opts; + let proceedAfterUpload; + + before(() => { + filesCollection = new FilesCollection({ collectionName: 'testserver', onAfterUpload: () => {}}); + path = '~/data/meteor-test-file.txt'; + fs.writeFileSync(path, 'test'); + opts = { type: 'text/plain'}; + proceedAfterUpload = false; + }); + + afterEach(() => { + // Restore the stubbed methods after each test + sinon.restore(); + }); + + it('should add a file successfully', async () => { + const result = await filesCollection.addFile(path, opts, proceedAfterUpload); + + // Check if the result is correct + expect(result).to.be.an('object'); + expect(result).to.have.property('_id'); + expect(result).to.have.property('name', 'meteor-test-file.txt'); + expect(result).to.have.property('size', 4); + expect(result).to.have.property('type', 'text/plain'); + expect(result).to.have.property('extension', 'txt'); + expect(result).to.have.property('extensionWithDot', '.txt'); + expect(result).to.have.property('path', path); + + // Check if the file exists in the database + const file = await filesCollection.collection.findOneAsync({ _id: result._id }); + expect(file).to.be.an('object'); + expect(file).to.have.property('_id'); + expect(file).to.have.property('name', 'meteor-test-file.txt'); + expect(file).to.have.property('size', 4); + expect(file).to.have.property('type', 'text/plain'); + expect(file).to.have.property('extension', 'txt'); + expect(file).to.have.property('extensionWithDot', '.txt'); + expect(file).to.have.property('path', path); + }); + + it('should call onAfterUpload hook if flag is true', async () => { + // Stub the `onAfterUpload` method + sinon.stub(filesCollection, 'onAfterUpload'); + + await filesCollection.addFile(path, opts, true); + + expect(filesCollection.onAfterUpload.calledWith()).to.be.true; + }); + + it('should not call onAfterUpload hook if flag is false', async () => { + // Stub the `onAfterUpload` method + sinon.stub(filesCollection, 'onAfterUpload'); + + await filesCollection.addFile(path, opts, false); + + expect(filesCollection.onAfterUpload.calledWith()).to.be.false; + }); + + it('should throw an error if file does not exist', async () => { + const nonExistingPath = '/tmp/meteor-test-file-non-existing.txt'; + try { + await filesCollection.addFile(nonExistingPath, opts, proceedAfterUpload); + } catch (e) { + expect(e).to.be.instanceOf(Meteor.Error); + expect(e.error).to.equal(400); + } + } + ); + + it('should throw an error if file is not readable', async () => { + const nonReadablePath = '/tmp/meteor-test-file-non-readable.txt'; + fs.writeFileSync(nonReadablePath, 'test'); + fs.chmodSync(nonReadablePath, 0o200); + try { + await filesCollection.addFile(nonReadablePath, opts, proceedAfterUpload); + } catch (e) { + expect(e).to.be.instanceOf(Meteor.Error); + expect(e.error).to.equal(400); + } + }); + + it('should throw an error, if path is not a file', async () => { + const nonFile = '/tmp'; + try { + await filesCollection.addFile(nonFile, opts, proceedAfterUpload); + } catch (e) { + expect(e).to.be.instanceOf(Meteor.Error); + expect(e.error).to.equal(400); + } + }); + + it('should throw an error, if file is added to a public collection', async () => { + const publicFilesCollection = new FilesCollection({ collectionName: 'testserver-pub', public: true, storagePath: '/tmp', downloadRoute: '/public' }); + try { + await publicFilesCollection.addFile(path, opts, proceedAfterUpload); + } catch (e) { + expect(e).to.be.instanceOf(Meteor.Error); + expect(e.error).to.equal(403); + } + }); + }); + + describe('#download', () => { + let filesCollection; + let httpObj; + let version; + let fileRef; + let statStub; + let _404Stub; + let serveStub; + + before(() => { + filesCollection = new FilesCollection({collectionName: 'testserver-downloadAsync', downloadCallbackAsync: async () => {return true;}}); + }); + + beforeEach(() => { + httpObj = { request: { originalUrl: '/path/to/file', headers: { 'x-mtok': 'token'} }, response: { writeHead: () => {}, end: () => {}} }; + + version = 'original'; + fileRef = { + versions: { + original: { + path: '/path/to/file', + size: 100, + }, + }, + }; + + // Stubbing the fs.promises.stat method + statStub = sinon.stub(fs.promises, 'stat'); + statStub.resolves({ isFile: () => true, size: 100 }); + + // Stubbing the _404 method + _404Stub = sinon.stub(filesCollection, '_404'); + + // Stubbing the serve method + serveStub = sinon.stub(filesCollection, 'serve'); + }); + + afterEach(() => { + // Restore the stubbed methods after each test + sinon.restore(); + }); + + it('should download a file successfully', async () => { + await filesCollection.download(httpObj, version, fileRef); + + expect(statStub.calledOnce).to.be.true; + expect(_404Stub.called).to.be.false; + expect(serveStub.calledOnce).to.be.true; + }); + + it('should return 404 if file does not exist', async () => { + statStub.resolves({ isFile: () => false }); + await filesCollection.download(httpObj, version, fileRef); + + expect(statStub.calledOnce).to.be.true; + expect(_404Stub.calledOnce).to.be.true; + expect(serveStub.called).to.be.false; + }); + + it('should call downloadCallbackAsync if it is a function', async () => { + const downloadCallback = sinon.stub().returns(new Promise((resolve) => resolve(true))); + filesCollection.downloadCallback = downloadCallback; + await filesCollection.download(httpObj, version, fileRef); + + expect(statStub.calledOnce).to.be.true; + expect(_404Stub.called).to.be.false; + expect(serveStub.calledOnce).to.be.true; + expect(downloadCallback.calledOnce).to.be.true; + }); + + it('should not call downloadCallback if it is not a function', async () => { + filesCollection.downloadCallbackAsync = null; + await filesCollection.download(httpObj, version, fileRef); + + expect(statStub.calledOnce).to.be.true; + expect(_404Stub.called).to.be.false; + expect(serveStub.calledOnce).to.be.true; + }); + + it('should return 404 if downloadCallbackAsync returns false', async () => { + const downloadCallback = sinon.stub().returns(new Promise((resolve) => resolve(false))); + filesCollection.downloadCallback = downloadCallback; + await filesCollection.download(httpObj, version, fileRef); + + expect(statStub.calledOnce).to.be.false; + expect(_404Stub.calledOnce).to.be.true; + expect(serveStub.called).to.be.false; + + filesCollection.downloadCallback = null; + }); + + it('should call interceptDownload if it is a function, that resolves true', async () => { + const interceptDownload = sinon.stub().resolves(true); + filesCollection.interceptDownload = interceptDownload; + await filesCollection.download(httpObj, version, fileRef); + + expect(statStub.calledOnce).to.be.false; + expect(_404Stub.called).to.be.false; + expect(serveStub.calledOnce).to.be.false; + expect(interceptDownload.calledOnce).to.be.true; + + filesCollection.interceptDownload = null; + }); + + it('should proceed if interceptDownload is a function, that returns false', async () => { + const interceptDownload = sinon.stub().resolves(false); + filesCollection.interceptDownload = interceptDownload; + await filesCollection.download(httpObj, version, fileRef); + + expect(statStub.calledOnce).to.be.true; + expect(_404Stub.called).to.be.false; + expect(serveStub.calledOnce).to.be.true; + expect(interceptDownload.calledOnce).to.be.true; + + filesCollection.interceptDownload = null; + }); + }); + + describe('#serve', function() { + let server; + let filesCollection; + let port; + + before(function() { + filesCollection = new FilesCollection({ collectionName: 'testserver-serve' }); + }); + + beforeEach(async function() { + const path = '/tmp/testfile.txt'; + const content = 'testfile'; + server = http.createServer((req, res) => { + const readableStream = Readable.from(content); + const fileRef = { name: 'testfile.txt' }; + const vRef = { name: 'testfile.txt', size: Buffer.byteLength(content), path }; + + filesCollection.serve({request: req, response: res}, fileRef, vRef, 'original', readableStream); + }); + server.listen(0); + + port = server.address().port; + }); + + afterEach(function() { + server.close(); + }); + + it('should serve a fileRef object', function(done) { + http.get('http://localhost:' + port, (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + expect(data).to.equal('testfile'); + done(); + }); + }); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..0ceb2f8a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "preserveSymlinks": true, + "paths": { + "meteor/*": [ + "node_modules/@types/meteor/*", + ".meteor/local/types/packages.d.ts" + ] + } + } +} \ No newline at end of file diff --git a/upload.js b/upload.js index 6ecee3d5..797d0d19 100644 --- a/upload.js +++ b/upload.js @@ -10,13 +10,17 @@ import { fixJSONParse, fixJSONStringify, helpers } from './lib.js'; const _rootUrl = (window.__meteor_runtime_config__.MOBILE_ROOT_URL || window.__meteor_runtime_config__.ROOT_URL).replace(/\/+$/, ''); const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); -/* +/** * @locus Client * @name FileUpload * @class FileUpload * @summary Internal Class, instance of this class is returned from `.insert()` method */ export class FileUpload extends EventEmitter { + /** + * Constructs a FileUpload instance. + * @param {FileUploadConfig} config - The configuration for the file upload. + */ constructor(config) { super(); this.config = config; @@ -34,15 +38,25 @@ export class FileUpload extends EventEmitter { this.continueFunc = () => {}; this.estimateTime = new ReactiveVar(1000); this.estimateSpeed = new ReactiveVar(0); + this.remainingTime = new ReactiveVar('00:00:00'); this.estimateTimer = Meteor.setInterval(() => { if (this.state.get() === 'active') { const _currentTime = this.estimateTime.get(); if (_currentTime > 1000) { - this.estimateTime.set(_currentTime - 1000); + const ms = _currentTime - 1000; + this.estimateTime.set(ms); + this.remainingTime.set(this._formatDuration(ms)); + } else { + this.remainingTime.set('00:00:00'); } } }, 1000); } + + /** + * Pauses the file upload. + * @returns {void} + */ pause() { this.config._debug('[FilesCollection] [insert] [.pause()]'); if (!this.onPause.get()) { @@ -51,6 +65,11 @@ export class FileUpload extends EventEmitter { this.emit('pause', this.file); } } + + /** + * Resumes the file upload if paused. + * @returns {void} + */ continue() { this.config._debug('[FilesCollection] [insert] [.continue()]'); if (this.onPause.get() && Meteor.status().connected) { @@ -60,6 +79,11 @@ export class FileUpload extends EventEmitter { this.continueFunc(); } } + + /** + * Toggles the file upload state between paused and active. + * @returns {void} + */ toggle() { this.config._debug('[FilesCollection] [insert] [.toggle()]'); if (this.onPause.get()) { @@ -68,33 +92,53 @@ export class FileUpload extends EventEmitter { this.pause(); } } - abort() { + + /** + * Aborts the file upload. + * @returns {Promise} A promise that resolves when the abort process is complete. + */ + async abort() { this.config._debug('[FilesCollection] [insert] [.abort()]'); this.pause(); this.config._onEnd(); this.state.set('aborted'); - this.config.onAbort && this.config.onAbort.call(this, this.file); + if (this.config.onAbort) { + this.config.onAbort.call(this, this.file); + } this.emit('abort', this.file); if (this.config.debug) { + // eslint-disable-next-line no-console console.timeEnd(`insert ${this.config.fileData.name}`); } - this.config.ddp.call(this.config._Abort, this.config.fileId); + await this.config.ddp.callAsync(this.config._Abort, this.config.fileId); + } + + _formatDuration(ms) { + const hh = `${Math.floor(ms / (1000 * 60 * 60))}`.padStart(2, '0'); + const mm = `${Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60))}`.padStart(2, '0'); + const ss = `${Math.floor((ms % (1000 * 60)) / 1000)}`.padStart(2, '0'); + return `${hh}:${mm}:${ss}`; } } -/* +/** * @locus Client * @name UploadInstance * @class UploadInstance * @summary Internal Class, used for file upload */ export class UploadInstance extends EventEmitter { + /** + * Constructs an UploadInstance. + * @param {UploadInstanceConfig} config - The upload instance configuration. + * @param {FilesCollection} collection - The FilesCollection instance. + */ constructor(config, collection) { super(); this.config = config; this.collection = collection; - this.collection._debug('[FilesCollection] [insert()]'); + this.collection._debug('[FilesCollection] [new UploadInstance()]'); if (!this.config.ddp) { this.config.ddp = this.collection.ddp; @@ -122,6 +166,7 @@ export class UploadInstance extends EventEmitter { this.config.allowWebWorkers = true; } + /* eslint-disable new-cap */ check(this.config, { ddp: Match.Any, file: Match.Any, @@ -136,10 +181,12 @@ export class UploadInstance extends EventEmitter { transport: Match.OneOf('http', 'ddp'), chunkSize: Match.OneOf('dynamic', Number), onUploaded: Match.Optional(Function), + disableUpload: Match.Optional(Boolean), onProgress: Match.Optional(Function), onBeforeUpload: Match.Optional(Function), allowWebWorkers: Boolean }); + /* eslint-enable new-cap */ this.config.isEnded = false; @@ -175,162 +222,207 @@ export class UploadInstance extends EventEmitter { } } - if (this.config.file) { - if (!this.config.isBase64) { - try { - if (!this.config.file.name || !this.config.file.size) { - throw new Meteor.Error(500, 'Not a File!'); - } - } catch (e) { - throw new Meteor.Error(500, '[FilesCollection] [insert] Insert method accepts File, not a FileList. You need to provide a real File. File must have `.name` property, and its size must be larger than zero.'); + if (!this.config.file) { + throw new Meteor.Error(500, '[FilesCollection] [insert] Have you forget to pass a File itself?'); + } + + if (!this.config.isBase64) { + try { + if (!this.config.file.name || isNaN(this.config.file.size)) { + throw new Meteor.Error(500, 'Not a File!'); } + } catch (err) { + throw new Meteor.Error(500, '[FilesCollection] [insert] Insert method accepts File, not a FileList. You need to provide a real File. File must have `.name` property, and its size must be larger than zero.', err); + } - this.fileData = { - size: this.config.file.size, - type: this.config.type || this.config.file.type, - name: this.config.fileName || this.config.file.name, - meta: this.config.meta - }; + this.fileData = { + size: this.config.file.size, + type: this.config.type || this.config.file.type, + name: this.config.fileName || this.config.file.name, + meta: this.config.meta + }; + } + + if (this.collection.debug) { + // eslint-disable-next-line no-console + console.time(`insert ${this.fileData.name}`); + // eslint-disable-next-line no-console + console.time(`loadFile ${this.fileData.name}`); + } + + if (this.collection._supportWebWorker && this.config.allowWebWorkers) { + try { + this.worker = new Worker(this.collection._webWorkerUrl); + } catch (wwError) { + this.worker = false; + this.collection._debug('[FilesCollection] [insert] [create WebWorker]: Can\'t create WebWorker, fallback to MainThread', wwError); } + } else { + this.worker = null; + } + + this.disonnectRe = /network|connection|fetch/i; + this.fetchControllers = {}; + this.fetchTimeouts = {}; + this.config._debug = this.collection._debug; + this.config.debug = this.collection.debug; + this.transferTime = 0; + this.trackerCompConnection = null; + this.trackerCompPause = null; + this.sentChunks = -1; + this.fileLength = 1; + this.startTime = {}; + this.EOFsent = false; + this.fileId = this.config.fileId || Random.id(); + this.pipes = []; + + this.fileData = Object.assign(this.fileData, this.collection._getExt(this.fileData.name), { mime: this.collection._getMimeType(this.fileData) }); + this.fileData['mime-type'] = this.fileData.mime; + + this.result = new FileUpload(Object.assign({}, this.config, { + fileData: this.fileData, + fileId: this.fileId, + _Abort: this.collection._methodNames._Abort + })); - if (this.collection.debug) { - console.time(`insert ${this.fileData.name}`); - console.time(`loadFile ${this.fileData.name}`); + this._handleNetworkError = (error) => { + this.collection._debug('[FilesCollection] [_handleNetworkError] Error:', error); + if (!Meteor.status().connected || this.disonnectRe.test(`${error}`)) { + this.result.pause(); + } else if (error?.error === 503) { + this._upload(); + } else if (this.result.state.get() !== 'aborted' && this.result.state.get() !== 'paused') { + this.emit('error', error); } + }; - if (this.collection._supportWebWorker && this.config.allowWebWorkers) { - try { - this.worker = new Worker(this.collection._webWorkerUrl); - } catch (wwError) { - this.worker = false; - this.collection._debug('[FilesCollection] [insert] [create WebWorker]: Can\'t create WebWorker, fallback to MainThread', wwError); - } - } else { - this.worker = null; - } - - this.fetchControllers = {}; - this.config._debug = this.collection._debug; - this.config.debug = this.collection.debug; - this.transferTime = 0; - this.trackerComp = null; - this.sentChunks = 0; - this.fileLength = 1; - this.startTime = {}; - this.EOFsent = false; - this.fileId = this.config.fileId || Random.id(); - this.FSName = this.collection.namingFunction ? this.collection.namingFunction(this.fileData) : this.fileId; - this.pipes = []; - - this.fileData = Object.assign(this.fileData, this.collection._getExt(this.fileData.name), {mime: this.collection._getMimeType(this.fileData)}); - this.fileData['mime-type'] = this.fileData.mime; - - this.result = new FileUpload(Object.assign({}, this.config, { - fileData: this.fileData, - fileId: this.fileId, - _Abort: this.collection._methodNames._Abort - })); - - this.beforeunload = (e) => { - const message = helpers.isFunction(this.collection.onbeforeunloadMessage) ? this.collection.onbeforeunloadMessage.call(this.result, this.fileData) : this.collection.onbeforeunloadMessage; - - if (e) { - e.returnValue = message; - } - return message; - }; + this.beforeunload = (e) => { + const message = helpers.isFunction(this.collection.onbeforeunloadMessage) ? this.collection.onbeforeunloadMessage.call(this.result, this.fileData) : this.collection.onbeforeunloadMessage; - this.result.config.beforeunload = this.beforeunload; - window.addEventListener('beforeunload', this.beforeunload, false); + if (e) { + e.returnValue = message; + } + return message; + }; - this.result.config._onEnd = () => this.emit('_onEnd'); + this.result.config.beforeunload = this.beforeunload; + window.addEventListener('beforeunload', this.beforeunload, false); - this._setProgress = (progress) => { - if (this.result.progress.get() >= 100) { - return; - } + this.result.config._onEnd = () => this.emit('_onEnd'); - let sentBytes = this.config.chunkSize * this.sentChunks; - if (sentBytes > this.fileData.size) { - // this case often occurs, when the last chunk - // is smaller than chunkSize, so we limit to fileSize - sentBytes = this.fileData.size; - } + this._setProgress = (progress) => { + if (this.result.progress.get() >= 100) { + return; + } - this.result.progress.set(progress); - this.config.onProgress && this.config.onProgress.call(this.result, progress, this.fileData); - this.result.emit('progress', progress, this.fileData, { chunksSent: this.sentChunks, chunksLength: this.fileLength, bytesSent: sentBytes }); - }; + let sentBytes = this.config.chunkSize * this.sentChunks; + if (sentBytes > this.fileData.size) { + // this case often occurs, when the last chunk + // is smaller than chunkSize, so we limit to fileSize + sentBytes = this.fileData.size; + } - this.addListener('end', this.end); - this.addListener('start', this.start); - this.addListener('upload', this.upload); - this.addListener('sendEOF', this.sendEOF); - this.addListener('prepare', this.prepare); - this.addListener('sendChunk', this.sendChunk); - this.addListener('proceedChunk', this.proceedChunk); - - this.addListener('calculateStats', helpers.throttle(() => { - if (this.result.progress.get() >= 100) { - return; - } + this.result.progress.set(progress); + this.config.onProgress && this.config.onProgress.call(this.result, progress, this.fileData); + this.result.emit('progress', progress, this.fileData, { chunksSent: this.sentChunks, chunksLength: this.fileLength, bytesSent: sentBytes }); + }; - const _t = (this.transferTime / (this.sentChunks || 1)); - this.result.estimateTime.set((_t * (this.fileLength - this.sentChunks))); - this.result.estimateSpeed.set((this.config.chunkSize / (_t / 1000))); + this.addListener('end', this._end); + this.addListener('error', this._error); + this.addListener('start', this.start); - const progress = Math.round((this.sentChunks / this.fileLength) * 100); - this._setProgress(progress); - }, 250)); + this.addListener('calculateStats', helpers.throttle(() => { + if (this.result.progress.get() >= 100) { + return; + } - this.addListener('_onEnd', () => { - if (this.config.isEnded) { - return; - } - this.config.isEnded = true; - for (const uid in this.fetchControllers) { - if (this.fetchControllers[uid]) { - this.fetchControllers[uid].abort(); - delete this.fetchControllers[uid]; - } - } - if (this.result.estimateTimer) { - Meteor.clearInterval(this.result.estimateTimer); - } - if (this.worker) { - this.worker.terminate(); - } - if (this.trackerComp) { - this.trackerComp.stop(); + const t = (this.transferTime / (this.sentChunks || 1)); + const ms = (t * (this.fileLength - this.sentChunks)); + this.result.estimateTime.set(ms); + this.result.remainingTime.set(this.result._formatDuration(ms)); + this.result.estimateSpeed.set((this.config.chunkSize / (t / 1000))); + + const progress = Math.round((this.sentChunks / this.fileLength) * 100); + this._setProgress(progress); + }, 250)); + + this.addListener('_onEnd', () => { + if (this.config.isEnded) { + return; + } + this.config.isEnded = true; + this.result.remainingTime.set('00:00:00'); + // eslint-disable-next-line guard-for-in + for (const uid in this.fetchControllers) { + if (this.fetchControllers[uid]) { + this.fetchControllers[uid].abort(new Meteor.Error(200, 'Upload has finished')); + delete this.fetchControllers[uid]; } - if (this.beforeunload) { - window.removeEventListener('beforeunload', this.beforeunload, false); + if (this.fetchTimeouts[uid]) { + clearTimeout(this.fetchTimeouts[uid]); + delete this.fetchTimeouts[uid]; } - return; - }); - } else { - throw new Meteor.Error(500, '[FilesCollection] [insert] Have you forget to pass a File itself?'); - } + } + if (this.result.estimateTimer) { + Meteor.clearInterval(this.result.estimateTimer); + } + if (this.worker) { + this.worker.terminate(); + } + if (this.trackerCompConnection) { + this.trackerCompConnection.stop(); + } + if (this.trackerCompPause) { + this.trackerCompPause.stop(); + } + if (this.beforeunload) { + window.removeEventListener('beforeunload', this.beforeunload, false); + } + return; + }); + } + + /** + * Handles an error during the upload process. + * @param {Meteor.Error} error - The error that occurred. + * @param {*} data - Additional error data. + * @returns {UploadInstance} Returns the current UploadInstance. + */ + _error(error, data) { + this.collection._debug('[FilesCollection] [UploadInstance] [error]', this.fileId, { error, data }); + this._end(error, data); + return this; } - end(error, data) { - this.collection._debug('[FilesCollection] [UploadInstance] [end]', this.fileData.name); + /** + * Finalizes the upload process. + * @param {Meteor.Error} [error] - An optional error if the upload failed. + * @param {*} [data] - Additional data associated with the upload. + * @returns {FileUpload} Returns the FileUpload result. + */ + _end(error, data) { + this.collection._debug('[FilesCollection] [UploadInstance] [end]', this.fileId, { error, data }); if (this.collection.debug) { + // eslint-disable-next-line no-console console.timeEnd(`insert ${this.fileData.name}`); } this._setProgress(100); this.emit('_onEnd'); - this.result.emit('uploaded', error, data); - this.config.onUploaded && this.config.onUploaded.call(this.result, error, data); if (error) { this.collection._debug('[FilesCollection] [insert] [end] Error:', error); this.result.abort(); this.result.state.set('aborted'); this.result.emit('error', error, this.fileData); - this.config.onError && this.config.onError.call(this.result, error, this.fileData); + if (this.config.onError) { + this.config.onError.call(this.result, error, this.fileData); + } } else { + this.result.emit('uploaded', error, data); + if (this.config.onUploaded) { + this.config.onUploaded.call(this.result, error, data); + } this.result.state.set('completed'); this.collection.emit('afterUpload', data); } @@ -338,7 +430,16 @@ export class UploadInstance extends EventEmitter { return this.result; } - sendChunk(evt) { + /** + * Sends a file chunk to the server. + * @param {Object} evt - The event containing chunk data. + * @param {Object} evt.data - Data related to the chunk. + * @param {string} evt.data.bin - The binary data of the chunk. + * @param {number} evt.data.chunkId - The chunk identifier. + * @returns {Promise} + */ + async _sendChunk(evt) { + this.collection._debug('[FilesCollection] [UploadInstance] [sendChunk]', this.fileId, evt.data.chunkId); const opts = { fileId: this.fileId, binData: evt.data.bin, @@ -356,7 +457,7 @@ export class UploadInstance extends EventEmitter { } } - this.emit('data', evt.data.bin); + this.result.emit('data', evt.data.bin); if (this.pipes.length) { for (let i = this.pipes.length - 1; i >= 0; i--) { opts.binData = this.pipes[i](opts.binData); @@ -365,180 +466,221 @@ export class UploadInstance extends EventEmitter { if (this.fileLength === evt.data.chunkId) { if (this.collection.debug) { + // eslint-disable-next-line no-console console.timeEnd(`loadFile ${this.fileData.name}`); } - this.emit('readEnd'); + this.result.emit('readEnd'); } - if (opts.binData) { - if (this.config.transport === 'ddp') { - this.config.ddp.call(this.collection._methodNames._Write, opts, (error) => { - this.transferTime += Date.now() - this.startTime[opts.chunkId]; - if (error) { - if (this.result.state.get() !== 'aborted') { - this.emit('end', error); - } - } else { - if (++this.sentChunks >= this.fileLength) { - this.emit('sendEOF'); - } else { - this.emit('upload'); - } - this.emit('calculateStats'); - } - }); - } else { - const uid = Random.id(); - this.fetchControllers[uid] = new AbortController(); - fetch(`${_rootUrl}${this.collection.downloadRoute}/${this.collection.collectionName}/__upload`, { - method: 'POST', - signal: this.fetchControllers[uid].signal, - body: opts.binData, - cache: 'no-cache', - credentials: 'include', - type: 'cors', - headers: { - 'x-mtok': (helpers.isObject(Meteor.connection) ? Meteor.connection._lastSessionId : void 0) || null, - 'x-fileid': opts.fileId, - 'x-chunkid': opts.chunkId, - 'content-type': 'text/plain' - } - }).then((response) => { - delete this.fetchControllers[uid]; - if (!this.config.isEnded) { - if (response.status === 204) { - this.collection._debug('[FilesCollection] [sendChunk] [fetch()] [then] chunk successfully sent'); - this.transferTime += Date.now() - this.startTime[opts.chunkId]; - if (++this.sentChunks >= this.fileLength) { - this.emit('sendEOF'); - } else { - this.emit('upload'); - } - this.emit('calculateStats'); - } else { - this.emit('end', new Meteor.Error(response.status, 'Can\'t continue upload, session expired. Please, start upload again.')); - } - } - }).catch((error) => { - delete this.fetchControllers[uid]; - if (!this.config.isEnded) { - this.collection._debug('[FilesCollection] [sendChunk] [fetch()] [error] EXCEPTION while sending chunk', error); - this.transferTime += Date.now() - this.startTime[opts.chunkId]; - Meteor.setTimeout(() => { - if (!Meteor.status().connected || `${error}` === 'Error: network' || `${error}` === 'Error: Connection lost') { - this.result.pause(); - } else if (this.result.state.get() !== 'aborted') { - this.emit('end', error); - } - }, 512); - } - }); + if (!opts.binData) { + this.collection._debug('[FilesCollection] [sendChunk] binData is empty! Can not send empty chunk!'); + return; + } + + const response = await this._sendRequest({ + methodName: this.collection._methodNames._Write, + payload: opts, + timeout: 25000, + headers: { + 'x-fileid': opts.fileId, + 'x-chunkid': opts.chunkId, + 'content-type': 'text/plain' } + }); + + if (!this.config.isEnded && response?.status === 204) { + this.transferTime += Date.now() - this.startTime[opts.chunkId]; + this.emit('calculateStats'); } } - sendEOF() { - this.collection._debug('[FilesCollection] [UploadInstance] [sendEOF]', this.EOFsent); - if (!this.EOFsent) { - this.EOFsent = true; - const opts = { - eof: true, - fileId: this.fileId - }; + /** + * Sends an EOF (end-of-file) marker to the server. + * @returns {Promise} + */ + async _sendEOF() { + this.collection._debug('[FilesCollection] [UploadInstance] [sendEOF]', this.fileId, this.EOFsent, this.config.isEnded); + if (this.EOFsent) { + return; + } + this.EOFsent = true; + const opts = { + eof: true, + fileId: this.fileId, + binData: '', + }; + + const response = await this._sendRequest({ + methodName: this.collection._methodNames._Write, + payload: opts, + timeout: 10000, + headers: { + 'x-eof': '1', + 'x-fileId': opts.fileId, + 'content-type': 'text/plain', + } + }); + + if (response && !this.config.isEnded) { + this.emit('end', response.error, response); + } + } + + /** + * Sends a network request for file upload. + * @param {Object} conf - The configuration for the network request. + * @param {string} conf.methodName - The method name to call. + * @param {Object} conf.payload - The payload to send. + * @param {number} [conf.timeout=25000] - Request timeout in milliseconds. + * @param {Object} conf.headers - Request headers. + * @returns {Promise} Resolves with the response. + */ + async _sendRequest(conf) { + this.collection._debug('[FilesCollection] [UploadInstance] [sendRequest]', this.fileId, { conf }); + let result = false; + try { if (this.config.transport === 'ddp') { - this.config.ddp.call(this.collection._methodNames._Write, opts, (error, result) => { - if (!this.config.isEnded) { - this.emit('end', error, result); - } - }); + result = await this.config.ddp.callAsync(conf.methodName, conf.payload); } else { + const payload = helpers.clone(conf.payload?.binData || conf.payload || ''); + if (helpers.isObject(payload?.file?.meta)) { + payload.file.meta = fixJSONStringify(payload.file.meta); + } + const uid = Random.id(); this.fetchControllers[uid] = new AbortController(); - fetch(`${_rootUrl}${this.collection.downloadRoute}/${this.collection.collectionName}/__upload`, { + this.fetchTimeouts[uid] = setTimeout(() => { + if (this.fetchControllers[uid]) { + this.fetchControllers[uid].abort(new Meteor.Error(503, 'Send Request Timeout')); + } + }, conf.timeout || 25000); + + const response = await fetch(`${_rootUrl}${this.collection.downloadRoute}/${this.collection.collectionName}/__upload`, { method: 'POST', signal: this.fetchControllers[uid].signal, - body: '', + body: helpers.isObject(payload) ? JSON.stringify(payload) : payload, cache: 'no-cache', credentials: 'include', type: 'cors', headers: { - 'x-eof': '1', - 'x-mtok': (helpers.isObject(Meteor.connection) ? Meteor.connection._lastSessionId : void 0) || null, - 'x-fileId': opts.fileId, - 'content-type': 'text/plain' - }, - }).then((response) => response.json()).then((result) => { - delete this.fetchControllers[uid]; - if (!this.config.isEnded) { - if (result.meta) { - result.meta = fixJSONParse(result.meta); - } + ...conf.headers, + 'x-mtok': (helpers.isObject(Meteor.connection) ? Meteor.connection._lastSessionId : void 0) || null + } + }); - this.emit('end', void 0, result); + clearTimeout(this.fetchTimeouts[uid]); + delete this.fetchControllers[uid]; + delete this.fetchTimeouts[uid]; + result = response; + + if (response.headers.get('content-type') === 'application/json') { + try { + const jsonData = await response.json(); + if (jsonData.meta) { + jsonData.meta = fixJSONParse(jsonData.meta); + } + result = { status: response.status, ...jsonData }; + } catch (jsonError) { + this.collection._debug('[FilesCollection] [UploadInstance] [sendRequest] [parseJSON] [ERROR:]', this.fileId, jsonError, response); } - }).catch((error) => { - delete this.fetchControllers[uid]; - if (!this.config.isEnded) { - Meteor.setTimeout(() => { - if (!Meteor.status().connected || `${error}` === 'Error: network' || `${error}` === 'Error: Connection lost') { - this.result.pause(); - } else if (this.result.state.get() !== 'aborted') { - Meteor._debug('Something went wrong! [sendEOF] method doesn\'t returned JSON! Looks like you\'re on Cordova app or behind proxy, switching to DDP transport is recommended.'); - this.emit('end', error); - } - }, 512); + } + } + + if (!this.config.isEnded) { + if (result.status === 200) { + return result; + } + + if (result.status === 204) { + ++this.sentChunks; + this._upload(); + } else if (result.status === 503) { + this._upload(); + } else if (result.status === 408) { + this.emit('error', new Meteor.Error(result.status, 'Can\'t continue upload, session expired. Please, start upload again.')); + } else if (result.status === 405) { + this.emit('error', new Meteor.Error(405, 'Uploads are disabled')); + } else if (result.status === 403) { + if (result.error && result.isClientSafe) { + this.emit('error', new Meteor.Error(result.error, result.reason)); + } else { + this.emit('error', new Meteor.Error(500, 'Unexpected error occurred during upload, try again later')); } - }); + } else { + this._handleNetworkError(new Meteor.Error(500, 'Unexpected error occurred during upload, make sure you\'re connected to the Internet. Reload the page or try again later.', result)); + } } + } catch (requestError) { + this.collection._debug('[FilesCollection] [UploadInstance] [sendRequest] [CAUGHT ERROR:]', this.fileId, requestError, conf); + setTimeout(() => { + this._handleNetworkError(requestError); + }, 128); } + + return result; } - proceedChunk(chunkId) { + /** + * Reads and sends a specific chunk from the file. + * @param {number} chunkId - The 1-indexed chunk number to process. + * @returns {Promise} + */ + async _proceedChunk(chunkId) { + this.collection._debug('[FilesCollection] [UploadInstance] [proceedChunk]', this.fileId, chunkId); const chunk = this.config.file.slice((this.config.chunkSize * (chunkId - 1)), (this.config.chunkSize * chunkId)); if (this.config.isBase64) { - this.emit('sendChunk', { + await this._sendChunk({ data: { bin: chunk, chunkId } }); - } else { - let fileReader; - if (window.FileReader) { - fileReader = new window.FileReader; - - fileReader.onloadend = (evt) => { - this.emit('sendChunk', { - data: { - bin: ((helpers.isObject(fileReader) ? fileReader.result : void 0) || (evt.srcElement ? evt.srcElement.result : void 0) || (evt.target ? evt.target.result : void 0)).split(',')[1], - chunkId - } - }); - }; + return; + } - fileReader.onerror = (e) => { - this.emit('end', (e.target || e.srcElement).error); - }; + let fileReader; + if (!window.FileReader && !window.FileReaderSync) { + this.emit('error', new Meteor.Error(400, 'File API is not supported in this Browser!')); + return; + } - fileReader.readAsDataURL(chunk); - } else if (window.FileReaderSync) { - fileReader = new window.FileReaderSync; + if (window.FileReader) { + fileReader = new window.FileReader; - this.emit('sendChunk', { + fileReader.onloadend = (evt) => { + this._sendChunk({ data: { - bin: fileReader.readAsDataURL(chunk).split(',')[1], + bin: ((helpers.isObject(fileReader) ? fileReader.result : void 0) || (evt.srcElement ? evt.srcElement.result : void 0) || (evt.target ? evt.target.result : void 0)).split(',')[1], chunkId } }); - } else { - this.emit('end', 'File API is not supported in this Browser!'); - } + }; + + fileReader.onerror = (e) => { + this.emit('error', (e.target || e.srcElement).error); + }; + + fileReader.readAsDataURL(chunk); + return; } + + fileReader = new window.FileReaderSync; + + await this._sendChunk({ + data: { + bin: fileReader.readAsDataURL(chunk).split(',')[1], + chunkId + } + }); } - upload() { + /** + * Initiates or continues the upload process by processing the next chunk. + * @returns {Promise} Resolves with the current UploadInstance. + */ + async _upload() { if (this.result.onPause.get()) { return this; } @@ -556,19 +698,25 @@ export class UploadInstance extends EventEmitter { ib: this.config.isBase64 }); } else { - this.emit('proceedChunk', this.sentChunks + 1); + await this._proceedChunk(this.sentChunks + 1); } } else { - this.emit('sendEOF'); + await this._sendEOF(); } this.startTime[this.sentChunks + 1] = Date.now(); return this; } - prepare() { + /** + * Prepares the file upload by setting chunk sizes and initiating the upload process. + * @returns {Promise} + */ + async _prepare() { let _len; - this.config.onStart && this.config.onStart.call(this.result, null, this.fileData); + if (this.config.onStart) { + this.config.onStart.call(this.result, null, this.fileData); + } this.result.emit('start', null, this.fileData); if (this.config.chunkSize === 'dynamic') { @@ -604,129 +752,133 @@ export class UploadInstance extends EventEmitter { fileLength: this.fileLength }; + this.FSName = this.collection.namingFunction ? (await this.collection.namingFunction(this.fileData)) : this.fileId; if (this.FSName !== this.fileId) { opts.FSName = this.FSName; } - const handleStart = (error) => { - if (!this.config.isEnded) { - if (error) { - Meteor.setTimeout(() => { - if (!Meteor.status().connected || `${error}` === 'Error: network' || `${error}` === 'Error: Connection lost') { - this.result.pause(); - } else if (this.result.state.get() !== 'aborted') { - this.collection._debug('[FilesCollection] [_Start] Error:', error); - this.emit('end', error); - } - }, 512); - } else { - this.result.continueFunc = () => { - this.collection._debug('[FilesCollection] [insert] [continueFunc]'); - this.emit('upload'); - }; - this.emit('upload'); - } + const response = await this._sendRequest({ + methodName: this.collection._methodNames._Start, + payload: opts, + timeout: 10000, + headers: { + 'x-start': '1', + 'content-type': 'application/json', } - }; + }); - if (this.config.transport === 'ddp') { - this.config.ddp.call(this.collection._methodNames._Start, opts, handleStart); - } else { - if (helpers.isObject(opts.file) ? opts.file.meta : void 0) { - opts.file.meta = fixJSONStringify(opts.file.meta); - } - - const uid = Random.id(); - this.fetchControllers[uid] = new AbortController(); - fetch(`${_rootUrl}${this.collection.downloadRoute}/${this.collection.collectionName}/__upload`, { - method: 'POST', - signal: this.fetchControllers[uid].signal, - body: JSON.stringify(opts), - cache: 'no-cache', - credentials: 'include', - type: 'cors', - headers: { - 'x-start': '1', - 'x-mtok': (helpers.isObject(Meteor.connection) ? Meteor.connection._lastSessionId : void 0) || null - } - }).then((response) => { - delete this.fetchControllers[uid]; - if (!this.config.isEnded) { - if (response.status === 204) { - handleStart(); - } else { - this.emit('end', new Meteor.Error(response.status, 'Can\'t start upload, make sure you\'re connected to the Internet. Reload the page or try again later.')); - } - } - }).catch((error) => { - delete this.fetchControllers[uid]; - handleStart(error); - }); + this.collection._debug('[FilesCollection] [UploadInstance] [prepare] [response]', this.fileId, response); + if (!this.config.isEnded && response?.status === 204) { + this.result.continueFunc = () => { + this.collection._debug('[FilesCollection] [insert] [continueFunc]', this.fileId); + this._upload(); + }; } } + /** + * Adds a transformation function to the upload pipeline. + * @param {function(string): string} func - A function to process the binary data. + * @returns {UploadInstance} Returns the current UploadInstance for chaining. + */ pipe(func) { this.pipes.push(func); return this; } - start() { + /** + * Starts the file upload process. + * @returns {Promise} Resolves with the FileUpload instance. + */ + async start() { let isUploadAllowed; + if (this.config.disableUpload) { + this.emit('error', new Meteor.Error(403, 'Uploads are disabled'), this); + return this.result; + } + if (this.fileData.size <= 0) { - this.end(new Meteor.Error(400, 'Can\'t upload empty file')); + this.emit('error', new Meteor.Error(400, 'Can\'t upload empty file')); return this.result; } - if (this.config.onBeforeUpload && helpers.isFunction(this.config.onBeforeUpload)) { - isUploadAllowed = this.config.onBeforeUpload.call(Object.assign({}, this.result, this.collection._getUser()), this.fileData); - if (isUploadAllowed !== true) { - return this.end(new Meteor.Error(403, helpers.isString(isUploadAllowed) ? isUploadAllowed : 'config.onBeforeUpload() returned false')); + try { + if (this.config.onBeforeUpload && helpers.isFunction(this.config.onBeforeUpload)) { + isUploadAllowed = await Promise.resolve(this.config.onBeforeUpload.call(Object.assign({}, this.result, this.collection._getUser()), this.fileData)); + if (isUploadAllowed !== true) { + this.emit('error', new Meteor.Error(403, helpers.isString(isUploadAllowed) ? isUploadAllowed : 'config.onBeforeUpload() returned false')); + return this.result; + } } - } - if (this.collection.onBeforeUpload && helpers.isFunction(this.collection.onBeforeUpload)) { - isUploadAllowed = this.collection.onBeforeUpload.call(Object.assign({}, this.result, this.collection._getUser()), this.fileData); - if (isUploadAllowed !== true) { - return this.end(new Meteor.Error(403, helpers.isString(isUploadAllowed) ? isUploadAllowed : 'collection.onBeforeUpload() returned false')); + if (this.collection.onBeforeUpload && helpers.isFunction(this.collection.onBeforeUpload)) { + isUploadAllowed = await Promise.resolve(this.collection.onBeforeUpload.call(Object.assign({}, this.result, this.collection._getUser()), this.fileData)); + if (isUploadAllowed !== true) { + this.emit('error', new Meteor.Error(403, helpers.isString(isUploadAllowed) ? isUploadAllowed : 'collection.onBeforeUpload() returned false')); + return this.result; + } } + } catch (error) { + this.emit('error', new Meteor.Error(500, `Error in onBeforeUpload: ${error.message}`)); + return this.result; } - Tracker.autorun((computation) => { - this.trackerComp = computation; + this.trackerCompConnection = Tracker.autorun(() => { if (!this.result.onPause.curValue && !Meteor.status().connected) { - this.collection._debug('[FilesCollection] [insert] [Tracker] [pause]'); + this.collection._debug('[FilesCollection] [insert] [Tracker connection] [pause]', this.fileId); this.result.pause(); } else if (this.result.onPause.curValue && Meteor.status().connected) { - this.collection._debug('[FilesCollection] [insert] [Tracker] [continue]'); + this.collection._debug('[FilesCollection] [insert] [Tracker connection] [continue]', this.fileId); this.result.continue(); } }); + this.trackerCompPause = Tracker.autorun(() => { + if (this.result.onPause.get() === true) { + this.collection._debug('[FilesCollection] [insert] [Tracker pause] [abort]', this.fileId); + // eslint-disable-next-line guard-for-in + for (const uid in this.fetchControllers) { + if (this.fetchControllers[uid]) { + this.fetchControllers[uid].abort(new Meteor.Error(412, 'Upload set to pause')); + delete this.fetchControllers[uid]; + } + if (this.fetchTimeouts[uid]) { + clearTimeout(this.fetchTimeouts[uid]); + delete this.fetchTimeouts[uid]; + } + } + } + }); + if (this.worker) { - this.collection._debug('[FilesCollection] [insert] using WebWorkers'); + this.collection._debug('[FilesCollection] [insert] using WebWorkers', this.fileId); this.worker.onmessage = (evt) => { if (evt.data.error) { - this.collection._debug('[FilesCollection] [insert] [worker] [onmessage] [ERROR:]', evt.data.error); - this.emit('proceedChunk', evt.data.chunkId); + this.collection._debug('[FilesCollection] [insert] [worker] [onmessage] [ERROR:]', this.fileId, evt.data.error); + this._proceedChunk(evt.data.chunkId); } else { - this.emit('sendChunk', evt); + this._sendChunk(evt); } }; this.worker.onerror = (e) => { - this.collection._debug('[FilesCollection] [insert] [worker] [onerror] [ERROR:]', e); - this.emit('end', e.message); + this.collection._debug('[FilesCollection] [insert] [worker] [onerror] [ERROR:]', this.fileId, e); + this.emit('error', new Meteor.Error(500, e.message)); }; } else { - this.collection._debug('[FilesCollection] [insert] using MainThread'); + this.collection._debug('[FilesCollection] [insert] using MainThread', this.fileId); } - this.emit('prepare'); + await this._prepare(); return this.result; } + /** + * Configures the upload instance for manual control. + * @returns {FileUpload} Returns the FileUpload instance. + */ manual() { - this.result.start = () => { + this.result.start = async () => { this.emit('start'); }; diff --git a/write-stream.js b/write-stream.js index 8bb64812..97d17dc7 100644 --- a/write-stream.js +++ b/write-stream.js @@ -1,171 +1,212 @@ -import fs from 'fs-extra'; -import nodePath from 'path'; +import fs from 'node:fs'; +import nodePath from 'node:path'; import { Meteor } from 'meteor/meteor'; import { helpers } from './lib.js'; -const noop = () => {}; /** - * @const {Object} bound - Meteor.bindEnvironment (Fiber wrapper) - * @const {Object} fdCache - File Descriptors Cache + * @const {FileHandleCache} fhCache - FileHandle Cache */ -const bound = Meteor.bindEnvironment(callback => callback()); -const fdCache = {}; +const fhCache = new Map(); /** * @private * @locus Server * @class WriteStream - * @param path {String} - Path to file on FS - * @param maxLength {Number} - Max amount of chunks in stream - * @param file {Object} - fileRef Object - * @param permissions {String} - Permissions which will be set to open descriptor (octal), like: `611` or `0o777`. Default: 0755 + * @param path {string} - Path to file on FS + * @param maxLength {number} - Max amount of chunks in stream + * @param file {FileObj} - FileObj Object + * @param permissions {string} - Permissions which will be set to open descriptor (octal), like: `611` or `0o777`. Default: 0644 + * @param parentDirPermissions {string} - Permissions which will be set to parent directory (octal), like: `611` or `0o777`. Default: 0755 * @summary writableStream wrapper class, makes sure chunks is written in given order. Implementation of queue stream. */ export default class WriteStream { - constructor(path, maxLength, file, permissions) { + constructor(path, maxLength, file, permissions, parentDirPermissions) { this.path = path.trim(); + if (!this.path || !helpers.isString(this.path)) { + throw new Meteor.Error(400, '[FilesCollection] [new WriteStream(path)] [constructor] {path} must be a String!', this.path); + } this.maxLength = maxLength; this.file = file; this.permissions = permissions; - if (!this.path || !helpers.isString(this.path)) { - return; - } + this.parentDirPermissions = parentDirPermissions; - this.fd = null; + this.fh = null; this.ended = false; this.aborted = false; this.writtenChunks = 0; + this.endRetries = 0; + } - if (fdCache[this.path] && !fdCache[this.path].ended && !fdCache[this.path].aborted) { - this.fd = fdCache[this.path].fd; - this.writtenChunks = fdCache[this.path].writtenChunks; + /** + * @memberOf writeStream + * @name init + * @summary Initialize WriteStream, create fileHandle, ensure directory and file is writable + * @returns {Promise} + */ + async init() { + const fhCached = fhCache.get(this.path); + if (fhCached && !fhCached.ended && !fhCached.aborted) { + this.fh = fhCached.fh; + this.writtenChunks = fhCached.writtenChunks; } else { - fs.stat(this.path, (statError, stats) => { - bound(() => { - if (statError || !stats.isFile()) { - const paths = this.path.split(nodePath.sep); - paths.pop(); - try { - fs.mkdirSync(paths.join(nodePath.sep), { recursive: true }); - } catch (mkdirError) { - throw new Meteor.Error(500, `[FilesCollection] [writeStream] [constructor] [mkdirSync] ERROR: can not make/ensure directory "${paths.join(nodePath.sep)}"`, mkdirError); - } - - try { - fs.writeFileSync(this.path, ''); - } catch (writeFileError) { - throw new Meteor.Error(500, `[FilesCollection] [writeStream] [constructor] [writeFileSync] ERROR: can not write file "${this.path}"`, writeFileError); - } - } - - fs.open(this.path, 'r+', this.permissions, (oError, fd) => { - bound(() => { - if (oError) { - this.abort(); - throw new Meteor.Error(500, '[FilesCollection] [writeStream] [constructor] [open] [Error:]', oError); - } else { - this.fd = fd; - fdCache[this.path] = this; - } - }); - }); - }); - }); + let statError; + let stats; + try { + stats = await fs.promises.stat(this.path); + } catch (err) { + statError = err; + } + + if (statError || (stats && !stats.isFile())) { + const paths = this.path.split(nodePath.sep); + paths.pop(); + try { + await fs.promises.mkdir(paths.join(nodePath.sep), { recursive: true, flush: true, mode: this.parentDirPermissions }); + } catch (mkdirError) { + throw new Meteor.Error(500, `[FilesCollection] [writeStream] [constructor] [mkdirSync] ERROR: can not make/ensure directory "${paths.join(nodePath.sep)}"`, mkdirError); + } + + try { + await fs.promises.writeFile(this.path, ''); + } catch (writeFileError) { + throw new Meteor.Error(500, `[FilesCollection] [writeStream] [constructor] [writeFileSync] ERROR: can not write file "${this.path}"`, writeFileError); + } + } else { + this.writtenChunks = Math.ceil(stats.size / this.file.chunkSize); + } + + try { + const fh = await fs.promises.open(this.path, fs.constants.O_RDWR | fs.constants.O_CREAT, this.permissions); + this.fh = fh; + fhCache.set(this.path, this); + } catch (fsOpenError) { + await this.abort(); + throw new Meteor.Error(500, '[FilesCollection] [writeStream] [constructor] [open] [Error:]', fsOpenError); + } } + + return this; } /** * @memberOf writeStream * @name write - * @param {Number} num - Chunk position in a stream + * @param {number} num - Chunk position in a stream * @param {Buffer} chunk - Buffer (chunk binary data) - * @param {Function} callback - Callback * @summary Write chunk in given order - * @returns {Boolean} - True if chunk is sent to stream, false if chunk is set into queue + * @returns {Promise} - True if chunk was written to a file, false if chunk wasn't written */ - write(num, chunk, callback) { - if (!this.aborted && !this.ended) { - if (this.fd) { - fs.write(this.fd, chunk, 0, chunk.length, (num - 1) * this.file.chunkSize, (error, written, buffer) => { - bound(() => { - callback && callback(error, written, buffer); - if (error) { - Meteor._debug('[FilesCollection] [writeStream] [write] [Error:]', error); - this.abort(); - } else { - ++this.writtenChunks; - } - }); - }); - } else { - Meteor.setTimeout(() => { - this.write(num, chunk, callback); - }, 25); + async write(num, chunk) { + if (this.aborted || this.ended) { + return false; + } + + try { + const { bytesWritten } = await this.fh.write(chunk, 0, chunk.byteLength, (num - 1) * this.file.chunkSize); + await this.fh.sync(); + if (bytesWritten !== chunk.byteLength) { + return false; } + ++this.writtenChunks; + return true; + } catch (error) { + Meteor._debug('[FilesCollection] [writeStream] [write] [Error:]', error); + await this.abort(); + } + + return false; + } + + /** + * @memberOf writeStream + * @name waitForCompletion + * @summary Waits for 25 seconds to all chunks to complete writing + * @returns {Promise} - `true` if file was fully written, `false` if writing was aborted and file removed + */ + async waitForCompletion() { + while ((!this.aborted && !this.ended && this.writtenChunks < this.maxLength) && this.endRetries < 1000) { + ++this.endRetries; + const stats = await fs.promises.stat(this.path); + this.writtenChunks = Math.ceil(stats.size / this.file.chunkSize); + await new Promise(resolve => setTimeout(resolve, 25)); + } + + if (this.writtenChunks >= this.maxLength) { + return await this.stop(false); } + + await this.abort(); return false; } /** * @memberOf writeStream * @name end - * @param {Function} callback - Callback * @summary Finishes writing to writableStream, only after all chunks in queue is written - * @returns {Boolean} - True if stream is fulfilled, false if queue is in progress + * @returns {Promise} - True if stream is fulfilled, false if queue is in progress */ - end(callback) { - if (!this.aborted && !this.ended) { - if (this.writtenChunks === this.maxLength) { - fs.close(this.fd, () => { - bound(() => { - delete fdCache[this.path]; - this.ended = true; - callback && callback(void 0, true); - }); - }); - return true; - } + async end() { + if (this.aborted || this.ended) { + return true; + } - fs.stat(this.path, (error, stat) => { - bound(() => { - if (!error && stat) { - this.writtenChunks = Math.ceil(stat.size / this.file.chunkSize); - } - - return Meteor.setTimeout(() => { - this.end(callback); - }, 25); - }); - }); - } else { - callback && callback(void 0, this.ended); + if (this.writtenChunks >= this.maxLength) { + return await this.stop(false); } - return false; + + if (await this.waitForCompletion()) { + return true; + } + + Meteor._debug('[FilesCollection] [writeStream] [end] waitForCompletion waited for 25 seconds to complete writing and failed with timeout', this.path); + return this.ended; } /** * @memberOf writeStream * @name abort - * @param {Function} callback - Callback * @summary Aborts writing to writableStream, removes created file - * @returns {Boolean} - True + * @returns {Promise} - True */ - abort(callback) { - this.aborted = true; - delete fdCache[this.path]; - fs.unlink(this.path, (callback || noop)); + async abort() { + if (this.aborted) { + return true; + } + + await this.stop(true); + try { + await fs.promises.unlink(this.path); + } catch (unlinkError) { + Meteor._debug('[FilesCollection] [writeStream] [abort] [unlink] [ERROR:]', this.path, unlinkError); + } return true; } /** * @memberOf writeStream * @name stop + * @param {boolean} [isAborted=false] - was stop called because it was aborted? * @summary Stop writing to writableStream - * @returns {Boolean} - True + * @returns {Promise} - true */ - stop() { - this.aborted = true; - delete fdCache[this.path]; + async stop(isAborted = false) { + if (this.ended) { + return true; + } + + this.ended = true; + if (isAborted) { + this.aborted = true; + } else { + try { + await this.fh.datasync(); + } catch (dsError) { + Meteor._debug('[FilesCollection] [writeStream] [stop] fh.datasync resulted in Error:', this.path, dsError); + } + } + + await this.fh.close(); + fhCache.delete(this.path); return true; } }