diff --git a/services/real-time/.dockerignore b/services/real-time/.dockerignore new file mode 100644 index 0000000000..ba1c3442de --- /dev/null +++ b/services/real-time/.dockerignore @@ -0,0 +1,7 @@ +node_modules/* +gitrev +.git +.gitignore +.npm +.nvmrc +nodemon.json diff --git a/services/real-time/.eslintrc b/services/real-time/.eslintrc new file mode 100644 index 0000000000..a97661b15f --- /dev/null +++ b/services/real-time/.eslintrc @@ -0,0 +1,86 @@ +// this file was auto-generated, do not edit it directly. +// instead run bin/update_build_scripts from +// https://github.com/sharelatex/sharelatex-dev-environment +{ + "extends": [ + "eslint:recommended", + "standard", + "prettier" + ], + "parserOptions": { + "ecmaVersion": 2018 + }, + "plugins": [ + "mocha", + "chai-expect", + "chai-friendly" + ], + "env": { + "node": true, + "mocha": true + }, + "rules": { + // TODO(das7pad): remove overrides after fixing all the violations manually (https://github.com/overleaf/issues/issues/3882#issuecomment-878999671) + // START of temporary overrides + "array-callback-return": "off", + "no-dupe-else-if": "off", + "no-var": "off", + "no-empty": "off", + "node/handle-callback-err": "off", + "no-loss-of-precision": "off", + "node/no-callback-literal": "off", + "node/no-path-concat": "off", + "prefer-regex-literals": "off", + // END of temporary overrides + + // Swap the no-unused-expressions rule with a more chai-friendly one + "no-unused-expressions": 0, + "chai-friendly/no-unused-expressions": "error", + + // Do not allow importing of implicit dependencies. + "import/no-extraneous-dependencies": "error" + }, + "overrides": [ + { + // Test specific rules + "files": ["test/**/*.js"], + "globals": { + "expect": true + }, + "rules": { + // mocha-specific rules + "mocha/handle-done-callback": "error", + "mocha/no-exclusive-tests": "error", + "mocha/no-global-tests": "error", + "mocha/no-identical-title": "error", + "mocha/no-nested-tests": "error", + "mocha/no-pending-tests": "error", + "mocha/no-skipped-tests": "error", + "mocha/no-mocha-arrows": "error", + + // chai-specific rules + "chai-expect/missing-assertion": "error", + "chai-expect/terminating-properties": "error", + + // prefer-arrow-callback applies to all callbacks, not just ones in mocha tests. + // we don't enforce this at the top-level - just in tests to manage `this` scope + // based on mocha's context mechanism + "mocha/prefer-arrow-callback": "error" + } + }, + { + // Backend specific rules + "files": ["app/**/*.js", "app.js", "index.js"], + "rules": { + // don't allow console.log in backend code + "no-console": "error", + + // Do not allow importing of implicit dependencies. + "import/no-extraneous-dependencies": ["error", { + // Do not allow importing of devDependencies. + "devDependencies": false + }] + } + } + ] +} diff --git a/services/real-time/.github/ISSUE_TEMPLATE.md b/services/real-time/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000000..e0093aa90c --- /dev/null +++ b/services/real-time/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,38 @@ + + +## Steps to Reproduce + + + +1. +2. +3. + +## Expected Behaviour + + +## Observed Behaviour + + + +## Context + + +## Technical Info + + +* URL: +* Browser Name and version: +* Operating System and version (desktop or mobile): +* Signed in as: +* Project and/or file: + +## Analysis + + +## Who Needs to Know? + + + +- +- diff --git a/services/real-time/.github/PULL_REQUEST_TEMPLATE.md b/services/real-time/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..12bb2eeb3f --- /dev/null +++ b/services/real-time/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,48 @@ + + + + + +### Description + + + +#### Screenshots + + + +#### Related Issues / PRs + + + +### Review + + + +#### Potential Impact + + + +#### Manual Testing Performed + +- [ ] +- [ ] + +#### Accessibility + + + +### Deployment + + + +#### Deployment Checklist + +- [ ] Update documentation not included in the PR (if any) +- [ ] + +#### Metrics and Monitoring + + + +#### Who Needs to Know? diff --git a/services/real-time/.github/dependabot.yml b/services/real-time/.github/dependabot.yml new file mode 100644 index 0000000000..c856753655 --- /dev/null +++ b/services/real-time/.github/dependabot.yml @@ -0,0 +1,23 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "daily" + + pull-request-branch-name: + # Separate sections of the branch name with a hyphen + # Docker images use the branch name and do not support slashes in tags + # https://github.com/overleaf/google-ops/issues/822 + # https://docs.github.com/en/github/administering-a-repository/configuration-options-for-dependency-updates#pull-request-branch-nameseparator + separator: "-" + + # Block informal upgrades -- security upgrades use a separate queue. + # https://docs.github.com/en/github/administering-a-repository/configuration-options-for-dependency-updates#open-pull-requests-limit + open-pull-requests-limit: 0 + + # currently assign team-magma to all dependabot PRs - this may change in + # future if we reorganise teams + labels: + - "dependencies" + - "type:maintenance" diff --git a/services/real-time/.gitignore b/services/real-time/.gitignore new file mode 100644 index 0000000000..80bac793a7 --- /dev/null +++ b/services/real-time/.gitignore @@ -0,0 +1,5 @@ +node_modules +forever + +# managed by dev-environment$ bin/update_build_scripts +.npmrc diff --git a/services/real-time/.mocharc.json b/services/real-time/.mocharc.json new file mode 100644 index 0000000000..dc3280aa96 --- /dev/null +++ b/services/real-time/.mocharc.json @@ -0,0 +1,3 @@ +{ + "require": "test/setup.js" +} diff --git a/services/real-time/.nvmrc b/services/real-time/.nvmrc new file mode 100644 index 0000000000..5a80a7e912 --- /dev/null +++ b/services/real-time/.nvmrc @@ -0,0 +1 @@ +12.22.3 diff --git a/services/real-time/.prettierrc b/services/real-time/.prettierrc new file mode 100644 index 0000000000..c92c3526e7 --- /dev/null +++ b/services/real-time/.prettierrc @@ -0,0 +1,11 @@ +# This file was auto-generated, do not edit it directly. +# Instead run bin/update_build_scripts from +# https://github.com/sharelatex/sharelatex-dev-environment +{ + "arrowParens": "avoid", + "semi": false, + "singleQuote": true, + "trailingComma": "es5", + "tabWidth": 2, + "useTabs": false +} diff --git a/services/real-time/Dockerfile b/services/real-time/Dockerfile new file mode 100644 index 0000000000..6b286376dc --- /dev/null +++ b/services/real-time/Dockerfile @@ -0,0 +1,23 @@ +# This file was auto-generated, do not edit it directly. +# Instead run bin/update_build_scripts from +# https://github.com/sharelatex/sharelatex-dev-environment + +FROM node:12.22.3 as base + +WORKDIR /app + +FROM base as app + +#wildcard as some files may not be in all repos +COPY package*.json npm-shrink*.json /app/ + +RUN npm ci --quiet + +COPY . /app + +FROM base + +COPY --from=app /app /app +USER node + +CMD ["node", "--expose-gc", "app.js"] diff --git a/services/real-time/LICENSE b/services/real-time/LICENSE new file mode 100644 index 0000000000..dba13ed2dd --- /dev/null +++ b/services/real-time/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/services/real-time/Makefile b/services/real-time/Makefile new file mode 100644 index 0000000000..9e065f2220 --- /dev/null +++ b/services/real-time/Makefile @@ -0,0 +1,90 @@ +# This file was auto-generated, do not edit it directly. +# Instead run bin/update_build_scripts from +# https://github.com/sharelatex/sharelatex-dev-environment + +BUILD_NUMBER ?= local +BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) +PROJECT_NAME = real-time +BUILD_DIR_NAME = $(shell pwd | xargs basename | tr -cd '[a-zA-Z0-9_.\-]') + +DOCKER_COMPOSE_FLAGS ?= -f docker-compose.yml +DOCKER_COMPOSE := BUILD_NUMBER=$(BUILD_NUMBER) \ + BRANCH_NAME=$(BRANCH_NAME) \ + PROJECT_NAME=$(PROJECT_NAME) \ + MOCHA_GREP=${MOCHA_GREP} \ + docker-compose ${DOCKER_COMPOSE_FLAGS} + +DOCKER_COMPOSE_TEST_ACCEPTANCE = \ + COMPOSE_PROJECT_NAME=test_acceptance_$(BUILD_DIR_NAME) $(DOCKER_COMPOSE) + +DOCKER_COMPOSE_TEST_UNIT = \ + COMPOSE_PROJECT_NAME=test_unit_$(BUILD_DIR_NAME) $(DOCKER_COMPOSE) + +clean: + -docker rmi ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) + -docker rmi gcr.io/overleaf-ops/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) + -$(DOCKER_COMPOSE_TEST_UNIT) down --rmi local + -$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down --rmi local + +format: + $(DOCKER_COMPOSE) run --rm test_unit npm run --silent format + +format_fix: + $(DOCKER_COMPOSE) run --rm test_unit npm run --silent format:fix + +lint: + $(DOCKER_COMPOSE) run --rm test_unit npm run --silent lint + +test: format lint test_unit test_acceptance + +test_unit: +ifneq (,$(wildcard test/unit)) + $(DOCKER_COMPOSE_TEST_UNIT) run --rm test_unit + $(MAKE) test_unit_clean +endif + +test_clean: test_unit_clean +test_unit_clean: +ifneq (,$(wildcard test/unit)) + $(DOCKER_COMPOSE_TEST_UNIT) down -v -t 0 +endif + +test_acceptance: test_acceptance_clean test_acceptance_pre_run test_acceptance_run + $(MAKE) test_acceptance_clean + +test_acceptance_debug: test_acceptance_clean test_acceptance_pre_run test_acceptance_run_debug + $(MAKE) test_acceptance_clean + +test_acceptance_run: +ifneq (,$(wildcard test/acceptance)) + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run --rm test_acceptance +endif + +test_acceptance_run_debug: +ifneq (,$(wildcard test/acceptance)) + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run -p 127.0.0.9:19999:19999 --rm test_acceptance npm run test:acceptance -- --inspect=0.0.0.0:19999 --inspect-brk +endif + +test_clean: test_acceptance_clean +test_acceptance_clean: + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0 + +test_acceptance_pre_run: +ifneq (,$(wildcard test/acceptance/js/scripts/pre-run)) + $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run --rm test_acceptance test/acceptance/js/scripts/pre-run +endif + +build: + docker build --pull --tag ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) \ + --tag gcr.io/overleaf-ops/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) \ + . + +tar: + $(DOCKER_COMPOSE) up tar + +publish: + + docker push $(DOCKER_REPO)/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) + + +.PHONY: clean test test_unit test_acceptance test_clean build publish diff --git a/services/real-time/app.js b/services/real-time/app.js new file mode 100644 index 0000000000..31216bb07e --- /dev/null +++ b/services/real-time/app.js @@ -0,0 +1,288 @@ +const Metrics = require('@overleaf/metrics') +const Settings = require('@overleaf/settings') +Metrics.initialize(Settings.appName || 'real-time') +const async = require('async') + +const logger = require('logger-sharelatex') +logger.initialize('real-time') +Metrics.event_loop.monitor(logger) + +const express = require('express') +const session = require('express-session') +const redis = require('@overleaf/redis-wrapper') +if (Settings.sentry && Settings.sentry.dsn) { + logger.initializeErrorReporting(Settings.sentry.dsn) +} + +const sessionRedisClient = redis.createClient(Settings.redis.websessions) + +const RedisStore = require('connect-redis')(session) +const SessionSockets = require('./app/js/SessionSockets') +const CookieParser = require('cookie-parser') + +const DrainManager = require('./app/js/DrainManager') +const HealthCheckManager = require('./app/js/HealthCheckManager') +const DeploymentManager = require('./app/js/DeploymentManager') + +// NOTE: debug is invoked for every blob that is put on the wire +const socketIoLogger = { + error(...message) { + logger.info({ fromSocketIo: true, originalLevel: 'error' }, ...message) + }, + warn(...message) { + logger.info({ fromSocketIo: true, originalLevel: 'warn' }, ...message) + }, + info() {}, + debug() {}, + log() {}, +} + +// monitor status file to take dark deployments out of the load-balancer +DeploymentManager.initialise() + +// Set up socket.io server +const app = express() + +const server = require('http').createServer(app) +const io = require('socket.io').listen(server, { + logger: socketIoLogger, +}) + +// Bind to sessions +const sessionStore = new RedisStore({ client: sessionRedisClient }) +const cookieParser = CookieParser(Settings.security.sessionSecret) + +const sessionSockets = new SessionSockets( + io, + sessionStore, + cookieParser, + Settings.cookieName +) + +Metrics.injectMetricsRoute(app) +app.use(Metrics.http.monitor(logger)) + +io.configure(function () { + io.enable('browser client minification') + io.enable('browser client etag') + + // Fix for Safari 5 error of "Error during WebSocket handshake: location mismatch" + // See http://answers.dotcloud.com/question/578/problem-with-websocket-over-ssl-in-safari-with + io.set('match origin protocol', true) + + // gzip uses a Node 0.8.x method of calling the gzip program which + // doesn't work with 0.6.x + // io.enable('browser client gzip') + io.set('transports', [ + 'websocket', + 'flashsocket', + 'htmlfile', + 'xhr-polling', + 'jsonp-polling', + ]) +}) + +// a 200 response on '/' is required for load balancer health checks +// these operate separately from kubernetes readiness checks +app.get('/', function (req, res) { + if (Settings.shutDownInProgress || DeploymentManager.deploymentIsClosed()) { + res.sendStatus(503) // Service unavailable + } else { + res.send('real-time is open') + } +}) + +app.get('/status', function (req, res) { + if (Settings.shutDownInProgress) { + res.sendStatus(503) // Service unavailable + } else { + res.send('real-time is alive') + } +}) + +app.get('/debug/events', function (req, res) { + Settings.debugEvents = parseInt(req.query.count, 10) || 20 + logger.log({ count: Settings.debugEvents }, 'starting debug mode') + res.send(`debug mode will log next ${Settings.debugEvents} events`) +}) + +const rclient = require('@overleaf/redis-wrapper').createClient( + Settings.redis.realtime +) + +function healthCheck(req, res) { + rclient.healthCheck(function (error) { + if (error) { + logger.err({ err: error }, 'failed redis health check') + res.sendStatus(500) + } else if (HealthCheckManager.isFailing()) { + const status = HealthCheckManager.status() + logger.err({ pubSubErrors: status }, 'failed pubsub health check') + res.sendStatus(500) + } else { + res.sendStatus(200) + } + }) +} +app.get( + '/health_check', + (req, res, next) => { + if (Settings.shutDownComplete) { + return res.sendStatus(503) + } + next() + }, + healthCheck +) + +app.get('/health_check/redis', healthCheck) + +const Router = require('./app/js/Router') +Router.configure(app, io, sessionSockets) + +const WebsocketLoadBalancer = require('./app/js/WebsocketLoadBalancer') +WebsocketLoadBalancer.listenForEditorEvents(io) + +const DocumentUpdaterController = require('./app/js/DocumentUpdaterController') +DocumentUpdaterController.listenForUpdatesFromDocumentUpdater(io) + +const { port } = Settings.internal.realTime +const { host } = Settings.internal.realTime + +server.listen(port, host, function (error) { + if (error) { + throw error + } + logger.info(`realtime starting up, listening on ${host}:${port}`) +}) + +// Stop huge stack traces in logs from all the socket.io parsing steps. +Error.stackTraceLimit = 10 + +function shutdownCleanly(signal) { + const connectedClients = io.sockets.clients().length + if (connectedClients === 0) { + logger.warn('no clients connected, exiting') + process.exit() + } else { + logger.warn( + { connectedClients }, + 'clients still connected, not shutting down yet' + ) + setTimeout(() => shutdownCleanly(signal), 30 * 1000) + } +} + +function drainAndShutdown(signal) { + if (Settings.shutDownInProgress) { + logger.warn({ signal }, 'shutdown already in progress, ignoring signal') + } else { + Settings.shutDownInProgress = true + const { statusCheckInterval } = Settings + if (statusCheckInterval) { + logger.warn( + { signal }, + `received interrupt, delay drain by ${statusCheckInterval}ms` + ) + } + setTimeout(function () { + logger.warn( + { signal }, + `received interrupt, starting drain over ${shutdownDrainTimeWindow} mins` + ) + DrainManager.startDrainTimeWindow(io, shutdownDrainTimeWindow, () => { + setTimeout(() => { + const staleClients = io.sockets.clients() + if (staleClients.length !== 0) { + logger.warn( + { staleClients: staleClients.map(client => client.id) }, + 'forcefully disconnecting stale clients' + ) + staleClients.forEach(client => { + client.disconnect() + }) + } + // Mark the node as unhealthy. + Settings.shutDownComplete = true + }, Settings.gracefulReconnectTimeoutMs) + }) + shutdownCleanly(signal) + }, statusCheckInterval) + } +} + +Settings.shutDownInProgress = false +const shutdownDrainTimeWindow = parseInt(Settings.shutdownDrainTimeWindow, 10) +if (Settings.shutdownDrainTimeWindow) { + logger.log({ shutdownDrainTimeWindow }, 'shutdownDrainTimeWindow enabled') + for (const signal of [ + 'SIGINT', + 'SIGHUP', + 'SIGQUIT', + 'SIGUSR1', + 'SIGUSR2', + 'SIGTERM', + 'SIGABRT', + ]) { + process.on(signal, drainAndShutdown) + } // signal is passed as argument to event handler + + // global exception handler + if (Settings.errors && Settings.errors.catchUncaughtErrors) { + process.removeAllListeners('uncaughtException') + process.on('uncaughtException', function (error) { + if ( + [ + 'ETIMEDOUT', + 'EHOSTUNREACH', + 'EPIPE', + 'ECONNRESET', + 'ERR_STREAM_WRITE_AFTER_END', + ].includes(error.code) + ) { + Metrics.inc('disconnected_write', 1, { status: error.code }) + return logger.warn( + { err: error }, + 'attempted to write to disconnected client' + ) + } + logger.error({ err: error }, 'uncaught exception') + if (Settings.errors && Settings.errors.shutdownOnUncaughtError) { + drainAndShutdown('SIGABRT') + } + }) + } +} + +if (Settings.continualPubsubTraffic) { + logger.warn('continualPubsubTraffic enabled') + + const pubsubClient = redis.createClient(Settings.redis.pubsub) + const clusterClient = redis.createClient(Settings.redis.websessions) + + const publishJob = function (channel, callback) { + const checker = new HealthCheckManager(channel) + logger.debug({ channel }, 'sending pub to keep connection alive') + const json = JSON.stringify({ + health_check: true, + key: checker.id, + date: new Date().toString(), + }) + Metrics.summary(`redis.publish.${channel}`, json.length) + pubsubClient.publish(channel, json, function (err) { + if (err) { + logger.err({ err, channel }, 'error publishing pubsub traffic to redis') + } + const blob = JSON.stringify({ keep: 'alive' }) + Metrics.summary('redis.publish.cluster-continual-traffic', blob.length) + clusterClient.publish('cluster-continual-traffic', blob, callback) + }) + } + + const runPubSubTraffic = () => + async.map(['applied-ops', 'editor-events'], publishJob, () => + setTimeout(runPubSubTraffic, 1000 * 20) + ) + + runPubSubTraffic() +} diff --git a/services/real-time/app/js/AuthorizationManager.js b/services/real-time/app/js/AuthorizationManager.js new file mode 100644 index 0000000000..4b48ed0e8c --- /dev/null +++ b/services/real-time/app/js/AuthorizationManager.js @@ -0,0 +1,67 @@ +/* eslint-disable + camelcase, +*/ +const { NotAuthorizedError } = require('./Errors') + +let AuthorizationManager +module.exports = AuthorizationManager = { + assertClientCanViewProject(client, callback) { + AuthorizationManager._assertClientHasPrivilegeLevel( + client, + ['readOnly', 'readAndWrite', 'owner'], + callback + ) + }, + + assertClientCanEditProject(client, callback) { + AuthorizationManager._assertClientHasPrivilegeLevel( + client, + ['readAndWrite', 'owner'], + callback + ) + }, + + _assertClientHasPrivilegeLevel(client, allowedLevels, callback) { + if (allowedLevels.includes(client.ol_context.privilege_level)) { + callback(null) + } else { + callback(new NotAuthorizedError()) + } + }, + + assertClientCanViewProjectAndDoc(client, doc_id, callback) { + AuthorizationManager.assertClientCanViewProject(client, function (error) { + if (error) { + return callback(error) + } + AuthorizationManager._assertClientCanAccessDoc(client, doc_id, callback) + }) + }, + + assertClientCanEditProjectAndDoc(client, doc_id, callback) { + AuthorizationManager.assertClientCanEditProject(client, function (error) { + if (error) { + return callback(error) + } + AuthorizationManager._assertClientCanAccessDoc(client, doc_id, callback) + }) + }, + + _assertClientCanAccessDoc(client, doc_id, callback) { + if (client.ol_context[`doc:${doc_id}`] === 'allowed') { + callback(null) + } else { + callback(new NotAuthorizedError()) + } + }, + + addAccessToDoc(client, doc_id, callback) { + client.ol_context[`doc:${doc_id}`] = 'allowed' + callback(null) + }, + + removeAccessToDoc(client, doc_id, callback) { + delete client.ol_context[`doc:${doc_id}`] + callback(null) + }, +} diff --git a/services/real-time/app/js/ChannelManager.js b/services/real-time/app/js/ChannelManager.js new file mode 100644 index 0000000000..81caa7dd83 --- /dev/null +++ b/services/real-time/app/js/ChannelManager.js @@ -0,0 +1,101 @@ +const logger = require('logger-sharelatex') +const metrics = require('@overleaf/metrics') +const settings = require('@overleaf/settings') +const OError = require('@overleaf/o-error') + +const ClientMap = new Map() // for each redis client, store a Map of subscribed channels (channelname -> subscribe promise) + +// Manage redis pubsub subscriptions for individual projects and docs, ensuring +// that we never subscribe to a channel multiple times. The socket.io side is +// handled by RoomManager. + +module.exports = { + getClientMapEntry(rclient) { + // return the per-client channel map if it exists, otherwise create and + // return an empty map for the client. + return ( + ClientMap.get(rclient) || ClientMap.set(rclient, new Map()).get(rclient) + ) + }, + + subscribe(rclient, baseChannel, id) { + const clientChannelMap = this.getClientMapEntry(rclient) + const channel = `${baseChannel}:${id}` + const actualSubscribe = function () { + // subscribe is happening in the foreground and it should reject + return rclient + .subscribe(channel) + .finally(function () { + if (clientChannelMap.get(channel) === subscribePromise) { + clientChannelMap.delete(channel) + } + }) + .then(function () { + logger.log({ channel }, 'subscribed to channel') + metrics.inc(`subscribe.${baseChannel}`) + }) + .catch(function (err) { + logger.error({ channel, err }, 'failed to subscribe to channel') + metrics.inc(`subscribe.failed.${baseChannel}`) + // add context for the stack-trace at the call-site + throw new OError('failed to subscribe to channel', { + channel, + }).withCause(err) + }) + } + + const pendingActions = clientChannelMap.get(channel) || Promise.resolve() + const subscribePromise = pendingActions.then( + actualSubscribe, + actualSubscribe + ) + clientChannelMap.set(channel, subscribePromise) + logger.log({ channel }, 'planned to subscribe to channel') + return subscribePromise + }, + + unsubscribe(rclient, baseChannel, id) { + const clientChannelMap = this.getClientMapEntry(rclient) + const channel = `${baseChannel}:${id}` + const actualUnsubscribe = function () { + // unsubscribe is happening in the background, it should not reject + return rclient + .unsubscribe(channel) + .finally(function () { + if (clientChannelMap.get(channel) === unsubscribePromise) { + clientChannelMap.delete(channel) + } + }) + .then(function () { + logger.log({ channel }, 'unsubscribed from channel') + metrics.inc(`unsubscribe.${baseChannel}`) + }) + .catch(function (err) { + logger.error({ channel, err }, 'unsubscribed from channel') + metrics.inc(`unsubscribe.failed.${baseChannel}`) + }) + } + + const pendingActions = clientChannelMap.get(channel) || Promise.resolve() + const unsubscribePromise = pendingActions.then( + actualUnsubscribe, + actualUnsubscribe + ) + clientChannelMap.set(channel, unsubscribePromise) + logger.log({ channel }, 'planned to unsubscribe from channel') + return unsubscribePromise + }, + + publish(rclient, baseChannel, id, data) { + let channel + metrics.summary(`redis.publish.${baseChannel}`, data.length) + if (id === 'all' || !settings.publishOnIndividualChannels) { + channel = baseChannel + } else { + channel = `${baseChannel}:${id}` + } + // we publish on a different client to the subscribe, so we can't + // check for the channel existing here + rclient.publish(channel, data) + }, +} diff --git a/services/real-time/app/js/ConnectedUsersManager.js b/services/real-time/app/js/ConnectedUsersManager.js new file mode 100644 index 0000000000..f8dd40fd6a --- /dev/null +++ b/services/real-time/app/js/ConnectedUsersManager.js @@ -0,0 +1,176 @@ +/* eslint-disable + camelcase, +*/ +const async = require('async') +const Settings = require('@overleaf/settings') +const logger = require('logger-sharelatex') +const redis = require('@overleaf/redis-wrapper') +const OError = require('@overleaf/o-error') +const rclient = redis.createClient(Settings.redis.realtime) +const Keys = Settings.redis.realtime.key_schema + +const ONE_HOUR_IN_S = 60 * 60 +const ONE_DAY_IN_S = ONE_HOUR_IN_S * 24 +const FOUR_DAYS_IN_S = ONE_DAY_IN_S * 4 + +const USER_TIMEOUT_IN_S = ONE_HOUR_IN_S / 4 +const REFRESH_TIMEOUT_IN_S = 10 // only show clients which have responded to a refresh request in the last 10 seconds + +module.exports = { + // Use the same method for when a user connects, and when a user sends a cursor + // update. This way we don't care if the connected_user key has expired when + // we receive a cursor update. + updateUserPosition(project_id, client_id, user, cursorData, callback) { + logger.log({ project_id, client_id }, 'marking user as joined or connected') + + const multi = rclient.multi() + + multi.sadd(Keys.clientsInProject({ project_id }), client_id) + multi.expire(Keys.clientsInProject({ project_id }), FOUR_DAYS_IN_S) + + multi.hset( + Keys.connectedUser({ project_id, client_id }), + 'last_updated_at', + Date.now() + ) + multi.hset( + Keys.connectedUser({ project_id, client_id }), + 'user_id', + user._id + ) + multi.hset( + Keys.connectedUser({ project_id, client_id }), + 'first_name', + user.first_name || '' + ) + multi.hset( + Keys.connectedUser({ project_id, client_id }), + 'last_name', + user.last_name || '' + ) + multi.hset( + Keys.connectedUser({ project_id, client_id }), + 'email', + user.email || '' + ) + + if (cursorData) { + multi.hset( + Keys.connectedUser({ project_id, client_id }), + 'cursorData', + JSON.stringify(cursorData) + ) + } + multi.expire( + Keys.connectedUser({ project_id, client_id }), + USER_TIMEOUT_IN_S + ) + + multi.exec(function (err) { + if (err) { + err = new OError('problem marking user as connected').withCause(err) + } + callback(err) + }) + }, + + refreshClient(project_id, client_id) { + logger.log({ project_id, client_id }, 'refreshing connected client') + const multi = rclient.multi() + multi.hset( + Keys.connectedUser({ project_id, client_id }), + 'last_updated_at', + Date.now() + ) + multi.expire( + Keys.connectedUser({ project_id, client_id }), + USER_TIMEOUT_IN_S + ) + multi.exec(function (err) { + if (err) { + logger.err( + { err, project_id, client_id }, + 'problem refreshing connected client' + ) + } + }) + }, + + markUserAsDisconnected(project_id, client_id, callback) { + logger.log({ project_id, client_id }, 'marking user as disconnected') + const multi = rclient.multi() + multi.srem(Keys.clientsInProject({ project_id }), client_id) + multi.expire(Keys.clientsInProject({ project_id }), FOUR_DAYS_IN_S) + multi.del(Keys.connectedUser({ project_id, client_id })) + multi.exec(function (err) { + if (err) { + err = new OError('problem marking user as disconnected').withCause(err) + } + callback(err) + }) + }, + + _getConnectedUser(project_id, client_id, callback) { + rclient.hgetall( + Keys.connectedUser({ project_id, client_id }), + function (err, result) { + if (err) { + err = new OError('problem fetching connected user details', { + other_client_id: client_id, + }).withCause(err) + return callback(err) + } + if (!(result && result.user_id)) { + result = { + connected: false, + client_id, + } + } else { + result.connected = true + result.client_id = client_id + result.client_age = + (Date.now() - parseInt(result.last_updated_at, 10)) / 1000 + if (result.cursorData) { + try { + result.cursorData = JSON.parse(result.cursorData) + } catch (e) { + OError.tag(e, 'error parsing cursorData JSON', { + other_client_id: client_id, + cursorData: result.cursorData, + }) + return callback(e) + } + } + } + callback(err, result) + } + ) + }, + + getConnectedUsers(project_id, callback) { + const self = this + rclient.smembers( + Keys.clientsInProject({ project_id }), + function (err, results) { + if (err) { + err = new OError('problem getting clients in project').withCause(err) + return callback(err) + } + const jobs = results.map( + client_id => cb => self._getConnectedUser(project_id, client_id, cb) + ) + async.series(jobs, function (err, users) { + if (err) { + OError.tag(err, 'problem getting connected users') + return callback(err) + } + users = users.filter( + user => + user && user.connected && user.client_age < REFRESH_TIMEOUT_IN_S + ) + callback(null, users) + }) + } + ) + }, +} diff --git a/services/real-time/app/js/DeploymentManager.js b/services/real-time/app/js/DeploymentManager.js new file mode 100644 index 0000000000..0e6b2a7f05 --- /dev/null +++ b/services/real-time/app/js/DeploymentManager.js @@ -0,0 +1,59 @@ +const logger = require('logger-sharelatex') +const settings = require('@overleaf/settings') +const fs = require('fs') + +// Monitor a status file (e.g. /etc/real_time_status) periodically and close the +// service if the file contents don't contain the matching deployment colour. + +const FILE_CHECK_INTERVAL = 5000 +const statusFile = settings.deploymentFile +const deploymentColour = settings.deploymentColour + +let serviceCloseTime + +function updateDeploymentStatus(fileContent) { + const closed = fileContent && !fileContent.includes(deploymentColour) + if (closed && !settings.serviceIsClosed) { + settings.serviceIsClosed = true + serviceCloseTime = Date.now() + 60 * 1000 // delay closing by 1 minute + logger.warn({ fileContent }, 'closing service') + } else if (!closed && settings.serviceIsClosed) { + settings.serviceIsClosed = false + logger.warn({ fileContent }, 'opening service') + } +} + +function pollStatusFile() { + fs.readFile(statusFile, { encoding: 'utf8' }, (err, fileContent) => { + if (err) { + logger.error( + { file: statusFile, fsErr: err }, + 'error reading service status file' + ) + return + } + updateDeploymentStatus(fileContent) + }) +} + +function checkStatusFileSync() { + // crash on start up if file does not exist + const content = fs.readFileSync(statusFile, { encoding: 'utf8' }) + updateDeploymentStatus(content) +} + +module.exports = { + initialise() { + if (statusFile && deploymentColour) { + logger.log( + { statusFile, deploymentColour, interval: FILE_CHECK_INTERVAL }, + 'monitoring deployment status file' + ) + checkStatusFileSync() // perform an initial synchronous check at start up + setInterval(pollStatusFile, FILE_CHECK_INTERVAL) // continue checking periodically + } + }, + deploymentIsClosed() { + return settings.serviceIsClosed && Date.now() > serviceCloseTime + }, +} diff --git a/services/real-time/app/js/DocumentUpdaterController.js b/services/real-time/app/js/DocumentUpdaterController.js new file mode 100644 index 0000000000..290882c41f --- /dev/null +++ b/services/real-time/app/js/DocumentUpdaterController.js @@ -0,0 +1,176 @@ +/* eslint-disable + camelcase, +*/ +const logger = require('logger-sharelatex') +const settings = require('@overleaf/settings') +const RedisClientManager = require('./RedisClientManager') +const SafeJsonParse = require('./SafeJsonParse') +const EventLogger = require('./EventLogger') +const HealthCheckManager = require('./HealthCheckManager') +const RoomManager = require('./RoomManager') +const ChannelManager = require('./ChannelManager') +const metrics = require('@overleaf/metrics') + +let DocumentUpdaterController +module.exports = DocumentUpdaterController = { + // DocumentUpdaterController is responsible for updates that come via Redis + // Pub/Sub from the document updater. + rclientList: RedisClientManager.createClientList(settings.redis.pubsub), + + listenForUpdatesFromDocumentUpdater(io) { + logger.log( + { rclients: this.rclientList.length }, + 'listening for applied-ops events' + ) + for (const rclient of this.rclientList) { + rclient.subscribe('applied-ops') + rclient.on('message', function (channel, message) { + metrics.inc('rclient', 0.001) // global event rate metric + if (settings.debugEvents > 0) { + EventLogger.debugEvent(channel, message) + } + DocumentUpdaterController._processMessageFromDocumentUpdater( + io, + channel, + message + ) + }) + } + // create metrics for each redis instance only when we have multiple redis clients + if (this.rclientList.length > 1) { + this.rclientList.forEach((rclient, i) => { + // per client event rate metric + const metricName = `rclient-${i}` + rclient.on('message', () => metrics.inc(metricName, 0.001)) + }) + } + this.handleRoomUpdates(this.rclientList) + }, + + handleRoomUpdates(rclientSubList) { + const roomEvents = RoomManager.eventSource() + roomEvents.on('doc-active', function (doc_id) { + const subscribePromises = rclientSubList.map(rclient => + ChannelManager.subscribe(rclient, 'applied-ops', doc_id) + ) + RoomManager.emitOnCompletion( + subscribePromises, + `doc-subscribed-${doc_id}` + ) + }) + roomEvents.on('doc-empty', doc_id => + rclientSubList.map(rclient => + ChannelManager.unsubscribe(rclient, 'applied-ops', doc_id) + ) + ) + }, + + _processMessageFromDocumentUpdater(io, channel, message) { + SafeJsonParse.parse(message, function (error, message) { + if (error) { + logger.error({ err: error, channel }, 'error parsing JSON') + return + } + if (message.op) { + if (message._id && settings.checkEventOrder) { + const status = EventLogger.checkEventOrder( + 'applied-ops', + message._id, + message + ) + if (status === 'duplicate') { + return // skip duplicate events + } + } + DocumentUpdaterController._applyUpdateFromDocumentUpdater( + io, + message.doc_id, + message.op + ) + } else if (message.error) { + DocumentUpdaterController._processErrorFromDocumentUpdater( + io, + message.doc_id, + message.error, + message + ) + } else if (message.health_check) { + logger.debug( + { message }, + 'got health check message in applied ops channel' + ) + HealthCheckManager.check(channel, message.key) + } + }) + }, + + _applyUpdateFromDocumentUpdater(io, doc_id, update) { + let client + const clientList = io.sockets.clients(doc_id) + // avoid unnecessary work if no clients are connected + if (clientList.length === 0) { + return + } + // send updates to clients + logger.log( + { + doc_id, + version: update.v, + source: update.meta && update.meta.source, + socketIoClients: clientList.map(client => client.id), + }, + 'distributing updates to clients' + ) + const seen = {} + // send messages only to unique clients (due to duplicate entries in io.sockets.clients) + for (client of clientList) { + if (!seen[client.id]) { + seen[client.id] = true + if (client.publicId === update.meta.source) { + logger.log( + { + doc_id, + version: update.v, + source: update.meta.source, + }, + 'distributing update to sender' + ) + client.emit('otUpdateApplied', { v: update.v, doc: update.doc }) + } else if (!update.dup) { + // Duplicate ops should just be sent back to sending client for acknowledgement + logger.log( + { + doc_id, + version: update.v, + source: update.meta.source, + client_id: client.id, + }, + 'distributing update to collaborator' + ) + client.emit('otUpdateApplied', update) + } + } + } + if (Object.keys(seen).length < clientList.length) { + metrics.inc('socket-io.duplicate-clients', 0.1) + logger.log( + { + doc_id, + socketIoClients: clientList.map(client => client.id), + }, + 'discarded duplicate clients' + ) + } + }, + + _processErrorFromDocumentUpdater(io, doc_id, error, message) { + for (const client of io.sockets.clients(doc_id)) { + logger.warn( + { err: error, doc_id, client_id: client.id }, + 'error from document updater, disconnecting client' + ) + client.emit('otUpdateError', error, message) + client.disconnect() + } + }, +} diff --git a/services/real-time/app/js/DocumentUpdaterManager.js b/services/real-time/app/js/DocumentUpdaterManager.js new file mode 100644 index 0000000000..9f01ed73e3 --- /dev/null +++ b/services/real-time/app/js/DocumentUpdaterManager.js @@ -0,0 +1,150 @@ +/* eslint-disable + camelcase, +*/ +const request = require('request') +const _ = require('underscore') +const OError = require('@overleaf/o-error') +const logger = require('logger-sharelatex') +const settings = require('@overleaf/settings') +const metrics = require('@overleaf/metrics') +const { + ClientRequestedMissingOpsError, + DocumentUpdaterRequestFailedError, + NullBytesInOpError, + UpdateTooLargeError, +} = require('./Errors') + +const rclient = require('@overleaf/redis-wrapper').createClient( + settings.redis.documentupdater +) +const Keys = settings.redis.documentupdater.key_schema + +const DocumentUpdaterManager = { + getDocument(project_id, doc_id, fromVersion, callback) { + const timer = new metrics.Timer('get-document') + const url = `${settings.apis.documentupdater.url}/project/${project_id}/doc/${doc_id}?fromVersion=${fromVersion}` + logger.log( + { project_id, doc_id, fromVersion }, + 'getting doc from document updater' + ) + request.get(url, function (err, res, body) { + timer.done() + if (err) { + OError.tag(err, 'error getting doc from doc updater') + return callback(err) + } + if (res.statusCode >= 200 && res.statusCode < 300) { + logger.log( + { project_id, doc_id }, + 'got doc from document document updater' + ) + try { + body = JSON.parse(body) + } catch (error) { + OError.tag(error, 'error parsing doc updater response') + return callback(error) + } + body = body || {} + callback(null, body.lines, body.version, body.ranges, body.ops) + } else if ([404, 422].includes(res.statusCode)) { + callback(new ClientRequestedMissingOpsError(res.statusCode)) + } else { + callback( + new DocumentUpdaterRequestFailedError('getDocument', res.statusCode) + ) + } + }) + }, + + checkDocument(project_id, doc_id, callback) { + // in this call fromVersion = -1 means get document without docOps + DocumentUpdaterManager.getDocument(project_id, doc_id, -1, callback) + }, + + flushProjectToMongoAndDelete(project_id, callback) { + // this method is called when the last connected user leaves the project + logger.log({ project_id }, 'deleting project from document updater') + const timer = new metrics.Timer('delete.mongo.project') + // flush the project in the background when all users have left + const url = + `${settings.apis.documentupdater.url}/project/${project_id}?background=true` + + (settings.shutDownInProgress ? '&shutdown=true' : '') + request.del(url, function (err, res) { + timer.done() + if (err) { + OError.tag(err, 'error deleting project from document updater') + callback(err) + } else if (res.statusCode >= 200 && res.statusCode < 300) { + logger.log({ project_id }, 'deleted project from document updater') + callback(null) + } else { + callback( + new DocumentUpdaterRequestFailedError( + 'flushProjectToMongoAndDelete', + res.statusCode + ) + ) + } + }) + }, + + _getPendingUpdateListKey() { + const shard = _.random(0, settings.pendingUpdateListShardCount - 1) + if (shard === 0) { + return 'pending-updates-list' + } else { + return `pending-updates-list-${shard}` + } + }, + + queueChange(project_id, doc_id, change, callback) { + const allowedKeys = [ + 'doc', + 'op', + 'v', + 'dupIfSource', + 'meta', + 'lastV', + 'hash', + ] + change = _.pick(change, allowedKeys) + const jsonChange = JSON.stringify(change) + if (jsonChange.indexOf('\u0000') !== -1) { + // memory corruption check + return callback(new NullBytesInOpError(jsonChange)) + } + + const updateSize = jsonChange.length + if (updateSize > settings.maxUpdateSize) { + return callback(new UpdateTooLargeError(updateSize)) + } + + // record metric for each update added to queue + metrics.summary('redis.pendingUpdates', updateSize, { status: 'push' }) + + const doc_key = `${project_id}:${doc_id}` + // Push onto pendingUpdates for doc_id first, because once the doc updater + // gets an entry on pending-updates-list, it starts processing. + rclient.rpush( + Keys.pendingUpdates({ doc_id }), + jsonChange, + function (error) { + if (error) { + error = new OError('error pushing update into redis').withCause(error) + return callback(error) + } + const queueKey = DocumentUpdaterManager._getPendingUpdateListKey() + rclient.rpush(queueKey, doc_key, function (error) { + if (error) { + error = new OError('error pushing doc_id into redis') + .withInfo({ queueKey }) + .withCause(error) + } + callback(error) + }) + } + ) + }, +} + +module.exports = DocumentUpdaterManager diff --git a/services/real-time/app/js/DrainManager.js b/services/real-time/app/js/DrainManager.js new file mode 100644 index 0000000000..5f9c192afd --- /dev/null +++ b/services/real-time/app/js/DrainManager.js @@ -0,0 +1,59 @@ +const logger = require('logger-sharelatex') + +module.exports = { + startDrainTimeWindow(io, minsToDrain, callback) { + const drainPerMin = io.sockets.clients().length / minsToDrain + // enforce minimum drain rate + this.startDrain(io, Math.max(drainPerMin / 60, 4), callback) + }, + + startDrain(io, rate, callback) { + // Clear out any old interval + clearInterval(this.interval) + logger.log({ rate }, 'starting drain') + if (rate === 0) { + return + } + let pollingInterval + if (rate < 1) { + // allow lower drain rates + // e.g. rate=0.1 will drain one client every 10 seconds + pollingInterval = 1000 / rate + rate = 1 + } else { + pollingInterval = 1000 + } + this.interval = setInterval(() => { + const requestedAllClientsToReconnect = this.reconnectNClients(io, rate) + if (requestedAllClientsToReconnect && callback) { + callback() + callback = undefined + } + }, pollingInterval) + }, + + RECONNECTED_CLIENTS: {}, + reconnectNClients(io, N) { + let drainedCount = 0 + for (const client of io.sockets.clients()) { + if (!this.RECONNECTED_CLIENTS[client.id]) { + this.RECONNECTED_CLIENTS[client.id] = true + logger.log( + { client_id: client.id }, + 'Asking client to reconnect gracefully' + ) + client.emit('reconnectGracefully') + drainedCount++ + } + const haveDrainedNClients = drainedCount === N + if (haveDrainedNClients) { + break + } + } + if (drainedCount < N) { + logger.log('All clients have been told to reconnectGracefully') + return true + } + return false + }, +} diff --git a/services/real-time/app/js/Errors.js b/services/real-time/app/js/Errors.js new file mode 100644 index 0000000000..a5177be737 --- /dev/null +++ b/services/real-time/app/js/Errors.js @@ -0,0 +1,103 @@ +const OError = require('@overleaf/o-error') + +class ClientRequestedMissingOpsError extends OError { + constructor(statusCode) { + super('doc updater could not load requested ops', { + statusCode, + }) + } +} + +class CodedError extends OError { + constructor(message, code) { + super(message, { code }) + } +} + +class CorruptedJoinProjectResponseError extends OError { + constructor() { + super('no data returned from joinProject request') + } +} + +class DataTooLargeToParseError extends OError { + constructor(data) { + super('data too large to parse', { + head: data.slice(0, 1024), + length: data.length, + }) + } +} + +class DocumentUpdaterRequestFailedError extends OError { + constructor(action, statusCode) { + super('doc updater returned a non-success status code', { + action, + statusCode, + }) + } +} + +class JoinLeaveEpochMismatchError extends OError { + constructor() { + super('joinLeaveEpoch mismatch') + } +} + +class MissingSessionError extends OError { + constructor() { + super('could not look up session by key') + } +} + +class NotAuthorizedError extends OError { + constructor() { + super('not authorized') + } +} + +class NotJoinedError extends OError { + constructor() { + super('no project_id found on client') + } +} + +class NullBytesInOpError extends OError { + constructor(jsonChange) { + super('null bytes found in op', { jsonChange }) + } +} + +class UnexpectedArgumentsError extends OError { + constructor() { + super('unexpected arguments') + } +} + +class UpdateTooLargeError extends OError { + constructor(updateSize) { + super('update is too large', { updateSize }) + } +} + +class WebApiRequestFailedError extends OError { + constructor(statusCode) { + super('non-success status code from web', { statusCode }) + } +} + +module.exports = { + CodedError, + CorruptedJoinProjectResponseError, + ClientRequestedMissingOpsError, + DataTooLargeToParseError, + DocumentUpdaterRequestFailedError, + JoinLeaveEpochMismatchError, + MissingSessionError, + NotAuthorizedError, + NotJoinedError, + NullBytesInOpError, + UnexpectedArgumentsError, + UpdateTooLargeError, + WebApiRequestFailedError, +} diff --git a/services/real-time/app/js/EventLogger.js b/services/real-time/app/js/EventLogger.js new file mode 100644 index 0000000000..91f63e143c --- /dev/null +++ b/services/real-time/app/js/EventLogger.js @@ -0,0 +1,84 @@ +/* eslint-disable + camelcase, +*/ +let EventLogger +const logger = require('logger-sharelatex') +const metrics = require('@overleaf/metrics') +const settings = require('@overleaf/settings') + +// keep track of message counters to detect duplicate and out of order events +// messsage ids have the format "UNIQUEHOSTKEY-COUNTER" + +const EVENT_LOG_COUNTER = {} +const EVENT_LOG_TIMESTAMP = {} +let EVENT_LAST_CLEAN_TIMESTAMP = 0 + +// counter for debug logs +let COUNTER = 0 + +module.exports = EventLogger = { + MAX_STALE_TIME_IN_MS: 3600 * 1000, + + debugEvent(channel, message) { + if (settings.debugEvents > 0) { + logger.log({ channel, message, counter: COUNTER++ }, 'logging event') + settings.debugEvents-- + } + }, + + checkEventOrder(channel, message_id) { + if (typeof message_id !== 'string') { + return + } + let result + if (!(result = message_id.match(/^(.*)-(\d+)$/))) { + return + } + const key = result[1] + const count = parseInt(result[2], 0) + if (!(count >= 0)) { + // ignore checks if counter is not present + return + } + // store the last count in a hash for each host + const previous = EventLogger._storeEventCount(key, count) + if (!previous || count === previous + 1) { + metrics.inc(`event.${channel}.valid`) + return // order is ok + } + if (count === previous) { + metrics.inc(`event.${channel}.duplicate`) + logger.warn({ channel, message_id }, 'duplicate event') + return 'duplicate' + } else { + metrics.inc(`event.${channel}.out-of-order`) + logger.warn( + { channel, message_id, key, previous, count }, + 'out of order event' + ) + return 'out-of-order' + } + }, + + _storeEventCount(key, count) { + const previous = EVENT_LOG_COUNTER[key] + const now = Date.now() + EVENT_LOG_COUNTER[key] = count + EVENT_LOG_TIMESTAMP[key] = now + // periodically remove old counts + if (now - EVENT_LAST_CLEAN_TIMESTAMP > EventLogger.MAX_STALE_TIME_IN_MS) { + EventLogger._cleanEventStream(now) + EVENT_LAST_CLEAN_TIMESTAMP = now + } + return previous + }, + + _cleanEventStream(now) { + Object.entries(EVENT_LOG_TIMESTAMP).forEach(([key, timestamp]) => { + if (now - timestamp > EventLogger.MAX_STALE_TIME_IN_MS) { + delete EVENT_LOG_COUNTER[key] + delete EVENT_LOG_TIMESTAMP[key] + } + }) + }, +} diff --git a/services/real-time/app/js/HealthCheckManager.js b/services/real-time/app/js/HealthCheckManager.js new file mode 100644 index 0000000000..f521dd9fa0 --- /dev/null +++ b/services/real-time/app/js/HealthCheckManager.js @@ -0,0 +1,77 @@ +const metrics = require('@overleaf/metrics') +const logger = require('logger-sharelatex') + +const os = require('os') +const HOST = os.hostname() +const PID = process.pid +let COUNT = 0 + +const CHANNEL_MANAGER = {} // hash of event checkers by channel name +const CHANNEL_ERROR = {} // error status by channel name + +module.exports = class HealthCheckManager { + // create an instance of this class which checks that an event with a unique + // id is received only once within a timeout + constructor(channel, timeout) { + // unique event string + this.channel = channel + this.id = `host=${HOST}:pid=${PID}:count=${COUNT++}` + // count of number of times the event is received + this.count = 0 + // after a timeout check the status of the count + this.handler = setTimeout(() => { + this.setStatus() + }, timeout || 1000) + // use a timer to record the latency of the channel + this.timer = new metrics.Timer(`event.${this.channel}.latency`) + // keep a record of these objects to dispatch on + CHANNEL_MANAGER[this.channel] = this + } + + processEvent(id) { + // if this is our event record it + if (id === this.id) { + this.count++ + if (this.timer) { + this.timer.done() + } + this.timer = undefined // only time the latency of the first event + } + } + + setStatus() { + // if we saw the event anything other than a single time that is an error + const isFailing = this.count !== 1 + if (isFailing) { + logger.err( + { channel: this.channel, count: this.count, id: this.id }, + 'redis channel health check error' + ) + } + CHANNEL_ERROR[this.channel] = isFailing + } + + // class methods + static check(channel, id) { + // dispatch event to manager for channel + if (CHANNEL_MANAGER[channel]) { + CHANNEL_MANAGER[channel].processEvent(id) + } + } + + static status() { + // return status of all channels for logging + return CHANNEL_ERROR + } + + static isFailing() { + // check if any channel status is bad + for (const channel in CHANNEL_ERROR) { + const error = CHANNEL_ERROR[channel] + if (error === true) { + return true + } + } + return false + } +} diff --git a/services/real-time/app/js/HttpApiController.js b/services/real-time/app/js/HttpApiController.js new file mode 100644 index 0000000000..625bbbf739 --- /dev/null +++ b/services/real-time/app/js/HttpApiController.js @@ -0,0 +1,52 @@ +/* eslint-disable + camelcase, +*/ +const WebsocketLoadBalancer = require('./WebsocketLoadBalancer') +const DrainManager = require('./DrainManager') +const logger = require('logger-sharelatex') + +module.exports = { + sendMessage(req, res) { + logger.log({ message: req.params.message }, 'sending message') + if (Array.isArray(req.body)) { + for (const payload of req.body) { + WebsocketLoadBalancer.emitToRoom( + req.params.project_id, + req.params.message, + payload + ) + } + } else { + WebsocketLoadBalancer.emitToRoom( + req.params.project_id, + req.params.message, + req.body + ) + } + res.sendStatus(204) + }, + + startDrain(req, res) { + const io = req.app.get('io') + let rate = req.query.rate || '4' + rate = parseFloat(rate) || 0 + logger.log({ rate }, 'setting client drain rate') + DrainManager.startDrain(io, rate) + res.sendStatus(204) + }, + + disconnectClient(req, res, next) { + const io = req.app.get('io') + const { client_id } = req.params + const client = io.sockets.sockets[client_id] + + if (!client) { + logger.info({ client_id }, 'api: client already disconnected') + res.sendStatus(404) + return + } + logger.warn({ client_id }, 'api: requesting client disconnect') + client.on('disconnect', () => res.sendStatus(204)) + client.disconnect() + }, +} diff --git a/services/real-time/app/js/HttpController.js b/services/real-time/app/js/HttpController.js new file mode 100644 index 0000000000..6e9d48cf63 --- /dev/null +++ b/services/real-time/app/js/HttpController.js @@ -0,0 +1,57 @@ +/* eslint-disable + camelcase, +*/ + +let HttpController +module.exports = HttpController = { + // The code in this controller is hard to unit test because of a lot of + // dependencies on internal socket.io methods. It is not critical to the running + // of ShareLaTeX, and is only used for getting stats about connected clients, + // and for checking internal state in acceptance tests. The acceptances tests + // should provide appropriate coverage. + _getConnectedClientView(ioClient) { + const client_id = ioClient.id + const { + project_id, + user_id, + first_name, + last_name, + email, + connected_time, + } = ioClient.ol_context + const client = { + client_id, + project_id, + user_id, + first_name, + last_name, + email, + connected_time, + } + client.rooms = Object.keys(ioClient.manager.roomClients[client_id] || {}) + // drop the namespace + .filter(room => room !== '') + // room names are composed as '/' and the default + // namespace is empty (see comments in RoomManager), just drop the '/' + .map(fullRoomPath => fullRoomPath.slice(1)) + return client + }, + + getConnectedClients(req, res) { + const io = req.app.get('io') + const ioClients = io.sockets.clients() + + res.json(ioClients.map(HttpController._getConnectedClientView)) + }, + + getConnectedClient(req, res) { + const { client_id } = req.params + const io = req.app.get('io') + const ioClient = io.sockets.sockets[client_id] + if (!ioClient) { + res.sendStatus(404) + return + } + res.json(HttpController._getConnectedClientView(ioClient)) + }, +} diff --git a/services/real-time/app/js/RedisClientManager.js b/services/real-time/app/js/RedisClientManager.js new file mode 100644 index 0000000000..226bf91cfd --- /dev/null +++ b/services/real-time/app/js/RedisClientManager.js @@ -0,0 +1,19 @@ +const redis = require('@overleaf/redis-wrapper') +const logger = require('logger-sharelatex') + +module.exports = { + createClientList(...configs) { + // create a dynamic list of redis clients, excluding any configurations which are not defined + return configs.filter(Boolean).map(x => { + const redisType = x.cluster + ? 'cluster' + : x.sentinels + ? 'sentinel' + : x.host + ? 'single' + : 'unknown' + logger.log({ redis: redisType }, 'creating redis client') + return redis.createClient(x) + }) + }, +} diff --git a/services/real-time/app/js/RoomManager.js b/services/real-time/app/js/RoomManager.js new file mode 100644 index 0000000000..e030b60490 --- /dev/null +++ b/services/real-time/app/js/RoomManager.js @@ -0,0 +1,164 @@ +/* eslint-disable + camelcase, +*/ +const logger = require('logger-sharelatex') +const metrics = require('@overleaf/metrics') +const { EventEmitter } = require('events') +const OError = require('@overleaf/o-error') + +const IdMap = new Map() // keep track of whether ids are from projects or docs +const RoomEvents = new EventEmitter() // emits {project,doc}-active and {project,doc}-empty events + +// Manage socket.io rooms for individual projects and docs +// +// The first time someone joins a project or doc we emit a 'project-active' or +// 'doc-active' event. +// +// When the last person leaves a project or doc, we emit 'project-empty' or +// 'doc-empty' event. +// +// The pubsub side is handled by ChannelManager + +module.exports = { + joinProject(client, project_id, callback) { + this.joinEntity(client, 'project', project_id, callback) + }, + + joinDoc(client, doc_id, callback) { + this.joinEntity(client, 'doc', doc_id, callback) + }, + + leaveDoc(client, doc_id) { + this.leaveEntity(client, 'doc', doc_id) + }, + + leaveProjectAndDocs(client) { + // what rooms is this client in? we need to leave them all. socket.io + // will cause us to leave the rooms, so we only need to manage our + // channel subscriptions... but it will be safer if we leave them + // explicitly, and then socket.io will just regard this as a client that + // has not joined any rooms and do a final disconnection. + const roomsToLeave = this._roomsClientIsIn(client) + logger.log({ client: client.id, roomsToLeave }, 'client leaving project') + for (const id of roomsToLeave) { + const entity = IdMap.get(id) + this.leaveEntity(client, entity, id) + } + }, + + emitOnCompletion(promiseList, eventName) { + Promise.all(promiseList) + .then(() => RoomEvents.emit(eventName)) + .catch(err => RoomEvents.emit(eventName, err)) + }, + + eventSource() { + return RoomEvents + }, + + joinEntity(client, entity, id, callback) { + const beforeCount = this._clientsInRoom(client, id) + // client joins room immediately but joinDoc request does not complete + // until room is subscribed + client.join(id) + // is this a new room? if so, subscribe + if (beforeCount === 0) { + logger.log({ entity, id }, 'room is now active') + RoomEvents.once(`${entity}-subscribed-${id}`, function (err) { + // only allow the client to join when all the relevant channels have subscribed + if (err) { + OError.tag(err, 'error joining', { entity, id }) + return callback(err) + } + logger.log( + { client: client.id, entity, id, beforeCount }, + 'client joined new room and subscribed to channel' + ) + callback(err) + }) + RoomEvents.emit(`${entity}-active`, id) + IdMap.set(id, entity) + // keep track of the number of listeners + metrics.gauge('room-listeners', RoomEvents.eventNames().length) + } else { + logger.log( + { client: client.id, entity, id, beforeCount }, + 'client joined existing room' + ) + callback() + } + }, + + leaveEntity(client, entity, id) { + // Ignore any requests to leave when the client is not actually in the + // room. This can happen if the client sends spurious leaveDoc requests + // for old docs after a reconnection. + // This can now happen all the time, as we skip the join for clients that + // disconnect before joinProject/joinDoc completed. + if (!this._clientAlreadyInRoom(client, id)) { + logger.log( + { client: client.id, entity, id }, + 'ignoring request from client to leave room it is not in' + ) + return + } + client.leave(id) + const afterCount = this._clientsInRoom(client, id) + logger.log( + { client: client.id, entity, id, afterCount }, + 'client left room' + ) + // is the room now empty? if so, unsubscribe + if (!entity) { + logger.error({ entity: id }, 'unknown entity when leaving with id') + return + } + if (afterCount === 0) { + logger.log({ entity, id }, 'room is now empty') + RoomEvents.emit(`${entity}-empty`, id) + IdMap.delete(id) + metrics.gauge('room-listeners', RoomEvents.eventNames().length) + } + }, + + // internal functions below, these access socket.io rooms data directly and + // will need updating for socket.io v2 + + // The below code makes some assumptions that are always true for v0 + // - we are using the base namespace '', so room names are '/' + // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/manager.js#L62 + // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/manager.js#L1018 + // - client.namespace is a Namespace + // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/namespace.js#L204 + // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/socket.js#L40 + // - client.manager is a Manager + // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/namespace.js#L204 + // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/socket.js#L41 + // - a Manager has + // - `.rooms={'NAMESPACE/ENTITY': []}` and + // - `.roomClients={'CLIENT_ID': {'...': true}}` + // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/manager.js#L287-L288 + // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/manager.js#L444-L455 + + _clientsInRoom(client, room) { + const clients = client.manager.rooms['/' + room] || [] + return clients.length + }, + + _roomsClientIsIn(client) { + const rooms = client.manager.roomClients[client.id] || {} + return ( + Object.keys(rooms) + // drop the namespace + .filter(room => room !== '') + // room names are composed as '/' and the default + // namespace is empty (see comments above), just drop the '/' + .map(fullRoomPath => fullRoomPath.slice(1)) + ) + }, + + _clientAlreadyInRoom(client, room) { + const rooms = client.manager.roomClients[client.id] || {} + return !!rooms['/' + room] + }, +} diff --git a/services/real-time/app/js/Router.js b/services/real-time/app/js/Router.js new file mode 100644 index 0000000000..eff8dc7b1a --- /dev/null +++ b/services/real-time/app/js/Router.js @@ -0,0 +1,374 @@ +/* eslint-disable + camelcase, +*/ +const metrics = require('@overleaf/metrics') +const logger = require('logger-sharelatex') +const settings = require('@overleaf/settings') +const WebsocketController = require('./WebsocketController') +const HttpController = require('./HttpController') +const HttpApiController = require('./HttpApiController') +const bodyParser = require('body-parser') +const base64id = require('base64id') +const { UnexpectedArgumentsError } = require('./Errors') + +const basicAuth = require('basic-auth-connect') +const httpAuth = basicAuth(function (user, pass) { + const isValid = + user === settings.internal.realTime.user && + pass === settings.internal.realTime.pass + if (!isValid) { + logger.err({ user, pass }, 'invalid login details') + } + return isValid +}) + +const HOSTNAME = require('os').hostname() + +let Router +module.exports = Router = { + _handleError(callback, error, client, method, attrs) { + attrs = attrs || {} + for (const key of ['project_id', 'user_id']) { + attrs[key] = attrs[key] || client.ol_context[key] + } + attrs.client_id = client.id + attrs.err = error + attrs.method = method + if (error.name === 'CodedError') { + logger.warn(attrs, error.message) + const serializedError = { message: error.message, code: error.info.code } + callback(serializedError) + } else if (error.message === 'unexpected arguments') { + // the payload might be very large, put it on level info + logger.log(attrs, 'unexpected arguments') + metrics.inc('unexpected-arguments', 1, { status: method }) + const serializedError = { message: error.message } + callback(serializedError) + } else if (error.message === 'no project_id found on client') { + logger.debug(attrs, error.message) + const serializedError = { message: error.message } + callback(serializedError) + } else if ( + [ + 'not authorized', + 'joinLeaveEpoch mismatch', + 'doc updater could not load requested ops', + ].includes(error.message) + ) { + logger.warn(attrs, error.message) + const serializedError = { message: error.message } + callback(serializedError) + } else { + logger.error(attrs, `server side error in ${method}`) + // Don't return raw error to prevent leaking server side info + const serializedError = { + message: 'Something went wrong in real-time service', + } + callback(serializedError) + } + }, + + _handleInvalidArguments(client, method, args) { + const error = new UnexpectedArgumentsError() + let callback = args[args.length - 1] + if (typeof callback !== 'function') { + callback = function () {} + } + const attrs = { arguments: args } + Router._handleError(callback, error, client, method, attrs) + }, + + configure(app, io, session) { + app.set('io', io) + app.get('/clients', HttpController.getConnectedClients) + app.get('/clients/:client_id', HttpController.getConnectedClient) + + app.post( + '/project/:project_id/message/:message', + httpAuth, + bodyParser.json({ limit: '5mb' }), + HttpApiController.sendMessage + ) + + app.post('/drain', httpAuth, HttpApiController.startDrain) + app.post( + '/client/:client_id/disconnect', + httpAuth, + HttpApiController.disconnectClient + ) + + session.on('connection', function (error, client, session) { + // init client context, we may access it in Router._handleError before + // setting any values + client.ol_context = {} + // bail out from joinDoc when a parallel joinDoc or leaveDoc is running + client.joinLeaveEpoch = 0 + + if (client) { + client.on('error', function (err) { + logger.err({ clientErr: err }, 'socket.io client error') + if (client.connected) { + client.emit('reconnectGracefully') + client.disconnect() + } + }) + } + + if (settings.shutDownInProgress) { + client.emit('connectionRejected', { message: 'retry' }) + client.disconnect() + return + } + + if ( + client && + error && + error.message.match(/could not look up session by key/) + ) { + logger.warn( + { err: error, client: !!client, session: !!session }, + 'invalid session' + ) + // tell the client to reauthenticate if it has an invalid session key + client.emit('connectionRejected', { message: 'invalid session' }) + client.disconnect() + return + } + + if (error) { + logger.err( + { err: error, client: !!client, session: !!session }, + 'error when client connected' + ) + if (client) { + client.emit('connectionRejected', { message: 'error' }) + } + if (client) { + client.disconnect() + } + return + } + + // send positive confirmation that the client has a valid connection + client.publicId = 'P.' + base64id.generateId() + client.emit('connectionAccepted', null, client.publicId) + + metrics.inc('socket-io.connection', 1, { status: client.transport }) + metrics.gauge('socket-io.clients', io.sockets.clients().length) + + logger.log({ session, client_id: client.id }, 'client connected') + + let user + if (session && session.passport && session.passport.user) { + ;({ user } = session.passport) + } else if (session && session.user) { + ;({ user } = session) + } else { + user = { _id: 'anonymous-user' } + } + + if (settings.exposeHostname) { + client.on('debug.getHostname', function (callback) { + if (typeof callback !== 'function') { + return Router._handleInvalidArguments( + client, + 'debug.getHostname', + arguments + ) + } + callback(HOSTNAME) + }) + } + + client.on('joinProject', function (data, callback) { + data = data || {} + if (typeof callback !== 'function') { + return Router._handleInvalidArguments( + client, + 'joinProject', + arguments + ) + } + + if (data.anonymousAccessToken) { + user.anonymousAccessToken = data.anonymousAccessToken + } + WebsocketController.joinProject( + client, + user, + data.project_id, + function (err, ...args) { + if (err) { + Router._handleError(callback, err, client, 'joinProject', { + project_id: data.project_id, + user_id: user._id, + }) + } else { + callback(null, ...args) + } + } + ) + }) + + client.on('disconnect', function () { + metrics.inc('socket-io.disconnect', 1, { status: client.transport }) + metrics.gauge('socket-io.clients', io.sockets.clients().length) + + WebsocketController.leaveProject(io, client, function (err) { + if (err) { + Router._handleError(function () {}, err, client, 'leaveProject') + } + }) + }) + + // Variadic. The possible arguments: + // doc_id, callback + // doc_id, fromVersion, callback + // doc_id, options, callback + // doc_id, fromVersion, options, callback + client.on('joinDoc', function (doc_id, fromVersion, options, callback) { + if (typeof fromVersion === 'function' && !options) { + callback = fromVersion + fromVersion = -1 + options = {} + } else if ( + typeof fromVersion === 'number' && + typeof options === 'function' + ) { + callback = options + options = {} + } else if ( + typeof fromVersion === 'object' && + typeof options === 'function' + ) { + callback = options + options = fromVersion + fromVersion = -1 + } else if ( + typeof fromVersion === 'number' && + typeof options === 'object' && + typeof callback === 'function' + ) { + // Called with 4 args, things are as expected + } else { + return Router._handleInvalidArguments(client, 'joinDoc', arguments) + } + + WebsocketController.joinDoc( + client, + doc_id, + fromVersion, + options, + function (err, ...args) { + if (err) { + Router._handleError(callback, err, client, 'joinDoc', { + doc_id, + fromVersion, + }) + } else { + callback(null, ...args) + } + } + ) + }) + + client.on('leaveDoc', function (doc_id, callback) { + if (typeof callback !== 'function') { + return Router._handleInvalidArguments(client, 'leaveDoc', arguments) + } + + WebsocketController.leaveDoc(client, doc_id, function (err, ...args) { + if (err) { + Router._handleError(callback, err, client, 'leaveDoc', { + doc_id, + }) + } else { + callback(null, ...args) + } + }) + }) + + client.on('clientTracking.getConnectedUsers', function (callback) { + if (typeof callback !== 'function') { + return Router._handleInvalidArguments( + client, + 'clientTracking.getConnectedUsers', + arguments + ) + } + + WebsocketController.getConnectedUsers(client, function (err, users) { + if (err) { + Router._handleError( + callback, + err, + client, + 'clientTracking.getConnectedUsers' + ) + } else { + callback(null, users) + } + }) + }) + + client.on( + 'clientTracking.updatePosition', + function (cursorData, callback) { + if (!callback) { + callback = function () {} + } + if (typeof callback !== 'function') { + return Router._handleInvalidArguments( + client, + 'clientTracking.updatePosition', + arguments + ) + } + + WebsocketController.updateClientPosition( + client, + cursorData, + function (err) { + if (err) { + Router._handleError( + callback, + err, + client, + 'clientTracking.updatePosition' + ) + } else { + callback() + } + } + ) + } + ) + + client.on('applyOtUpdate', function (doc_id, update, callback) { + if (typeof callback !== 'function') { + return Router._handleInvalidArguments( + client, + 'applyOtUpdate', + arguments + ) + } + + WebsocketController.applyOtUpdate( + client, + doc_id, + update, + function (err) { + if (err) { + Router._handleError(callback, err, client, 'applyOtUpdate', { + doc_id, + update, + }) + } else { + callback() + } + } + ) + }) + }) + }, +} diff --git a/services/real-time/app/js/SafeJsonParse.js b/services/real-time/app/js/SafeJsonParse.js new file mode 100644 index 0000000000..bc7a6bed10 --- /dev/null +++ b/services/real-time/app/js/SafeJsonParse.js @@ -0,0 +1,17 @@ +const Settings = require('@overleaf/settings') +const { DataTooLargeToParseError } = require('./Errors') + +module.exports = { + parse(data, callback) { + if (data.length > Settings.maxUpdateSize) { + return callback(new DataTooLargeToParseError(data)) + } + let parsed + try { + parsed = JSON.parse(data) + } catch (e) { + return callback(e) + } + callback(null, parsed) + }, +} diff --git a/services/real-time/app/js/SessionSockets.js b/services/real-time/app/js/SessionSockets.js new file mode 100644 index 0000000000..b8ccde2ea8 --- /dev/null +++ b/services/real-time/app/js/SessionSockets.js @@ -0,0 +1,36 @@ +const OError = require('@overleaf/o-error') +const { EventEmitter } = require('events') +const { MissingSessionError } = require('./Errors') + +module.exports = function (io, sessionStore, cookieParser, cookieName) { + const missingSessionError = new MissingSessionError() + + const sessionSockets = new EventEmitter() + function next(error, socket, session) { + sessionSockets.emit('connection', error, socket, session) + } + + io.on('connection', function (socket) { + const req = socket.handshake + cookieParser(req, {}, function () { + const sessionId = req.signedCookies && req.signedCookies[cookieName] + if (!sessionId) { + return next(missingSessionError, socket) + } + sessionStore.get(sessionId, function (error, session) { + if (error) { + OError.tag(error, 'error getting session from sessionStore', { + sessionId, + }) + return next(error, socket) + } + if (!session) { + return next(missingSessionError, socket) + } + next(null, socket, session) + }) + }) + }) + + return sessionSockets +} diff --git a/services/real-time/app/js/WebApiManager.js b/services/real-time/app/js/WebApiManager.js new file mode 100644 index 0000000000..a51610ee77 --- /dev/null +++ b/services/real-time/app/js/WebApiManager.js @@ -0,0 +1,69 @@ +/* eslint-disable + camelcase, +*/ +const request = require('request') +const OError = require('@overleaf/o-error') +const settings = require('@overleaf/settings') +const logger = require('logger-sharelatex') +const { + CodedError, + CorruptedJoinProjectResponseError, + NotAuthorizedError, + WebApiRequestFailedError, +} = require('./Errors') + +module.exports = { + joinProject(project_id, user, callback) { + const user_id = user._id + logger.log({ project_id, user_id }, 'sending join project request to web') + const url = `${settings.apis.web.url}/project/${project_id}/join` + const headers = {} + if (user.anonymousAccessToken) { + headers['x-sl-anonymous-access-token'] = user.anonymousAccessToken + } + request.post( + { + url, + qs: { user_id }, + auth: { + user: settings.apis.web.user, + pass: settings.apis.web.pass, + sendImmediately: true, + }, + json: true, + jar: false, + headers, + }, + function (error, response, data) { + if (error) { + OError.tag(error, 'join project request failed') + return callback(error) + } + if (response.statusCode >= 200 && response.statusCode < 300) { + if (!(data && data.project)) { + return callback(new CorruptedJoinProjectResponseError()) + } + callback( + null, + data.project, + data.privilegeLevel, + data.isRestrictedUser + ) + } else if (response.statusCode === 429) { + callback( + new CodedError( + 'rate-limit hit when joining project', + 'TooManyRequests' + ) + ) + } else if (response.statusCode === 403) { + callback(new NotAuthorizedError()) + } else if (response.statusCode === 404) { + callback(new CodedError('project not found', 'ProjectNotFound')) + } else { + callback(new WebApiRequestFailedError(response.statusCode)) + } + } + ) + }, +} diff --git a/services/real-time/app/js/WebsocketController.js b/services/real-time/app/js/WebsocketController.js new file mode 100644 index 0000000000..c1ea8d232c --- /dev/null +++ b/services/real-time/app/js/WebsocketController.js @@ -0,0 +1,573 @@ +/* eslint-disable + camelcase, +*/ +const OError = require('@overleaf/o-error') +const logger = require('logger-sharelatex') +const metrics = require('@overleaf/metrics') +const WebApiManager = require('./WebApiManager') +const AuthorizationManager = require('./AuthorizationManager') +const DocumentUpdaterManager = require('./DocumentUpdaterManager') +const ConnectedUsersManager = require('./ConnectedUsersManager') +const WebsocketLoadBalancer = require('./WebsocketLoadBalancer') +const RoomManager = require('./RoomManager') +const { + JoinLeaveEpochMismatchError, + NotAuthorizedError, + NotJoinedError, +} = require('./Errors') + +let WebsocketController +module.exports = WebsocketController = { + // If the protocol version changes when the client reconnects, + // it will force a full refresh of the page. Useful for non-backwards + // compatible protocol changes. Use only in extreme need. + PROTOCOL_VERSION: 2, + + joinProject(client, user, project_id, callback) { + if (client.disconnected) { + metrics.inc('editor.join-project.disconnected', 1, { + status: 'immediately', + }) + return callback() + } + + const user_id = user._id + logger.log( + { user_id, project_id, client_id: client.id }, + 'user joining project' + ) + metrics.inc('editor.join-project', 1, { status: client.transport }) + WebApiManager.joinProject( + project_id, + user, + function (error, project, privilegeLevel, isRestrictedUser) { + if (error) { + return callback(error) + } + if (client.disconnected) { + metrics.inc('editor.join-project.disconnected', 1, { + status: 'after-web-api-call', + }) + return callback() + } + + if (!privilegeLevel) { + return callback(new NotAuthorizedError()) + } + + client.ol_context = {} + client.ol_context.privilege_level = privilegeLevel + client.ol_context.user_id = user_id + client.ol_context.project_id = project_id + client.ol_context.owner_id = project.owner && project.owner._id + client.ol_context.first_name = user.first_name + client.ol_context.last_name = user.last_name + client.ol_context.email = user.email + client.ol_context.connected_time = new Date() + client.ol_context.signup_date = user.signUpDate + client.ol_context.login_count = user.loginCount + client.ol_context.is_restricted_user = !!isRestrictedUser + + RoomManager.joinProject(client, project_id, function (err) { + if (err) { + return callback(err) + } + logger.log( + { user_id, project_id, client_id: client.id }, + 'user joined project' + ) + callback( + null, + project, + privilegeLevel, + WebsocketController.PROTOCOL_VERSION + ) + }) + + // No need to block for setting the user as connected in the cursor tracking + ConnectedUsersManager.updateUserPosition( + project_id, + client.publicId, + user, + null, + function (err) { + if (err) { + logger.warn( + { err, project_id, user_id, client_id: client.id }, + 'background cursor update failed' + ) + } + } + ) + } + ) + }, + + // We want to flush a project if there are no more (local) connected clients + // but we need to wait for the triggering client to disconnect. How long we wait + // is determined by FLUSH_IF_EMPTY_DELAY. + FLUSH_IF_EMPTY_DELAY: 500, // ms + leaveProject(io, client, callback) { + const { project_id, user_id } = client.ol_context + if (!project_id) { + return callback() + } // client did not join project + + metrics.inc('editor.leave-project', 1, { status: client.transport }) + logger.log( + { project_id, user_id, client_id: client.id }, + 'client leaving project' + ) + WebsocketLoadBalancer.emitToRoom( + project_id, + 'clientTracking.clientDisconnected', + client.publicId + ) + + // We can do this in the background + ConnectedUsersManager.markUserAsDisconnected( + project_id, + client.publicId, + function (err) { + if (err) { + logger.error( + { err, project_id, user_id, client_id: client.id }, + 'error marking client as disconnected' + ) + } + } + ) + + RoomManager.leaveProjectAndDocs(client) + setTimeout(function () { + const remainingClients = io.sockets.clients(project_id) + if (remainingClients.length === 0) { + // Flush project in the background + DocumentUpdaterManager.flushProjectToMongoAndDelete( + project_id, + function (err) { + if (err) { + logger.error( + { err, project_id, user_id, client_id: client.id }, + 'error flushing to doc updater after leaving project' + ) + } + } + ) + } + callback() + }, WebsocketController.FLUSH_IF_EMPTY_DELAY) + }, + + joinDoc(client, doc_id, fromVersion, options, callback) { + if (client.disconnected) { + metrics.inc('editor.join-doc.disconnected', 1, { status: 'immediately' }) + return callback() + } + + const joinLeaveEpoch = ++client.joinLeaveEpoch + metrics.inc('editor.join-doc', 1, { status: client.transport }) + const { project_id, user_id, is_restricted_user } = client.ol_context + if (!project_id) { + return callback(new NotJoinedError()) + } + logger.log( + { user_id, project_id, doc_id, fromVersion, client_id: client.id }, + 'client joining doc' + ) + + WebsocketController._assertClientAuthorization( + client, + doc_id, + function (error) { + if (error) { + return callback(error) + } + if (client.disconnected) { + metrics.inc('editor.join-doc.disconnected', 1, { + status: 'after-client-auth-check', + }) + // the client will not read the response anyways + return callback() + } + if (joinLeaveEpoch !== client.joinLeaveEpoch) { + // another joinDoc or leaveDoc rpc overtook us + return callback(new JoinLeaveEpochMismatchError()) + } + // ensure the per-doc applied-ops channel is subscribed before sending the + // doc to the client, so that no events are missed. + RoomManager.joinDoc(client, doc_id, function (error) { + if (error) { + return callback(error) + } + if (client.disconnected) { + metrics.inc('editor.join-doc.disconnected', 1, { + status: 'after-joining-room', + }) + // the client will not read the response anyways + return callback() + } + + DocumentUpdaterManager.getDocument( + project_id, + doc_id, + fromVersion, + function (error, lines, version, ranges, ops) { + if (error) { + return callback(error) + } + if (client.disconnected) { + metrics.inc('editor.join-doc.disconnected', 1, { + status: 'after-doc-updater-call', + }) + // the client will not read the response anyways + return callback() + } + + if (is_restricted_user && ranges && ranges.comments) { + ranges.comments = [] + } + + // Encode any binary bits of data so it can go via WebSockets + // See http://ecmanaut.blogspot.co.uk/2006/07/encoding-decoding-utf8-in-javascript.html + const encodeForWebsockets = text => + unescape(encodeURIComponent(text)) + const escapedLines = [] + for (let line of lines) { + try { + line = encodeForWebsockets(line) + } catch (err) { + OError.tag(err, 'error encoding line uri component', { line }) + return callback(err) + } + escapedLines.push(line) + } + if (options.encodeRanges) { + try { + for (const comment of (ranges && ranges.comments) || []) { + if (comment.op.c) { + comment.op.c = encodeForWebsockets(comment.op.c) + } + } + for (const change of (ranges && ranges.changes) || []) { + if (change.op.i) { + change.op.i = encodeForWebsockets(change.op.i) + } + if (change.op.d) { + change.op.d = encodeForWebsockets(change.op.d) + } + } + } catch (err) { + OError.tag(err, 'error encoding range uri component', { + ranges, + }) + return callback(err) + } + } + + AuthorizationManager.addAccessToDoc(client, doc_id, () => {}) + logger.log( + { + user_id, + project_id, + doc_id, + fromVersion, + client_id: client.id, + }, + 'client joined doc' + ) + callback(null, escapedLines, version, ops, ranges) + } + ) + }) + } + ) + }, + + _assertClientAuthorization(client, doc_id, callback) { + // Check for project-level access first + AuthorizationManager.assertClientCanViewProject(client, function (error) { + if (error) { + return callback(error) + } + // Check for doc-level access next + AuthorizationManager.assertClientCanViewProjectAndDoc( + client, + doc_id, + function (error) { + if (error) { + // No cached access, check docupdater + const { project_id } = client.ol_context + DocumentUpdaterManager.checkDocument( + project_id, + doc_id, + function (error) { + if (error) { + return callback(error) + } else { + // Success + AuthorizationManager.addAccessToDoc(client, doc_id, callback) + } + } + ) + } else { + // Access already cached + callback() + } + } + ) + }) + }, + + leaveDoc(client, doc_id, callback) { + // client may have disconnected, but we have to cleanup internal state. + client.joinLeaveEpoch++ + metrics.inc('editor.leave-doc', 1, { status: client.transport }) + const { project_id, user_id } = client.ol_context + logger.log( + { user_id, project_id, doc_id, client_id: client.id }, + 'client leaving doc' + ) + RoomManager.leaveDoc(client, doc_id) + // we could remove permission when user leaves a doc, but because + // the connection is per-project, we continue to allow access + // after the initial joinDoc since we know they are already authorised. + // # AuthorizationManager.removeAccessToDoc client, doc_id + callback() + }, + updateClientPosition(client, cursorData, callback) { + if (client.disconnected) { + // do not create a ghost entry in redis + return callback() + } + + metrics.inc('editor.update-client-position', 0.1, { + status: client.transport, + }) + const { project_id, first_name, last_name, email, user_id } = + client.ol_context + logger.log( + { user_id, project_id, client_id: client.id, cursorData }, + 'updating client position' + ) + + AuthorizationManager.assertClientCanViewProjectAndDoc( + client, + cursorData.doc_id, + function (error) { + if (error) { + logger.info( + { err: error, client_id: client.id, project_id, user_id }, + "silently ignoring unauthorized updateClientPosition. Client likely hasn't called joinProject yet." + ) + return callback() + } + cursorData.id = client.publicId + if (user_id) { + cursorData.user_id = user_id + } + if (email) { + cursorData.email = email + } + // Don't store anonymous users in redis to avoid influx + if (!user_id || user_id === 'anonymous-user') { + cursorData.name = '' + // consistent async behaviour + setTimeout(callback) + } else { + cursorData.name = + first_name && last_name + ? `${first_name} ${last_name}` + : first_name || last_name || '' + ConnectedUsersManager.updateUserPosition( + project_id, + client.publicId, + { + first_name, + last_name, + email, + _id: user_id, + }, + { + row: cursorData.row, + column: cursorData.column, + doc_id: cursorData.doc_id, + }, + callback + ) + } + WebsocketLoadBalancer.emitToRoom( + project_id, + 'clientTracking.clientUpdated', + cursorData + ) + } + ) + }, + + CLIENT_REFRESH_DELAY: 1000, + getConnectedUsers(client, callback) { + if (client.disconnected) { + // they are not interested anymore, skip the redis lookups + return callback() + } + + metrics.inc('editor.get-connected-users', { status: client.transport }) + const { project_id, user_id, is_restricted_user } = client.ol_context + if (is_restricted_user) { + return callback(null, []) + } + if (!project_id) { + return callback(new NotJoinedError()) + } + logger.log( + { user_id, project_id, client_id: client.id }, + 'getting connected users' + ) + AuthorizationManager.assertClientCanViewProject(client, function (error) { + if (error) { + return callback(error) + } + WebsocketLoadBalancer.emitToRoom(project_id, 'clientTracking.refresh') + setTimeout( + () => + ConnectedUsersManager.getConnectedUsers( + project_id, + function (error, users) { + if (error) { + return callback(error) + } + logger.log( + { user_id, project_id, client_id: client.id }, + 'got connected users' + ) + callback(null, users) + } + ), + WebsocketController.CLIENT_REFRESH_DELAY + ) + }) + }, + + applyOtUpdate(client, doc_id, update, callback) { + // client may have disconnected, but we can submit their update to doc-updater anyways. + const { user_id, project_id } = client.ol_context + if (!project_id) { + return callback(new NotJoinedError()) + } + + WebsocketController._assertClientCanApplyUpdate( + client, + doc_id, + update, + function (error) { + if (error) { + setTimeout( + () => + // Disconnect, but give the client the chance to receive the error + client.disconnect(), + 100 + ) + return callback(error) + } + if (!update.meta) { + update.meta = {} + } + update.meta.source = client.publicId + update.meta.user_id = user_id + metrics.inc('editor.doc-update', 0.3, { status: client.transport }) + + logger.log( + { + user_id, + doc_id, + project_id, + client_id: client.id, + version: update.v, + }, + 'sending update to doc updater' + ) + + DocumentUpdaterManager.queueChange( + project_id, + doc_id, + update, + function (error) { + if ((error && error.message) === 'update is too large') { + metrics.inc('update_too_large') + const { updateSize } = error.info + logger.warn( + { user_id, project_id, doc_id, updateSize }, + 'update is too large' + ) + + // mark the update as received -- the client should not send it again! + callback() + + // trigger an out-of-sync error + const message = { + project_id, + doc_id, + error: 'update is too large', + } + setTimeout(function () { + if (client.disconnected) { + // skip the message broadcast, the client has moved on + return metrics.inc('editor.doc-update.disconnected', 1, { + status: 'at-otUpdateError', + }) + } + client.emit('otUpdateError', message.error, message) + client.disconnect() + }, 100) + return + } + + if (error) { + OError.tag(error, 'document was not available for update', { + version: update.v, + }) + client.disconnect() + } + callback(error) + } + ) + } + ) + }, + + _assertClientCanApplyUpdate(client, doc_id, update, callback) { + AuthorizationManager.assertClientCanEditProjectAndDoc( + client, + doc_id, + function (error) { + if ( + error && + error.message === 'not authorized' && + WebsocketController._isCommentUpdate(update) + ) { + // This might be a comment op, which we only need read-only priveleges for + AuthorizationManager.assertClientCanViewProjectAndDoc( + client, + doc_id, + callback + ) + return + } + callback(error) + } + ) + }, + + _isCommentUpdate(update) { + if (!(update && update.op instanceof Array)) { + return false + } + for (const op of update.op) { + if (!op.c) { + return false + } + } + return true + }, +} diff --git a/services/real-time/app/js/WebsocketLoadBalancer.js b/services/real-time/app/js/WebsocketLoadBalancer.js new file mode 100644 index 0000000000..f90f28c76b --- /dev/null +++ b/services/real-time/app/js/WebsocketLoadBalancer.js @@ -0,0 +1,171 @@ +/* eslint-disable + camelcase, +*/ +const Settings = require('@overleaf/settings') +const logger = require('logger-sharelatex') +const RedisClientManager = require('./RedisClientManager') +const SafeJsonParse = require('./SafeJsonParse') +const EventLogger = require('./EventLogger') +const HealthCheckManager = require('./HealthCheckManager') +const RoomManager = require('./RoomManager') +const ChannelManager = require('./ChannelManager') +const ConnectedUsersManager = require('./ConnectedUsersManager') + +const RESTRICTED_USER_MESSAGE_TYPE_PASS_LIST = [ + 'connectionAccepted', + 'otUpdateApplied', + 'otUpdateError', + 'joinDoc', + 'reciveNewDoc', + 'reciveNewFile', + 'reciveNewFolder', + 'removeEntity', +] + +let WebsocketLoadBalancer +module.exports = WebsocketLoadBalancer = { + rclientPubList: RedisClientManager.createClientList(Settings.redis.pubsub), + rclientSubList: RedisClientManager.createClientList(Settings.redis.pubsub), + + emitToRoom(room_id, message, ...payload) { + if (!room_id) { + logger.warn( + { message, payload }, + 'no room_id provided, ignoring emitToRoom' + ) + return + } + const data = JSON.stringify({ + room_id, + message, + payload, + }) + logger.log( + { room_id, message, payload, length: data.length }, + 'emitting to room' + ) + + this.rclientPubList.map(rclientPub => + ChannelManager.publish(rclientPub, 'editor-events', room_id, data) + ) + }, + + emitToAll(message, ...payload) { + this.emitToRoom('all', message, ...payload) + }, + + listenForEditorEvents(io) { + logger.log( + { rclients: this.rclientSubList.length }, + 'listening for editor events' + ) + for (const rclientSub of this.rclientSubList) { + rclientSub.subscribe('editor-events') + rclientSub.on('message', function (channel, message) { + if (Settings.debugEvents > 0) { + EventLogger.debugEvent(channel, message) + } + WebsocketLoadBalancer._processEditorEvent(io, channel, message) + }) + } + this.handleRoomUpdates(this.rclientSubList) + }, + + handleRoomUpdates(rclientSubList) { + const roomEvents = RoomManager.eventSource() + roomEvents.on('project-active', function (project_id) { + const subscribePromises = rclientSubList.map(rclient => + ChannelManager.subscribe(rclient, 'editor-events', project_id) + ) + RoomManager.emitOnCompletion( + subscribePromises, + `project-subscribed-${project_id}` + ) + }) + roomEvents.on('project-empty', project_id => + rclientSubList.map(rclient => + ChannelManager.unsubscribe(rclient, 'editor-events', project_id) + ) + ) + }, + + _processEditorEvent(io, channel, message) { + SafeJsonParse.parse(message, function (error, message) { + if (error) { + logger.error({ err: error, channel }, 'error parsing JSON') + return + } + if (message.room_id === 'all') { + io.sockets.emit(message.message, ...message.payload) + } else if ( + message.message === 'clientTracking.refresh' && + message.room_id + ) { + const clientList = io.sockets.clients(message.room_id) + logger.log( + { + channel, + message: message.message, + room_id: message.room_id, + message_id: message._id, + socketIoClients: clientList.map(client => client.id), + }, + 'refreshing client list' + ) + for (const client of clientList) { + ConnectedUsersManager.refreshClient(message.room_id, client.publicId) + } + } else if (message.room_id) { + if (message._id && Settings.checkEventOrder) { + const status = EventLogger.checkEventOrder( + 'editor-events', + message._id, + message + ) + if (status === 'duplicate') { + return // skip duplicate events + } + } + + const is_restricted_message = + !RESTRICTED_USER_MESSAGE_TYPE_PASS_LIST.includes(message.message) + + // send messages only to unique clients (due to duplicate entries in io.sockets.clients) + const clientList = io.sockets + .clients(message.room_id) + .filter( + client => + !(is_restricted_message && client.ol_context.is_restricted_user) + ) + + // avoid unnecessary work if no clients are connected + if (clientList.length === 0) { + return + } + logger.log( + { + channel, + message: message.message, + room_id: message.room_id, + message_id: message._id, + socketIoClients: clientList.map(client => client.id), + }, + 'distributing event to clients' + ) + const seen = new Map() + for (const client of clientList) { + if (!seen.has(client.id)) { + seen.set(client.id, true) + client.emit(message.message, ...message.payload) + } + } + } else if (message.health_check) { + logger.debug( + { message }, + 'got health check message in editor events channel' + ) + HealthCheckManager.check(channel, message.key) + } + }) + }, +} diff --git a/services/real-time/buildscript.txt b/services/real-time/buildscript.txt new file mode 100644 index 0000000000..fcf340d99c --- /dev/null +++ b/services/real-time/buildscript.txt @@ -0,0 +1,8 @@ +real-time +--dependencies=redis +--docker-repos=gcr.io/overleaf-ops +--env-add= +--env-pass-through= +--node-version=12.22.3 +--public-repo=True +--script-version=3.11.0 diff --git a/services/real-time/config/settings.defaults.js b/services/real-time/config/settings.defaults.js new file mode 100644 index 0000000000..49b15e46fd --- /dev/null +++ b/services/real-time/config/settings.defaults.js @@ -0,0 +1,168 @@ +/* eslint-disable camelcase */ + +const settings = { + redis: { + pubsub: { + host: + process.env.PUBSUB_REDIS_HOST || process.env.REDIS_HOST || 'localhost', + port: process.env.PUBSUB_REDIS_PORT || process.env.REDIS_PORT || '6379', + password: + process.env.PUBSUB_REDIS_PASSWORD || process.env.REDIS_PASSWORD || '', + maxRetriesPerRequest: parseInt( + process.env.PUBSUB_REDIS_MAX_RETRIES_PER_REQUEST || + process.env.REDIS_MAX_RETRIES_PER_REQUEST || + '20' + ), + }, + + realtime: { + host: + process.env.REAL_TIME_REDIS_HOST || + process.env.REDIS_HOST || + 'localhost', + port: + process.env.REAL_TIME_REDIS_PORT || process.env.REDIS_PORT || '6379', + password: + process.env.REAL_TIME_REDIS_PASSWORD || + process.env.REDIS_PASSWORD || + '', + key_schema: { + clientsInProject({ project_id }) { + return `clients_in_project:{${project_id}}` + }, + connectedUser({ project_id, client_id }) { + return `connected_user:{${project_id}}:${client_id}` + }, + }, + maxRetriesPerRequest: parseInt( + process.env.REAL_TIME_REDIS_MAX_RETRIES_PER_REQUEST || + process.env.REDIS_MAX_RETRIES_PER_REQUEST || + '20' + ), + }, + + documentupdater: { + host: + process.env.DOC_UPDATER_REDIS_HOST || + process.env.REDIS_HOST || + 'localhost', + port: + process.env.DOC_UPDATER_REDIS_PORT || process.env.REDIS_PORT || '6379', + password: + process.env.DOC_UPDATER_REDIS_PASSWORD || + process.env.REDIS_PASSWORD || + '', + key_schema: { + pendingUpdates({ doc_id }) { + return `PendingUpdates:{${doc_id}}` + }, + }, + maxRetriesPerRequest: parseInt( + process.env.DOC_UPDATER_REDIS_MAX_RETRIES_PER_REQUEST || + process.env.REDIS_MAX_RETRIES_PER_REQUEST || + '20' + ), + }, + + websessions: { + host: process.env.WEB_REDIS_HOST || process.env.REDIS_HOST || 'localhost', + port: process.env.WEB_REDIS_PORT || process.env.REDIS_PORT || '6379', + password: + process.env.WEB_REDIS_PASSWORD || process.env.REDIS_PASSWORD || '', + maxRetriesPerRequest: parseInt( + process.env.WEB_REDIS_MAX_RETRIES_PER_REQUEST || + process.env.REDIS_MAX_RETRIES_PER_REQUEST || + '20' + ), + }, + }, + + internal: { + realTime: { + port: 3026, + host: process.env.LISTEN_ADDRESS || 'localhost', + user: 'sharelatex', + pass: 'password', + }, + }, + + apis: { + web: { + url: `http://${ + process.env.WEB_API_HOST || process.env.WEB_HOST || 'localhost' + }:${process.env.WEB_API_PORT || process.env.WEB_PORT || 3000}`, + user: process.env.WEB_API_USER || 'sharelatex', + pass: process.env.WEB_API_PASSWORD || 'password', + }, + documentupdater: { + url: `http://${ + process.env.DOCUMENT_UPDATER_HOST || + process.env.DOCUPDATER_HOST || + 'localhost' + }:3003`, + }, + }, + + security: { + sessionSecret: process.env.SESSION_SECRET || 'secret-please-change', + }, + + cookieName: process.env.COOKIE_NAME || 'sharelatex.sid', + + // Expose the hostname in the `debug.getHostname` rpc + exposeHostname: process.env.EXPOSE_HOSTNAME === 'true', + + max_doc_length: 2 * 1024 * 1024, // 2mb + + // should be set to the same same as dispatcherCount in document updater + pendingUpdateListShardCount: parseInt( + process.env.PENDING_UPDATE_LIST_SHARD_COUNT || 10, + 10 + ), + + // combine + // max_doc_length (2mb see above) * 2 (delete + insert) + // max_ranges_size (3mb see MAX_RANGES_SIZE in document-updater) + // overhead for JSON serialization + maxUpdateSize: + parseInt(process.env.MAX_UPDATE_SIZE) || 7 * 1024 * 1024 + 64 * 1024, + + shutdownDrainTimeWindow: process.env.SHUTDOWN_DRAIN_TIME_WINDOW || 9, + + // The shutdown procedure asks clients to reconnect gracefully. + // 3rd-party/buggy clients may not act upon receiving the message and keep + // stale connections alive. We forcefully disconnect them after X ms: + gracefulReconnectTimeoutMs: + parseInt(process.env.GRACEFUL_RECONNECT_TIMEOUT_MS, 10) || + // The frontend allows actively editing users to keep the connection open + // for up-to ConnectionManager.MAX_RECONNECT_GRACEFULLY_INTERVAL=45s + // Permit an extra delay to account for slow/flaky connections. + (45 + 30) * 1000, + + continualPubsubTraffic: process.env.CONTINUAL_PUBSUB_TRAFFIC || false, + + checkEventOrder: process.env.CHECK_EVENT_ORDER || false, + + publishOnIndividualChannels: + process.env.PUBLISH_ON_INDIVIDUAL_CHANNELS || false, + + statusCheckInterval: parseInt(process.env.STATUS_CHECK_INTERVAL || '0'), + + // The deployment colour for this app (if any). Used for blue green deploys. + deploymentColour: process.env.DEPLOYMENT_COLOUR, + // Load balancer health checks will return 200 only when this file contains + // the deployment colour for this app. + deploymentFile: process.env.DEPLOYMENT_FILE, + + sentry: { + dsn: process.env.SENTRY_DSN, + }, + + errors: { + catchUncaughtErrors: true, + shutdownOnUncaughtError: true, + }, +} + +// console.log settings.redis +module.exports = settings diff --git a/services/real-time/config/settings.test.js b/services/real-time/config/settings.test.js new file mode 100644 index 0000000000..9b426631b9 --- /dev/null +++ b/services/real-time/config/settings.test.js @@ -0,0 +1,5 @@ +module.exports = { + errors: { + catchUncaughtErrors: false, + }, +} diff --git a/services/real-time/docker-compose.ci.yml b/services/real-time/docker-compose.ci.yml new file mode 100644 index 0000000000..4e90129f69 --- /dev/null +++ b/services/real-time/docker-compose.ci.yml @@ -0,0 +1,49 @@ +# This file was auto-generated, do not edit it directly. +# Instead run bin/update_build_scripts from +# https://github.com/sharelatex/sharelatex-dev-environment + +version: "2.3" + +services: + test_unit: + image: ci/$PROJECT_NAME:$BRANCH_NAME-$BUILD_NUMBER + user: node + command: npm run test:unit:_run + environment: + NODE_ENV: test + NODE_OPTIONS: "--unhandled-rejections=strict" + + + test_acceptance: + build: . + image: ci/$PROJECT_NAME:$BRANCH_NAME-$BUILD_NUMBER + environment: + ELASTIC_SEARCH_DSN: es:9200 + REDIS_HOST: redis + QUEUES_REDIS_HOST: redis + MONGO_HOST: mongo + POSTGRES_HOST: postgres + MOCHA_GREP: ${MOCHA_GREP} + NODE_ENV: test + NODE_OPTIONS: "--unhandled-rejections=strict" + depends_on: + redis: + condition: service_healthy + user: node + command: npm run test:acceptance:_run + + + tar: + build: . + image: ci/$PROJECT_NAME:$BRANCH_NAME-$BUILD_NUMBER + volumes: + - ./:/tmp/build/ + command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . + user: root + redis: + image: redis + healthcheck: + test: ping="$$(redis-cli ping)" && [ "$$ping" = 'PONG' ] + interval: 1s + retries: 20 + diff --git a/services/real-time/docker-compose.yml b/services/real-time/docker-compose.yml new file mode 100644 index 0000000000..59ad1c5efe --- /dev/null +++ b/services/real-time/docker-compose.yml @@ -0,0 +1,47 @@ +# This file was auto-generated, do not edit it directly. +# Instead run bin/update_build_scripts from +# https://github.com/sharelatex/sharelatex-dev-environment + +version: "2.3" + +services: + test_unit: + image: node:12.22.3 + volumes: + - .:/app + working_dir: /app + environment: + MOCHA_GREP: ${MOCHA_GREP} + NODE_ENV: test + NODE_OPTIONS: "--unhandled-rejections=strict" + command: npm run --silent test:unit + user: node + + test_acceptance: + image: node:12.22.3 + volumes: + - .:/app + working_dir: /app + environment: + ELASTIC_SEARCH_DSN: es:9200 + REDIS_HOST: redis + QUEUES_REDIS_HOST: redis + MONGO_HOST: mongo + POSTGRES_HOST: postgres + MOCHA_GREP: ${MOCHA_GREP} + LOG_LEVEL: ERROR + NODE_ENV: test + NODE_OPTIONS: "--unhandled-rejections=strict" + user: node + depends_on: + redis: + condition: service_healthy + command: npm run --silent test:acceptance + + redis: + image: redis + healthcheck: + test: ping=$$(redis-cli ping) && [ "$$ping" = 'PONG' ] + interval: 1s + retries: 20 + diff --git a/services/real-time/nodemon.json b/services/real-time/nodemon.json new file mode 100644 index 0000000000..e3e8817d90 --- /dev/null +++ b/services/real-time/nodemon.json @@ -0,0 +1,17 @@ +{ + "ignore": [ + ".git", + "node_modules/" + ], + "verbose": true, + "legacyWatch": true, + "execMap": { + "js": "npm run start" + }, + "watch": [ + "app/js/", + "app.js", + "config/" + ], + "ext": "js" +} diff --git a/services/real-time/package-lock.json b/services/real-time/package-lock.json new file mode 100644 index 0000000000..8a7d062b76 --- /dev/null +++ b/services/real-time/package-lock.json @@ -0,0 +1,5587 @@ +{ + "name": "real-time-sharelatex", + "version": "0.1.4", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@babel/code-frame": { + "version": "7.12.11", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz", + "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==", + "dev": true, + "requires": { + "@babel/highlight": "^7.10.4" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.5.tgz", + "integrity": "sha512-5lsetuxCLilmVGyiLEfoHBRX8UCFD+1m2x3Rj97WrW3V7H3u4RWRXA4evMjImCsin2J2YT0QaVDGf+z8ondbAg==", + "dev": true + }, + "@babel/highlight": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.14.5.tgz", + "integrity": "sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.14.5", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@eslint/eslintrc": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.2.tgz", + "integrity": "sha512-8nmGq/4ycLpIwzvhI4tNDmQztZ8sp+hI7cyG8i1nQDhkAbRzHpXPidRAHlNvCZQpJTKw5ItIpMw9RSToGF00mg==", + "dev": true, + "requires": { + "ajv": "^6.12.4", + "debug": "^4.1.1", + "espree": "^7.3.0", + "globals": "^13.9.0", + "ignore": "^4.0.6", + "import-fresh": "^3.2.1", + "js-yaml": "^3.13.1", + "minimatch": "^3.0.4", + "strip-json-comments": "^3.1.1" + }, + "dependencies": { + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "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 + } + } + }, + "@google-cloud/common": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-3.6.0.tgz", + "integrity": "sha512-aHIFTqJZmeTNO9md8XxV+ywuvXF3xBm5WNmgWeeCK+XN5X+kGW0WEX94wGwj+/MdOnrVf4dL2RvSIt9J5yJG6Q==", + "requires": { + "@google-cloud/projectify": "^2.0.0", + "@google-cloud/promisify": "^2.0.0", + "arrify": "^2.0.1", + "duplexify": "^4.1.1", + "ent": "^2.2.0", + "extend": "^3.0.2", + "google-auth-library": "^7.0.2", + "retry-request": "^4.1.1", + "teeny-request": "^7.0.0" + }, + "dependencies": { + "duplexify": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.1.tgz", + "integrity": "sha512-DY3xVEmVHTv1wSzKNbwoU6nVjzI369Y6sPoqfYr0/xlx3IdX2n94xIszTcjPO8W8ZIv0Wb0PXNcjuZyT4wiICA==", + "requires": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.0" + } + }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, + "@google-cloud/debug-agent": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@google-cloud/debug-agent/-/debug-agent-5.1.3.tgz", + "integrity": "sha512-WbzeEz4MvPlM7DX2QBsPcWgF62u7LSQv/oMYPl0L+TddTebqjDKiVXwxpzWk61NIfcKiet3dyCbPIt3N5o8XPQ==", + "requires": { + "@google-cloud/common": "^3.0.0", + "acorn": "^8.0.0", + "coffeescript": "^2.0.0", + "console-log-level": "^1.4.0", + "extend": "^3.0.2", + "findit2": "^2.2.3", + "gcp-metadata": "^4.0.0", + "p-limit": "^3.0.1", + "semver": "^7.0.0", + "source-map": "^0.6.1", + "split": "^1.0.0" + }, + "dependencies": { + "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==", + "requires": { + "yallist": "^4.0.0" + } + }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "requires": { + "lru-cache": "^6.0.0" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + } + }, + "@google-cloud/logging": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@google-cloud/logging/-/logging-7.3.0.tgz", + "integrity": "sha512-xTW1V4MKpYC0mjSugyuiyUoZ9g6A42IhrrO3z7Tt3SmAb2IRj2Gf4RLoguKKncs340ooZFXrrVN/++t2Aj5zgg==", + "requires": { + "@google-cloud/common": "^2.2.2", + "@google-cloud/paginator": "^2.0.0", + "@google-cloud/projectify": "^1.0.0", + "@google-cloud/promisify": "^1.0.0", + "@opencensus/propagation-stackdriver": "0.0.20", + "arrify": "^2.0.0", + "dot-prop": "^5.1.0", + "eventid": "^1.0.0", + "extend": "^3.0.2", + "gcp-metadata": "^3.1.0", + "google-auth-library": "^5.2.2", + "google-gax": "^1.11.0", + "is": "^3.3.0", + "on-finished": "^2.3.0", + "pumpify": "^2.0.0", + "snakecase-keys": "^3.0.0", + "stream-events": "^1.0.4", + "through2": "^3.0.0", + "type-fest": "^0.12.0" + }, + "dependencies": { + "@google-cloud/common": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-2.4.0.tgz", + "integrity": "sha512-zWFjBS35eI9leAHhjfeOYlK5Plcuj/77EzstnrJIZbKgF/nkqjcQuGiMCpzCwOfPyUbz8ZaEOYgbHa759AKbjg==", + "requires": { + "@google-cloud/projectify": "^1.0.0", + "@google-cloud/promisify": "^1.0.0", + "arrify": "^2.0.0", + "duplexify": "^3.6.0", + "ent": "^2.2.0", + "extend": "^3.0.2", + "google-auth-library": "^5.5.0", + "retry-request": "^4.0.0", + "teeny-request": "^6.0.0" + } + }, + "@google-cloud/projectify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-1.0.4.tgz", + "integrity": "sha512-ZdzQUN02eRsmTKfBj9FDL0KNDIFNjBn/d6tHQmA/+FImH5DO6ZV8E7FzxMgAUiVAUq41RFAkb25p1oHOZ8psfg==" + }, + "@google-cloud/promisify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-1.0.4.tgz", + "integrity": "sha512-VccZDcOql77obTnFh0TbNED/6ZbbmHDf8UMNnzO1d5g9V0Htfm4k5cllY8P1tJsRKC3zWYGRLaViiupcgVjBoQ==" + }, + "debug": { + "version": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + }, + "gaxios": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-2.3.4.tgz", + "integrity": "sha512-US8UMj8C5pRnao3Zykc4AAVr+cffoNKRTg9Rsf2GiuZCW69vgJj38VK2PzlPuQU73FZ/nTk9/Av6/JGcE1N9vA==", + "requires": { + "abort-controller": "^3.0.0", + "extend": "^3.0.2", + "https-proxy-agent": "^5.0.0", + "is-stream": "^2.0.0", + "node-fetch": "^2.3.0" + } + }, + "gcp-metadata": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-3.5.0.tgz", + "integrity": "sha512-ZQf+DLZ5aKcRpLzYUyBS3yo3N0JSa82lNDO8rj3nMSlovLcz2riKFBsYgDzeXcv75oo5eqB2lx+B14UvPoCRnA==", + "requires": { + "gaxios": "^2.1.0", + "json-bigint": "^0.3.0" + } + }, + "google-auth-library": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-5.10.1.tgz", + "integrity": "sha512-rOlaok5vlpV9rSiUu5EpR0vVpc+PhN62oF4RyX/6++DG1VsaulAFEMlDYBLjJDDPI6OcNOCGAKy9UVB/3NIDXg==", + "requires": { + "arrify": "^2.0.0", + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "fast-text-encoding": "^1.0.0", + "gaxios": "^2.1.0", + "gcp-metadata": "^3.4.0", + "gtoken": "^4.1.0", + "jws": "^4.0.0", + "lru-cache": "^5.0.0" + } + }, + "google-p12-pem": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-2.0.4.tgz", + "integrity": "sha512-S4blHBQWZRnEW44OcR7TL9WR+QCqByRvhNDZ/uuQfpxywfupikf/miba8js1jZi6ZOGv5slgSuoshCWh6EMDzg==", + "requires": { + "node-forge": "^0.9.0" + } + }, + "gtoken": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-4.1.4.tgz", + "integrity": "sha512-VxirzD0SWoFUo5p8RDP8Jt2AGyOmyYcT/pOUgDKJCK+iSw0TMqwrVfY37RXTNmoKwrzmDHSk0GMT9FsgVmnVSA==", + "requires": { + "gaxios": "^2.1.0", + "google-p12-pem": "^2.0.0", + "jws": "^4.0.0", + "mime": "^2.2.0" + } + }, + "mime": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.6.tgz", + "integrity": "sha512-RZKhC3EmpBchfTGBVb8fb+RL2cWyw/32lshnsETttkBAyAUXSGHxbEJWWRXc751DrIxG1q04b8QwMbAwkRPpUA==" + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node-forge": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.9.1.tgz", + "integrity": "sha512-G6RlQt5Sb4GMBzXvhfkeFmbqR6MzhtnT7VTHuLadjkii3rdYHNdw0m8zA4BTxVIh68FicCQ2NSUANpsqkr9jvQ==" + }, + "teeny-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-6.0.3.tgz", + "integrity": "sha512-TZG/dfd2r6yeji19es1cUIwAlVD8y+/svB1kAC2Y0bjEyysrfbO8EZvJBRwIE6WkwmUoB7uvWLwTIhJbMXZ1Dw==", + "requires": { + "http-proxy-agent": "^4.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.2.0", + "stream-events": "^1.0.5", + "uuid": "^7.0.0" + } + }, + "type-fest": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.12.0.tgz", + "integrity": "sha512-53RyidyjvkGpnWPMF9bQgFtWp+Sl8O2Rp13VavmJgfAP9WWG6q6TkrKU8iyJdnwnfgHI6k2hTlgqH4aSdjoTbg==" + } + } + }, + "@google-cloud/logging-bunyan": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/logging-bunyan/-/logging-bunyan-3.0.0.tgz", + "integrity": "sha512-ZLVXEejNQ27ktGcA3S/sd7GPefp7kywbn+/KoBajdb1Syqcmtc98jhXpYQBXVtNP2065iyu77s4SBaiYFbTC5A==", + "requires": { + "@google-cloud/logging": "^7.0.0", + "google-auth-library": "^6.0.0" + }, + "dependencies": { + "bignumber.js": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.0.tgz", + "integrity": "sha512-t/OYhhJ2SD+YGBQcjY8GzzDHEk9f3nerxjtfa6tlMXfe7frs/WozhvCNoGvpM0P3bNf3Gq5ZRMlGr5f3r4/N8A==" + }, + "debug": { + "version": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + }, + "gaxios": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-3.1.0.tgz", + "integrity": "sha512-DDTn3KXVJJigtz+g0J3vhcfbDbKtAroSTxauWsdnP57sM5KZ3d2c/3D9RKFJ86s43hfw6WULg6TXYw/AYiBlpA==", + "requires": { + "abort-controller": "^3.0.0", + "extend": "^3.0.2", + "https-proxy-agent": "^5.0.0", + "is-stream": "^2.0.0", + "node-fetch": "^2.3.0" + } + }, + "google-auth-library": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-6.0.6.tgz", + "integrity": "sha512-fWYdRdg55HSJoRq9k568jJA1lrhg9i2xgfhVIMJbskUmbDpJGHsbv9l41DGhCDXM21F9Kn4kUwdysgxSYBYJUw==", + "requires": { + "arrify": "^2.0.0", + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "fast-text-encoding": "^1.0.0", + "gaxios": "^3.0.0", + "gcp-metadata": "^4.1.0", + "gtoken": "^5.0.0", + "jws": "^4.0.0", + "lru-cache": "^6.0.0" + } + }, + "json-bigint": { + "version": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "requires": { + "bignumber.js": "^9.0.0" + } + }, + "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==", + "requires": { + "yallist": "^4.0.0" + } + }, + "mime": { + "version": "https://registry.npmjs.org/mime/-/mime-2.4.6.tgz", + "integrity": "sha512-RZKhC3EmpBchfTGBVb8fb+RL2cWyw/32lshnsETttkBAyAUXSGHxbEJWWRXc751DrIxG1q04b8QwMbAwkRPpUA==" + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node-forge": { + "version": "https://registry.npmjs.org/node-forge/-/node-forge-0.9.1.tgz", + "integrity": "sha512-G6RlQt5Sb4GMBzXvhfkeFmbqR6MzhtnT7VTHuLadjkii3rdYHNdw0m8zA4BTxVIh68FicCQ2NSUANpsqkr9jvQ==" + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + } + }, + "@google-cloud/paginator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-2.0.3.tgz", + "integrity": "sha512-kp/pkb2p/p0d8/SKUu4mOq8+HGwF8NPzHWkj+VKrIPQPyMRw8deZtrO/OcSiy9C/7bpfU5Txah5ltUNfPkgEXg==", + "requires": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + } + }, + "@google-cloud/profiler": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@google-cloud/profiler/-/profiler-4.1.1.tgz", + "integrity": "sha512-qk08aDxTaLnu+NoNEh5Jh+Fs5iR8lRLMr5Mb3YJDoZw72jHJI4f5N5F2JWt1xRc9D6da4gA6stBUJrbfbubvGQ==", + "requires": { + "@google-cloud/common": "^3.0.0", + "@types/console-log-level": "^1.4.0", + "@types/semver": "^7.0.0", + "console-log-level": "^1.4.0", + "delay": "^5.0.0", + "extend": "^3.0.2", + "gcp-metadata": "^4.0.0", + "parse-duration": "^1.0.0", + "pprof": "3.0.0", + "pretty-ms": "^7.0.0", + "protobufjs": "~6.10.0", + "semver": "^7.0.0", + "teeny-request": "^7.0.0" + }, + "dependencies": { + "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==", + "requires": { + "yallist": "^4.0.0" + } + }, + "protobufjs": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.10.2.tgz", + "integrity": "sha512-27yj+04uF6ya9l+qfpH187aqEzfCF4+Uit0I9ZBQVqK09hk/SQzKa2MUqUpXaVa7LOFRg1TSSr3lVxGOk6c0SQ==", + "requires": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.1", + "@types/node": "^13.7.0", + "long": "^4.0.0" + } + }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "requires": { + "lru-cache": "^6.0.0" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + } + }, + "@google-cloud/projectify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-2.0.1.tgz", + "integrity": "sha512-ZDG38U/Yy6Zr21LaR3BTiiLtpJl6RkPS/JwoRT453G+6Q1DhlV0waNf8Lfu+YVYGIIxgKnLayJRfYlFJfiI8iQ==" + }, + "@google-cloud/promisify": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-2.0.3.tgz", + "integrity": "sha512-d4VSA86eL/AFTe5xtyZX+ePUjE8dIFu2T8zmdeNBSa5/kNgXPCx/o/wbFNHAGLJdGnk1vddRuMESD9HbOC8irw==" + }, + "@google-cloud/trace-agent": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@google-cloud/trace-agent/-/trace-agent-5.1.3.tgz", + "integrity": "sha512-f+5DX7n6QpDlHA+4kr81z69SLAdrlvd9T8skqCMgnYvtXx14AwzXZyzEDf3jppOYzYoqPPJv8XYiyYHHmYD0BA==", + "requires": { + "@google-cloud/common": "^3.0.0", + "@opencensus/propagation-stackdriver": "0.0.22", + "builtin-modules": "^3.0.0", + "console-log-level": "^1.4.0", + "continuation-local-storage": "^3.2.1", + "extend": "^3.0.2", + "gcp-metadata": "^4.0.0", + "google-auth-library": "^7.0.0", + "hex2dec": "^1.0.1", + "is": "^3.2.0", + "methods": "^1.1.1", + "require-in-the-middle": "^5.0.0", + "semver": "^7.0.0", + "shimmer": "^1.2.0", + "source-map-support": "^0.5.16", + "uuid": "^8.0.0" + }, + "dependencies": { + "@opencensus/core": { + "version": "0.0.22", + "resolved": "https://registry.npmjs.org/@opencensus/core/-/core-0.0.22.tgz", + "integrity": "sha512-ErazJtivjceNoOZI1bG9giQ6cWS45J4i6iPUtlp7dLNu58OLs/v+CD0FsaPCh47XgPxAI12vbBE8Ec09ViwHNA==", + "requires": { + "continuation-local-storage": "^3.2.1", + "log-driver": "^1.2.7", + "semver": "^7.0.0", + "shimmer": "^1.2.0", + "uuid": "^8.0.0" + } + }, + "@opencensus/propagation-stackdriver": { + "version": "0.0.22", + "resolved": "https://registry.npmjs.org/@opencensus/propagation-stackdriver/-/propagation-stackdriver-0.0.22.tgz", + "integrity": "sha512-eBvf/ihb1mN8Yz/ASkz8nHzuMKqygu77+VNnUeR0yEh3Nj+ykB8VVR6lK+NAFXo1Rd1cOsTmgvuXAZgDAGleQQ==", + "requires": { + "@opencensus/core": "^0.0.22", + "hex2dec": "^1.0.1", + "uuid": "^8.0.0" + } + }, + "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==", + "requires": { + "yallist": "^4.0.0" + } + }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "requires": { + "lru-cache": "^6.0.0" + } + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + } + }, + "@grpc/grpc-js": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.0.5.tgz", + "integrity": "sha512-Hm+xOiqAhcpT9RYM8lc15dbQD7aQurM7ZU8ulmulepiPlN7iwBXXwP3vSBUimoFoApRqz7pSIisXU8pZaCB4og==", + "requires": { + "semver": "^6.2.0" + } + }, + "@grpc/proto-loader": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.5.5.tgz", + "integrity": "sha512-WwN9jVNdHRQoOBo9FDH7qU+mgfjPc8GygPYms3M+y3fbQLfnCe/Kv/E01t7JRgnrsOHH8euvSbed3mIalXhwqQ==", + "requires": { + "lodash.camelcase": "^4.3.0", + "protobufjs": "^6.8.6" + } + }, + "@humanwhocodes/config-array": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz", + "integrity": "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==", + "dev": true, + "requires": { + "@humanwhocodes/object-schema": "^1.2.0", + "debug": "^4.1.1", + "minimatch": "^3.0.4" + }, + "dependencies": { + "debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "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 + } + } + }, + "@humanwhocodes/object-schema": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz", + "integrity": "sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==", + "dev": true + }, + "@opencensus/core": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@opencensus/core/-/core-0.0.20.tgz", + "integrity": "sha512-vqOuTd2yuMpKohp8TNNGUAPjWEGjlnGfB9Rh5e3DKqeyR94YgierNs4LbMqxKtsnwB8Dm2yoEtRuUgoe5vD9DA==", + "requires": { + "continuation-local-storage": "^3.2.1", + "log-driver": "^1.2.7", + "semver": "^6.0.0", + "shimmer": "^1.2.0", + "uuid": "^3.2.1" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + } + } + }, + "@opencensus/propagation-stackdriver": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@opencensus/propagation-stackdriver/-/propagation-stackdriver-0.0.20.tgz", + "integrity": "sha512-P8yuHSLtce+yb+2EZjtTVqG7DQ48laC+IuOWi3X9q78s1Gni5F9+hmbmyP6Nb61jb5BEvXQX1s2rtRI6bayUWA==", + "requires": { + "@opencensus/core": "^0.0.20", + "hex2dec": "^1.0.1", + "uuid": "^3.2.1" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + } + } + }, + "@overleaf/metrics": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@overleaf/metrics/-/metrics-3.5.1.tgz", + "integrity": "sha512-RLHxkMF7Y3725L3QwXo9cIn2gGobsMYUGuxKxg7PVMrPTMsomHEMeG7StOxCO7ML1Z/BwB/9nsVYNrsRdAJtKg==", + "requires": { + "@google-cloud/debug-agent": "^5.1.2", + "@google-cloud/profiler": "^4.0.3", + "@google-cloud/trace-agent": "^5.1.1", + "compression": "^1.7.4", + "prom-client": "^11.1.3", + "underscore": "~1.6.0", + "yn": "^3.1.1" + }, + "dependencies": { + "underscore": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.6.0.tgz", + "integrity": "sha1-izixDKze9jM3uLJOT/htRa6lKag=" + } + } + }, + "@overleaf/o-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@overleaf/o-error/-/o-error-3.1.0.tgz", + "integrity": "sha512-TWJ80ozJ1LeugGTJyGQSPEuTkZ9LqZD7/ndLE6azKa03SU/mKV/FINcfk8atpVil8iv1hHQwzYZc35klplpMpQ==" + }, + "@overleaf/redis-wrapper": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@overleaf/redis-wrapper/-/redis-wrapper-2.0.0.tgz", + "integrity": "sha512-lREuhDPNgmKyOmL1g6onfRzDLWOG/POsE4Vd7ZzLnKDYt9SbOIujtx3CxI2qtQAKBYHf/hfyrbtyX3Ib2yTvYA==", + "requires": { + "ioredis": "~4.17.3" + } + }, + "@overleaf/settings": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@overleaf/settings/-/settings-2.1.1.tgz", + "integrity": "sha512-vcJwqCGFKmQxTP/syUqCeMaSRjHmBcQgKOACR9He2uJcErg2GZPa1go+nGvszMbkElM4HfRKm/MfxvqHhoN4TQ==" + }, + "@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" + }, + "@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "requires": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" + }, + "@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" + }, + "@sinonjs/commons": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.2.tgz", + "integrity": "sha512-sruwd86RJHdsVf/AtBoijDmUqJp3B6hF/DGC23C+JaegnDHaZyewCjoVGTdg3J0uz3Zs7NnIT05OBOmML72lQw==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz", + "integrity": "sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.7.0" + } + }, + "@sinonjs/samsam": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-5.3.1.tgz", + "integrity": "sha512-1Hc0b1TtyfBu8ixF/tpfSHTVWKwCBLY4QJbkgnE7HcwyvT2xArDxb4K7dMgqRm3szI+LJbzmW/s4xxEhv6hwDg==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.6.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "@sinonjs/text-encoding": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz", + "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==", + "dev": true + }, + "@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==" + }, + "@types/console-log-level": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@types/console-log-level/-/console-log-level-1.4.0.tgz", + "integrity": "sha512-x+OscEQwcx5Biair4enH7ov9W+clcqUWaZRaxn5IkT4yNWWjRr2oiYDkY/x1uXSTVZOQ2xlbFQySaQGB+VdXGQ==" + }, + "@types/fs-extra": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.1.tgz", + "integrity": "sha512-TcUlBem321DFQzBNuz8p0CLLKp0VvF/XH9E4KHNmgwyp4E3AfgI5cjiIVZWlbfThBop2qxFIh4+LeY6hVWWZ2w==", + "requires": { + "@types/node": "*" + } + }, + "@types/long": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz", + "integrity": "sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==" + }, + "@types/node": { + "version": "13.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-13.9.5.tgz", + "integrity": "sha512-hkzMMD3xu6BrJpGVLeQ3htQQNAcOrJjX7WFmtK8zWQpz2UJf13LCFF2ALA7c9OVdvc2vQJeDdjfR35M0sBCxvw==" + }, + "@types/semver": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.4.tgz", + "integrity": "sha512-+nVsLKlcUCeMzD2ufHEYuJ9a2ovstb6Dp52A5VsoKxDXgvE051XgHI/33I1EymwkRGQkwnA0LkhnUzituGs4EQ==" + }, + "@ungap/promise-all-settled": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz", + "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==", + "dev": true + }, + "abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" + }, + "abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "requires": { + "event-target-shim": "^5.0.0" + } + }, + "accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", + "requires": { + "mime-types": "~2.1.24", + "negotiator": "0.6.2" + }, + "dependencies": { + "mime-db": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz", + "integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==" + }, + "mime-types": { + "version": "2.1.27", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz", + "integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==", + "requires": { + "mime-db": "1.44.0" + } + } + } + }, + "acorn": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.1.0.tgz", + "integrity": "sha512-LWCF/Wn0nfHOmJ9rzQApGnxnvgfROzGilS8936rqN/lfcYkY9MYZzdMqN+2NJ4SlTc+m5HiSa+kNfDtI64dwUA==" + }, + "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 + }, + "active-x-obfuscator": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/active-x-obfuscator/-/active-x-obfuscator-0.0.1.tgz", + "integrity": "sha1-CJuJs3FF/x2ex0r2UwvlUmyuHxo=", + "requires": { + "zeparser": "0.0.5" + } + }, + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "requires": { + "debug": "4" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "ajv": { + "version": "6.12.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.2.tgz", + "integrity": "sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ==", + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true + }, + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, + "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, + "requires": { + "color-convert": "^1.9.0" + } + }, + "anymatch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz", + "integrity": "sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==", + "dev": true, + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==" + }, + "are-we-there-yet": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz", + "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==", + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "array-includes": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.3.tgz", + "integrity": "sha512-gcem1KlBU7c9rB+Rq8/3PPKsK2kjqeEBa3bD5kkQo4nYlOHQCJqIJFqBXDEfwaRuYTT4E+FxA9xez7Gf/e3Q7A==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.2", + "get-intrinsic": "^1.1.1", + "is-string": "^1.0.5" + } + }, + "array.prototype.flat": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.4.tgz", + "integrity": "sha512-4470Xi3GAPAjZqFcljX2xzckv1qeKPizoNkiS0+O4IoPR2ZNpcjE0pkhdihlDouK+x6QOast26B4Q/O9DJnwSg==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.1" + } + }, + "arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==" + }, + "asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", + "requires": { + "safer-buffer": "~2.1.0" + } + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==" + }, + "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 + }, + "astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true + }, + "async": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz", + "integrity": "sha512-l6ToIJIotphWahxxHyzK9bnLR6kM4jJIIgLShZeqLY7iboHoGkdgFl7W2/Ivi4SkMJYGKqW8vSuk0uKUj6qsSw==" + }, + "async-listener": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/async-listener/-/async-listener-0.6.10.tgz", + "integrity": "sha512-gpuo6xOyF4D5DE5WvyqZdPA3NGhiT6Qf07l7DCB0wwDEsLvDIbCr6j9S5aj5Ch96dLace5tXVzWBZkxU/c5ohw==", + "requires": { + "semver": "^5.3.0", + "shimmer": "^1.1.0" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" + } + } + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==" + }, + "aws4": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.10.0.tgz", + "integrity": "sha512-3YDiu347mtVtjpyV3u5kVqQLP242c06zwDOgpeRnybmXlYYsLbtTrUBUm8i8srONt+FWobl5aibnU1030PeeuA==" + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg==" + }, + "base64-js": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", + "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==" + }, + "base64id": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-0.1.0.tgz", + "integrity": "sha512-DSjtfjhAsHl9J4OJj7e4+toV2zqxJrGwVd3CLlsCp8QmicvOn7irG0Mb8brOc/nur3SdO8lIbNlY1s1ZDJdUKQ==" + }, + "basic-auth-connect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/basic-auth-connect/-/basic-auth-connect-1.0.0.tgz", + "integrity": "sha512-kiV+/DTgVro4aZifY/hwRwALBISViL5NP4aReaR2EVJEObpbUBHIkdJh/YpcoEiYt7nBodZ6U2ajZeZvSxUCCg==" + }, + "bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "requires": { + "tweetnacl": "^0.14.3" + } + }, + "bignumber.js": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-7.2.1.tgz", + "integrity": "sha512-S4XzBk5sMB+Rcb/LNcpzXr57VRTxgAvaAEDAl1AwRx27j00hT84O6OkteE7u8UB3NuaaygCRrEpqox4uDOrbdQ==" + }, + "binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true + }, + "bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "requires": { + "file-uri-to-path": "1.0.0" + } + }, + "bintrees": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.1.tgz", + "integrity": "sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ=" + }, + "body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==", + "requires": { + "bytes": "3.1.0", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "~1.1.2", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "on-finished": "~2.3.0", + "qs": "6.7.0", + "raw-body": "2.4.0", + "type-is": "~1.6.17" + } + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true + }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==" + }, + "builtin-modules": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.2.0.tgz", + "integrity": "sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==" + }, + "bunyan": { + "version": "1.8.15", + "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.8.15.tgz", + "integrity": "sha512-0tECWShh6wUysgucJcBAoYegf3JJoZWibxdqhTm7OHPeT42qdjkZ29QCMcKwbgU1kiH+auSIasNRXMLWXafXig==", + "requires": { + "dtrace-provider": "~0.8", + "moment": "^2.19.3", + "mv": "~2", + "safe-json-stringify": "~1" + } + }, + "bytes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", + "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" + }, + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "camelcase": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz", + "integrity": "sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==", + "dev": true + }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" + }, + "chai": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.4.tgz", + "integrity": "sha512-yS5H68VYOCtN1cjfwumDSuzn/9c+yza4f3reKXlE5rUg7SFcCEy90gJvydNgOYtblyf4Zi6jIWRnXOgErta0KA==", + "dev": true, + "requires": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.2", + "deep-eql": "^3.0.1", + "get-func-name": "^2.0.0", + "pathval": "^1.1.1", + "type-detect": "^4.0.5" + } + }, + "chai-as-promised": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz", + "integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==", + "dev": true, + "requires": { + "check-error": "^1.0.2" + } + }, + "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, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=" + }, + "check-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", + "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", + "dev": true + }, + "chokidar": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.1.tgz", + "integrity": "sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==", + "dev": true, + "requires": { + "anymatch": "~3.1.1", + "braces": "~3.0.2", + "glob-parent": "~5.1.0", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.5.0" + } + }, + "chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, + "cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "cluster-key-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz", + "integrity": "sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw==" + }, + "code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" + }, + "coffeescript": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/coffeescript/-/coffeescript-2.5.1.tgz", + "integrity": "sha512-J2jRPX0eeFh5VKyVnoLrfVFgLZtnnmp96WQSLAS8OrLm2wtQLcnikYKe1gViJKDH7vucjuhHvBKKBP3rKcD1tQ==" + }, + "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, + "requires": { + "color-name": "1.1.3" + } + }, + "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 + }, + "combined-stream": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.7.tgz", + "integrity": "sha512-brWl9y6vOB1xYPZcpZde3N9zDByXTosAeMDo4p1wzo6UMOX4vumB+TP1RZ76sfE6Md68Q0NJSrE/gbezd4Ul+w==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "commander": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.1.0.tgz", + "integrity": "sha1-0SG7roYNmZKj1Re6lvVliOR8Z4E=" + }, + "compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "requires": { + "mime-db": ">= 1.43.0 < 2" + }, + "dependencies": { + "mime-db": { + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.46.0.tgz", + "integrity": "sha512-svXaP8UQRZ5K7or+ZmfNhg2xX3yKDMUzqadsSqi4NCH/KomcH75MAMYAGVlvXn4+b/xOPhS3I2uHKRUzvjY7BQ==" + } + } + }, + "compression": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", + "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "requires": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.16", + "debug": "2.6.9", + "on-headers": "~1.0.2", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" + }, + "dependencies": { + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "connect-redis": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-2.5.1.tgz", + "integrity": "sha512-mnPCfN12SYcTw1yYRLejwv6WMmWpNtMkLeVIPzzAkC+u/bY8GxvMezG7wUKpMNoLmggS5xG2DWjEelggv6s5cw==", + "requires": { + "debug": "^1.0.4", + "redis": "^0.12.1" + }, + "dependencies": { + "debug": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-1.0.5.tgz", + "integrity": "sha512-SIKSrp4+XqcUaNWhwaPJbLFnvSXPsZ4xBdH2WRK0Xo++UzMC4eepYghGAVhVhOwmfq3kqowqJ5w45R3pmYZnuA==", + "requires": { + "ms": "2.0.0" + } + } + } + }, + "console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=" + }, + "console-log-level": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/console-log-level/-/console-log-level-1.4.1.tgz", + "integrity": "sha512-VZzbIORbP+PPcN/gg3DXClTLPLg5Slwd5fL2MIc+o1qZ4BXBvWyc6QxPk6T/Mkr6IVjRpoAGf32XxP3ZWMVRcQ==" + }, + "content-disposition": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz", + "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", + "requires": { + "safe-buffer": "5.1.2" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + }, + "continuation-local-storage": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/continuation-local-storage/-/continuation-local-storage-3.2.1.tgz", + "integrity": "sha512-jx44cconVqkCEEyLSKWwkvUXwO561jXMa3LPjTPsm5QR22PA0/mhe33FT4Xb5y74JDvt/Cq+5lm8S8rskLv9ZA==", + "requires": { + "async-listener": "^0.6.0", + "emitter-listener": "^1.1.1" + } + }, + "cookie": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", + "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=" + }, + "cookie-parser": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.5.tgz", + "integrity": "sha512-f13bPUj/gG/5mDr+xLmSxxDsB9DQiTIfhJS/sqjrmfAWiAN+x2O4i/XguTL9yDZ+/IFDanJ+5x7hC4CXT9Tdzw==", + "requires": { + "cookie": "0.4.0", + "cookie-signature": "1.0.6" + }, + "dependencies": { + "cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + } + } + }, + "cookie-signature": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.1.0.tgz", + "integrity": "sha512-Alvs19Vgq07eunykd3Xy2jF0/qSNv2u7KDbAek9H5liV1UMijbqFs5cycZvv5dVsvseT/U4H8/7/w8Koh35C4A==", + "dev": true + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" + }, + "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, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=" + }, + "d64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/d64/-/d64-1.0.0.tgz", + "integrity": "sha1-QAKofoUMv8n52XBrYPymE6MzbpA=" + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "requires": { + "assert-plus": "^1.0.0" + } + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true + }, + "deep-eql": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", + "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", + "dev": true, + "requires": { + "type-detect": "^4.0.0" + } + }, + "deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==" + }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha512-GtxAN4HvBachZzm4OnWqc45ESpUCMwkYcsjnsPs23FwJbsO+k4t0k9bQCgOmzIlpHO28+WPK/KRbRk0DDHuuDw==", + "dev": true + }, + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, + "requires": { + "object-keys": "^1.0.12" + } + }, + "delay": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz", + "integrity": "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==" + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, + "delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=" + }, + "denque": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.4.1.tgz", + "integrity": "sha512-OfzPuSZKGcgr96rf1oODnfjqBFmr1DVoc/TrItj3Ohe0Ah1C5WX5Baquw/9U9KovnQ88EqmJbD66rKYUQYN1tQ==" + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==" + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha512-3NdhDuEXnfun/z7x9GOElY49LoqVHoGScmOKwmxhsS8N5Y+Z8KyPPDnaSzqWgYt/ji4mqwfTS34Htrk0zPIXVg==" + }, + "detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=" + }, + "diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true + }, + "doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "dot-prop": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.2.0.tgz", + "integrity": "sha512-uEUyaDKoSQ1M4Oq8l45hSE26SnTxL6snNnqvK/VWx5wJhmff5z0FUVJDKDanor/6w3kzE3i7XZOk+7wC0EXr1A==", + "requires": { + "is-obj": "^2.0.0" + } + }, + "dtrace-provider": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.8.8.tgz", + "integrity": "sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg==", + "optional": true, + "requires": { + "nan": "^2.14.0" + } + }, + "duplexify": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", + "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", + "requires": { + "end-of-stream": "^1.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.0.0", + "stream-shift": "^1.0.0" + } + }, + "ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "requires": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "emitter-listener": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/emitter-listener/-/emitter-listener-1.1.2.tgz", + "integrity": "sha512-Bt1sBAGFHY9DKY+4/2cV6izcKJUf5T7/gkdmkxzX/qv9CcGH8xSwVRW5mtX03SWJtRTWSOpzCuWN9rBFYZepZQ==", + "requires": { + "shimmer": "^1.2.0" + } + }, + "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 + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" + }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "requires": { + "once": "^1.4.0" + } + }, + "enquirer": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", + "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", + "dev": true, + "requires": { + "ansi-colors": "^4.1.1" + } + }, + "ent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.0.tgz", + "integrity": "sha512-GHrMyVZQWvTIdDtpiEXdHZnFQKzeO09apj8Cbl4pKWy4i0Oprcq17usfDt5aO63swf0JOeMWjWQE/LzgSRuWpA==" + }, + "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, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "es-abstract": { + "version": "1.18.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.3.tgz", + "integrity": "sha512-nQIr12dxV7SSxE6r6f1l3DtAeEYdsGpps13dR0TwJg1S8gyp4ZPgy3FZcHBgbiQqnoqSTb+oC+kO4UQ0C/J8vw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "get-intrinsic": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.2", + "is-callable": "^1.2.3", + "is-negative-zero": "^2.0.1", + "is-regex": "^1.1.3", + "is-string": "^1.0.6", + "object-inspect": "^1.10.3", + "object-keys": "^1.1.1", + "object.assign": "^4.1.2", + "string.prototype.trimend": "^1.0.4", + "string.prototype.trimstart": "^1.0.4", + "unbox-primitive": "^1.0.1" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "eslint": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.30.0.tgz", + "integrity": "sha512-VLqz80i3as3NdloY44BQSJpFw534L9Oh+6zJOUaViV4JPd+DaHwutqP7tcpkW3YiXbK6s05RZl7yl7cQn+lijg==", + "dev": true, + "requires": { + "@babel/code-frame": "7.12.11", + "@eslint/eslintrc": "^0.4.2", + "@humanwhocodes/config-array": "^0.5.0", + "ajv": "^6.10.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.0.1", + "doctrine": "^3.0.0", + "enquirer": "^2.3.5", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^5.1.1", + "eslint-utils": "^2.1.0", + "eslint-visitor-keys": "^2.0.0", + "espree": "^7.3.1", + "esquery": "^1.4.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "functional-red-black-tree": "^1.0.1", + "glob-parent": "^5.1.2", + "globals": "^13.6.0", + "ignore": "^4.0.6", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "js-yaml": "^3.13.1", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.0.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.1", + "progress": "^2.0.0", + "regexpp": "^3.1.0", + "semver": "^7.2.1", + "strip-ansi": "^6.0.0", + "strip-json-comments": "^3.1.0", + "table": "^6.0.9", + "text-table": "^0.2.0", + "v8-compile-cache": "^2.0.3" + }, + "dependencies": { + "chalk": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz", + "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "dependencies": { + "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, + "requires": { + "color-convert": "^2.0.1" + } + }, + "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, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "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, + "requires": { + "color-name": "~1.1.4" + } + }, + "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 + }, + "debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "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 + }, + "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 + }, + "estraverse": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", + "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==" + }, + "glob": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", + "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "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, + "requires": { + "is-glob": "^4.0.1" + } + }, + "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 + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "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, + "requires": { + "yallist": "^4.0.0" + } + }, + "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 + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "requires": { + "glob": "^7.1.3" + } + }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + } + } + }, + "eslint-config-prettier": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.3.0.tgz", + "integrity": "sha512-BgZuLUSeKzvlL/VUjx/Yb787VQ26RU3gGjA3iiFvdsp/2bMfVIWUVP7tjxtjS0e+HP409cPlPvNkQloz8C91ew==", + "dev": true + }, + "eslint-config-standard": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-16.0.3.tgz", + "integrity": "sha512-x4fmJL5hGqNJKGHSjnLdgA6U6h1YW/G2dW9fA+cyVur4SK6lyue8+UgNKWlZtUDTXvgKDD/Oa3GQjmB5kjtVvg==", + "dev": true + }, + "eslint-import-resolver-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.4.tgz", + "integrity": "sha512-ogtf+5AB/O+nM6DIeBUNr2fuT7ot9Qg/1harBfBtaP13ekEWFQEEMP94BCB7zaNW3gyY+8SHYF00rnqYwXKWOA==", + "dev": true, + "requires": { + "debug": "^2.6.9", + "resolve": "^1.13.1" + } + }, + "eslint-module-utils": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.6.1.tgz", + "integrity": "sha512-ZXI9B8cxAJIH4nfkhTwcRTEAnrVfobYqwjWy/QMCZ8rHkZHFjf9yO4BzpiF9kCSfNlMG54eKigISHpX0+AaT4A==", + "dev": true, + "requires": { + "debug": "^3.2.7", + "pkg-dir": "^2.0.0" + }, + "dependencies": { + "debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + } + } + }, + "eslint-plugin-chai-expect": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-chai-expect/-/eslint-plugin-chai-expect-2.2.0.tgz", + "integrity": "sha512-ExTJKhgeYMfY8wDj3UiZmgpMKJOUHGNHmWMlxT49JUDB1vTnw0sSNfXJSxnX+LcebyBD/gudXzjzD136WqPJrQ==", + "dev": true + }, + "eslint-plugin-chai-friendly": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-chai-friendly/-/eslint-plugin-chai-friendly-0.6.0.tgz", + "integrity": "sha512-Uvvv1gkbRGp/qfN15B0kQyQWg+oFA8buDSqrwmW3egNSk/FpqH2MjQqKOuKwmEL6w4QIQrIjDp+gg6kGGmD3oQ==", + "dev": true + }, + "eslint-plugin-es": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-3.0.1.tgz", + "integrity": "sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==", + "dev": true, + "requires": { + "eslint-utils": "^2.0.0", + "regexpp": "^3.0.0" + } + }, + "eslint-plugin-import": { + "version": "2.23.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.23.4.tgz", + "integrity": "sha512-6/wP8zZRsnQFiR3iaPFgh5ImVRM1WN5NUWfTIRqwOdeiGJlBcSk82o1FEVq8yXmy4lkIzTo7YhHCIxlU/2HyEQ==", + "dev": true, + "requires": { + "array-includes": "^3.1.3", + "array.prototype.flat": "^1.2.4", + "debug": "^2.6.9", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.4", + "eslint-module-utils": "^2.6.1", + "find-up": "^2.0.0", + "has": "^1.0.3", + "is-core-module": "^2.4.0", + "minimatch": "^3.0.4", + "object.values": "^1.1.3", + "pkg-up": "^2.0.0", + "read-pkg-up": "^3.0.0", + "resolve": "^1.20.0", + "tsconfig-paths": "^3.9.0" + }, + "dependencies": { + "doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "resolve": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", + "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", + "dev": true, + "requires": { + "is-core-module": "^2.2.0", + "path-parse": "^1.0.6" + } + } + } + }, + "eslint-plugin-mocha": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-mocha/-/eslint-plugin-mocha-8.2.0.tgz", + "integrity": "sha512-8oOR47Ejt+YJPNQzedbiklDqS1zurEaNrxXpRs+Uk4DMDPVmKNagShFeUaYsfvWP55AhI+P1non5QZAHV6K78A==", + "dev": true, + "requires": { + "eslint-utils": "^2.1.0", + "ramda": "^0.27.1" + } + }, + "eslint-plugin-node": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz", + "integrity": "sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==", + "dev": true, + "requires": { + "eslint-plugin-es": "^3.0.0", + "eslint-utils": "^2.0.0", + "ignore": "^5.1.1", + "minimatch": "^3.0.4", + "resolve": "^1.10.1", + "semver": "^6.1.0" + }, + "dependencies": { + "ignore": { + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz", + "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==", + "dev": true + } + } + }, + "eslint-plugin-prettier": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.4.tgz", + "integrity": "sha512-jZDa8z76klRqo+TdGDTFJSavwbnWK2ZpqGKNZ+VvweMW516pDUMmQ2koXvxEE4JhzNvTv+radye/bWGBmA6jmg==", + "dev": true, + "requires": { + "prettier-linter-helpers": "^1.0.0" + } + }, + "eslint-plugin-promise": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-4.2.1.tgz", + "integrity": "sha512-VoM09vT7bfA7D+upt+FjeBO5eHIJQBUWki1aPvB+vbNiHS3+oGIJGIeyBtKQTME6UPXXy3vV07OL1tHd3ANuDw==", + "dev": true + }, + "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, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + } + }, + "eslint-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", + "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^1.1.0" + } + }, + "eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true + }, + "espree": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", + "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==", + "dev": true, + "requires": { + "acorn": "^7.4.0", + "acorn-jsx": "^5.3.1", + "eslint-visitor-keys": "^1.3.0" + }, + "dependencies": { + "acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true + } + } + }, + "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 + }, + "esquery": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", + "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", + "dev": true + }, + "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, + "requires": { + "estraverse": "^5.2.0" + }, + "dependencies": { + "estraverse": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", + "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", + "dev": true + } + } + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" + }, + "event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" + }, + "eventid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/eventid/-/eventid-1.0.0.tgz", + "integrity": "sha512-4upSDsvpxhWPsmw4fsJCp0zj8S7I0qh1lCDTmZXP8V3TtryQKDI8CgQPN+e5JakbWwzaAX3lrdp2b3KSoMSUpw==", + "requires": { + "d64": "^1.0.0", + "uuid": "^3.0.1" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + } + } + }, + "express": { + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz", + "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==", + "requires": { + "accepts": "~1.3.7", + "array-flatten": "1.1.1", + "body-parser": "1.19.0", + "content-disposition": "0.5.3", + "content-type": "~1.0.4", + "cookie": "0.4.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~1.1.2", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.1.2", + "fresh": "0.5.2", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.5", + "qs": "6.7.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.1.2", + "send": "0.17.1", + "serve-static": "1.14.1", + "setprototypeof": "1.1.1", + "statuses": "~1.5.0", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "dependencies": { + "cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, + "express-session": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.1.tgz", + "integrity": "sha512-UbHwgqjxQZJiWRTMyhvWGvjBQduGCSBDhhZXYenziMFjxst5rMV+aJZ6hKPHZnPyHGsrqRICxtX8jtEbm/z36Q==", + "requires": { + "cookie": "0.4.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.0.2", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.0", + "uid-safe": "~2.1.5" + }, + "dependencies": { + "cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + }, + "safe-buffer": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz", + "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==" + } + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==" + }, + "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==" + }, + "fast-diff": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz", + "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", + "dev": true + }, + "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==" + }, + "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 + }, + "fast-text-encoding": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.1.tgz", + "integrity": "sha512-x4FEgaz3zNRtJfLFqJmHWxkMDDvXVtaznj2V9jiP8ACUJrUgist4bP9FmDL2Vew2Y9mEQI/tG4GqabaitYp9CQ==" + }, + "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, + "requires": { + "flat-cache": "^3.0.4" + } + }, + "file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + } + }, + "find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", + "dev": true, + "requires": { + "locate-path": "^2.0.0" + } + }, + "findit2": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/findit2/-/findit2-2.2.3.tgz", + "integrity": "sha1-WKRmaX34piBc39vzlVNri9d3pfY=" + }, + "flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true + }, + "flat-cache": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", + "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "dev": true, + "requires": { + "flatted": "^3.1.0" + } + }, + "flatted": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.1.tgz", + "integrity": "sha512-OMQjaErSFHmHqZe+PSidH5n8j3O0F2DdnVh8JB4j4eUQ2k6KvB0qGfrKIhapvez5JerBbmWkaLYUYWISaESoXg==", + "dev": true + }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==" + }, + "form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + }, + "forwarded": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", + "integrity": "sha512-Ua9xNhH0b8pwE3yRbFfXJvfdWF0UHNCdeyb2sbi9Ul/M+r3PTdrz7Cv4SCfZRMjmzEM9PhraqfZFbGTIg3OMyA==" + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" + }, + "fs-minipass": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz", + "integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==", + "requires": { + "minipass": "^2.6.0" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "dev": true + }, + "gauge": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "requires": { + "ansi-regex": "^2.0.0" + } + } + } + }, + "gaxios": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-4.2.0.tgz", + "integrity": "sha512-Ms7fNifGv0XVU+6eIyL9LB7RVESeML9+cMvkwGS70xyD6w2Z80wl6RiqiJ9k1KFlJCUTQqFFc8tXmPQfSKUe8g==", + "requires": { + "abort-controller": "^3.0.0", + "extend": "^3.0.2", + "https-proxy-agent": "^5.0.0", + "is-stream": "^2.0.0", + "node-fetch": "^2.3.0" + } + }, + "gcp-metadata": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-4.2.1.tgz", + "integrity": "sha512-tSk+REe5iq/N+K+SK1XjZJUrFPuDqGZVzCy2vocIHIGmPlTGsa8owXMJwGkrXr73NO0AzhPW4MF2DEHz7P2AVw==", + "requires": { + "gaxios": "^4.0.0", + "json-bigint": "^1.0.0" + }, + "dependencies": { + "bignumber.js": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.1.tgz", + "integrity": "sha512-IdZR9mh6ahOBv/hYGiXyVuyCetmGJhtYkqLBpTStdhEGjegpPlUawydyaF3pbIOFynJTpllEs+NP+CS9jKFLjA==" + }, + "json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "requires": { + "bignumber.js": "^9.0.0" + } + } + } + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true + }, + "get-func-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", + "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", + "dev": true + }, + "get-intrinsic": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", + "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1" + } + }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "requires": { + "assert-plus": "^1.0.0" + } + }, + "glob": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", + "integrity": "sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI=", + "optional": true, + "requires": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "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, + "requires": { + "is-glob": "^4.0.1" + } + }, + "globals": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.10.0.tgz", + "integrity": "sha512-piHC3blgLGFjvOuMmWZX60f+na1lXFDhQXBf1UYp2fXPXqvEUbOhNwi6BsQ0bQishwedgnjkwv1d9zKf+MWw3g==", + "dev": true, + "requires": { + "type-fest": "^0.20.2" + } + }, + "google-auth-library": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-7.0.3.tgz", + "integrity": "sha512-6wJNYqY1QUr5I2lWaUkkzOT2b9OCNhNQrdFOt/bsBbGb7T7NCdEvrBsXraUm+KTUGk2xGlQ7m9RgUd4Llcw8NQ==", + "requires": { + "arrify": "^2.0.0", + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "fast-text-encoding": "^1.0.0", + "gaxios": "^4.0.0", + "gcp-metadata": "^4.2.0", + "gtoken": "^5.0.4", + "jws": "^4.0.0", + "lru-cache": "^6.0.0" + }, + "dependencies": { + "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==", + "requires": { + "yallist": "^4.0.0" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + } + }, + "google-gax": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-1.15.3.tgz", + "integrity": "sha512-3JKJCRumNm3x2EksUTw4P1Rad43FTpqrtW9jzpf3xSMYXx+ogaqTM1vGo7VixHB4xkAyATXVIa3OcNSh8H9zsQ==", + "requires": { + "@grpc/grpc-js": "~1.0.3", + "@grpc/proto-loader": "^0.5.1", + "@types/fs-extra": "^8.0.1", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^3.6.0", + "google-auth-library": "^5.0.0", + "is-stream-ended": "^0.1.4", + "lodash.at": "^4.6.0", + "lodash.has": "^4.5.2", + "node-fetch": "^2.6.0", + "protobufjs": "^6.8.9", + "retry-request": "^4.0.0", + "semver": "^6.0.0", + "walkdir": "^0.4.0" + }, + "dependencies": { + "debug": { + "version": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + }, + "gaxios": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-2.3.4.tgz", + "integrity": "sha512-US8UMj8C5pRnao3Zykc4AAVr+cffoNKRTg9Rsf2GiuZCW69vgJj38VK2PzlPuQU73FZ/nTk9/Av6/JGcE1N9vA==", + "requires": { + "abort-controller": "^3.0.0", + "extend": "^3.0.2", + "https-proxy-agent": "^5.0.0", + "is-stream": "^2.0.0", + "node-fetch": "^2.3.0" + } + }, + "gcp-metadata": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-3.5.0.tgz", + "integrity": "sha512-ZQf+DLZ5aKcRpLzYUyBS3yo3N0JSa82lNDO8rj3nMSlovLcz2riKFBsYgDzeXcv75oo5eqB2lx+B14UvPoCRnA==", + "requires": { + "gaxios": "^2.1.0", + "json-bigint": "^0.3.0" + } + }, + "google-auth-library": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-5.10.1.tgz", + "integrity": "sha512-rOlaok5vlpV9rSiUu5EpR0vVpc+PhN62oF4RyX/6++DG1VsaulAFEMlDYBLjJDDPI6OcNOCGAKy9UVB/3NIDXg==", + "requires": { + "arrify": "^2.0.0", + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "fast-text-encoding": "^1.0.0", + "gaxios": "^2.1.0", + "gcp-metadata": "^3.4.0", + "gtoken": "^4.1.0", + "jws": "^4.0.0", + "lru-cache": "^5.0.0" + } + }, + "google-p12-pem": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-2.0.4.tgz", + "integrity": "sha512-S4blHBQWZRnEW44OcR7TL9WR+QCqByRvhNDZ/uuQfpxywfupikf/miba8js1jZi6ZOGv5slgSuoshCWh6EMDzg==", + "requires": { + "node-forge": "^0.9.0" + } + }, + "gtoken": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-4.1.4.tgz", + "integrity": "sha512-VxirzD0SWoFUo5p8RDP8Jt2AGyOmyYcT/pOUgDKJCK+iSw0TMqwrVfY37RXTNmoKwrzmDHSk0GMT9FsgVmnVSA==", + "requires": { + "gaxios": "^2.1.0", + "google-p12-pem": "^2.0.0", + "jws": "^4.0.0", + "mime": "^2.2.0" + } + }, + "mime": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.6.tgz", + "integrity": "sha512-RZKhC3EmpBchfTGBVb8fb+RL2cWyw/32lshnsETttkBAyAUXSGHxbEJWWRXc751DrIxG1q04b8QwMbAwkRPpUA==" + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node-forge": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.9.1.tgz", + "integrity": "sha512-G6RlQt5Sb4GMBzXvhfkeFmbqR6MzhtnT7VTHuLadjkii3rdYHNdw0m8zA4BTxVIh68FicCQ2NSUANpsqkr9jvQ==" + } + } + }, + "google-p12-pem": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-3.0.3.tgz", + "integrity": "sha512-wS0ek4ZtFx/ACKYF3JhyGe5kzH7pgiQ7J5otlumqR9psmWMYc+U9cErKlCYVYHoUaidXHdZ2xbo34kB+S+24hA==", + "requires": { + "node-forge": "^0.10.0" + } + }, + "graceful-fs": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz", + "integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==", + "dev": true + }, + "growl": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", + "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", + "dev": true + }, + "gtoken": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-5.2.1.tgz", + "integrity": "sha512-OY0BfPKe3QnMsY9MzTHTSKn+Vl2l1CcLe6BwDEQj00mbbkl5nyQ/7EUREstg4fQNZ8iYE7br4JJ7TdKeDOPWmw==", + "requires": { + "gaxios": "^4.0.0", + "google-p12-pem": "^3.0.3", + "jws": "^4.0.0" + } + }, + "har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==" + }, + "har-validator": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz", + "integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==", + "requires": { + "ajv": "^6.5.5", + "har-schema": "^2.0.0" + } + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-bigints": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz", + "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==", + "dev": true + }, + "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 + }, + "has-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", + "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", + "dev": true + }, + "has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=" + }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true + }, + "hex2dec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/hex2dec/-/hex2dec-1.1.2.tgz", + "integrity": "sha512-Yu+q/XWr2fFQ11tHxPq4p4EiNkb2y+lAacJNhAdRXVfRIcDH6gi7htWFnnlIzvqHMHoWeIsfXlNAjZInpAOJDA==" + }, + "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 + }, + "http-errors": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", + "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + } + }, + "http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "requires": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "requires": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, + "https-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", + "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", + "requires": { + "agent-base": "6", + "debug": "4" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ignore": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "dev": true + }, + "ignore-walk": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.3.tgz", + "integrity": "sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw==", + "requires": { + "minimatch": "^3.0.4" + } + }, + "import-fresh": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.2.1.tgz", + "integrity": "sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==" + }, + "ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + }, + "ioredis": { + "version": "4.17.3", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.17.3.tgz", + "integrity": "sha512-iRvq4BOYzNFkDnSyhx7cmJNOi1x/HWYe+A4VXHBu4qpwJaGT1Mp+D2bVGJntH9K/Z/GeOM/Nprb8gB3bmitz1Q==", + "requires": { + "cluster-key-slot": "^1.1.0", + "debug": "^4.1.1", + "denque": "^1.1.0", + "lodash.defaults": "^4.2.0", + "lodash.flatten": "^4.4.0", + "redis-commands": "1.5.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.0.1" + }, + "dependencies": { + "debug": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.2.0.tgz", + "integrity": "sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + }, + "is": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/is/-/is-3.3.0.tgz", + "integrity": "sha512-nW24QBoPcFGGHJGUwnfpI7Yc5CdqWNdsyHQszVE/z2pKHXzh7FZ5GWhJqSyaQ9wMkQnsTx+kAI8bHlCX4tKdbg==" + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + }, + "is-bigint": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.2.tgz", + "integrity": "sha512-0JV5+SOCQkIdzjBK9buARcV804Ddu7A0Qet6sHi3FimE9ne6m4BGQZfRn+NZiXbBk4F4XmHfDZIipLj9pX8dSA==", + "dev": true + }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "requires": { + "binary-extensions": "^2.0.0" + } + }, + "is-boolean-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.1.tgz", + "integrity": "sha512-bXdQWkECBUIAcCkeH1unwJLIpZYaa5VvuygSyS/c2lf719mTKZDU5UdDRlpd01UjADgmW8RfqaP+mRaVPdr/Ng==", + "dev": true, + "requires": { + "call-bind": "^1.0.2" + } + }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, + "is-callable": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.3.tgz", + "integrity": "sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ==", + "dev": true + }, + "is-core-module": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.5.0.tgz", + "integrity": "sha512-TXCMSDsEHMEEZ6eCA8rwRDbLu55MRGmrctljsBX/2v1d9/GzqHOxW5c5oPSgrUt2vBFXebu9rGqckXGPWOlYpg==", + "dev": true, + "requires": { + "has": "^1.0.3" + } + }, + "is-date-object": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.4.tgz", + "integrity": "sha512-/b4ZVsG7Z5XVtIxs/h9W8nvfLgSAyKYdtGWQLbqy6jA1icmgjf8WCoTKgeS4wy5tYaPePouzFMANbnj94c2Z+A==", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true + }, + "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 + }, + "is-glob": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", + "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-negative-zero": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz", + "integrity": "sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==", + "dev": true + }, + "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 + }, + "is-number-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.5.tgz", + "integrity": "sha512-RU0lI/n95pMoUKu9v1BZP5MBcZuNSVJkMkAG2dJqC4z2GlkGUNeH68SuHuBKBD/XFe+LHZ+f9BKkLET60Niedw==", + "dev": true + }, + "is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==" + }, + "is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true + }, + "is-regex": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.3.tgz", + "integrity": "sha512-qSVXFz28HM7y+IWX6vLCsexdlvzT1PJNFSBuaQLQ5o0IEw8UDYW6/2+eCMVyIsbM8CNLX2a/QWmSpyxYEHY7CQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "has-symbols": "^1.0.2" + } + }, + "is-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", + "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==" + }, + "is-stream-ended": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-stream-ended/-/is-stream-ended-0.1.4.tgz", + "integrity": "sha512-xj0XPvmr7bQFTvirqnFr50o0hQIh6ZItDqloxt5aJrR4NQsYeSsyFQERYGCAzfindAcnKjINnwEEgLx4IqVzQw==" + }, + "is-string": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.6.tgz", + "integrity": "sha512-2gdzbKUuqtQ3lYNrUTQYoClPhm7oQu4UdpSZMp1/DGgkHBT8E2Z1l0yMdb6D4zNAxwDiMv8MdulKROJGNl0Q0w==", + "dev": true + }, + "is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "requires": { + "has-symbols": "^1.0.2" + } + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==" + }, + "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 + }, + "js-yaml": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz", + "integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==" + }, + "json-bigint": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-0.3.0.tgz", + "integrity": "sha512-u+c/u/F+JNPUekHCFyGVycRPyh9UHD5iUhSyIAn10kxbDTJxijwAbT6XHaONEOXuGGfmWUSroheXgHcml4gLgg==", + "requires": { + "bignumber.js": "^7.0.0" + } + }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha512-a3xHnILGMtk+hDOqNwHzF6e2fNbiMrXZvxKQiEv2MlgQP+pjIOzqAmKYD2mDpXYE/44M7g+n9p2bKkYWDUcXCQ==" + }, + "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==" + }, + "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 + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==" + }, + "json5": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz", + "integrity": "sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + }, + "dependencies": { + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true + } + } + }, + "jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha512-4Dj8Rf+fQ+/Pn7C5qeEX02op1WfOss3PKTE9Nsop3Dx+6UPxlm1dr/og7o2cRa5hNN07CACr4NFzRLtj/rjWog==", + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + } + }, + "just-extend": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.1.1.tgz", + "integrity": "sha512-aWgeGFW67BP3e5181Ep1Fv2v8z//iBJfrvyTnq8wG86vEESwmonn1zPBJ0VfmT9CJq2FIT0VsETtrNFm2a+SHA==", + "dev": true + }, + "jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "requires": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "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, + "requires": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + } + }, + "load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha1-L19Fq5HjMhYjT9U62rZo607AmTs=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "dependencies": { + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + } + } + }, + "locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", + "dev": true, + "requires": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + } + }, + "lodash.at": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.at/-/lodash.at-4.6.0.tgz", + "integrity": "sha1-k83OZk8KGZTqM9181A4jr9EbD/g=" + }, + "lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=" + }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", + "dev": true + }, + "lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=" + }, + "lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=" + }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", + "dev": true + }, + "lodash.has": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/lodash.has/-/lodash.has-4.5.2.tgz", + "integrity": "sha1-0Z9NwQlQWMzL4rDN9O4P5Ko3yGI=" + }, + "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 + }, + "lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=", + "dev": true + }, + "log-driver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/log-driver/-/log-driver-1.2.7.tgz", + "integrity": "sha512-U7KCmLdqsGHBLeWqYlFA0V0Sl6P08EE1ZrmA9cxjUE0WVqT9qnyVDPz1kzpFEP0jdJuFnasWIfSd7fsaNXkpbg==" + }, + "log-symbols": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.0.0.tgz", + "integrity": "sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA==", + "dev": true, + "requires": { + "chalk": "^4.0.0" + }, + "dependencies": { + "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, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "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, + "requires": { + "color-name": "~1.1.4" + } + }, + "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 + }, + "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 + }, + "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, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "logger-sharelatex": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/logger-sharelatex/-/logger-sharelatex-2.2.0.tgz", + "integrity": "sha512-ko+OmE25XHJJCiz1R9EgwlfM7J/5olpunUfR3WcfuqOQrcUqsdBrDA2sOytngT0ViwjCR0Fh4qZVPwEWfmrvwA==", + "requires": { + "@google-cloud/logging-bunyan": "^3.0.0", + "@overleaf/o-error": "^3.0.0", + "bunyan": "^1.8.14", + "node-fetch": "^2.6.0", + "raven": "^2.6.4", + "yn": "^4.0.0" + }, + "dependencies": { + "yn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yn/-/yn-4.0.0.tgz", + "integrity": "sha512-huWiiCS4TxKc4SfgmTwW1K7JmXPPAmuXWYy4j9qjQo4+27Kni8mGhAAi1cloRWmBe2EqcLgt3IGqQoRL/MtPgg==" + } + } + }, + "long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + }, + "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==", + "requires": { + "yallist": "^3.0.2" + } + }, + "map-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.1.0.tgz", + "integrity": "sha512-glc9y00wgtwcDmp7GaE/0b0OnxpNJsVf3ael/An6Fe2Q51LLwN1er6sdomLRzz5h0+yMpiYLhWYF5R7HeqVd4g==" + }, + "md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "requires": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, + "mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==" + }, + "mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "requires": { + "mime-db": "~1.33.0" + } + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha512-miQKw5Hv4NS1Psg2517mV4e4dYNaO3++hjAvLOAzKqZ61rH8NS1SK+vbfBWZ5PY/Me/bEWhUwqMghEW5Fb9T7Q==" + }, + "minipass": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz", + "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==", + "requires": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + } + } + }, + "minizlib": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz", + "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==", + "requires": { + "minipass": "^2.9.0" + } + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==", + "requires": { + "minimist": "0.0.8" + } + }, + "mocha": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-8.3.2.tgz", + "integrity": "sha512-UdmISwr/5w+uXLPKspgoV7/RXZwKRTiTjJ2/AC5ZiEztIoOYdfKb19+9jNmEInzx5pBsCyJQzarAxqIGBNYJhg==", + "dev": true, + "requires": { + "@ungap/promise-all-settled": "1.1.2", + "ansi-colors": "4.1.1", + "browser-stdout": "1.3.1", + "chokidar": "3.5.1", + "debug": "4.3.1", + "diff": "5.0.0", + "escape-string-regexp": "4.0.0", + "find-up": "5.0.0", + "glob": "7.1.6", + "growl": "1.10.5", + "he": "1.2.0", + "js-yaml": "4.0.0", + "log-symbols": "4.0.0", + "minimatch": "3.0.4", + "ms": "2.1.3", + "nanoid": "3.1.20", + "serialize-javascript": "5.0.1", + "strip-json-comments": "3.1.1", + "supports-color": "8.1.1", + "which": "2.0.2", + "wide-align": "1.1.3", + "workerpool": "6.1.0", + "yargs": "16.2.0", + "yargs-parser": "20.2.4", + "yargs-unparser": "2.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "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 + }, + "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==", + "requires": { + "color-name": "~1.1.4" + } + }, + "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==" + }, + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + }, + "dependencies": { + "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 + } + } + }, + "diff": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", + "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", + "dev": true + }, + "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 + }, + "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, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "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 + }, + "js-yaml": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.0.0.tgz", + "integrity": "sha512-pqon0s+4ScYUvX30wxQi3PogGFAlUyH0awepWvwkj4jD4v+ova3RiYw8bmA6x2rDrEaj8i/oWKoRxpVNW+Re8Q==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + } + }, + "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, + "requires": { + "p-locate": "^5.0.0" + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "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, + "requires": { + "p-limit": "^3.0.2" + } + }, + "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 + }, + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "module-details-from-path": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.3.tgz", + "integrity": "sha1-EUyUlnPiqKNenTV4hSeqN7Z52is=" + }, + "moment": { + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", + "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==", + "optional": true + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "mv": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz", + "integrity": "sha1-rmzg1vbV4KT32JN5jQPB6pVZtqI=", + "optional": true, + "requires": { + "mkdirp": "~0.5.1", + "ncp": "~2.0.0", + "rimraf": "~2.4.0" + } + }, + "nan": { + "version": "2.14.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz", + "integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==" + }, + "nanoid": { + "version": "3.1.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.20.tgz", + "integrity": "sha512-a1cQNyczgKbLX9jwbS/+d7W8fX/RfgYR7lVWwWOGIPNgK2m0MWvrGF6/m4kk6U3QcFMnZf3RIhL0v2Jgh/0Uxw==", + "dev": true + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "ncp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", + "integrity": "sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=", + "optional": true + }, + "needle": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/needle/-/needle-2.6.0.tgz", + "integrity": "sha512-KKYdza4heMsEfSWD7VPUIz3zX2XDwOyX2d+geb4vrERZMT5RMU6ujjaD+I5Yr54uZxQ2w6XRTAhHBbSCyovZBg==", + "requires": { + "debug": "^3.2.6", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + }, + "dependencies": { + "debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + } + } + }, + "negotiator": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", + "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" + }, + "nise": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/nise/-/nise-4.1.0.tgz", + "integrity": "sha512-eQMEmGN/8arp0xsvGoQ+B1qvSkR73B1nWSCh7nOt5neMCtwcQVYQGdzQMhcNscktTsWB54xnlSQFzOAPJD8nXA==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.7.0", + "@sinonjs/fake-timers": "^6.0.0", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + }, + "path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dev": true, + "requires": { + "isarray": "0.0.1" + } + } + } + }, + "node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" + }, + "node-forge": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz", + "integrity": "sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==" + }, + "node-pre-gyp": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.16.0.tgz", + "integrity": "sha512-4efGA+X/YXAHLi1hN8KaPrILULaUn2nWecFrn1k2I+99HpoyvcOGEbtcOxpDiUwPF2ZANMJDh32qwOUPenuR1g==", + "requires": { + "detect-libc": "^1.0.2", + "mkdirp": "^0.5.3", + "needle": "^2.5.0", + "nopt": "^4.0.1", + "npm-packlist": "^1.1.6", + "npmlog": "^4.0.2", + "rc": "^1.2.7", + "rimraf": "^2.6.1", + "semver": "^5.3.0", + "tar": "^4.4.2" + }, + "dependencies": { + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + }, + "mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "requires": { + "minimist": "^1.2.5" + } + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "requires": { + "glob": "^7.1.3" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" + } + } + }, + "nopt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz", + "integrity": "sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==", + "requires": { + "abbrev": "1", + "osenv": "^0.1.4" + } + }, + "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, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "npm-bundled": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.1.tgz", + "integrity": "sha512-gqkfgGePhTpAEgUsGEgcq1rqPXA+tv/aVBlgEzfXwA1yiUJF7xtEt3CtVwOjNYQOVknDk0F20w58Fnm3EtG0fA==", + "requires": { + "npm-normalize-package-bin": "^1.0.1" + } + }, + "npm-normalize-package-bin": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz", + "integrity": "sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA==" + }, + "npm-packlist": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.8.tgz", + "integrity": "sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A==", + "requires": { + "ignore-walk": "^3.0.1", + "npm-bundled": "^1.0.1", + "npm-normalize-package-bin": "^1.0.1" + } + }, + "npmlog": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" + }, + "oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "object-inspect": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.11.0.tgz", + "integrity": "sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg==", + "dev": true + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + }, + "object.assign": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", + "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "has-symbols": "^1.0.1", + "object-keys": "^1.1.1" + } + }, + "object.values": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.4.tgz", + "integrity": "sha512-TnGo7j4XSnKQoK3MfvkzqKCi0nVe/D9I9IjwTNYdb/fxYHpjrluHVOgw0AF6jrRFGMPHdfuidR09tIDiIvnaSg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.18.2" + } + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "requires": { + "ee-first": "1.1.1" + } + }, + "on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==" + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "requires": { + "wrappy": "1" + } + }, + "optionator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", + "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "dev": true, + "requires": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.3" + } + }, + "options": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/options/-/options-0.0.6.tgz", + "integrity": "sha1-7CLTEoBrtT5zF3Pnza788cZDEo8=" + }, + "os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=" + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==" + }, + "osenv": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", + "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", + "dev": true, + "requires": { + "p-limit": "^1.1.0" + }, + "dependencies": { + "p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "requires": { + "p-try": "^1.0.0" + } + } + } + }, + "p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=", + "dev": true + }, + "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, + "requires": { + "callsites": "^3.0.0" + } + }, + "parse-duration": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parse-duration/-/parse-duration-1.0.0.tgz", + "integrity": "sha512-X4kUkCTHU1N/kEbwK9FpUJ0UZQa90VzeczfS704frR30gljxDG0pSziws06XlK+CGRSo/1wtG1mFIdBFQTMQNw==" + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "parse-ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz", + "integrity": "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==" + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true + }, + "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==" + }, + "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 + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==" + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + }, + "path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "requires": { + "pify": "^3.0.0" + }, + "dependencies": { + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + } + } + }, + "pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" + }, + "picomatch": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", + "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==", + "dev": true + }, + "pify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz", + "integrity": "sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==" + }, + "pkg-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-2.0.0.tgz", + "integrity": "sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s=", + "dev": true, + "requires": { + "find-up": "^2.1.0" + } + }, + "pkg-up": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-2.0.0.tgz", + "integrity": "sha1-yBmscoBZpGHKscOImivjxJoATX8=", + "dev": true, + "requires": { + "find-up": "^2.1.0" + } + }, + "policyfile": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/policyfile/-/policyfile-0.0.4.tgz", + "integrity": "sha1-1rgurZiueeviKOLa9ZAzEeyYLk0=" + }, + "pprof": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pprof/-/pprof-3.0.0.tgz", + "integrity": "sha512-uPWbAhoH/zvq1kM3/Fd/wshb4D7sLlGap8t6uCTER4aZRWqqyPYgXzpjWbT0Unn5U25pEy2VREUu27nQ9o9VPA==", + "requires": { + "bindings": "^1.2.1", + "delay": "^4.0.1", + "findit2": "^2.2.3", + "nan": "^2.14.0", + "node-pre-gyp": "^0.16.0", + "p-limit": "^3.0.0", + "pify": "^5.0.0", + "protobufjs": "~6.10.0", + "source-map": "^0.7.3", + "split": "^1.0.1" + }, + "dependencies": { + "delay": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/delay/-/delay-4.4.1.tgz", + "integrity": "sha512-aL3AhqtfhOlT/3ai6sWXeqwnw63ATNpnUiN4HL7x9q+My5QtHlO3OIkasmug9LKzpheLdmUKGRKnYXYAS7FQkQ==" + }, + "protobufjs": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.10.2.tgz", + "integrity": "sha512-27yj+04uF6ya9l+qfpH187aqEzfCF4+Uit0I9ZBQVqK09hk/SQzKa2MUqUpXaVa7LOFRg1TSSr3lVxGOk6c0SQ==", + "requires": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.1", + "@types/node": "^13.7.0", + "long": "^4.0.0" + } + }, + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==" + } + } + }, + "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 + }, + "prettier": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.2.tgz", + "integrity": "sha512-lnJzDfJ66zkMy58OL5/NY5zp70S7Nz6KqcKkXYzn2tMVrNxvbqaBpg7H3qHaLxCJ5lNMsGuM8+ohS7cZrthdLQ==", + "dev": true + }, + "prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "requires": { + "fast-diff": "^1.1.2" + } + }, + "pretty-ms": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-7.0.1.tgz", + "integrity": "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==", + "requires": { + "parse-ms": "^2.1.0" + } + }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true + }, + "prom-client": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-11.5.3.tgz", + "integrity": "sha512-iz22FmTbtkyL2vt0MdDFY+kWof+S9UB/NACxSn2aJcewtw+EERsen0urSkZ2WrHseNdydsvcxCTAnPcSMZZv4Q==", + "requires": { + "tdigest": "^0.1.1" + } + }, + "protobufjs": { + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.9.tgz", + "integrity": "sha512-j2JlRdUeL/f4Z6x4aU4gj9I2LECglC+5qR2TrWb193Tla1qfdaNQTZ8I27Pt7K0Ajmvjjpft7O3KWTGciz4gpw==", + "requires": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.0", + "@types/node": "^10.1.0", + "long": "^4.0.0" + }, + "dependencies": { + "@types/node": { + "version": "10.17.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.17.tgz", + "integrity": "sha512-gpNnRnZP3VWzzj5k3qrpRC6Rk3H/uclhAVo1aIvwzK5p5cOrs9yEyQ8H/HBsBY0u5rrWxXEiVPQ0dEB6pkjE8Q==" + } + } + }, + "proxy-addr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz", + "integrity": "sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw==", + "requires": { + "forwarded": "~0.1.2", + "ipaddr.js": "1.9.1" + } + }, + "psl": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", + "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "pumpify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pumpify/-/pumpify-2.0.1.tgz", + "integrity": "sha512-m7KOje7jZxrmutanlkS1daj1dS6z6BgslzOXmcSEpIlCxM3VJH7lG5QLeck/6hgF6F4crFf01UtQmNsJfweTAw==", + "requires": { + "duplexify": "^4.1.1", + "inherits": "^2.0.3", + "pump": "^3.0.0" + }, + "dependencies": { + "duplexify": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.1.tgz", + "integrity": "sha512-DY3xVEmVHTv1wSzKNbwoU6nVjzI369Y6sPoqfYr0/xlx3IdX2n94xIszTcjPO8W8ZIv0Wb0PXNcjuZyT4wiICA==", + "requires": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.0" + } + }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + }, + "qs": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", + "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" + }, + "ramda": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.27.1.tgz", + "integrity": "sha512-PgIdVpn5y5Yns8vqb8FzBUEYn98V3xcPgawAkkgj0YJ0qDsnHCiNmZYfOGMgOvoB0eWFLpYbhxUR3mxfDIMvpw==", + "dev": true + }, + "random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==" + }, + "randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.0" + } + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "raven": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/raven/-/raven-2.6.4.tgz", + "integrity": "sha512-6PQdfC4+DQSFncowthLf+B6Hr0JpPsFBgTVYTAOq7tCmx/kR4SXbeawtPch20+3QfUcQDoJBLjWW1ybvZ4kXTw==", + "requires": { + "cookie": "0.3.1", + "md5": "^2.2.1", + "stack-trace": "0.0.10", + "timed-out": "4.0.1", + "uuid": "3.3.2" + }, + "dependencies": { + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + } + } + }, + "raw-body": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", + "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==", + "requires": { + "bytes": "3.1.0", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + }, + "rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "dependencies": { + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=" + } + } + }, + "read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=", + "dev": true, + "requires": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + } + }, + "read-pkg-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-3.0.0.tgz", + "integrity": "sha1-PtSWaF26D4/hGNBpHcUfSh/5bwc=", + "dev": true, + "requires": { + "find-up": "^2.0.0", + "read-pkg": "^3.0.0" + } + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "readdirp": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz", + "integrity": "sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==", + "dev": true, + "requires": { + "picomatch": "^2.2.1" + } + }, + "redis": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-0.12.1.tgz", + "integrity": "sha512-DtqxdmgmVAO7aEyxaXBiUTvhQPOYznTIvmPzs9AwWZqZywM50JlFxQjFhicI+LVbaun7uwfO3izuvc1L8NlPKQ==" + }, + "redis-commands": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.5.0.tgz", + "integrity": "sha512-6KxamqpZ468MeQC3bkWmCB1fp56XL64D4Kf0zJSwDZbVLLm7KFkoIcHrgRvQ+sk8dnhySs7+yBg94yIkAK7aJg==" + }, + "redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=" + }, + "redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", + "requires": { + "redis-errors": "^1.0.0" + } + }, + "regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "dev": true + }, + "request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "dependencies": { + "mime-db": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz", + "integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==" + }, + "mime-types": { + "version": "2.1.27", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz", + "integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==", + "requires": { + "mime-db": "1.44.0" + } + }, + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + } + } + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true + }, + "require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true + }, + "require-in-the-middle": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-5.1.0.tgz", + "integrity": "sha512-M2rLKVupQfJ5lf9OvqFGIT+9iVLnTmjgbOmpil12hiSQNn5zJTKGPoIisETNjfK+09vP3rpm1zJajmErpr2sEQ==", + "requires": { + "debug": "^4.1.1", + "module-details-from-path": "^1.0.3", + "resolve": "^1.12.0" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "require-like": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/require-like/-/require-like-0.1.2.tgz", + "integrity": "sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==", + "dev": true + }, + "resolve": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.15.1.tgz", + "integrity": "sha512-84oo6ZTtoTUpjgNEr5SJyzQhzL72gaRodsSfyxC/AXRvwu0Yse9H8eF9IpGo7b8YetZhlI6v7ZQ6bKBFV/6S7w==", + "requires": { + "path-parse": "^1.0.6" + } + }, + "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 + }, + "retry-request": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-4.1.1.tgz", + "integrity": "sha512-BINDzVtLI2BDukjWmjAIRZ0oglnCAkpP2vQjM3jdLhmT62h0xnQgciPwBRDAvHqpkPT2Wo1XuUyLyn6nbGrZQQ==", + "requires": { + "debug": "^4.1.1", + "through2": "^3.0.1" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "rimraf": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz", + "integrity": "sha1-7nEM5dk6j9uFb7Xqj/Di11k0sto=", + "optional": true, + "requires": { + "glob": "^6.0.1" + } + }, + "safe-buffer": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", + "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==" + }, + "safe-json-stringify": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz", + "integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==", + "optional": true + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "sandboxed-module": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/sandboxed-module/-/sandboxed-module-0.3.0.tgz", + "integrity": "sha512-/2IfB1wtca3eNVPXbQrb6UkhE/1pV4Wz+5CdG6DPYqeaDsYDzxglBT7/cVaqyrlRyQKdmw+uTZUTRos9FFD2PQ==", + "dev": true, + "requires": { + "require-like": "0.1.2", + "stack-trace": "0.0.6" + }, + "dependencies": { + "stack-trace": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.6.tgz", + "integrity": "sha512-5/6uZt7RYjjAl8z2j1mXWAewz+I4Hk2/L/3n6NRLIQ31+uQ7nMd9O6G69QCdrrufHv0QGRRHl/jwUEGTqhelTA==", + "dev": true + } + } + }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + }, + "send": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz", + "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==", + "requires": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.7.2", + "mime": "1.6.0", + "ms": "2.1.1", + "on-finished": "~2.3.0", + "range-parser": "~1.2.1", + "statuses": "~1.5.0" + }, + "dependencies": { + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + } + } + }, + "serialize-javascript": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz", + "integrity": "sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + }, + "serve-static": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz", + "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==", + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.17.1" + } + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, + "setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" + }, + "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, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "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 + }, + "shimmer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" + }, + "signal-exit": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" + }, + "sinon": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-9.2.4.tgz", + "integrity": "sha512-zljcULZQsJxVra28qIAL6ow1Z9tpattkCTEJR4RBP3TGc00FcttsP5pK284Nas5WjMZU5Yzy3kAIp3B3KRf5Yg==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.8.1", + "@sinonjs/fake-timers": "^6.0.1", + "@sinonjs/samsam": "^5.3.1", + "diff": "^4.0.2", + "nise": "^4.0.4", + "supports-color": "^7.1.0" + }, + "dependencies": { + "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 + }, + "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, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "dependencies": { + "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 + } + } + }, + "snakecase-keys": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/snakecase-keys/-/snakecase-keys-3.2.0.tgz", + "integrity": "sha512-WTJ0NhCH/37J+PU3fuz0x5b6TvtWQChTcKPOndWoUy0pteKOe0hrHMzSRsJOWSIP48EQkzUEsgQPmrG3W8pFNQ==", + "requires": { + "map-obj": "^4.0.0", + "to-snake-case": "^1.0.0" + } + }, + "socket.io": { + "version": "https://github.com/overleaf/socket.io/archive/0.9.19-overleaf-5.tar.gz", + "integrity": "sha512-MDRh05EWE7OSgLzsFR0ikLzIVxPD7ItC5FcScxY58QYTRmC4p0kbod4zVSYjIT9aTdMM6CnWXMvFYSe50vV/iA==", + "requires": { + "base64id": "0.1.0", + "policyfile": "0.0.4", + "redis": "0.7.3", + "socket.io-client": "https://github.com/overleaf/socket.io-client/archive/0.9.17-overleaf-3.tar.gz" + }, + "dependencies": { + "redis": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/redis/-/redis-0.7.3.tgz", + "integrity": "sha1-7le3pE0l7BWU5ENl2BZfp9HUgRo=", + "optional": true + } + } + }, + "socket.io-client": { + "version": "https://github.com/overleaf/socket.io-client/archive/0.9.17-overleaf-3.tar.gz", + "integrity": "sha512-EtKV6qGQjG/DwMXfLAiS559f07xjPVavYZ+amYwiEZ0FUHdLObuGH3zvoxghjbI6l8gFTflQhGt1foiHIGnDKg==", + "requires": { + "active-x-obfuscator": "0.0.1", + "uglify-js": "1.2.5", + "ws": "0.4.x", + "xmlhttprequest": "1.4.2" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + }, + "source-map-support": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz", + "integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==", + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "spdx-correct": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz", + "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==", + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "dev": true + }, + "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, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.9.tgz", + "integrity": "sha512-Ki212dKK4ogX+xDo4CtOZBVIwhsKBEfsEEcwmJfLQzirgc2jIWdzg40Unxz/HzEUqM1WFzVlQSMF9kZZ2HboLQ==", + "dev": true + }, + "split": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", + "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", + "requires": { + "through": "2" + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, + "sshpk": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", + "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + } + }, + "stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=" + }, + "standard-as-callback": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.0.1.tgz", + "integrity": "sha512-NQOxSeB8gOI5WjSaxjBgog2QFw55FV8TkS6Y07BiB3VJ8xNTvUYm0wl0s8ObgQ5NhdpnNfigMIKjgPESzgr4tg==" + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==" + }, + "stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "requires": { + "stubs": "^3.0.0" + } + }, + "stream-shift": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", + "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==" + }, + "string-width": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", + "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + } + }, + "string.prototype.trimend": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz", + "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + }, + "string.prototype.trimstart": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz", + "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.0" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + }, + "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 + }, + "stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha1-6NK6H6nJBXAwPAMLaQD31fiavls=" + }, + "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, + "requires": { + "has-flag": "^3.0.0" + } + }, + "table": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/table/-/table-6.7.1.tgz", + "integrity": "sha512-ZGum47Yi6KOOFDE8m223td53ath2enHcYLgOCjGr5ngu8bdIARQk6mN/wRMv4yMRcHnCSnHbCEha4sobQx5yWg==", + "dev": true, + "requires": { + "ajv": "^8.0.1", + "lodash.clonedeep": "^4.5.0", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0" + }, + "dependencies": { + "ajv": { + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.6.1.tgz", + "integrity": "sha512-42VLtQUOLefAvKFAQIxIZDaThq6om/PrfP0CYk3/vn+y4BMNkKnbli8ON2QCiHov4KkzOSJ/xSoBJdayiiYvVQ==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + } + } + }, + "tar": { + "version": "4.4.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.13.tgz", + "integrity": "sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA==", + "requires": { + "chownr": "^1.1.1", + "fs-minipass": "^1.2.5", + "minipass": "^2.8.6", + "minizlib": "^1.2.1", + "mkdirp": "^0.5.0", + "safe-buffer": "^5.1.2", + "yallist": "^3.0.3" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + } + } + }, + "tdigest": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.1.tgz", + "integrity": "sha1-Ljyyw56kSeVdHmzZEReszKRYgCE=", + "requires": { + "bintrees": "1.0.1" + } + }, + "teeny-request": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-7.0.1.tgz", + "integrity": "sha512-sasJmQ37klOlplL4Ia/786M5YlOcoLGQyq2TE4WHSRupbAuDaQW0PfVxV4MtdBtRJ4ngzS+1qim8zP6Zp35qCw==", + "requires": { + "http-proxy-agent": "^4.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.1", + "stream-events": "^1.0.5", + "uuid": "^8.0.0" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + } + } + }, + "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 + }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" + }, + "through2": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/through2/-/through2-3.0.1.tgz", + "integrity": "sha512-M96dvTalPT3YbYLaKaCuwu+j06D/8Jfib0o/PxbVt6Amhv3dUAtW6rTV1jPgJSBG83I/e04Y6xkVdVhSRhi0ww==", + "requires": { + "readable-stream": "2 || 3" + } + }, + "timed-out": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/timed-out/-/timed-out-4.0.1.tgz", + "integrity": "sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8=" + }, + "timekeeper": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/timekeeper/-/timekeeper-0.0.4.tgz", + "integrity": "sha512-QSNovcsPIbrI9zzXTesL/iiDrS+4IT+0xCxFzDSI2/yHkHVL1QEB5FhzrKMzCEwsbSOISEsH1yPUZ6/Fve9DZQ==", + "dev": true + }, + "tinycolor": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/tinycolor/-/tinycolor-0.0.1.tgz", + "integrity": "sha1-MgtaUtg6u1l42Bo+iH1K77FaYWQ=" + }, + "to-no-case": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/to-no-case/-/to-no-case-1.0.2.tgz", + "integrity": "sha1-xyKQcWTvaxeBMsjmmTAhLRtKoWo=" + }, + "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, + "requires": { + "is-number": "^7.0.0" + } + }, + "to-snake-case": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-snake-case/-/to-snake-case-1.0.0.tgz", + "integrity": "sha1-znRpE4l5RgGah+Yu366upMYIq4w=", + "requires": { + "to-space-case": "^1.0.0" + } + }, + "to-space-case": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-space-case/-/to-space-case-1.0.0.tgz", + "integrity": "sha1-sFLar7Gysp3HcM6gFj5ewOvJ/Bc=", + "requires": { + "to-no-case": "^1.0.0" + } + }, + "toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" + }, + "tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "requires": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + } + }, + "tsconfig-paths": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.10.1.tgz", + "integrity": "sha512-rETidPDgCpltxF7MjBZlAFPUHv5aHH2MymyPvh+vEyWAED4Eb/WeMbsnD/JDr4OKPOA1TssDHgIcpTN5Kh0p6Q==", + "dev": true, + "requires": { + "json5": "^2.2.0", + "minimist": "^1.2.0", + "strip-bom": "^3.0.0" + }, + "dependencies": { + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true + } + } + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" + }, + "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, + "requires": { + "prelude-ls": "^1.2.1" + } + }, + "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 + }, + "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 + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "dependencies": { + "mime-db": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz", + "integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==" + }, + "mime-types": { + "version": "2.1.27", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz", + "integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==", + "requires": { + "mime-db": "1.44.0" + } + } + } + }, + "uglify-js": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-1.2.5.tgz", + "integrity": "sha1-tULCx29477NLIAsgF3Y0Mw/3ArY=" + }, + "uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "requires": { + "random-bytes": "~1.0.0" + } + }, + "unbox-primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz", + "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "has-bigints": "^1.0.1", + "has-symbols": "^1.0.2", + "which-boxed-primitive": "^1.0.2" + } + }, + "underscore": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz", + "integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==" + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + }, + "uri-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", + "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "requires": { + "punycode": "^2.1.0" + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" + }, + "uuid": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", + "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==" + }, + "v8-compile-cache": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", + "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", + "dev": true + }, + "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, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "walkdir": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/walkdir/-/walkdir-0.4.1.tgz", + "integrity": "sha512-3eBwRyEln6E1MSzcxcVpQIhRG8Q1jLvEqRmCZqS3dsfXEDR/AhOF4d+jHg1qvDCpYaVRZjENPQyrVxAkQqxPgQ==" + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "requires": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + } + }, + "wide-align": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", + "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", + "requires": { + "string-width": "^1.0.2 || 2" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=" + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "word-wrap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "dev": true + }, + "workerpool": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.1.0.tgz", + "integrity": "sha512-toV7q9rWNYha963Pl/qyeZ6wG+3nnsyvolaNUS8+R5Wtw6qJPTxIlOP1ZSvcGhEJw+l3HMMmtiNo9Gl61G4GVg==", + "dev": true + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "requires": { + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "ws": { + "version": "0.4.32", + "resolved": "https://registry.npmjs.org/ws/-/ws-0.4.32.tgz", + "integrity": "sha1-eHphVEFPPJntg8V3IVOyD+sM7DI=", + "requires": { + "commander": "~2.1.0", + "nan": "~1.0.0", + "options": ">=0.0.5", + "tinycolor": "0.x" + }, + "dependencies": { + "nan": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-1.0.0.tgz", + "integrity": "sha1-riT4hQgY1mL8q1rPfzuVv6oszzg=" + } + } + }, + "xmlhttprequest": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest/-/xmlhttprequest-1.4.2.tgz", + "integrity": "sha1-AUU6HZvtHo8XL2SVu/TIxCYyFQA=" + }, + "y18n": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.5.tgz", + "integrity": "sha512-hsRUr4FFrvhhRH12wOdfs38Gy7k2FFzB9qgN9v3aLykRq0dRcdcpz5C9FxdS2NuhOrI/628b/KSTJ3rwHysYSg==", + "dev": true + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + }, + "yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "requires": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + } + }, + "yargs-parser": { + "version": "20.2.4", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", + "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", + "dev": true + }, + "yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "requires": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + } + }, + "yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==" + }, + "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==" + }, + "zeparser": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/zeparser/-/zeparser-0.0.5.tgz", + "integrity": "sha1-A3JlYbwmjy5URPVMZlt/1KjAKeI=" + } + } +} diff --git a/services/real-time/package.json b/services/real-time/package.json new file mode 100644 index 0000000000..308957330d --- /dev/null +++ b/services/real-time/package.json @@ -0,0 +1,65 @@ +{ + "name": "real-time-sharelatex", + "version": "0.1.4", + "description": "The socket.io layer of ShareLaTeX for real-time editor interactions", + "author": "ShareLaTeX ", + "license": "AGPL-3.0-only", + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/sharelatex/real-time-sharelatex.git" + }, + "scripts": { + "start": "node $NODE_APP_OPTIONS app.js", + "test:acceptance:_run": "mocha --recursive --reporter spec --timeout 15000 --exit $@ test/acceptance/js", + "test:acceptance": "npm run test:acceptance:_run -- --grep=$MOCHA_GREP", + "test:unit:_run": "mocha --recursive --reporter spec $@ test/unit/js", + "test:unit": "npm run test:unit:_run -- --grep=$MOCHA_GREP", + "nodemon": "nodemon --config nodemon.json", + "lint": "eslint --max-warnings 0 --format unix .", + "format": "prettier --list-different $PWD/'**/*.js'", + "format:fix": "prettier --write $PWD/'**/*.js'", + "lint:fix": "eslint --fix ." + }, + "dependencies": { + "@overleaf/metrics": "^3.5.1", + "@overleaf/o-error": "^3.1.0", + "@overleaf/redis-wrapper": "^2.0.0", + "@overleaf/settings": "^2.1.1", + "async": "^0.9.0", + "base64id": "0.1.0", + "basic-auth-connect": "^1.0.0", + "body-parser": "^1.19.0", + "bunyan": "^1.8.15", + "connect-redis": "^2.1.0", + "cookie-parser": "^1.4.5", + "express": "^4.17.1", + "express-session": "^1.17.1", + "logger-sharelatex": "^2.2.0", + "request": "^2.88.2", + "socket.io": "https://github.com/overleaf/socket.io/archive/0.9.19-overleaf-5.tar.gz", + "socket.io-client": "https://github.com/overleaf/socket.io-client/archive/0.9.17-overleaf-3.tar.gz", + "underscore": "1.13.1" + }, + "devDependencies": { + "chai": "^4.2.0", + "chai-as-promised": "^7.1.1", + "cookie-signature": "^1.1.0", + "eslint": "^7.21.0", + "eslint-config-prettier": "^8.1.0", + "eslint-config-standard": "^16.0.2", + "eslint-plugin-chai-expect": "^2.2.0", + "eslint-plugin-chai-friendly": "^0.6.0", + "eslint-plugin-import": "^2.22.1", + "eslint-plugin-mocha": "^8.0.0", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-prettier": "^3.1.2", + "eslint-plugin-promise": "^4.2.1", + "mocha": "^8.3.2", + "prettier": "^2.2.1", + "sandboxed-module": "~0.3.0", + "sinon": "^9.2.4", + "timekeeper": "0.0.4", + "uid-safe": "^2.1.5" + } +} diff --git a/services/real-time/test/acceptance/js/ApplyUpdateTests.js b/services/real-time/test/acceptance/js/ApplyUpdateTests.js new file mode 100644 index 0000000000..bfa05d0b35 --- /dev/null +++ b/services/real-time/test/acceptance/js/ApplyUpdateTests.js @@ -0,0 +1,475 @@ +/* eslint-disable + camelcase, + handle-callback-err, + no-return-assign, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS201: Simplify complex destructure assignments + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const async = require('async') +const { expect } = require('chai') + +const RealTimeClient = require('./helpers/RealTimeClient') +const FixturesManager = require('./helpers/FixturesManager') + +const settings = require('@overleaf/settings') +const redis = require('@overleaf/redis-wrapper') +const rclient = redis.createClient(settings.redis.documentupdater) + +const redisSettings = settings.redis + +const PENDING_UPDATES_LIST_KEYS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => { + let key = 'pending-updates-list' + if (n !== 0) { + key += `-${n}` + } + return key +}) + +function getPendingUpdatesList(cb) { + Promise.all(PENDING_UPDATES_LIST_KEYS.map(key => rclient.lrange(key, 0, -1))) + .then(results => { + cb( + null, + results.reduce((acc, more) => { + if (more.length) { + acc.push(...more) + } + return acc + }, []) + ) + }) + .catch(cb) +} + +function clearPendingUpdatesList(cb) { + Promise.all(PENDING_UPDATES_LIST_KEYS.map(key => rclient.del(key))) + .then(() => cb(null)) + .catch(cb) +} + +describe('applyOtUpdate', function () { + before(function () { + return (this.update = { + op: [{ i: 'foo', p: 42 }], + }) + }) + describe('when authorized', function () { + before(function (done) { + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'readAndWrite', + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + cb => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + cb => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + cb => { + return this.client.emit('joinDoc', this.doc_id, cb) + }, + + cb => { + return this.client.emit( + 'applyOtUpdate', + this.doc_id, + this.update, + cb + ) + }, + ], + done + ) + }) + + it('should push the doc into the pending updates list', function (done) { + getPendingUpdatesList((error, ...rest) => { + const [doc_id] = Array.from(rest[0]) + doc_id.should.equal(`${this.project_id}:${this.doc_id}`) + return done() + }) + return null + }) + + it('should push the update into redis', function (done) { + rclient.lrange( + redisSettings.documentupdater.key_schema.pendingUpdates({ + doc_id: this.doc_id, + }), + 0, + -1, + (error, ...rest) => { + let [update] = Array.from(rest[0]) + update = JSON.parse(update) + update.op.should.deep.equal(this.update.op) + update.meta.should.deep.equal({ + source: this.client.publicId, + user_id: this.user_id, + }) + return done() + } + ) + return null + }) + + return after(function (done) { + return async.series( + [ + cb => clearPendingUpdatesList(cb), + cb => + rclient.del( + 'DocsWithPendingUpdates', + `${this.project_id}:${this.doc_id}`, + cb + ), + cb => + rclient.del( + redisSettings.documentupdater.key_schema.pendingUpdates( + this.doc_id + ), + cb + ), + ], + done + ) + }) + }) + + describe('when authorized with a huge edit update', function () { + before(function (done) { + this.update = { + op: { + p: 12, + t: 'update is too large'.repeat(1024 * 400), // >7MB + }, + } + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'readAndWrite', + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + cb => { + this.client = RealTimeClient.connect() + this.client.on('connectionAccepted', cb) + return this.client.on('otUpdateError', otUpdateError => { + this.otUpdateError = otUpdateError + }) + }, + + cb => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + cb => { + return this.client.emit('joinDoc', this.doc_id, cb) + }, + + cb => { + return this.client.emit( + 'applyOtUpdate', + this.doc_id, + this.update, + error => { + this.error = error + return cb() + } + ) + }, + ], + done + ) + }) + + it('should not return an error', function () { + return expect(this.error).to.not.exist + }) + + it('should send an otUpdateError to the client', function (done) { + return setTimeout(() => { + expect(this.otUpdateError).to.exist + return done() + }, 300) + }) + + it('should disconnect the client', function (done) { + return setTimeout(() => { + this.client.socket.connected.should.equal(false) + return done() + }, 300) + }) + + return it('should not put the update in redis', function (done) { + rclient.llen( + redisSettings.documentupdater.key_schema.pendingUpdates({ + doc_id: this.doc_id, + }), + (error, len) => { + len.should.equal(0) + return done() + } + ) + return null + }) + }) + + describe('when authorized to read-only with an edit update', function () { + before(function (done) { + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'readOnly', + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + cb => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + cb => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + cb => { + return this.client.emit('joinDoc', this.doc_id, cb) + }, + + cb => { + return this.client.emit( + 'applyOtUpdate', + this.doc_id, + this.update, + error => { + this.error = error + return cb() + } + ) + }, + ], + done + ) + }) + + it('should return an error', function () { + return expect(this.error).to.exist + }) + + it('should disconnect the client', function (done) { + return setTimeout(() => { + this.client.socket.connected.should.equal(false) + return done() + }, 300) + }) + + return it('should not put the update in redis', function (done) { + rclient.llen( + redisSettings.documentupdater.key_schema.pendingUpdates({ + doc_id: this.doc_id, + }), + (error, len) => { + len.should.equal(0) + return done() + } + ) + return null + }) + }) + + return describe('when authorized to read-only with a comment update', function () { + before(function (done) { + this.comment_update = { + op: [{ c: 'foo', p: 42 }], + } + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'readOnly', + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + cb => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + cb => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + cb => { + return this.client.emit('joinDoc', this.doc_id, cb) + }, + + cb => { + return this.client.emit( + 'applyOtUpdate', + this.doc_id, + this.comment_update, + cb + ) + }, + ], + done + ) + }) + + it('should push the doc into the pending updates list', function (done) { + getPendingUpdatesList((error, ...rest) => { + const [doc_id] = Array.from(rest[0]) + doc_id.should.equal(`${this.project_id}:${this.doc_id}`) + return done() + }) + return null + }) + + it('should push the update into redis', function (done) { + rclient.lrange( + redisSettings.documentupdater.key_schema.pendingUpdates({ + doc_id: this.doc_id, + }), + 0, + -1, + (error, ...rest) => { + let [update] = Array.from(rest[0]) + update = JSON.parse(update) + update.op.should.deep.equal(this.comment_update.op) + update.meta.should.deep.equal({ + source: this.client.publicId, + user_id: this.user_id, + }) + return done() + } + ) + return null + }) + + return after(function (done) { + return async.series( + [ + cb => clearPendingUpdatesList(cb), + cb => + rclient.del( + 'DocsWithPendingUpdates', + `${this.project_id}:${this.doc_id}`, + cb + ), + cb => + rclient.del( + redisSettings.documentupdater.key_schema.pendingUpdates({ + doc_id: this.doc_id, + }), + cb + ), + ], + done + ) + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/ClientTrackingTests.js b/services/real-time/test/acceptance/js/ClientTrackingTests.js new file mode 100644 index 0000000000..0fc6c6cd5b --- /dev/null +++ b/services/real-time/test/acceptance/js/ClientTrackingTests.js @@ -0,0 +1,253 @@ +/* eslint-disable + camelcase, + handle-callback-err, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const { expect } = require('chai') + +const RealTimeClient = require('./helpers/RealTimeClient') +const MockWebServer = require('./helpers/MockWebServer') +const FixturesManager = require('./helpers/FixturesManager') + +const async = require('async') + +describe('clientTracking', function () { + describe('when a client updates its cursor location', function () { + before(function (done) { + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { name: 'Test Project' }, + }, + (error, { user_id, project_id }) => { + this.user_id = user_id + this.project_id = project_id + return cb() + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + cb => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connectionAccepted', cb) + }, + + cb => { + this.clientB = RealTimeClient.connect() + return this.clientB.on('connectionAccepted', cb) + }, + + cb => { + return this.clientA.emit( + 'joinProject', + { + project_id: this.project_id, + }, + cb + ) + }, + + cb => { + return this.clientA.emit('joinDoc', this.doc_id, cb) + }, + + cb => { + return this.clientB.emit( + 'joinProject', + { + project_id: this.project_id, + }, + cb + ) + }, + + cb => { + this.updates = [] + this.clientB.on('clientTracking.clientUpdated', data => { + return this.updates.push(data) + }) + + return this.clientA.emit( + 'clientTracking.updatePosition', + { + row: (this.row = 42), + column: (this.column = 36), + doc_id: this.doc_id, + }, + error => { + if (error != null) { + throw error + } + return setTimeout(cb, 300) + } + ) + }, // Give the message a chance to reach client B. + ], + done + ) + }) + + it('should tell other clients about the update', function () { + return this.updates.should.deep.equal([ + { + row: this.row, + column: this.column, + doc_id: this.doc_id, + id: this.clientA.publicId, + user_id: this.user_id, + name: 'Joe Bloggs', + }, + ]) + }) + + return it('should record the update in getConnectedUsers', function (done) { + return this.clientB.emit( + 'clientTracking.getConnectedUsers', + (error, users) => { + for (const user of Array.from(users)) { + if (user.client_id === this.clientA.publicId) { + expect(user.cursorData).to.deep.equal({ + row: this.row, + column: this.column, + doc_id: this.doc_id, + }) + return done() + } + } + throw new Error('user was never found') + } + ) + }) + }) + + return describe('when an anonymous client updates its cursor location', function () { + before(function (done) { + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { name: 'Test Project' }, + publicAccess: 'readAndWrite', + }, + (error, { user_id, project_id }) => { + this.user_id = user_id + this.project_id = project_id + return cb() + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + cb => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connectionAccepted', cb) + }, + + cb => { + return this.clientA.emit( + 'joinProject', + { + project_id: this.project_id, + }, + cb + ) + }, + + cb => { + return RealTimeClient.setSession({}, cb) + }, + + cb => { + this.anonymous = RealTimeClient.connect() + return this.anonymous.on('connectionAccepted', cb) + }, + + cb => { + return this.anonymous.emit( + 'joinProject', + { + project_id: this.project_id, + }, + cb + ) + }, + + cb => { + return this.anonymous.emit('joinDoc', this.doc_id, cb) + }, + + cb => { + this.updates = [] + this.clientA.on('clientTracking.clientUpdated', data => { + return this.updates.push(data) + }) + + return this.anonymous.emit( + 'clientTracking.updatePosition', + { + row: (this.row = 42), + column: (this.column = 36), + doc_id: this.doc_id, + }, + error => { + if (error != null) { + throw error + } + return setTimeout(cb, 300) + } + ) + }, // Give the message a chance to reach client B. + ], + done + ) + }) + + return it('should tell other clients about the update', function () { + return this.updates.should.deep.equal([ + { + row: this.row, + column: this.column, + doc_id: this.doc_id, + id: this.anonymous.publicId, + user_id: 'anonymous-user', + name: '', + }, + ]) + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/DrainManagerTests.js b/services/real-time/test/acceptance/js/DrainManagerTests.js new file mode 100644 index 0000000000..0f4d67fb3d --- /dev/null +++ b/services/real-time/test/acceptance/js/DrainManagerTests.js @@ -0,0 +1,135 @@ +/* eslint-disable + camelcase, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const RealTimeClient = require('./helpers/RealTimeClient') +const FixturesManager = require('./helpers/FixturesManager') + +const { expect } = require('chai') + +const async = require('async') +const request = require('request') + +const Settings = require('@overleaf/settings') + +const drain = function (rate, callback) { + request.post( + { + url: `http://localhost:3026/drain?rate=${rate}`, + auth: { + user: Settings.internal.realTime.user, + pass: Settings.internal.realTime.pass, + }, + }, + (error, response, data) => callback(error, data) + ) + return null +} + +describe('DrainManagerTests', function () { + before(function (done) { + FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project', + }, + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return done() + } + ) + return null + }) + + before(function (done) { + // cleanup to speedup reconnecting + this.timeout(10000) + return RealTimeClient.disconnectAllClients(done) + }) + + // trigger and check cleanup + it('should have disconnected all previous clients', function (done) { + return RealTimeClient.getConnectedClients((error, data) => { + if (error) { + return done(error) + } + expect(data.length).to.equal(0) + return done() + }) + }) + + return describe('with two clients in the project', function () { + beforeEach(function (done) { + return async.series( + [ + cb => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connectionAccepted', cb) + }, + + cb => { + this.clientB = RealTimeClient.connect() + return this.clientB.on('connectionAccepted', cb) + }, + + cb => { + return this.clientA.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + cb => { + return this.clientB.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + ], + done + ) + }) + + return describe('starting to drain', function () { + beforeEach(function (done) { + return async.parallel( + [ + cb => { + return this.clientA.on('reconnectGracefully', cb) + }, + cb => { + return this.clientB.on('reconnectGracefully', cb) + }, + + cb => drain(2, cb), + ], + done + ) + }) + + afterEach(function (done) { + return drain(0, done) + }) // reset drain + + it('should not timeout', function () { + return expect(true).to.equal(true) + }) + + return it('should not have disconnected', function () { + expect(this.clientA.socket.connected).to.equal(true) + return expect(this.clientB.socket.connected).to.equal(true) + }) + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/EarlyDisconnect.js b/services/real-time/test/acceptance/js/EarlyDisconnect.js new file mode 100644 index 0000000000..14356f42ac --- /dev/null +++ b/services/real-time/test/acceptance/js/EarlyDisconnect.js @@ -0,0 +1,288 @@ +/* eslint-disable + camelcase, + no-return-assign, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const async = require('async') +const { expect } = require('chai') + +const RealTimeClient = require('./helpers/RealTimeClient') +const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer') +const MockWebServer = require('./helpers/MockWebServer') +const FixturesManager = require('./helpers/FixturesManager') + +const settings = require('@overleaf/settings') +const redis = require('@overleaf/redis-wrapper') +const rclient = redis.createClient(settings.redis.pubsub) +const rclientRT = redis.createClient(settings.redis.realtime) +const KeysRT = settings.redis.realtime.key_schema + +describe('EarlyDisconnect', function () { + before(function (done) { + return MockDocUpdaterServer.run(done) + }) + + describe('when the client disconnects before joinProject completes', function () { + before(function () { + // slow down web-api requests to force the race condition + let joinProject + this.actualWebAPIjoinProject = joinProject = MockWebServer.joinProject + return (MockWebServer.joinProject = (project_id, user_id, cb) => + setTimeout(() => joinProject(project_id, user_id, cb), 300)) + }) + + after(function () { + return (MockWebServer.joinProject = this.actualWebAPIjoinProject) + }) + + beforeEach(function (done) { + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project', + }, + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb() + } + ) + }, + + cb => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connectionAccepted', cb) + }, + + cb => { + this.clientA.emit( + 'joinProject', + { project_id: this.project_id }, + () => {} + ) + // disconnect before joinProject completes + this.clientA.on('disconnect', () => cb()) + return this.clientA.disconnect() + }, + + cb => { + // wait for joinDoc and subscribe + return setTimeout(cb, 500) + }, + ], + done + ) + }) + + // we can force the race condition, there is no need to repeat too often + return Array.from(Array.from({ length: 5 }).map((_, i) => i + 1)).map( + attempt => + it(`should not subscribe to the pub/sub channel anymore (race ${attempt})`, function (done) { + rclient.pubsub('CHANNELS', (err, resp) => { + if (err) { + return done(err) + } + expect(resp).to.not.include(`editor-events:${this.project_id}`) + return done() + }) + return null + }) + ) + }) + + describe('when the client disconnects before joinDoc completes', function () { + beforeEach(function (done) { + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project', + }, + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb() + } + ) + }, + + cb => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connectionAccepted', cb) + }, + + cb => { + return this.clientA.emit( + 'joinProject', + { project_id: this.project_id }, + (error, project, privilegeLevel, protocolVersion) => { + this.project = project + this.privilegeLevel = privilegeLevel + this.protocolVersion = protocolVersion + return cb(error) + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + cb => { + this.clientA.emit('joinDoc', this.doc_id, () => {}) + // disconnect before joinDoc completes + this.clientA.on('disconnect', () => cb()) + return this.clientA.disconnect() + }, + + cb => { + // wait for subscribe and unsubscribe + return setTimeout(cb, 100) + }, + ], + done + ) + }) + + // we can not force the race condition, so we have to try many times + return Array.from(Array.from({ length: 20 }).map((_, i) => i + 1)).map( + attempt => + it(`should not subscribe to the pub/sub channels anymore (race ${attempt})`, function (done) { + rclient.pubsub('CHANNELS', (err, resp) => { + if (err) { + return done(err) + } + expect(resp).to.not.include(`editor-events:${this.project_id}`) + + return rclient.pubsub('CHANNELS', (err, resp) => { + if (err) { + return done(err) + } + expect(resp).to.not.include(`applied-ops:${this.doc_id}`) + return done() + }) + }) + return null + }) + ) + }) + + return describe('when the client disconnects before clientTracking.updatePosition starts', function () { + beforeEach(function (done) { + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project', + }, + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb() + } + ) + }, + + cb => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connectionAccepted', cb) + }, + + cb => { + return this.clientA.emit( + 'joinProject', + { project_id: this.project_id }, + (error, project, privilegeLevel, protocolVersion) => { + this.project = project + this.privilegeLevel = privilegeLevel + this.protocolVersion = protocolVersion + return cb(error) + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + cb => { + return this.clientA.emit('joinDoc', this.doc_id, cb) + }, + + cb => { + this.clientA.emit( + 'clientTracking.updatePosition', + { + row: 42, + column: 36, + doc_id: this.doc_id, + }, + () => {} + ) + // disconnect before updateClientPosition completes + this.clientA.on('disconnect', () => cb()) + return this.clientA.disconnect() + }, + + cb => { + // wait for updateClientPosition + return setTimeout(cb, 100) + }, + ], + done + ) + }) + + // we can not force the race condition, so we have to try many times + return Array.from(Array.from({ length: 20 }).map((_, i) => i + 1)).map( + attempt => + it(`should not show the client as connected (race ${attempt})`, function (done) { + rclientRT.smembers( + KeysRT.clientsInProject({ project_id: this.project_id }), + (err, results) => { + if (err) { + return done(err) + } + expect(results).to.deep.equal([]) + return done() + } + ) + return null + }) + ) + }) +}) diff --git a/services/real-time/test/acceptance/js/HttpControllerTests.js b/services/real-time/test/acceptance/js/HttpControllerTests.js new file mode 100644 index 0000000000..8704c18135 --- /dev/null +++ b/services/real-time/test/acceptance/js/HttpControllerTests.js @@ -0,0 +1,117 @@ +/* eslint-disable + camelcase, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const async = require('async') +const { expect } = require('chai') +const request = require('request').defaults({ + baseUrl: 'http://localhost:3026', +}) + +const RealTimeClient = require('./helpers/RealTimeClient') +const FixturesManager = require('./helpers/FixturesManager') + +describe('HttpControllerTests', function () { + describe('without a user', function () { + return it('should return 404 for the client view', function (done) { + const client_id = 'not-existing' + return request.get( + { + url: `/clients/${client_id}`, + json: true, + }, + (error, response, data) => { + if (error) { + return done(error) + } + expect(response.statusCode).to.equal(404) + return done() + } + ) + }) + }) + + return describe('with a user and after joining a project', function () { + before(function (done) { + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + }, + (error, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(error) + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id, + {}, + (error, { doc_id }) => { + this.doc_id = doc_id + return cb(error) + } + ) + }, + + cb => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + cb => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + cb => { + return this.client.emit('joinDoc', this.doc_id, cb) + }, + ], + done + ) + }) + + return it('should send a client view', function (done) { + return request.get( + { + url: `/clients/${this.client.socket.sessionid}`, + json: true, + }, + (error, response, data) => { + if (error) { + return done(error) + } + expect(response.statusCode).to.equal(200) + expect(data.connected_time).to.exist + delete data.connected_time + // .email is not set in the session + delete data.email + expect(data).to.deep.equal({ + client_id: this.client.socket.sessionid, + first_name: 'Joe', + last_name: 'Bloggs', + project_id: this.project_id, + user_id: this.user_id, + rooms: [this.project_id, this.doc_id], + }) + return done() + } + ) + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/JoinDocTests.js b/services/real-time/test/acceptance/js/JoinDocTests.js new file mode 100644 index 0000000000..abe8714cca --- /dev/null +++ b/services/real-time/test/acceptance/js/JoinDocTests.js @@ -0,0 +1,563 @@ +/* eslint-disable + camelcase, + handle-callback-err, + no-return-assign, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const { expect } = require('chai') + +const RealTimeClient = require('./helpers/RealTimeClient') +const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer') +const FixturesManager = require('./helpers/FixturesManager') + +const async = require('async') + +describe('joinDoc', function () { + before(function () { + this.lines = ['test', 'doc', 'lines'] + this.version = 42 + this.ops = ['mock', 'doc', 'ops'] + return (this.ranges = { mock: 'ranges' }) + }) + + describe('when authorised readAndWrite', function () { + before(function (done) { + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'readAndWrite', + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id, + { + lines: this.lines, + version: this.version, + ops: this.ops, + ranges: this.ranges, + }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + cb => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + cb => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + cb => { + return this.client.emit( + 'joinDoc', + this.doc_id, + (error, ...rest) => { + ;[...this.returnedArgs] = Array.from(rest) + return cb(error) + } + ) + }, + ], + done + ) + }) + + it('should get the doc from the doc updater', function () { + return MockDocUpdaterServer.getDocument + .calledWith(this.project_id, this.doc_id, -1) + .should.equal(true) + }) + + it('should return the doc lines, version, ranges and ops', function () { + return this.returnedArgs.should.deep.equal([ + this.lines, + this.version, + this.ops, + this.ranges, + ]) + }) + + return it('should have joined the doc room', function (done) { + return RealTimeClient.getConnectedClient( + this.client.socket.sessionid, + (error, client) => { + expect(Array.from(client.rooms).includes(this.doc_id)).to.equal(true) + return done() + } + ) + }) + }) + + describe('when authorised readOnly', function () { + before(function (done) { + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'readOnly', + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id, + { + lines: this.lines, + version: this.version, + ops: this.ops, + ranges: this.ranges, + }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + cb => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + cb => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + cb => { + return this.client.emit( + 'joinDoc', + this.doc_id, + (error, ...rest) => { + ;[...this.returnedArgs] = Array.from(rest) + return cb(error) + } + ) + }, + ], + done + ) + }) + + it('should get the doc from the doc updater', function () { + return MockDocUpdaterServer.getDocument + .calledWith(this.project_id, this.doc_id, -1) + .should.equal(true) + }) + + it('should return the doc lines, version, ranges and ops', function () { + return this.returnedArgs.should.deep.equal([ + this.lines, + this.version, + this.ops, + this.ranges, + ]) + }) + + return it('should have joined the doc room', function (done) { + return RealTimeClient.getConnectedClient( + this.client.socket.sessionid, + (error, client) => { + expect(Array.from(client.rooms).includes(this.doc_id)).to.equal(true) + return done() + } + ) + }) + }) + + describe('when authorised as owner', function () { + before(function (done) { + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id, + { + lines: this.lines, + version: this.version, + ops: this.ops, + ranges: this.ranges, + }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + cb => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + cb => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + cb => { + return this.client.emit( + 'joinDoc', + this.doc_id, + (error, ...rest) => { + ;[...this.returnedArgs] = Array.from(rest) + return cb(error) + } + ) + }, + ], + done + ) + }) + + it('should get the doc from the doc updater', function () { + return MockDocUpdaterServer.getDocument + .calledWith(this.project_id, this.doc_id, -1) + .should.equal(true) + }) + + it('should return the doc lines, version, ranges and ops', function () { + return this.returnedArgs.should.deep.equal([ + this.lines, + this.version, + this.ops, + this.ranges, + ]) + }) + + return it('should have joined the doc room', function (done) { + return RealTimeClient.getConnectedClient( + this.client.socket.sessionid, + (error, client) => { + expect(Array.from(client.rooms).includes(this.doc_id)).to.equal(true) + return done() + } + ) + }) + }) + + // It is impossible to write an acceptance test to test joining an unauthorized + // project, since joinProject already catches that. If you can join a project, + // then you can join a doc in that project. + + describe('with a fromVersion', function () { + before(function (done) { + this.fromVersion = 36 + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'readAndWrite', + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id, + { + lines: this.lines, + version: this.version, + ops: this.ops, + ranges: this.ranges, + }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + cb => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + cb => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + cb => { + return this.client.emit( + 'joinDoc', + this.doc_id, + this.fromVersion, + (error, ...rest) => { + ;[...this.returnedArgs] = Array.from(rest) + return cb(error) + } + ) + }, + ], + done + ) + }) + + it('should get the doc from the doc updater with the fromVersion', function () { + return MockDocUpdaterServer.getDocument + .calledWith(this.project_id, this.doc_id, this.fromVersion) + .should.equal(true) + }) + + it('should return the doc lines, version, ranges and ops', function () { + return this.returnedArgs.should.deep.equal([ + this.lines, + this.version, + this.ops, + this.ranges, + ]) + }) + + return it('should have joined the doc room', function (done) { + return RealTimeClient.getConnectedClient( + this.client.socket.sessionid, + (error, client) => { + expect(Array.from(client.rooms).includes(this.doc_id)).to.equal(true) + return done() + } + ) + }) + }) + + describe('with options', function () { + before(function (done) { + this.options = { encodeRanges: true } + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'readAndWrite', + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id, + { + lines: this.lines, + version: this.version, + ops: this.ops, + ranges: this.ranges, + }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + cb => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + cb => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + cb => { + return this.client.emit( + 'joinDoc', + this.doc_id, + this.options, + (error, ...rest) => { + ;[...this.returnedArgs] = Array.from(rest) + return cb(error) + } + ) + }, + ], + done + ) + }) + + it('should get the doc from the doc updater with the default fromVersion', function () { + return MockDocUpdaterServer.getDocument + .calledWith(this.project_id, this.doc_id, -1) + .should.equal(true) + }) + + it('should return the doc lines, version, ranges and ops', function () { + return this.returnedArgs.should.deep.equal([ + this.lines, + this.version, + this.ops, + this.ranges, + ]) + }) + + return it('should have joined the doc room', function (done) { + return RealTimeClient.getConnectedClient( + this.client.socket.sessionid, + (error, client) => { + expect(Array.from(client.rooms).includes(this.doc_id)).to.equal(true) + return done() + } + ) + }) + }) + + return describe('with fromVersion and options', function () { + before(function (done) { + this.fromVersion = 36 + this.options = { encodeRanges: true } + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'readAndWrite', + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id, + { + lines: this.lines, + version: this.version, + ops: this.ops, + ranges: this.ranges, + }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + cb => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + cb => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + cb => { + return this.client.emit( + 'joinDoc', + this.doc_id, + this.fromVersion, + this.options, + (error, ...rest) => { + ;[...this.returnedArgs] = Array.from(rest) + return cb(error) + } + ) + }, + ], + done + ) + }) + + it('should get the doc from the doc updater with the fromVersion', function () { + return MockDocUpdaterServer.getDocument + .calledWith(this.project_id, this.doc_id, this.fromVersion) + .should.equal(true) + }) + + it('should return the doc lines, version, ranges and ops', function () { + return this.returnedArgs.should.deep.equal([ + this.lines, + this.version, + this.ops, + this.ranges, + ]) + }) + + return it('should have joined the doc room', function (done) { + return RealTimeClient.getConnectedClient( + this.client.socket.sessionid, + (error, client) => { + expect(Array.from(client.rooms).includes(this.doc_id)).to.equal(true) + return done() + } + ) + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/JoinProjectTests.js b/services/real-time/test/acceptance/js/JoinProjectTests.js new file mode 100644 index 0000000000..63e46d9a3a --- /dev/null +++ b/services/real-time/test/acceptance/js/JoinProjectTests.js @@ -0,0 +1,328 @@ +/* eslint-disable + camelcase, + handle-callback-err, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const { expect } = require('chai') + +const RealTimeClient = require('./helpers/RealTimeClient') +const MockWebServer = require('./helpers/MockWebServer') +const FixturesManager = require('./helpers/FixturesManager') + +const async = require('async') + +describe('joinProject', function () { + describe('when authorized', function () { + before(function (done) { + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project', + }, + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + cb => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + cb => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + (error, project, privilegeLevel, protocolVersion) => { + this.project = project + this.privilegeLevel = privilegeLevel + this.protocolVersion = protocolVersion + return cb(error) + } + ) + }, + ], + done + ) + }) + + it('should get the project from web', function () { + return MockWebServer.joinProject + .calledWith(this.project_id, this.user_id) + .should.equal(true) + }) + + it('should return the project', function () { + return this.project.should.deep.equal({ + name: 'Test Project', + }) + }) + + it('should return the privilege level', function () { + return this.privilegeLevel.should.equal('owner') + }) + + it('should return the protocolVersion', function () { + return this.protocolVersion.should.equal(2) + }) + + it('should have joined the project room', function (done) { + return RealTimeClient.getConnectedClient( + this.client.socket.sessionid, + (error, client) => { + expect(Array.from(client.rooms).includes(this.project_id)).to.equal( + true + ) + return done() + } + ) + }) + + return it('should have marked the user as connected', function (done) { + return this.client.emit( + 'clientTracking.getConnectedUsers', + (error, users) => { + let connected = false + for (const user of Array.from(users)) { + if ( + user.client_id === this.client.publicId && + user.user_id === this.user_id + ) { + connected = true + break + } + } + expect(connected).to.equal(true) + return done() + } + ) + }) + }) + + describe('when not authorized', function () { + before(function (done) { + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: null, + project: { + name: 'Test Project', + }, + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + cb => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + cb => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + (error, project, privilegeLevel, protocolVersion) => { + this.error = error + this.project = project + this.privilegeLevel = privilegeLevel + this.protocolVersion = protocolVersion + return cb() + } + ) + }, + ], + done + ) + }) + + it('should return an error', function () { + return this.error.message.should.equal('not authorized') + }) + + return it('should not have joined the project room', function (done) { + return RealTimeClient.getConnectedClient( + this.client.socket.sessionid, + (error, client) => { + expect(Array.from(client.rooms).includes(this.project_id)).to.equal( + false + ) + return done() + } + ) + }) + }) + + describe('when not authorized and web replies with a 403', function () { + before(function (done) { + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + project_id: 'forbidden', + privilegeLevel: 'owner', + project: { + name: 'Test Project', + }, + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + cb(e) + } + ) + }, + + cb => { + this.client = RealTimeClient.connect() + this.client.on('connectionAccepted', cb) + }, + + cb => { + this.client.emit( + 'joinProject', + { project_id: this.project_id }, + (error, project, privilegeLevel, protocolVersion) => { + this.error = error + this.project = project + this.privilegeLevel = privilegeLevel + this.protocolVersion = protocolVersion + cb() + } + ) + }, + ], + done + ) + }) + + it('should return an error', function () { + this.error.message.should.equal('not authorized') + }) + + it('should not have joined the project room', function (done) { + RealTimeClient.getConnectedClient( + this.client.socket.sessionid, + (error, client) => { + expect(Array.from(client.rooms).includes(this.project_id)).to.equal( + false + ) + done() + } + ) + }) + }) + + describe('when deleted and web replies with a 404', function () { + before(function (done) { + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + project_id: 'not-found', + privilegeLevel: 'owner', + project: { + name: 'Test Project', + }, + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + cb(e) + } + ) + }, + + cb => { + this.client = RealTimeClient.connect() + this.client.on('connectionAccepted', cb) + }, + + cb => { + this.client.emit( + 'joinProject', + { project_id: this.project_id }, + (error, project, privilegeLevel, protocolVersion) => { + this.error = error + this.project = project + this.privilegeLevel = privilegeLevel + this.protocolVersion = protocolVersion + cb() + } + ) + }, + ], + done + ) + }) + + it('should return an error', function () { + this.error.code.should.equal('ProjectNotFound') + }) + + it('should not have joined the project room', function (done) { + RealTimeClient.getConnectedClient( + this.client.socket.sessionid, + (error, client) => { + expect(Array.from(client.rooms).includes(this.project_id)).to.equal( + false + ) + done() + } + ) + }) + }) + + return describe('when over rate limit', function () { + before(function (done) { + return async.series( + [ + cb => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + cb => { + return this.client.emit( + 'joinProject', + { project_id: 'rate-limited' }, + error => { + this.error = error + return cb() + } + ) + }, + ], + done + ) + }) + + return it('should return a TooManyRequests error code', function () { + this.error.message.should.equal('rate-limit hit when joining project') + return this.error.code.should.equal('TooManyRequests') + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/LeaveDocTests.js b/services/real-time/test/acceptance/js/LeaveDocTests.js new file mode 100644 index 0000000000..61a3288176 --- /dev/null +++ b/services/real-time/test/acceptance/js/LeaveDocTests.js @@ -0,0 +1,174 @@ +/* eslint-disable + camelcase, + handle-callback-err, + no-return-assign, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const { expect } = require('chai') +const sinon = require('sinon') + +const RealTimeClient = require('./helpers/RealTimeClient') +const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer') +const FixturesManager = require('./helpers/FixturesManager') +const logger = require('logger-sharelatex') + +const async = require('async') + +describe('leaveDoc', function () { + before(function () { + this.lines = ['test', 'doc', 'lines'] + this.version = 42 + this.ops = ['mock', 'doc', 'ops'] + sinon.spy(logger, 'error') + sinon.spy(logger, 'warn') + sinon.spy(logger, 'log') + return (this.other_doc_id = FixturesManager.getRandomId()) + }) + + after(function () { + logger.error.restore() // remove the spy + logger.warn.restore() + return logger.log.restore() + }) + + return describe('when joined to a doc', function () { + beforeEach(function (done) { + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'readAndWrite', + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + cb => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + cb => { + return this.client.emit( + 'joinProject', + { project_id: this.project_id }, + cb + ) + }, + + cb => { + return this.client.emit( + 'joinDoc', + this.doc_id, + (error, ...rest) => { + ;[...this.returnedArgs] = Array.from(rest) + return cb(error) + } + ) + }, + ], + done + ) + }) + + describe('then leaving the doc', function () { + beforeEach(function (done) { + return this.client.emit('leaveDoc', this.doc_id, error => { + if (error != null) { + throw error + } + return done() + }) + }) + + return it('should have left the doc room', function (done) { + return RealTimeClient.getConnectedClient( + this.client.socket.sessionid, + (error, client) => { + expect(Array.from(client.rooms).includes(this.doc_id)).to.equal( + false + ) + return done() + } + ) + }) + }) + + describe('when sending a leaveDoc request before the previous joinDoc request has completed', function () { + beforeEach(function (done) { + this.client.emit('leaveDoc', this.doc_id, () => {}) + this.client.emit('joinDoc', this.doc_id, () => {}) + return this.client.emit('leaveDoc', this.doc_id, error => { + if (error != null) { + throw error + } + return done() + }) + }) + + it('should not trigger an error', function () { + return sinon.assert.neverCalledWith( + logger.error, + sinon.match.any, + "not subscribed - shouldn't happen" + ) + }) + + return it('should have left the doc room', function (done) { + return RealTimeClient.getConnectedClient( + this.client.socket.sessionid, + (error, client) => { + expect(Array.from(client.rooms).includes(this.doc_id)).to.equal( + false + ) + return done() + } + ) + }) + }) + + return describe('when sending a leaveDoc for a room the client has not joined ', function () { + beforeEach(function (done) { + return this.client.emit('leaveDoc', this.other_doc_id, error => { + if (error != null) { + throw error + } + return done() + }) + }) + + return it('should trigger a low level message only', function () { + return sinon.assert.calledWith( + logger.log, + sinon.match.any, + 'ignoring request from client to leave room it is not in' + ) + }) + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/LeaveProjectTests.js b/services/real-time/test/acceptance/js/LeaveProjectTests.js new file mode 100644 index 0000000000..9c71678087 --- /dev/null +++ b/services/real-time/test/acceptance/js/LeaveProjectTests.js @@ -0,0 +1,270 @@ +/* eslint-disable + camelcase, + handle-callback-err, + no-throw-literal, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const RealTimeClient = require('./helpers/RealTimeClient') +const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer') +const FixturesManager = require('./helpers/FixturesManager') + +const async = require('async') + +const settings = require('@overleaf/settings') +const redis = require('@overleaf/redis-wrapper') +const rclient = redis.createClient(settings.redis.pubsub) + +describe('leaveProject', function () { + before(function (done) { + return MockDocUpdaterServer.run(done) + }) + + describe('with other clients in the project', function () { + before(function (done) { + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project', + }, + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb() + } + ) + }, + + cb => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connectionAccepted', cb) + }, + + cb => { + this.clientB = RealTimeClient.connect() + this.clientB.on('connectionAccepted', cb) + + this.clientBDisconnectMessages = [] + return this.clientB.on( + 'clientTracking.clientDisconnected', + data => { + return this.clientBDisconnectMessages.push(data) + } + ) + }, + + cb => { + return this.clientA.emit( + 'joinProject', + { project_id: this.project_id }, + (error, project, privilegeLevel, protocolVersion) => { + this.project = project + this.privilegeLevel = privilegeLevel + this.protocolVersion = protocolVersion + return cb(error) + } + ) + }, + + cb => { + return this.clientB.emit( + 'joinProject', + { project_id: this.project_id }, + (error, project, privilegeLevel, protocolVersion) => { + this.project = project + this.privilegeLevel = privilegeLevel + this.protocolVersion = protocolVersion + return cb(error) + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + cb => { + return this.clientA.emit('joinDoc', this.doc_id, cb) + }, + cb => { + return this.clientB.emit('joinDoc', this.doc_id, cb) + }, + + cb => { + // leaveProject is called when the client disconnects + this.clientA.on('disconnect', () => cb()) + return this.clientA.disconnect() + }, + + cb => { + // The API waits a little while before flushing changes + return setTimeout(done, 1000) + }, + ], + done + ) + }) + + it('should emit a disconnect message to the room', function () { + return this.clientBDisconnectMessages.should.deep.equal([ + this.clientA.publicId, + ]) + }) + + it('should no longer list the client in connected users', function (done) { + return this.clientB.emit( + 'clientTracking.getConnectedUsers', + (error, users) => { + for (const user of Array.from(users)) { + if (user.client_id === this.clientA.publicId) { + throw 'Expected clientA to not be listed in connected users' + } + } + return done() + } + ) + }) + + it('should not flush the project to the document updater', function () { + return MockDocUpdaterServer.deleteProject + .calledWith(this.project_id) + .should.equal(false) + }) + + it('should remain subscribed to the editor-events channels', function (done) { + rclient.pubsub('CHANNELS', (err, resp) => { + if (err) { + return done(err) + } + resp.should.include(`editor-events:${this.project_id}`) + return done() + }) + return null + }) + + return it('should remain subscribed to the applied-ops channels', function (done) { + rclient.pubsub('CHANNELS', (err, resp) => { + if (err) { + return done(err) + } + resp.should.include(`applied-ops:${this.doc_id}`) + return done() + }) + return null + }) + }) + + return describe('with no other clients in the project', function () { + before(function (done) { + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project', + }, + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb() + } + ) + }, + + cb => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connect', cb) + }, + + cb => { + return this.clientA.emit( + 'joinProject', + { project_id: this.project_id }, + (error, project, privilegeLevel, protocolVersion) => { + this.project = project + this.privilegeLevel = privilegeLevel + this.protocolVersion = protocolVersion + return cb(error) + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + cb => { + return this.clientA.emit('joinDoc', this.doc_id, cb) + }, + + cb => { + // leaveProject is called when the client disconnects + this.clientA.on('disconnect', () => cb()) + return this.clientA.disconnect() + }, + + cb => { + // The API waits a little while before flushing changes + return setTimeout(done, 1000) + }, + ], + done + ) + }) + + it('should flush the project to the document updater', function () { + return MockDocUpdaterServer.deleteProject + .calledWith(this.project_id) + .should.equal(true) + }) + + it('should not subscribe to the editor-events channels anymore', function (done) { + rclient.pubsub('CHANNELS', (err, resp) => { + if (err) { + return done(err) + } + resp.should.not.include(`editor-events:${this.project_id}`) + return done() + }) + return null + }) + + return it('should not subscribe to the applied-ops channels anymore', function (done) { + rclient.pubsub('CHANNELS', (err, resp) => { + if (err) { + return done(err) + } + resp.should.not.include(`applied-ops:${this.doc_id}`) + return done() + }) + return null + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/MatrixTests.js b/services/real-time/test/acceptance/js/MatrixTests.js new file mode 100644 index 0000000000..690a61fc24 --- /dev/null +++ b/services/real-time/test/acceptance/js/MatrixTests.js @@ -0,0 +1,477 @@ +/* +This test suite is a multi level matrix which allows us to test many cases + with all kinds of setups. + +Users/Actors are defined in USERS and are a low level entity that does connect + to a real-time pod. A typical UserItem is: + + someDescriptiveNameForTheTestSuite: { + setup(cb) { + // + const options = { client: RealTimeClient.connect(), foo: 'bar' } + cb(null, options) + } + } + +Sessions are a set of actions that a User performs in the life-cycle of a + real-time session, before they try something weird. A typical SessionItem is: + + someOtherDescriptiveNameForTheTestSuite: { + getActions(cb) { + cb(null, [ + { rpc: 'RPC_ENDPOINT', args: [...] } + ]) + } + } + +Finally there are InvalidRequests which are the weird actions I hinted on in + the Sessions section. The defined actions may be marked as 'failed' to denote + that real-time rejects them with an (for this test) expected error. + A typical InvalidRequestItem is: + + joinOwnProject: { + getActions(cb) { + cb(null, [ + { rpc: 'RPC_ENDPOINT', args: [...], failed: true } + ]) + } + } + +There is additional meta-data that UserItems and SessionItems may use to skip + certain areas of the matrix. Theses are: + +- Has the User an own project that they join as part of the Session? + UserItem: { hasOwnProject: true, setup(cb) { cb(null, { project_id, ... }) }} + SessionItem: { needsOwnProject: true } + */ +/* eslint-disable + camelcase, +*/ +const { expect } = require('chai') +const async = require('async') + +const RealTimeClient = require('./helpers/RealTimeClient') +const FixturesManager = require('./helpers/FixturesManager') + +const settings = require('@overleaf/settings') +const Keys = settings.redis.documentupdater.key_schema +const redis = require('@overleaf/redis-wrapper') +const rclient = redis.createClient(settings.redis.pubsub) + +function getPendingUpdates(doc_id, cb) { + rclient.lrange(Keys.pendingUpdates({ doc_id }), 0, 10, cb) +} +function cleanupPreviousUpdates(doc_id, cb) { + rclient.del(Keys.pendingUpdates({ doc_id }), cb) +} + +describe('MatrixTests', function () { + let privateProjectId, privateDocId, readWriteProjectId, readWriteDocId + + let privateClient + before(function setupPrivateProject(done) { + FixturesManager.setUpEditorSession( + { privilegeLevel: 'owner' }, + (err, { project_id, doc_id }) => { + if (err) return done(err) + privateProjectId = project_id + privateDocId = doc_id + privateClient = RealTimeClient.connect() + privateClient.on('connectionAccepted', () => { + privateClient.emit( + 'joinProject', + { project_id: privateProjectId }, + err => { + if (err) return done(err) + privateClient.emit('joinDoc', privateDocId, done) + } + ) + }) + } + ) + }) + + before(function setupReadWriteProject(done) { + FixturesManager.setUpEditorSession( + { + publicAccess: 'readAndWrite', + }, + (err, { project_id, doc_id }) => { + readWriteProjectId = project_id + readWriteDocId = doc_id + done(err) + } + ) + }) + + const USER_SETUP = { + anonymous: { + setup(cb) { + RealTimeClient.setSession({}, err => { + if (err) return cb(err) + cb(null, { + client: RealTimeClient.connect(), + }) + }) + }, + }, + + registered: { + setup(cb) { + const user_id = FixturesManager.getRandomId() + RealTimeClient.setSession( + { + user: { + _id: user_id, + first_name: 'Joe', + last_name: 'Bloggs', + }, + }, + err => { + if (err) return cb(err) + cb(null, { + user_id, + client: RealTimeClient.connect(), + }) + } + ) + }, + }, + + registeredWithOwnedProject: { + setup(cb) { + FixturesManager.setUpEditorSession( + { privilegeLevel: 'owner' }, + (err, { project_id, user_id, doc_id }) => { + if (err) return cb(err) + cb(null, { + user_id, + project_id, + doc_id, + client: RealTimeClient.connect(), + }) + } + ) + }, + hasOwnProject: true, + }, + } + + Object.entries(USER_SETUP).forEach(level0 => { + const [userDescription, userItem] = level0 + let options, client + + const SESSION_SETUP = { + noop: { + getActions(cb) { + cb(null, []) + }, + needsOwnProject: false, + }, + + joinReadWriteProject: { + getActions(cb) { + cb(null, [ + { rpc: 'joinProject', args: [{ project_id: readWriteProjectId }] }, + ]) + }, + needsOwnProject: false, + }, + + joinReadWriteProjectAndDoc: { + getActions(cb) { + cb(null, [ + { rpc: 'joinProject', args: [{ project_id: readWriteProjectId }] }, + { rpc: 'joinDoc', args: [readWriteDocId] }, + ]) + }, + needsOwnProject: false, + }, + + joinOwnProject: { + getActions(cb) { + cb(null, [ + { rpc: 'joinProject', args: [{ project_id: options.project_id }] }, + ]) + }, + needsOwnProject: true, + }, + + joinOwnProjectAndDoc: { + getActions(cb) { + cb(null, [ + { rpc: 'joinProject', args: [{ project_id: options.project_id }] }, + { rpc: 'joinDoc', args: [options.doc_id] }, + ]) + }, + needsOwnProject: true, + }, + } + + function performActions(getActions, done) { + getActions((err, actions) => { + if (err) return done(err) + + async.eachSeries( + actions, + (action, cb) => { + if (action.rpc) { + client.emit(action.rpc, ...action.args, (...returnedArgs) => { + const error = returnedArgs.shift() + if (action.fails) { + expect(error).to.exist + expect(returnedArgs).to.have.length(0) + return cb() + } + cb(error) + }) + } else { + cb(new Error('unexpected action')) + } + }, + done + ) + }) + } + + describe(userDescription, function () { + beforeEach(function userSetup(done) { + userItem.setup((err, _options) => { + if (err) return done(err) + + options = _options + client = options.client + client.on('connectionAccepted', done) + }) + }) + + Object.entries(SESSION_SETUP).forEach(level1 => { + const [sessionSetupDescription, sessionSetupItem] = level1 + const INVALID_REQUESTS = { + noop: { + getActions(cb) { + cb(null, []) + }, + }, + + joinProjectWithDocId: { + getActions(cb) { + cb(null, [ + { + rpc: 'joinProject', + args: [{ project_id: privateDocId }], + fails: 1, + }, + ]) + }, + }, + + joinDocWithDocId: { + getActions(cb) { + cb(null, [{ rpc: 'joinDoc', args: [privateDocId], fails: 1 }]) + }, + }, + + joinProjectWithProjectId: { + getActions(cb) { + cb(null, [ + { + rpc: 'joinProject', + args: [{ project_id: privateProjectId }], + fails: 1, + }, + ]) + }, + }, + + joinDocWithProjectId: { + getActions(cb) { + cb(null, [{ rpc: 'joinDoc', args: [privateProjectId], fails: 1 }]) + }, + }, + + joinProjectWithProjectIdThenJoinDocWithDocId: { + getActions(cb) { + cb(null, [ + { + rpc: 'joinProject', + args: [{ project_id: privateProjectId }], + fails: 1, + }, + { rpc: 'joinDoc', args: [privateDocId], fails: 1 }, + ]) + }, + }, + } + + // skip some areas of the matrix + // - some Users do not have an own project + const skip = sessionSetupItem.needsOwnProject && !userItem.hasOwnProject + + describe(sessionSetupDescription, function () { + beforeEach(function performSessionActions(done) { + if (skip) return this.skip() + performActions(sessionSetupItem.getActions, done) + }) + + Object.entries(INVALID_REQUESTS).forEach(level2 => { + const [InvalidRequestDescription, InvalidRequestItem] = level2 + describe(InvalidRequestDescription, function () { + beforeEach(function performInvalidRequests(done) { + performActions(InvalidRequestItem.getActions, done) + }) + + describe('rooms', function () { + it('should not add the user into the privateProject room', function (done) { + RealTimeClient.getConnectedClient( + client.socket.sessionid, + (error, client) => { + if (error) return done(error) + expect(client.rooms).to.not.include(privateProjectId) + done() + } + ) + }) + + it('should not add the user into the privateDoc room', function (done) { + RealTimeClient.getConnectedClient( + client.socket.sessionid, + (error, client) => { + if (error) return done(error) + expect(client.rooms).to.not.include(privateDocId) + done() + } + ) + }) + }) + + describe('receive updates', function () { + const receivedMessages = [] + beforeEach(function publishAnUpdateInRedis(done) { + const update = { + doc_id: privateDocId, + op: { + meta: { source: privateClient.publicId }, + v: 42, + doc: privateDocId, + op: [{ i: 'foo', p: 50 }], + }, + } + client.on('otUpdateApplied', update => { + receivedMessages.push(update) + }) + privateClient.once('otUpdateApplied', () => { + setTimeout(done, 10) + }) + rclient.publish('applied-ops', JSON.stringify(update)) + }) + + it('should send nothing to client', function () { + expect(receivedMessages).to.have.length(0) + }) + }) + + describe('receive messages from web', function () { + const receivedMessages = [] + beforeEach(function publishAMessageInRedis(done) { + const event = { + room_id: privateProjectId, + message: 'removeEntity', + payload: ['foo', 'convertDocToFile'], + _id: 'web:123', + } + client.on('removeEntity', (...args) => { + receivedMessages.push(args) + }) + privateClient.once('removeEntity', () => { + setTimeout(done, 10) + }) + rclient.publish('editor-events', JSON.stringify(event)) + }) + + it('should send nothing to client', function () { + expect(receivedMessages).to.have.length(0) + }) + }) + + describe('send updates', function () { + let receivedArgs, submittedUpdates, update + + beforeEach(function cleanup(done) { + cleanupPreviousUpdates(privateDocId, done) + }) + + beforeEach(function setupUpdateFields() { + update = { + doc_id: privateDocId, + op: { + v: 43, + lastV: 42, + doc: privateDocId, + op: [{ i: 'foo', p: 50 }], + }, + } + }) + + beforeEach(function sendAsUser(done) { + const userUpdate = Object.assign({}, update, { + hash: 'user', + }) + + client.emit( + 'applyOtUpdate', + privateDocId, + userUpdate, + (...args) => { + receivedArgs = args + done() + } + ) + }) + + beforeEach(function sendAsPrivateUserForReferenceOp(done) { + const privateUpdate = Object.assign({}, update, { + hash: 'private', + }) + + privateClient.emit( + 'applyOtUpdate', + privateDocId, + privateUpdate, + done + ) + }) + + beforeEach(function fetchPendingOps(done) { + getPendingUpdates(privateDocId, (err, updates) => { + submittedUpdates = updates + done(err) + }) + }) + + it('should error out trying to send', function () { + expect(receivedArgs).to.have.length(1) + expect(receivedArgs[0]).to.have.property('message') + // we are using an old version of chai: 1.9.2 + // TypeError: expect(...).to.be.oneOf is not a function + expect( + [ + 'no project_id found on client', + 'not authorized', + ].includes(receivedArgs[0].message) + ).to.equal(true) + }) + + it('should submit the private users message only', function () { + expect(submittedUpdates).to.have.length(1) + const update = JSON.parse(submittedUpdates[0]) + expect(update.hash).to.equal('private') + }) + }) + }) + }) + }) + }) + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/PubSubRace.js b/services/real-time/test/acceptance/js/PubSubRace.js new file mode 100644 index 0000000000..9563f71354 --- /dev/null +++ b/services/real-time/test/acceptance/js/PubSubRace.js @@ -0,0 +1,373 @@ +/* eslint-disable + camelcase, + no-return-assign, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const RealTimeClient = require('./helpers/RealTimeClient') +const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer') +const FixturesManager = require('./helpers/FixturesManager') + +const async = require('async') + +const settings = require('@overleaf/settings') +const redis = require('@overleaf/redis-wrapper') +const rclient = redis.createClient(settings.redis.pubsub) + +describe('PubSubRace', function () { + before(function (done) { + return MockDocUpdaterServer.run(done) + }) + + describe('when the client leaves a doc before joinDoc completes', function () { + before(function (done) { + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project', + }, + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb() + } + ) + }, + + cb => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connect', cb) + }, + + cb => { + return this.clientA.emit( + 'joinProject', + { project_id: this.project_id }, + (error, project, privilegeLevel, protocolVersion) => { + this.project = project + this.privilegeLevel = privilegeLevel + this.protocolVersion = protocolVersion + return cb(error) + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + cb => { + this.clientA.emit('joinDoc', this.doc_id, () => {}) + // leave before joinDoc completes + return this.clientA.emit('leaveDoc', this.doc_id, cb) + }, + + cb => { + // wait for subscribe and unsubscribe + return setTimeout(cb, 100) + }, + ], + done + ) + }) + + return it('should not subscribe to the applied-ops channels anymore', function (done) { + rclient.pubsub('CHANNELS', (err, resp) => { + if (err) { + return done(err) + } + resp.should.not.include(`applied-ops:${this.doc_id}`) + return done() + }) + return null + }) + }) + + describe('when the client emits joinDoc and leaveDoc requests frequently and leaves eventually', function () { + before(function (done) { + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project', + }, + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb() + } + ) + }, + + cb => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connect', cb) + }, + + cb => { + return this.clientA.emit( + 'joinProject', + { project_id: this.project_id }, + (error, project, privilegeLevel, protocolVersion) => { + this.project = project + this.privilegeLevel = privilegeLevel + this.protocolVersion = protocolVersion + return cb(error) + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + cb => { + this.clientA.emit('joinDoc', this.doc_id, () => {}) + this.clientA.emit('leaveDoc', this.doc_id, () => {}) + this.clientA.emit('joinDoc', this.doc_id, () => {}) + this.clientA.emit('leaveDoc', this.doc_id, () => {}) + this.clientA.emit('joinDoc', this.doc_id, () => {}) + this.clientA.emit('leaveDoc', this.doc_id, () => {}) + this.clientA.emit('joinDoc', this.doc_id, () => {}) + this.clientA.emit('leaveDoc', this.doc_id, () => {}) + this.clientA.emit('joinDoc', this.doc_id, () => {}) + return this.clientA.emit('leaveDoc', this.doc_id, cb) + }, + + cb => { + // wait for subscribe and unsubscribe + return setTimeout(cb, 100) + }, + ], + done + ) + }) + + return it('should not subscribe to the applied-ops channels anymore', function (done) { + rclient.pubsub('CHANNELS', (err, resp) => { + if (err) { + return done(err) + } + resp.should.not.include(`applied-ops:${this.doc_id}`) + return done() + }) + return null + }) + }) + + describe('when the client emits joinDoc and leaveDoc requests frequently and remains in the doc', function () { + before(function (done) { + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project', + }, + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb() + } + ) + }, + + cb => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connect', cb) + }, + + cb => { + return this.clientA.emit( + 'joinProject', + { project_id: this.project_id }, + (error, project, privilegeLevel, protocolVersion) => { + this.project = project + this.privilegeLevel = privilegeLevel + this.protocolVersion = protocolVersion + return cb(error) + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + cb => { + this.clientA.emit('joinDoc', this.doc_id, () => {}) + this.clientA.emit('leaveDoc', this.doc_id, () => {}) + this.clientA.emit('joinDoc', this.doc_id, () => {}) + this.clientA.emit('leaveDoc', this.doc_id, () => {}) + this.clientA.emit('joinDoc', this.doc_id, () => {}) + this.clientA.emit('leaveDoc', this.doc_id, () => {}) + this.clientA.emit('joinDoc', this.doc_id, () => {}) + this.clientA.emit('leaveDoc', this.doc_id, () => {}) + return this.clientA.emit('joinDoc', this.doc_id, cb) + }, + + cb => { + // wait for subscribe and unsubscribe + return setTimeout(cb, 100) + }, + ], + done + ) + }) + + return it('should subscribe to the applied-ops channels', function (done) { + rclient.pubsub('CHANNELS', (err, resp) => { + if (err) { + return done(err) + } + resp.should.include(`applied-ops:${this.doc_id}`) + return done() + }) + return null + }) + }) + + return describe('when the client disconnects before joinDoc completes', function () { + before(function (done) { + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project', + }, + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb() + } + ) + }, + + cb => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connect', cb) + }, + + cb => { + return this.clientA.emit( + 'joinProject', + { project_id: this.project_id }, + (error, project, privilegeLevel, protocolVersion) => { + this.project = project + this.privilegeLevel = privilegeLevel + this.protocolVersion = protocolVersion + return cb(error) + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + cb => { + let joinDocCompleted = false + this.clientA.emit( + 'joinDoc', + this.doc_id, + () => (joinDocCompleted = true) + ) + // leave before joinDoc completes + return setTimeout( + () => { + if (joinDocCompleted) { + return cb(new Error('joinDocCompleted -- lower timeout')) + } + this.clientA.on('disconnect', () => cb()) + return this.clientA.disconnect() + }, + // socket.io processes joinDoc and disconnect with different delays: + // - joinDoc goes through two process.nextTick + // - disconnect goes through one process.nextTick + // We have to inject the disconnect event into a different event loop + // cycle. + 3 + ) + }, + + cb => { + // wait for subscribe and unsubscribe + return setTimeout(cb, 100) + }, + ], + done + ) + }) + + it('should not subscribe to the editor-events channels anymore', function (done) { + rclient.pubsub('CHANNELS', (err, resp) => { + if (err) { + return done(err) + } + resp.should.not.include(`editor-events:${this.project_id}`) + return done() + }) + return null + }) + + return it('should not subscribe to the applied-ops channels anymore', function (done) { + rclient.pubsub('CHANNELS', (err, resp) => { + if (err) { + return done(err) + } + resp.should.not.include(`applied-ops:${this.doc_id}`) + return done() + }) + return null + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/ReceiveUpdateTests.js b/services/real-time/test/acceptance/js/ReceiveUpdateTests.js new file mode 100644 index 0000000000..b8cd9858b6 --- /dev/null +++ b/services/real-time/test/acceptance/js/ReceiveUpdateTests.js @@ -0,0 +1,347 @@ +/* eslint-disable + camelcase, + handle-callback-err, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const { expect } = require('chai') + +const RealTimeClient = require('./helpers/RealTimeClient') +const MockWebServer = require('./helpers/MockWebServer') +const FixturesManager = require('./helpers/FixturesManager') + +const async = require('async') + +const settings = require('@overleaf/settings') +const redis = require('@overleaf/redis-wrapper') +const rclient = redis.createClient(settings.redis.pubsub) + +describe('receiveUpdate', function () { + beforeEach(function (done) { + this.lines = ['test', 'doc', 'lines'] + this.version = 42 + this.ops = ['mock', 'doc', 'ops'] + + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { name: 'Test Project' }, + }, + (error, { user_id, project_id }) => { + this.user_id = user_id + this.project_id = project_id + return cb() + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id }) => { + this.doc_id = doc_id + return cb(e) + } + ) + }, + + cb => { + this.clientA = RealTimeClient.connect() + return this.clientA.on('connectionAccepted', cb) + }, + + cb => { + this.clientB = RealTimeClient.connect() + return this.clientB.on('connectionAccepted', cb) + }, + + cb => { + return this.clientA.emit( + 'joinProject', + { + project_id: this.project_id, + }, + cb + ) + }, + + cb => { + return this.clientA.emit('joinDoc', this.doc_id, cb) + }, + + cb => { + return this.clientB.emit( + 'joinProject', + { + project_id: this.project_id, + }, + cb + ) + }, + + cb => { + return this.clientB.emit('joinDoc', this.doc_id, cb) + }, + + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { name: 'Test Project' }, + }, + ( + error, + { user_id: user_id_second, project_id: project_id_second } + ) => { + this.user_id_second = user_id_second + this.project_id_second = project_id_second + return cb() + } + ) + }, + + cb => { + return FixturesManager.setUpDoc( + this.project_id_second, + { lines: this.lines, version: this.version, ops: this.ops }, + (e, { doc_id: doc_id_second }) => { + this.doc_id_second = doc_id_second + return cb(e) + } + ) + }, + + cb => { + this.clientC = RealTimeClient.connect() + return this.clientC.on('connectionAccepted', cb) + }, + + cb => { + return this.clientC.emit( + 'joinProject', + { + project_id: this.project_id_second, + }, + cb + ) + }, + cb => { + return this.clientC.emit('joinDoc', this.doc_id_second, cb) + }, + + cb => { + this.clientAUpdates = [] + this.clientA.on('otUpdateApplied', update => + this.clientAUpdates.push(update) + ) + this.clientBUpdates = [] + this.clientB.on('otUpdateApplied', update => + this.clientBUpdates.push(update) + ) + this.clientCUpdates = [] + this.clientC.on('otUpdateApplied', update => + this.clientCUpdates.push(update) + ) + + this.clientAErrors = [] + this.clientA.on('otUpdateError', error => + this.clientAErrors.push(error) + ) + this.clientBErrors = [] + this.clientB.on('otUpdateError', error => + this.clientBErrors.push(error) + ) + this.clientCErrors = [] + this.clientC.on('otUpdateError', error => + this.clientCErrors.push(error) + ) + return cb() + }, + ], + done + ) + }) + + afterEach(function () { + if (this.clientA != null) { + this.clientA.disconnect() + } + if (this.clientB != null) { + this.clientB.disconnect() + } + return this.clientC != null ? this.clientC.disconnect() : undefined + }) + + describe('with an update from clientA', function () { + beforeEach(function (done) { + this.update = { + doc_id: this.doc_id, + op: { + meta: { + source: this.clientA.publicId, + }, + v: this.version, + doc: this.doc_id, + op: [{ i: 'foo', p: 50 }], + }, + } + rclient.publish('applied-ops', JSON.stringify(this.update)) + return setTimeout(done, 200) + }) // Give clients time to get message + + it('should send the full op to clientB', function () { + return this.clientBUpdates.should.deep.equal([this.update.op]) + }) + + it('should send an ack to clientA', function () { + return this.clientAUpdates.should.deep.equal([ + { + v: this.version, + doc: this.doc_id, + }, + ]) + }) + + return it('should send nothing to clientC', function () { + return this.clientCUpdates.should.deep.equal([]) + }) + }) + + describe('with an update from clientC', function () { + beforeEach(function (done) { + this.update = { + doc_id: this.doc_id_second, + op: { + meta: { + source: this.clientC.publicId, + }, + v: this.version, + doc: this.doc_id_second, + op: [{ i: 'update from clientC', p: 50 }], + }, + } + rclient.publish('applied-ops', JSON.stringify(this.update)) + return setTimeout(done, 200) + }) // Give clients time to get message + + it('should send nothing to clientA', function () { + return this.clientAUpdates.should.deep.equal([]) + }) + + it('should send nothing to clientB', function () { + return this.clientBUpdates.should.deep.equal([]) + }) + + return it('should send an ack to clientC', function () { + return this.clientCUpdates.should.deep.equal([ + { + v: this.version, + doc: this.doc_id_second, + }, + ]) + }) + }) + + describe('with an update from a remote client for project 1', function () { + beforeEach(function (done) { + this.update = { + doc_id: this.doc_id, + op: { + meta: { + source: 'this-is-a-remote-client-id', + }, + v: this.version, + doc: this.doc_id, + op: [{ i: 'foo', p: 50 }], + }, + } + rclient.publish('applied-ops', JSON.stringify(this.update)) + return setTimeout(done, 200) + }) // Give clients time to get message + + it('should send the full op to clientA', function () { + return this.clientAUpdates.should.deep.equal([this.update.op]) + }) + + it('should send the full op to clientB', function () { + return this.clientBUpdates.should.deep.equal([this.update.op]) + }) + + return it('should send nothing to clientC', function () { + return this.clientCUpdates.should.deep.equal([]) + }) + }) + + describe('with an error for the first project', function () { + beforeEach(function (done) { + rclient.publish( + 'applied-ops', + JSON.stringify({ + doc_id: this.doc_id, + error: (this.error = 'something went wrong'), + }) + ) + return setTimeout(done, 200) + }) // Give clients time to get message + + it('should send the error to the clients in the first project', function () { + this.clientAErrors.should.deep.equal([this.error]) + return this.clientBErrors.should.deep.equal([this.error]) + }) + + it('should not send any errors to the client in the second project', function () { + return this.clientCErrors.should.deep.equal([]) + }) + + it('should disconnect the clients of the first project', function () { + this.clientA.socket.connected.should.equal(false) + return this.clientB.socket.connected.should.equal(false) + }) + + return it('should not disconnect the client in the second project', function () { + return this.clientC.socket.connected.should.equal(true) + }) + }) + + return describe('with an error for the second project', function () { + beforeEach(function (done) { + rclient.publish( + 'applied-ops', + JSON.stringify({ + doc_id: this.doc_id_second, + error: (this.error = 'something went wrong'), + }) + ) + return setTimeout(done, 200) + }) // Give clients time to get message + + it('should not send any errors to the clients in the first project', function () { + this.clientAErrors.should.deep.equal([]) + return this.clientBErrors.should.deep.equal([]) + }) + + it('should send the error to the client in the second project', function () { + return this.clientCErrors.should.deep.equal([this.error]) + }) + + it('should not disconnect the clients of the first project', function () { + this.clientA.socket.connected.should.equal(true) + return this.clientB.socket.connected.should.equal(true) + }) + + return it('should disconnect the client in the second project', function () { + return this.clientC.socket.connected.should.equal(false) + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/RouterTests.js b/services/real-time/test/acceptance/js/RouterTests.js new file mode 100644 index 0000000000..cc3862975d --- /dev/null +++ b/services/real-time/test/acceptance/js/RouterTests.js @@ -0,0 +1,121 @@ +/* eslint-disable + camelcase, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const async = require('async') +const { expect } = require('chai') + +const RealTimeClient = require('./helpers/RealTimeClient') +const FixturesManager = require('./helpers/FixturesManager') + +describe('Router', function () { + return describe('joinProject', function () { + describe('when there is no callback provided', function () { + after(function () { + return process.removeListener('unhandledRejection', this.onUnhandled) + }) + + before(function (done) { + this.onUnhandled = error => done(error) + process.on('unhandledRejection', this.onUnhandled) + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project', + }, + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + cb => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + cb => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + cb => { + this.client.emit('joinProject', { project_id: this.project_id }) + return setTimeout(cb, 100) + }, + ], + done + ) + }) + + return it('should keep on going', function () { + return expect('still running').to.exist + }) + }) + + return describe('when there are too many arguments', function () { + after(function () { + return process.removeListener('unhandledRejection', this.onUnhandled) + }) + + before(function (done) { + this.onUnhandled = error => done(error) + process.on('unhandledRejection', this.onUnhandled) + return async.series( + [ + cb => { + return FixturesManager.setUpProject( + { + privilegeLevel: 'owner', + project: { + name: 'Test Project', + }, + }, + (e, { project_id, user_id }) => { + this.project_id = project_id + this.user_id = user_id + return cb(e) + } + ) + }, + + cb => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + cb => { + this.client = RealTimeClient.connect() + return this.client.on('connectionAccepted', cb) + }, + + cb => { + return this.client.emit('joinProject', 1, 2, 3, 4, 5, error => { + this.error = error + return cb() + }) + }, + ], + done + ) + }) + + return it('should return an error message', function () { + return expect(this.error.message).to.equal('unexpected arguments') + }) + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/SessionSocketsTests.js b/services/real-time/test/acceptance/js/SessionSocketsTests.js new file mode 100644 index 0000000000..ae636fdd96 --- /dev/null +++ b/services/real-time/test/acceptance/js/SessionSocketsTests.js @@ -0,0 +1,103 @@ +/* eslint-disable + no-return-assign, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const RealTimeClient = require('./helpers/RealTimeClient') +const Settings = require('@overleaf/settings') +const { expect } = require('chai') + +describe('SessionSockets', function () { + before(function () { + return (this.checkSocket = function (fn) { + const client = RealTimeClient.connect() + client.on('connectionAccepted', fn) + client.on('connectionRejected', fn) + return null + }) + }) + + describe('without cookies', function () { + before(function () { + return (RealTimeClient.cookie = null) + }) + + return it('should return a lookup error', function (done) { + return this.checkSocket(error => { + expect(error).to.exist + expect(error.message).to.equal('invalid session') + return done() + }) + }) + }) + + describe('with a different cookie', function () { + before(function () { + return (RealTimeClient.cookie = 'some.key=someValue') + }) + + return it('should return a lookup error', function (done) { + return this.checkSocket(error => { + expect(error).to.exist + expect(error.message).to.equal('invalid session') + return done() + }) + }) + }) + + describe('with an invalid cookie', function () { + before(function (done) { + RealTimeClient.setSession({}, error => { + if (error) { + return done(error) + } + RealTimeClient.cookie = `${ + Settings.cookieName + }=${RealTimeClient.cookie.slice(17, 49)}` + return done() + }) + return null + }) + + return it('should return a lookup error', function (done) { + return this.checkSocket(error => { + expect(error).to.exist + expect(error.message).to.equal('invalid session') + return done() + }) + }) + }) + + describe('with a valid cookie and no matching session', function () { + before(function () { + return (RealTimeClient.cookie = `${Settings.cookieName}=unknownId`) + }) + + return it('should return a lookup error', function (done) { + return this.checkSocket(error => { + expect(error).to.exist + expect(error.message).to.equal('invalid session') + return done() + }) + }) + }) + + return describe('with a valid cookie and a matching session', function () { + before(function (done) { + RealTimeClient.setSession({}, done) + return null + }) + + return it('should not return an error', function (done) { + return this.checkSocket(error => { + expect(error).to.not.exist + return done() + }) + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/SessionTests.js b/services/real-time/test/acceptance/js/SessionTests.js new file mode 100644 index 0000000000..21d691bea3 --- /dev/null +++ b/services/real-time/test/acceptance/js/SessionTests.js @@ -0,0 +1,60 @@ +/* eslint-disable + handle-callback-err, + no-return-assign, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const { expect } = require('chai') + +const RealTimeClient = require('./helpers/RealTimeClient') + +describe('Session', function () { + return describe('with an established session', function () { + before(function (done) { + this.user_id = 'mock-user-id' + RealTimeClient.setSession( + { + user: { _id: this.user_id }, + }, + error => { + if (error != null) { + throw error + } + this.client = RealTimeClient.connect() + return done() + } + ) + return null + }) + + it('should not get disconnected', function (done) { + let disconnected = false + this.client.on('disconnect', () => (disconnected = true)) + return setTimeout(() => { + expect(disconnected).to.equal(false) + return done() + }, 500) + }) + + return it('should appear in the list of connected clients', function (done) { + return RealTimeClient.getConnectedClients((error, clients) => { + let included = false + for (const client of Array.from(clients)) { + if (client.client_id === this.client.socket.sessionid) { + included = true + break + } + } + expect(included).to.equal(true) + return done() + }) + }) + }) +}) diff --git a/services/real-time/test/acceptance/js/helpers/FixturesManager.js b/services/real-time/test/acceptance/js/helpers/FixturesManager.js new file mode 100644 index 0000000000..48fdc16ada --- /dev/null +++ b/services/real-time/test/acceptance/js/helpers/FixturesManager.js @@ -0,0 +1,130 @@ +/* eslint-disable + camelcase, + handle-callback-err, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let FixturesManager +const RealTimeClient = require('./RealTimeClient') +const MockWebServer = require('./MockWebServer') +const MockDocUpdaterServer = require('./MockDocUpdaterServer') + +module.exports = FixturesManager = { + setUpProject(options, callback) { + if (options == null) { + options = {} + } + if (callback == null) { + callback = function (error, data) {} + } + if (!options.user_id) { + options.user_id = FixturesManager.getRandomId() + } + if (!options.project_id) { + options.project_id = FixturesManager.getRandomId() + } + if (!options.project) { + options.project = { name: 'Test Project' } + } + const { project_id, user_id, privilegeLevel, project, publicAccess } = + options + + const privileges = {} + privileges[user_id] = privilegeLevel + if (publicAccess) { + privileges['anonymous-user'] = publicAccess + } + + MockWebServer.createMockProject(project_id, privileges, project) + return MockWebServer.run(error => { + if (error != null) { + throw error + } + return RealTimeClient.setSession( + { + user: { + _id: user_id, + first_name: 'Joe', + last_name: 'Bloggs', + }, + }, + error => { + if (error != null) { + throw error + } + return callback(null, { + project_id, + user_id, + privilegeLevel, + project, + }) + } + ) + }) + }, + + setUpDoc(project_id, options, callback) { + if (options == null) { + options = {} + } + if (callback == null) { + callback = function (error, data) {} + } + if (!options.doc_id) { + options.doc_id = FixturesManager.getRandomId() + } + if (!options.lines) { + options.lines = ['doc', 'lines'] + } + if (!options.version) { + options.version = 42 + } + if (!options.ops) { + options.ops = ['mock', 'ops'] + } + const { doc_id, lines, version, ops, ranges } = options + + MockDocUpdaterServer.createMockDoc(project_id, doc_id, { + lines, + version, + ops, + ranges, + }) + return MockDocUpdaterServer.run(error => { + if (error != null) { + throw error + } + return callback(null, { project_id, doc_id, lines, version, ops }) + }) + }, + + setUpEditorSession(options, callback) { + FixturesManager.setUpProject(options, (err, detailsProject) => { + if (err) return callback(err) + + FixturesManager.setUpDoc( + detailsProject.project_id, + options, + (err, detailsDoc) => { + if (err) return callback(err) + + callback(null, Object.assign({}, detailsProject, detailsDoc)) + } + ) + }) + }, + + getRandomId() { + return require('crypto') + .createHash('sha1') + .update(Math.random().toString()) + .digest('hex') + .slice(0, 24) + }, +} diff --git a/services/real-time/test/acceptance/js/helpers/MockDocUpdaterServer.js b/services/real-time/test/acceptance/js/helpers/MockDocUpdaterServer.js new file mode 100644 index 0000000000..519da94745 --- /dev/null +++ b/services/real-time/test/acceptance/js/helpers/MockDocUpdaterServer.js @@ -0,0 +1,93 @@ +/* eslint-disable + camelcase, + handle-callback-err, + no-return-assign, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let MockDocUpdaterServer +const sinon = require('sinon') +const express = require('express') + +module.exports = MockDocUpdaterServer = { + docs: {}, + + createMockDoc(project_id, doc_id, data) { + return (MockDocUpdaterServer.docs[`${project_id}:${doc_id}`] = data) + }, + + getDocument(project_id, doc_id, fromVersion, callback) { + if (callback == null) { + callback = function (error, data) {} + } + return callback(null, MockDocUpdaterServer.docs[`${project_id}:${doc_id}`]) + }, + + deleteProject: sinon.stub().callsArg(1), + + getDocumentRequest(req, res, next) { + const { project_id, doc_id } = req.params + let { fromVersion } = req.query + fromVersion = parseInt(fromVersion, 10) + return MockDocUpdaterServer.getDocument( + project_id, + doc_id, + fromVersion, + (error, data) => { + if (error != null) { + return next(error) + } + if (!data) { + return res.sendStatus(404) + } + return res.json(data) + } + ) + }, + + deleteProjectRequest(req, res, next) { + const { project_id } = req.params + return MockDocUpdaterServer.deleteProject(project_id, error => { + if (error != null) { + return next(error) + } + return res.sendStatus(204) + }) + }, + + running: false, + run(callback) { + if (callback == null) { + callback = function (error) {} + } + if (MockDocUpdaterServer.running) { + return callback() + } + const app = express() + app.get( + '/project/:project_id/doc/:doc_id', + MockDocUpdaterServer.getDocumentRequest + ) + app.delete( + '/project/:project_id', + MockDocUpdaterServer.deleteProjectRequest + ) + return app + .listen(3003, error => { + MockDocUpdaterServer.running = true + return callback(error) + }) + .on('error', error => { + console.error('error starting MockDocUpdaterServer:', error.message) + return process.exit(1) + }) + }, +} + +sinon.spy(MockDocUpdaterServer, 'getDocument') diff --git a/services/real-time/test/acceptance/js/helpers/MockWebServer.js b/services/real-time/test/acceptance/js/helpers/MockWebServer.js new file mode 100644 index 0000000000..c84bfd14b6 --- /dev/null +++ b/services/real-time/test/acceptance/js/helpers/MockWebServer.js @@ -0,0 +1,89 @@ +/* eslint-disable + camelcase, + handle-callback-err, + no-return-assign, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let MockWebServer +const sinon = require('sinon') +const express = require('express') + +module.exports = MockWebServer = { + projects: {}, + privileges: {}, + + createMockProject(project_id, privileges, project) { + MockWebServer.privileges[project_id] = privileges + return (MockWebServer.projects[project_id] = project) + }, + + joinProject(project_id, user_id, callback) { + if (callback == null) { + callback = function (error, project, privilegeLevel) {} + } + return callback( + null, + MockWebServer.projects[project_id], + MockWebServer.privileges[project_id][user_id] || + MockWebServer.privileges[project_id]['anonymous-user'] + ) + }, + + joinProjectRequest(req, res, next) { + const { project_id } = req.params + const { user_id } = req.query + if (project_id === 'not-found') { + return res.status(404).send() + } + if (project_id === 'forbidden') { + return res.status(403).send() + } + if (project_id === 'rate-limited') { + return res.status(429).send() + } else { + return MockWebServer.joinProject( + project_id, + user_id, + (error, project, privilegeLevel) => { + if (error != null) { + return next(error) + } + return res.json({ + project, + privilegeLevel, + }) + } + ) + } + }, + + running: false, + run(callback) { + if (callback == null) { + callback = function (error) {} + } + if (MockWebServer.running) { + return callback() + } + const app = express() + app.post('/project/:project_id/join', MockWebServer.joinProjectRequest) + return app + .listen(3000, error => { + MockWebServer.running = true + return callback(error) + }) + .on('error', error => { + console.error('error starting MockWebServer:', error.message) + return process.exit(1) + }) + }, +} + +sinon.spy(MockWebServer, 'joinProject') diff --git a/services/real-time/test/acceptance/js/helpers/RealTimeClient.js b/services/real-time/test/acceptance/js/helpers/RealTimeClient.js new file mode 100644 index 0000000000..00f00b5ee9 --- /dev/null +++ b/services/real-time/test/acceptance/js/helpers/RealTimeClient.js @@ -0,0 +1,127 @@ +/* eslint-disable + camelcase, + handle-callback-err, + no-return-assign, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +let Client +const { XMLHttpRequest } = require('../../libs/XMLHttpRequest') +const io = require('socket.io-client') +const async = require('async') + +const request = require('request') +const Settings = require('@overleaf/settings') +const redis = require('@overleaf/redis-wrapper') +const rclient = redis.createClient(Settings.redis.websessions) + +const uid = require('uid-safe').sync +const signature = require('cookie-signature') + +io.util.request = function () { + const xhr = new XMLHttpRequest() + const _open = xhr.open + xhr.open = function () { + _open.apply(xhr, arguments) + if (Client.cookie != null) { + return xhr.setRequestHeader('Cookie', Client.cookie) + } + } + return xhr +} + +module.exports = Client = { + cookie: null, + + setSession(session, callback) { + if (callback == null) { + callback = function (error) {} + } + const sessionId = uid(24) + session.cookie = {} + return rclient.set('sess:' + sessionId, JSON.stringify(session), error => { + if (error != null) { + return callback(error) + } + const secret = Settings.security.sessionSecret + const cookieKey = 's:' + signature.sign(sessionId, secret) + Client.cookie = `${Settings.cookieName}=${cookieKey}` + return callback() + }) + }, + + unsetSession(callback) { + if (callback == null) { + callback = function (error) {} + } + Client.cookie = null + return callback() + }, + + connect(cookie) { + const client = io.connect('http://localhost:3026', { + 'force new connection': true, + }) + client.on( + 'connectionAccepted', + (_, publicId) => (client.publicId = publicId) + ) + return client + }, + + getConnectedClients(callback) { + if (callback == null) { + callback = function (error, clients) {} + } + return request.get( + { + url: 'http://localhost:3026/clients', + json: true, + }, + (error, response, data) => callback(error, data) + ) + }, + + getConnectedClient(client_id, callback) { + if (callback == null) { + callback = function (error, clients) {} + } + return request.get( + { + url: `http://localhost:3026/clients/${client_id}`, + json: true, + }, + (error, response, data) => callback(error, data) + ) + }, + + disconnectClient(client_id, callback) { + request.post( + { + url: `http://localhost:3026/client/${client_id}/disconnect`, + auth: { + user: Settings.internal.realTime.user, + pass: Settings.internal.realTime.pass, + }, + }, + (error, response, data) => callback(error, data) + ) + return null + }, + + disconnectAllClients(callback) { + return Client.getConnectedClients((error, clients) => + async.each( + clients, + (clientView, cb) => Client.disconnectClient(clientView.client_id, cb), + callback + ) + ) + }, +} diff --git a/services/real-time/test/acceptance/js/helpers/RealtimeServer.js b/services/real-time/test/acceptance/js/helpers/RealtimeServer.js new file mode 100644 index 0000000000..e964765b7a --- /dev/null +++ b/services/real-time/test/acceptance/js/helpers/RealtimeServer.js @@ -0,0 +1,64 @@ +/* eslint-disable + handle-callback-err, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS103: Rewrite code to no longer use __guard__ + * DS205: Consider reworking code to avoid use of IIFEs + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const app = require('../../../../app') +const logger = require('logger-sharelatex') +const Settings = require('@overleaf/settings') + +module.exports = { + running: false, + initing: false, + callbacks: [], + ensureRunning(callback) { + if (callback == null) { + callback = function (error) {} + } + if (this.running) { + return callback() + } else if (this.initing) { + return this.callbacks.push(callback) + } else { + this.initing = true + this.callbacks.push(callback) + return app.listen( + __guard__( + Settings.internal != null ? Settings.internal.realtime : undefined, + x => x.port + ), + 'localhost', + error => { + if (error != null) { + throw error + } + this.running = true + logger.log('clsi running in dev mode') + + return (() => { + const result = [] + for (callback of Array.from(this.callbacks)) { + result.push(callback()) + } + return result + })() + } + ) + } + }, +} + +function __guard__(value, transform) { + return typeof value !== 'undefined' && value !== null + ? transform(value) + : undefined +} diff --git a/services/real-time/test/acceptance/libs/XMLHttpRequest.js b/services/real-time/test/acceptance/libs/XMLHttpRequest.js new file mode 100644 index 0000000000..b0436718e3 --- /dev/null +++ b/services/real-time/test/acceptance/libs/XMLHttpRequest.js @@ -0,0 +1,579 @@ +/** + * Wrapper for built-in http.js to emulate the browser XMLHttpRequest object. + * + * This can be used with JS designed for browsers to improve reuse of code and + * allow the use of existing libraries. + * + * Usage: include("XMLHttpRequest.js") and use XMLHttpRequest per W3C specs. + * + * @author Dan DeFelippi + * @contributor David Ellis + * @license MIT + */ + +const { URL } = require('url') +const spawn = require('child_process').spawn +const fs = require('fs') + +exports.XMLHttpRequest = function () { + /** + * Private variables + */ + const self = this + const http = require('http') + const https = require('https') + + // Holds http.js objects + let request + let response + + // Request settings + let settings = {} + + // Set some default headers + const defaultHeaders = { + 'User-Agent': 'node-XMLHttpRequest', + Accept: '*/*', + } + + let headers = defaultHeaders + + // These headers are not user setable. + // The following are allowed but banned in the spec: + // * user-agent + const forbiddenRequestHeaders = [ + 'accept-charset', + 'accept-encoding', + 'access-control-request-headers', + 'access-control-request-method', + 'connection', + 'content-length', + 'content-transfer-encoding', + // "cookie", + 'cookie2', + 'date', + 'expect', + 'host', + 'keep-alive', + 'origin', + 'referer', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'via', + ] + + // These request methods are not allowed + const forbiddenRequestMethods = ['TRACE', 'TRACK', 'CONNECT'] + + // Send flag + let sendFlag = false + // Error flag, used when errors occur or abort is called + let errorFlag = false + + // Event listeners + const listeners = {} + + /** + * Constants + */ + + this.UNSENT = 0 + this.OPENED = 1 + this.HEADERS_RECEIVED = 2 + this.LOADING = 3 + this.DONE = 4 + + /** + * Public vars + */ + + // Current state + this.readyState = this.UNSENT + + // default ready state change handler in case one is not set or is set late + this.onreadystatechange = null + + // Result & response + this.responseText = '' + this.responseXML = '' + this.status = null + this.statusText = null + + /** + * Private methods + */ + + /** + * Check if the specified header is allowed. + * + * @param string header Header to validate + * @return boolean False if not allowed, otherwise true + */ + const isAllowedHttpHeader = function (header) { + return ( + header && forbiddenRequestHeaders.indexOf(header.toLowerCase()) === -1 + ) + } + + /** + * Check if the specified method is allowed. + * + * @param string method Request method to validate + * @return boolean False if not allowed, otherwise true + */ + const isAllowedHttpMethod = function (method) { + return method && forbiddenRequestMethods.indexOf(method) === -1 + } + + /** + * Public methods + */ + + /** + * Open the connection. Currently supports local server requests. + * + * @param string method Connection method (eg GET, POST) + * @param string url URL for the connection. + * @param boolean async Asynchronous connection. Default is true. + * @param string user Username for basic authentication (optional) + * @param string password Password for basic authentication (optional) + */ + this.open = function (method, url, async, user, password) { + this.abort() + errorFlag = false + + // Check for valid request method + if (!isAllowedHttpMethod(method)) { + throw new Error('SecurityError: Request method not allowed') + } + + settings = { + method: method, + url: url.toString(), + async: typeof async !== 'boolean' ? true : async, + user: user || null, + password: password || null, + } + + setState(this.OPENED) + } + + /** + * Sets a header for the request. + * + * @param string header Header name + * @param string value Header value + */ + this.setRequestHeader = function (header, value) { + if (this.readyState !== this.OPENED) { + throw new Error( + 'INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN' + ) + } + if (!isAllowedHttpHeader(header)) { + console.warn('Refused to set unsafe header "' + header + '"') + return + } + if (sendFlag) { + throw new Error('INVALID_STATE_ERR: send flag is true') + } + headers[header] = value + } + + /** + * Gets a header from the server response. + * + * @param string header Name of header to get. + * @return string Text of the header or null if it doesn't exist. + */ + this.getResponseHeader = function (header) { + if ( + typeof header === 'string' && + this.readyState > this.OPENED && + response.headers[header.toLowerCase()] && + !errorFlag + ) { + return response.headers[header.toLowerCase()] + } + + return null + } + + /** + * Gets all the response headers. + * + * @return string A string with all response headers separated by CR+LF + */ + this.getAllResponseHeaders = function () { + if (this.readyState < this.HEADERS_RECEIVED || errorFlag) { + return '' + } + let result = '' + + for (const i in response.headers) { + // Cookie headers are excluded + if (i !== 'set-cookie' && i !== 'set-cookie2') { + result += i + ': ' + response.headers[i] + '\r\n' + } + } + return result.substr(0, result.length - 2) + } + + /** + * Gets a request header + * + * @param string name Name of header to get + * @return string Returns the request header or empty string if not set + */ + this.getRequestHeader = function (name) { + // @TODO Make this case insensitive + if (typeof name === 'string' && headers[name]) { + return headers[name] + } + + return '' + } + + /** + * Sends the request to the server. + * + * @param string data Optional data to send as request body. + */ + this.send = function (data) { + if (this.readyState !== this.OPENED) { + throw new Error( + 'INVALID_STATE_ERR: connection must be opened before send() is called' + ) + } + + if (sendFlag) { + throw new Error('INVALID_STATE_ERR: send has already been called') + } + + let host + let ssl = false + let local = false + const url = new URL(settings.url) + + // Determine the server + switch (url.protocol) { + case 'https:': + ssl = true + host = url.hostname + break + case 'http:': + host = url.hostname + break + + case 'file:': + local = true + break + + case undefined: + case '': + host = 'localhost' + break + + default: + throw new Error('Protocol not supported.') + } + + // Load files off the local filesystem (file://) + if (local) { + if (settings.method !== 'GET') { + throw new Error('XMLHttpRequest: Only GET method is supported') + } + + if (settings.async) { + fs.readFile(url.pathname, 'utf8', (error, data) => { + if (error) { + self.handleError(error) + } else { + self.status = 200 + self.responseText = data + setState(self.DONE) + } + }) + } else { + try { + this.responseText = fs.readFileSync(url.pathname, 'utf8') + this.status = 200 + setState(self.DONE) + } catch (e) { + this.handleError(e) + } + } + + return + } + + // Default to port 80. If accessing localhost on another port be sure + // to use http://localhost:port/path + const port = url.port || (ssl ? 443 : 80) + // Add query string if one is used + const uri = url.pathname + (url.search ? url.search : '') + + // Set the Host header or the server may reject the request + headers.Host = host + if (!((ssl && port === 443) || port === 80)) { + headers.Host += ':' + url.port + } + + // Set Basic Auth if necessary + if (settings.user) { + if (typeof settings.password === 'undefined') { + settings.password = '' + } + const authBuf = Buffer.from(settings.user + ':' + settings.password) + headers.Authorization = 'Basic ' + authBuf.toString('base64') + } + + // Set content length header + if (settings.method === 'GET' || settings.method === 'HEAD') { + data = null + } else if (data) { + headers['Content-Length'] = Buffer.byteLength(data) + + if (!headers['Content-Type']) { + headers['Content-Type'] = 'text/plain;charset=UTF-8' + } + } else if (settings.method === 'POST') { + // For a post with no data set Content-Length: 0. + // This is required by buggy servers that don't meet the specs. + headers['Content-Length'] = 0 + } + + const options = { + host: host, + port: port, + path: uri, + method: settings.method, + headers: headers, + } + + // Reset error flag + errorFlag = false + + // Handle async requests + if (settings.async) { + // Use the proper protocol + const doRequest = ssl ? https.request : http.request + + // Request is being sent, set send flag + sendFlag = true + + // As per spec, this is called here for historical reasons. + self.dispatchEvent('readystatechange') + + // Create the request + request = doRequest(options, resp => { + response = resp + response.setEncoding('utf8') + + setState(self.HEADERS_RECEIVED) + self.status = response.statusCode + + response.on('data', chunk => { + // Make sure there's some data + if (chunk) { + self.responseText += chunk + } + // Don't emit state changes if the connection has been aborted. + if (sendFlag) { + setState(self.LOADING) + } + }) + + response.on('end', () => { + if (sendFlag) { + // Discard the 'end' event if the connection has been aborted + setState(self.DONE) + sendFlag = false + } + }) + + response.on('error', error => { + self.handleError(error) + }) + }).on('error', error => { + self.handleError(error) + }) + + // Node 0.4 and later won't accept empty data. Make sure it's needed. + if (data) { + request.write(data) + } + + request.end() + + self.dispatchEvent('loadstart') + } else { + // Synchronous + // Create a temporary file for communication with the other Node process + const syncFile = '.node-xmlhttprequest-sync-' + process.pid + fs.writeFileSync(syncFile, '', 'utf8') + // The async request the other Node process executes + const execString = + "var http = require('http'), https = require('https'), fs = require('fs');" + + 'var doRequest = http' + + (ssl ? 's' : '') + + '.request;' + + 'var options = ' + + JSON.stringify(options) + + ';' + + "var responseText = '';" + + 'var req = doRequest(options, function(response) {' + + "response.setEncoding('utf8');" + + "response.on('data', function(chunk) {" + + 'responseText += chunk;' + + '});' + + "response.on('end', function() {" + + "fs.writeFileSync('" + + syncFile + + "', 'NODE-XMLHTTPREQUEST-STATUS:' + response.statusCode + ',' + responseText, 'utf8');" + + '});' + + "response.on('error', function(error) {" + + "fs.writeFileSync('" + + syncFile + + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" + + '});' + + "}).on('error', function(error) {" + + "fs.writeFileSync('" + + syncFile + + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" + + '});' + + (data ? "req.write('" + data.replace(/'/g, "\\'") + "');" : '') + + 'req.end();' + // Start the other Node Process, executing this string + const syncProc = spawn(process.argv[0], ['-e', execString]) + while ((self.responseText = fs.readFileSync(syncFile, 'utf8')) === '') { + // Wait while the file is empty + } + // Kill the child process once the file has data + syncProc.stdin.end() + // Remove the temporary file + fs.unlinkSync(syncFile) + if (self.responseText.match(/^NODE-XMLHTTPREQUEST-ERROR:/)) { + // If the file returned an error, handle it + const errorObj = self.responseText.replace( + /^NODE-XMLHTTPREQUEST-ERROR:/, + '' + ) + self.handleError(errorObj) + } else { + // If the file returned okay, parse its data and move to the DONE state + self.status = self.responseText.replace( + /^NODE-XMLHTTPREQUEST-STATUS:([0-9]*),.*/, + '$1' + ) + self.responseText = self.responseText.replace( + /^NODE-XMLHTTPREQUEST-STATUS:[0-9]*,(.*)/, + '$1' + ) + setState(self.DONE) + } + } + } + + /** + * Called when an error is encountered to deal with it. + */ + this.handleError = function (error) { + this.status = 503 + this.statusText = error + this.responseText = error.stack + errorFlag = true + setState(this.DONE) + } + + /** + * Aborts a request. + */ + this.abort = function () { + if (request) { + request.abort() + request = null + } + + headers = defaultHeaders + this.responseText = '' + this.responseXML = '' + + errorFlag = true + + if ( + this.readyState !== this.UNSENT && + (this.readyState !== this.OPENED || sendFlag) && + this.readyState !== this.DONE + ) { + sendFlag = false + setState(this.DONE) + } + this.readyState = this.UNSENT + } + + /** + * Adds an event listener. Preferred method of binding to events. + */ + this.addEventListener = function (event, callback) { + if (!(event in listeners)) { + listeners[event] = [] + } + // Currently allows duplicate callbacks. Should it? + listeners[event].push(callback) + } + + /** + * Remove an event callback that has already been bound. + * Only works on the matching funciton, cannot be a copy. + */ + this.removeEventListener = function (event, callback) { + if (event in listeners) { + // Filter will return a new array with the callback removed + listeners[event] = listeners[event].filter(ev => { + return ev !== callback + }) + } + } + + /** + * Dispatch any events, including both "on" methods and events attached using addEventListener. + */ + this.dispatchEvent = function (event) { + if (typeof self['on' + event] === 'function') { + self['on' + event]() + } + if (event in listeners) { + for (let i = 0, len = listeners[event].length; i < len; i++) { + listeners[event][i].call(self) + } + } + } + + /** + * Changes readyState and calls onreadystatechange. + * + * @param int state New state + */ + var setState = function (state) { + if (self.readyState !== state) { + self.readyState = state + + if ( + settings.async || + self.readyState < self.OPENED || + self.readyState === self.DONE + ) { + self.dispatchEvent('readystatechange') + } + + if (self.readyState === self.DONE && !errorFlag) { + self.dispatchEvent('load') + // @TODO figure out InspectorInstrumentation::didLoadXHR(cookie) + self.dispatchEvent('loadend') + } + } + } +} diff --git a/services/real-time/test/acceptance/scripts/full-test.sh b/services/real-time/test/acceptance/scripts/full-test.sh new file mode 100755 index 0000000000..8584cd17d0 --- /dev/null +++ b/services/real-time/test/acceptance/scripts/full-test.sh @@ -0,0 +1,23 @@ +#! /usr/bin/env bash + +# npm rebuild + +echo ">> Starting server..." + +grunt --no-color forever:app:start + +echo ">> Server started" + +sleep 5 + +echo ">> Running acceptance tests..." +grunt --no-color mochaTest:acceptance +_test_exit_code=$? + +echo ">> Killing server" + +grunt --no-color forever:app:stop + +echo ">> Done" + +exit $_test_exit_code diff --git a/services/real-time/test/setup.js b/services/real-time/test/setup.js new file mode 100644 index 0000000000..19520444e8 --- /dev/null +++ b/services/real-time/test/setup.js @@ -0,0 +1,38 @@ +const chai = require('chai') +const SandboxedModule = require('sandboxed-module') +const sinon = require('sinon') + +// Chai configuration +chai.should() + +// Global stubs +const sandbox = sinon.createSandbox() +const stubs = { + logger: { + debug: sandbox.stub(), + log: sandbox.stub(), + info: sandbox.stub(), + warn: sandbox.stub(), + err: sandbox.stub(), + error: sandbox.stub(), + }, +} + +// SandboxedModule configuration +SandboxedModule.configure({ + requires: { + 'logger-sharelatex': stubs.logger, + }, + globals: { Buffer, JSON, console, process }, +}) + +// Mocha hooks +exports.mochaHooks = { + beforeEach() { + this.logger = stubs.logger + }, + + afterEach() { + sandbox.reset() + }, +} diff --git a/services/real-time/test/unit/js/AuthorizationManagerTests.js b/services/real-time/test/unit/js/AuthorizationManagerTests.js new file mode 100644 index 0000000000..422ce8d15d --- /dev/null +++ b/services/real-time/test/unit/js/AuthorizationManagerTests.js @@ -0,0 +1,318 @@ +/* eslint-disable + no-return-assign, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const { expect } = require('chai') +const sinon = require('sinon') +const SandboxedModule = require('sandboxed-module') +const path = require('path') +const modulePath = '../../../app/js/AuthorizationManager' + +describe('AuthorizationManager', function () { + beforeEach(function () { + this.client = { ol_context: {} } + + return (this.AuthorizationManager = SandboxedModule.require(modulePath, { + requires: {}, + })) + }) + + describe('assertClientCanViewProject', function () { + it('should allow the readOnly privilegeLevel', function (done) { + this.client.ol_context.privilege_level = 'readOnly' + return this.AuthorizationManager.assertClientCanViewProject( + this.client, + error => { + expect(error).to.be.null + return done() + } + ) + }) + + it('should allow the readAndWrite privilegeLevel', function (done) { + this.client.ol_context.privilege_level = 'readAndWrite' + return this.AuthorizationManager.assertClientCanViewProject( + this.client, + error => { + expect(error).to.be.null + return done() + } + ) + }) + + it('should allow the owner privilegeLevel', function (done) { + this.client.ol_context.privilege_level = 'owner' + return this.AuthorizationManager.assertClientCanViewProject( + this.client, + error => { + expect(error).to.be.null + return done() + } + ) + }) + + return it('should return an error with any other privilegeLevel', function (done) { + this.client.ol_context.privilege_level = 'unknown' + return this.AuthorizationManager.assertClientCanViewProject( + this.client, + error => { + error.message.should.equal('not authorized') + return done() + } + ) + }) + }) + + describe('assertClientCanEditProject', function () { + it('should not allow the readOnly privilegeLevel', function (done) { + this.client.ol_context.privilege_level = 'readOnly' + return this.AuthorizationManager.assertClientCanEditProject( + this.client, + error => { + error.message.should.equal('not authorized') + return done() + } + ) + }) + + it('should allow the readAndWrite privilegeLevel', function (done) { + this.client.ol_context.privilege_level = 'readAndWrite' + return this.AuthorizationManager.assertClientCanEditProject( + this.client, + error => { + expect(error).to.be.null + return done() + } + ) + }) + + it('should allow the owner privilegeLevel', function (done) { + this.client.ol_context.privilege_level = 'owner' + return this.AuthorizationManager.assertClientCanEditProject( + this.client, + error => { + expect(error).to.be.null + return done() + } + ) + }) + + return it('should return an error with any other privilegeLevel', function (done) { + this.client.ol_context.privilege_level = 'unknown' + return this.AuthorizationManager.assertClientCanEditProject( + this.client, + error => { + error.message.should.equal('not authorized') + return done() + } + ) + }) + }) + + // check doc access for project + + describe('assertClientCanViewProjectAndDoc', function () { + beforeEach(function () { + this.doc_id = '12345' + this.callback = sinon.stub() + return (this.client.ol_context = {}) + }) + + describe('when not authorised at the project level', function () { + beforeEach(function () { + return (this.client.ol_context.privilege_level = 'unknown') + }) + + it('should not allow access', function () { + return this.AuthorizationManager.assertClientCanViewProjectAndDoc( + this.client, + this.doc_id, + err => err.message.should.equal('not authorized') + ) + }) + + return describe('even when authorised at the doc level', function () { + beforeEach(function (done) { + return this.AuthorizationManager.addAccessToDoc( + this.client, + this.doc_id, + done + ) + }) + + return it('should not allow access', function () { + return this.AuthorizationManager.assertClientCanViewProjectAndDoc( + this.client, + this.doc_id, + err => err.message.should.equal('not authorized') + ) + }) + }) + }) + + return describe('when authorised at the project level', function () { + beforeEach(function () { + return (this.client.ol_context.privilege_level = 'readOnly') + }) + + describe('and not authorised at the document level', function () { + return it('should not allow access', function () { + return this.AuthorizationManager.assertClientCanViewProjectAndDoc( + this.client, + this.doc_id, + err => err.message.should.equal('not authorized') + ) + }) + }) + + describe('and authorised at the document level', function () { + beforeEach(function (done) { + return this.AuthorizationManager.addAccessToDoc( + this.client, + this.doc_id, + done + ) + }) + + return it('should allow access', function () { + this.AuthorizationManager.assertClientCanViewProjectAndDoc( + this.client, + this.doc_id, + this.callback + ) + return this.callback.calledWith(null).should.equal(true) + }) + }) + + return describe('when document authorisation is added and then removed', function () { + beforeEach(function (done) { + return this.AuthorizationManager.addAccessToDoc( + this.client, + this.doc_id, + () => { + return this.AuthorizationManager.removeAccessToDoc( + this.client, + this.doc_id, + done + ) + } + ) + }) + + return it('should deny access', function () { + return this.AuthorizationManager.assertClientCanViewProjectAndDoc( + this.client, + this.doc_id, + err => err.message.should.equal('not authorized') + ) + }) + }) + }) + }) + + return describe('assertClientCanEditProjectAndDoc', function () { + beforeEach(function () { + this.doc_id = '12345' + this.callback = sinon.stub() + return (this.client.ol_context = {}) + }) + + describe('when not authorised at the project level', function () { + beforeEach(function () { + return (this.client.ol_context.privilege_level = 'readOnly') + }) + + it('should not allow access', function () { + return this.AuthorizationManager.assertClientCanEditProjectAndDoc( + this.client, + this.doc_id, + err => err.message.should.equal('not authorized') + ) + }) + + return describe('even when authorised at the doc level', function () { + beforeEach(function (done) { + return this.AuthorizationManager.addAccessToDoc( + this.client, + this.doc_id, + done + ) + }) + + return it('should not allow access', function () { + return this.AuthorizationManager.assertClientCanEditProjectAndDoc( + this.client, + this.doc_id, + err => err.message.should.equal('not authorized') + ) + }) + }) + }) + + return describe('when authorised at the project level', function () { + beforeEach(function () { + return (this.client.ol_context.privilege_level = 'readAndWrite') + }) + + describe('and not authorised at the document level', function () { + return it('should not allow access', function () { + return this.AuthorizationManager.assertClientCanEditProjectAndDoc( + this.client, + this.doc_id, + err => err.message.should.equal('not authorized') + ) + }) + }) + + describe('and authorised at the document level', function () { + beforeEach(function (done) { + return this.AuthorizationManager.addAccessToDoc( + this.client, + this.doc_id, + done + ) + }) + + return it('should allow access', function () { + this.AuthorizationManager.assertClientCanEditProjectAndDoc( + this.client, + this.doc_id, + this.callback + ) + return this.callback.calledWith(null).should.equal(true) + }) + }) + + return describe('when document authorisation is added and then removed', function () { + beforeEach(function (done) { + return this.AuthorizationManager.addAccessToDoc( + this.client, + this.doc_id, + () => { + return this.AuthorizationManager.removeAccessToDoc( + this.client, + this.doc_id, + done + ) + } + ) + }) + + return it('should deny access', function () { + return this.AuthorizationManager.assertClientCanEditProjectAndDoc( + this.client, + this.doc_id, + err => err.message.should.equal('not authorized') + ) + }) + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/ChannelManagerTests.js b/services/real-time/test/unit/js/ChannelManagerTests.js new file mode 100644 index 0000000000..2e51c584f2 --- /dev/null +++ b/services/real-time/test/unit/js/ChannelManagerTests.js @@ -0,0 +1,432 @@ +/* eslint-disable + no-return-assign, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const { expect } = require('chai') +const sinon = require('sinon') +const modulePath = '../../../app/js/ChannelManager.js' +const SandboxedModule = require('sandboxed-module') + +describe('ChannelManager', function () { + beforeEach(function () { + this.rclient = {} + this.other_rclient = {} + return (this.ChannelManager = SandboxedModule.require(modulePath, { + requires: { + '@overleaf/settings': (this.settings = {}), + '@overleaf/metrics': (this.metrics = { + inc: sinon.stub(), + summary: sinon.stub(), + }), + }, + })) + }) + + describe('subscribe', function () { + describe('when there is no existing subscription for this redis client', function () { + beforeEach(function (done) { + this.rclient.subscribe = sinon.stub().resolves() + this.ChannelManager.subscribe( + this.rclient, + 'applied-ops', + '1234567890abcdef' + ) + return setTimeout(done) + }) + + return it('should subscribe to the redis channel', function () { + return this.rclient.subscribe + .calledWithExactly('applied-ops:1234567890abcdef') + .should.equal(true) + }) + }) + + describe('when there is an existing subscription for this redis client', function () { + beforeEach(function (done) { + this.rclient.subscribe = sinon.stub().resolves() + this.ChannelManager.subscribe( + this.rclient, + 'applied-ops', + '1234567890abcdef' + ) + this.ChannelManager.subscribe( + this.rclient, + 'applied-ops', + '1234567890abcdef' + ) + return setTimeout(done) + }) + + return it('should subscribe to the redis channel again', function () { + return this.rclient.subscribe.callCount.should.equal(2) + }) + }) + + describe('when subscribe errors', function () { + beforeEach(function (done) { + this.rclient.subscribe = sinon + .stub() + .onFirstCall() + .rejects(new Error('some redis error')) + .onSecondCall() + .resolves() + const p = this.ChannelManager.subscribe( + this.rclient, + 'applied-ops', + '1234567890abcdef' + ) + p.then(() => done(new Error('should not subscribe but fail'))).catch( + err => { + err.message.should.equal('failed to subscribe to channel') + err.cause.message.should.equal('some redis error') + this.ChannelManager.getClientMapEntry(this.rclient) + .has('applied-ops:1234567890abcdef') + .should.equal(false) + this.ChannelManager.subscribe( + this.rclient, + 'applied-ops', + '1234567890abcdef' + ) + // subscribe is wrapped in Promise, delay other assertions + return setTimeout(done) + } + ) + return null + }) + + it('should have recorded the error', function () { + return expect( + this.metrics.inc.calledWithExactly('subscribe.failed.applied-ops') + ).to.equal(true) + }) + + it('should subscribe again', function () { + return this.rclient.subscribe.callCount.should.equal(2) + }) + + return it('should cleanup', function () { + return this.ChannelManager.getClientMapEntry(this.rclient) + .has('applied-ops:1234567890abcdef') + .should.equal(false) + }) + }) + + describe('when subscribe errors and the clientChannelMap entry was replaced', function () { + beforeEach(function (done) { + this.rclient.subscribe = sinon + .stub() + .onFirstCall() + .rejects(new Error('some redis error')) + .onSecondCall() + .resolves() + this.first = this.ChannelManager.subscribe( + this.rclient, + 'applied-ops', + '1234567890abcdef' + ) + // ignore error + this.first.catch(() => {}) + expect( + this.ChannelManager.getClientMapEntry(this.rclient).get( + 'applied-ops:1234567890abcdef' + ) + ).to.equal(this.first) + + this.rclient.unsubscribe = sinon.stub().resolves() + this.ChannelManager.unsubscribe( + this.rclient, + 'applied-ops', + '1234567890abcdef' + ) + this.second = this.ChannelManager.subscribe( + this.rclient, + 'applied-ops', + '1234567890abcdef' + ) + // should get replaced immediately + expect( + this.ChannelManager.getClientMapEntry(this.rclient).get( + 'applied-ops:1234567890abcdef' + ) + ).to.equal(this.second) + + // let the first subscribe error -> unsubscribe -> subscribe + return setTimeout(done) + }) + + return it('should cleanup the second subscribePromise', function () { + return expect( + this.ChannelManager.getClientMapEntry(this.rclient).has( + 'applied-ops:1234567890abcdef' + ) + ).to.equal(false) + }) + }) + + return describe('when there is an existing subscription for another redis client but not this one', function () { + beforeEach(function (done) { + this.other_rclient.subscribe = sinon.stub().resolves() + this.ChannelManager.subscribe( + this.other_rclient, + 'applied-ops', + '1234567890abcdef' + ) + this.rclient.subscribe = sinon.stub().resolves() // discard the original stub + this.ChannelManager.subscribe( + this.rclient, + 'applied-ops', + '1234567890abcdef' + ) + return setTimeout(done) + }) + + return it('should subscribe to the redis channel on this redis client', function () { + return this.rclient.subscribe + .calledWithExactly('applied-ops:1234567890abcdef') + .should.equal(true) + }) + }) + }) + + describe('unsubscribe', function () { + describe('when there is no existing subscription for this redis client', function () { + beforeEach(function (done) { + this.rclient.unsubscribe = sinon.stub().resolves() + this.ChannelManager.unsubscribe( + this.rclient, + 'applied-ops', + '1234567890abcdef' + ) + return setTimeout(done) + }) + + return it('should unsubscribe from the redis channel', function () { + return this.rclient.unsubscribe.called.should.equal(true) + }) + }) + + describe('when there is an existing subscription for this another redis client but not this one', function () { + beforeEach(function (done) { + this.other_rclient.subscribe = sinon.stub().resolves() + this.rclient.unsubscribe = sinon.stub().resolves() + this.ChannelManager.subscribe( + this.other_rclient, + 'applied-ops', + '1234567890abcdef' + ) + this.ChannelManager.unsubscribe( + this.rclient, + 'applied-ops', + '1234567890abcdef' + ) + return setTimeout(done) + }) + + return it('should still unsubscribe from the redis channel on this client', function () { + return this.rclient.unsubscribe.called.should.equal(true) + }) + }) + + describe('when unsubscribe errors and completes', function () { + beforeEach(function (done) { + this.rclient.subscribe = sinon.stub().resolves() + this.ChannelManager.subscribe( + this.rclient, + 'applied-ops', + '1234567890abcdef' + ) + this.rclient.unsubscribe = sinon + .stub() + .rejects(new Error('some redis error')) + this.ChannelManager.unsubscribe( + this.rclient, + 'applied-ops', + '1234567890abcdef' + ) + setTimeout(done) + return null + }) + + it('should have cleaned up', function () { + return this.ChannelManager.getClientMapEntry(this.rclient) + .has('applied-ops:1234567890abcdef') + .should.equal(false) + }) + + return it('should not error out when subscribing again', function (done) { + const p = this.ChannelManager.subscribe( + this.rclient, + 'applied-ops', + '1234567890abcdef' + ) + p.then(() => done()).catch(done) + return null + }) + }) + + describe('when unsubscribe errors and another client subscribes at the same time', function () { + beforeEach(function (done) { + this.rclient.subscribe = sinon.stub().resolves() + this.ChannelManager.subscribe( + this.rclient, + 'applied-ops', + '1234567890abcdef' + ) + let rejectSubscribe + this.rclient.unsubscribe = () => + new Promise((resolve, reject) => (rejectSubscribe = reject)) + this.ChannelManager.unsubscribe( + this.rclient, + 'applied-ops', + '1234567890abcdef' + ) + + setTimeout(() => { + // delay, actualUnsubscribe should not see the new subscribe request + this.ChannelManager.subscribe( + this.rclient, + 'applied-ops', + '1234567890abcdef' + ) + .then(() => setTimeout(done)) + .catch(done) + return setTimeout(() => + // delay, rejectSubscribe is not defined immediately + rejectSubscribe(new Error('redis error')) + ) + }) + return null + }) + + it('should have recorded the error', function () { + return expect( + this.metrics.inc.calledWithExactly('unsubscribe.failed.applied-ops') + ).to.equal(true) + }) + + it('should have subscribed', function () { + return this.rclient.subscribe.called.should.equal(true) + }) + + return it('should have discarded the finished Promise', function () { + return this.ChannelManager.getClientMapEntry(this.rclient) + .has('applied-ops:1234567890abcdef') + .should.equal(false) + }) + }) + + return describe('when there is an existing subscription for this redis client', function () { + beforeEach(function (done) { + this.rclient.subscribe = sinon.stub().resolves() + this.rclient.unsubscribe = sinon.stub().resolves() + this.ChannelManager.subscribe( + this.rclient, + 'applied-ops', + '1234567890abcdef' + ) + this.ChannelManager.unsubscribe( + this.rclient, + 'applied-ops', + '1234567890abcdef' + ) + return setTimeout(done) + }) + + return it('should unsubscribe from the redis channel', function () { + return this.rclient.unsubscribe + .calledWithExactly('applied-ops:1234567890abcdef') + .should.equal(true) + }) + }) + }) + + return describe('publish', function () { + describe("when the channel is 'all'", function () { + beforeEach(function () { + this.rclient.publish = sinon.stub() + return this.ChannelManager.publish( + this.rclient, + 'applied-ops', + 'all', + 'random-message' + ) + }) + + return it('should publish on the base channel', function () { + return this.rclient.publish + .calledWithExactly('applied-ops', 'random-message') + .should.equal(true) + }) + }) + + describe('when the channel has an specific id', function () { + describe('when the individual channel setting is false', function () { + beforeEach(function () { + this.rclient.publish = sinon.stub() + this.settings.publishOnIndividualChannels = false + return this.ChannelManager.publish( + this.rclient, + 'applied-ops', + '1234567890abcdef', + 'random-message' + ) + }) + + return it('should publish on the per-id channel', function () { + this.rclient.publish + .calledWithExactly('applied-ops', 'random-message') + .should.equal(true) + return this.rclient.publish.calledOnce.should.equal(true) + }) + }) + + return describe('when the individual channel setting is true', function () { + beforeEach(function () { + this.rclient.publish = sinon.stub() + this.settings.publishOnIndividualChannels = true + return this.ChannelManager.publish( + this.rclient, + 'applied-ops', + '1234567890abcdef', + 'random-message' + ) + }) + + return it('should publish on the per-id channel', function () { + this.rclient.publish + .calledWithExactly('applied-ops:1234567890abcdef', 'random-message') + .should.equal(true) + return this.rclient.publish.calledOnce.should.equal(true) + }) + }) + }) + + return describe('metrics', function () { + beforeEach(function () { + this.rclient.publish = sinon.stub() + return this.ChannelManager.publish( + this.rclient, + 'applied-ops', + 'all', + 'random-message' + ) + }) + + return it('should track the payload size', function () { + return this.metrics.summary + .calledWithExactly( + 'redis.publish.applied-ops', + 'random-message'.length + ) + .should.equal(true) + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/ConnectedUsersManagerTests.js b/services/real-time/test/unit/js/ConnectedUsersManagerTests.js new file mode 100644 index 0000000000..51abee1bc7 --- /dev/null +++ b/services/real-time/test/unit/js/ConnectedUsersManagerTests.js @@ -0,0 +1,407 @@ +/* eslint-disable + camelcase, + handle-callback-err, + no-return-assign, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ + +const SandboxedModule = require('sandboxed-module') +const assert = require('assert') +const path = require('path') +const sinon = require('sinon') +const modulePath = path.join(__dirname, '../../../app/js/ConnectedUsersManager') +const { expect } = require('chai') +const tk = require('timekeeper') + +describe('ConnectedUsersManager', function () { + beforeEach(function () { + this.settings = { + redis: { + realtime: { + key_schema: { + clientsInProject({ project_id }) { + return `clients_in_project:${project_id}` + }, + connectedUser({ project_id, client_id }) { + return `connected_user:${project_id}:${client_id}` + }, + }, + }, + }, + } + this.rClient = { + auth() {}, + setex: sinon.stub(), + sadd: sinon.stub(), + get: sinon.stub(), + srem: sinon.stub(), + del: sinon.stub(), + smembers: sinon.stub(), + expire: sinon.stub(), + hset: sinon.stub(), + hgetall: sinon.stub(), + exec: sinon.stub(), + multi: () => { + return this.rClient + }, + } + tk.freeze(new Date()) + + this.ConnectedUsersManager = SandboxedModule.require(modulePath, { + requires: { + '@overleaf/settings': this.settings, + '@overleaf/redis-wrapper': { + createClient: () => { + return this.rClient + }, + }, + }, + }) + this.client_id = '32132132' + this.project_id = 'dskjh2u21321' + this.user = { + _id: 'user-id-123', + first_name: 'Joe', + last_name: 'Bloggs', + email: 'joe@example.com', + } + return (this.cursorData = { + row: 12, + column: 9, + doc_id: '53c3b8c85fee64000023dc6e', + }) + }) + + afterEach(function () { + return tk.reset() + }) + + describe('updateUserPosition', function () { + beforeEach(function () { + return this.rClient.exec.callsArgWith(0) + }) + + it('should set a key with the date and give it a ttl', function (done) { + return this.ConnectedUsersManager.updateUserPosition( + this.project_id, + this.client_id, + this.user, + null, + err => { + this.rClient.hset + .calledWith( + `connected_user:${this.project_id}:${this.client_id}`, + 'last_updated_at', + Date.now() + ) + .should.equal(true) + return done() + } + ) + }) + + it('should set a key with the user_id', function (done) { + return this.ConnectedUsersManager.updateUserPosition( + this.project_id, + this.client_id, + this.user, + null, + err => { + this.rClient.hset + .calledWith( + `connected_user:${this.project_id}:${this.client_id}`, + 'user_id', + this.user._id + ) + .should.equal(true) + return done() + } + ) + }) + + it('should set a key with the first_name', function (done) { + return this.ConnectedUsersManager.updateUserPosition( + this.project_id, + this.client_id, + this.user, + null, + err => { + this.rClient.hset + .calledWith( + `connected_user:${this.project_id}:${this.client_id}`, + 'first_name', + this.user.first_name + ) + .should.equal(true) + return done() + } + ) + }) + + it('should set a key with the last_name', function (done) { + return this.ConnectedUsersManager.updateUserPosition( + this.project_id, + this.client_id, + this.user, + null, + err => { + this.rClient.hset + .calledWith( + `connected_user:${this.project_id}:${this.client_id}`, + 'last_name', + this.user.last_name + ) + .should.equal(true) + return done() + } + ) + }) + + it('should set a key with the email', function (done) { + return this.ConnectedUsersManager.updateUserPosition( + this.project_id, + this.client_id, + this.user, + null, + err => { + this.rClient.hset + .calledWith( + `connected_user:${this.project_id}:${this.client_id}`, + 'email', + this.user.email + ) + .should.equal(true) + return done() + } + ) + }) + + it('should push the client_id on to the project list', function (done) { + return this.ConnectedUsersManager.updateUserPosition( + this.project_id, + this.client_id, + this.user, + null, + err => { + this.rClient.sadd + .calledWith(`clients_in_project:${this.project_id}`, this.client_id) + .should.equal(true) + return done() + } + ) + }) + + it('should add a ttl to the project set so it stays clean', function (done) { + return this.ConnectedUsersManager.updateUserPosition( + this.project_id, + this.client_id, + this.user, + null, + err => { + this.rClient.expire + .calledWith( + `clients_in_project:${this.project_id}`, + 24 * 4 * 60 * 60 + ) + .should.equal(true) + return done() + } + ) + }) + + it('should add a ttl to the connected user so it stays clean', function (done) { + return this.ConnectedUsersManager.updateUserPosition( + this.project_id, + this.client_id, + this.user, + null, + err => { + this.rClient.expire + .calledWith( + `connected_user:${this.project_id}:${this.client_id}`, + 60 * 15 + ) + .should.equal(true) + return done() + } + ) + }) + + return it('should set the cursor position when provided', function (done) { + return this.ConnectedUsersManager.updateUserPosition( + this.project_id, + this.client_id, + this.user, + this.cursorData, + err => { + this.rClient.hset + .calledWith( + `connected_user:${this.project_id}:${this.client_id}`, + 'cursorData', + JSON.stringify(this.cursorData) + ) + .should.equal(true) + return done() + } + ) + }) + }) + + describe('markUserAsDisconnected', function () { + beforeEach(function () { + return this.rClient.exec.callsArgWith(0) + }) + + it('should remove the user from the set', function (done) { + return this.ConnectedUsersManager.markUserAsDisconnected( + this.project_id, + this.client_id, + err => { + this.rClient.srem + .calledWith(`clients_in_project:${this.project_id}`, this.client_id) + .should.equal(true) + return done() + } + ) + }) + + it('should delete the connected_user string', function (done) { + return this.ConnectedUsersManager.markUserAsDisconnected( + this.project_id, + this.client_id, + err => { + this.rClient.del + .calledWith(`connected_user:${this.project_id}:${this.client_id}`) + .should.equal(true) + return done() + } + ) + }) + + return it('should add a ttl to the connected user set so it stays clean', function (done) { + return this.ConnectedUsersManager.markUserAsDisconnected( + this.project_id, + this.client_id, + err => { + this.rClient.expire + .calledWith( + `clients_in_project:${this.project_id}`, + 24 * 4 * 60 * 60 + ) + .should.equal(true) + return done() + } + ) + }) + }) + + describe('_getConnectedUser', function () { + it('should return a connected user if there is a user object', function (done) { + const cursorData = JSON.stringify({ cursorData: { row: 1 } }) + this.rClient.hgetall.callsArgWith(1, null, { + connected_at: new Date(), + user_id: this.user._id, + last_updated_at: `${Date.now()}`, + cursorData, + }) + return this.ConnectedUsersManager._getConnectedUser( + this.project_id, + this.client_id, + (err, result) => { + result.connected.should.equal(true) + result.client_id.should.equal(this.client_id) + return done() + } + ) + }) + + it('should return a not connected user if there is no object', function (done) { + this.rClient.hgetall.callsArgWith(1, null, null) + return this.ConnectedUsersManager._getConnectedUser( + this.project_id, + this.client_id, + (err, result) => { + result.connected.should.equal(false) + result.client_id.should.equal(this.client_id) + return done() + } + ) + }) + + return it('should return a not connected user if there is an empty object', function (done) { + this.rClient.hgetall.callsArgWith(1, null, {}) + return this.ConnectedUsersManager._getConnectedUser( + this.project_id, + this.client_id, + (err, result) => { + result.connected.should.equal(false) + result.client_id.should.equal(this.client_id) + return done() + } + ) + }) + }) + + return describe('getConnectedUsers', function () { + beforeEach(function () { + this.users = ['1234', '5678', '9123', '8234'] + this.rClient.smembers.callsArgWith(1, null, this.users) + this.ConnectedUsersManager._getConnectedUser = sinon.stub() + this.ConnectedUsersManager._getConnectedUser + .withArgs(this.project_id, this.users[0]) + .callsArgWith(2, null, { + connected: true, + client_age: 2, + client_id: this.users[0], + }) + this.ConnectedUsersManager._getConnectedUser + .withArgs(this.project_id, this.users[1]) + .callsArgWith(2, null, { + connected: false, + client_age: 1, + client_id: this.users[1], + }) + this.ConnectedUsersManager._getConnectedUser + .withArgs(this.project_id, this.users[2]) + .callsArgWith(2, null, { + connected: true, + client_age: 3, + client_id: this.users[2], + }) + return this.ConnectedUsersManager._getConnectedUser + .withArgs(this.project_id, this.users[3]) + .callsArgWith(2, null, { + connected: true, + client_age: 11, + client_id: this.users[3], + }) + }) // connected but old + + return it('should only return the users in the list which are still in redis and recently updated', function (done) { + return this.ConnectedUsersManager.getConnectedUsers( + this.project_id, + (err, users) => { + users.length.should.equal(2) + users[0].should.deep.equal({ + client_id: this.users[0], + client_age: 2, + connected: true, + }) + users[1].should.deep.equal({ + client_id: this.users[2], + client_age: 3, + connected: true, + }) + return done() + } + ) + }) + }) +}) diff --git a/services/real-time/test/unit/js/DocumentUpdaterControllerTests.js b/services/real-time/test/unit/js/DocumentUpdaterControllerTests.js new file mode 100644 index 0000000000..20fa5bfe18 --- /dev/null +++ b/services/real-time/test/unit/js/DocumentUpdaterControllerTests.js @@ -0,0 +1,257 @@ +/* eslint-disable + camelcase, + no-return-assign, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const SandboxedModule = require('sandboxed-module') +const sinon = require('sinon') +const modulePath = require('path').join( + __dirname, + '../../../app/js/DocumentUpdaterController' +) +const MockClient = require('./helpers/MockClient') + +describe('DocumentUpdaterController', function () { + beforeEach(function () { + this.project_id = 'project-id-123' + this.doc_id = 'doc-id-123' + this.callback = sinon.stub() + this.io = { mock: 'socket.io' } + this.rclient = [] + this.RoomEvents = { on: sinon.stub() } + this.EditorUpdatesController = SandboxedModule.require(modulePath, { + requires: { + '@overleaf/settings': (this.settings = { + redis: { + documentupdater: { + key_schema: { + pendingUpdates({ doc_id }) { + return `PendingUpdates:${doc_id}` + }, + }, + }, + pubsub: null, + }, + }), + './RedisClientManager': { + createClientList: () => { + this.redis = { + createClient: name => { + let rclientStub + this.rclient.push((rclientStub = { name })) + return rclientStub + }, + } + }, + }, + './SafeJsonParse': (this.SafeJsonParse = { + parse: (data, cb) => cb(null, JSON.parse(data)), + }), + './EventLogger': (this.EventLogger = { checkEventOrder: sinon.stub() }), + './HealthCheckManager': { check: sinon.stub() }, + '@overleaf/metrics': (this.metrics = { inc: sinon.stub() }), + './RoomManager': (this.RoomManager = { + eventSource: sinon.stub().returns(this.RoomEvents), + }), + './ChannelManager': (this.ChannelManager = {}), + }, + }) + }) + + describe('listenForUpdatesFromDocumentUpdater', function () { + beforeEach(function () { + this.rclient.length = 0 // clear any existing clients + this.EditorUpdatesController.rclientList = [ + this.redis.createClient('first'), + this.redis.createClient('second'), + ] + this.rclient[0].subscribe = sinon.stub() + this.rclient[0].on = sinon.stub() + this.rclient[1].subscribe = sinon.stub() + this.rclient[1].on = sinon.stub() + this.EditorUpdatesController.listenForUpdatesFromDocumentUpdater() + }) + + it('should subscribe to the doc-updater stream', function () { + this.rclient[0].subscribe.calledWith('applied-ops').should.equal(true) + }) + + it('should register a callback to handle updates', function () { + this.rclient[0].on.calledWith('message').should.equal(true) + }) + + it('should subscribe to any additional doc-updater stream', function () { + this.rclient[1].subscribe.calledWith('applied-ops').should.equal(true) + this.rclient[1].on.calledWith('message').should.equal(true) + }) + }) + + describe('_processMessageFromDocumentUpdater', function () { + describe('with bad JSON', function () { + beforeEach(function () { + this.SafeJsonParse.parse = sinon + .stub() + .callsArgWith(1, new Error('oops')) + return this.EditorUpdatesController._processMessageFromDocumentUpdater( + this.io, + 'applied-ops', + 'blah' + ) + }) + + it('should log an error', function () { + return this.logger.error.called.should.equal(true) + }) + }) + + describe('with update', function () { + beforeEach(function () { + this.message = { + doc_id: this.doc_id, + op: { t: 'foo', p: 12 }, + } + this.EditorUpdatesController._applyUpdateFromDocumentUpdater = + sinon.stub() + return this.EditorUpdatesController._processMessageFromDocumentUpdater( + this.io, + 'applied-ops', + JSON.stringify(this.message) + ) + }) + + it('should apply the update', function () { + return this.EditorUpdatesController._applyUpdateFromDocumentUpdater + .calledWith(this.io, this.doc_id, this.message.op) + .should.equal(true) + }) + }) + + describe('with error', function () { + beforeEach(function () { + this.message = { + doc_id: this.doc_id, + error: 'Something went wrong', + } + this.EditorUpdatesController._processErrorFromDocumentUpdater = + sinon.stub() + return this.EditorUpdatesController._processMessageFromDocumentUpdater( + this.io, + 'applied-ops', + JSON.stringify(this.message) + ) + }) + + return it('should process the error', function () { + return this.EditorUpdatesController._processErrorFromDocumentUpdater + .calledWith(this.io, this.doc_id, this.message.error) + .should.equal(true) + }) + }) + }) + + describe('_applyUpdateFromDocumentUpdater', function () { + beforeEach(function () { + this.sourceClient = new MockClient() + this.otherClients = [new MockClient(), new MockClient()] + this.update = { + op: [{ t: 'foo', p: 12 }], + meta: { source: this.sourceClient.publicId }, + v: (this.version = 42), + doc: this.doc_id, + } + return (this.io.sockets = { + clients: sinon + .stub() + .returns([ + this.sourceClient, + ...Array.from(this.otherClients), + this.sourceClient, + ]), + }) + }) // include a duplicate client + + describe('normally', function () { + beforeEach(function () { + return this.EditorUpdatesController._applyUpdateFromDocumentUpdater( + this.io, + this.doc_id, + this.update + ) + }) + + it('should send a version bump to the source client', function () { + this.sourceClient.emit + .calledWith('otUpdateApplied', { v: this.version, doc: this.doc_id }) + .should.equal(true) + return this.sourceClient.emit.calledOnce.should.equal(true) + }) + + it('should get the clients connected to the document', function () { + return this.io.sockets.clients + .calledWith(this.doc_id) + .should.equal(true) + }) + + return it('should send the full update to the other clients', function () { + return Array.from(this.otherClients).map(client => + client.emit + .calledWith('otUpdateApplied', this.update) + .should.equal(true) + ) + }) + }) + + return describe('with a duplicate op', function () { + beforeEach(function () { + this.update.dup = true + return this.EditorUpdatesController._applyUpdateFromDocumentUpdater( + this.io, + this.doc_id, + this.update + ) + }) + + it('should send a version bump to the source client as usual', function () { + return this.sourceClient.emit + .calledWith('otUpdateApplied', { v: this.version, doc: this.doc_id }) + .should.equal(true) + }) + + return it("should not send anything to the other clients (they've already had the op)", function () { + return Array.from(this.otherClients).map(client => + client.emit.calledWith('otUpdateApplied').should.equal(false) + ) + }) + }) + }) + + return describe('_processErrorFromDocumentUpdater', function () { + beforeEach(function () { + this.clients = [new MockClient(), new MockClient()] + this.io.sockets = { clients: sinon.stub().returns(this.clients) } + return this.EditorUpdatesController._processErrorFromDocumentUpdater( + this.io, + this.doc_id, + 'Something went wrong' + ) + }) + + it('should log a warning', function () { + return this.logger.warn.called.should.equal(true) + }) + + return it('should disconnect all clients in that document', function () { + this.io.sockets.clients.calledWith(this.doc_id).should.equal(true) + return Array.from(this.clients).map(client => + client.disconnect.called.should.equal(true) + ) + }) + }) +}) diff --git a/services/real-time/test/unit/js/DocumentUpdaterManagerTests.js b/services/real-time/test/unit/js/DocumentUpdaterManagerTests.js new file mode 100644 index 0000000000..9d565b4a3b --- /dev/null +++ b/services/real-time/test/unit/js/DocumentUpdaterManagerTests.js @@ -0,0 +1,424 @@ +/* eslint-disable + camelcase, + no-return-assign, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const sinon = require('sinon') +const SandboxedModule = require('sandboxed-module') +const path = require('path') +const modulePath = '../../../app/js/DocumentUpdaterManager' +const _ = require('underscore') + +describe('DocumentUpdaterManager', function () { + beforeEach(function () { + let Timer + this.project_id = 'project-id-923' + this.doc_id = 'doc-id-394' + this.lines = ['one', 'two', 'three'] + this.version = 42 + this.settings = { + apis: { documentupdater: { url: 'http://doc-updater.example.com' } }, + redis: { + documentupdater: { + key_schema: { + pendingUpdates({ doc_id }) { + return `PendingUpdates:${doc_id}` + }, + }, + }, + }, + maxUpdateSize: 7 * 1024 * 1024, + pendingUpdateListShardCount: 10, + } + this.rclient = { auth() {} } + + return (this.DocumentUpdaterManager = SandboxedModule.require(modulePath, { + requires: { + '@overleaf/settings': this.settings, + request: (this.request = {}), + '@overleaf/redis-wrapper': { createClient: () => this.rclient }, + '@overleaf/metrics': (this.Metrics = { + summary: sinon.stub(), + Timer: (Timer = class Timer { + done() {} + }), + }), + }, + })) + }) // avoid modifying JSON object directly + + describe('getDocument', function () { + beforeEach(function () { + return (this.callback = sinon.stub()) + }) + + describe('successfully', function () { + beforeEach(function () { + this.body = JSON.stringify({ + lines: this.lines, + version: this.version, + ops: (this.ops = ['mock-op-1', 'mock-op-2']), + ranges: (this.ranges = { mock: 'ranges' }), + }) + this.fromVersion = 2 + this.request.get = sinon + .stub() + .callsArgWith(1, null, { statusCode: 200 }, this.body) + return this.DocumentUpdaterManager.getDocument( + this.project_id, + this.doc_id, + this.fromVersion, + this.callback + ) + }) + + it('should get the document from the document updater', function () { + const url = `${this.settings.apis.documentupdater.url}/project/${this.project_id}/doc/${this.doc_id}?fromVersion=${this.fromVersion}` + return this.request.get.calledWith(url).should.equal(true) + }) + + return it('should call the callback with the lines, version, ranges and ops', function () { + return this.callback + .calledWith(null, this.lines, this.version, this.ranges, this.ops) + .should.equal(true) + }) + }) + + describe('when the document updater API returns an error', function () { + beforeEach(function () { + this.request.get = sinon + .stub() + .callsArgWith( + 1, + (this.error = new Error('something went wrong')), + null, + null + ) + return this.DocumentUpdaterManager.getDocument( + this.project_id, + this.doc_id, + this.fromVersion, + this.callback + ) + }) + + return it('should return an error to the callback', function () { + return this.callback.calledWith(this.error).should.equal(true) + }) + }) + ;[404, 422].forEach(statusCode => + describe(`when the document updater returns a ${statusCode} status code`, function () { + beforeEach(function () { + this.request.get = sinon + .stub() + .callsArgWith(1, null, { statusCode }, '') + return this.DocumentUpdaterManager.getDocument( + this.project_id, + this.doc_id, + this.fromVersion, + this.callback + ) + }) + + return it('should return the callback with an error', function () { + this.callback.called.should.equal(true) + this.callback + .calledWith( + sinon.match({ + message: 'doc updater could not load requested ops', + info: { statusCode }, + }) + ) + .should.equal(true) + this.logger.error.called.should.equal(false) + this.logger.warn.called.should.equal(false) + }) + }) + ) + + return describe('when the document updater returns a failure error code', function () { + beforeEach(function () { + this.request.get = sinon + .stub() + .callsArgWith(1, null, { statusCode: 500 }, '') + return this.DocumentUpdaterManager.getDocument( + this.project_id, + this.doc_id, + this.fromVersion, + this.callback + ) + }) + + return it('should return the callback with an error', function () { + this.callback.called.should.equal(true) + this.callback + .calledWith( + sinon.match({ + message: 'doc updater returned a non-success status code', + info: { + action: 'getDocument', + statusCode: 500, + }, + }) + ) + .should.equal(true) + this.logger.error.called.should.equal(false) + }) + }) + }) + + describe('flushProjectToMongoAndDelete', function () { + beforeEach(function () { + return (this.callback = sinon.stub()) + }) + + describe('successfully', function () { + beforeEach(function () { + this.request.del = sinon + .stub() + .callsArgWith(1, null, { statusCode: 204 }, '') + return this.DocumentUpdaterManager.flushProjectToMongoAndDelete( + this.project_id, + this.callback + ) + }) + + it('should delete the project from the document updater', function () { + const url = `${this.settings.apis.documentupdater.url}/project/${this.project_id}?background=true` + return this.request.del.calledWith(url).should.equal(true) + }) + + return it('should call the callback with no error', function () { + return this.callback.calledWith(null).should.equal(true) + }) + }) + + describe('when the document updater API returns an error', function () { + beforeEach(function () { + this.request.del = sinon + .stub() + .callsArgWith( + 1, + (this.error = new Error('something went wrong')), + null, + null + ) + return this.DocumentUpdaterManager.flushProjectToMongoAndDelete( + this.project_id, + this.callback + ) + }) + + return it('should return an error to the callback', function () { + return this.callback.calledWith(this.error).should.equal(true) + }) + }) + + return describe('when the document updater returns a failure error code', function () { + beforeEach(function () { + this.request.del = sinon + .stub() + .callsArgWith(1, null, { statusCode: 500 }, '') + return this.DocumentUpdaterManager.flushProjectToMongoAndDelete( + this.project_id, + this.callback + ) + }) + + return it('should return the callback with an error', function () { + this.callback.called.should.equal(true) + this.callback + .calledWith( + sinon.match({ + message: 'doc updater returned a non-success status code', + info: { + action: 'flushProjectToMongoAndDelete', + statusCode: 500, + }, + }) + ) + .should.equal(true) + }) + }) + }) + + describe('queueChange', function () { + beforeEach(function () { + this.change = { + doc: '1234567890', + op: [{ d: 'test', p: 345 }], + v: 789, + } + this.rclient.rpush = sinon.stub().yields() + return (this.callback = sinon.stub()) + }) + + describe('successfully', function () { + beforeEach(function () { + this.pendingUpdateListKey = `pending-updates-list-key-${Math.random()}` + + this.DocumentUpdaterManager._getPendingUpdateListKey = sinon + .stub() + .returns(this.pendingUpdateListKey) + this.DocumentUpdaterManager.queueChange( + this.project_id, + this.doc_id, + this.change, + this.callback + ) + }) + + it('should push the change', function () { + this.rclient.rpush + .calledWith( + `PendingUpdates:${this.doc_id}`, + JSON.stringify(this.change) + ) + .should.equal(true) + }) + + it('should notify the doc updater of the change via the pending-updates-list queue', function () { + this.rclient.rpush + .calledWith( + this.pendingUpdateListKey, + `${this.project_id}:${this.doc_id}` + ) + .should.equal(true) + }) + }) + + describe('with error talking to redis during rpush', function () { + beforeEach(function () { + this.rclient.rpush = sinon + .stub() + .yields(new Error('something went wrong')) + return this.DocumentUpdaterManager.queueChange( + this.project_id, + this.doc_id, + this.change, + this.callback + ) + }) + + return it('should return an error', function () { + return this.callback + .calledWithExactly(sinon.match(Error)) + .should.equal(true) + }) + }) + + describe('with null byte corruption', function () { + beforeEach(function () { + this.stringifyStub = sinon + .stub(JSON, 'stringify') + .callsFake(() => '["bad bytes! \u0000 <- here"]') + return this.DocumentUpdaterManager.queueChange( + this.project_id, + this.doc_id, + this.change, + this.callback + ) + }) + + afterEach(function () { + this.stringifyStub.restore() + }) + + it('should return an error', function () { + return this.callback + .calledWithExactly(sinon.match(Error)) + .should.equal(true) + }) + + return it('should not push the change onto the pending-updates-list queue', function () { + return this.rclient.rpush.called.should.equal(false) + }) + }) + + describe('when the update is too large', function () { + beforeEach(function () { + this.change = { + op: { p: 12, t: 'update is too large'.repeat(1024 * 400) }, + } + return this.DocumentUpdaterManager.queueChange( + this.project_id, + this.doc_id, + this.change, + this.callback + ) + }) + + it('should return an error', function () { + return this.callback + .calledWithExactly(sinon.match(Error)) + .should.equal(true) + }) + + it('should add the size to the error', function () { + return this.callback.args[0][0].info.updateSize.should.equal(7782422) + }) + + return it('should not push the change onto the pending-updates-list queue', function () { + return this.rclient.rpush.called.should.equal(false) + }) + }) + + describe('with invalid keys', function () { + beforeEach(function () { + this.change = { + op: [{ d: 'test', p: 345 }], + version: 789, // not a valid key + } + return this.DocumentUpdaterManager.queueChange( + this.project_id, + this.doc_id, + this.change, + this.callback + ) + }) + + it('should remove the invalid keys from the change', function () { + return this.rclient.rpush + .calledWith( + `PendingUpdates:${this.doc_id}`, + JSON.stringify({ op: this.change.op }) + ) + .should.equal(true) + }) + }) + }) + + describe('_getPendingUpdateListKey', function () { + beforeEach(function () { + const keys = _.times( + 10000, + this.DocumentUpdaterManager._getPendingUpdateListKey + ) + this.keys = _.unique(keys) + }) + it('should return normal pending updates key', function () { + _.contains(this.keys, 'pending-updates-list').should.equal(true) + }) + + it('should return pending-updates-list-n keys', function () { + _.contains(this.keys, 'pending-updates-list-1').should.equal(true) + _.contains(this.keys, 'pending-updates-list-3').should.equal(true) + _.contains(this.keys, 'pending-updates-list-9').should.equal(true) + }) + + it('should not include pending-updates-list-0 key', function () { + _.contains(this.keys, 'pending-updates-list-0').should.equal(false) + }) + + it('should not include maximum as pendingUpdateListShardCount value', function () { + _.contains(this.keys, 'pending-updates-list-10').should.equal(false) + }) + }) +}) diff --git a/services/real-time/test/unit/js/DrainManagerTests.js b/services/real-time/test/unit/js/DrainManagerTests.js new file mode 100644 index 0000000000..3075c647c7 --- /dev/null +++ b/services/real-time/test/unit/js/DrainManagerTests.js @@ -0,0 +1,127 @@ +/* eslint-disable + no-return-assign, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const sinon = require('sinon') +const SandboxedModule = require('sandboxed-module') +const path = require('path') +const modulePath = path.join(__dirname, '../../../app/js/DrainManager') + +describe('DrainManager', function () { + beforeEach(function () { + this.DrainManager = SandboxedModule.require(modulePath, {}) + return (this.io = { + sockets: { + clients: sinon.stub(), + }, + }) + }) + + describe('startDrainTimeWindow', function () { + beforeEach(function () { + this.clients = [] + for (let i = 0; i <= 5399; i++) { + this.clients[i] = { + id: i, + emit: sinon.stub(), + } + } + this.io.sockets.clients.returns(this.clients) + return (this.DrainManager.startDrain = sinon.stub()) + }) + + return it('should set a drain rate fast enough', function (done) { + this.DrainManager.startDrainTimeWindow(this.io, 9) + this.DrainManager.startDrain.calledWith(this.io, 10).should.equal(true) + return done() + }) + }) + + return describe('reconnectNClients', function () { + beforeEach(function () { + this.clients = [] + for (let i = 0; i <= 9; i++) { + this.clients[i] = { + id: i, + emit: sinon.stub(), + } + } + return this.io.sockets.clients.returns(this.clients) + }) + + return describe('after first pass', function () { + beforeEach(function () { + return this.DrainManager.reconnectNClients(this.io, 3) + }) + + it('should reconnect the first 3 clients', function () { + return [0, 1, 2].map(i => + this.clients[i].emit + .calledWith('reconnectGracefully') + .should.equal(true) + ) + }) + + it('should not reconnect any more clients', function () { + return [3, 4, 5, 6, 7, 8, 9].map(i => + this.clients[i].emit + .calledWith('reconnectGracefully') + .should.equal(false) + ) + }) + + return describe('after second pass', function () { + beforeEach(function () { + return this.DrainManager.reconnectNClients(this.io, 3) + }) + + it('should reconnect the next 3 clients', function () { + return [3, 4, 5].map(i => + this.clients[i].emit + .calledWith('reconnectGracefully') + .should.equal(true) + ) + }) + + it('should not reconnect any more clients', function () { + return [6, 7, 8, 9].map(i => + this.clients[i].emit + .calledWith('reconnectGracefully') + .should.equal(false) + ) + }) + + it('should not reconnect the first 3 clients again', function () { + return [0, 1, 2].map(i => + this.clients[i].emit.calledOnce.should.equal(true) + ) + }) + + return describe('after final pass', function () { + beforeEach(function () { + return this.DrainManager.reconnectNClients(this.io, 100) + }) + + it('should not reconnect the first 6 clients again', function () { + return [0, 1, 2, 3, 4, 5].map(i => + this.clients[i].emit.calledOnce.should.equal(true) + ) + }) + + return it('should log out that it reached the end', function () { + return this.logger.log + .calledWith('All clients have been told to reconnectGracefully') + .should.equal(true) + }) + }) + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/EventLoggerTests.js b/services/real-time/test/unit/js/EventLoggerTests.js new file mode 100644 index 0000000000..037f2e214a --- /dev/null +++ b/services/real-time/test/unit/js/EventLoggerTests.js @@ -0,0 +1,153 @@ +/* eslint-disable + no-return-assign, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const { expect } = require('chai') +const SandboxedModule = require('sandboxed-module') +const modulePath = '../../../app/js/EventLogger' +const sinon = require('sinon') +const tk = require('timekeeper') + +describe('EventLogger', function () { + beforeEach(function () { + this.start = Date.now() + tk.freeze(new Date(this.start)) + this.EventLogger = SandboxedModule.require(modulePath, { + requires: { + '@overleaf/metrics': (this.metrics = { inc: sinon.stub() }), + }, + }) + this.channel = 'applied-ops' + this.id_1 = 'random-hostname:abc-1' + this.message_1 = 'message-1' + this.id_2 = 'random-hostname:abc-2' + return (this.message_2 = 'message-2') + }) + + afterEach(function () { + return tk.reset() + }) + + return describe('checkEventOrder', function () { + describe('when the events are in order', function () { + beforeEach(function () { + this.EventLogger.checkEventOrder( + this.channel, + this.id_1, + this.message_1 + ) + return (this.status = this.EventLogger.checkEventOrder( + this.channel, + this.id_2, + this.message_2 + )) + }) + + it('should accept events in order', function () { + return expect(this.status).to.be.undefined + }) + + return it('should increment the valid event metric', function () { + return this.metrics.inc + .calledWith(`event.${this.channel}.valid`) + .should.equals(true) + }) + }) + + describe('when there is a duplicate events', function () { + beforeEach(function () { + this.EventLogger.checkEventOrder( + this.channel, + this.id_1, + this.message_1 + ) + return (this.status = this.EventLogger.checkEventOrder( + this.channel, + this.id_1, + this.message_1 + )) + }) + + it('should return "duplicate" for the same event', function () { + return expect(this.status).to.equal('duplicate') + }) + + return it('should increment the duplicate event metric', function () { + return this.metrics.inc + .calledWith(`event.${this.channel}.duplicate`) + .should.equals(true) + }) + }) + + describe('when there are out of order events', function () { + beforeEach(function () { + this.EventLogger.checkEventOrder( + this.channel, + this.id_1, + this.message_1 + ) + this.EventLogger.checkEventOrder( + this.channel, + this.id_2, + this.message_2 + ) + return (this.status = this.EventLogger.checkEventOrder( + this.channel, + this.id_1, + this.message_1 + )) + }) + + it('should return "out-of-order" for the event', function () { + return expect(this.status).to.equal('out-of-order') + }) + + return it('should increment the out-of-order event metric', function () { + return this.metrics.inc + .calledWith(`event.${this.channel}.out-of-order`) + .should.equals(true) + }) + }) + + return describe('after MAX_STALE_TIME_IN_MS', function () { + return it('should flush old entries', function () { + let status + this.EventLogger.MAX_EVENTS_BEFORE_CLEAN = 10 + this.EventLogger.checkEventOrder( + this.channel, + this.id_1, + this.message_1 + ) + for (let i = 1; i <= 8; i++) { + status = this.EventLogger.checkEventOrder( + this.channel, + this.id_1, + this.message_1 + ) + expect(status).to.equal('duplicate') + } + // the next event should flush the old entries aboce + this.EventLogger.MAX_STALE_TIME_IN_MS = 1000 + tk.freeze(new Date(this.start + 5 * 1000)) + // because we flushed the entries this should not be a duplicate + this.EventLogger.checkEventOrder( + this.channel, + 'other-1', + this.message_2 + ) + status = this.EventLogger.checkEventOrder( + this.channel, + this.id_1, + this.message_1 + ) + return expect(status).to.be.undefined + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/RoomManagerTests.js b/services/real-time/test/unit/js/RoomManagerTests.js new file mode 100644 index 0000000000..f33d2ecce2 --- /dev/null +++ b/services/real-time/test/unit/js/RoomManagerTests.js @@ -0,0 +1,412 @@ +/* eslint-disable + no-return-assign, + no-unused-vars, + promise/param-names, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const { expect } = require('chai') +const sinon = require('sinon') +const modulePath = '../../../app/js/RoomManager.js' +const SandboxedModule = require('sandboxed-module') + +describe('RoomManager', function () { + beforeEach(function () { + this.project_id = 'project-id-123' + this.doc_id = 'doc-id-456' + this.other_doc_id = 'doc-id-789' + this.client = { namespace: { name: '' }, id: 'first-client' } + this.RoomManager = SandboxedModule.require(modulePath, { + requires: { + '@overleaf/settings': (this.settings = {}), + '@overleaf/metrics': (this.metrics = { gauge: sinon.stub() }), + }, + }) + this.RoomManager._clientsInRoom = sinon.stub() + this.RoomManager._clientAlreadyInRoom = sinon.stub() + this.RoomEvents = this.RoomManager.eventSource() + sinon.spy(this.RoomEvents, 'emit') + return sinon.spy(this.RoomEvents, 'once') + }) + + describe('emitOnCompletion', function () { + return describe('when a subscribe errors', function () { + afterEach(function () { + return process.removeListener('unhandledRejection', this.onUnhandled) + }) + + beforeEach(function (done) { + this.onUnhandled = error => { + this.unhandledError = error + return done(new Error(`unhandledRejection: ${error.message}`)) + } + process.on('unhandledRejection', this.onUnhandled) + + let reject + const subscribePromise = new Promise((_, r) => (reject = r)) + const promises = [subscribePromise] + const eventName = 'project-subscribed-123' + this.RoomEvents.once(eventName, () => setTimeout(done, 100)) + this.RoomManager.emitOnCompletion(promises, eventName) + return setTimeout(() => reject(new Error('subscribe failed'))) + }) + + return it('should keep going', function () { + return expect(this.unhandledError).to.not.exist + }) + }) + }) + + describe('joinProject', function () { + describe('when the project room is empty', function () { + beforeEach(function (done) { + this.RoomManager._clientsInRoom + .withArgs(this.client, this.project_id) + .onFirstCall() + .returns(0) + this.client.join = sinon.stub() + this.callback = sinon.stub() + this.RoomEvents.on('project-active', id => { + return setTimeout(() => { + return this.RoomEvents.emit(`project-subscribed-${id}`) + }, 100) + }) + return this.RoomManager.joinProject( + this.client, + this.project_id, + err => { + this.callback(err) + return done() + } + ) + }) + + it("should emit a 'project-active' event with the id", function () { + return this.RoomEvents.emit + .calledWithExactly('project-active', this.project_id) + .should.equal(true) + }) + + it("should listen for the 'project-subscribed-id' event", function () { + return this.RoomEvents.once + .calledWith(`project-subscribed-${this.project_id}`) + .should.equal(true) + }) + + return it('should join the room using the id', function () { + return this.client.join + .calledWithExactly(this.project_id) + .should.equal(true) + }) + }) + + return describe('when there are other clients in the project room', function () { + beforeEach(function (done) { + this.RoomManager._clientsInRoom + .withArgs(this.client, this.project_id) + .onFirstCall() + .returns(123) + .onSecondCall() + .returns(124) + this.client.join = sinon.stub() + this.RoomManager.joinProject(this.client, this.project_id, done) + }) + + it('should join the room using the id', function () { + return this.client.join.called.should.equal(true) + }) + + return it('should not emit any events', function () { + return this.RoomEvents.emit.called.should.equal(false) + }) + }) + }) + + describe('joinDoc', function () { + describe('when the doc room is empty', function () { + beforeEach(function (done) { + this.RoomManager._clientsInRoom + .withArgs(this.client, this.doc_id) + .onFirstCall() + .returns(0) + this.client.join = sinon.stub() + this.callback = sinon.stub() + this.RoomEvents.on('doc-active', id => { + return setTimeout(() => { + return this.RoomEvents.emit(`doc-subscribed-${id}`) + }, 100) + }) + return this.RoomManager.joinDoc(this.client, this.doc_id, err => { + this.callback(err) + return done() + }) + }) + + it("should emit a 'doc-active' event with the id", function () { + return this.RoomEvents.emit + .calledWithExactly('doc-active', this.doc_id) + .should.equal(true) + }) + + it("should listen for the 'doc-subscribed-id' event", function () { + return this.RoomEvents.once + .calledWith(`doc-subscribed-${this.doc_id}`) + .should.equal(true) + }) + + return it('should join the room using the id', function () { + return this.client.join + .calledWithExactly(this.doc_id) + .should.equal(true) + }) + }) + + return describe('when there are other clients in the doc room', function () { + beforeEach(function (done) { + this.RoomManager._clientsInRoom + .withArgs(this.client, this.doc_id) + .onFirstCall() + .returns(123) + .onSecondCall() + .returns(124) + this.client.join = sinon.stub() + this.RoomManager.joinDoc(this.client, this.doc_id, done) + }) + + it('should join the room using the id', function () { + return this.client.join.called.should.equal(true) + }) + + return it('should not emit any events', function () { + return this.RoomEvents.emit.called.should.equal(false) + }) + }) + }) + + describe('leaveDoc', function () { + describe('when doc room will be empty after this client has left', function () { + beforeEach(function () { + this.RoomManager._clientAlreadyInRoom + .withArgs(this.client, this.doc_id) + .returns(true) + this.RoomManager._clientsInRoom + .withArgs(this.client, this.doc_id) + .onCall(0) + .returns(0) + this.client.leave = sinon.stub() + return this.RoomManager.leaveDoc(this.client, this.doc_id) + }) + + it('should leave the room using the id', function () { + return this.client.leave + .calledWithExactly(this.doc_id) + .should.equal(true) + }) + + return it("should emit a 'doc-empty' event with the id", function () { + return this.RoomEvents.emit + .calledWithExactly('doc-empty', this.doc_id) + .should.equal(true) + }) + }) + + describe('when there are other clients in the doc room', function () { + beforeEach(function () { + this.RoomManager._clientAlreadyInRoom + .withArgs(this.client, this.doc_id) + .returns(true) + this.RoomManager._clientsInRoom + .withArgs(this.client, this.doc_id) + .onCall(0) + .returns(123) + this.client.leave = sinon.stub() + return this.RoomManager.leaveDoc(this.client, this.doc_id) + }) + + it('should leave the room using the id', function () { + return this.client.leave + .calledWithExactly(this.doc_id) + .should.equal(true) + }) + + return it('should not emit any events', function () { + return this.RoomEvents.emit.called.should.equal(false) + }) + }) + + return describe('when the client is not in the doc room', function () { + beforeEach(function () { + this.RoomManager._clientAlreadyInRoom + .withArgs(this.client, this.doc_id) + .returns(false) + this.RoomManager._clientsInRoom + .withArgs(this.client, this.doc_id) + .onCall(0) + .returns(0) + this.client.leave = sinon.stub() + return this.RoomManager.leaveDoc(this.client, this.doc_id) + }) + + it('should not leave the room', function () { + return this.client.leave.called.should.equal(false) + }) + + return it('should not emit any events', function () { + return this.RoomEvents.emit.called.should.equal(false) + }) + }) + }) + + return describe('leaveProjectAndDocs', function () { + return describe('when the client is connected to the project and multiple docs', function () { + beforeEach(function () { + this.RoomManager._roomsClientIsIn = sinon + .stub() + .returns([this.project_id, this.doc_id, this.other_doc_id]) + this.client.join = sinon.stub() + return (this.client.leave = sinon.stub()) + }) + + describe('when this is the only client connected', function () { + beforeEach(function (done) { + // first call is for the join, + // second for the leave + this.RoomManager._clientsInRoom + .withArgs(this.client, this.doc_id) + .onCall(0) + .returns(0) + .onCall(1) + .returns(0) + this.RoomManager._clientsInRoom + .withArgs(this.client, this.other_doc_id) + .onCall(0) + .returns(0) + .onCall(1) + .returns(0) + this.RoomManager._clientsInRoom + .withArgs(this.client, this.project_id) + .onCall(0) + .returns(0) + .onCall(1) + .returns(0) + this.RoomManager._clientAlreadyInRoom + .withArgs(this.client, this.doc_id) + .returns(true) + .withArgs(this.client, this.other_doc_id) + .returns(true) + .withArgs(this.client, this.project_id) + .returns(true) + this.RoomEvents.on('project-active', id => { + return setTimeout(() => { + return this.RoomEvents.emit(`project-subscribed-${id}`) + }, 100) + }) + this.RoomEvents.on('doc-active', id => { + return setTimeout(() => { + return this.RoomEvents.emit(`doc-subscribed-${id}`) + }, 100) + }) + // put the client in the rooms + return this.RoomManager.joinProject( + this.client, + this.project_id, + () => { + return this.RoomManager.joinDoc(this.client, this.doc_id, () => { + return this.RoomManager.joinDoc( + this.client, + this.other_doc_id, + () => { + // now leave the project + this.RoomManager.leaveProjectAndDocs(this.client) + return done() + } + ) + }) + } + ) + }) + + it('should leave all the docs', function () { + this.client.leave.calledWithExactly(this.doc_id).should.equal(true) + return this.client.leave + .calledWithExactly(this.other_doc_id) + .should.equal(true) + }) + + it('should leave the project', function () { + return this.client.leave + .calledWithExactly(this.project_id) + .should.equal(true) + }) + + it("should emit a 'doc-empty' event with the id for each doc", function () { + this.RoomEvents.emit + .calledWithExactly('doc-empty', this.doc_id) + .should.equal(true) + return this.RoomEvents.emit + .calledWithExactly('doc-empty', this.other_doc_id) + .should.equal(true) + }) + + return it("should emit a 'project-empty' event with the id for the project", function () { + return this.RoomEvents.emit + .calledWithExactly('project-empty', this.project_id) + .should.equal(true) + }) + }) + + return describe('when other clients are still connected', function () { + beforeEach(function () { + this.RoomManager._clientsInRoom + .withArgs(this.client, this.doc_id) + .onFirstCall() + .returns(123) + .onSecondCall() + .returns(122) + this.RoomManager._clientsInRoom + .withArgs(this.client, this.other_doc_id) + .onFirstCall() + .returns(123) + .onSecondCall() + .returns(122) + this.RoomManager._clientsInRoom + .withArgs(this.client, this.project_id) + .onFirstCall() + .returns(123) + .onSecondCall() + .returns(122) + this.RoomManager._clientAlreadyInRoom + .withArgs(this.client, this.doc_id) + .returns(true) + .withArgs(this.client, this.other_doc_id) + .returns(true) + .withArgs(this.client, this.project_id) + .returns(true) + return this.RoomManager.leaveProjectAndDocs(this.client) + }) + + it('should leave all the docs', function () { + this.client.leave.calledWithExactly(this.doc_id).should.equal(true) + return this.client.leave + .calledWithExactly(this.other_doc_id) + .should.equal(true) + }) + + it('should leave the project', function () { + return this.client.leave + .calledWithExactly(this.project_id) + .should.equal(true) + }) + + return it('should not emit any events', function () { + return this.RoomEvents.emit.called.should.equal(false) + }) + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/SafeJsonParseTest.js b/services/real-time/test/unit/js/SafeJsonParseTest.js new file mode 100644 index 0000000000..dbdb4a41d2 --- /dev/null +++ b/services/real-time/test/unit/js/SafeJsonParseTest.js @@ -0,0 +1,56 @@ +/* eslint-disable + camelcase, + handle-callback-err, + no-return-assign, + no-useless-escape, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const { expect } = require('chai') +const SandboxedModule = require('sandboxed-module') +const modulePath = '../../../app/js/SafeJsonParse' + +describe('SafeJsonParse', function () { + beforeEach(function () { + return (this.SafeJsonParse = SandboxedModule.require(modulePath, { + requires: { + '@overleaf/settings': (this.Settings = { + maxUpdateSize: 16 * 1024, + }), + }, + })) + }) + + return describe('parse', function () { + it('should parse documents correctly', function (done) { + return this.SafeJsonParse.parse('{"foo": "bar"}', (error, parsed) => { + expect(parsed).to.deep.equal({ foo: 'bar' }) + return done() + }) + }) + + it('should return an error on bad data', function (done) { + return this.SafeJsonParse.parse('blah', (error, parsed) => { + expect(error).to.exist + return done() + }) + }) + + return it('should return an error on oversized data', function (done) { + // we have a 2k overhead on top of max size + const big_blob = Array(16 * 1024).join('A') + const data = `{\"foo\": \"${big_blob}\"}` + this.Settings.maxUpdateSize = 2 * 1024 + return this.SafeJsonParse.parse(data, (error, parsed) => { + this.logger.error.called.should.equal(false) + expect(error).to.exist + return done() + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/SessionSocketsTests.js b/services/real-time/test/unit/js/SessionSocketsTests.js new file mode 100644 index 0000000000..b6f6c87fb9 --- /dev/null +++ b/services/real-time/test/unit/js/SessionSocketsTests.js @@ -0,0 +1,197 @@ +/* eslint-disable + handle-callback-err, + no-return-assign, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const { EventEmitter } = require('events') +const { expect } = require('chai') +const SandboxedModule = require('sandboxed-module') +const modulePath = '../../../app/js/SessionSockets' +const sinon = require('sinon') + +describe('SessionSockets', function () { + before(function () { + this.SessionSocketsModule = SandboxedModule.require(modulePath) + this.io = new EventEmitter() + this.id1 = Math.random().toString() + this.id2 = Math.random().toString() + const redisResponses = { + error: [new Error('Redis: something went wrong'), null], + unknownId: [null, null], + } + redisResponses[this.id1] = [null, { user: { _id: '123' } }] + redisResponses[this.id2] = [null, { user: { _id: 'abc' } }] + + this.sessionStore = { + get: sinon + .stub() + .callsFake((id, fn) => fn.apply(null, redisResponses[id])), + } + this.cookieParser = function (req, res, next) { + req.signedCookies = req._signedCookies + return next() + } + this.SessionSockets = this.SessionSocketsModule( + this.io, + this.sessionStore, + this.cookieParser, + 'ol.sid' + ) + return (this.checkSocket = (socket, fn) => { + this.SessionSockets.once('connection', fn) + return this.io.emit('connection', socket) + }) + }) + + describe('without cookies', function () { + before(function () { + return (this.socket = { handshake: {} }) + }) + + it('should return a lookup error', function (done) { + return this.checkSocket(this.socket, error => { + expect(error).to.exist + expect(error.message).to.equal('could not look up session by key') + return done() + }) + }) + + return it('should not query redis', function (done) { + return this.checkSocket(this.socket, () => { + expect(this.sessionStore.get.called).to.equal(false) + return done() + }) + }) + }) + + describe('with a different cookie', function () { + before(function () { + return (this.socket = { handshake: { _signedCookies: { other: 1 } } }) + }) + + it('should return a lookup error', function (done) { + return this.checkSocket(this.socket, error => { + expect(error).to.exist + expect(error.message).to.equal('could not look up session by key') + return done() + }) + }) + + return it('should not query redis', function (done) { + return this.checkSocket(this.socket, () => { + expect(this.sessionStore.get.called).to.equal(false) + return done() + }) + }) + }) + + describe('with a valid cookie and a failing session lookup', function () { + before(function () { + return (this.socket = { + handshake: { _signedCookies: { 'ol.sid': 'error' } }, + }) + }) + + it('should query redis', function (done) { + return this.checkSocket(this.socket, () => { + expect(this.sessionStore.get.called).to.equal(true) + return done() + }) + }) + + return it('should return a redis error', function (done) { + return this.checkSocket(this.socket, error => { + expect(error).to.exist + expect(error.message).to.equal('Redis: something went wrong') + return done() + }) + }) + }) + + describe('with a valid cookie and no matching session', function () { + before(function () { + return (this.socket = { + handshake: { _signedCookies: { 'ol.sid': 'unknownId' } }, + }) + }) + + it('should query redis', function (done) { + return this.checkSocket(this.socket, () => { + expect(this.sessionStore.get.called).to.equal(true) + return done() + }) + }) + + return it('should return a lookup error', function (done) { + return this.checkSocket(this.socket, error => { + expect(error).to.exist + expect(error.message).to.equal('could not look up session by key') + return done() + }) + }) + }) + + describe('with a valid cookie and a matching session', function () { + before(function () { + return (this.socket = { + handshake: { _signedCookies: { 'ol.sid': this.id1 } }, + }) + }) + + it('should query redis', function (done) { + return this.checkSocket(this.socket, () => { + expect(this.sessionStore.get.called).to.equal(true) + return done() + }) + }) + + it('should not return an error', function (done) { + return this.checkSocket(this.socket, error => { + expect(error).to.not.exist + return done() + }) + }) + + return it('should return the session', function (done) { + return this.checkSocket(this.socket, (error, s, session) => { + expect(session).to.deep.equal({ user: { _id: '123' } }) + return done() + }) + }) + }) + + return describe('with a different valid cookie and matching session', function () { + before(function () { + return (this.socket = { + handshake: { _signedCookies: { 'ol.sid': this.id2 } }, + }) + }) + + it('should query redis', function (done) { + return this.checkSocket(this.socket, () => { + expect(this.sessionStore.get.called).to.equal(true) + return done() + }) + }) + + it('should not return an error', function (done) { + return this.checkSocket(this.socket, error => { + expect(error).to.not.exist + return done() + }) + }) + + return it('should return the other session', function (done) { + return this.checkSocket(this.socket, (error, s, session) => { + expect(session).to.deep.equal({ user: { _id: 'abc' } }) + return done() + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/WebApiManagerTests.js b/services/real-time/test/unit/js/WebApiManagerTests.js new file mode 100644 index 0000000000..922e15a8f8 --- /dev/null +++ b/services/real-time/test/unit/js/WebApiManagerTests.js @@ -0,0 +1,208 @@ +/* eslint-disable + no-return-assign, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const sinon = require('sinon') +const modulePath = '../../../app/js/WebApiManager.js' +const SandboxedModule = require('sandboxed-module') +const { CodedError } = require('../../../app/js/Errors') + +describe('WebApiManager', function () { + beforeEach(function () { + this.project_id = 'project-id-123' + this.user_id = 'user-id-123' + this.user = { _id: this.user_id } + this.callback = sinon.stub() + return (this.WebApiManager = SandboxedModule.require(modulePath, { + requires: { + request: (this.request = {}), + '@overleaf/settings': (this.settings = { + apis: { + web: { + url: 'http://web.example.com', + user: 'username', + pass: 'password', + }, + }, + }), + }, + })) + }) + + return describe('joinProject', function () { + describe('successfully', function () { + beforeEach(function () { + this.response = { + project: { name: 'Test project' }, + privilegeLevel: 'owner', + isRestrictedUser: true, + } + this.request.post = sinon + .stub() + .callsArgWith(1, null, { statusCode: 200 }, this.response) + return this.WebApiManager.joinProject( + this.project_id, + this.user, + this.callback + ) + }) + + it('should send a request to web to join the project', function () { + return this.request.post + .calledWith({ + url: `${this.settings.apis.web.url}/project/${this.project_id}/join`, + qs: { + user_id: this.user_id, + }, + auth: { + user: this.settings.apis.web.user, + pass: this.settings.apis.web.pass, + sendImmediately: true, + }, + json: true, + jar: false, + headers: {}, + }) + .should.equal(true) + }) + + return it('should return the project, privilegeLevel, and restricted flag', function () { + return this.callback + .calledWith( + null, + this.response.project, + this.response.privilegeLevel, + this.response.isRestrictedUser + ) + .should.equal(true) + }) + }) + + describe('when web replies with a 403', function () { + beforeEach(function () { + this.request.post = sinon + .stub() + .callsArgWith(1, null, { statusCode: 403 }, null) + this.WebApiManager.joinProject( + this.project_id, + this.user_id, + this.callback + ) + }) + + it('should call the callback with an error', function () { + this.callback + .calledWith( + sinon.match({ + message: 'not authorized', + }) + ) + .should.equal(true) + }) + }) + + describe('when web replies with a 404', function () { + beforeEach(function () { + this.request.post = sinon + .stub() + .callsArgWith(1, null, { statusCode: 404 }, null) + this.WebApiManager.joinProject( + this.project_id, + this.user_id, + this.callback + ) + }) + + it('should call the callback with an error', function () { + this.callback + .calledWith( + sinon.match({ + message: 'project not found', + info: { code: 'ProjectNotFound' }, + }) + ) + .should.equal(true) + }) + }) + + describe('with an error from web', function () { + beforeEach(function () { + this.request.post = sinon + .stub() + .callsArgWith(1, null, { statusCode: 500 }, null) + return this.WebApiManager.joinProject( + this.project_id, + this.user_id, + this.callback + ) + }) + + return it('should call the callback with an error', function () { + return this.callback + .calledWith( + sinon.match({ + message: 'non-success status code from web', + info: { statusCode: 500 }, + }) + ) + .should.equal(true) + }) + }) + + describe('with no data from web', function () { + beforeEach(function () { + this.request.post = sinon + .stub() + .callsArgWith(1, null, { statusCode: 200 }, null) + return this.WebApiManager.joinProject( + this.project_id, + this.user_id, + this.callback + ) + }) + + return it('should call the callback with an error', function () { + return this.callback + .calledWith( + sinon.match({ + message: 'no data returned from joinProject request', + }) + ) + .should.equal(true) + }) + }) + + return describe('when the project is over its rate limit', function () { + beforeEach(function () { + this.request.post = sinon + .stub() + .callsArgWith(1, null, { statusCode: 429 }, null) + return this.WebApiManager.joinProject( + this.project_id, + this.user_id, + this.callback + ) + }) + + return it('should call the callback with a TooManyRequests error code', function () { + return this.callback + .calledWith( + sinon.match({ + message: 'rate-limit hit when joining project', + info: { + code: 'TooManyRequests', + }, + }) + ) + .should.equal(true) + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/WebsocketControllerTests.js b/services/real-time/test/unit/js/WebsocketControllerTests.js new file mode 100644 index 0000000000..8b7c60aed6 --- /dev/null +++ b/services/real-time/test/unit/js/WebsocketControllerTests.js @@ -0,0 +1,1662 @@ +/* eslint-disable + camelcase, + no-return-assign, + no-throw-literal, + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const sinon = require('sinon') +const { expect } = require('chai') +const modulePath = '../../../app/js/WebsocketController.js' +const SandboxedModule = require('sandboxed-module') +const tk = require('timekeeper') +const { UpdateTooLargeError } = require('../../../app/js/Errors') + +describe('WebsocketController', function () { + beforeEach(function () { + tk.freeze(new Date()) + this.project_id = 'project-id-123' + this.user = { + _id: (this.user_id = 'user-id-123'), + first_name: 'James', + last_name: 'Allen', + email: 'james@example.com', + signUpDate: new Date('2014-01-01'), + loginCount: 42, + } + this.callback = sinon.stub() + this.client = { + disconnected: false, + id: (this.client_id = 'mock-client-id-123'), + publicId: `other-id-${Math.random()}`, + ol_context: {}, + joinLeaveEpoch: 0, + join: sinon.stub(), + leave: sinon.stub(), + } + return (this.WebsocketController = SandboxedModule.require(modulePath, { + requires: { + './WebApiManager': (this.WebApiManager = {}), + './AuthorizationManager': (this.AuthorizationManager = {}), + './DocumentUpdaterManager': (this.DocumentUpdaterManager = {}), + './ConnectedUsersManager': (this.ConnectedUsersManager = {}), + './WebsocketLoadBalancer': (this.WebsocketLoadBalancer = {}), + '@overleaf/metrics': (this.metrics = { + inc: sinon.stub(), + set: sinon.stub(), + }), + './RoomManager': (this.RoomManager = {}), + }, + })) + }) + + afterEach(function () { + return tk.reset() + }) + + describe('joinProject', function () { + describe('when authorised', function () { + beforeEach(function () { + this.client.id = 'mock-client-id' + this.project = { + name: 'Test Project', + owner: { + _id: (this.owner_id = 'mock-owner-id-123'), + }, + } + this.privilegeLevel = 'owner' + this.ConnectedUsersManager.updateUserPosition = sinon + .stub() + .callsArgAsync(4) + this.isRestrictedUser = true + this.WebApiManager.joinProject = sinon + .stub() + .callsArgWith( + 2, + null, + this.project, + this.privilegeLevel, + this.isRestrictedUser + ) + this.RoomManager.joinProject = sinon.stub().callsArg(2) + return this.WebsocketController.joinProject( + this.client, + this.user, + this.project_id, + this.callback + ) + }) + + it('should load the project from web', function () { + return this.WebApiManager.joinProject + .calledWith(this.project_id, this.user) + .should.equal(true) + }) + + it('should join the project room', function () { + return this.RoomManager.joinProject + .calledWith(this.client, this.project_id) + .should.equal(true) + }) + + it('should set the privilege level on the client', function () { + return this.client.ol_context.privilege_level.should.equal( + this.privilegeLevel + ) + }) + it("should set the user's id on the client", function () { + return this.client.ol_context.user_id.should.equal(this.user._id) + }) + it("should set the user's email on the client", function () { + return this.client.ol_context.email.should.equal(this.user.email) + }) + it("should set the user's first_name on the client", function () { + return this.client.ol_context.first_name.should.equal( + this.user.first_name + ) + }) + it("should set the user's last_name on the client", function () { + return this.client.ol_context.last_name.should.equal( + this.user.last_name + ) + }) + it("should set the user's sign up date on the client", function () { + return this.client.ol_context.signup_date.should.equal( + this.user.signUpDate + ) + }) + it("should set the user's login_count on the client", function () { + return this.client.ol_context.login_count.should.equal( + this.user.loginCount + ) + }) + it('should set the connected time on the client', function () { + return this.client.ol_context.connected_time.should.equal(new Date()) + }) + it('should set the project_id on the client', function () { + return this.client.ol_context.project_id.should.equal(this.project_id) + }) + it('should set the project owner id on the client', function () { + return this.client.ol_context.owner_id.should.equal(this.owner_id) + }) + it('should set the is_restricted_user flag on the client', function () { + return this.client.ol_context.is_restricted_user.should.equal( + this.isRestrictedUser + ) + }) + it('should call the callback with the project, privilegeLevel and protocolVersion', function () { + return this.callback + .calledWith( + null, + this.project, + this.privilegeLevel, + this.WebsocketController.PROTOCOL_VERSION + ) + .should.equal(true) + }) + + it('should mark the user as connected in ConnectedUsersManager', function () { + return this.ConnectedUsersManager.updateUserPosition + .calledWith(this.project_id, this.client.publicId, this.user, null) + .should.equal(true) + }) + + return it('should increment the join-project metric', function () { + return this.metrics.inc + .calledWith('editor.join-project') + .should.equal(true) + }) + }) + + describe('when not authorized', function () { + beforeEach(function () { + this.WebApiManager.joinProject = sinon + .stub() + .callsArgWith(2, null, null, null) + return this.WebsocketController.joinProject( + this.client, + this.user, + this.project_id, + this.callback + ) + }) + + it('should return an error', function () { + return this.callback + .calledWith(sinon.match({ message: 'not authorized' })) + .should.equal(true) + }) + + return it('should not log an error', function () { + return this.logger.error.called.should.equal(false) + }) + }) + + describe('when the subscribe failed', function () { + beforeEach(function () { + this.client.id = 'mock-client-id' + this.project = { + name: 'Test Project', + owner: { + _id: (this.owner_id = 'mock-owner-id-123'), + }, + } + this.privilegeLevel = 'owner' + this.ConnectedUsersManager.updateUserPosition = sinon + .stub() + .callsArgAsync(4) + this.isRestrictedUser = true + this.WebApiManager.joinProject = sinon + .stub() + .callsArgWith( + 2, + null, + this.project, + this.privilegeLevel, + this.isRestrictedUser + ) + this.RoomManager.joinProject = sinon + .stub() + .callsArgWith(2, new Error('subscribe failed')) + return this.WebsocketController.joinProject( + this.client, + this.user, + this.project_id, + this.callback + ) + }) + + return it('should return an error', function () { + this.callback + .calledWith(sinon.match({ message: 'subscribe failed' })) + .should.equal(true) + return this.callback.args[0][0].message.should.equal('subscribe failed') + }) + }) + + describe('when the client has disconnected', function () { + beforeEach(function () { + this.client.disconnected = true + this.WebApiManager.joinProject = sinon.stub().callsArg(2) + return this.WebsocketController.joinProject( + this.client, + this.user, + this.project_id, + this.callback + ) + }) + + it('should not call WebApiManager.joinProject', function () { + return expect(this.WebApiManager.joinProject.called).to.equal(false) + }) + + it('should call the callback with no details', function () { + return expect(this.callback.args[0]).to.deep.equal([]) + }) + + return it('should increment the editor.join-project.disconnected metric with a status', function () { + return expect( + this.metrics.inc.calledWith('editor.join-project.disconnected', 1, { + status: 'immediately', + }) + ).to.equal(true) + }) + }) + + return describe('when the client disconnects while WebApiManager.joinProject is running', function () { + beforeEach(function () { + this.WebApiManager.joinProject = (project, user, cb) => { + this.client.disconnected = true + return cb( + null, + this.project, + this.privilegeLevel, + this.isRestrictedUser + ) + } + + return this.WebsocketController.joinProject( + this.client, + this.user, + this.project_id, + this.callback + ) + }) + + it('should call the callback with no details', function () { + return expect(this.callback.args[0]).to.deep.equal([]) + }) + + return it('should increment the editor.join-project.disconnected metric with a status', function () { + return expect( + this.metrics.inc.calledWith('editor.join-project.disconnected', 1, { + status: 'after-web-api-call', + }) + ).to.equal(true) + }) + }) + }) + + describe('leaveProject', function () { + beforeEach(function () { + this.DocumentUpdaterManager.flushProjectToMongoAndDelete = sinon + .stub() + .callsArg(1) + this.ConnectedUsersManager.markUserAsDisconnected = sinon + .stub() + .callsArg(2) + this.WebsocketLoadBalancer.emitToRoom = sinon.stub() + this.RoomManager.leaveProjectAndDocs = sinon.stub() + this.clientsInRoom = [] + this.io = { + sockets: { + clients: room_id => { + if (room_id !== this.project_id) { + throw 'expected room_id to be project_id' + } + return this.clientsInRoom + }, + }, + } + this.client.ol_context.project_id = this.project_id + this.client.ol_context.user_id = this.user_id + this.WebsocketController.FLUSH_IF_EMPTY_DELAY = 0 + return tk.reset() + }) // Allow setTimeout to work. + + describe('when the client did not joined a project yet', function () { + beforeEach(function (done) { + this.client.ol_context = {} + return this.WebsocketController.leaveProject(this.io, this.client, done) + }) + + it('should bail out when calling leaveProject', function () { + this.WebsocketLoadBalancer.emitToRoom.called.should.equal(false) + this.RoomManager.leaveProjectAndDocs.called.should.equal(false) + return this.ConnectedUsersManager.markUserAsDisconnected.called.should.equal( + false + ) + }) + + return it('should not inc any metric', function () { + return this.metrics.inc.called.should.equal(false) + }) + }) + + describe('when the project is empty', function () { + beforeEach(function (done) { + this.clientsInRoom = [] + return this.WebsocketController.leaveProject(this.io, this.client, done) + }) + + it('should end clientTracking.clientDisconnected to the project room', function () { + return this.WebsocketLoadBalancer.emitToRoom + .calledWith( + this.project_id, + 'clientTracking.clientDisconnected', + this.client.publicId + ) + .should.equal(true) + }) + + it('should mark the user as disconnected', function () { + return this.ConnectedUsersManager.markUserAsDisconnected + .calledWith(this.project_id, this.client.publicId) + .should.equal(true) + }) + + it('should flush the project in the document updater', function () { + return this.DocumentUpdaterManager.flushProjectToMongoAndDelete + .calledWith(this.project_id) + .should.equal(true) + }) + + it('should increment the leave-project metric', function () { + return this.metrics.inc + .calledWith('editor.leave-project') + .should.equal(true) + }) + + return it('should track the disconnection in RoomManager', function () { + return this.RoomManager.leaveProjectAndDocs + .calledWith(this.client) + .should.equal(true) + }) + }) + + describe('when the project is not empty', function () { + beforeEach(function (done) { + this.clientsInRoom = ['mock-remaining-client'] + this.io = { + sockets: { + clients: room_id => { + if (room_id !== this.project_id) { + throw 'expected room_id to be project_id' + } + return this.clientsInRoom + }, + }, + } + return this.WebsocketController.leaveProject(this.io, this.client, done) + }) + + return it('should not flush the project in the document updater', function () { + return this.DocumentUpdaterManager.flushProjectToMongoAndDelete.called.should.equal( + false + ) + }) + }) + + describe('when client has not authenticated', function () { + beforeEach(function (done) { + this.client.ol_context.user_id = null + this.client.ol_context.project_id = null + return this.WebsocketController.leaveProject(this.io, this.client, done) + }) + + it('should not end clientTracking.clientDisconnected to the project room', function () { + return this.WebsocketLoadBalancer.emitToRoom + .calledWith( + this.project_id, + 'clientTracking.clientDisconnected', + this.client.publicId + ) + .should.equal(false) + }) + + it('should not mark the user as disconnected', function () { + return this.ConnectedUsersManager.markUserAsDisconnected + .calledWith(this.project_id, this.client.publicId) + .should.equal(false) + }) + + it('should not flush the project in the document updater', function () { + return this.DocumentUpdaterManager.flushProjectToMongoAndDelete + .calledWith(this.project_id) + .should.equal(false) + }) + + return it('should not increment the leave-project metric', function () { + return this.metrics.inc + .calledWith('editor.leave-project') + .should.equal(false) + }) + }) + + return describe('when client has not joined a project', function () { + beforeEach(function (done) { + this.client.ol_context.user_id = this.user_id + this.client.ol_context.project_id = null + return this.WebsocketController.leaveProject(this.io, this.client, done) + }) + + it('should not end clientTracking.clientDisconnected to the project room', function () { + return this.WebsocketLoadBalancer.emitToRoom + .calledWith( + this.project_id, + 'clientTracking.clientDisconnected', + this.client.publicId + ) + .should.equal(false) + }) + + it('should not mark the user as disconnected', function () { + return this.ConnectedUsersManager.markUserAsDisconnected + .calledWith(this.project_id, this.client.publicId) + .should.equal(false) + }) + + it('should not flush the project in the document updater', function () { + return this.DocumentUpdaterManager.flushProjectToMongoAndDelete + .calledWith(this.project_id) + .should.equal(false) + }) + + return it('should not increment the leave-project metric', function () { + return this.metrics.inc + .calledWith('editor.leave-project') + .should.equal(false) + }) + }) + }) + + describe('joinDoc', function () { + beforeEach(function () { + this.doc_id = 'doc-id-123' + this.doc_lines = ['doc', 'lines'] + this.version = 42 + this.ops = ['mock', 'ops'] + this.ranges = { mock: 'ranges' } + this.options = {} + + this.client.ol_context.project_id = this.project_id + this.client.ol_context.is_restricted_user = false + this.AuthorizationManager.addAccessToDoc = sinon.stub().yields() + this.AuthorizationManager.assertClientCanViewProject = sinon + .stub() + .callsArgWith(1, null) + this.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon + .stub() + .callsArgWith(2, null) + this.DocumentUpdaterManager.getDocument = sinon + .stub() + .callsArgWith( + 3, + null, + this.doc_lines, + this.version, + this.ranges, + this.ops + ) + return (this.RoomManager.joinDoc = sinon.stub().callsArg(2)) + }) + + describe('works', function () { + beforeEach(function () { + return this.WebsocketController.joinDoc( + this.client, + this.doc_id, + -1, + this.options, + this.callback + ) + }) + + it('should inc the joinLeaveEpoch', function () { + expect(this.client.joinLeaveEpoch).to.equal(1) + }) + + it('should check that the client is authorized to view the project', function () { + return this.AuthorizationManager.assertClientCanViewProject + .calledWith(this.client) + .should.equal(true) + }) + + it('should get the document from the DocumentUpdaterManager with fromVersion', function () { + return this.DocumentUpdaterManager.getDocument + .calledWith(this.project_id, this.doc_id, -1) + .should.equal(true) + }) + + it('should add permissions for the client to access the doc', function () { + return this.AuthorizationManager.addAccessToDoc + .calledWith(this.client, this.doc_id) + .should.equal(true) + }) + + it('should join the client to room for the doc_id', function () { + return this.RoomManager.joinDoc + .calledWith(this.client, this.doc_id) + .should.equal(true) + }) + + it('should call the callback with the lines, version, ranges and ops', function () { + return this.callback + .calledWith(null, this.doc_lines, this.version, this.ops, this.ranges) + .should.equal(true) + }) + + return it('should increment the join-doc metric', function () { + return this.metrics.inc.calledWith('editor.join-doc').should.equal(true) + }) + }) + + describe('with a fromVersion', function () { + beforeEach(function () { + this.fromVersion = 40 + return this.WebsocketController.joinDoc( + this.client, + this.doc_id, + this.fromVersion, + this.options, + this.callback + ) + }) + + return it('should get the document from the DocumentUpdaterManager with fromVersion', function () { + return this.DocumentUpdaterManager.getDocument + .calledWith(this.project_id, this.doc_id, this.fromVersion) + .should.equal(true) + }) + }) + + describe('with doclines that need escaping', function () { + beforeEach(function () { + this.doc_lines.push(['räksmörgås']) + return this.WebsocketController.joinDoc( + this.client, + this.doc_id, + -1, + this.options, + this.callback + ) + }) + + return it('should call the callback with the escaped lines', function () { + const escaped_lines = this.callback.args[0][1] + const escaped_word = escaped_lines.pop() + escaped_word.should.equal('räksmörgÃ¥s') + // Check that unescaping works + return decodeURIComponent(escape(escaped_word)).should.equal( + 'räksmörgås' + ) + }) + }) + + describe('with comments that need encoding', function () { + beforeEach(function () { + this.ranges.comments = [{ op: { c: 'räksmörgås' } }] + return this.WebsocketController.joinDoc( + this.client, + this.doc_id, + -1, + { encodeRanges: true }, + this.callback + ) + }) + + return it('should call the callback with the encoded comment', function () { + const encoded_comments = this.callback.args[0][4] + const encoded_comment = encoded_comments.comments.pop() + const encoded_comment_text = encoded_comment.op.c + return encoded_comment_text.should.equal('räksmörgÃ¥s') + }) + }) + + describe('with changes that need encoding', function () { + it('should call the callback with the encoded insert change', function () { + this.ranges.changes = [{ op: { i: 'räksmörgås' } }] + this.WebsocketController.joinDoc( + this.client, + this.doc_id, + -1, + { encodeRanges: true }, + this.callback + ) + + const encoded_changes = this.callback.args[0][4] + const encoded_change = encoded_changes.changes.pop() + const encoded_change_text = encoded_change.op.i + return encoded_change_text.should.equal('räksmörgÃ¥s') + }) + + return it('should call the callback with the encoded delete change', function () { + this.ranges.changes = [{ op: { d: 'räksmörgås' } }] + this.WebsocketController.joinDoc( + this.client, + this.doc_id, + -1, + { encodeRanges: true }, + this.callback + ) + + const encoded_changes = this.callback.args[0][4] + const encoded_change = encoded_changes.changes.pop() + const encoded_change_text = encoded_change.op.d + return encoded_change_text.should.equal('räksmörgÃ¥s') + }) + }) + + describe('when not authorized', function () { + beforeEach(function () { + this.AuthorizationManager.assertClientCanViewProject = sinon + .stub() + .callsArgWith(1, (this.err = new Error('not authorized'))) + return this.WebsocketController.joinDoc( + this.client, + this.doc_id, + -1, + this.options, + this.callback + ) + }) + + it('should call the callback with an error', function () { + return this.callback + .calledWith(sinon.match({ message: 'not authorized' })) + .should.equal(true) + }) + + return it('should not call the DocumentUpdaterManager', function () { + return this.DocumentUpdaterManager.getDocument.called.should.equal( + false + ) + }) + }) + + describe('with a restricted client', function () { + beforeEach(function () { + this.ranges.comments = [{ op: { a: 1 } }, { op: { a: 2 } }] + this.client.ol_context.is_restricted_user = true + return this.WebsocketController.joinDoc( + this.client, + this.doc_id, + -1, + this.options, + this.callback + ) + }) + + return it('should overwrite ranges.comments with an empty list', function () { + const ranges = this.callback.args[0][4] + return expect(ranges.comments).to.deep.equal([]) + }) + }) + + describe('when the client has disconnected', function () { + beforeEach(function () { + this.client.disconnected = true + return this.WebsocketController.joinDoc( + this.client, + this.doc_id, + -1, + this.options, + this.callback + ) + }) + + it('should call the callback with no details', function () { + return expect(this.callback.args[0]).to.deep.equal([]) + }) + + it('should increment the editor.join-doc.disconnected metric with a status', function () { + return expect( + this.metrics.inc.calledWith('editor.join-doc.disconnected', 1, { + status: 'immediately', + }) + ).to.equal(true) + }) + + return it('should not get the document', function () { + return expect(this.DocumentUpdaterManager.getDocument.called).to.equal( + false + ) + }) + }) + + describe('when the client disconnects while auth checks are running', function () { + beforeEach(function (done) { + this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields( + new Error() + ) + this.DocumentUpdaterManager.checkDocument = ( + project_id, + doc_id, + cb + ) => { + this.client.disconnected = true + cb() + } + + this.WebsocketController.joinDoc( + this.client, + this.doc_id, + -1, + this.options, + (...args) => { + this.callback(...args) + done() + } + ) + }) + + it('should call the callback with no details', function () { + expect(this.callback.called).to.equal(true) + expect(this.callback.args[0]).to.deep.equal([]) + }) + + it('should increment the editor.join-doc.disconnected metric with a status', function () { + expect( + this.metrics.inc.calledWith('editor.join-doc.disconnected', 1, { + status: 'after-client-auth-check', + }) + ).to.equal(true) + }) + + it('should not get the document', function () { + expect(this.DocumentUpdaterManager.getDocument.called).to.equal(false) + }) + }) + + describe('when the client starts a parallel joinDoc request', function () { + beforeEach(function (done) { + this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields( + new Error() + ) + this.DocumentUpdaterManager.checkDocument = ( + project_id, + doc_id, + cb + ) => { + this.DocumentUpdaterManager.checkDocument = sinon.stub().yields() + this.WebsocketController.joinDoc( + this.client, + this.doc_id, + -1, + {}, + () => {} + ) + cb() + } + + this.WebsocketController.joinDoc( + this.client, + this.doc_id, + -1, + this.options, + (...args) => { + this.callback(...args) + // make sure the other joinDoc request completed + setTimeout(done, 5) + } + ) + }) + + it('should call the callback with an error', function () { + expect(this.callback.called).to.equal(true) + expect(this.callback.args[0][0].message).to.equal( + 'joinLeaveEpoch mismatch' + ) + }) + + it('should get the document once (the parallel request wins)', function () { + expect(this.DocumentUpdaterManager.getDocument.callCount).to.equal(1) + }) + }) + + describe('when the client starts a parallel leaveDoc request', function () { + beforeEach(function (done) { + this.RoomManager.leaveDoc = sinon.stub() + + this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields( + new Error() + ) + this.DocumentUpdaterManager.checkDocument = ( + project_id, + doc_id, + cb + ) => { + this.WebsocketController.leaveDoc(this.client, this.doc_id, () => {}) + cb() + } + + this.WebsocketController.joinDoc( + this.client, + this.doc_id, + -1, + this.options, + (...args) => { + this.callback(...args) + done() + } + ) + }) + + it('should call the callback with an error', function () { + expect(this.callback.called).to.equal(true) + expect(this.callback.args[0][0].message).to.equal( + 'joinLeaveEpoch mismatch' + ) + }) + + it('should not get the document', function () { + expect(this.DocumentUpdaterManager.getDocument.called).to.equal(false) + }) + }) + + describe('when the client disconnects while RoomManager.joinDoc is running', function () { + beforeEach(function () { + this.RoomManager.joinDoc = (client, doc_id, cb) => { + this.client.disconnected = true + return cb() + } + + return this.WebsocketController.joinDoc( + this.client, + this.doc_id, + -1, + this.options, + this.callback + ) + }) + + it('should call the callback with no details', function () { + return expect(this.callback.args[0]).to.deep.equal([]) + }) + + it('should increment the editor.join-doc.disconnected metric with a status', function () { + return expect( + this.metrics.inc.calledWith('editor.join-doc.disconnected', 1, { + status: 'after-joining-room', + }) + ).to.equal(true) + }) + + return it('should not get the document', function () { + return expect(this.DocumentUpdaterManager.getDocument.called).to.equal( + false + ) + }) + }) + + return describe('when the client disconnects while DocumentUpdaterManager.getDocument is running', function () { + beforeEach(function () { + this.DocumentUpdaterManager.getDocument = ( + project_id, + doc_id, + fromVersion, + callback + ) => { + this.client.disconnected = true + return callback( + null, + this.doc_lines, + this.version, + this.ranges, + this.ops + ) + } + + return this.WebsocketController.joinDoc( + this.client, + this.doc_id, + -1, + this.options, + this.callback + ) + }) + + it('should call the callback with no details', function () { + return expect(this.callback.args[0]).to.deep.equal([]) + }) + + return it('should increment the editor.join-doc.disconnected metric with a status', function () { + return expect( + this.metrics.inc.calledWith('editor.join-doc.disconnected', 1, { + status: 'after-doc-updater-call', + }) + ).to.equal(true) + }) + }) + }) + + describe('leaveDoc', function () { + beforeEach(function () { + this.doc_id = 'doc-id-123' + this.client.ol_context.project_id = this.project_id + this.RoomManager.leaveDoc = sinon.stub() + return this.WebsocketController.leaveDoc( + this.client, + this.doc_id, + this.callback + ) + }) + + it('should inc the joinLeaveEpoch', function () { + expect(this.client.joinLeaveEpoch).to.equal(1) + }) + + it('should remove the client from the doc_id room', function () { + return this.RoomManager.leaveDoc + .calledWith(this.client, this.doc_id) + .should.equal(true) + }) + + it('should call the callback', function () { + return this.callback.called.should.equal(true) + }) + + return it('should increment the leave-doc metric', function () { + return this.metrics.inc.calledWith('editor.leave-doc').should.equal(true) + }) + }) + + describe('getConnectedUsers', function () { + beforeEach(function () { + this.client.ol_context.project_id = this.project_id + this.users = ['mock', 'users'] + this.WebsocketLoadBalancer.emitToRoom = sinon.stub() + return (this.ConnectedUsersManager.getConnectedUsers = sinon + .stub() + .callsArgWith(1, null, this.users)) + }) + + describe('when authorized', function () { + beforeEach(function (done) { + this.AuthorizationManager.assertClientCanViewProject = sinon + .stub() + .callsArgWith(1, null) + return this.WebsocketController.getConnectedUsers( + this.client, + (...args) => { + this.callback(...Array.from(args || [])) + return done() + } + ) + }) + + it('should check that the client is authorized to view the project', function () { + return this.AuthorizationManager.assertClientCanViewProject + .calledWith(this.client) + .should.equal(true) + }) + + it('should broadcast a request to update the client list', function () { + return this.WebsocketLoadBalancer.emitToRoom + .calledWith(this.project_id, 'clientTracking.refresh') + .should.equal(true) + }) + + it('should get the connected users for the project', function () { + return this.ConnectedUsersManager.getConnectedUsers + .calledWith(this.project_id) + .should.equal(true) + }) + + it('should return the users', function () { + return this.callback.calledWith(null, this.users).should.equal(true) + }) + + return it('should increment the get-connected-users metric', function () { + return this.metrics.inc + .calledWith('editor.get-connected-users') + .should.equal(true) + }) + }) + + describe('when not authorized', function () { + beforeEach(function () { + this.AuthorizationManager.assertClientCanViewProject = sinon + .stub() + .callsArgWith(1, (this.err = new Error('not authorized'))) + return this.WebsocketController.getConnectedUsers( + this.client, + this.callback + ) + }) + + it('should not get the connected users for the project', function () { + return this.ConnectedUsersManager.getConnectedUsers.called.should.equal( + false + ) + }) + + return it('should return an error', function () { + return this.callback.calledWith(this.err).should.equal(true) + }) + }) + + describe('when restricted user', function () { + beforeEach(function () { + this.client.ol_context.is_restricted_user = true + this.AuthorizationManager.assertClientCanViewProject = sinon + .stub() + .callsArgWith(1, null) + return this.WebsocketController.getConnectedUsers( + this.client, + this.callback + ) + }) + + it('should return an empty array of users', function () { + return this.callback.calledWith(null, []).should.equal(true) + }) + + return it('should not get the connected users for the project', function () { + return this.ConnectedUsersManager.getConnectedUsers.called.should.equal( + false + ) + }) + }) + + return describe('when the client has disconnected', function () { + beforeEach(function () { + this.client.disconnected = true + this.AuthorizationManager.assertClientCanViewProject = sinon.stub() + return this.WebsocketController.getConnectedUsers( + this.client, + this.callback + ) + }) + + it('should call the callback with no details', function () { + return expect(this.callback.args[0]).to.deep.equal([]) + }) + + return it('should not check permissions', function () { + return expect( + this.AuthorizationManager.assertClientCanViewProject.called + ).to.equal(false) + }) + }) + }) + + describe('updateClientPosition', function () { + beforeEach(function () { + this.WebsocketLoadBalancer.emitToRoom = sinon.stub() + this.ConnectedUsersManager.updateUserPosition = sinon + .stub() + .callsArgAsync(4) + this.AuthorizationManager.assertClientCanViewProjectAndDoc = sinon + .stub() + .callsArgWith(2, null) + return (this.update = { + doc_id: (this.doc_id = 'doc-id-123'), + row: (this.row = 42), + column: (this.column = 37), + }) + }) + + describe('with a logged in user', function () { + beforeEach(function (done) { + this.client.ol_context = { + project_id: this.project_id, + first_name: (this.first_name = 'Douglas'), + last_name: (this.last_name = 'Adams'), + email: (this.email = 'joe@example.com'), + user_id: (this.user_id = 'user-id-123'), + } + + this.populatedCursorData = { + doc_id: this.doc_id, + id: this.client.publicId, + name: `${this.first_name} ${this.last_name}`, + row: this.row, + column: this.column, + email: this.email, + user_id: this.user_id, + } + this.WebsocketController.updateClientPosition( + this.client, + this.update, + done + ) + }) + + it("should send the update to the project room with the user's name", function () { + return this.WebsocketLoadBalancer.emitToRoom + .calledWith( + this.project_id, + 'clientTracking.clientUpdated', + this.populatedCursorData + ) + .should.equal(true) + }) + + it('should send the cursor data to the connected user manager', function (done) { + this.ConnectedUsersManager.updateUserPosition + .calledWith( + this.project_id, + this.client.publicId, + { + _id: this.user_id, + email: this.email, + first_name: this.first_name, + last_name: this.last_name, + }, + { + row: this.row, + column: this.column, + doc_id: this.doc_id, + } + ) + .should.equal(true) + return done() + }) + + return it('should increment the update-client-position metric at 0.1 frequency', function () { + return this.metrics.inc + .calledWith('editor.update-client-position', 0.1) + .should.equal(true) + }) + }) + + describe('with a logged in user who has no last_name set', function () { + beforeEach(function (done) { + this.client.ol_context = { + project_id: this.project_id, + first_name: (this.first_name = 'Douglas'), + last_name: undefined, + email: (this.email = 'joe@example.com'), + user_id: (this.user_id = 'user-id-123'), + } + + this.populatedCursorData = { + doc_id: this.doc_id, + id: this.client.publicId, + name: `${this.first_name}`, + row: this.row, + column: this.column, + email: this.email, + user_id: this.user_id, + } + this.WebsocketController.updateClientPosition( + this.client, + this.update, + done + ) + }) + + it("should send the update to the project room with the user's name", function () { + return this.WebsocketLoadBalancer.emitToRoom + .calledWith( + this.project_id, + 'clientTracking.clientUpdated', + this.populatedCursorData + ) + .should.equal(true) + }) + + it('should send the cursor data to the connected user manager', function (done) { + this.ConnectedUsersManager.updateUserPosition + .calledWith( + this.project_id, + this.client.publicId, + { + _id: this.user_id, + email: this.email, + first_name: this.first_name, + last_name: undefined, + }, + { + row: this.row, + column: this.column, + doc_id: this.doc_id, + } + ) + .should.equal(true) + return done() + }) + + return it('should increment the update-client-position metric at 0.1 frequency', function () { + return this.metrics.inc + .calledWith('editor.update-client-position', 0.1) + .should.equal(true) + }) + }) + + describe('with a logged in user who has no first_name set', function () { + beforeEach(function (done) { + this.client.ol_context = { + project_id: this.project_id, + first_name: undefined, + last_name: (this.last_name = 'Adams'), + email: (this.email = 'joe@example.com'), + user_id: (this.user_id = 'user-id-123'), + } + + this.populatedCursorData = { + doc_id: this.doc_id, + id: this.client.publicId, + name: `${this.last_name}`, + row: this.row, + column: this.column, + email: this.email, + user_id: this.user_id, + } + this.WebsocketController.updateClientPosition( + this.client, + this.update, + done + ) + }) + + it("should send the update to the project room with the user's name", function () { + return this.WebsocketLoadBalancer.emitToRoom + .calledWith( + this.project_id, + 'clientTracking.clientUpdated', + this.populatedCursorData + ) + .should.equal(true) + }) + + it('should send the cursor data to the connected user manager', function (done) { + this.ConnectedUsersManager.updateUserPosition + .calledWith( + this.project_id, + this.client.publicId, + { + _id: this.user_id, + email: this.email, + first_name: undefined, + last_name: this.last_name, + }, + { + row: this.row, + column: this.column, + doc_id: this.doc_id, + } + ) + .should.equal(true) + return done() + }) + + return it('should increment the update-client-position metric at 0.1 frequency', function () { + return this.metrics.inc + .calledWith('editor.update-client-position', 0.1) + .should.equal(true) + }) + }) + describe('with a logged in user who has no names set', function () { + beforeEach(function (done) { + this.client.ol_context = { + project_id: this.project_id, + first_name: undefined, + last_name: undefined, + email: (this.email = 'joe@example.com'), + user_id: (this.user_id = 'user-id-123'), + } + return this.WebsocketController.updateClientPosition( + this.client, + this.update, + done + ) + }) + + return it('should send the update to the project name with no name', function () { + return this.WebsocketLoadBalancer.emitToRoom + .calledWith(this.project_id, 'clientTracking.clientUpdated', { + doc_id: this.doc_id, + id: this.client.publicId, + user_id: this.user_id, + name: '', + row: this.row, + column: this.column, + email: this.email, + }) + .should.equal(true) + }) + }) + + describe('with an anonymous user', function () { + beforeEach(function (done) { + this.client.ol_context = { + project_id: this.project_id, + } + return this.WebsocketController.updateClientPosition( + this.client, + this.update, + done + ) + }) + + it('should send the update to the project room with no name', function () { + return this.WebsocketLoadBalancer.emitToRoom + .calledWith(this.project_id, 'clientTracking.clientUpdated', { + doc_id: this.doc_id, + id: this.client.publicId, + name: '', + row: this.row, + column: this.column, + }) + .should.equal(true) + }) + + return it('should not send cursor data to the connected user manager', function (done) { + this.ConnectedUsersManager.updateUserPosition.called.should.equal(false) + return done() + }) + }) + + return describe('when the client has disconnected', function () { + beforeEach(function (done) { + this.client.disconnected = true + this.AuthorizationManager.assertClientCanViewProjectAndDoc = + sinon.stub() + return this.WebsocketController.updateClientPosition( + this.client, + this.update, + (...args) => { + this.callback(...args) + done(args[0]) + } + ) + }) + + it('should call the callback with no details', function () { + return expect(this.callback.args[0]).to.deep.equal([]) + }) + + return it('should not check permissions', function () { + return expect( + this.AuthorizationManager.assertClientCanViewProjectAndDoc.called + ).to.equal(false) + }) + }) + }) + + describe('applyOtUpdate', function () { + beforeEach(function () { + this.update = { op: { p: 12, t: 'foo' } } + this.client.ol_context.user_id = this.user_id + this.client.ol_context.project_id = this.project_id + this.WebsocketController._assertClientCanApplyUpdate = sinon + .stub() + .yields() + return (this.DocumentUpdaterManager.queueChange = sinon + .stub() + .callsArg(3)) + }) + + describe('succesfully', function () { + beforeEach(function () { + return this.WebsocketController.applyOtUpdate( + this.client, + this.doc_id, + this.update, + this.callback + ) + }) + + it('should set the source of the update to the client id', function () { + return this.update.meta.source.should.equal(this.client.publicId) + }) + + it('should set the user_id of the update to the user id', function () { + return this.update.meta.user_id.should.equal(this.user_id) + }) + + it('should queue the update', function () { + return this.DocumentUpdaterManager.queueChange + .calledWith(this.project_id, this.doc_id, this.update) + .should.equal(true) + }) + + it('should call the callback', function () { + return this.callback.called.should.equal(true) + }) + + return it('should increment the doc updates', function () { + return this.metrics.inc + .calledWith('editor.doc-update') + .should.equal(true) + }) + }) + + describe('unsuccessfully', function () { + beforeEach(function () { + this.client.disconnect = sinon.stub() + this.DocumentUpdaterManager.queueChange = sinon + .stub() + .callsArgWith(3, (this.error = new Error('Something went wrong'))) + return this.WebsocketController.applyOtUpdate( + this.client, + this.doc_id, + this.update, + this.callback + ) + }) + + it('should disconnect the client', function () { + return this.client.disconnect.called.should.equal(true) + }) + + it('should not log an error', function () { + return this.logger.error.called.should.equal(false) + }) + + return it('should call the callback with the error', function () { + return this.callback.calledWith(this.error).should.equal(true) + }) + }) + + describe('when not authorized', function () { + beforeEach(function () { + this.client.disconnect = sinon.stub() + this.WebsocketController._assertClientCanApplyUpdate = sinon + .stub() + .yields((this.error = new Error('not authorized'))) + return this.WebsocketController.applyOtUpdate( + this.client, + this.doc_id, + this.update, + this.callback + ) + }) + + // This happens in a setTimeout to allow the client a chance to receive the error first. + // I'm not sure how to unit test, but it is acceptance tested. + // it "should disconnect the client", -> + // @client.disconnect.called.should.equal true + + it('should not log a warning', function () { + return this.logger.warn.called.should.equal(false) + }) + + return it('should call the callback with the error', function () { + return this.callback.calledWith(this.error).should.equal(true) + }) + }) + + return describe('update_too_large', function () { + beforeEach(function (done) { + this.client.disconnect = sinon.stub() + this.client.emit = sinon.stub() + this.client.ol_context.user_id = this.user_id + this.client.ol_context.project_id = this.project_id + const error = new UpdateTooLargeError(7372835) + this.DocumentUpdaterManager.queueChange = sinon + .stub() + .callsArgWith(3, error) + this.WebsocketController.applyOtUpdate( + this.client, + this.doc_id, + this.update, + this.callback + ) + return setTimeout(() => done(), 1) + }) + + it('should call the callback with no error', function () { + this.callback.called.should.equal(true) + return this.callback.args[0].should.deep.equal([]) + }) + + it('should log a warning with the size and context', function () { + this.logger.warn.called.should.equal(true) + return this.logger.warn.args[0].should.deep.equal([ + { + user_id: this.user_id, + project_id: this.project_id, + doc_id: this.doc_id, + updateSize: 7372835, + }, + 'update is too large', + ]) + }) + + describe('after 100ms', function () { + beforeEach(function (done) { + return setTimeout(done, 100) + }) + + it('should send an otUpdateError the client', function () { + return this.client.emit.calledWith('otUpdateError').should.equal(true) + }) + + return it('should disconnect the client', function () { + return this.client.disconnect.called.should.equal(true) + }) + }) + + return describe('when the client disconnects during the next 100ms', function () { + beforeEach(function (done) { + this.client.disconnected = true + return setTimeout(done, 100) + }) + + it('should not send an otUpdateError the client', function () { + return this.client.emit + .calledWith('otUpdateError') + .should.equal(false) + }) + + it('should not disconnect the client', function () { + return this.client.disconnect.called.should.equal(false) + }) + + return it('should increment the editor.doc-update.disconnected metric with a status', function () { + return expect( + this.metrics.inc.calledWith('editor.doc-update.disconnected', 1, { + status: 'at-otUpdateError', + }) + ).to.equal(true) + }) + }) + }) + }) + + return describe('_assertClientCanApplyUpdate', function () { + beforeEach(function () { + this.edit_update = { + op: [ + { i: 'foo', p: 42 }, + { c: 'bar', p: 132 }, + ], + } // comments may still be in an edit op + this.comment_update = { op: [{ c: 'bar', p: 132 }] } + this.AuthorizationManager.assertClientCanEditProjectAndDoc = sinon.stub() + return (this.AuthorizationManager.assertClientCanViewProjectAndDoc = + sinon.stub()) + }) + + describe('with a read-write client', function () { + return it('should return successfully', function (done) { + this.AuthorizationManager.assertClientCanEditProjectAndDoc.yields(null) + return this.WebsocketController._assertClientCanApplyUpdate( + this.client, + this.doc_id, + this.edit_update, + error => { + expect(error).to.be.null + return done() + } + ) + }) + }) + + describe('with a read-only client and an edit op', function () { + return it('should return an error', function (done) { + this.AuthorizationManager.assertClientCanEditProjectAndDoc.yields( + new Error('not authorized') + ) + this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null) + return this.WebsocketController._assertClientCanApplyUpdate( + this.client, + this.doc_id, + this.edit_update, + error => { + expect(error.message).to.equal('not authorized') + return done() + } + ) + }) + }) + + describe('with a read-only client and a comment op', function () { + return it('should return successfully', function (done) { + this.AuthorizationManager.assertClientCanEditProjectAndDoc.yields( + new Error('not authorized') + ) + this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields(null) + return this.WebsocketController._assertClientCanApplyUpdate( + this.client, + this.doc_id, + this.comment_update, + error => { + expect(error).to.be.null + return done() + } + ) + }) + }) + + return describe('with a totally unauthorized client', function () { + return it('should return an error', function (done) { + this.AuthorizationManager.assertClientCanEditProjectAndDoc.yields( + new Error('not authorized') + ) + this.AuthorizationManager.assertClientCanViewProjectAndDoc.yields( + new Error('not authorized') + ) + return this.WebsocketController._assertClientCanApplyUpdate( + this.client, + this.doc_id, + this.comment_update, + error => { + expect(error.message).to.equal('not authorized') + return done() + } + ) + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/WebsocketLoadBalancerTests.js b/services/real-time/test/unit/js/WebsocketLoadBalancerTests.js new file mode 100644 index 0000000000..ec341a43bd --- /dev/null +++ b/services/real-time/test/unit/js/WebsocketLoadBalancerTests.js @@ -0,0 +1,304 @@ +/* eslint-disable + no-return-assign, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md + */ +const SandboxedModule = require('sandboxed-module') +const sinon = require('sinon') +const modulePath = require('path').join( + __dirname, + '../../../app/js/WebsocketLoadBalancer' +) + +describe('WebsocketLoadBalancer', function () { + beforeEach(function () { + this.rclient = {} + this.RoomEvents = { on: sinon.stub() } + this.WebsocketLoadBalancer = SandboxedModule.require(modulePath, { + requires: { + './RedisClientManager': { + createClientList: () => [], + }, + './SafeJsonParse': (this.SafeJsonParse = { + parse: (data, cb) => cb(null, JSON.parse(data)), + }), + './EventLogger': { checkEventOrder: sinon.stub() }, + './HealthCheckManager': { check: sinon.stub() }, + './RoomManager': (this.RoomManager = { + eventSource: sinon.stub().returns(this.RoomEvents), + }), + './ChannelManager': (this.ChannelManager = { publish: sinon.stub() }), + './ConnectedUsersManager': (this.ConnectedUsersManager = { + refreshClient: sinon.stub(), + }), + }, + }) + this.io = {} + this.WebsocketLoadBalancer.rclientPubList = [{ publish: sinon.stub() }] + this.WebsocketLoadBalancer.rclientSubList = [ + { + subscribe: sinon.stub(), + on: sinon.stub(), + }, + ] + + this.room_id = 'room-id' + this.message = 'otUpdateApplied' + return (this.payload = ['argument one', 42]) + }) + + describe('emitToRoom', function () { + beforeEach(function () { + return this.WebsocketLoadBalancer.emitToRoom( + this.room_id, + this.message, + ...Array.from(this.payload) + ) + }) + + return it('should publish the message to redis', function () { + return this.ChannelManager.publish + .calledWith( + this.WebsocketLoadBalancer.rclientPubList[0], + 'editor-events', + this.room_id, + JSON.stringify({ + room_id: this.room_id, + message: this.message, + payload: this.payload, + }) + ) + .should.equal(true) + }) + }) + + describe('emitToAll', function () { + beforeEach(function () { + this.WebsocketLoadBalancer.emitToRoom = sinon.stub() + return this.WebsocketLoadBalancer.emitToAll( + this.message, + ...Array.from(this.payload) + ) + }) + + return it("should emit to the room 'all'", function () { + return this.WebsocketLoadBalancer.emitToRoom + .calledWith('all', this.message, ...Array.from(this.payload)) + .should.equal(true) + }) + }) + + describe('listenForEditorEvents', function () { + beforeEach(function () { + this.WebsocketLoadBalancer._processEditorEvent = sinon.stub() + return this.WebsocketLoadBalancer.listenForEditorEvents() + }) + + it('should subscribe to the editor-events channel', function () { + return this.WebsocketLoadBalancer.rclientSubList[0].subscribe + .calledWith('editor-events') + .should.equal(true) + }) + + return it('should process the events with _processEditorEvent', function () { + return this.WebsocketLoadBalancer.rclientSubList[0].on + .calledWith('message', sinon.match.func) + .should.equal(true) + }) + }) + + return describe('_processEditorEvent', function () { + describe('with bad JSON', function () { + beforeEach(function () { + this.isRestrictedUser = false + this.SafeJsonParse.parse = sinon + .stub() + .callsArgWith(1, new Error('oops')) + return this.WebsocketLoadBalancer._processEditorEvent( + this.io, + 'editor-events', + 'blah' + ) + }) + + return it('should log an error', function () { + return this.logger.error.called.should.equal(true) + }) + }) + + describe('with a designated room', function () { + beforeEach(function () { + this.io.sockets = { + clients: sinon.stub().returns([ + { + id: 'client-id-1', + emit: (this.emit1 = sinon.stub()), + ol_context: {}, + }, + { + id: 'client-id-2', + emit: (this.emit2 = sinon.stub()), + ol_context: {}, + }, + { + id: 'client-id-1', + emit: (this.emit3 = sinon.stub()), + ol_context: {}, + }, // duplicate client + ]), + } + const data = JSON.stringify({ + room_id: this.room_id, + message: this.message, + payload: this.payload, + }) + return this.WebsocketLoadBalancer._processEditorEvent( + this.io, + 'editor-events', + data + ) + }) + + return it('should send the message to all (unique) clients in the room', function () { + this.io.sockets.clients.calledWith(this.room_id).should.equal(true) + this.emit1 + .calledWith(this.message, ...Array.from(this.payload)) + .should.equal(true) + this.emit2 + .calledWith(this.message, ...Array.from(this.payload)) + .should.equal(true) + return this.emit3.called.should.equal(false) + }) + }) // duplicate client should be ignored + + describe('with a designated room, and restricted clients, not restricted message', function () { + beforeEach(function () { + this.io.sockets = { + clients: sinon.stub().returns([ + { + id: 'client-id-1', + emit: (this.emit1 = sinon.stub()), + ol_context: {}, + }, + { + id: 'client-id-2', + emit: (this.emit2 = sinon.stub()), + ol_context: {}, + }, + { + id: 'client-id-1', + emit: (this.emit3 = sinon.stub()), + ol_context: {}, + }, // duplicate client + { + id: 'client-id-4', + emit: (this.emit4 = sinon.stub()), + ol_context: { is_restricted_user: true }, + }, + ]), + } + const data = JSON.stringify({ + room_id: this.room_id, + message: this.message, + payload: this.payload, + }) + return this.WebsocketLoadBalancer._processEditorEvent( + this.io, + 'editor-events', + data + ) + }) + + return it('should send the message to all (unique) clients in the room', function () { + this.io.sockets.clients.calledWith(this.room_id).should.equal(true) + this.emit1 + .calledWith(this.message, ...Array.from(this.payload)) + .should.equal(true) + this.emit2 + .calledWith(this.message, ...Array.from(this.payload)) + .should.equal(true) + this.emit3.called.should.equal(false) // duplicate client should be ignored + return this.emit4.called.should.equal(true) + }) + }) // restricted client, but should be called + + describe('with a designated room, and restricted clients, restricted message', function () { + beforeEach(function () { + this.io.sockets = { + clients: sinon.stub().returns([ + { + id: 'client-id-1', + emit: (this.emit1 = sinon.stub()), + ol_context: {}, + }, + { + id: 'client-id-2', + emit: (this.emit2 = sinon.stub()), + ol_context: {}, + }, + { + id: 'client-id-1', + emit: (this.emit3 = sinon.stub()), + ol_context: {}, + }, // duplicate client + { + id: 'client-id-4', + emit: (this.emit4 = sinon.stub()), + ol_context: { is_restricted_user: true }, + }, + ]), + } + const data = JSON.stringify({ + room_id: this.room_id, + message: (this.restrictedMessage = 'new-comment'), + payload: this.payload, + }) + return this.WebsocketLoadBalancer._processEditorEvent( + this.io, + 'editor-events', + data + ) + }) + + return it('should send the message to all (unique) clients in the room, who are not restricted', function () { + this.io.sockets.clients.calledWith(this.room_id).should.equal(true) + this.emit1 + .calledWith(this.restrictedMessage, ...Array.from(this.payload)) + .should.equal(true) + this.emit2 + .calledWith(this.restrictedMessage, ...Array.from(this.payload)) + .should.equal(true) + this.emit3.called.should.equal(false) // duplicate client should be ignored + return this.emit4.called.should.equal(false) + }) + }) // restricted client, should not be called + + return describe('when emitting to all', function () { + beforeEach(function () { + this.io.sockets = { emit: (this.emit = sinon.stub()) } + const data = JSON.stringify({ + room_id: 'all', + message: this.message, + payload: this.payload, + }) + return this.WebsocketLoadBalancer._processEditorEvent( + this.io, + 'editor-events', + data + ) + }) + + return it('should send the message to all clients', function () { + return this.emit + .calledWith(this.message, ...Array.from(this.payload)) + .should.equal(true) + }) + }) + }) +}) diff --git a/services/real-time/test/unit/js/helpers/MockClient.js b/services/real-time/test/unit/js/helpers/MockClient.js new file mode 100644 index 0000000000..61cde89ba9 --- /dev/null +++ b/services/real-time/test/unit/js/helpers/MockClient.js @@ -0,0 +1,23 @@ +/* eslint-disable + no-unused-vars, +*/ +// TODO: This file was created by bulk-decaffeinate. +// Fix any style issues and re-enable lint. +let MockClient +const sinon = require('sinon') + +let idCounter = 0 + +module.exports = MockClient = class MockClient { + constructor() { + this.ol_context = {} + this.join = sinon.stub() + this.emit = sinon.stub() + this.disconnect = sinon.stub() + this.id = idCounter++ + this.publicId = idCounter++ + this.joinLeaveEpoch = 0 + } + + disconnect() {} +}