From b4cd156301093659ff487fff48fa5e844959fcb7 Mon Sep 17 00:00:00 2001 From: kenshin-samourai Date: Sat, 1 Jun 2019 23:11:18 +0200 Subject: [PATCH] Initial commit --- .dockerignore | 3 + .gitignore | 7 + LICENSE.md | 616 ++ README.md | 77 + accounts/api-helper.js | 160 + accounts/fees-rest-api.js | 55 + accounts/headers-rest-api.js | 78 + accounts/index-cluster.js | 37 + accounts/index.js | 76 + accounts/multiaddr-rest-api.js | 136 + accounts/notifications-server.js | 83 + accounts/notifications-service.js | 476 ++ accounts/status-rest-api.js | 56 + accounts/status.js | 52 + accounts/support-rest-api.js | 384 + accounts/transactions-rest-api.js | 156 + accounts/unspent-rest-api.js | 136 + accounts/xpub-rest-api.js | 450 ++ db-scripts/1_db.sql | 212 + doc/DELETE_xpub.md | 37 + doc/DOCKER_setup.md | 196 + doc/GET_fees.md | 39 + doc/GET_header.md | 47 + doc/GET_multiaddr.md | 117 + doc/GET_tx.md | 131 + doc/GET_txs.md | 87 + doc/GET_unspent.md | 61 + doc/GET_xpub.md | 45 + doc/POST_auth_login.md | 69 + doc/POST_auth_refresh.md | 31 + doc/POST_pushtx.md | 38 + doc/POST_pushtx_schedule.md | 121 + doc/POST_xpub.md | 41 + doc/POST_xpub_lock.md | 39 + doc/README.md | 29 + docker/my-dojo/.env | 52 + docker/my-dojo/bitcoin/Dockerfile | 56 + docker/my-dojo/bitcoin/bitcoin.conf | 22 + docker/my-dojo/bitcoin/restart.sh | 14 + docker/my-dojo/bitcoin/wait-for-it.sh | 178 + docker/my-dojo/conf/docker-bitcoind.conf | 32 + docker/my-dojo/conf/docker-mysql.conf | 15 + docker/my-dojo/conf/docker-node.conf | 30 + docker/my-dojo/docker-compose.yaml | 130 + docker/my-dojo/dojo.sh | 222 + docker/my-dojo/mysql/Dockerfile | 7 + docker/my-dojo/mysql/mysql-dojo.cnf | 2 + docker/my-dojo/nginx/Dockerfile | 18 + docker/my-dojo/nginx/dojo.conf | 53 + docker/my-dojo/nginx/nginx.conf | 44 + docker/my-dojo/nginx/wait-for | 79 + docker/my-dojo/node/Dockerfile | 43 + docker/my-dojo/node/keys.index.js | 244 + docker/my-dojo/node/restart.sh | 13 + docker/my-dojo/node/wait-for-it.sh | 178 + docker/my-dojo/tor/Dockerfile | 55 + docker/my-dojo/tor/torrc | 49 + docker/my-dojo/tor/wait-for-it.sh | 178 + keys/index-example.js | 349 + lib/auth/auth-rest-api.js | 106 + lib/auth/authentication-manager.js | 77 + lib/auth/authorizations-manager.js | 296 + lib/auth/localapikey-strategy-configurator.js | 62 + lib/bitcoin/addresses-helper.js | 106 + lib/bitcoin/addresses-service.js | 44 + lib/bitcoin/hd-accounts-helper.js | 400 + lib/bitcoin/hd-accounts-service.js | 250 + lib/bitcoin/network.js | 44 + lib/bitcoin/parallel-address-derivation.js | 92 + lib/bitcoind-rpc/fees.js | 71 + lib/bitcoind-rpc/headers.js | 56 + lib/bitcoind-rpc/latest-block.js | 69 + lib/bitcoind-rpc/rpc-client.js | 88 + lib/bitcoind-rpc/transactions.js | 215 + lib/db/mysql-db-wrapper.js | 1974 +++++ lib/errors.js | 80 + lib/fork-pool.js | 85 + lib/http-server/http-server.js | 242 + lib/logger.js | 67 + lib/remote-importer/bitcoind-wrapper.js | 129 + lib/remote-importer/btccom-wrapper.js | 122 + lib/remote-importer/insight-wrapper.js | 90 + lib/remote-importer/oxt-wrapper.js | 114 + lib/remote-importer/remote-importer.js | 436 ++ lib/remote-importer/sources-mainnet.js | 112 + lib/remote-importer/sources-testnet.js | 153 + lib/remote-importer/sources.js | 40 + lib/remote-importer/wrapper.js | 47 + lib/util.js | 368 + lib/wallet/address-info.js | 152 + lib/wallet/hd-account-info.js | 187 + lib/wallet/wallet-entities.js | 88 + lib/wallet/wallet-info.js | 309 + lib/wallet/wallet-service.js | 301 + package.json | 42 + pushtx/index-orchestrator.js | 49 + pushtx/index.js | 57 + pushtx/orchestrator.js | 182 + pushtx/pushtx-processor.js | 77 + pushtx/pushtx-rest-api.js | 223 + pushtx/status.js | 129 + pushtx/transactions-scheduler.js | 128 + restart-example.sh | 16 + scripts/create-first-blocks.js | 84 + scripts/delete-data-banned-addresses.js | 79 + scripts/generate-passphrase.js | 25 + scripts/import-hd-accounts.js | 58 + scripts/patches/revert-hd-accounts.js | 105 + scripts/patches/translate-hd-accounts.js | 109 + scripts/rescan-blocks.js | 42 + static/admin/conf/index.js | 25 + static/admin/css/bootstrap-theme.css | 587 ++ static/admin/css/bootstrap-theme.min.css | 6 + static/admin/css/bootstrap.css | 6757 +++++++++++++++++ static/admin/css/bootstrap.min.css | 6 + static/admin/css/style.css | 457 ++ .../ic_power_settings_new_white_24dp_1x.png | Bin 0 -> 274 bytes .../ic_power_settings_new_white_24dp_2x.png | Bin 0 -> 556 bytes static/admin/icons/samourai-logo-trans@2x.png | Bin 0 -> 10170 bytes static/admin/index.html | 57 + static/admin/index.js | 51 + static/admin/lib/api-wrapper.js | 228 + static/admin/lib/auth-utils.js | 101 + static/admin/lib/bootstrap.js | 2377 ++++++ static/admin/lib/bootstrap.min.js | 7 + static/admin/lib/common-script.js | 51 + static/admin/lib/format-utils.js | 62 + static/admin/lib/jquery-3.2.1.min.js | 4 + static/admin/lib/jquery.qrcode.min.js | 28 + static/admin/lib/messages.js | 39 + static/admin/tool/index.html | 120 + static/admin/tool/index.js | 244 + test/a-init-network.js | 29 + test/lib/bitcoin/addresses-helper-test.js | 165 + test/lib/bitcoin/hd-accounts-helper-test.js | 176 + tracker/abstract-processor.js | 50 + tracker/block.js | 116 + tracker/blockchain-processor.js | 370 + tracker/index.js | 40 + tracker/mempool-processor.js | 282 + tracker/tracker.js | 55 + tracker/transaction.js | 399 + tracker/transactions-bundle.js | 216 + 143 files changed, 28023 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 accounts/api-helper.js create mode 100644 accounts/fees-rest-api.js create mode 100644 accounts/headers-rest-api.js create mode 100644 accounts/index-cluster.js create mode 100644 accounts/index.js create mode 100644 accounts/multiaddr-rest-api.js create mode 100644 accounts/notifications-server.js create mode 100644 accounts/notifications-service.js create mode 100644 accounts/status-rest-api.js create mode 100644 accounts/status.js create mode 100644 accounts/support-rest-api.js create mode 100644 accounts/transactions-rest-api.js create mode 100644 accounts/unspent-rest-api.js create mode 100644 accounts/xpub-rest-api.js create mode 100644 db-scripts/1_db.sql create mode 100644 doc/DELETE_xpub.md create mode 100644 doc/DOCKER_setup.md create mode 100644 doc/GET_fees.md create mode 100644 doc/GET_header.md create mode 100644 doc/GET_multiaddr.md create mode 100644 doc/GET_tx.md create mode 100644 doc/GET_txs.md create mode 100644 doc/GET_unspent.md create mode 100644 doc/GET_xpub.md create mode 100644 doc/POST_auth_login.md create mode 100644 doc/POST_auth_refresh.md create mode 100644 doc/POST_pushtx.md create mode 100644 doc/POST_pushtx_schedule.md create mode 100644 doc/POST_xpub.md create mode 100644 doc/POST_xpub_lock.md create mode 100644 doc/README.md create mode 100644 docker/my-dojo/.env create mode 100644 docker/my-dojo/bitcoin/Dockerfile create mode 100644 docker/my-dojo/bitcoin/bitcoin.conf create mode 100644 docker/my-dojo/bitcoin/restart.sh create mode 100644 docker/my-dojo/bitcoin/wait-for-it.sh create mode 100644 docker/my-dojo/conf/docker-bitcoind.conf create mode 100644 docker/my-dojo/conf/docker-mysql.conf create mode 100644 docker/my-dojo/conf/docker-node.conf create mode 100644 docker/my-dojo/docker-compose.yaml create mode 100755 docker/my-dojo/dojo.sh create mode 100644 docker/my-dojo/mysql/Dockerfile create mode 100644 docker/my-dojo/mysql/mysql-dojo.cnf create mode 100644 docker/my-dojo/nginx/Dockerfile create mode 100644 docker/my-dojo/nginx/dojo.conf create mode 100644 docker/my-dojo/nginx/nginx.conf create mode 100644 docker/my-dojo/nginx/wait-for create mode 100644 docker/my-dojo/node/Dockerfile create mode 100644 docker/my-dojo/node/keys.index.js create mode 100644 docker/my-dojo/node/restart.sh create mode 100644 docker/my-dojo/node/wait-for-it.sh create mode 100644 docker/my-dojo/tor/Dockerfile create mode 100644 docker/my-dojo/tor/torrc create mode 100644 docker/my-dojo/tor/wait-for-it.sh create mode 100644 keys/index-example.js create mode 100644 lib/auth/auth-rest-api.js create mode 100644 lib/auth/authentication-manager.js create mode 100644 lib/auth/authorizations-manager.js create mode 100644 lib/auth/localapikey-strategy-configurator.js create mode 100644 lib/bitcoin/addresses-helper.js create mode 100644 lib/bitcoin/addresses-service.js create mode 100644 lib/bitcoin/hd-accounts-helper.js create mode 100644 lib/bitcoin/hd-accounts-service.js create mode 100644 lib/bitcoin/network.js create mode 100644 lib/bitcoin/parallel-address-derivation.js create mode 100644 lib/bitcoind-rpc/fees.js create mode 100644 lib/bitcoind-rpc/headers.js create mode 100644 lib/bitcoind-rpc/latest-block.js create mode 100644 lib/bitcoind-rpc/rpc-client.js create mode 100644 lib/bitcoind-rpc/transactions.js create mode 100644 lib/db/mysql-db-wrapper.js create mode 100644 lib/errors.js create mode 100644 lib/fork-pool.js create mode 100644 lib/http-server/http-server.js create mode 100644 lib/logger.js create mode 100644 lib/remote-importer/bitcoind-wrapper.js create mode 100644 lib/remote-importer/btccom-wrapper.js create mode 100644 lib/remote-importer/insight-wrapper.js create mode 100644 lib/remote-importer/oxt-wrapper.js create mode 100644 lib/remote-importer/remote-importer.js create mode 100644 lib/remote-importer/sources-mainnet.js create mode 100644 lib/remote-importer/sources-testnet.js create mode 100644 lib/remote-importer/sources.js create mode 100644 lib/remote-importer/wrapper.js create mode 100644 lib/util.js create mode 100644 lib/wallet/address-info.js create mode 100644 lib/wallet/hd-account-info.js create mode 100644 lib/wallet/wallet-entities.js create mode 100644 lib/wallet/wallet-info.js create mode 100644 lib/wallet/wallet-service.js create mode 100644 package.json create mode 100644 pushtx/index-orchestrator.js create mode 100644 pushtx/index.js create mode 100644 pushtx/orchestrator.js create mode 100644 pushtx/pushtx-processor.js create mode 100644 pushtx/pushtx-rest-api.js create mode 100644 pushtx/status.js create mode 100644 pushtx/transactions-scheduler.js create mode 100644 restart-example.sh create mode 100644 scripts/create-first-blocks.js create mode 100644 scripts/delete-data-banned-addresses.js create mode 100644 scripts/generate-passphrase.js create mode 100644 scripts/import-hd-accounts.js create mode 100644 scripts/patches/revert-hd-accounts.js create mode 100644 scripts/patches/translate-hd-accounts.js create mode 100644 scripts/rescan-blocks.js create mode 100644 static/admin/conf/index.js create mode 100644 static/admin/css/bootstrap-theme.css create mode 100644 static/admin/css/bootstrap-theme.min.css create mode 100644 static/admin/css/bootstrap.css create mode 100644 static/admin/css/bootstrap.min.css create mode 100644 static/admin/css/style.css create mode 100644 static/admin/icons/ic_power_settings_new_white_24dp_1x.png create mode 100644 static/admin/icons/ic_power_settings_new_white_24dp_2x.png create mode 100644 static/admin/icons/samourai-logo-trans@2x.png create mode 100644 static/admin/index.html create mode 100644 static/admin/index.js create mode 100644 static/admin/lib/api-wrapper.js create mode 100644 static/admin/lib/auth-utils.js create mode 100644 static/admin/lib/bootstrap.js create mode 100644 static/admin/lib/bootstrap.min.js create mode 100644 static/admin/lib/common-script.js create mode 100644 static/admin/lib/format-utils.js create mode 100644 static/admin/lib/jquery-3.2.1.min.js create mode 100644 static/admin/lib/jquery.qrcode.min.js create mode 100644 static/admin/lib/messages.js create mode 100644 static/admin/tool/index.html create mode 100644 static/admin/tool/index.js create mode 100644 test/a-init-network.js create mode 100644 test/lib/bitcoin/addresses-helper-test.js create mode 100644 test/lib/bitcoin/hd-accounts-helper-test.js create mode 100644 tracker/abstract-processor.js create mode 100644 tracker/block.js create mode 100644 tracker/blockchain-processor.js create mode 100644 tracker/index.js create mode 100644 tracker/mempool-processor.js create mode 100644 tracker/tracker.js create mode 100644 tracker/transaction.js create mode 100644 tracker/transactions-bundle.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a7bc9d2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +node_modules +.git +private-tests \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..050a408 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +db-scripts/updates/ +keys/index.js +keys/sslcert/ +node_modules/ +private-tests/ +*.log +package-lock.json diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..51a2261 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,616 @@ +### 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. + +Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea3d529 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# Samourai Dojo + +Samourai Dojo is the backing server for Samourai Wallet. Provides HD account & loose addresses (BIP47) balances & transactions lists. Provides unspent output lists to the wallet. PushTX endpoint broadcasts transactions through the backing bitcoind node. + +[View API documentation](../master/doc/README.md) + + +## Installation ## + +### MyDojo (installation with Docker and Docker Compose) + +This setup is recommended to Samourai users who feel comfortable with a few command lines. + +It provides in a single command the setup of a full Samourai backend composed of: + +* a bitcoin full node only accessible as an ephemeral Tor hidden service, +* the backend database, +* the backend modules with an API accessible as a static Tor hidden service, +* a maintenance tool accessible through a Tor web browser. + +See [the documentation](./doc/DOCKER_setup.md) for detailed setup instructions. + + +### Manual installation (developers only) + +A full manual setup isn't recommended if you don't intend to install a local development environment. + + +## Theory of Operation + +Tracking wallet balances via `xpub` requires conforming to [BIP44](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki), [BIP49](https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki) or [BIP84](https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki) address derivation scheme. Public keys received by Dojo correspond to single accounts and derive all addresses in the account and change chains. These addresses are at `M/0/x` and `M/1/y`, respectively. + +Dojo relies on the backing bitcoind node to maintain privacy. + + +### Architecture + +Dojo is composed of 3 modules: +* API (/account): web server providing a REST API and web sockets used by Samourai Wallet and Sentinel. +* PushTx (/pushtx): web server providing a REST API used to push transactions on the Bitcoin P2P network. +* Tracker (/tracker): process listening to the bitcoind node and indexing transactions of interest. + +API and PushTx modules are able to operate behind a web server (e.g. nginx) or as frontend http servers (not recommended). Both support HTTP or HTTPS (if SSL has been properly configured in /keys/index.js). These modules can also operate as a Tor hidden service (recommended). + +Authentication is enforced by an API key and Json Web Tokens. + + +### Implementation Notes + +**Tracker** + +* ZMQ notifications send raw transactions and block hashes. Keep track of txids with timestamps, clearing out old txids after a timeout +* On realtime transaction: + * Query database with all output addresses to see if an account has received a transaction. Notify client via WebSocket. + * Query database with all input txids to see if an account has sent coins. Make proper database entries and notify via WebSocket. +* On a block notification, query database for txids included and update confirmed height +* On a blockchain reorg (orphan block), previous block hash will not match last known block hash in the app. Need to mark transactions as unconfirmed and rescan blocks from new chain tip to last known hash. Note that many of the transactions from the orphaned block may be included in the new chain. +* When an input spending a known output is confirmed in a block, delete any other inputs referencing that output, since this would be a double-spend. + + +**Import of HD Accounts and data sources** + +* First import of an unknown HD account relies on a data source (local bitcoind or OXT). After that, the tracker will keep everything current. + +* Default option relies on the local bitcoind and makes you 100% independent of Samourai Wallet's infrastructure. This option is recommended for better privacy. + +* Activation of bitcoind as the data source: + * Edit /keys/index.js and set "explorers.bitcoind" to "active". OXT API will be ignored. + +* Activation of OXT as the data source (through socks5): + * Edit /keys/index.js and set "explorers.bitcoind" to "inactive". + +* Main drawbacks of using your local bitcoind for these imports: + * It doesn't return the full transactional history associated to the HD account but only transactions having an unspent output controlled by the HD account. + * It's slightly slower than using the option relying on the OXT API. + * In some specific cases, the importer might miss the most recent unspent outputs. Higher values of gap.external and gap.internal in /keys/index.js should help to mitigate this issue. Another workaround is to request the endpoint /support/xpub/.../rescan provided by the REST API with the optional gap parameter. + * This option is considered as experimental. diff --git a/accounts/api-helper.js b/accounts/api-helper.js new file mode 100644 index 0000000..b39f561 --- /dev/null +++ b/accounts/api-helper.js @@ -0,0 +1,160 @@ +/*! + * accounts/api-helper.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const bitcoin = require('bitcoinjs-lib') +const validator = require('validator') +const Logger = require('../lib/logger') +const errors = require('../lib/errors') +const WalletEntities = require('../lib/wallet/wallet-entities') +const network = require('../lib/bitcoin/network') +const activeNet = network.network +const hdaHelper = require('../lib/bitcoin/hd-accounts-helper') +const addrHelper = require('../lib/bitcoin/addresses-helper') +const HttpServer = require('../lib/http-server/http-server') + + +/** + * A singleton providing util methods used by the API + */ +class ApiHelper { + + /** + * Parse a string and extract (x|y|z|t|u|v)pubs, addresses and pubkeys + * @param {string} str - list of entities separated by '|' + * @returns {object} returns a WalletEntities object + */ + parseEntities(str) { + const ret = new WalletEntities() + + if (typeof str !== 'string') + return ret + + for (let item of str.split('|')) { + try { + + if (hdaHelper.isValid(item) && !ret.hasXPub(item)) { + const xpub = hdaHelper.xlatXPUB(item) + + if (hdaHelper.isYpub(item)) + ret.addHdAccount(xpub, item, false) + else if (hdaHelper.isZpub(item)) + ret.addHdAccount(xpub, false, item) + else + ret.addHdAccount(item, false, false) + + } else if (addrHelper.isSupportedPubKey(item) && !ret.hasPubKey(item)) { + // Derive pubkey as 3 addresses (P1PKH, P2WPKH/P2SH, BECH32) + const bufItem = new Buffer(item, 'hex') + + const funcs = [ + addrHelper.p2pkhAddress, + addrHelper.p2wpkhP2shAddress, + addrHelper.p2wpkhAddress + ] + + for (let f of funcs) { + const addr = f(bufItem) + if (ret.hasAddress(addr)) + ret.updatePubKey(addr, item) + else + ret.addAddress(addr, item) + } + + } else if (bitcoin.address.toOutputScript(item, activeNet) && !ret.hasAddress(item)) { + + // Bech32 addresses are managed in lower case + if (addrHelper.isBech32(item)) + item = item.toLowerCase() + ret.addAddress(item, false) + } + } catch(e) {} + } + + return ret + } + + /** + * Check entities passed as url params + * @param {object} params - request query or body object + * @returns {boolean} return true if conditions are met, false otherwise + */ + checkEntitiesParams(params) { + return params.active + || params.new + || params.pubkey + || params.bip49 + || params.bip84 + } + + /** + * Parse the entities passed as arguments of an url + * @param {object} params - request query or body object + * @returns {object} return a mapping object + * {active:..., legacy:..., pubkey:..., bip49:..., bip84:...} + */ + parseEntitiesParams(params) { + return { + active: this.parseEntities(params.active), + legacy: this.parseEntities(params.new), + pubkey: this.parseEntities(params.pubkey), + bip49: this.parseEntities(params.bip49), + bip84: this.parseEntities(params.bip84) + } + } + + /** + * Express middleware validating if entities params are well formed + * @param {object} req - http request object + * @param {object} res - http response object + * @param {function} next - next express middleware + */ + validateEntitiesParams(req, res, next) { + const params = this.checkEntitiesParams(req.query) ? req.query : req.body + + let isValid = true + + if (params.active && !this.subValidateEntitiesParams(params.active)) + isValid &= false + + if (params.new && !this.subValidateEntitiesParams(params.new)) + isValid &= false + + if (params.pubkey && !this.subValidateEntitiesParams(params.pubkey)) + isValid &= false + + if (params.bip49 && !this.subValidateEntitiesParams(params.bip49)) + isValid &= false + + if (params.bip84 && !this.subValidateEntitiesParams(params.bip84)) + isValid &= false + + if (isValid) { + next() + } else { + HttpServer.sendError(res, errors.body.INVDATA) + Logger.error( + params, + `ApiHelper.validateEntitiesParams() : Invalid arguments` + ) + } + } + + /** + * Validate a request argument + * @param {string} arg - request argument + */ + subValidateEntitiesParams(arg) { + for (let item of arg.split('|')) { + const isValid = validator.isAlphanumeric(item) + if (!isValid) + return false + } + return true + } + +} + +module.exports = new ApiHelper() diff --git a/accounts/fees-rest-api.js b/accounts/fees-rest-api.js new file mode 100644 index 0000000..3c8d63b --- /dev/null +++ b/accounts/fees-rest-api.js @@ -0,0 +1,55 @@ +/*! + * accounts/get-fees-rest-api.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const Logger = require('../lib/logger') +const rpcFees = require('../lib/bitcoind-rpc/fees') +const authMgr = require('../lib/auth/authorizations-manager') +const HttpServer = require('../lib/http-server/http-server') + +const debugApi = !!(process.argv.indexOf('api-debug') > -1) + + +/** + * A singleton providing util methods used by the API + */ +class FeesRestApi { + + /** + * Constructor + * @param {pushtx.HttpServer} httpServer - HTTP server + */ + constructor(httpServer) { + this.httpServer = httpServer + // Establish routes + this.httpServer.app.get( + '/fees', + authMgr.checkAuthentication.bind(authMgr), + this.getFees.bind(this), + HttpServer.sendAuthError + ) + // Refresh the network fees + rpcFees.refresh() + } + + /** + * Refresh and return the current fees + * @param {object} req - http request object + * @param {object} res - http response object + */ + async getFees(req, res) { + try { + const fees = await rpcFees.getFees() + HttpServer.sendOkDataOnly(res, fees) + } catch (e) { + HttpServer.sendError(res, e) + } finally { + debugApi && Logger.info(`Completed GET /fees`) + } + } + +} + +module.exports = FeesRestApi diff --git a/accounts/headers-rest-api.js b/accounts/headers-rest-api.js new file mode 100644 index 0000000..5eb8fc3 --- /dev/null +++ b/accounts/headers-rest-api.js @@ -0,0 +1,78 @@ +/*! + * accounts/headers-fees-rest-api.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const validator = require('validator') +const Logger = require('../lib/logger') +const errors = require('../lib/errors') +const rpcHeaders = require('../lib/bitcoind-rpc/headers') +const authMgr = require('../lib/auth/authorizations-manager') +const HttpServer = require('../lib/http-server/http-server') +const apiHelper = require('./api-helper') + +const debugApi = !!(process.argv.indexOf('api-debug') > -1) + + +/** + * Headers API endpoints + */ +class HeadersRestApi { + + /** + * Constructor + * @param {pushtx.HttpServer} httpServer - HTTP server + */ + constructor(httpServer) { + this.httpServer = httpServer + + // Establish routes + this.httpServer.app.get( + '/header/:hash', + authMgr.checkAuthentication.bind(authMgr), + this.validateArgsGetHeader.bind(this), + this.getHeader.bind(this), + HttpServer.sendAuthError + ) + } + + /** + * Retrieve the block header for a given hash + * @param {object} req - http request object + * @param {object} res - http response object + */ + async getHeader(req, res) { + try { + const header = await rpcHeaders.getHeader(req.params.hash) + HttpServer.sendRawData(res, header) + } catch(e) { + HttpServer.sendError(res, e) + } finally { + debugApi && Logger.info(`Completed GET /header/${req.params.hash}`) + } + } + + /** + * Validate request arguments + * @param {object} req - http request object + * @param {object} res - http response object + * @param {function} next - next express middleware + */ + validateArgsGetHeader(req, res, next) { + const isValidHash = validator.isHash(req.params.hash, 'sha256') + + if (!isValidHash) { + HttpServer.sendError(res, errors.body.INVDATA) + Logger.error( + req.params.hash, + 'HeadersRestApi.validateArgsGetHeader() : Invalid hash' + ) + } else { + next() + } + } + +} + +module.exports = HeadersRestApi diff --git a/accounts/index-cluster.js b/accounts/index-cluster.js new file mode 100644 index 0000000..4a4e1b5 --- /dev/null +++ b/accounts/index-cluster.js @@ -0,0 +1,37 @@ +/*! + * accounts/index-cluster.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const os = require('os') +const cluster = require('cluster') +const Logger = require('../lib/logger') + + +/** + * Launch a cluster of Samourai API + */ +const nbCPUS = os.cpus() + +if (cluster.isMaster) { + nbCPUS.forEach(function() { + cluster.fork() + }) + + cluster.on('listening', function(worker) { + Logger.info(`Cluster ${worker.process.pid} connected`) + }) + + cluster.on('disconnect', function(worker) { + Logger.info(`Cluster ${worker.process.pid} disconnected`) + }) + + cluster.on('exit', function(worker) { + Logger.info(`Cluster ${worker.process.pid} is dead`) + // Ensuring a new cluster will start if an old one dies + cluster.fork() + }) +} else { + require('./index.js') +} diff --git a/accounts/index.js b/accounts/index.js new file mode 100644 index 0000000..573a292 --- /dev/null +++ b/accounts/index.js @@ -0,0 +1,76 @@ +/*! + * accounts/index.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +(async () => { + + 'use strict' + + const Logger = require('../lib/logger') + const RpcClient = require('../lib/bitcoind-rpc/rpc-client') + const network = require('../lib/bitcoin/network') + const keys = require('../keys')[network.key] + const db = require('../lib/db/mysql-db-wrapper') + const hdaHelper = require('../lib/bitcoin/hd-accounts-helper') + const HttpServer = require('../lib/http-server/http-server') + const AuthRestApi = require('../lib/auth/auth-rest-api') + const XPubRestApi = require('./xpub-rest-api') + const FeesRestApi = require('./fees-rest-api') + const HeadersRestApi = require('./headers-rest-api') + const TransactionsRestApi = require('./transactions-rest-api') + const StatusRestApi = require('./status-rest-api') + const notifServer = require('./notifications-server') + const MultiaddrRestApi = require('./multiaddr-rest-api') + const UnspentRestApi = require('./unspent-rest-api') + const SupportRestApi = require('./support-rest-api') + + + /** + * Samourai REST API + */ + Logger.info('Process ID: ' + process.pid) + Logger.info('Preparing the REST API') + + // Wait for Bitcoind RPC API + // being ready to process requests + await RpcClient.waitForBitcoindRpcApi() + + // Initialize the db wrapper + const dbConfig = { + connectionLimit: keys.db.connectionLimitApi, + acquireTimeout: keys.db.acquireTimeout, + host: keys.db.host, + user: keys.db.user, + password: keys.db.pass, + database: keys.db.database + } + + db.connect(dbConfig) + + // Activate addresses derivation + // in an external process + hdaHelper.activateExternalDerivation() + + // Initialize the http server + const port = keys.ports.account + const httpsOptions = keys.https.account + const httpServer = new HttpServer(port, httpsOptions) + + // Initialize the rest api endpoints + const authRestApi = new AuthRestApi(httpServer) + const xpubRestApi = new XPubRestApi(httpServer) + const feesRestApi = new FeesRestApi(httpServer) + const headersRestApi = new HeadersRestApi(httpServer) + const transactionsRestApi = new TransactionsRestApi(httpServer) + const statusRestApi = new StatusRestApi(httpServer) + const multiaddrRestApi = new MultiaddrRestApi(httpServer) + const unspentRestApi = new UnspentRestApi(httpServer) + const supportRestApi = new SupportRestApi(httpServer) + + // Start the http server + httpServer.start() + + // Attach the web sockets server to the web server + notifServer.attach(httpServer) + +})() diff --git a/accounts/multiaddr-rest-api.js b/accounts/multiaddr-rest-api.js new file mode 100644 index 0000000..b9859f4 --- /dev/null +++ b/accounts/multiaddr-rest-api.js @@ -0,0 +1,136 @@ +/*! + * accounts/multiaddr-rest-api.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const bodyParser = require('body-parser') +const Logger = require('../lib/logger') +const errors = require('../lib/errors') +const walletService = require('../lib/wallet/wallet-service') +const authMgr = require('../lib/auth/authorizations-manager') +const HttpServer = require('../lib/http-server/http-server') +const apiHelper = require('./api-helper') + +const debugApi = !!(process.argv.indexOf('api-debug') > -1) + + +/** + * Multiaddr API endpoints + */ +class MultiaddrRestApi { + + /** + * Constructor + * @param {pushtx.HttpServer} httpServer - HTTP server + */ + constructor(httpServer) { + this.httpServer = httpServer + + // Establish routes + const urlencodedParser = bodyParser.urlencoded({ extended: true }) + + this.httpServer.app.get( + '/multiaddr', + authMgr.checkAuthentication.bind(authMgr), + apiHelper.validateEntitiesParams.bind(apiHelper), + this.getMultiaddr.bind(this), + HttpServer.sendAuthError + ) + + this.httpServer.app.post( + '/multiaddr', + urlencodedParser, + authMgr.checkAuthentication.bind(authMgr), + apiHelper.validateEntitiesParams.bind(apiHelper), + this.postMultiaddr.bind(this), + HttpServer.sendAuthError + ) + } + + /** + * Handle multiaddr GET request + * @param {object} req - http request object + * @param {object} res - http response object + */ + async getMultiaddr(req, res) { + try { + // Check request params + if (!apiHelper.checkEntitiesParams(req.query)) + return HttpServer.sendError(res, errors.multiaddr.NOACT) + + // Parse params + const entities = apiHelper.parseEntitiesParams(req.query) + + const result = await walletService.getWalletInfo( + entities.active, + entities.legacy, + entities.bip49, + entities.bip84, + entities.pubkey + ) + + const ret = JSON.stringify(result, null, 2) + HttpServer.sendRawData(res, ret) + + } catch(e) { + HttpServer.sendError(res, e) + + } finally { + if (debugApi) { + const strParams = + `${req.query.active ? req.query.active : ''} \ + ${req.query.new ? req.query.new : ''} \ + ${req.query.pubkey ? req.query.pubkey : ''} \ + ${req.query.bip49 ? req.query.bip49 : ''} \ + ${req.query.bip84 ? req.query.bip84 : ''}` + + Logger.info(`Completed GET /multiaddr ${strParams}`) + } + } + } + + /** + * Handle multiaddr POST request + * @param {object} req - http request object + * @param {object} res - http response object + */ + async postMultiaddr(req, res) { + try { + // Check request params + if (!apiHelper.checkEntitiesParams(req.body)) + return HttpServer.sendError(res, errors.multiaddr.NOACT) + + // Parse params + const entities = apiHelper.parseEntitiesParams(req.body) + + const result = await walletService.getWalletInfo( + entities.active, + entities.legacy, + entities.bip49, + entities.bip84, + entities.pubkey + ) + + HttpServer.sendOkDataOnly(res, result) + + } catch(e) { + HttpServer.sendError(res, e) + + } finally { + if (debugApi) { + const strParams = + `${req.body.active ? req.body.active : ''} \ + ${req.body.new ? req.body.new : ''} \ + ${req.body.pubkey ? req.body.pubkey : ''} \ + ${req.body.bip49 ? req.body.bip49 : ''} \ + ${req.body.bip84 ? req.body.bip84 : ''}` + + Logger.info(`Completed POST /multiaddr ${strParams}`) + } + } + } + +} + +module.exports = MultiaddrRestApi diff --git a/accounts/notifications-server.js b/accounts/notifications-server.js new file mode 100644 index 0000000..60c376f --- /dev/null +++ b/accounts/notifications-server.js @@ -0,0 +1,83 @@ +/*! + * accounts/notification-web-sockets.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const _ = require('lodash') +const zmq = require('zeromq') +const WebSocket = require('websocket') +const Logger = require('../lib/logger') +const network = require('../lib/bitcoin/network') +const keys = require('../keys')[network.key] +const status = require('./status') +const NotificationsService = require('./notifications-service') + + +/** + * A singleton providing a notifications server over web sockets + */ +class NotificationsServer { + + /** + * Constructor + */ + constructor() { + // Http server + this.httpServer = null + // Notifications service + this.notifService = null + // Initialize the zmq socket for communications + // with the tracker + this._initTrackerSocket() + } + + /** + * Attach the web sockets server to the listening web server + * @param {pushtx.HttpServer} httpServer - HTTP server + */ + attach(httpServer) { + this.httpServer = httpServer + + if (this.notifService !== null) return + + this.notifService = new NotificationsService(httpServer.server) + } + + + /** + * Initialize a zmq socket for notifications from the tracker + */ + _initTrackerSocket() { + this.sock = zmq.socket('sub') + this.sock.connect(`tcp://127.0.0.1:${keys.ports.tracker}`) + this.sock.subscribe('block') + this.sock.subscribe('transaction') + + this.sock.on('message', (topic, message, sequence) => { + switch(topic.toString()) { + case 'block': + try { + const header = JSON.parse(message.toString()) + this.notifService.notifyBlock(header) + } catch(e) { + Logger.error(e, 'NotificationServer._initTrackerSocket() : Error in block message') + } + break + case 'transaction': + try { + const tx = JSON.parse(message.toString()) + this.notifService.notifyTransaction(tx) + } catch(e) { + Logger.error(e, 'NotificationServer._initTrackerSocket() : Error in transaction message') + } + break + default: + Logger.info(`Unknown ZMQ message topic: "${topic}"`) + } + }) + } + +} + +module.exports = new NotificationsServer() diff --git a/accounts/notifications-service.js b/accounts/notifications-service.js new file mode 100644 index 0000000..e20d241 --- /dev/null +++ b/accounts/notifications-service.js @@ -0,0 +1,476 @@ +/*! + * accounts/notification-web-sockets.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const _ = require('lodash') +const LRU = require('lru-cache') +const WebSocket = require('websocket') +const Logger = require('../lib/logger') +const network = require('../lib/bitcoin/network') +const keys = require('../keys')[network.key] +const apiHelper = require('./api-helper') +const status = require('./status') +const authMgr = require('../lib/auth/authorizations-manager') + +const debug = !!(process.argv.indexOf('ws-debug') > -1) + + +/** + * A class providing a notifications server over web sockets + */ +class NotificationsService { + + /** + * Constructor + * @param {object} server - listening instance of a http server + */ + constructor(server) { + // Web sockets server + this.ws = null + // Dictionary of connections + this.conn = {} + // Dictionary of subscriptions + this.subs = {} + // Dictionary mapping addresses to pubkeys + this.cachePubKeys = {} + + // Cache registering the most recent subscriptions received + // Used to filter multiple subscriptions sent by external apps. + this.cacheSubs = LRU({ + // Maximum number of subscriptions to store in cache + // Estimate: 1000 clients with an average of 5 subscriptions + max: 5000, + // Function used to compute length of item + length: (n, key) => 1, + // Maximum age for items in the cache (1mn) + maxAge: 60000 + }) + + // Initialize the web socket server + this._initWSServer(server) + } + + /** + * Initialize the web sockets server + * @param {object} server - listening instance of a http server + */ + _initWSServer(server) { + this.ws = new WebSocket.server({httpServer: server}) + + Logger.info('Created WebSocket server') + + this.ws.on('request', req => { + try { + let conn = req.accept(null, req.origin) + conn.id = status.sessions++ + conn.subs = [] + + debug && Logger.info(`Client ${conn.id} connected`) + + conn.on('close', () => { + this._closeWSConnection(conn, false) + }) + + conn.on('error', err => { + Logger.error(err, `NotificationsService : Error on connection ${conn.id}`) + if (conn.connected) + this._closeWSConnection(conn, true) + }) + + conn.on('message', msg => { + if (msg.type == 'utf8') + this._handleWSMessage(msg.utf8Data, conn) + else + this._closeWSConnection(conn, true) + }) + + this.conn[conn.id] = conn + status.clients = status.clients + 1 + status.maxConn = Math.max(status.maxConn, Object.keys(this.conn).length) + + } catch(e) { + Logger.error(e, `NotificationsService._initWSServer() : Error during request accept`) + } + }) + } + + /** + * Close a web sockets connection + * @param {object} conn - web socket connection + * @param {boolean} forcedClose - true if close initiated by server + */ + _closeWSConnection(conn, forcedClose) { + try { + for (let topic of conn.subs) { + this._unsub(topic, conn.id) + + // Close initiated by client, remove subscriptions from cache + if (!forcedClose && this.cacheSubs.has(topic)) + this.cacheSubs.del(topic) + } + + if (this.conn[conn.id]) { + delete this.conn[conn.id] + status.clients = status.clients - 1 + } + + // Close initiated by server, drop the connection + if (forcedClose && conn.connected) + conn.drop(1008, 'Get out of here!') + + debug && Logger.info(`Client ${conn.id} disconnected`) + + } catch(e) { + Logger.error(e, 'NotificationsService._closeWSConnection()') + } + } + + /** + * Filter potential duplicate subscriptions + * @param {string} msg - subscription received + * @returns {boolean} returns false if it's a duplicate, true otherwise. + */ + _filterWSMessage(msg) { + if (this.cacheSubs.has(msg)) { + debug && Logger.info('Duplicate subscriptions detected') + return false + } else { + this.cacheSubs.set(msg, true) + return true + } + } + + /** + * Handle messages received over the web sockets + * (subscriptions) + * @param {string} msg - subscription received + * @param {object} conn - connection + */ + _handleWSMessage(msg, conn) { + try { + debug && Logger.info(`Received from client ${conn.id}: ${msg}`) + + const data = JSON.parse(msg) + + // Check authentication (if needed) + if (authMgr.authActive && authMgr.isMandatory) { + try { + authMgr.isAuthenticated(msg.at) + } catch(e) { + this.notifyAuthError(e, conn.id) + return + } + } + + switch(data.op) { + case 'ping': + conn.sendUTF('{"op": "pong"}') + break + case 'addr_sub': + if (data.addr) { + // Check for potential flood by clients + // subscribing for the same xpub again and again + if (this._filterWSMessage(data.addr)) + this._entitysub(data.addr, conn) + else + this._closeWSConnection(conn, true) + } + break + case 'blocks_sub': + this._addsub('block', conn) + break + } + } catch(e) { + Logger.error(e, 'NotificationsService._handleWSMessage() : WebSocket message error') + } + } + + /** + * Subscribe to a list of addresses/xpubs/pubkeys + * @param {string} topic - topic + * @param {object} conn - connection asking for subscription + */ + _entitysub(topic, conn) { + const valid = apiHelper.parseEntities(topic) + + for (let a in valid.addrs) { + const address = valid.addrs[a] + this._addsub(address, conn) + if (valid.pubkeys[a]) { + this.cachePubKeys[address] = valid.pubkeys[a] + } + } + + for (let xpub of valid.xpubs) + this._addsub(xpub, conn) + } + + /** + * Subscribe to a topic + * @param {string} topic - topic + * @param {object} conn - connection asking for subscription + */ + _addsub(topic, conn) { + if (conn.subs.indexOf(topic) >= 0) + return false + + conn.subs.push(topic) + + if (!this.subs[topic]) + this.subs[topic] = [] + + this.subs[topic].push(conn.id) + + debug && Logger.info(`Client ${conn.id} subscribed to ${topic}`) + } + + /** + * Unsubscribe from a topic + * @param {string} topic - topic + * @param {int} cid - client id + */ + _unsub(topic, cid) { + if (!this.subs[topic]) + return false + + const index = this.subs[topic].indexOf(cid) + if (index < 0) + return false + + this.subs[topic].splice(index, 1) + + if (this.subs[topic].length == 0) { + delete this.subs[topic] + if (this.cachePubKeys.hasOwnProperty(topic)) + delete this.cachePubKeys[topic] + } + + return true + } + + /** + * Dispatch a notification to all clients + * who have subscribed to a topic + * @param {string} topic - topic + * @param {string} msg - content of the notification + */ + dispatch(topic, msg) { + if (!this.subs[topic]) + return + + for (let cid of this.subs[topic]) { + if (!this.conn[cid]) + continue + + try { + this.conn[cid].sendUTF(msg) + } catch(e) { + Logger.error(e, `NotificationsService.dispatch() : Error sending dispatch for ${topic} to client ${cid}`) + } + } + } + + /** + * Dispatch notifications for a new block + * @param {string} header - block header + */ + notifyBlock(header) { + try { + const data = { + op: 'block', + x: header + } + this.dispatch('block', JSON.stringify(data)) + } catch(e) { + Logger.error(e, `NotificationsService.notifyBlock()`) + } + } + + /** + * Dispatch notifications for a transaction + * + * Transaction notification operates within these constraints: + * 1. Notify each client ONCE of a relevant transaction + * 2. Maintain privacy of other parties when transactions are between clients + * + * Clients subscribe to a list of xpubs and addresses. Transactions identify + * address and xpub if available on inputs and outputs, omitting inputs and + * outputs for untracked addresses. + * + * Example: + * tx + * inputs + * addr1 + * xpub2 + * outputs + * xpub1 + * xpub2 + * addr2 + * xpub3 + * + * subs + * addr1: client1, client2 + * addr2: client1 + * xpub1: client1 + * xpub2: client2 + * xpub4: client3 + * + * client1: addr1, addr2, xpub1 + * client2: addr1, xpub2 + * client3: xpub4 + * + * tx -> client1 + * inputs + * addr1 + * outputs + * xpub1 + * addr2 + * + * tx -> client2 + * inputs + * addr1 + * xpub2 + * outputs + * xpub2 + * + * @param {object} tx - transaction + * + * @note Synchronous processing done by this method + * may become a bottleneck in the future if under heavy load. + * Split in multiple async calls might make sense. + */ + notifyTransaction(tx) { + try { + // Topics extracted from the transaction + const topics = {} + // Client subscriptions: {[cid]: [topic1, topic2, ...]} + const clients = {} + + // Extract topics from the inputs + for (let i in tx.inputs) { + let input = tx.inputs[i] + let topic = null + + if (input.prev_out) { + // Topic is either xpub or addr. Should it be both? + if (input.prev_out.xpub) { + topic = input.prev_out.xpub.m + } else if (input.prev_out.addr) { + topic = input.prev_out.addr + } + } + + if (this.subs[topic]) { + topics[topic] = true + // Add topic information to the input + input.topic = topic + } + } + + // Extract topics from the outputs + for (let o in tx.out) { + let output = tx.out[o] + let topic = null + + if (output.xpub) { + topic = output.xpub.m + } else if (output.addr) { + topic = output.addr + } + + if (this.subs[topic]) { + topics[topic] = true + // Add topic information to the output + output.topic = topic + } + } + + for (let topic in topics) { + for (let cid of this.subs[topic]) { + if (!clients[cid]) + clients[cid] = [] + if (clients[cid].indexOf(topic) == -1) + clients[cid].push(topic) + } + } + + // Tailor a transaction for each client + for (let cid in clients) { + const ctx = _.cloneDeep(tx) + ctx.inputs = [] + ctx.out = [] + + // List of topics relevant to this client + const clientTopics = clients[cid] + + // Check for topic information on inputs & outputs (added above) + for (let input of tx.inputs) { + const topic = input.topic + if (topic && clientTopics.indexOf(topic) > -1) { + const cin = _.cloneDeep(input) + delete cin.topic + if (this.cachePubKeys.hasOwnProperty(topic)) + cin.pubkey = this.cachePubKeys[topic] + ctx.inputs.push(cin) + } + } + + for (let output of tx.out) { + const topic = output.topic + if (topic && clientTopics.indexOf(topic) > -1) { + const cout = _.cloneDeep(output) + delete cout.topic + if (this.cachePubKeys.hasOwnProperty(topic)) + cout.pubkey = this.cachePubKeys[topic] + ctx.out.push(cout) + } + } + + // Move on if the custom transaction has no inputs or outputs + if (ctx.inputs.length == 0 && ctx.out.length == 0) + continue + + // Send custom transaction to client + const data = { + op: 'utx', + x: ctx + } + + try { + this.conn[cid].sendUTF(JSON.stringify(data)) + debug && Logger.error(`Sent ctx ${ctx.hash} to client ${cid}`) + } catch(e) { + Logger.error(e, `NotificationsService.notifyTransaction() : Trouble sending ctx to client ${cid}`) + } + } + + } catch(e) { + Logger.error(e, `NotificationsService.notifyTransaction()`) + } + } + + /** + * Dispatch notification for an authentication error + * @param {string} err - error + * @param {integer} cid - connection id + */ + notifyAuthError(err, cid) { + const data = { + op: 'error', + msg: err + } + + try { + this.conn[cid].sendUTF(JSON.stringify(data)) + debug && Logger.error(`Sent authentication error to client ${cid}`) + } catch(e) { + Logger.error(e, `NotificationsService.notifyAuthError() : Trouble sending authentication error to client ${cid}`) + } + } + + +} + +module.exports = NotificationsService diff --git a/accounts/status-rest-api.js b/accounts/status-rest-api.js new file mode 100644 index 0000000..8684d7e --- /dev/null +++ b/accounts/status-rest-api.js @@ -0,0 +1,56 @@ +/*! + * accounts/status-rest-api.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const Logger = require('../lib/logger') +const network = require('../lib/bitcoin/network') +const keys = require('../keys')[network.key] +const authMgr = require('../lib/auth/authorizations-manager') +const HttpServer = require('../lib/http-server/http-server') +const status = require('./status') + +const debugApi = !!(process.argv.indexOf('api-debug') > -1) + + +/** + * Status API endpoints + */ +class StatusRestApi { + + /** + * Constructor + * @param {pushtx.HttpServer} httpServer - HTTP server + */ + constructor(httpServer) { + this.httpServer = httpServer + + // Establish routes + this.httpServer.app.get( + `/${keys.prefixes.status}/`, + authMgr.checkHasAdminProfile.bind(authMgr), + this.getStatus.bind(this), + HttpServer.sendAuthError + ) + } + + /** + * Return information about the api + * @param {object} req - http request object + * @param {object} res - http response object + */ + async getStatus(req, res) { + try { + const currStatus = await status.getCurrent() + HttpServer.sendRawData(res, JSON.stringify(currStatus, null, 2)) + } catch(e) { + HttpServer.sendError(res, e) + } finally { + debugApi && Logger.info(`Completed GET /status`) + } + } + +} + +module.exports = StatusRestApi diff --git a/accounts/status.js b/accounts/status.js new file mode 100644 index 0000000..5c5fffa --- /dev/null +++ b/accounts/status.js @@ -0,0 +1,52 @@ +/*! + * accounts/status.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const util = require('../lib/util') +const db = require('../lib/db/mysql-db-wrapper') + + +/** + * Singleton providing information about the accounts endpoints + */ +class Status { + + /** + * Constructor + */ + constructor() { + this.t0 = Date.now() + this.clients = 0 + this.sessions = 0 + this.maxConn = 0 + } + + /** + * Get current status + * @returns {Promise - object} status object + */ + async getCurrent() { + const uptime = util.timePeriod((Date.now() - this.t0) / 1000, false) + const memory = `${util.toMb(process.memoryUsage().rss)} MiB` + + // Get highest block processed by the tracker + const highest = await db.getHighestBlock() + const dbMaxHeight = highest.blockHeight + + return { + uptime: uptime, + memory: memory, + ws: { + clients: this.clients, + sessions: this.sessions, + max: this.maxConn + }, + blocks: dbMaxHeight + } + } + +} + +module.exports = new Status() diff --git a/accounts/support-rest-api.js b/accounts/support-rest-api.js new file mode 100644 index 0000000..1544a0e --- /dev/null +++ b/accounts/support-rest-api.js @@ -0,0 +1,384 @@ +/*! + * accounts/support-rest-api.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const heapdump = require('heapdump') +const validator = require('validator') +const bodyParser = require('body-parser') +const errors = require('../lib/errors') +const Logger = require('../lib/logger') +const authMgr = require('../lib/auth/authorizations-manager') +const HttpServer = require('../lib/http-server/http-server') +const network = require('../lib/bitcoin/network') +const hdaService = require('../lib/bitcoin/hd-accounts-service') +const addrService = require('../lib/bitcoin/addresses-service') +const HdAccountInfo = require('../lib/wallet/hd-account-info') +const AddressInfo = require('../lib/wallet/address-info') +const apiHelper = require('./api-helper') +const keys = require('../keys')[network.key] + +const debugApi = !!(process.argv.indexOf('api-debug') > -1) + + +/** + * Support API endpoints + */ +class SupportRestApi { + + /** + * Constructor + * @param {pushtx.HttpServer} httpServer - HTTP server + */ + constructor(httpServer) { + this.httpServer = httpServer + + // Establish routes + const urlencodedParser = bodyParser.urlencoded({ extended: true }) + + this.httpServer.app.get( + `/${keys.prefixes.support}/address/:addr/info`, + authMgr.checkHasAdminProfile.bind(authMgr), + this.validateAddress.bind(this), + this.getAddressInfo.bind(this), + HttpServer.sendAuthError + ) + + this.httpServer.app.get( + `/${keys.prefixes.support}/address/:addr/rescan`, + authMgr.checkHasAdminProfile.bind(authMgr), + this.validateAddress.bind(this), + this.getAddressRescan.bind(this), + HttpServer.sendAuthError + ) + + this.httpServer.app.get( + `/${keys.prefixes.support}/xpub/:xpub/info`, + authMgr.checkHasAdminProfile.bind(authMgr), + this.validateArgsGetXpubInfo.bind(this), + this.getXpubInfo.bind(this), + HttpServer.sendAuthError + ) + + this.httpServer.app.get( + `/${keys.prefixes.support}/xpub/:xpub/rescan`, + authMgr.checkHasAdminProfile.bind(authMgr), + this.validateArgsGetXpubRescan.bind(this), + this.getXpubRescan.bind(this), + HttpServer.sendAuthError + ) + + this.httpServer.app.get( + `/${keys.prefixes.support}/dump/heap`, + authMgr.checkHasAdminProfile.bind(authMgr), + this.getHeapDump.bind(this), + HttpServer.sendAuthError + ) + + this.httpServer.app.get( + `/${keys.prefixes.support}/pairing`, + authMgr.checkHasAdminProfile.bind(authMgr), + this.getPairing.bind(this), + HttpServer.sendAuthError + ) + } + + /** + * Retrieve information for a given address + * @param {object} req - http request object + * @param {object} res - http response object + */ + async getAddressInfo(req, res) { + try { + // Parse the entities passed as url params + const entities = apiHelper.parseEntities(req.params.addr).addrs + if (entities.length == 0) + return HttpServer.sendError(res, errors.address.INVALID) + + const address = entities[0] + const info = new AddressInfo(address) + await info.loadInfoExtended() + await info.loadTransactions() + await info.loadUtxos() + const ret = this._formatAddressInfoResult(info) + HttpServer.sendRawData(res, ret) + + } catch(e) { + HttpServer.sendError(res, errors.generic.GEN) + + } finally { + debugApi && Logger.info(`Completed GET /support/address/${req.params.addr}/info`) + } + } + + /** + * Format response to be returned + * for calls to getAddressInfo + * @param {AddressInfo} info + * @returns {string} return the json to be sent as a response + */ + _formatAddressInfoResult(info) { + const res = info.toPojoExtended() + /*res._endpoints = [] + + if (info.tracked) { + res._endpoints.push({ + task: 'Rescan this address from remote sources', + url: `/${keys.prefixes.support}/address/${info.address}/rescan` + }) + } + + if (info.xpub != null) { + res._endpoints.push({ + task: 'Get information about the HD account that owns this address', + url: `/${keys.prefixes.support}/xpub/${info.xpub}/info` + }) + + res._endpoints.push({ + task: 'Rescan the whole HD account that owns this address', + url: `/${keys.prefixes.support}/xpub/${info.xpub}/rescan` + }) + }*/ + + return JSON.stringify(res, null, 2) + } + + + + /** + * Rescan the blockchain for a given address + * @param {object} req - http request object + * @param {object} res - http response object + */ + async getAddressRescan(req, res) { + try { + // Parse the entities passed as url params + const entities = apiHelper.parseEntities(req.params.addr).addrs + if (entities.length == 0) + return HttpServer.sendError(res, errors.address.INVALID) + + const address = entities[0] + + const ret = { + status: 'Rescan complete', + /*_endpoints: [{ + task: 'Get updated information about this address', + url: `/${keys.prefixes.support}/address/${address}/info` + }]*/ + } + + await addrService.rescan(address) + HttpServer.sendRawData(res, JSON.stringify(ret, null, 2)) + + } catch(e) { + HttpServer.sendError(res, errors.generic.GEN) + + } finally { + debugApi && Logger.info(`Completed GET /support/address/${req.params.addr}/rescan`) + } + } + + /** + * Retrieve information for a given hd account + * @param {object} req - http request object + * @param {object} res - http response object + */ + async getXpubInfo(req, res) { + try { + // Parse the entities passed as url params + const entities = apiHelper.parseEntities(req.params.xpub).xpubs + if (entities.length == 0) + return HttpServer.sendError(res, errors.xpub.INVALID) + + const xpub = entities[0] + let info + + try { + info = new HdAccountInfo(xpub) + await info.loadInfo() + const ret = this._formatXpubInfoResult(info) + HttpServer.sendRawData(res, ret) + } catch(e) { + if(e == errors.db.ERROR_NO_HD_ACCOUNT) { + const ret = this._formatXpubInfoResult(info) + HttpServer.sendRawData(res, ret) + } else { + HttpServer.sendError(res, errors.generic.GEN) + } + } + + } catch(e) { + HttpServer.sendError(res, errors.generic.GEN) + + } finally { + debugApi && Logger.info(`Completed GET /support/xpub/${req.params.xpub}/info`) + } + } + + /** + * Format response to be returned + * for calls to getXpubInfo + * @param {HdAccountInfo} info + * @returns {string} return the json to be sent as a response + */ + _formatXpubInfoResult(info) { + const res = info.toPojoExtended() + + /*res._endpoints = [{ + task: 'Rescan the whole HD account from remote sources', + url: `/${keys.prefixes.support}/xpub/${info.xpub}/rescan` + }]*/ + + return JSON.stringify(res, null, 2) + } + + /** + * Rescan the blockchain for a given address + * @param {object} req - http request object + * @param {object} res - http response object + */ + async getXpubRescan(req, res) { + try { + // Parse the entities passed as url params + const entities = apiHelper.parseEntities(req.params.xpub).xpubs + if (entities.length == 0) + return HttpServer.sendError(res, errors.xpub.INVALID) + + const xpub = entities[0] + + const ret = { + status: 'Rescan complete', + /*_endpoints: [{ + task: 'Get updated information about this HD account', + url: `/${keys.prefixes.support}/xpub/${xpub}/info` + }]*/ + } + + const gapLimit = req.query.gap != null ? parseInt(req.query.gap) : 0 + const startIndex = req.query.startidx != null ? parseInt(req.query.startidx) : 0 + + try { + await hdaService.rescan(xpub, gapLimit, startIndex) + HttpServer.sendRawData(res, JSON.stringify(ret, null, 2)) + } catch(e) { + if (e == errors.db.ERROR_NO_HD_ACCOUNT) { + ret.status = 'Error: Not tracking xpub' + HttpServer.sendRawData(res, JSON.stringify(ret, null, 2)) + } else if (e == errors.xpub.OVERLAP) { + ret.status = 'Error: Rescan in progress' + HttpServer.sendRawData(res, JSON.stringify(ret, null, 2)) + } else { + ret.status = 'Rescan Error' + Logger.error(e, 'SupportRestApi.getXpubRescan() : Support rescan error') + HttpServer.sendError(res, JSON.stringify(ret, null, 2)) + } + } + + } catch(e) { + HttpServer.sendError(res, errors.generic.GEN) + + } finally { + debugApi && Logger.info(`Completed GET /support/xpub/${req.params.xpub}/rescan`) + } + } + + /** + * Get a dump of the heap + * and store it on the filesystem + */ + async getHeapDump(req, res) { + try { + heapdump.writeSnapshot(function(err, filename) { + Logger.info(`Dump written to ${filename}`) + }) + HttpServer.sendOk(res) + } catch(e) { + const ret = { + status: 'error' + } + Logger.error(e, 'SupportRestApi.getHeapDump() : Support head dump error') + HttpServer.sendError(res, JSON.stringify(ret, null, 2)) + } finally { + debugApi && Logger.info(`Completed GET /dump/heap`) + } + } + + /** + * Get pairing info + */ + async getPairing(req, res) { + try { + const ret = { + 'pairing': { + 'type': 'dojo.api', + 'version': keys.dojoVersion, + 'apikey': keys.auth.strategies.localApiKey.apiKeys[0] + } + } + HttpServer.sendRawData(res, JSON.stringify(ret, null, 2)) + } catch(e) { + const ret = { + status: 'error' + } + Logger.error(e, 'SupportRestApi.getPairing() : Support pairing error') + HttpServer.sendError(res, JSON.stringify(ret, null, 2)) + } finally { + debugApi && Logger.info(`Completed GET /pairing`) + } + } + + /** + * Validate arguments related to GET xpub info requests + * @param {object} req - http request object + * @param {object} res - http response object + * @param {function} next - next express middleware + */ + validateArgsGetXpubInfo(req, res, next) { + const isValidXpub = validator.isAlphanumeric(req.params.xpub) + + if (!isValidXpub) { + HttpServer.sendError(res, errors.body.INVDATA) + Logger.error(null, `SupportRestApi.validateArgsGetXpubInfo() : Invalid xpub ${req.params.xpub}`) + } else { + next() + } + } + + /** + * Validate arguments related to GET xpub rescan requests + * @param {object} req - http request object + * @param {object} res - http response object + * @param {function} next - next express middleware + */ + validateArgsGetXpubRescan(req, res, next) { + const isValidXpub = validator.isAlphanumeric(req.params.xpub) + const isValidGap = !req.query.gap || validator.isInt(req.query.gap) + + if (!(isValidXpub && isValidGap)) { + HttpServer.sendError(res, errors.body.INVDATA) + Logger.error(null, 'SupportRestApi.validateArgsGetXpubRescan() : Invalid arguments') + } else { + next() + } + } + + /** + * Validate arguments related to addresses requests + * @param {object} req - http request object + * @param {object} res - http response object + * @param {function} next - next express middleware + */ + validateAddress(req, res, next) { + const isValidAddress = validator.isAlphanumeric(req.params.addr) + + if (!isValidAddress) { + HttpServer.sendError(res, errors.body.INVDATA) + Logger.error(null, `SupportRestApi.validateAddress() : Invalid address ${req.params.addr}`) + } else { + next() + } + } +} + +module.exports = SupportRestApi diff --git a/accounts/transactions-rest-api.js b/accounts/transactions-rest-api.js new file mode 100644 index 0000000..fdfa425 --- /dev/null +++ b/accounts/transactions-rest-api.js @@ -0,0 +1,156 @@ +/*! + * accounts/transactions-fees-rest-api.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const validator = require('validator') +const Logger = require('../lib/logger') +const errors = require('../lib/errors') +const rpcTxns = require('../lib/bitcoind-rpc/transactions') +const authMgr = require('../lib/auth/authorizations-manager') +const HttpServer = require('../lib/http-server/http-server') +const walletService = require('../lib/wallet/wallet-service') +const network = require('../lib/bitcoin/network') +const apiHelper = require('./api-helper') +const keys = require('../keys')[network.key] + +const debugApi = !!(process.argv.indexOf('api-debug') > -1) + + +/** + * Transactions API endpoints + */ +class TransactionsRestApi { + + /** + * Constructor + * @param {pushtx.HttpServer} httpServer - HTTP server + */ + constructor(httpServer) { + this.httpServer = httpServer + + // Establish routes + this.httpServer.app.get( + '/tx/:txid', + authMgr.checkAuthentication.bind(authMgr), + this.validateArgsGetTransaction.bind(this), + this.getTransaction.bind(this), + HttpServer.sendAuthError + ) + + this.httpServer.app.get( + '/txs', + authMgr.checkAuthentication.bind(authMgr), + apiHelper.validateEntitiesParams.bind(apiHelper), + this.validateArgsGetTransactions.bind(this), + this.getTransactions.bind(this), + HttpServer.sendAuthError + ) + } + + /** + * Retrieve the transaction for a given tiid + * @param {object} req - http request object + * @param {object} res - http response object + */ + async getTransaction(req, res) { + try { + const tx = await rpcTxns.getTransaction(req.params.txid, req.query.fees) + const ret = JSON.stringify(tx, null, 2) + HttpServer.sendRawData(res, ret) + } catch(e) { + HttpServer.sendError(res, e) + } finally { + const strParams = `${req.query.fees ? req.query.fees : ''}` + debugApi && Logger.info(`Completed GET /tx/${req.params.txid} ${strParams}`) + } + } + + + /** + * Retrieve a page of transactions related to a wallet + * @param {object} req - http request object + * @param {object} res - http response object + */ + async getTransactions(req, res) { + try { + // Check request params + if (!apiHelper.checkEntitiesParams(req.query)) + return HttpServer.sendError(res, errors.multiaddr.NOACT) + + // Parse params + const active = apiHelper.parseEntities(req.query.active) + const page = req.query.page != null ? parseInt(req.query.page) : 0 + const count = req.query.count != null ? parseInt(req.query.count) : keys.multiaddr.transactions + + const result = await walletService.getWalletTransactions(active, page, count) + const ret = JSON.stringify(result, null, 2) + HttpServer.sendRawData(res, ret) + + } catch(e) { + HttpServer.sendError(res, e) + + } finally { + const strParams = + `${req.query.active} \ + ${req.query.page ? req.query.page : ''} \ + ${req.query.count ? req.query.count : ''}` + + debugApi && Logger.info(`Completed GET /txs ${strParams}`) + } + } + + /** + * Validate arguments of /tx requests + * @param {object} req - http request object + * @param {object} res - http response object + * @param {function} next - next express middleware + */ + validateArgsGetTransaction(req, res, next) { + const isValidTxid = validator.isHash(req.params.txid, 'sha256') + + const isValidFees = + !req.query.fees + || validator.isAlphanumeric(req.query.fees) + + if (!(isValidTxid && isValidFees)) { + HttpServer.sendError(res, errors.body.INVDATA) + Logger.error( + req.params, + 'HeadersRestApi.validateArgsGetTransaction() : Invalid arguments' + ) + Logger.error(req.query, '') + } else { + next() + } + } + + /** + * Validate arguments of /txs requests + * @param {object} req - http request object + * @param {object} res - http response object + * @param {function} next - next express middleware + */ + validateArgsGetTransactions(req, res, next) { + const isValidPage = + !req.query.page + || validator.isInt(req.query.page) + + const isValidCount = + !req.query.count + || validator.isInt(req.query.count) + + if (!(isValidPage && isValidCount)) { + HttpServer.sendError(res, errors.body.INVDATA) + Logger.error( + req.query, + 'HeadersRestApi.validateArgsGetTransactions() : Invalid arguments' + ) + } else { + next() + } + } +} + +module.exports = TransactionsRestApi diff --git a/accounts/unspent-rest-api.js b/accounts/unspent-rest-api.js new file mode 100644 index 0000000..279fce2 --- /dev/null +++ b/accounts/unspent-rest-api.js @@ -0,0 +1,136 @@ +/*! + * accounts/unspent-rest-api.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const bodyParser = require('body-parser') +const Logger = require('../lib/logger') +const errors = require('../lib/errors') +const walletService = require('../lib/wallet/wallet-service') +const authMgr = require('../lib/auth/authorizations-manager') +const HttpServer = require('../lib/http-server/http-server') +const apiHelper = require('./api-helper') + +const debugApi = !!(process.argv.indexOf('api-debug') > -1) + + +/** + * Unspent API endpoints + */ +class UnspentRestApi { + + /** + * Constructor + * @param {pushtx.HttpServer} httpServer - HTTP server + */ + constructor(httpServer) { + this.httpServer = httpServer + + // Establish routes + const urlencodedParser = bodyParser.urlencoded({ extended: true }) + + this.httpServer.app.get( + '/unspent', + authMgr.checkAuthentication.bind(authMgr), + apiHelper.validateEntitiesParams.bind(apiHelper), + this.getUnspent.bind(this), + HttpServer.sendAuthError + ) + + this.httpServer.app.post( + '/unspent', + urlencodedParser, + authMgr.checkAuthentication.bind(authMgr), + apiHelper.validateEntitiesParams.bind(apiHelper), + this.postUnspent.bind(this), + HttpServer.sendAuthError + ) + } + + /** + * Handle unspent GET request + * @param {object} req - http request object + * @param {object} res - http response object + */ + async getUnspent(req, res) { + // Check request params + if (!apiHelper.checkEntitiesParams(req.query)) + return HttpServer.sendError(res, errors.multiaddr.NOACT) + + // Parse params + const entities = apiHelper.parseEntitiesParams(req.query) + + try { + const result = await walletService.getWalletUtxos( + entities.active, + entities.legacy, + entities.bip49, + entities.bip84, + entities.pubkey + ) + + const ret = JSON.stringify(result, null, 2) + HttpServer.sendRawData(res, ret) + + } catch(e) { + HttpServer.sendError(res, e) + + } finally { + if (debugApi) { + const strParams = + `${req.query.active ? req.query.active : ''} \ + ${req.query.new ? req.query.new : ''} \ + ${req.query.pubkey ? req.query.pubkey : ''} \ + ${req.query.bip49 ? req.query.bip49 : ''} \ + ${req.query.bip84 ? req.query.bip84 : ''}` + + Logger.info(`Completed GET /unspent ${strParams}`) + } + } + } + + /** + * Handle unspent POST request + * @param {object} req - http request object + * @param {object} res - http response object + */ + async postUnspent(req, res) { + // Check request params + if (!apiHelper.checkEntitiesParams(req.body)) + return HttpServer.sendError(res, errors.multiaddr.NOACT) + + // Parse params + const entities = apiHelper.parseEntitiesParams(req.body) + + try { + const result = await walletService.getWalletUtxos( + entities.active, + entities.legacy, + entities.bip49, + entities.bip84, + entities.pubkey + ) + + HttpServer.sendOkDataOnly(res, result) + + } catch(e) { + HttpServer.sendError(res, e) + + } finally { + if (debugApi) { + const strParams = + `${req.body.active ? req.body.active : ''} \ + ${req.body.new ? req.body.new : ''} \ + ${req.body.pubkey ? req.body.pubkey : ''} \ + ${req.body.bip49 ? req.body.bip49 : ''} \ + ${req.body.bip84 ? req.body.bip84 : ''}` + + Logger.info(`Completed POST /unspent ${strParams}`) + } + } + } + +} + +module.exports = UnspentRestApi \ No newline at end of file diff --git a/accounts/xpub-rest-api.js b/accounts/xpub-rest-api.js new file mode 100644 index 0000000..0751247 --- /dev/null +++ b/accounts/xpub-rest-api.js @@ -0,0 +1,450 @@ +/*! + * accounts/xpub-rest-api.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const validator = require('validator') +const bodyParser = require('body-parser') +const errors = require('../lib/errors') +const network = require('../lib/bitcoin/network') +const Logger = require('../lib/logger') +const db = require('../lib/db/mysql-db-wrapper') +const hdaHelper = require('../lib/bitcoin/hd-accounts-helper') +const hdaService = require('../lib/bitcoin/hd-accounts-service') +const RpcClient = require('../lib/bitcoind-rpc/rpc-client') +const HdAccountInfo = require('../lib/wallet/hd-account-info') +const authMgr = require('../lib/auth/authorizations-manager') +const HttpServer = require('../lib/http-server/http-server') + +const debugApi = !!(process.argv.indexOf('api-debug') > -1) +const gap = require('../keys/')[network.key].gap + + +/** + * XPub API endpoints + */ +class XPubRestApi { + + /** + * Constructor + * @param {pushtx.HttpServer} httpServer - HTTP server + */ + constructor(httpServer) { + this.httpServer = httpServer + + // Initialize the rpc client + this.rpcClient = new RpcClient() + + // Establish routes + const urlencodedParser = bodyParser.urlencoded({ extended: true }) + + this.httpServer.app.post( + '/xpub/', + urlencodedParser, + authMgr.checkAuthentication.bind(authMgr), + this.validateArgsPostXpub.bind(this), + this.postXpub.bind(this), + HttpServer.sendAuthError + ) + + this.httpServer.app.get( + '/xpub/:xpub', + authMgr.checkAuthentication.bind(authMgr), + this.validateArgsGetXpub.bind(this), + this.getXpub.bind(this), + HttpServer.sendAuthError + ) + + this.httpServer.app.post( + '/xpub/:xpub/lock', + urlencodedParser, + authMgr.checkAuthentication.bind(authMgr), + this.validateArgsPostLockXpub.bind(this), + this.postLockXpub.bind(this), + HttpServer.sendAuthError + ) + + this.httpServer.app.delete( + '/xpub/:xpub', + urlencodedParser, + authMgr.checkAuthentication.bind(authMgr), + this.validateArgsDeleteXpub.bind(this), + this.deleteXpub.bind(this), + HttpServer.sendAuthError + ) + } + + + /** + * Handle xPub POST request + * @param {object} req - http request object + * @param {object} res - http response object + */ + async postXpub(req, res) { + try { + let xpub + + // Check request arguments + if (!req.body) + return HttpServer.sendError(res, errors.body.NODATA) + + if (!req.body.xpub) + return HttpServer.sendError(res, errors.body.NOXPUB) + + if (!req.body.type) + return HttpServer.sendError(res, errors.body.NOTYPE) + + // Extracts arguments + const argXpub = req.body.xpub + const argSegwit = req.body.segwit + const argAction = req.body.type + const argForceOverride = req.body.force + + // Translate xpub if needed + try { + const ret = this.xlatHdAccount(argXpub, true) + xpub = ret.xpub + } catch(e) { + return HttpServer.sendError(res, e) + } + + // Define the derivation scheme + let scheme = hdaHelper.BIP44 + + if (argSegwit) { + const segwit = argSegwit.toLowerCase() + if (segwit == 'bip49') + scheme = hdaHelper.BIP49 + else if (segwit == 'bip84') + scheme = hdaHelper.BIP84 + else + return HttpServer.sendError(res, errors.xpub.SEGWIT) + } + + // Define default forceOverride if needed + const forceOverride = argForceOverride ? argForceOverride : false + + // Process action + if (argAction == 'new') { + // New hd account + try { + await hdaService.createHdAccount(xpub, scheme) + HttpServer.sendOk(res) + } catch(e) { + HttpServer.sendError(res, e) + } + } else if (argAction == 'restore') { + // Restore hd account + try { + await hdaService.restoreHdAccount(xpub, scheme, forceOverride) + HttpServer.sendOk(res) + } catch(e) { + HttpServer.sendError(res, e) + } + } else { + // Unknown action + return HttpServer.sendError(res, errors.body.INVTYPE) + } + + } catch(e) { + return HttpServer.sendError(res, errors.generic.GEN) + + } finally { + debugApi && Logger.info(`Completed POST /xpub ${req.body.xpub}`) + } + } + + /** + * Handle xPub GET request + * @param {object} req - http request object + * @param {object} res - http response object + */ + async getXpub(req, res) { + try { + let xpub + + // Extracts arguments + const argXpub = req.params.xpub + + // Translate xpub if needed + try { + const ret = this.xlatHdAccount(argXpub) + xpub = ret.xpub + } catch(e) { + return HttpServer.sendError(res, e) + } + + const hdaInfo = new HdAccountInfo(xpub) + + const info = await hdaInfo.loadInfo() + if (!info) + return Promise.reject() + + const ret = { + balance: hdaInfo.finalBalance, + unused: { + external: hdaInfo.accountIndex, + internal: hdaInfo.changeIndex, + }, + derivation: hdaInfo.derivation, + created: hdaInfo.created + } + + HttpServer.sendOkData(res, ret) + + } catch(e) { + Logger.error(e, 'XpubRestApi.getXpub()') + HttpServer.sendError(res, e) + + } finally { + debugApi && Logger.info(`Completed GET /xpub/${req.params.xpub}`) + } + } + + /** + * Handle Lock XPub POST request + * @param {object} req - http request object + * @param {object} res - http response object + */ + async postLockXpub(req, res) { + try { + let xpub, scheme + + // Check request arguments + if (!req.body) + return HttpServer.sendError(res, errors.body.NODATA) + + if (!req.body.address) + return HttpServer.sendError(res, errors.body.NOADDR) + + if (!req.body.signature) + return HttpServer.sendError(res, errors.body.NOSIG) + + if (!req.body.message) + return HttpServer.sendError(res, errors.body.NOMSG) + + if (!(req.body.message == 'lock' || req.body.message == 'unlock')) + return HttpServer.sendError(res, errors.sig.INVMSG) + + // Extract arguments + const argXpub = req.params.xpub + const argAddr = req.body.address + const argSig = req.body.signature + const argMsg = req.body.message + + // Translate xpub if needed + try { + const ret = this.xlatHdAccount(argXpub) + xpub = ret.xpub + scheme = ret.scheme + } catch(e) { + return HttpServer.sendError(res, e) + } + + try { + // Check the signature and process the request + await hdaService.verifyXpubSignature(xpub, argAddr, argSig, argMsg, scheme) + const lock = (argMsg == 'unlock') ? false : true + const ret = await hdaService.lockHdAccount(xpub, lock) + HttpServer.sendOkData(res, {derivation: ret}) + } catch(e) { + HttpServer.sendError(res, errors.generic.GEN) + } + + } finally { + debugApi && Logger.info(`Completed POST /xpub/${req.params.xpub}/lock`) + } + } + + /** + * Handle XPub DELETE request + * @param {object} req - http request object + * @param {object} res - http response object + */ + async deleteXpub(req, res) { + try { + let xpub, scheme + + // Check request arguments + if (!req.body) + return HttpServer.sendError(res, errors.body.NODATA) + + if (!req.body.address) + return HttpServer.sendError(res, errors.body.NOADDR) + + if (!req.body.signature) + return HttpServer.sendError(res, errors.body.NOSIG) + + // Extract arguments + const argXpub = req.params.xpub + const argAddr = req.body.address + const argSig = req.body.signature + + // Translate xpub if needed + try { + const ret = this.xlatHdAccount(argXpub) + xpub = ret.xpub + scheme = ret.scheme + } catch(e) { + return HttpServer.sendError(res, e) + } + + try { + // Check the signature and process the request + await hdaService.verifyXpubSignature(xpub, argAddr, argSig, argXpub, scheme) + await hdaService.deleteHdAccount(xpub) + HttpServer.sendOk(res) + } catch(e) { + HttpServer.sendError(res, e) + } + + } catch(e) { + HttpServer.sendError(res, errors.generic.GEN) + + } finally { + debugApi && Logger.info(`Completed DELETE /xpub/${req.params.xpub}`) + } + } + + /** + * Translate a ypub/zpub into a xpub + * @param {string} origXpub - original hd account + * @param {boolean} trace - flag indicating if we shoudl trace the conversion in our logs + * @returns {object} returns an object {xpub: , scheme: } + * or raises an exception + */ + xlatHdAccount(origXpub, trace) { + try { + // Translate xpub if needed + let xpub = origXpub + let scheme = hdaHelper.BIP44 + + const isYpub = hdaHelper.isYpub(origXpub) + const isZpub = hdaHelper.isZpub(origXpub) + + if (isYpub || isZpub) { + xpub = hdaHelper.xlatXPUB(origXpub) + scheme = isYpub ? hdaHelper.BIP49 : hdaHelper.BIP84 + if (trace) { + Logger.info('Converted: ' + origXpub) + Logger.info('Resulting xpub: ' + xpub) + } + } + + if (!hdaHelper.isValid(xpub)) + throw errors.xpub.INVALID + + return { + xpub: xpub, + scheme: scheme + } + + } catch(e) { + const err = (e == errors.xpub.PRIVKEY) ? e : errors.xpub.INVALID + throw err + } + } + + /** + * Validate arguments of postXpub requests + * @param {object} req - http request object + * @param {object} res - http response object + * @param {function} next - next express middleware + */ + validateArgsPostXpub(req, res, next) { + const isValidXpub = validator.isAlphanumeric(req.body.xpub) + + const isValidSegwit = + !req.body.segwit + || validator.isAlphanumeric(req.body.segwit) + + const isValidType = + !req.body.type + || validator.isAlphanumeric(req.body.type) + + const isValidForce = + !req.body.force + || validator.isAlphanumeric(req.body.force) + + if (!(isValidXpub && isValidSegwit && isValidType && isValidForce)) { + HttpServer.sendError(res, errors.body.INVDATA) + Logger.error( + req.body, + 'XpubRestApi.validateArgsPostXpub() : Invalid arguments' + ) + } else { + next() + } + } + + /** + * Validate arguments of getXpub requests + * @param {object} req - http request object + * @param {object} res - http response object + * @param {function} next - next express middleware + */ + validateArgsGetXpub(req, res, next) { + const isValidXpub = validator.isAlphanumeric(req.params.xpub) + + if (!isValidXpub) { + HttpServer.sendError(res, errors.body.INVDATA) + Logger.error( + req.params.xpub, + 'XpubRestApi.validateArgsGetXpub() : Invalid arguments' + ) + } else { + next() + } + } + + /** + * Validate arguments of postLockXpub requests + * @param {object} req - http request object + * @param {object} res - http response object + * @param {function} next - next express middleware + */ + validateArgsPostLockXpub(req, res, next) { + const isValidXpub = validator.isAlphanumeric(req.params.xpub) + const isValidAddr = validator.isAlphanumeric(req.body.address) + const isValidSig = validator.isBase64(req.body.signature) + const isValidMsg = validator.isAlphanumeric(req.body.message) + + if (!(isValidXpub && isValidAddr && isValidSig && isValidMsg)) { + HttpServer.sendError(res, errors.body.INVDATA) + Logger.error( + req.params, + 'XpubRestApi.validateArgsPostLockXpub() : Invalid arguments' + ) + Logger.error(req.body, '') + } else { + next() + } + } + + /** + * Validate arguments of deleteXpub requests + * @param {object} req - http request object + * @param {object} res - http response object + * @param {function} next - next express middleware + */ + validateArgsDeleteXpub(req, res, next) { + const isValidXpub = validator.isAlphanumeric(req.params.xpub) + const isValidAddr = validator.isAlphanumeric(req.body.address) + const isValidSig = validator.isBase64(req.body.signature) + + if (!(isValidXpub && isValidAddr && isValidSig)) { + HttpServer.sendError(res, errors.body.INVDATA) + Logger.error( + req.params, + 'XpubRestApi.validateArgsDeleteXpub() : Invalid arguments' + ) + Logger.error(req.body, '') + } else { + next() + } + } + +} + +module.exports = XPubRestApi diff --git a/db-scripts/1_db.sql b/db-scripts/1_db.sql new file mode 100644 index 0000000..0095fc5 --- /dev/null +++ b/db-scripts/1_db.sql @@ -0,0 +1,212 @@ +# Database tables + +# Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + + +# Naming conventions +# 1. Table names are lowercase plural +# 2. Join table names are snake_case plural +# 3. Column names have a table prefix +# 4. Foreign key names match primary key of foreign table + + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!40101 SET NAMES utf8 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Table structure for table `addresses` +-- + +DROP TABLE IF EXISTS `addresses`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `addresses` ( + `addrID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `addrAddress` varchar(74) DEFAULT NULL, + PRIMARY KEY (`addrID`), + UNIQUE KEY `addrAddress` (`addrAddress`) +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `banned_addresses` +-- + +DROP TABLE IF EXISTS `banned_addresses`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `banned_addresses` ( + `bannedAddressId` int(11) NOT NULL AUTO_INCREMENT, + `addrAddress` varchar(35) NOT NULL, + PRIMARY KEY (`bannedAddressId`), + UNIQUE KEY `banned_addresses_addresses` (`addrAddress`) +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `blocks` +-- + +DROP TABLE IF EXISTS `blocks`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `blocks` ( + `blockID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `blockHash` char(64) NOT NULL DEFAULT '', + `blockParent` int(10) unsigned DEFAULT NULL, + `blockHeight` int(10) unsigned NOT NULL DEFAULT '0', + `blockTime` int(10) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`blockID`), + UNIQUE KEY `blockHash` (`blockHash`), + KEY `blockParent` (`blockParent`), + KEY `blockHeight` (`blockHeight`), + CONSTRAINT `blocks_ibfk_1` FOREIGN KEY (`blockParent`) REFERENCES `blocks` (`blockID`) ON DELETE SET NULL ON UPDATE NO ACTION +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `hd` +-- + +DROP TABLE IF EXISTS `hd`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `hd` ( + `hdID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `hdXpub` char(112) DEFAULT NULL, + `hdCreated` int(10) unsigned NOT NULL DEFAULT '0', + `hdType` smallint(5) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`hdID`), + UNIQUE KEY `hdXpub` (`hdXpub`), + KEY `hdCreated` (`hdCreated`) +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `hd_addresses` +-- + +DROP TABLE IF EXISTS `hd_addresses`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `hd_addresses` ( + `hdAddrID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `hdID` int(10) unsigned NOT NULL DEFAULT '0', + `addrID` int(10) unsigned NOT NULL DEFAULT '0', + `hdAddrChain` smallint(5) unsigned NOT NULL DEFAULT '0', + `hdAddrIndex` int(10) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`hdAddrID`), + UNIQUE KEY `hdID_2` (`hdID`,`addrID`), + KEY `hdID` (`hdID`), + KEY `addrID` (`addrID`), + CONSTRAINT `hd_addresses_ibfk_1` FOREIGN KEY (`hdID`) REFERENCES `hd` (`hdID`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `hd_addresses_ibfk_2` FOREIGN KEY (`addrID`) REFERENCES `addresses` (`addrID`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `inputs` +-- + +DROP TABLE IF EXISTS `inputs`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `inputs` ( + `inID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `outID` int(10) unsigned NOT NULL DEFAULT '0', + `txnID` int(10) unsigned NOT NULL DEFAULT '0', + `inIndex` int(10) unsigned NOT NULL DEFAULT '0', + `inSequence` int(10) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`inID`), + UNIQUE KEY `txnID_2` (`txnID`,`inIndex`), + KEY `outID` (`outID`), + KEY `txnID` (`txnID`), + CONSTRAINT `inputs_ibfk_1` FOREIGN KEY (`txnID`) REFERENCES `transactions` (`txnID`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `inputs_ibfk_2` FOREIGN KEY (`outID`) REFERENCES `outputs` (`outID`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `outputs` +-- + +DROP TABLE IF EXISTS `outputs`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `outputs` ( + `outID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `txnID` int(10) unsigned NOT NULL DEFAULT '0', + `addrID` int(10) unsigned NOT NULL DEFAULT '0', + `outIndex` int(10) unsigned NOT NULL DEFAULT '0', + `outAmount` bigint(20) unsigned NOT NULL DEFAULT '0', + `outScript` varchar(20000) NOT NULL DEFAULT '', + PRIMARY KEY (`outID`), + UNIQUE KEY `txnID_2` (`txnID`,`addrID`,`outIndex`), + KEY `txnID` (`txnID`), + KEY `addrID` (`addrID`), + CONSTRAINT `outputs_ibfk_1` FOREIGN KEY (`txnID`) REFERENCES `transactions` (`txnID`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `outputs_ibfk_2` FOREIGN KEY (`addrID`) REFERENCES `addresses` (`addrID`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `transactions` +-- + +DROP TABLE IF EXISTS `transactions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `transactions` ( + `txnID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `txnTxid` char(64) DEFAULT NULL, + `txnCreated` int(10) unsigned NOT NULL DEFAULT '0', + `txnVersion` int(10) unsigned NOT NULL DEFAULT '0', + `txnLocktime` int(10) unsigned NOT NULL DEFAULT '0', + `blockID` int(10) unsigned DEFAULT NULL, + PRIMARY KEY (`txnID`), + UNIQUE KEY `txnTxid` (`txnTxid`), + KEY `txnCreated` (`txnCreated`), + KEY `blockID` (`blockID`), + CONSTRAINT `transactions_ibfk_1` FOREIGN KEY (`blockID`) REFERENCES `blocks` (`blockID`) ON DELETE SET NULL ON UPDATE NO ACTION +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `scheduled_transactions` +-- + +DROP TABLE IF EXISTS `scheduled_transactions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `scheduled_transactions` ( + `schID` int(10) unsigned NOT NULL AUTO_INCREMENT, + `schTxid` char(64) NOT NULL DEFAULT '', + `schCreated` int(10) unsigned NOT NULL DEFAULT '0', + `schRaw` varchar(50000) NOT NULL DEFAULT '', + `schParentID` int(10) unsigned DEFAULT NULL, + `schParentTxid` char(64) DEFAULT '', + `schDelay` int(10) unsigned NOT NULL DEFAULT '0', + `schTrigger` int(10) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`schID`), + UNIQUE KEY `schTxid` (`schTxid`), + KEY `schParentID` (`schParentID`), + CONSTRAINT `scheduled_transactions_ibfk_1` FOREIGN KEY (`schParentID`) REFERENCES `scheduled_transactions` (`schID`) ON DELETE SET NULL ON UPDATE NO ACTION +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; diff --git a/doc/DELETE_xpub.md b/doc/DELETE_xpub.md new file mode 100644 index 0000000..bb31a65 --- /dev/null +++ b/doc/DELETE_xpub.md @@ -0,0 +1,37 @@ +# Delete HD Account + +Remove an HD account from the server. All addresses and transactions associated with the HD account will be removed. Transactions that are also associated with another `xpub` will remain. + +Note: this endpoint uses the HTTP `DELETE` verb. + +``` +DELETE /xpub/:xpub +``` + +## Parameters +* **address** - `string` - The first address of the internal chain for this `xpub`, derivation path `M/1/0`. Use compressed P2PHK address regardless of HD derivation scheme. +* **signature** - `string` - The base64-encoded signature of the double SHA256 hash of `[varuint length of xpub string, xpub string]`. Signature scheme follows [bitcoinjs-message](https://github.com/bitcoinjs/bitcoinjs-message/blob/master/index.js) with a message prefix matching the [coin type](https://github.com/bitcoinjs/bitcoinjs-lib/blob/v3.1.1/src/networks.js). Use the ECPair associated with the `M/1/0` address to sign. +* **at** - `string` (optional) - Access Token (json web token). Required if authentication is activated. + +### Example + +``` +DELETE /xpub/xpub0123456789?address=1address&signature=Base64X== +``` + +#### Success +Status code 200 with JSON response: +```json +{ + "status": "ok" +} +``` + +#### Failure +Status code 400 with JSON response: +```json +{ + "status": "error", + "error": "" +} +``` diff --git a/doc/DOCKER_setup.md b/doc/DOCKER_setup.md new file mode 100644 index 0000000..18b0900 --- /dev/null +++ b/doc/DOCKER_setup.md @@ -0,0 +1,196 @@ +# Installation of Dojo with Docker and Docker Compose + +MyDojo is a set of Docker containers providing a full Samourai backend composed of: +* a bitcoin full node accessible as an ephemeral Tor hidden service, +* the backend database, +* the backend modules with an API accessible as a static Tor hidden service, +* a maintenance tool accessible through a Tor web browser. + + +## Architecture ## + + + ------------------- ------------------- -------------------- + | Samourai Wallet | | Sentinel | | Bitcoin full nodes | + ------------------- ------------------- -------------------- + |_______________________|_______________________| + | + ------------ + + Tor network + + ------------ + | + Host machine | (Tor - port 80) + ______________________________ | _____________________________ + | | | + | ------------------- | + | | Tor Container | | + | ------------------- | + | | | | + | ------------------- | | + | | Nginx Container | | dmznet | + | ------------------- | | + |- - - - - - - - - - - | - - - - - - - | - - - - - - - - - - - | + | -------------------- -------------------- | + | | Nodejs Container | ------ | Bitcoind Container | | + | -------------------- -------------------- | + | | | + | ------------------- | + | | MySQL Container | dojonet | + | ------------------- | + |______________________________________________________________| + + + +## Requirements ## + +* A dedicated computer (host machine) connected 24/7 to internet +* OS: Linux is recommended +* Disk: 500GB (minimal) / 1TB (recommended) - SSD is recommended +* RAM: 4GB (minimal) +* Docker and Docker Compose installed on the host machine (be sure to run a recent version supporting v3.2 of docker-compose files, i.e. Docker Engine v17.04.0+) +* Check that the clock of your computer is properly set (required for Tor) +* Tor Browser installed on the host machine (or on another machine if your host is a headless server) + + +## Setup ## + +* Install [Docker and Docker Compose](https://docs.docker.com/compose/install/) on the host machine and check that your installation is working. + +* Install [Tor Browser](https://www.torproject.org/projects/torbrowser.html.en) on the host machine. + +* Download the most recent version of Dojo from [Github](https://github.com/Samourai-Wallet/samourai-dojo/archive/master.zip) + +* Uncompress the archive on the host machine in a temporary directory of your choice (named /tmp_dir in this doc) + +* Create a directory for Dojo (named /dojo_dir in this doc) + +* Copy the content of the "/tmp_dir/samourai-dojo-master" directory into the "/dojo_dir" directory + +* Customize the configuration of your Dojo + + * Go to the "/dojo_dir/docker/my_dojo/conf" directory + + * Edit docker-bitcoin.conf and provide a new value for the following parameters: + * BITCOIND_RPC_USER = login protecting the access to the RPC API of your full node, + * BITCOIND_RPC_PASSWORD = password protecting the access to the RPC API of your full node. + * If your machine has a lot of RAM, it's recommended that you increase the value of BITCOIND_DB_CACHE for a faster Initial Block Download. + + * Edit docker-mysql.conf and provide a new value for the following parameters: + * MYSQL_ROOT_PASSWORD = password protecting the root account of MySQL, + * MYSQL_USER = login of the account used to access the database of your Dojo, + * MYSQL_PASSWORD = password of the account used to access the database of your Dojo. + + * Edit docker-node.conf and provide a new value for the following parameters: + * NODE_API_KEY = API key which will be required from your Samourai Wallet / Sentinel for its interactions with the API of your Dojo, + * NODE_ADMIN_KEY = API key which will be required from the maintenance tool for accessing a set of advanced features provided by the API of your Dojo, + * NODE_JWT_SECRET = secret used by your Dojo for the initialization of a cryptographic key signing Json Web Tokens. + These parameters will protect the access to your Dojo. Be sure to provide alphanumeric values with enough entropy. + +* Open the docker quickstart terminal or a terminal console and go to the "/dojo_dir/docker/my_dojo" directory. This directory contains a script named dojo.sh which will be your entrypoint for all operations related to the management of your Dojo. + + +* Launch the installation of your Dojo with + +``` +./dojo.sh install +``` + +Docker and Docker Compose are going to build the images and containers of your Dojo. This operation will take a few minutes (download and setup of all required software components). After completion, your Dojo will be launched and will begin the initialization of the full node (Bitcoin Initial Block Download and syncing of the database). This step will take several hours/days according to the specs of your machine. Be patient. Use CTRL+C to stop the display of the full logs. + + +* Monitor the progress made for the initialization of the database with this command displaying the logs of the tracker + +``` +./dojo.sh logs tracker +``` + +Exit the logs with CTRL+C when the syncing of the database has completed. + + +* Retrieve the Tor onion addresses (v2 and v3) of the API of your Dojo + +``` +./dojo.sh onion +``` + +* Restrict the access to your host machine as much as possible by configuring its firewall. + + +## Dojo shell script ## + +dojo.sh is a multifeature tool allowing to interact with your Dojo. + +``` +Usage: ./dojo.sh command [module] [options] + +Available commands: + + help Display the help message. + + bitcoin-cli Launch a bitcoin-cli console for interacting with bitcoind RPC API. + + install Install your Dojo. + + logs [module] [options] Display the logs of your Dojo. Use CTRL+C to stop the logs. + + Available modules: + dojo.sh logs : display the logs of all containers + dojo.sh logs bitcoind : display the logs of bitcoind + dojo.sh logs db : display the logs of the MySQL database + dojo.sh logs tor : display the logs of tor + dojo.sh logs api : display the logs of the REST API (nodejs) + dojo.sh logs tracker : display the logs of the Tracker (nodejs) + dojo.sh logs pushtx : display the logs of the pushTx API (nodejs) + dojo.sh logs pushtx-orchest : display the logs of the Orchestrator (nodejs) + + Available options (for api, tracker, pushtx and pushtx-orchest modules): + -d [VALUE] : select the type of log to be displayed. + VALUE can be output (default) or error. + -n [VALUE] : display the last VALUE lines + + onion Display the Tor onion address allowing your wallet to access your Dojo. + + restart Restart your Dojo. + + start Start your Dojo. + + stop Stop your Dojo. + + uninstall Delete your Dojo. Be careful! This command will also remove all data. + +``` + + +## Dojo maintenance tool ## + +A maintenance tool is accessible through your Tor browser at the url: /admin + +The maintenance tool requires that you allow javascript for the site. + +Sign in with the value entered for NODE_ADMIN_KEY. + + + +## Pairing ## + +Once the database has finished syncing, you can pair your Samourai Wallet with your Dojo in 2 steps: + +* Open the maintenance tool in a Tor browser and sign in with your admin key. + +* Get your smartphone and launch the Samourai Wallet app. Scan the QRCode displayed in the "Pairing" tab of the maintenance tool. + + + +## Network connections ## + +The API of your Dojo is accessed as a Tor hidden service (static onion address). + +If OXT is selected as the default source for imports, OXT clearnet API is accessed through the Tor local proxy. + +The maintenance tool is accessed as a Tor hidden service (static onion address). + +The Bitcoin node only allows incoming connections from Tor (dynamic onion address). + +The Bitcoin node attempts outgoing connections to both Tor and clearrnet nodes (through the Tor local proxy). diff --git a/doc/GET_fees.md b/doc/GET_fees.md new file mode 100644 index 0000000..dcfb5cf --- /dev/null +++ b/doc/GET_fees.md @@ -0,0 +1,39 @@ +# Get Fees + +Returns `bitcoind`'s estimated fee rates for inclusion in blocks at various delays. Fee rates are in Satoshi/byte. + + +``` +GET /fees +``` + +## Parameters +* **at** - `string` (optional) - Access Token (json web token). Required if authentication is activated. + + +### Examples + +``` +GET /fees +``` + +#### Success +Status code 200 with JSON response: +```json +{ + "2": 181, + "4": 150, + "6": 150, + "12": 111, + "24": 62 +} +``` + +#### Failure +Status code 400 with JSON response: +```json +{ + "status": "error", + "error": "" +} +``` diff --git a/doc/GET_header.md b/doc/GET_header.md new file mode 100644 index 0000000..2b9fd75 --- /dev/null +++ b/doc/GET_header.md @@ -0,0 +1,47 @@ +# Get Block Header + +Request the header for a given block. + + +``` +GET /header/:hash +``` + +## Parameters +* **hash** - `string` - The block hash +* **at** - `string` (optional) - Access Token (json web token). Required if authentication is activated. + +### Examples + +``` +GET /header/000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f +``` + +#### Success +Status code 200 with JSON response: +```json +{ + "hash": "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", + "confirmations": 475000, + "height": 0, + "version": 1, + "versionHex": "00000001", + "merkleroot": "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", + "time": 1231006505, + "mediantime": 1231006505, + "nonce": 2083236893, + "bits": "1d00ffff", + "difficulty": 1, + "chainwork": "0000000000000000000000000000000000000000000000000000000100010001", + "nextblockhash": "00000000839a8e6886ab5951d76f411475428afc90947ee320161bbf18eb6048" +} +``` + +#### Failure +Status code 400 with JSON response: +```json +{ + "status": "error", + "error": "" +} +``` diff --git a/doc/GET_multiaddr.md b/doc/GET_multiaddr.md new file mode 100644 index 0000000..92ebb2f --- /dev/null +++ b/doc/GET_multiaddr.md @@ -0,0 +1,117 @@ +# Get Multiaddr + +Request details about a collection of HD accounts and/or loose addresses and/or public keys. If accounts do not exist, they will be created with a relayed call to the [POST /xpub](./POST_xpub.md) mechanics if new or will be imported from external data sources. Instruct the server that entities are new with `?new=xpub1|addr2|addr3` in the query parameters, and the server will skip importing for those entities. SegWit support via BIP49 is activated for new xpubs with `?bip49=xpub3|xpub4`. SegWit support via BIP84 is activated for new xpubs with `?bip84=xpub3|xpub4`. Pass xpubs to `?bip49` or `?bip84` only for newly-created accounts. Support of BIP47 (with addresses derived in 3 formats (P2PKH, P2WPKH/P2SH, P2WPKH Bech32)) is activated for new pubkeys with `?pubkey=pubkey1|pubkey2`. + +Note that loose addresses that are also part of one of the HD accounts requested will be ignored. Their balances and transactions are listed as part of the HD account result. + +The `POST` version of multiaddr is identical, except the `active` and `new` parameters are in the POST body. + + +``` +GET /multiaddr?active=...[&new=...][&bip49=...][&bip84=...][&pubkey=...] +``` + +## Parameters +* **active** - `string` - A pipe-separated list of extended public keys and/or loose addresses and/or pubkeys (`xpub1|address1|address2|pubkey1|...`) +* **new** - `string` - A pipe-separated list of extended public keys and/or loose addresses that need no import from external services +* **bip49** - `string` - A pipe-separated list of new extended public keys to be derived via [BIP49](https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki) +* **bip84** - `string` - A pipe-separated list of new extended public keys to be derived via [BIP84](https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki) +* **pubkey** - `string` - A pipe-separated list of public keys to be derived as P2PKH, P2WPKH/P2SH, P2WPKH Bech32 addresses. +* **at** - `string` (optional) - Access Token (json web token). Required if authentication is activated. + +### Examples + +``` +GET /multiaddr?active=xpub0123456789&new=address2|address3&pubkey=pubkey4 +GET /multiaddr?active=xpub0123456789|address1|address2 +GET /multiaddr?bip49=xpub0123456789 +GET /multiaddr?bip84=xpub0123456789 +GET /multiaddr?pubkey=0312345678901 +``` + +#### Success +Status code 200 with JSON response: +```json +{ + "wallet": { + "final_balance": 100000000 + }, + "info": { + "latest_block": { + "height": 100000, + "hash": "abcdef", + "time": 1000000000 + } + }, + "addresses": [ + { + "address": "xpubABCDEF -or- 1xAddress", + "pubkey": "04Pubkey -or- inexistant attribute" + "final_balance": 100000000, + "account_index": 0, + "change_index": 0, + "n_tx": 0 + } + ], + "txs": [ + { + "block_height": 100000, + "hash": "abcdef", + "version": 1, + "locktime": 0, + "result": -10000, + "balance": 90000, + "time": 1400000000, + "inputs": [ + { + "vin": 1, + "prev_out": { + "txid": "abcdef", + "vout": 2, + "value": 20000, + "xpub": { + "m": "xpubABCDEF", + "path": "M/0/3" + }, + "addr": "1xAddress", + "pubkey": "04Pubkey" + }, + "sequence": 4294967295 + } + ], + "out": [ + { + "n": 2, + "value": 10000, + "addr": "1xAddress", + "pubkey": "03Pubkey" + "xpub": { + "m": "xpubABCDEF", + "path": "M/1/5" + } + } + ] + } + ] +} +``` + +**Notes** +* The transaction `inputs` and `out` arrays are for known addresses only and do not reflect the full input and output list of the transaction on the blockchain +* `result.addresses[i].n_tx` used by BIP47 logic to detemine unused index +* `result.txs[i].block_height` should not be present for unconfirmed transactions +* `result.txs[i].result` is the change in value for the "wallet" as defined by all entries on the `active` query parameter +* `result.txs[i].inputs[j].prev_out.addr` should be present for BIP47-related addresses but may be `null` if the previous output address is unknown +* `result.txs[i].out[j].addr` should be present for BIP47-related addresses + +#### Failure +Status code 400 with JSON response: +```json +{ + "status": "error", + "error": "" +} +``` + +## Notes +Multiaddr response is consumed by the wallet in the [APIFactory](https://github.com/Samourai-Wallet/samourai-wallet-android/blob/master/app/src/main/java/com/samourai/wallet/api/APIFactory.java) diff --git a/doc/GET_tx.md b/doc/GET_tx.md new file mode 100644 index 0000000..95f2aeb --- /dev/null +++ b/doc/GET_tx.md @@ -0,0 +1,131 @@ +# Get Transaction + +Request details about a single Bitcoin transaction. Pass `?fees=1` to scan the previous outputs and compute the fees paid in this transaction. + + +``` +GET /tx/:txid +GET /tx/:txid?fees=1 +``` + +## Parameters +* **txid** - `string` - The transaction ID +* **fees** - `string` - (optional) Scan previous outputs to compute fees +* **at** - `string` (optional) - Access Token (json web token). Required if authentication is activated. + +### Examples + +``` +GET /tx/abcdef +GET /tx/abcdef?fees=1 +``` + +#### Success +Status code 200 with JSON response: +```json +{ + "txid": "abcdef", + "size": 250, + "vsize": 125, + "version": 1, + "locktime": 0, + "block": { + "height": 100000, + "hash": "abcdef", + "time": 1400000000 + }, + "inputs": [ + { + "n": 0, + "outpoint": { + "txid": "abcdef", + "vout": 2 + }, + "sig": "0a1b2c3d4e5f", + "seq": 4294967295 + }, + { + "n": 1, + "outpoint": { + "txid": "abcdef", + "vout": 3 + }, + "sig": "", + "seq": 4294967295, + "witness": [ + "aabbccddeeff", + "00112233" + ] + } + ], + "outputs": [ + { + "n": 0, + "value": 10000, + "scriptpubkey": "0a1b2c3d4e5f", + "type": "pubkeyhash", + "address": "1xAddress" + }, + { + "n": 1, + "value": 0, + "scriptpubkey": "0a1b2c3d4e5f", + "type": "nulldata" + }, + { + "n": 2, + "value": 10000, + "scriptpubkey": "0a1b2c3d4e5f", + "type": "multisig", + "addresses": [ + "1xAddress", + "1yAddress" + ] + }, + { + "n": 3, + "value": 10000, + "scriptpubkey": "000a1b2c3d4e5f", + "type": "witness_v0_scripthash" + }, + { + "n": 4, + "value": 10000, + "scriptpubkey": "000b1b2c3d4e5f", + "type": "witness_v0_keyhash" + } + ] +} +``` +Additional fields with `?fees=1`: +```json +{ + "fees": 10000, + "feerate": 50, + "vfeerate": 75, + "inputs": [ + { + "outpoint": { + "value": 20000, + "scriptpubkey": "0a1b2c3d4e5f" + } + } + ], + "outputs": ["..."] +} +``` + +**Notes** +* `block` details will be missing for unconfirmed transactions +* Input `sig` is the raw hex, not ASM of script signature +* `feerate` has units of Satoshi/byte +* `vsize` and `vfeerate` are the virtual size and virtual fee rate and are different than `size` and `feerate` for SegWit transactions + +#### Failure +Status code 400 with JSON response: +```json +{ + "status": "error", + "error": "" +} +``` diff --git a/doc/GET_txs.md b/doc/GET_txs.md new file mode 100644 index 0000000..0c84e33 --- /dev/null +++ b/doc/GET_txs.md @@ -0,0 +1,87 @@ +# Get Transactions + +Request a paginated list of transactions related to a collection of HD accounts and/or loose addresses and/or public keys. + +Note that loose addresses that are also part of one of the HD accounts requested will be ignored. Their transactions are listed as part of the HD account result. + +``` +GET /txs?active=... +``` + +## Parameters +* **active** - `string` - A pipe-separated list of extended public keys and/or loose addresses and/or pubkeys (`xpub1|address1|address2|pubkey1|...`) +* **page** - `integer` - Index of the requested page (first page is index 0) +* **count** - `integer` - Number of transactions returned per page +* **at** - `string` (optional) - Access Token (json web token). Required if authentication is activated. + +### Examples + +``` +GET /txs?active=xpub0123456789 +GET /txs?active=xpub0123456789|address1|address2|pubkey1 +``` + +#### Success +Status code 200 with JSON response: +```json +{ + "n_tx": 153, + "page": 2, + "n_tx_page": 50, + "txs": [ + { + "block_height": 100000, + "hash": "abcdef", + "version": 1, + "locktime": 0, + "result": -10000, + "time": 1400000000, + "inputs": [ + { + "vin": 1, + "prev_out": { + "txid": "abcdef", + "vout": 2, + "value": 20000, + "xpub": { + "m": "xpubABCDEF", + "path": "M/0/3" + }, + "addr": "1xAddress", + "pubkey": "04Pubkey" + }, + "sequence": 4294967295 + } + ], + "out": [ + { + "n": 2, + "value": 10000, + "addr": "1xAddress", + "pubkey": "03Pubkey" + "xpub": { + "m": "xpubABCDEF", + "path": "M/1/5" + } + } + ] + } + ] +} +``` + +**Notes** +* The transaction `inputs` and `out` arrays are for known addresses only and do not reflect the full input and output list of the transaction on the blockchain +* `result.txs[i].block_height` should not be present for unconfirmed transactions +* `result.txs[i].result` is the change in value for the "wallet" as defined by all entries on the `active` query parameter +* `result.txs[i].inputs[j].prev_out.addr` should be present for BIP47-related addresses but may be `null` if the previous output address is unknown +* `result.txs[i].out[j].addr` should be present for BIP47-related addresses + +#### Failure +Status code 400 with JSON response: +```json +{ + "status": "error", + "error": "" +} +``` diff --git a/doc/GET_unspent.md b/doc/GET_unspent.md new file mode 100644 index 0000000..cb3b6a1 --- /dev/null +++ b/doc/GET_unspent.md @@ -0,0 +1,61 @@ +# Get Unspent + +Request a list of unspent transaction outputs from a collection of HD accounts and/or loose addresses and or public keys. If accounts do not exist, they will be created with a relayed call to the [POST /xpub](./POST_xpub.md) mechanics if new or will be imported from external data sources. Instruct the server that entities are new with `?new=xpub1|addr2|addr3` in the query parameters. SegWit support via BIP49 is activated for new xpubs with `?bip49=xpub3|xpub4`. SegWit support via BIP84 is activated for new xpubs with `?bip84=xpub3|xpub4`. Pass xpubs to `?bip49` or `?bip84` only for newly-created accounts. Support of BIP47 (with addresses derived in 3 formats (P2PKH, P2WPKH/P2SH, P2WPKH Bech32)) is activated for new pubkeys with `?pubkey=pubkey1|pubkey2`. + +The `POST` version of unspent is identical, except the parameters are in the POST body. + + +``` +GET /unspent?active=...&new=...&bip49=...&bip84=...&pubkey=... +``` + +## Parameters +* **active** - `string` - A pipe-separated list of extended public keys and/or loose addresses (`xpub1|address1|address2|...`) +* **new** - `string` - A pipe-separated list of extended public keys and/or loose addresses that need no import from external services +* **bip49** - `string` - A pipe-separated list of new extended public keys to be derived via [BIP49](https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki) +* **bip84** - `string` - A pipe-separated list of new extended public keys to be derived via [BIP84](https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki) +* **pubkey** - `string` - A pipe-separated list of public keys to be derived as P2PKH, P2WPKH/P2SH, P2WPKH Bech32 addresses. +* **at** - `string` (optional) - Access Token (json web token). Required if authentication is activated. + +### Examples + +``` +GET /unspent?active=xpub0123456789&new=address2|address3&pubkey=pubkey4 +GET /unspent?active=xpub0123456789|address1|address2|pubkey4 +``` + +#### Success +Status code 200 with JSON response: +```json +{ + "unspent_outputs": [ + { + "tx_hash": "abcdef", + "tx_output_n": 2, + "tx_version": 1, + "tx_locktime": 0, + "value": 100000000, + "script": "abcdef", + "addr": "1xAddress", + "pubkey": "04Pubkey -or- inexistant attribute" + "confirmations": 10000, + "xpub": { + "m": "xpub0123456789", + "path": "M/0/5" + } + } + ] +} +``` + +#### Failure +Status code 400 with JSON response: +```json +{ + "status": "error", + "error": "" +} +``` + +## Notes +Unspent response is consumed by the wallet in the [APIFactory](https://github.com/Samourai-Wallet/samourai-wallet-android/blob/master/app/src/main/java/com/samourai/wallet/api/APIFactory.java) diff --git a/doc/GET_xpub.md b/doc/GET_xpub.md new file mode 100644 index 0000000..d9e1770 --- /dev/null +++ b/doc/GET_xpub.md @@ -0,0 +1,45 @@ +# Get HD Account + +Request details about an HD account. If account does not exist, it must be created with [POST /xpub](./POST_xpub.md), and this call will return an error. + +Data returned includes the unspent `balance`, the next `unused` address indices for external and internal chains, the `derivation` path of addresses, and the `created` timestamp when the server first saw this HD account. + +``` +GET /xpub/:xpub +``` + +## Parameters +* **:xpub** - `string` - The extended public key for the HD Account +* **at** - `string` (optional) - Access Token (json web token). Required if authentication is activated. + +### Example + +``` +GET /xpub/xpub0123456789 +``` + +#### Success +Status code 200 with JSON response: +```json +{ + "status": "ok", + "data": { + "balance": 100000000, + "unused": { + "external": 2, + "internal": 1 + }, + "derivation": "BIP44|BIP49", + "created": 1500000000 + } +} +``` + +#### Failure +Status code 400 with JSON response: +```json +{ + "status": "error", + "error": "" +} +``` diff --git a/doc/POST_auth_login.md b/doc/POST_auth_login.md new file mode 100644 index 0000000..8a4e750 --- /dev/null +++ b/doc/POST_auth_login.md @@ -0,0 +1,69 @@ +# Authentication + +Authenticate to the backend by providing the API key expected by the server. If authentication succeeds, the endpoint returns a json embedding an access token and a refresh token (JSON Web Tokens). The access token must be passed as an argument for all later calls to the backend (account & pushtx REST API + websockets). The refresh token must be passed as an argument for later calls to /auth/refresh allowing to generate a new access token. + +Authentication is activated in /keys/inndex.js configuration file + +``` +auth: { + // Name of the authentication strategy used + // Available values: + // null : No authentication + // 'localApiKey' : authentication with a shared local api key + activeStrategy: 'localApiKey', + // List of available authentication strategies + strategies: { + // Authentication with a shared local api key + localApiKey: { + // API key (alphanumeric characters) + apiKey: 'myApiKey', + // DO NOT MODIFY + configurator: 'localapikey-strategy-configurator' + } + }, + // Configuration of Json Web Tokens + // used for the management of authorizations + jwt: { + // Secret passphrase used by the server to sign the jwt + // (alphanumeric characters) + secret: 'myJwtSecret', + accessToken: { + // Number of seconds after which the jwt expires + expires: 900 + }, + refreshToken: { + // Number of seconds after which the jwt expires + expires: 7200 + } + } +}, +``` + + +``` +POST /auth/login +``` + +## Parameters +* **apikey** - `string` - The API key securing access to the backend + + +### Example + +``` +POST /auth/login?apikey=myAPIKey +``` + +#### Success +Status code 200 with JSON response: +```json +{ + "authorizations": { + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJTYW1vdXJhaSBXYWxsZXQgYmFja2VuZCIsInR5cGUiOiJhY2Nlc3MtdG9rZW4iLCJpYXQiOjE1NDQxMDM5MjksImV4cCI6MTU0NDEwNDUyOX0.DDzz0EUEQS8vqdhfUwi_MFhjnSLKZ9nY-P55Yoi0wlI", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJTYW1vdXJhaSBXYWxsZXQgYmFja2VuZCIsInR5cGUiOiJyZWZyZXNoLXRva2VuIiwiaWF0IjoxNTQ0MTAzOTI5LCJleHAiOjE1NDQxMTExMjl9.6gykKq31WL4Jq7hfmoTwi1fpmBTtAeFb4KjfmSO6l00" + } +} +``` + +#### Failure +Status code 401 diff --git a/doc/POST_auth_refresh.md b/doc/POST_auth_refresh.md new file mode 100644 index 0000000..7c86189 --- /dev/null +++ b/doc/POST_auth_refresh.md @@ -0,0 +1,31 @@ +# Refresh the access token + +Request a new access token from the backend. A valid refresh token must be passed as an argument. + + +``` +POST /auth/refresh +``` + +## Parameters +* **rt** - `string` - A valid refresh token + + +### Example + +``` +POST /auth/refresh?rt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJTYW1vdXJhaSBXYWxsZXQgYmFja2VuZCIsInR5cGUiOiJyZWZyZXNoLXRva2VuIiwiaWF0IjoxNTQ0MTAzOTI5LCJleHAiOjE1NDQxMTExMjl9.6gykKq31WL4Jq7hfmoTwi1fpmBTtAeFb4KjfmSO6l00 +``` + +#### Success +Status code 200 with JSON response: +```json +{ + "authorizations": { + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJTYW1vdXJhaSBXYWxsZXQgYmFja2VuZCIsInR5cGUiOiJhY2Nlc3MtdG9rZW4iLCJpYXQiOjE1NDQxMDM5MjksImV4cCI6MTU0NDEwNDUyOX0.DDzz0EUEQS8vqdhfUwi_MFhjnSLKZ9nY-P55Yoi0wlI" + } +} +``` + +#### Failure +Status code 401 diff --git a/doc/POST_pushtx.md b/doc/POST_pushtx.md new file mode 100644 index 0000000..b5c6f54 --- /dev/null +++ b/doc/POST_pushtx.md @@ -0,0 +1,38 @@ +# PushTX + +Push a transaction to the network. + +``` +POST /pushtx/ +``` + +## Parameters +* **tx** - `hex string` - The raw transaction hex +* **at** - `string` (optional) - Access Token (json web token). Required if authentication is activated. + +### Example + +``` +POST /pushtx/?tx=abcdef0123456789 +``` + +#### Success +Status code 200 with JSON response: +```json +{ + "status": "ok", + "data": "" +} +``` + +#### Failure +Status code 400 with JSON response: +```json +{ + "status": "error", + "error": { + "message": "", + "code": "" + } +} +``` diff --git a/doc/POST_pushtx_schedule.md b/doc/POST_pushtx_schedule.md new file mode 100644 index 0000000..6da9d39 --- /dev/null +++ b/doc/POST_pushtx_schedule.md @@ -0,0 +1,121 @@ +# Scheduled PushTX + +Schedule the delayed push of an ordered list of transactions (used for programmable Ricochet). + + +``` +POST /pushtx/schedule +``` + +## Parameters + +* **script** - `ScriptStep[]` - An array of ScriptStep objects defining the script. + + +## ScriptStep structure + +* **hop** - `integer` - Index of this step in the script. +Transactions are pushed by ascending order of **hop** values. + +* **nlocktime** - `integer` - Height of the block after which the transaction should be pushed to the network. +This value shouldn't be set too far in the future (default tolerance is currently the height of current tip + 18 blocks). +If step A has a **hop** value higher than step B, then step A MUST have a **nlocktime** greater than or equal to the **nlocktime** of step B. +If step A and step B have the same **hop** value, then they MAY HAVE different **nlocktime** values. + +* **tx** - `string` - The raw transaction hex for the transaction to be pushed during this step. +The transaction MUST HAVE its nLockTime field filled with the height of a block. +The height of the block MUST BE equal to the value of the **nlocktime** field of the ScriptStep object. + + +### Examples + +Ricochet-like script + +``` + +tx0 -- tx1 -- tx2 -- tx3 -- tx4 + +POST /pushtx/schedule + +Request Body (JSON-encoded) +{ + "script": [{ + "hop": 0, + "nlocktime": 549817, + "tx": "" + }, { + "hop": 1, + "nlocktime": 549818, + "tx": "" + }, { + "hop": 2, + "nlocktime": 549820, + "tx": "" + }, { + "hop": 3, + "nlocktime": 549823, + "tx": "" + }, { + "hop": 4, + "nlocktime": 549824, + "tx": "" + }] +} +``` + +Serialized script with 2 parallel branches + +``` + -- tx1 -- tx3 --------- +tx0 --| |-- tx5 + -- tx2 --------- tx4 -- + + +POST /pushtx/schedule + +Request Body (JSON-encoded) +{ + "script": [{ + "hop": 0, + "nlocktime": 549817, + "tx": "" + }, { + "hop": 1, + "nlocktime": 549818, + "tx": "" + }, { + "hop": 1, + "nlocktime": 549818, + "tx": "" + }, { + "hop": 2, + "nlocktime": 549819, + "tx": "" + }, { + "hop": 2, + "nlocktime": 549820, + "tx": "" + }, { + "hop": 3, + "nlocktime": 549821, + "tx": "" + }] +} +``` + +#### Success +Status code 200 with JSON response: +```json +{ + "status": "ok" +} +``` + +#### Failure +Status code 400 with JSON response: +```json +{ + "status": "error", + "error": "" +} +``` diff --git a/doc/POST_xpub.md b/doc/POST_xpub.md new file mode 100644 index 0000000..8a1afc8 --- /dev/null +++ b/doc/POST_xpub.md @@ -0,0 +1,41 @@ +# Add HD Account + +Notify the server of the new HD account for tracking. When new accounts are sent, there is no need to rescan the addresses for existing transaction activity. SegWit support is provided via [BIP49](https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki) or [BIP84](https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki). + +Response time for restored accounts might be long if there is much previous activity. + +``` +POST /xpub +``` + +## Parameters +* **xpub** - `string` - The extended public key for the HD Account +* **type** - `string` - Whether this is a newly-created account or one being restored. Recognized values are `'new'` and `'restore'`. +* **segwit** - `string` (optional) - What type of SegWit support for this xpub, if any. Valid values: `'bip49'` and `'bip84'` +* **force** - `boolean` (optional) - Force an override of derivation scheme even if xpub is locked. Used for `'restore'` operation. +* **at** - `string` (optional) - Access Token (json web token). Required if authentication is activated. + +### Example + +``` +POST /xpub?xpub=xpub0123456789&type=restore +POST /xpub?xpub=xpub0123456789&type=new&segwit=bip49 +POST /xpub?xpub=xpub0123456789&type=restore&segwit=bip84 +``` + +#### Success +Status code 200 with JSON response: +```json +{ + "status": "ok" +} +``` + +#### Failure +Status code 400 with JSON response: +```json +{ + "status": "error", + "error": "" +} +``` diff --git a/doc/POST_xpub_lock.md b/doc/POST_xpub_lock.md new file mode 100644 index 0000000..b74219a --- /dev/null +++ b/doc/POST_xpub_lock.md @@ -0,0 +1,39 @@ +# Lock an HD Account Type + +To avoid errors related to `POST xpub` and SegWit derivation type, this endpoint allows locking of the type of an xpub in the database. + +``` +POST /xpub/:xpub/lock +``` + +## Parameters +* **address** - `string` - The first address of the internal chain for this `xpub`, derivation path `M/1/0`. Use compressed P2PHK address regardless of HD derivation scheme. +* **message** - `string` - Either `"lock"` or `"unlock"` +* **signature** - `string` - The base64-encoded signature of the double SHA256 hash of `[varuint length of message string, message string]`. Signature scheme follows [bitcoinjs-message](https://github.com/bitcoinjs/bitcoinjs-message/blob/master/index.js) with a message prefix matching the [coin type](https://github.com/bitcoinjs/bitcoinjs-lib/blob/v3.1.1/src/networks.js). Use the ECPair associated with the `M/1/0` address to sign. +* **at** - `string` (optional) - Access Token (json web token). Required if authentication is activated. + +### Example + +``` +POST /xpub/xpub0123456789/lock?address=1address&message=lock&signature=Base64X== +``` + +#### Success +Status code 200 with JSON response: +```json +{ + "status": "ok", + "data": { + "derivation": "LOCKED BIP49, etc" + } +} +``` + +#### Failure +Status code 400 with JSON response: +```json +{ + "status": "error", + "error": "" +} +``` diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 0000000..6a70129 --- /dev/null +++ b/doc/README.md @@ -0,0 +1,29 @@ +# Installation + +# Endpoint documentation + +Endpoint documentation is split into separate files and presented here under specific servers. + + +## Accounts Server +Keeps track of HD account balances, represented by `xpub` extended public keys. + +### Endpoints +* [POST auth/login](./POST_auth_login.md) +* [GET multiaddr](./GET_multiaddr.md) +* [GET unspent](./GET_unspent.md) +* [GET xpub](./GET_xpub.md) +* [POST xpub](./POST_xpub.md) +* [POST xpub/lock](./POST_xpub_lock.md) +* [GET tx](./GET_tx.md) +* [GET txs](./GET_txs.md) +* [GET header](./GET_header.md) +* [GET fees](./GET_fees.md) + + +## PushTX Server +A simple server that relays transactions from the wallet to the full node. + +### Endpoints +* [POST pushtx](./POST_pushtx.md) + diff --git a/docker/my-dojo/.env b/docker/my-dojo/.env new file mode 100644 index 0000000..a51bc5b --- /dev/null +++ b/docker/my-dojo/.env @@ -0,0 +1,52 @@ +######################################### +# SYSTEM ENVIRONMENT VARIABLES +# DO NOT MODIFY +######################################### + + +######################################### +# GLOBAL +######################################### + +COMPOSE_CONVERT_WINDOWS_PATHS=1 +DOJO_VERSION_TAG=1.0.0 + + +######################################### +# MYSQL +######################################### + +MYSQL_DATABASE=samourai-main + + +######################################### +# BITCOIND +######################################### + +BITCOIND_DNSSEED=0 +BITCOIND_DNS=0 + + +######################################### +# NODEJS +######################################### + +NODE_GAP_EXTERNAL=100 +NODE_GAP_INTERNAL=100 +NODE_ADDR_FILTER_THRESHOLD=1000 +NODE_URL_OXT_API=https://api.oxt.me +NODE_ADDR_DERIVATION_MIN_CHILD=2 +NODE_ADDR_DERIVATION_MAX_CHILD=2 +NODE_ADDR_DERIVATION_THRESHOLD=10 +NODE_TXS_SCHED_MAX_ENTRIES=10 +NODE_TXS_SCHED_MAX_DELTA_HEIGHT=18 + +NODE_JWT_ACCESS_EXPIRES=900 +NODE_JWT_REFRESH_EXPIRES=7200 + +NODE_PREFIX_STATUS=status +NODE_PREFIX_SUPPORT=support +NODE_PREFIX_STATUS_PUSHTX=status + +NODE_TRACKER_MEMPOOL_PERIOD=10000 +NODE_TRACKER_UNCONF_TXS_PERIOD=300000 diff --git a/docker/my-dojo/bitcoin/Dockerfile b/docker/my-dojo/bitcoin/Dockerfile new file mode 100644 index 0000000..706238d --- /dev/null +++ b/docker/my-dojo/bitcoin/Dockerfile @@ -0,0 +1,56 @@ +FROM debian:stretch + + +################################################################# +# INSTALL BITCOIN +################################################################# +ENV BITCOIN_HOME /home/bitcoin +ENV BITCOIN_VERSION 0.18.0 +ENV BITCOIN_URL https://bitcoincore.org/bin/bitcoin-core-0.18.0/bitcoin-0.18.0-x86_64-linux-gnu.tar.gz +ENV BITCOIN_SHA256 5146ac5310133fbb01439666131588006543ab5364435b748ddfc95a8cb8d63f +ENV BITCOIN_ASC_URL https://bitcoincore.org/bin/bitcoin-core-0.18.0/SHA256SUMS.asc +ENV BITCOIN_PGP_KEY 01EA5486DE18A882D4C2684590C8019E36C2E964 + +RUN set -ex && \ + apt-get update && \ + apt-get install -qq --no-install-recommends ca-certificates dirmngr gosu gpg wget && \ + rm -rf /var/lib/apt/lists/* + +# Build and install bitcoin binaries +RUN set -ex && \ + cd /tmp && \ + wget -qO bitcoin.tar.gz "$BITCOIN_URL" && \ + echo "$BITCOIN_SHA256 bitcoin.tar.gz" | sha256sum -c - && \ + gpg --batch --keyserver keyserver.ubuntu.com --recv-keys "$BITCOIN_PGP_KEY" && \ + wget -qO bitcoin.asc "$BITCOIN_ASC_URL" && \ + gpg --batch --verify bitcoin.asc && \ + tar -xzvf bitcoin.tar.gz -C /usr/local --strip-components=1 --exclude=*-qt && \ + rm -rf /tmp/* + +# Create group & user bitcoin +RUN addgroup --system -gid 1108 bitcoin && \ + adduser --system --ingroup bitcoin -uid 1105 bitcoin + +# Create data directory +RUN mkdir "$BITCOIN_HOME/.bitcoin" && \ + chown -h bitcoin:bitcoin "$BITCOIN_HOME/.bitcoin" + +# Copy bitcoin config file +COPY ./bitcoin.conf "$BITCOIN_HOME/.bitcoin/bitcoin.conf" +RUN chown bitcoin:bitcoin "$BITCOIN_HOME/.bitcoin/bitcoin.conf" + +# Copy restart script +COPY ./restart.sh /restart.sh +RUN chown bitcoin:bitcoin /restart.sh && \ + chmod 777 /restart.sh + +# Copy wait-for-it script +COPY ./wait-for-it.sh /wait-for-it.sh + +RUN chown bitcoin:bitcoin /wait-for-it.sh && \ + chmod u+x /wait-for-it.sh && \ + chmod g+x /wait-for-it.sh + +EXPOSE 8333 9501 9502 28256 + +USER bitcoin \ No newline at end of file diff --git a/docker/my-dojo/bitcoin/bitcoin.conf b/docker/my-dojo/bitcoin/bitcoin.conf new file mode 100644 index 0000000..fe7167d --- /dev/null +++ b/docker/my-dojo/bitcoin/bitcoin.conf @@ -0,0 +1,22 @@ +# Bitcoin Configuration +server=1 +listen=1 +bind=127.0.0.1 + +# Tor proxy through dojonet +proxy=172.28.1.4:9050 + +# Non-default RPC Port +rpcport=28256 +rpcallowip=::/0 +rpcbind=bitcoind + +# Store transaction information for fully-spent txns +txindex=1 + +# No wallet +disablewallet=1 + +# ZeroMQ Notification Settings +zmqpubhashblock=tcp://0.0.0.0:9502 +zmqpubrawtx=tcp://0.0.0.0:9501 diff --git a/docker/my-dojo/bitcoin/restart.sh b/docker/my-dojo/bitcoin/restart.sh new file mode 100644 index 0000000..8a53527 --- /dev/null +++ b/docker/my-dojo/bitcoin/restart.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -e + +echo "## Start bitcoind #############################" +bitcoind -datadir=/home/bitcoin/.bitcoin \ + -dbcache=$BITCOIND_DB_CACHE \ + -dnsseed=$BITCOIND_DNSSEED \ + -dns=$BITCOIND_DNS \ + -rpcuser=$BITCOIND_RPC_USER \ + -rpcpassword=$BITCOIND_RPC_PASSWORD \ + -maxconnections=$BITCOIND_MAX_CONNECTIONS \ + -maxmempool=$BITCOIND_MAX_MEMPOOL \ + -mempoolexpiry=$BITCOIND_MEMPOOL_EXPIRY \ + -minrelaytxfee=$BITCOIND_MIN_RELAY_TX_FEE diff --git a/docker/my-dojo/bitcoin/wait-for-it.sh b/docker/my-dojo/bitcoin/wait-for-it.sh new file mode 100644 index 0000000..071c2be --- /dev/null +++ b/docker/my-dojo/bitcoin/wait-for-it.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# Use this script to test if a given TCP host/port are available + +WAITFORIT_cmdname=${0##*/} + +echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } + +usage() +{ + cat << USAGE >&2 +Usage: + $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] + -h HOST | --host=HOST Host or IP under test + -p PORT | --port=PORT TCP port under test + Alternatively, you specify the host and port as host:port + -s | --strict Only execute subcommand if the test succeeds + -q | --quiet Don't output any status messages + -t TIMEOUT | --timeout=TIMEOUT + Timeout in seconds, zero for no timeout + -- COMMAND ARGS Execute command with args after the test finishes +USAGE + exit 1 +} + +wait_for() +{ + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + else + echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" + fi + WAITFORIT_start_ts=$(date +%s) + while : + do + if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then + nc -z $WAITFORIT_HOST $WAITFORIT_PORT + WAITFORIT_result=$? + else + (echo > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 + WAITFORIT_result=$? + fi + if [[ $WAITFORIT_result -eq 0 ]]; then + WAITFORIT_end_ts=$(date +%s) + echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" + break + fi + sleep 1 + done + return $WAITFORIT_result +} + +wait_for_wrapper() +{ + # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 + if [[ $WAITFORIT_QUIET -eq 1 ]]; then + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + else + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + fi + WAITFORIT_PID=$! + trap "kill -INT -$WAITFORIT_PID" INT + wait $WAITFORIT_PID + WAITFORIT_RESULT=$? + if [[ $WAITFORIT_RESULT -ne 0 ]]; then + echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + fi + return $WAITFORIT_RESULT +} + +# process arguments +while [[ $# -gt 0 ]] +do + case "$1" in + *:* ) + WAITFORIT_hostport=(${1//:/ }) + WAITFORIT_HOST=${WAITFORIT_hostport[0]} + WAITFORIT_PORT=${WAITFORIT_hostport[1]} + shift 1 + ;; + --child) + WAITFORIT_CHILD=1 + shift 1 + ;; + -q | --quiet) + WAITFORIT_QUIET=1 + shift 1 + ;; + -s | --strict) + WAITFORIT_STRICT=1 + shift 1 + ;; + -h) + WAITFORIT_HOST="$2" + if [[ $WAITFORIT_HOST == "" ]]; then break; fi + shift 2 + ;; + --host=*) + WAITFORIT_HOST="${1#*=}" + shift 1 + ;; + -p) + WAITFORIT_PORT="$2" + if [[ $WAITFORIT_PORT == "" ]]; then break; fi + shift 2 + ;; + --port=*) + WAITFORIT_PORT="${1#*=}" + shift 1 + ;; + -t) + WAITFORIT_TIMEOUT="$2" + if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi + shift 2 + ;; + --timeout=*) + WAITFORIT_TIMEOUT="${1#*=}" + shift 1 + ;; + --) + shift + WAITFORIT_CLI=("$@") + break + ;; + --help) + usage + ;; + *) + echoerr "Unknown argument: $1" + usage + ;; + esac +done + +if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then + echoerr "Error: you need to provide a host and port to test." + usage +fi + +WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} +WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} +WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} +WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} + +# check to see if timeout is from busybox? +WAITFORIT_TIMEOUT_PATH=$(type -p timeout) +WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) +if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then + WAITFORIT_ISBUSY=1 + WAITFORIT_BUSYTIMEFLAG="-t" + +else + WAITFORIT_ISBUSY=0 + WAITFORIT_BUSYTIMEFLAG="" +fi + +if [[ $WAITFORIT_CHILD -gt 0 ]]; then + wait_for + WAITFORIT_RESULT=$? + exit $WAITFORIT_RESULT +else + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + wait_for_wrapper + WAITFORIT_RESULT=$? + else + wait_for + WAITFORIT_RESULT=$? + fi +fi + +if [[ $WAITFORIT_CLI != "" ]]; then + if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then + echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" + exit $WAITFORIT_RESULT + fi + exec "${WAITFORIT_CLI[@]}" +else + exit $WAITFORIT_RESULT +fi diff --git a/docker/my-dojo/conf/docker-bitcoind.conf b/docker/my-dojo/conf/docker-bitcoind.conf new file mode 100644 index 0000000..9957409 --- /dev/null +++ b/docker/my-dojo/conf/docker-bitcoind.conf @@ -0,0 +1,32 @@ +######################################### +# CONFIGURATION OF BITCOIND CONTAINER +######################################### + +# User account used for rpc access to bitcoind +# Type: alphanumeric +BITCOIND_RPC_USER=dojorpc + +# Password of user account used for rpc access to bitcoind +# Type: alphanumeric +BITCOIND_RPC_PASSWORD=dojorpcpassword + +# Max number of connections to network peers +# Type: integer +BITCOIND_MAX_CONNECTIONS=16 + +# Mempool maximum size in MB +# Type: integer +BITCOIND_MAX_MEMPOOL=1024 + +# Db cache size in MB +# Type: integer +BITCOIND_DB_CACHE=1024 + +# Mempool expiry in hours +# Defines how long transactions stay in your local mempool before expiring +# Type: integer +BITCOIND_MEMPOOL_EXPIRY=72 + +# Min relay tx fee in BTC +# Type: numeric +BITCOIND_MIN_RELAY_TX_FEE=0.00001 \ No newline at end of file diff --git a/docker/my-dojo/conf/docker-mysql.conf b/docker/my-dojo/conf/docker-mysql.conf new file mode 100644 index 0000000..eaea8b0 --- /dev/null +++ b/docker/my-dojo/conf/docker-mysql.conf @@ -0,0 +1,15 @@ +######################################### +# CONFIGURATION OF MYSQL CONTAINER +######################################### + +# Password of MySql root account +# Type: alphanumeric +MYSQL_ROOT_PASSWORD=rootpassword + +# User account used for db access +# Type: alphanumeric +MYSQL_USER=samourai + +# Password of of user account +# Type: alphanumeric +MYSQL_PASSWORD=password \ No newline at end of file diff --git a/docker/my-dojo/conf/docker-node.conf b/docker/my-dojo/conf/docker-node.conf new file mode 100644 index 0000000..55dbfe4 --- /dev/null +++ b/docker/my-dojo/conf/docker-node.conf @@ -0,0 +1,30 @@ +######################################### +# CONFIGURATION OF NODE JS CONTAINER +######################################### + +# API key required for accessing the services provided by the server +# Keep this API key secret! +# Provide a value with a high entropy! +# Type: alphanumeric +NODE_API_KEY=myApiKey + +# API key required for accessing the admin/maintenance services provided by the server +# Keep this Admin key secret! +# Provide a value with a high entropy! +# Type: alphanumeric +NODE_ADMIN_KEY=myAdminKey + +# Secret used by the server for signing Json Web Token +# Keep this value secret! +# Provide a value with a high entropy! +# Type: alphanumeric +NODE_JWT_SECRET=myJwtSecret + +# Data source used for imports and rescans (bitcoind or OXT) +# Note: support of local bitcoind is an experimental feature +# Values: active | inactive +NODE_IMPORT_FROM_BITCOIND=active + +# FEE TYPE USED FOR FEES ESTIMATIONS BY BITCOIND +# Allowed values are ECONOMICAL or CONSERVATIVE +NODE_FEE_TYPE=ECONOMICAL \ No newline at end of file diff --git a/docker/my-dojo/docker-compose.yaml b/docker/my-dojo/docker-compose.yaml new file mode 100644 index 0000000..980e978 --- /dev/null +++ b/docker/my-dojo/docker-compose.yaml @@ -0,0 +1,130 @@ +version: "3.2" + +services: + db: + image: "samouraiwallet/dojo-db:1.0.0" + container_name: db + build: + context: ./../.. + dockerfile: ./docker/my-dojo/mysql/Dockerfile + env_file: + - ./.env + - ./conf/docker-mysql.conf + restart: on-failure + expose: + - "3306" + volumes: + - data-mysql:/var/lib/mysql + networks: + dojonet: + ipv4_address: 172.28.1.1 + + bitcoind: + image: "samouraiwallet/dojo-bitcoind:1.0.0" + container_name: bitcoind + build: + context: ./bitcoin + env_file: + - ./.env + - ./conf/docker-bitcoind.conf + restart: on-failure + command: "/wait-for-it.sh tor:9050 --timeout=360 --strict -- /restart.sh" + expose: + - "28256" + - "9501" + - "9502" + volumes: + - data-bitcoind:/home/bitcoin/.bitcoin + depends_on: + - db + - tor + networks: + dojonet: + ipv4_address: 172.28.1.5 + + node: + image: "samouraiwallet/dojo-nodejs:1.0.0" + container_name: nodejs + build: + context: ./../.. + dockerfile: ./docker/my-dojo/node/Dockerfile + env_file: + - ./.env + - ./conf/docker-mysql.conf + - ./conf/docker-bitcoind.conf + - ./conf/docker-node.conf + restart: on-failure + command: "/home/node/app/wait-for-it.sh db:3306 --timeout=360 --strict -- /home/node/app/restart.sh" + expose: + - "8080" + - "8081" + volumes: + - data-nodejs:/data + depends_on: + - bitcoind + - db + networks: + dojonet: + ipv4_address: 172.28.1.2 + + nginx: + image: "samouraiwallet/dojo-nginx:1.0.0" + container_name: nginx + build: + context: ./nginx + env_file: + - ./.env + restart: on-failure + command: "/wait-for node:8080 --timeout=360 -- nginx" + expose: + - "80" + volumes: + - data-nginx:/data + depends_on: + - node + networks: + dmznet: + ipv4_address: 172.29.1.3 + dojonet: + ipv4_address: 172.28.1.3 + + tor: + image: "samouraiwallet/dojo-tor:1.0.0" + container_name: tor + build: + context: ./tor + env_file: + - ./.env + restart: on-failure + command: tor + ports: + - "80:80" + volumes: + - data-tor:/var/lib/tor + networks: + dmznet: + ipv4_address: 172.29.1.4 + dojonet: + ipv4_address: 172.28.1.4 + +networks: + dojonet: + driver: bridge + ipam: + driver: default + config: + - subnet: 172.28.0.0/16 + dmznet: + driver: bridge + ipam: + driver: default + config: + - subnet: 172.29.0.0/16 + +volumes: + data-mysql: + data-bitcoind: + data-bitcoind-tor: + data-nodejs: + data-nginx: + data-tor: diff --git a/docker/my-dojo/dojo.sh b/docker/my-dojo/dojo.sh new file mode 100755 index 0000000..453c345 --- /dev/null +++ b/docker/my-dojo/dojo.sh @@ -0,0 +1,222 @@ +#!/bin/bash + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" +source "$DIR/conf/docker-bitcoind.conf" + + +# Start +start() { + docker-compose up --remove-orphans -d +} + +# Stop +stop() { + docker exec -it bitcoind bitcoin-cli \ + -rpcconnect=bitcoind \ + --rpcport=28256 \ + --rpcuser="$BITCOIND_RPC_USER" \ + --rpcpassword="$BITCOIND_RPC_PASSWORD" \ + stop + + echo "Preparing shutdown of dojo. Please wait." + sleep 15s + + docker-compose down +} + +# Restart dojo +restart() { + docker exec -it bitcoind bitcoin-cli \ + -rpcconnect=bitcoind \ + --rpcport=28256 \ + --rpcuser="$BITCOIND_RPC_USER" \ + --rpcpassword="$BITCOIND_RPC_PASSWORD" \ + stop + + echo "Preparing shutdown of dojo. Please wait." + sleep 15s + + docker-compose down + docker-compose up -d +} + +# Install +install() { + docker-compose up -d --remove-orphans + docker-compose logs --tail=0 --follow +} + +# Delete everything +uninstall() { + docker-compose rm + docker-compose down + + docker image rm samouraiwallet/dojo-db:1.0.0 + docker image rm samouraiwallet/dojo-bitcoind:1.0.0 + docker image rm samouraiwallet/dojo-nodejs:1.0.0 + docker image rm samouraiwallet/dojo-nginx:1.0.0 + docker image rm samouraiwallet/dojo-tor:1.0.0 + + docker volume prune +} + +# Display the onion address +onion() { + V2_ADDR=$( docker exec -it tor cat /var/lib/tor/hsv2dojo/hostname ) + V3_ADDR=$( docker exec -it tor cat /var/lib/tor/hsv3dojo/hostname ) + + echo "API Hidden Service address (v3) = $V3_ADDR" + echo "API Hidden Service address (v2) = $V2_ADDR" +} + +# Display logs +logs_node() { + if [ $3 -eq 0 ]; then + docker exec -ti nodejs tail -f /data/logs/$1-$2.log + else + docker exec -ti nodejs tail -n $3 /data/logs/$1-$2.log + fi +} + +logs() { + case $1 in + db ) + docker-compose logs --tail=50 --follow db + ;; + bitcoind ) + docker exec -ti bitcoind tail -f /home/bitcoin/.bitcoin/debug.log + ;; + tor ) + docker-compose logs --tail=50 --follow tor + ;; + api | pushtx | pushtx-orchest | tracker ) + logs_node $1 $2 $3 + ;; + * ) + docker-compose logs --tail=0 --follow + ;; + esac +} + +# Display the help +help() { + echo "Usage: dojo.sh command [module] [options]" + echo "Interact with your dojo." + echo " " + echo "Available commands:" + echo " " + echo " help Display this help message." + echo " " + echo " bitcoin-cli Launch a bitcoin-cli console allowing to interact with your full node through its RPC API." + echo " " + echo " install Install your dojo." + echo " " + echo " logs [module] [options] Display the logs of your dojo. Use CTRL+C to stop the logs." + echo " " + echo " Available modules:" + echo " dojo.sh logs : display the logs of all the Docker containers" + echo " dojo.sh logs bitcoind : display the logs of bitcoind" + echo " dojo.sh logs db : display the logs of the MySQL database" + echo " dojo.sh logs tor : display the logs of tor" + echo " dojo.sh logs api : display the logs of the REST API (nodejs)" + echo " dojo.sh logs tracker : display the logs of the Tracker (nodejs)" + echo " dojo.sh logs pushtx : display the logs of the pushTx API (nodejs)" + echo " dojo.sh logs pushtx-orchest : display the logs of the pushTx Orchestrator (nodejs)" + echo " " + echo " Available options (only available for api, tracker, pushtx and pushtx-orchest modules):" + echo " -d [VALUE] : select the type of log to be displayed." + echo " VALUE can be output (default) or error." + echo " -n [VALUE] : display the last VALUE lines" + echo " " + echo " onion Display the Tor onion address allowing your wallet to access your dojo." + echo " " + echo " restart Restart your dojo." + echo " " + echo " start Start your dojo." + echo " " + echo " stop Stop your dojo." + echo " " + echo " uninstall Delete your dojo. Be careful! This command will also remove all data." +} + + +# +# Parse options to the dojo command +# +while getopts ":h" opt; do + case ${opt} in + h ) + help + exit 0 + ;; + \? ) + echo "Invalid Option: -$OPTARG" 1>&2 + exit 1 + ;; + esac +done + +shift $((OPTIND -1)) + + +subcommand=$1; shift + +case "$subcommand" in + bitcoin-cli ) + docker exec -it bitcoind bitcoin-cli \ + -rpcconnect=bitcoind \ + --rpcport=28256 \ + --rpcuser="$BITCOIND_RPC_USER" \ + --rpcpassword="$BITCOIND_RPC_PASSWORD" \ + $1 $2 $3 $4 $5 + ;; + help ) + help + ;; + install ) + install + ;; + logs ) + module=$1; shift + display="output" + numlines=0 + + # Process package options + while getopts ":d:n:" opt; do + case ${opt} in + d ) + display=$OPTARG + ;; + n ) + numlines=$OPTARG + ;; + \? ) + echo "Invalid Option: -$OPTARG" 1>&2 + exit 1 + ;; + : ) + echo "Invalid Option: -$OPTARG requires an argument" 1>&2 + exit 1 + ;; + esac + done + shift $((OPTIND -1)) + + logs $module $display $numlines + ;; + onion ) + onion + ;; + restart ) + restart + ;; + start ) + start + ;; + stop ) + stop + ;; + uninstall ) + uninstall + ;; +esac \ No newline at end of file diff --git a/docker/my-dojo/mysql/Dockerfile b/docker/my-dojo/mysql/Dockerfile new file mode 100644 index 0000000..4802b8a --- /dev/null +++ b/docker/my-dojo/mysql/Dockerfile @@ -0,0 +1,7 @@ +FROM mysql:5.7.25 + +# Copy mysql config +COPY ./docker/my-dojo/mysql/mysql-dojo.cnf /etc/mysql/conf.d/mysql-dojo.cnf + +# Copy content of mysql scripts into /docker-entrypoint-initdb.d +COPY ./db-scripts/ /docker-entrypoint-initdb.d \ No newline at end of file diff --git a/docker/my-dojo/mysql/mysql-dojo.cnf b/docker/my-dojo/mysql/mysql-dojo.cnf new file mode 100644 index 0000000..1b697f5 --- /dev/null +++ b/docker/my-dojo/mysql/mysql-dojo.cnf @@ -0,0 +1,2 @@ +[mysqld] +sql_mode="NO_ENGINE_SUBSTITUTION" \ No newline at end of file diff --git a/docker/my-dojo/nginx/Dockerfile b/docker/my-dojo/nginx/Dockerfile new file mode 100644 index 0000000..4eef0b5 --- /dev/null +++ b/docker/my-dojo/nginx/Dockerfile @@ -0,0 +1,18 @@ +FROM nginx:1.15.10-alpine + +# Create data directory +ENV LOGS_DIR /data/logs + +RUN mkdir -p "$LOGS_DIR" && \ + chown -R nginx:nginx "$LOGS_DIR" + +# Copy configuration files +COPY ./nginx.conf /etc/nginx/nginx.conf + +COPY ./dojo.conf /etc/nginx/sites-enabled/dojo.conf + +# Copy wait-for script +COPY ./wait-for /wait-for + +RUN chmod u+x /wait-for && \ + chmod g+x /wait-for \ No newline at end of file diff --git a/docker/my-dojo/nginx/dojo.conf b/docker/my-dojo/nginx/dojo.conf new file mode 100644 index 0000000..e9aa240 --- /dev/null +++ b/docker/my-dojo/nginx/dojo.conf @@ -0,0 +1,53 @@ +# Proxy WebSockets +# https://www.nginx.com/blog/websocket-nginx/ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +# WebSocket server listening here +upstream websocket { + server node:8080; +} + +# Site Configuration +server { + listen 80; + server_name _; + + # Set proxy timeouts for the application + proxy_connect_timeout 600; + proxy_read_timeout 600; + proxy_send_timeout 600; + send_timeout 600; + + # Proxy WebSocket connections first + location /v2/inv { + proxy_pass http://websocket; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } + + # PushTX server is separate, so proxy first + location /v2/pushtx/ { + proxy_pass http://node:8081/; + } + + # Proxy requests to maintenance tool + location /admin/ { + proxy_pass http://node:8080/static/admin/; + } + + # Proxy all other v2 requests to the accounts server + location /v2/ { + proxy_pass http://node:8080/; + } + + # Serve remaining requests + location / { + return 200 '{"status":"ok"}'; + add_header Content-Type application/json; + } +} + diff --git a/docker/my-dojo/nginx/nginx.conf b/docker/my-dojo/nginx/nginx.conf new file mode 100644 index 0000000..ef6e6bc --- /dev/null +++ b/docker/my-dojo/nginx/nginx.conf @@ -0,0 +1,44 @@ +user nginx; +worker_processes auto; +daemon off; + +# Log critical errors and higher +error_log /data/logs/error.log crit; +pid /var/run/nginx.pid; + + +events { + worker_connections 1024; +} + + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Disable activity logging for privacy. + access_log off; + + # Do not reveal the version of server + server_tokens off; + + sendfile on; + + keepalive_timeout 65; + + # Enable response compression + gzip on; + # Compression level: 1-9 + gzip_comp_level 1; + # Disable gzip compression for older IE + gzip_disable msie6; + # Minimum length of response before gzip kicks in + gzip_min_length 128; + # Compress these MIME types in addition to text/html + gzip_types application/json; + # Help with proxying by adding the Vary: Accept-Encoding response + gzip_vary on; + + include /etc/nginx/sites-enabled/*.conf; +} + diff --git a/docker/my-dojo/nginx/wait-for b/docker/my-dojo/nginx/wait-for new file mode 100644 index 0000000..ddfc39e --- /dev/null +++ b/docker/my-dojo/nginx/wait-for @@ -0,0 +1,79 @@ +#!/bin/sh + +TIMEOUT=15 +QUIET=0 + +echoerr() { + if [ "$QUIET" -ne 1 ]; then printf "%s\n" "$*" 1>&2; fi +} + +usage() { + exitcode="$1" + cat << USAGE >&2 +Usage: + $cmdname host:port [-t timeout] [-- command args] + -q | --quiet Do not output any status messages + -t TIMEOUT | --timeout=timeout Timeout in seconds, zero for no timeout + -- COMMAND ARGS Execute command with args after the test finishes +USAGE + exit "$exitcode" +} + +wait_for() { + for i in `seq $TIMEOUT` ; do + nc -z "$HOST" "$PORT" > /dev/null 2>&1 + + result=$? + if [ $result -eq 0 ] ; then + if [ $# -gt 0 ] ; then + exec "$@" + fi + exit 0 + fi + sleep 1 + done + echo "Operation timed out" >&2 + exit 1 +} + +while [ $# -gt 0 ] +do + case "$1" in + *:* ) + HOST=$(printf "%s\n" "$1"| cut -d : -f 1) + PORT=$(printf "%s\n" "$1"| cut -d : -f 2) + shift 1 + ;; + -q | --quiet) + QUIET=1 + shift 1 + ;; + -t) + TIMEOUT="$2" + if [ "$TIMEOUT" = "" ]; then break; fi + shift 2 + ;; + --timeout=*) + TIMEOUT="${1#*=}" + shift 1 + ;; + --) + shift + break + ;; + --help) + usage 0 + ;; + *) + echoerr "Unknown argument: $1" + usage 1 + ;; + esac +done + +if [ "$HOST" = "" -o "$PORT" = "" ]; then + echoerr "Error: you need to provide a host and port to test." + usage 2 +fi + +wait_for "$@" diff --git a/docker/my-dojo/node/Dockerfile b/docker/my-dojo/node/Dockerfile new file mode 100644 index 0000000..7a73a0d --- /dev/null +++ b/docker/my-dojo/node/Dockerfile @@ -0,0 +1,43 @@ +FROM node:8.12.0-stretch + +ENV LOGS_DIR /data/logs +ENV APP_DIR /home/node/app + + +# Install forever +RUN npm install -g forever + +# Create data directory +RUN mkdir -p "$LOGS_DIR" && \ + chown -R node:node "$LOGS_DIR" + +# Create app directory +RUN mkdir "$APP_DIR" && \ + chown -R node:node "$APP_DIR" + +# Copy app source files into APP_DIR +COPY . "$APP_DIR" + +# Install node modules required by the app +RUN cd "$APP_DIR" && \ + npm install --only=prod + +# Copy config file +COPY ./docker/my-dojo/node/keys.index.js "$APP_DIR/keys/index.js" +RUN chown node:node "$APP_DIR/keys/index.js" + +# Copy restart script +COPY ./docker/my-dojo/node/restart.sh "$APP_DIR/restart.sh" + +RUN chown node:node "$APP_DIR/restart.sh" && \ + chmod u+x "$APP_DIR/restart.sh" && \ + chmod g+x "$APP_DIR/restart.sh" + +# Copy wait-for-it script +COPY ./docker/my-dojo/node/wait-for-it.sh "$APP_DIR/wait-for-it.sh" + +RUN chown node:node "$APP_DIR/wait-for-it.sh" && \ + chmod u+x "$APP_DIR/wait-for-it.sh" && \ + chmod g+x "$APP_DIR/wait-for-it.sh" + +USER node \ No newline at end of file diff --git a/docker/my-dojo/node/keys.index.js b/docker/my-dojo/node/keys.index.js new file mode 100644 index 0000000..c1831ac --- /dev/null +++ b/docker/my-dojo/node/keys.index.js @@ -0,0 +1,244 @@ +/*! + * keys/index-example.js + * Copyright (c) 2016-2018, Samourai Wallet (CC BY-NC-ND 4.0 License). + */ + + +/** + * Desired structure of /keys/index.js, which is ignored in the repository. + */ +module.exports = { + /* + * Mainnet parameters + */ + bitcoin: { + /* + * Dojo version + */ + dojoVersion: process.env.DOJO_VERSION_TAG, + /* + * Bitcoind + */ + bitcoind: { + // RPC API + rpc: { + // Login + user: process.env.BITCOIND_RPC_USER, + // Password + pass: process.env.BITCOIND_RPC_PASSWORD, + // IP address + host: 'bitcoind', + // TCP port + port: 28256 + }, + // ZMQ Tx notifications + zmqTx: 'tcp://bitcoind:9501', + // ZMQ Block notifications + zmqBlk: 'tcp://bitcoind:9502', + // Fee type (estimatesmartfee) + feeType: process.env.NODE_FEE_TYPE + }, + /* + * MySQL database + */ + db: { + // User + user: process.env.MYSQL_USER, + // Password + pass: process.env.MYSQL_PASSWORD, + // IP address + host: 'db', + // TCP port + port: 3306, + // Db name + database: process.env.MYSQL_DATABASE, + // Timeout + acquireTimeout: 15000, + // Max number of concurrent connections + // for each module + connectionLimitApi: 50, + connectionLimitTracker: 10, + connectionLimitPushTxApi: 5, + connectionLimitPushTxOrchestrator: 5 + }, + /* + * TCP Ports + */ + ports: { + // Port used by the API + account: 8080, + // Port used by pushtx + pushtx: 8081, + // Port used by the tracker for its notifications + tracker: 5555, + // Port used by pushtx for its notifications + notifpushtx: 5556, + // Port used by the pushtx orchestrator for its notifications + orchestrator: 5557 + }, + /* + * HTTPS + * Activate only if node js is used as frontend web server + * (no nginx proxy server) + */ + https: { + // HTTPS for the API + account: { + // Activate https + active: false, + // Filepath of server private key + // (shoud be stored in keys/sslcert) + keypath: '', + // Passphrase of the private key + passphrase: '', + // Filepath of server certificate + // (shoud be stored in keys/sslcert) + certpath: '', + // Filepath of CA certificate + // (shoud be stored in keys/sslcert) + capath: '' + }, + // HTTPS for pushtx + pushtx: { + // Activate https + active: false, + // Filepath of server private key + // (shoud be stored in keys/sslcert) + keypath: '', + // Passphrase of the private key + passphrase: '', + // Filepath of server certificate + // (shoud be stored in keys/sslcert) + certpath: '', + // Filepath of CA certificate + // (shoud be stored in keys/sslcert) + capath: '' + } + }, + /* + * Authenticated access to the APIs (account & pushtx) + */ + auth: { + // Name of the authentication strategy used + // Available values: + // null : No authentication + // 'localApiKey' : authentication with a shared local api key + activeStrategy: 'localApiKey', + // Flag indicating if authenticated access is mandatory + // (useful for launch, othewise should be true) + // @todo Set to true !!! + mandatory: true, + // List of available authentication strategies + strategies: { + // Authentication with a shared local api key + localApiKey: { + // List of API keys (alphanumeric characters) + apiKeys: [process.env.NODE_API_KEY], + // Admin key (alphanumeric characters) + adminKey: process.env.NODE_ADMIN_KEY, + // DO NOT MODIFY + configurator: 'localapikey-strategy-configurator' + } + }, + // Configuration of Json Web Tokens + // used for the management of authorizations + jwt: { + // Secret passphrase used by the server to sign the jwt + // (alphanumeric characters) + secret: process.env.NODE_JWT_SECRET, + accessToken: { + // Number of seconds after which the jwt expires + expires: parseInt(process.env.NODE_JWT_ACCESS_EXPIRES) + }, + refreshToken: { + // Number of seconds after which the jwt expires + expires: parseInt(process.env.NODE_JWT_REFRESH_EXPIRES) + } + } + }, + /* + * Prefixes used by the API + * for /support and /status endpoints + */ + prefixes: { + // Prefix for /support endpoint + support: process.env.NODE_PREFIX_SUPPORT, + // Prefix for /status endpoint + status: process.env.NODE_PREFIX_STATUS, + // Prefix for pushtx /status endpoint + statusPushtx: process.env.NODE_PREFIX_STATUS_PUSHTX + }, + /* + * Gaps used for derivation of keys + */ + gap: { + // Gap for derivation of external addresses + external: parseInt(process.env.NODE_GAP_EXTERNAL), + // Gap for derivation of internal (change) addresses + internal: parseInt(process.env.NODE_GAP_INTERNAL) + }, + /* + * Multiaddr endpoint + */ + multiaddr: { + // Number of transactions returned by the endpoint + transactions: 50 + }, + /* + * Third party explorers + * used for fast scan of addresses + */ + explorers: { + // Use local bitcoind for imports and rescans + // or use OXT as a fallback + // Values: active | inactive + bitcoind: process.env.NODE_IMPORT_FROM_BITCOIND, + // Use a SOCKS5 proxy for all communications with external services + // Values: null if no socks5 proxy used, otherwise the url of the socks5 proxy + socks5Proxy: 'socks5h://172.28.1.4:9050', + // OXT + oxt: process.env.NODE_URL_OXT_API + }, + /* + * Max number of transactions per address + * accepted during fast scan + */ + addrFilterThreshold: parseInt(process.env.NODE_ADDR_FILTER_THRESHOLD), + /* + * Pool of child processes + * for parallel derivation of addresses + * Be careful with these parameters ;) + */ + addrDerivationPool: { + // Min number of child processes always running + minNbChildren: parseInt(process.env.NODE_ADDR_DERIVATION_MIN_CHILD), + // Max number of child processes allowed + maxNbChildren: parseInt(process.env.NODE_ADDR_DERIVATION_MAX_CHILD), + // Max duration + acquireTimeoutMillis: 60000, + // Parallel derivation threshold + // (use parallel derivation if number of addresses to be derived + // is greater than thresholdParalleDerivation) + thresholdParallelDerivation: parseInt(process.env.NODE_ADDR_DERIVATION_THRESHOLD), + }, + /* + * PushTx - Scheduler + */ + txsScheduler: { + // Max number of transactions allowed in a single script + maxNbEntries: parseInt(process.env.NODE_TXS_SCHED_MAX_ENTRIES), + // Max number of blocks allowed in the future + maxDeltaHeight: parseInt(process.env.NODE_TXS_SCHED_MAX_DELTA_HEIGHT) + }, + /* + * Tracker + */ + tracker: { + // Processing of mempool (periodicity in ms) + mempoolProcessPeriod: parseInt(process.env.NODE_TRACKER_MEMPOOL_PERIOD), + // Processing of unconfirmed transactions (periodicity in ms) + unconfirmedTxsProcessPeriod: parseInt(process.env.NODE_TRACKER_UNCONF_TXS_PERIOD) + } + } + +} diff --git a/docker/my-dojo/node/restart.sh b/docker/my-dojo/node/restart.sh new file mode 100644 index 0000000..dea0ff9 --- /dev/null +++ b/docker/my-dojo/node/restart.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +cd /home/node/app/accounts +forever start -a -l /dev/null -o /data/logs/api-output.log -e /data/logs/api-error.log index.js + +cd /home/node/app/pushtx +forever start -a -l /dev/null -o /data/logs/pushtx-output.log -e /data/logs/pushtx-error.log index.js +forever start -a -l /dev/null -o /data/logs/pushtx-orchest-output.log -e /data/logs/pushtx-orchest-error.log index-orchestrator.js + +cd /home/node/app/tracker +forever start -a -l /dev/null -o /data/logs/tracker-output.log -e /data/logs/tracker-error.log index.js + +forever --fifo logs 0 \ No newline at end of file diff --git a/docker/my-dojo/node/wait-for-it.sh b/docker/my-dojo/node/wait-for-it.sh new file mode 100644 index 0000000..071c2be --- /dev/null +++ b/docker/my-dojo/node/wait-for-it.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# Use this script to test if a given TCP host/port are available + +WAITFORIT_cmdname=${0##*/} + +echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } + +usage() +{ + cat << USAGE >&2 +Usage: + $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] + -h HOST | --host=HOST Host or IP under test + -p PORT | --port=PORT TCP port under test + Alternatively, you specify the host and port as host:port + -s | --strict Only execute subcommand if the test succeeds + -q | --quiet Don't output any status messages + -t TIMEOUT | --timeout=TIMEOUT + Timeout in seconds, zero for no timeout + -- COMMAND ARGS Execute command with args after the test finishes +USAGE + exit 1 +} + +wait_for() +{ + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + else + echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" + fi + WAITFORIT_start_ts=$(date +%s) + while : + do + if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then + nc -z $WAITFORIT_HOST $WAITFORIT_PORT + WAITFORIT_result=$? + else + (echo > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 + WAITFORIT_result=$? + fi + if [[ $WAITFORIT_result -eq 0 ]]; then + WAITFORIT_end_ts=$(date +%s) + echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" + break + fi + sleep 1 + done + return $WAITFORIT_result +} + +wait_for_wrapper() +{ + # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 + if [[ $WAITFORIT_QUIET -eq 1 ]]; then + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + else + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + fi + WAITFORIT_PID=$! + trap "kill -INT -$WAITFORIT_PID" INT + wait $WAITFORIT_PID + WAITFORIT_RESULT=$? + if [[ $WAITFORIT_RESULT -ne 0 ]]; then + echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + fi + return $WAITFORIT_RESULT +} + +# process arguments +while [[ $# -gt 0 ]] +do + case "$1" in + *:* ) + WAITFORIT_hostport=(${1//:/ }) + WAITFORIT_HOST=${WAITFORIT_hostport[0]} + WAITFORIT_PORT=${WAITFORIT_hostport[1]} + shift 1 + ;; + --child) + WAITFORIT_CHILD=1 + shift 1 + ;; + -q | --quiet) + WAITFORIT_QUIET=1 + shift 1 + ;; + -s | --strict) + WAITFORIT_STRICT=1 + shift 1 + ;; + -h) + WAITFORIT_HOST="$2" + if [[ $WAITFORIT_HOST == "" ]]; then break; fi + shift 2 + ;; + --host=*) + WAITFORIT_HOST="${1#*=}" + shift 1 + ;; + -p) + WAITFORIT_PORT="$2" + if [[ $WAITFORIT_PORT == "" ]]; then break; fi + shift 2 + ;; + --port=*) + WAITFORIT_PORT="${1#*=}" + shift 1 + ;; + -t) + WAITFORIT_TIMEOUT="$2" + if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi + shift 2 + ;; + --timeout=*) + WAITFORIT_TIMEOUT="${1#*=}" + shift 1 + ;; + --) + shift + WAITFORIT_CLI=("$@") + break + ;; + --help) + usage + ;; + *) + echoerr "Unknown argument: $1" + usage + ;; + esac +done + +if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then + echoerr "Error: you need to provide a host and port to test." + usage +fi + +WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} +WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} +WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} +WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} + +# check to see if timeout is from busybox? +WAITFORIT_TIMEOUT_PATH=$(type -p timeout) +WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) +if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then + WAITFORIT_ISBUSY=1 + WAITFORIT_BUSYTIMEFLAG="-t" + +else + WAITFORIT_ISBUSY=0 + WAITFORIT_BUSYTIMEFLAG="" +fi + +if [[ $WAITFORIT_CHILD -gt 0 ]]; then + wait_for + WAITFORIT_RESULT=$? + exit $WAITFORIT_RESULT +else + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + wait_for_wrapper + WAITFORIT_RESULT=$? + else + wait_for + WAITFORIT_RESULT=$? + fi +fi + +if [[ $WAITFORIT_CLI != "" ]]; then + if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then + echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" + exit $WAITFORIT_RESULT + fi + exec "${WAITFORIT_CLI[@]}" +else + exit $WAITFORIT_RESULT +fi diff --git a/docker/my-dojo/tor/Dockerfile b/docker/my-dojo/tor/Dockerfile new file mode 100644 index 0000000..60cd36d --- /dev/null +++ b/docker/my-dojo/tor/Dockerfile @@ -0,0 +1,55 @@ +FROM debian:stretch + +ENV TOR_HOME /var/lib/tor + +# Install Tor +RUN set -ex && \ + apt-get update && \ + apt-get install -y git libevent-dev zlib1g-dev libssl-dev gcc make automake ca-certificates autoconf musl-dev coreutils && \ + mkdir -p /usr/local/src/ && \ + git clone https://git.torproject.org/tor.git /usr/local/src/tor && \ + cd /usr/local/src/tor && \ + git checkout tor-0.3.5.8 && \ + ./autogen.sh && \ + ./configure \ + --disable-asciidoc \ + --sysconfdir=/etc \ + --disable-unittests && \ + make && make install && \ + cd .. && \ + rm -rf tor + +# Create group & user tor +RUN addgroup --system -gid 1107 tor && \ + adduser --system --ingroup tor -uid 1104 tor + +# Create group & user bitcoin and add user to tor group +RUN addgroup --system -gid 1108 bitcoin && \ + adduser --system --ingroup bitcoin -uid 1105 bitcoin && \ + usermod -a -G tor bitcoin + +# Create /etc/tor directory +RUN mkdir -p /etc/tor/ && \ + chown -Rv tor:tor /etc/tor + +# Create .tor subdirectory of TOR_HOME +RUN mkdir -p "$TOR_HOME/.tor" && \ + chown -Rv tor:tor "$TOR_HOME" && \ + chmod -R 700 "$TOR_HOME" + +# Copy Tor configuration file +COPY ./torrc /etc/tor/torrc +RUN chown tor:tor /etc/tor/torrc + +# Copy wait-for-it script +COPY ./wait-for-it.sh /wait-for-it.sh + +RUN chown tor:tor /wait-for-it.sh && \ + chmod u+x /wait-for-it.sh && \ + chmod g+x /wait-for-it.sh + +# Expose socks port +EXPOSE 9050 + +# Switch to user tor +USER tor diff --git a/docker/my-dojo/tor/torrc b/docker/my-dojo/tor/torrc new file mode 100644 index 0000000..a04fbdc --- /dev/null +++ b/docker/my-dojo/tor/torrc @@ -0,0 +1,49 @@ +## Tor opens a socks proxy on port 9050 by default -- even if you don't +## configure one below. Set "SocksPort 0" if you plan to run Tor only +## as a relay, and not make any local application connections yourself. + +# Socks is only available from dojonet +SocksPort 172.28.1.4:9050 + +## Entry policies to allow/deny SOCKS requests based on IP address. +## First entry that matches wins. If no SocksPolicy is set, we accept +## all (and only) requests that reach a SocksPort. Untrusted users who +## can access your SocksPort may be able to learn about the connections +## you make. + +# Socks is only available from dojonet +SocksPolicy accept 172.28.0.0/16 +SocksPolicy reject * + +## The directory for keeping all the keys/etc. By default, we store +## things in $HOME/.tor on Unix, and in Application Data\tor on Windows. + +DataDirectory /var/lib/tor/.tor + +## The port on which Tor will listen for local connections from Tor +## controller applications, as documented in control-spec.txt. + +ControlPort 9051 + +## If you enable the controlport, be sure to enable one of these +## authentication methods, to prevent attackers from accessing it. + +CookieAuthentication 1 +CookieAuthFileGroupReadable 1 + + +############### This section is just for location-hidden services ### + +## Once you have configured a hidden service, you can look at the +## contents of the file ".../hidden_service/hostname" for the address +## to tell people. +## HiddenServicePort x y:z says to redirect requests on port x to the +## address y:z. + +HiddenServiceDir /var/lib/tor/hsv2dojo +HiddenServiceVersion 2 +HiddenServicePort 80 172.29.1.3:80 + +HiddenServiceDir /var/lib/tor/hsv3dojo +HiddenServiceVersion 3 +HiddenServicePort 80 172.29.1.3:80 diff --git a/docker/my-dojo/tor/wait-for-it.sh b/docker/my-dojo/tor/wait-for-it.sh new file mode 100644 index 0000000..071c2be --- /dev/null +++ b/docker/my-dojo/tor/wait-for-it.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# Use this script to test if a given TCP host/port are available + +WAITFORIT_cmdname=${0##*/} + +echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } + +usage() +{ + cat << USAGE >&2 +Usage: + $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] + -h HOST | --host=HOST Host or IP under test + -p PORT | --port=PORT TCP port under test + Alternatively, you specify the host and port as host:port + -s | --strict Only execute subcommand if the test succeeds + -q | --quiet Don't output any status messages + -t TIMEOUT | --timeout=TIMEOUT + Timeout in seconds, zero for no timeout + -- COMMAND ARGS Execute command with args after the test finishes +USAGE + exit 1 +} + +wait_for() +{ + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + else + echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" + fi + WAITFORIT_start_ts=$(date +%s) + while : + do + if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then + nc -z $WAITFORIT_HOST $WAITFORIT_PORT + WAITFORIT_result=$? + else + (echo > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 + WAITFORIT_result=$? + fi + if [[ $WAITFORIT_result -eq 0 ]]; then + WAITFORIT_end_ts=$(date +%s) + echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" + break + fi + sleep 1 + done + return $WAITFORIT_result +} + +wait_for_wrapper() +{ + # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 + if [[ $WAITFORIT_QUIET -eq 1 ]]; then + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + else + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + fi + WAITFORIT_PID=$! + trap "kill -INT -$WAITFORIT_PID" INT + wait $WAITFORIT_PID + WAITFORIT_RESULT=$? + if [[ $WAITFORIT_RESULT -ne 0 ]]; then + echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + fi + return $WAITFORIT_RESULT +} + +# process arguments +while [[ $# -gt 0 ]] +do + case "$1" in + *:* ) + WAITFORIT_hostport=(${1//:/ }) + WAITFORIT_HOST=${WAITFORIT_hostport[0]} + WAITFORIT_PORT=${WAITFORIT_hostport[1]} + shift 1 + ;; + --child) + WAITFORIT_CHILD=1 + shift 1 + ;; + -q | --quiet) + WAITFORIT_QUIET=1 + shift 1 + ;; + -s | --strict) + WAITFORIT_STRICT=1 + shift 1 + ;; + -h) + WAITFORIT_HOST="$2" + if [[ $WAITFORIT_HOST == "" ]]; then break; fi + shift 2 + ;; + --host=*) + WAITFORIT_HOST="${1#*=}" + shift 1 + ;; + -p) + WAITFORIT_PORT="$2" + if [[ $WAITFORIT_PORT == "" ]]; then break; fi + shift 2 + ;; + --port=*) + WAITFORIT_PORT="${1#*=}" + shift 1 + ;; + -t) + WAITFORIT_TIMEOUT="$2" + if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi + shift 2 + ;; + --timeout=*) + WAITFORIT_TIMEOUT="${1#*=}" + shift 1 + ;; + --) + shift + WAITFORIT_CLI=("$@") + break + ;; + --help) + usage + ;; + *) + echoerr "Unknown argument: $1" + usage + ;; + esac +done + +if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then + echoerr "Error: you need to provide a host and port to test." + usage +fi + +WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} +WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} +WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} +WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} + +# check to see if timeout is from busybox? +WAITFORIT_TIMEOUT_PATH=$(type -p timeout) +WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) +if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then + WAITFORIT_ISBUSY=1 + WAITFORIT_BUSYTIMEFLAG="-t" + +else + WAITFORIT_ISBUSY=0 + WAITFORIT_BUSYTIMEFLAG="" +fi + +if [[ $WAITFORIT_CHILD -gt 0 ]]; then + wait_for + WAITFORIT_RESULT=$? + exit $WAITFORIT_RESULT +else + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + wait_for_wrapper + WAITFORIT_RESULT=$? + else + wait_for + WAITFORIT_RESULT=$? + fi +fi + +if [[ $WAITFORIT_CLI != "" ]]; then + if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then + echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" + exit $WAITFORIT_RESULT + fi + exec "${WAITFORIT_CLI[@]}" +else + exit $WAITFORIT_RESULT +fi diff --git a/keys/index-example.js b/keys/index-example.js new file mode 100644 index 0000000..d9647c6 --- /dev/null +++ b/keys/index-example.js @@ -0,0 +1,349 @@ +/*! + * keys/index-example.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ + + +/** + * Desired structure of /keys/index.js, which is ignored in the repository. + */ +module.exports = { + /* + * Mainnet parameters + */ + bitcoin: { + /* + * Dojo version + */ + dojoVersion: '1.0.0', + /* + * Bitcoind + */ + bitcoind: { + // RPC API + rpc: { + // Login + user: 'user', + // Password + pass: 'password', + // IP address + host: '127.0.0.1', + // TCP port + port: 8332 + }, + // ZMQ Tx notifications + zmqTx: 'tcp://127.0.0.1:9501', + // ZMQ Block notifications + zmqBlk: 'tcp://127.0.0.1:9502', + // Fee type (estimatesmartfee) + feeType: 'ECONOMICAL' + }, + /* + * MySQL database + */ + db: { + // User + user: 'user', + // Password + pass: 'password', + // IP address + host: '127.0.0.1', + // TCP port + port: 3306, + // Db name + database: 'db_name', + // Timeout + acquireTimeout: 15000, + // Max number of concurrent connections + // for each module + connectionLimitApi: 50, + connectionLimitTracker: 10, + connectionLimitPushTxApi: 5, + connectionLimitPushTxOrchestrator: 5 + }, + /* + * TCP Ports + */ + ports: { + // Port used by the API + account: 8080, + // Port used by pushtx + pushtx: 8081, + // Port used by the tracker for its notifications + tracker: 5555, + // Port used by pushtx for its notifications + notifpushtx: 5556, + // Port used by the pushtx orchestrator for its notifications + orchestrator: 5557 + }, + /* + * HTTPS + * Activate only if node js is used as frontend web server + * (no nginx proxy server) + */ + https: { + // HTTPS for the API + account: { + // Activate https + active: false, + // Filepath of server private key + // (shoud be stored in keys/sslcert) + keypath: '', + // Passphrase of the private key + passphrase: '', + // Filepath of server certificate + // (shoud be stored in keys/sslcert) + certpath: '', + // Filepath of CA certificate + // (shoud be stored in keys/sslcert) + capath: '' + }, + // HTTPS for pushtx + pushtx: { + // Activate https + active: false, + // Filepath of server private key + // (shoud be stored in keys/sslcert) + keypath: '', + // Passphrase of the private key + passphrase: '', + // Filepath of server certificate + // (shoud be stored in keys/sslcert) + certpath: '', + // Filepath of CA certificate + // (shoud be stored in keys/sslcert) + capath: '' + } + }, + /* + * Authenticated access to the APIs (account & pushtx) + */ + auth: { + // Name of the authentication strategy used + // Available values: + // null : No authentication + // 'localApiKey' : authentication with a shared local api key + activeStrategy: 'localApiKey', + // Flag indicating if authenticated access is mandatory + // (useful for launch, othewise should be true) + mandatory: false, + // List of available authentication strategies + strategies: { + // Authentication with a shared local api key + localApiKey: { + // List of API keys (alphanumeric characters) + apiKeys: ['', ''], + // Admin key (alphanumeric characters) + adminKey: '', + // DO NOT MODIFY + configurator: 'localapikey-strategy-configurator' + } + }, + // Configuration of Json Web Tokens + // used for the management of authorizations + jwt: { + // Secret passphrase used by the server to sign the jwt + // (alphanumeric characters) + secret: '', + accessToken: { + // Number of seconds after which the jwt expires + expires: 600 + }, + refreshToken: { + // Number of seconds after which the jwt expires + expires: 7200 + } + } + }, + /* + * Prefixes used by the API + * for /support and /status endpoints + */ + prefixes: { + // Prefix for /support endpoint + support: 'support', + // Prefix for /status endpoint + status: 'status', + // Prefix for pushtx /status endpoint + statusPushtx: 'status' + }, + /* + * Gaps used for derivation of keys + */ + gap: { + // Gap for derivation of external addresses + external: 20, + // Gap for derivation of internal (change) addresses + internal: 20 + }, + /* + * Multiaddr endpoint + */ + multiaddr: { + // Number of transactions returned by the endpoint + transactions: 50 + }, + /* + * Third party explorers + * used for fast scan of addresses + */ + explorers: { + // Use local bitcoind for imports and rescans + // or use OXT as a fallback + // Values: active | inactive + bitcoind: 'active', + // Use a SOCKS5 proxy for all communications with external services + // Values: null if no socks5 proxy used, otherwise the url of the socks5 proxy + socks5Proxy: null, + // OXT + oxt: 'https://api.oxt.me' + }, + /* + * Max number of transactions per address + * accepted during fast scan + */ + addrFilterThreshold: 1000, + /* + * Pool of child processes + * for parallel derivation of addresses + * Be careful with these parameters ;) + */ + addrDerivationPool: { + // Min number of child processes always running + minNbChildren: 2, + // Max number of child processes allowed + maxNbChildren: 2, + // Max duration + acquireTimeoutMillis: 60000, + // Parallel derivation threshold + // (use parallel derivation if number of addresses to be derived + // is greater than thresholdParalleDerivation) + thresholdParallelDerivation: 10 + }, + /* + * PushTx - Scheduler + */ + txsScheduler: { + // Max number of transactions allowed in a single script + maxNbEntries: 10, + // Max number of blocks allowed in the future + maxDeltaHeight: 18 + }, + /* + * Tracker + */ + tracker: { + // Processing of mempool (periodicity in ms) + mempoolProcessPeriod: 2000, + // Processing of unconfirmed transactions (periodicity in ms) + unconfirmedTxsProcessPeriod: 300000 + } + }, + + /* + * Testnet parameters + */ + testnet: { + bitcoind: { + rpc: { + user: 'user', + pass: 'password', + host: '127.0.0.1', + port: 18332 + }, + zmqTx: 'tcp://127.0.0.1:19501', + zmqBlk: 'tcp://127.0.0.1:19502', + feeType: 'ECONOMICAL' + }, + db: { + user: 'user', + pass: 'password', + host: '127.0.0.1', + port: 3306, + database: 'db_name', + acquireTimeout: 15000, + connectionLimitApi: 5, + connectionLimitTracker: 5, + connectionLimitPushTxApi: 1, + connectionLimitPushTxOrchestrator: 5 + }, + ports: { + account: 18080, + pushtx: 18081, + tracker: 15555, + notifpushtx: 15556, + orchestrator: 15557 + }, + https: { + account: { + active: false, + keypath: '', + passphrase: '', + certpath: '', + capath: '' + }, + pushtx: { + active: false, + keypath: '', + passphrase: '', + certpath: '', + capath: '' + } + }, + auth: { + activeStrategy: null, + mandatory: false, + strategies: { + localApiKey: { + apiKeys: ['', ''], + adminKey: '', + configurator: 'localapikey-strategy-configurator' + } + }, + jwt: { + secret: 'myJwtSecret', + accessToken: { + expires: 600 + }, + refreshToken: { + expires: 7200 + } + } + }, + prefixes: { + support: 'support', + status: 'status', + statusPushtx: 'status' + }, + gap: { + external: 20, + internal: 20 + }, + multiaddr: { + transactions: 50 + }, + explorers: { + bitcoind: 'inactive', + socks5Proxy: null, + insight: [ + 'https://testnet-api.example.com' + ], + btccom: 'https://tchain.api.btc.com/v3' + }, + addrFilterThreshold: 1000, + addrDerivationPool: { + minNbChildren: 1, + maxNbChildren: 1, + acquireTimeoutMillis: 60000, + thresholdParallelDerivation: 10 + }, + txsScheduler: { + maxNbEntries: 10, + maxDeltaHeight: 18 + }, + tracker: { + mempoolProcessPeriod: 2000, + unconfirmedTxsProcessPeriod: 300000 + } + } +} \ No newline at end of file diff --git a/lib/auth/auth-rest-api.js b/lib/auth/auth-rest-api.js new file mode 100644 index 0000000..5d7a8de --- /dev/null +++ b/lib/auth/auth-rest-api.js @@ -0,0 +1,106 @@ +/*! + * lib/auth/auth-rest-api.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const bodyParser = require('body-parser') +const passport = require('passport') +const network = require('../bitcoin/network') +const keys = require('../../keys/')[network.key] +const HttpServer = require('../http-server/http-server') +const authentMgr = require('./authentication-manager') +const authorzMgr = require('./authorizations-manager') + + +/** + * Auth API endpoints + */ +class AuthRestApi { + + /** + * Constructor + * @param {pushtx.HttpServer} httpServer - HTTP server + */ + constructor(httpServer) { + this.httpServer = httpServer + + // Initialize passport + this.httpServer.app.use(passport.initialize()) + + // Check if authentication is activated + if (keys.auth.activeStrategy == null) + return + + // Establish routes + const urlencodedParser = bodyParser.urlencoded({ extended: true }) + + this.httpServer.app.post( + '/auth/login', + urlencodedParser, + authentMgr.authenticate({session: false}), + authentMgr.serialize, + authorzMgr.generateAuthorizations.bind(authorzMgr), + this.login.bind(this), + HttpServer.sendAuthError + ) + + this.httpServer.app.post( + '/auth/logout', + urlencodedParser, + authorzMgr.revokeAuthorizations.bind(authorzMgr), + this.logout.bind(this), + HttpServer.sendAuthError + ) + + this.httpServer.app.post( + '/auth/refresh', + urlencodedParser, + authorzMgr.refreshAuthorizations.bind(authorzMgr), + this.refresh.bind(this), + HttpServer.sendAuthError + ) + } + + /** + * Login + * @param {object} req - http request object + * @param {object} res - http response object + */ + login(req, res) { + try { + const result = {authorizations: req.authorizations} + const ret = JSON.stringify(result, null, 2) + HttpServer.sendRawData(res, ret) + } catch(e) { + HttpServer.sendError(res, e) + } + } + + /** + * Refresh + * @param {object} req - http request object + * @param {object} res - http response object + */ + refresh(req, res) { + try { + const result = {authorizations: req.authorizations} + const ret = JSON.stringify(result, null, 2) + HttpServer.sendRawData(res, ret) + } catch(e) { + HttpServer.sendError(res, e) + } + } + + /** + * Logout + * @param {object} req - http request object + * @param {object} res - http response object + */ + logout(req, res) { + HttpServer.sendOk(res) + } + +} + +module.exports = AuthRestApi diff --git a/lib/auth/authentication-manager.js b/lib/auth/authentication-manager.js new file mode 100644 index 0000000..91a5231 --- /dev/null +++ b/lib/auth/authentication-manager.js @@ -0,0 +1,77 @@ +/*! + * lib/auth/authentication-manager.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const passport = require('passport') +const network = require('../bitcoin/network') +const keys = require('../../keys/')[network.key] +const errors = require('../errors') +const Logger = require('../logger') + + +/** + * A singleton managing the authentication to the API + */ +class AuthenticationManager { + + /** + * Constructor + */ + constructor() { + this.activeStrategyName = '' + this.activeStrategy = null + // Configure the authentication strategy + this._configureStrategy() + } + + /** + * Configure the active strategy + */ + _configureStrategy() { + if (keys.auth.activeStrategy) { + this.activeStrategyName = keys.auth.activeStrategy + + try { + const configuratorName = keys.auth.strategies[this.activeStrategyName].configurator + const Configurator= require(`./${configuratorName}`) + + if (Configurator) { + this.activeStrategy = new Configurator() + this.activeStrategy.configure() + Logger.info(`Authentication strategy ${this.activeStrategyName} successfully configured`) + } + + } catch(e) { + Logger.error(e, errors.auth.INVALID_CONF) + } + } + } + + /** + * Authenticate a user + * @param {Object} options + */ + authenticate(options) { + return passport.authenticate(this.activeStrategyName, options) + } + + /** + * Serialize user's information + * @param {Object} req - http request object + * @param {Object} res - http response object + * @param {function} next - callback + */ + serialize(req, res, next) { + if (req.user == null) + req.user = {} + + req.user['authenticated'] = true + next() + } + +} + + +module.exports = new AuthenticationManager() diff --git a/lib/auth/authorizations-manager.js b/lib/auth/authorizations-manager.js new file mode 100644 index 0000000..c3e1f71 --- /dev/null +++ b/lib/auth/authorizations-manager.js @@ -0,0 +1,296 @@ +/*! + * lib/auth/authorizations-manager.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const validator = require('validator') +const jwt = require('jsonwebtoken') +const network = require('../bitcoin/network') +const keys = require('../../keys/')[network.key] +const errors = require('../errors') +const Logger = require('../logger') + + +/** + * A singleton managing authorizations the API + */ +class AuthorizationsManager { + + /** + * Constructor + */ + constructor() { + try { + // Constants + this.ISS = 'Samourai Wallet backend' + this.TOKEN_TYPE_ACCESS = 'access-token' + this.TOKEN_TYPE_REFRESH = 'refresh-token' + this.TOKEN_PROFILE_API = 'api' + this.TOKEN_PROFILE_ADMIN = 'admin' + + this.authActive = (keys.auth.activeStrategy != null) + this._secret = keys.auth.jwt.secret + this.isMandatory = keys.auth.mandatory + this.accessTokenExpires = keys.auth.jwt.accessToken.expires + this.refreshTokenExpires = keys.auth.jwt.refreshToken.expires + } catch(e) { + this._secret = null + Logger.error(e, errors.auth.INVALID_CONF) + } + } + + + /** + * Middleware generating authorization token + * @param {Object} req - http request object + * @param {Object} res - http response object + * @param {function} next - callback + */ + generateAuthorizations(req, res, next) { + if (!(req.user && req.user.authenticated)) + return next(errors.auth.TECH_ISSUE) + + // Generates an access token + const accessToken = this._generateAccessToken(req.user) + + // Generates a refresh token + const refreshToken = this._generateRefreshToken(req.user) + + // Stores the tokens in the request + req.authorizations = { + access_token: accessToken, + refresh_token: refreshToken + } + + next() + } + + /** + * Middleware refreshing authorizations + * @param {Object} req - http request object + * @param {Object} res - http response object + * @param {function} next - callback + */ + refreshAuthorizations(req, res, next) { + // Check if authentication is activated + if (!this.authActive) + return next() + + // Authentication is activated + // A refresh token is required + const refreshToken = this._extractRefreshToken(req) + if (!refreshToken) + return next(errors.auth.MISSING_JWT) + + try { + const decodedRefrehToken = this._verifyRefreshToken(refreshToken) + if (req.user == null) + req.user = {} + req.user['profile'] = decodedRefrehToken['prf'] + } catch(e) { + Logger.error(e, `${errors.auth.INVALID_JWT}: ${refreshToken}`) + return next(errors.auth.INVALID_JWT) + } + + // Generates a new access token + const accessToken = this._generateAccessToken(req.user) + + // Stores the access token in the request + req.authorizations = { + access_token: accessToken + } + + next() + } + + /** + * Middleware revoking authorizations + * @param {Object} req - http request object + * @param {Object} res - http response object + * @param {function} next - callback + */ + revokeAuthorizations(req, res, next) { + // Nothing to do (for now) + } + + /** + * Middleware checking if user is authenticated + * @param {Object} req - http request object + * @param {Object} res - http response object + * @param {function} next - callback + * @returns {boolean} returns true if user is authenticated, false otherwise + */ + checkAuthentication(req, res, next) { + // Check if authentication is activated + if (!this.authActive) + return next() + + // Authentication is activated + // A JSON web token is required + const token = this._extractAccessToken(req) + + if (this.isMandatory || token) { + try { + const decodedToken = this.isAuthenticated(token) + req.authorizations = {decoded_access_token: decodedToken} + next() + } catch (e) { + return next(e) + } + } else { + next() + } + } + + /** + * Middleware checking if user is authenticated and has admin profile + * @param {Object} req - http request object + * @param {Object} res - http response object + * @param {function} next - callback + * @returns {boolean} returns true if user is authenticated and has admin profile, false otherwise + */ + checkHasAdminProfile(req, res, next) { + // Check if authentication is activated + if (!this.authActive) + return next() + + // Authentication is activated + // A JSON web token is required + const token = this._extractAccessToken(req) + + try { + const decodedToken = this.isAuthenticated(token) + if (decodedToken['prf'] == this.TOKEN_PROFILE_ADMIN) { + req.authorizations = {decoded_access_token: decodedToken} + next() + } else { + return next(errors.auth.INVALID_PRF) + } + } catch (e) { + return next(e) + } + } + + /** + * Check if user is authenticated + * (i.e. we have received a valid json web token) + * @param {string} token - json web token + * @returns {boolean} returns the decoded token if valid + * throws an exception otherwise + */ + isAuthenticated(token) { + if (!token) { + Logger.error(null, `${errors.auth.MISSING_JWT}`) + throw errors.auth.MISSING_JWT + } + + try { + return this._verifyAccessToken(token) + } catch(e) { + //Logger.error(e, `${errors.auth.INVALID_JWT}: ${token}`) + throw errors.auth.INVALID_JWT + } + } + + /** + * Generate an access token + * @param {Object} user - user's information + * @returns {Object} returns a json web token + */ + _generateAccessToken(user) { + // Builds claims + const claims = { + 'iss': this.ISS, + 'type': this.TOKEN_TYPE_ACCESS, + 'prf': user['profile'] + } + + // Builds and signs the access token + return jwt.sign( + claims, + this._secret, + {expiresIn: this.accessTokenExpires} + ) + } + + /** + * Extract the access token from the http request + * @param {Object} req - http request object + * @returns {Object} returns the json web token + */ + _extractAccessToken(req) { + if (req.body && req.body.at && validator.isJWT(req.body.at)) + return req.body.at + + if (req.query && req.query.at && validator.isJWT(req.query.at)) + return req.query.at + + return null + } + + /** + * Verify an access token + * @param {Object} token - json web token + * @returns {Object} payload of the json web token + */ + _verifyAccessToken(token) { + const payload = jwt.verify(token, this._secret, {}) + + if (payload['type'] != this.TOKEN_TYPE_ACCESS) + throw errors.auth.INVALID_JWT + + return payload + } + + /** + * Generate an refresh token + * @param {Object} user - user's information + * @returns {Object} returns a json web token + */ + _generateRefreshToken(user) { + // Builds claims + const claims = { + 'iss': this.ISS, + 'type': this.TOKEN_TYPE_REFRESH, + 'prf': user['profile'] + } + // Builds and signs the access token + return jwt.sign( + claims, + this._secret, + {expiresIn: this.refreshTokenExpires} + ) + } + + /** + * Extract the refresh token from the http request + * @param {Object} req - http request object + * @returns {Object} returns the json web token + */ + _extractRefreshToken(req) { + if (req.body && req.body.rt && validator.isJWT(req.body.rt)) + return req.body.rt + + if (req.query && req.query.rt && validator.isJWT(req.query.rt)) + return req.query.rt + + return null + } + + /** + * Verify a refresh token + * @param {Object} token - json web token + * @returns {Object} payload of the json web token + */ + _verifyRefreshToken(token) { + const payload = jwt.verify(token, this._secret, {}) + + if (payload['type'] != this.TOKEN_TYPE_REFRESH) + throw errors.auth.INVALID_JWT + + return payload + } +} + +module.exports = new AuthorizationsManager() diff --git a/lib/auth/localapikey-strategy-configurator.js b/lib/auth/localapikey-strategy-configurator.js new file mode 100644 index 0000000..bd5ddae --- /dev/null +++ b/lib/auth/localapikey-strategy-configurator.js @@ -0,0 +1,62 @@ +/*! + * lib/auth/localapikey-strategy-configurator.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const passport = require('passport') +const Strategy = require('passport-localapikey-update').Strategy +const network = require('../bitcoin/network') +const keys = require('../../keys/')[network.key] +const errors = require('../errors') +const Logger = require('../logger') +const authorzMgr = require('./authorizations-manager') + + +/** + * A Passport configurator for a local API key strategy + */ +class LocalApiKeyStrategyConfigurator { + + /** + * Constructor + */ + constructor() {} + + /** + * Configure the strategy + */ + configure() { + const strategy = new Strategy({apiKeyField: 'apikey'}, this.authenticate) + passport.use(LocalApiKeyStrategyConfigurator.NAME, strategy) + } + + /** + * Authentication + * @param {object} req - http request object + * @param {string} apiKey - api key received + * @param {function} done - callback + */ + authenticate(apiKey, done) { + const _adminKey = keys.auth.strategies[LocalApiKeyStrategyConfigurator.NAME].adminKey + const _apiKeys = keys.auth.strategies[LocalApiKeyStrategyConfigurator.NAME].apiKeys + + if (apiKey == _adminKey) { + // Check if received key is a valid api key + Logger.info('Successful authentication with an admin key') + return done(null, {'profile': authorzMgr.TOKEN_PROFILE_ADMIN}) + } else if (_apiKeys.indexOf(apiKey) >= 0) { + // Check if received key is a valid api key + Logger.info('Successful authentication with an api key') + return done(null, {'profile': authorzMgr.TOKEN_PROFILE_API}) + } else { + Logger.error(null, `Authentication failure (apikey=${apiKey})`) + return done('Invalid API key', false) + } + } + +} + +LocalApiKeyStrategyConfigurator.NAME = 'localApiKey' + +module.exports = LocalApiKeyStrategyConfigurator diff --git a/lib/bitcoin/addresses-helper.js b/lib/bitcoin/addresses-helper.js new file mode 100644 index 0000000..76eb572 --- /dev/null +++ b/lib/bitcoin/addresses-helper.js @@ -0,0 +1,106 @@ +/*! + * lib/bitcoin/addresses-helper.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const bitcoin = require('bitcoinjs-lib') +const btcMessage = require('bitcoinjs-message') +const activeNet = require('./network').network + + +/** + * A singleton providing Addresses helper functions + */ +class AddressesHelper { + + /** + * Derives a P2PKH from a public key + * @param {Buffer} pubKeyBuffer - Buffer storing a public key + * @returns {string} return the derived address + */ + p2pkhAddress(pubKeyBuffer) { + const pubKeyHash = bitcoin.crypto.hash160(pubKeyBuffer) + return bitcoin.address.toBase58Check(pubKeyHash, activeNet.pubKeyHash) + } + + /** + * Derives a P2WPKH-P2SH from a public key + * @param {Buffer} pubKeyBuffer - Buffer storing a public key + * @returns {string} return the derived address + */ + p2wpkhP2shAddress(pubKeyBuffer) { + const pubKeyHash = bitcoin.crypto.hash160(pubKeyBuffer) + const witnessProgram = bitcoin.script.witnessPubKeyHash.output.encode(pubKeyHash) + const scriptPubKey = bitcoin.crypto.hash160(witnessProgram) + const outputScript = bitcoin.script.scriptHash.output.encode(scriptPubKey) + return bitcoin.address.fromOutputScript(outputScript, activeNet) + } + + /** + * Derives a P2WPKH from a public key + * @param {Buffer} pubKeyBuffer - Buffer storing a public key + * @returns {string} return the derived address + */ + p2wpkhAddress(pubKeyBuffer) { + const pubKeyHash = bitcoin.crypto.hash160(pubKeyBuffer) + const outputScript = bitcoin.script.witnessPubKeyHash.output.encode(pubKeyHash) + return bitcoin.address.fromOutputScript(outputScript, activeNet).toLowerCase() + } + + /** + * Verify the signature of a given message + * @param {string} msg - signed message + * @param {string} address - address used to sign the message + * @param {string} sig - signature of the message + * @returns {boolean} retuns true if signature is valid, otherwise false + */ + verifySignature(msg, address, sig) { + try { + const prefix = activeNet.messagePrefix + return btcMessage.verify(msg, prefix, address, sig) + } catch(e) { + return false + } + } + + /** + * Checks if a string seems like a supported pubkey + * @param {string} str - string + * @returns {boolean} return true if str is a supported pubkey format, false otherwise + */ + isSupportedPubKey(str) { + return (str.length == 66 && (str.startsWith('02') || str.startsWith('03'))) + } + + /** + * Check if string is a Bech32 address + * @param {string} str - string to be checked + * @returns {boolean} return true if str is a Bech32 address, false otherwise + */ + isBech32(str) { + try { + bitcoin.address.fromBech32(str) + return true + } catch(e) { + return false + } + } + + /** + * Get the script hash associated to a Bech32 address + * @param {string} str - bech32 address + * @returns {string} script hash in hex format + */ + getScriptHashFromBech32(str) { + try { + return bitcoin.address.fromBech32(str).data.toString('hex') + } catch(e) { + Logger.error(e, 'AddressesHelper.getScriptHashFromBech32()') + return null + } + } + +} + +module.exports = new AddressesHelper() diff --git a/lib/bitcoin/addresses-service.js b/lib/bitcoin/addresses-service.js new file mode 100644 index 0000000..fed881d --- /dev/null +++ b/lib/bitcoin/addresses-service.js @@ -0,0 +1,44 @@ +/*! + * lib/bitcoin/addresses-service.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const db = require('../db/mysql-db-wrapper') +const remote = require('../remote-importer/remote-importer') + + +/** + * A singleton providing an Adresses service + */ +class AddressesService { + + /** + * Constructor + */ + constructor() {} + + /** + * Rescan the blockchain for an address + * @param {string} address - bitcoin address + * @returns {Promise} + */ + async rescan(address) { + const hdaccount = await db.getUngroupedHDAccountsByAddresses([address]) + // Don't filter addresses associated to an HDAccount + const filterAddr = !(hdaccount.length > 0 && hdaccount[0]['hdID']) + return remote.importAddresses([address], filterAddr) + } + + /** + * Restore an address in db + * @param {string[]} addresses - array of bitcoin addresses + * @param {boolean} filterAddr - true if addresses should be filter, false otherwise + * @returns {Promise} + */ + async restoreAddresses(address, filterAddr) { + return remote.importAddresses(address, filterAddr) + } +} + +module.exports = new AddressesService() \ No newline at end of file diff --git a/lib/bitcoin/hd-accounts-helper.js b/lib/bitcoin/hd-accounts-helper.js new file mode 100644 index 0000000..b42c19e --- /dev/null +++ b/lib/bitcoin/hd-accounts-helper.js @@ -0,0 +1,400 @@ +/*! + * lib/bitcoin/hd-accounts-helper.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const cp = require('child_process') +const LRU = require('lru-cache') +const bitcoin = require('bitcoinjs-lib') +const bs58check = require('bs58check') +const bs58 = require('bs58') +const errors = require('../errors') +const Logger = require('../logger') +const ForkPool = require('../fork-pool') +const network = require('./network') +const activeNet = network.network +const keys = require('../../keys/')[network.key] +const addrHelper = require('./addresses-helper') + + +/** + * A singleton providing HD Accounts helper functions + */ +class HDAccountsHelper { + + /** + * Constructor + */ + constructor() { + // HD accounts types + this.BIP44 = 0 + this.BIP49 = 1 + this.BIP84 = 2 + this.LOCKED = 1<<7 + + // Magic numbers + this.MAGIC_XPUB = 0x0488b21e + this.MAGIC_TPUB = 0x043587cf + this.MAGIC_YPUB = 0x049d7cb2 + this.MAGIC_UPUB = 0x044a5262 + this.MAGIC_ZPUB = 0x04b24746 + this.MAGIC_VPUB = 0x045f1cf6 + + // HD accounts cache + this.nodes = LRU({ + // Maximum number of nodes to store in cache + max: 1000, + // Function used to compute length of item + length: (n, key) => 1, + // Maximum age for items in the cache. Items do not expire + maxAge: Infinity + }) + + // Default = external addresses derivation deactivated + this.externalDerivationActivated = false + this.derivationPool = null + } + + /** + * Activate external derivation of addresses + * (provides improved performances) + */ + activateExternalDerivation() { + // Pool of child processes used for derivation of addresses + const poolKeys = keys.addrDerivationPool + + this.derivationPool = new ForkPool( + `${__dirname}/parallel-address-derivation.js`, + { + networkKey: network.key, + max: poolKeys.maxNbChildren, + min: poolKeys.minNbChildren, + acquireTimeoutMillis: poolKeys.acquireTimeoutMillis + } + ) + + this.externalDerivationActivated = true + } + + /** + * Check if a string encodes a xpub/tpub + * @param {string} xpub - extended public key to be checked + * @returns {boolean} returns true if xpub encodes a xpub/tpub, false otherwise + */ + isXpub(xpub) { + return (xpub.indexOf('xpub') == 0) || (xpub.indexOf('tpub') == 0) + } + + /** + * Check if a string encodes a ypub/upub + * @param {string} xpub - extended public key to be checked + * @returns {boolean} returns true if xpub encodes a ypub/upub, false otherwise + */ + isYpub(xpub) { + return (xpub.indexOf('ypub') == 0) || (xpub.indexOf('upub') == 0) + } + + /** + * Check if a string encodes a zpub/vpub + * @param {string} xpub - extended public key to be checked + * @returns {boolean} returns true if xpub encodes a zpub/vpub, false otherwise + */ + isZpub(xpub) { + return (xpub.indexOf('zpub') == 0) || (xpub.indexOf('vpub') == 0) + } + + /** + * Translates + * - a xpub/ypub/zpub into a xpub + * - a tpub/upub/vpub into a tpub + * @param {string} xpub - extended public key to be translated + * @returns {boolean} returns the translated extended public key + */ + xlatXPUB(xpub) { + const decoded = bs58check.decode(xpub) + const ver = decoded.readInt32BE() + + if ( + ver != this.MAGIC_XPUB + && ver != this.MAGIC_TPUB + && ver != this.MAGIC_YPUB + && ver != this.MAGIC_UPUB + && ver != this.MAGIC_ZPUB + && ver != this.MAGIC_VPUB + ) { + //Logger.error(null, 'HdAccountsHelper.xlatXPUB() : Incorrect format') + return '' + } + + let xlatVer = 0 + switch(ver) { + case this.MAGIC_XPUB: + return xpub + break + case this.MAGIC_YPUB: + xlatVer = this.MAGIC_XPUB + break + case this.MAGIC_ZPUB: + xlatVer = this.MAGIC_XPUB + break + case this.MAGIC_TPUB: + return xpub + break + case this.MAGIC_UPUB: + xlatVer = this.MAGIC_TPUB + break + case this.MAGIC_VPUB: + xlatVer = this.MAGIC_TPUB + break + } + + let b = Buffer.alloc(4) + b.writeInt32BE(xlatVer) + + decoded.writeInt32BE(xlatVer, 0) + + const checksum = bitcoin.crypto.hash256(decoded).slice(0, 4) + const xlatXpub = Buffer.alloc(decoded.length + checksum.length) + + decoded.copy(xlatXpub, 0, 0, decoded.length) + + checksum.copy(xlatXpub, xlatXpub.length - 4, 0, checksum.length) + + const encoded = bs58.encode(xlatXpub) + return encoded + } + + /** + * Classify the hd account type retrieved from db + * @param {integer} v - HD Account type (db encoding) + * @returns {object} object storing the type and lock status of the hd account + */ + classify(v) { + const ret = { + type: null, + locked: false, + } + + let p = v + + if (p >= this.LOCKED) { + ret.locked = true + p -= this.LOCKED + } + + switch (p) { + case this.BIP44: + case this.BIP49: + case this.BIP84: + ret.type = p + break + } + + return ret + } + + /** + * Encode hd account type and lock status in db format + * @param {integer} type - HD Account type (db encoding) + * @param {boolean} locked - lock status of the hd account + * @returns {integer} + */ + makeType(type, locked) { + let p = + (type >= this.LOCKED) + ? type - this.LOCKED + : type + + locked = !!locked + + if (locked) + p += this.LOCKED + + return p + } + + /** + * Return a string representation of the hd account type + * @param {integer} v - HD Account type (db encoding) + * @returns {string} + */ + typeString(v) { + const info = this.classify(v) + + const prefix = info.locked ? 'LOCKED ' : '' + + let suffix = '' + + switch (info.type) { + case this.BIP44: + suffix = 'BIP44' + break + case this.BIP49: + suffix = 'BIP49' + break + case this.BIP84: + suffix = 'BIP84' + break + default: + suffix = 'UNKNOWN' + break + } + + return prefix + suffix + } + + /** + * Checks if a hd account is a valid hdnode + * @param {string} xpub - hd account + * @returns {boolean} returns true if hd account is valid, false otherwise + */ + isValid(xpub) { + if (this.nodes.has(xpub)) + return true + + try { + // Translate the xpub + const xlatedXpub = this.xlatXPUB(xpub) + + // Parse input as an HD Node. Throws if invalid + const node = bitcoin.HDNode.fromBase58(xlatedXpub, activeNet) + + // Check and see if this is a private key + if (!node.isNeutered()) + throw errors.xpub.PRIVKEY + + // Store the external and internal chain nodes in the proper indices. + // Store the parent node as well, at index 2. + this.nodes.set(xpub, [node.derive(0), node.derive(1), node]) + return true + + } catch(e) { + if (e == errors.xpub.PRIVKEY) throw e + return false + } + } + + /** + * Get the hd node associated to an hd account + * @param {string} xpub - hd account + * @returns {HDNode} + */ + getNode(xpub) { + if (this.isValid(xpub)) + return this.nodes.get(xpub) + else + return null + } + + /** + * Derives an address for an hd account + * @param {int} chain - chain to be derived + * must have a value on [0,1] for BIP44/BIP49/BIP84 derivation + * @param {HDNode} chainNode - Parent HDNode used for derivation + * @param {int} index - index to be derived + * @param {int} type - type of derivation + * @returns {Promise - object} returns an object {address: '...', chain: , index: } + */ + async deriveAddress(chain, chainNode, index, type) { + // Derive M/chain/index + const indexNode = chainNode.derive(index) + + const addr = { + chain: chain, + index: index + } + + switch (type) { + case this.BIP44: + addr.address = indexNode.getAddress() + break + case this.BIP49: + addr.address = addrHelper.p2wpkhP2shAddress(indexNode.getPublicKeyBuffer()) + break + case this.BIP84: + addr.address = addrHelper.p2wpkhAddress(indexNode.getPublicKeyBuffer()) + break + } + + return addr + } + + /** + * Derives addresses for an hd account + * @param {string} xpub - hd account to be derived + * @param {int} chain - chain to be derived + * must have a value on [0,1] for BIP44/BIP49/BIP84 derivation + * @param {int[]} indices - array of indices to be derived + * @param {int} type - type of derivation + * @returns {Promise - object[]} array of {address: '...', chain: , index: } + */ + async deriveAddresses(xpub, chain, indices, type) { + const ret = [] + + try { + const node = this.getNode(xpub) + + if (node === null) + throw errors.xpub.INVALID + + if (chain > 1 || chain < 0) + throw errors.xpub.CHAIN + + if (typeof type == 'undefined') + type = this.makeType(this.BIP44, false) + + const info = this.classify(type) + + // Node at M/chain + const chainNode = node[chain] + + // Optimization: if number of addresses beyond a given treshold + // derivation is done in a child process + if ( + !this.externalDerivationActivated + || indices.length <= keys.addrDerivationPool.thresholdParallelDerivation + ) { + // Few addresses to be derived or external derivation deactivated + // Let's do it here + let promises = indices.map(index => { + return this.deriveAddress(chain, chainNode, index, info.type) + }) + return Promise.all(promises) + + } else { + // Many addresses to be derived + // Let's do it in a child process + return new Promise(async (resolve, reject) => { + try { + const data = { + xpub: this.xlatXPUB(xpub), + chain: chain, + indices: indices, + type: info.type + } + + const msg = await this.derivationPool.enqueue(data) + + if (msg.status = 'ok') { + resolve(msg.addresses) + } else { + Logger.error(null, 'A problem was met during parallel addresses derivation') + reject() + } + + } catch(e) { + Logger.error(e, 'A problem was met during parallel addresses derivation') + reject(e) + } + }) + } + + } catch(e) { + return Promise.reject(e) + } + } + +} + +module.exports = new HDAccountsHelper() diff --git a/lib/bitcoin/hd-accounts-service.js b/lib/bitcoin/hd-accounts-service.js new file mode 100644 index 0000000..8da10e1 --- /dev/null +++ b/lib/bitcoin/hd-accounts-service.js @@ -0,0 +1,250 @@ +/*! + * lib/bitcoin/hd-accounts-service.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const _ = require('lodash') +const errors = require('../errors') +const Logger = require('../logger') +const db = require('../db/mysql-db-wrapper') +const network = require('../bitcoin/network') +const gap = require('../../keys/')[network.key].gap +const remote = require('../remote-importer/remote-importer') +const hdaHelper = require('./hd-accounts-helper') +const addrHelper = require('./addresses-helper') + + +/** + * A singleton providing a HD Accounts service + */ +class HDAccountsService { + + /** + * Constructor + */ + constructor() {} + + + /** + * Create a new hd account in db + * @param {string} xpub - xpub + * @param {int} scheme - derivation scheme + * @returns {Promise} returns true if success, false otherwise + */ + async createHdAccount(xpub, scheme) { + try { + await this.newHdAccount(xpub, scheme) + return true + } catch(e) { + const isInvalidXpub = (e == errors.xpub.INVALID || e == errors.xpub.PRIVKEY) + const isLockedXpub = (e == errors.xpub.LOCKED) + const err = (isInvalidXpub || isLockedXpub) ? e : errors.xpub.CREATE + Logger.error(e, 'HdAccountsService.createHdAccount()' + err) + return Promise.reject(err) + } + } + + + /** + * Restore a hd account in db + * @param {string} xpub - xpub + * @param {int} scheme - derivation scheme + * @param {bool} forceOverride - force override of scheme even if hd account is locked + * @returns {Promise} + */ + async restoreHdAccount(xpub, scheme, forceOverride) { + let isLocked + + // Check if hd accounts exists in db and is locked + try { + const account = await db.getHDAccount(xpub) + const info = hdaHelper.classify(account.hdType) + isLocked = info.locked + } catch(e) {} + + // Override derivation scheme if needed + await this.derivationOverrideCheck(xpub, scheme, forceOverride) + + // Import the hd account + await remote.importHDAccount(xpub, scheme) + + // Lock the hd account if needed + if (isLocked) + return this.lockHdAccount(xpub, true) + } + + /** + * Lock a hd account + * @param {string} xpub - xpub + * @param {boolean} lock - true for locking, false for unlocking + * @returns {Promise} returns the derivation type as a string + */ + async lockHdAccount(xpub, lock) { + try { + const account = await db.getHDAccount(xpub) + + const hdType = account.hdType + const info = hdaHelper.classify(hdType) + + if (info.locked === lock) + return hdaHelper.typeString(hdType) + + await db.setLockHDAccountType(xpub, lock) + + const type = hdaHelper.makeType(hdType, lock) + return hdaHelper.typeString(type) + + } catch(e) { + const err = (e == errors.db.ERROR_NO_HD_ACCOUNT) ? errors.get.UNKNXPUB : errors.generic.DB + return Promise.reject(err) + } + } + + /** + * Delete a hd account + * @param {string} xpub - xpub + * @returns {Promise} + */ + async deleteHdAccount(xpub) { + try { + await db.deleteHDAccount(xpub) + } catch(e) { + const err = (e == errors.db.ERROR_NO_HD_ACCOUNT) ? errors.get.UNKNXPUB : errors.generic.DB + return Promise.reject(err) + } + } + + /** + * Create a new xpub in db + * @param {string} xpub - xpub + * @param {string} scheme - derivation scheme + * @returns {Promise} + */ + async newHdAccount(xpub, scheme) { + // Get the HDNode bitcoinjs object. + // Throws if xpub is actually a private key + const HDNode = hdaHelper.getNode(xpub) + + if (HDNode === null) + throw errors.xpub.INVALID + + await this.derivationOverrideCheck(xpub, scheme) + await db.ensureHDAccountId(xpub, scheme) + + let segwit = '' + + if (scheme == hdaHelper.BIP49) + segwit = ' SegWit (BIP49)' + else if (scheme == hdaHelper.BIP84) + segwit = ' SegWit (BIP84)' + + Logger.info(`Created HD Account: ${xpub}${segwit}`) + + const externalPrm = hdaHelper.deriveAddresses(xpub, 0, _.range(gap.external), scheme) + const internalPrm = hdaHelper.deriveAddresses(xpub, 1, _.range(gap.internal), scheme) + + const external = await externalPrm + const internal = await internalPrm + + const addresses = _.flatten([external, internal]) + + return db.addAddressesToHDAccount(xpub, addresses) + } + + /** + * Rescan the blockchain for a hd account + * @param {string} xpub - xpub + * @param {integer} gapLimit - (optional) gap limit for derivation + * @param {integer} startIndex - (optional) rescan shall start from this index + * @returns {Promise} + */ + async rescan(xpub, gapLimit, startIndex) { + // Force rescan + remote.clearGuard(xpub) + + try { + const account = await db.getHDAccount(xpub) + await remote.importHDAccount(xpub, account.hdType, gapLimit, startIndex) + } catch(e) { + return Promise.reject(e) + } + } + + /** + * Check if we try to override an existing xpub + * Delete the old xpub from db if it's the case + * @param {string} xpub - xpub + * @param {string} scheme - derivation scheme + * @param {boolean} forceOverride - force override of scheme even if hd account is locked + * (default = false) + * @returns {Promise} + */ + async derivationOverrideCheck(xpub, scheme, forceOverride) { + let account + + // Nothing to do here if hd account doesn't exist in db + try { + account = await db.getHDAccount(xpub) + } catch(e) { + return Promise.resolve() + } + + try { + const info = hdaHelper.classify(account.hdType) + // If this account is already known in the database, + // check for a derivation scheme mismatch + if (info.type != scheme) { + if (info.locked && !forceOverride) { + Logger.info(`Attempted override on locked account: ${xpub}`) + return Promise.reject(errors.xpub.LOCKED) + } else { + Logger.info(`Derivation scheme override: ${xpub}`) + return db.deleteHDAccount(xpub) + } + } + } catch(e) { + Logger.error(e, 'HDAccountsService.derivationOverrideCheck()') + return Promise.reject(e) + } + } + + /** + * Verify that a given message has been signed + * with the first external key of a known xpub/ypub/zpub + * + * @param {string} xpub - xpub + * @param {string} address - address used to sign the message + * @param {string} sig - signature of the message + * @param {string} msg - signed message + * @param {integer} scheme - derivation scheme to be used for the xpub + * @returns {Promise} returns the xpub if signature is valid, otherwise returns an error + */ + async verifyXpubSignature(xpub, address, sig, msg, scheme) { + // Derive addresses (P2PKH addresse used for signature + expected address) + const sigAddressRecord = await hdaHelper.deriveAddresses(xpub, 1, [0], hdaHelper.BIP44) + const sigAddress = sigAddressRecord[0].address + + const expectedAddressRecord = await hdaHelper.deriveAddresses(xpub, 1, [0], scheme) + const expectedAddress = expectedAddressRecord[0].address + + try { + // Check that xpub exists in db + await db.getHDAccountId(xpub) + // Check the signature + if (!addrHelper.verifySignature(msg, sigAddress, sig)) + return Promise.reject(errors.sig.INVSIG) + // Check that adresses match + if (address != expectedAddress) + return Promise.reject(errors.sig.INVADDR) + // Return the corresponding xpub + return xpub + } catch(err) { + const ret = (err == errors.db.ERROR_NO_HD_ACCOUNT) ? errors.get.UNKNXPUB : errors.generic.DB + return Promise.reject(ret) + } + } + +} + +module.exports = new HDAccountsService() diff --git a/lib/bitcoin/network.js b/lib/bitcoin/network.js new file mode 100644 index 0000000..6ce6022 --- /dev/null +++ b/lib/bitcoin/network.js @@ -0,0 +1,44 @@ +/*! + * lib/bitcoin/network.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const bitcoin = require('bitcoinjs-lib') + + +/** + * A set of keywords encoding for testnet + */ +const TESTNET_KEY = [ + 'testnet', + 'testing', + 'test' +] + + +/** + * A singleton determining which network to run: bitcoin or testnet + */ +class Network { + + /** + * Constructor + */ + constructor() { + this.key = 'bitcoin' + this.network = bitcoin.networks.bitcoin + + for (let kw of TESTNET_KEY) { + // Calling like 'node file.js arg1 arg2' + if (process.argv.indexOf(kw) > 1) { + this.key = 'testnet' + this.network = bitcoin.networks.testnet + break + } + } + } + +} + +module.exports = new Network() diff --git a/lib/bitcoin/parallel-address-derivation.js b/lib/bitcoin/parallel-address-derivation.js new file mode 100644 index 0000000..b032762 --- /dev/null +++ b/lib/bitcoin/parallel-address-derivation.js @@ -0,0 +1,92 @@ +/*! + * lib/bitcoin/parallel-address-derivation.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const bitcoin = require('bitcoinjs-lib') +const errors = require('../errors') +const activeNet = require('./network').network +const addrHelper = require('./addresses-helper') + +/** + * Constants duplicated from HDAccountsHelper + */ +const BIP44 = 0 +const BIP49 = 1 +const BIP84 = 2 + + +/** + * Derives an address for an hd account + * @param {int} chain - chain to be derived + * must have a value on [0,1] for BIP44/BIP49/BIP84 derivation + * @param {HDNode} chainNode - Parent HDNode used for derivation + * @param {int} index - index to be derived + * @param {int} type - type of derivation + * @returns {Promise - object} returns an object {address: '...', chain: , index: } + */ +const deriveAddress = async function(chain, chainNode, index, type) { + // Derive M/chain/index + const indexNode = chainNode.derive(index) + + const addr = { + chain: chain, + index: index + } + + switch (type) { + case BIP44: + addr.address = indexNode.getAddress() + break + case BIP49: + addr.address = addrHelper.p2wpkhP2shAddress(indexNode.getPublicKeyBuffer()) + break + case BIP84: + addr.address = addrHelper.p2wpkhAddress(indexNode.getPublicKeyBuffer()) + break + } + + return addr +} + +/** + * Receive message from parent process + */ +process.on('message', async (msg) => { + try { + const xpub = msg.xpub + const chain = msg.chain + const indices = msg.indices + const type = msg.type + + // Parse input as an HD Node. Throws if invalid + const node = bitcoin.HDNode.fromBase58(xpub, activeNet) + + // Check and see if this is a private key + if (!node.isNeutered()) + throw errors.xpub.PRIVKEY + + const chainNode = node.derive(chain) + + const promises = indices.map(index => { + return deriveAddress(chain, chainNode, index, type) + }) + + const addresses = await Promise.all(promises) + + // Send response to parent process + process.send({ + status: 'ok', + addresses: addresses + }) + + } catch(e) { + process.send({ + status: 'error', + addresses: [], + error: e + }) + } + +}) diff --git a/lib/bitcoind-rpc/fees.js b/lib/bitcoind-rpc/fees.js new file mode 100644 index 0000000..60d20f3 --- /dev/null +++ b/lib/bitcoind-rpc/fees.js @@ -0,0 +1,71 @@ +/*! + * lib/bitcoind-rpc/fees.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const util = require('../util') +const errors = require('../errors') +const Logger = require('../logger') +const network = require('../bitcoin/network') +const keys = require('../../keys')[network.key] +const RpcClient = require('./rpc-client') +const latestBlock = require('./latest-block') + + +/** + * A singleton providing information about network fees + */ +class Fees { + + /** + * Constructor + */ + constructor() { + this.block = -1 + this.targets = [2, 4, 6, 12, 24] + this.fees = {} + this.feeType = keys.bitcoind.feeType + + this.rpcClient = new RpcClient() + + this.refresh() + } + + /** + * Refresh and return the current fees + * @returns {Promise} + */ + async getFees() { + try { + if (latestBlock.height > this.block) + await this.refresh() + + return this.fees + + } catch(err) { + return Promise.reject(errors.generic.GEN) + } + } + + /** + * Refresh the current fees + * @returns {Promise} + */ + async refresh() { + await util.seriesCall(this.targets, async tgt => { + try { + const level = await this.rpcClient.cmd('estimatesmartfee', tgt, this.feeType) + this.fees[tgt] = Math.round(level.feerate * 1e5) + } catch(e) { + Logger.error(e, 'Fees.refresh()') + delete this.fees[tgt] + } + }) + + this.block = latestBlock.height + } + +} + +module.exports = new Fees() diff --git a/lib/bitcoind-rpc/headers.js b/lib/bitcoind-rpc/headers.js new file mode 100644 index 0000000..0464aec --- /dev/null +++ b/lib/bitcoind-rpc/headers.js @@ -0,0 +1,56 @@ +/*! + * lib/bitcoind-rpc/headers.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const LRU = require('lru-cache') +const errors = require('../errors') +const RpcClient = require('./rpc-client') + + +/** + * A singleton providing information about block headers + */ +class Headers { + + /** + * Constructor + */ + constructor() { + // Cache + this.headers = LRU({ + // Maximum number of headers to store in cache + max: 2016, + // Function used to compute length of item + length: (n, key) => 1, + // Maximum age for items in the cache. Items do not expire + maxAge: Infinity + }) + + // Initialize the rpc client + this.rpcClient = new RpcClient() + } + + /** + * Get the block header for a given hash + * @param {string} hash - block hash + * @returns {Promise} + */ + async getHeader(hash) { + if (this.headers.has(hash)) + return this.headers.get(hash) + + try { + const header = await this.rpcClient.getblockheader(hash, true) + const fmtHeader = JSON.stringify(header, null, 2) + this.headers.set(hash, fmtHeader) + return fmtHeader + } catch(e) { + return Promise.reject(errors.generic.GEN) + } + } + +} + +module.exports = new Headers() diff --git a/lib/bitcoind-rpc/latest-block.js b/lib/bitcoind-rpc/latest-block.js new file mode 100644 index 0000000..1f37a5f --- /dev/null +++ b/lib/bitcoind-rpc/latest-block.js @@ -0,0 +1,69 @@ +/*! + * lib/bitcoind_rpc/latest-block.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const zmq = require('zeromq') +const Logger = require('../logger') +const util = require('../util') +const network = require('../bitcoin/network') +const keys = require('../../keys')[network.key] +const RpcClient = require('./rpc-client') + + +/** + * A singleton providing information about the latest block + */ +class LatestBlock { + + /** + * Constructor + */ + constructor() { + this.height = null + this.hash = null + this.time = null + this.diff = null + + // Initialize the rpc client + this.rpcClient = new RpcClient() + + // Gets the latest block from bitcoind + this.rpcClient.getbestblockhash().then(hash => this.onBlockHash(hash)) + + // Initializes zmq socket + this.sock = zmq.socket('sub') + this.sock.connect(keys.bitcoind.zmqBlk) + this.sock.subscribe('hashblock') + + this.sock.on('message', (topic, msg) => { + switch(topic.toString()) { + case 'hashblock': + this.onBlockHash(msg.toString('hex')) + break + default: + Logger.info(topic.toString()) + } + }) + } + + /** + * Retrieve and store information for a given block + * @param {string} hash - txid of the block + * @returns {Promise} + */ + async onBlockHash(hash) { + const header = await this.rpcClient.getblockheader(hash) + + this.height = header.height + this.hash = hash + this.time = header.mediantime + this.diff = header.difficulty + + Logger.info(`Block ${this.height} ${this.hash}`) + } + +} + +module.exports = new LatestBlock() diff --git a/lib/bitcoind-rpc/rpc-client.js b/lib/bitcoind-rpc/rpc-client.js new file mode 100644 index 0000000..84ba57a --- /dev/null +++ b/lib/bitcoind-rpc/rpc-client.js @@ -0,0 +1,88 @@ +/*! + * lib/bitcoind_rpc/rpc-client.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const rpc = require('bitcoind-rpc-client') +const network = require('../bitcoin/network') +const keys = require('../../keys')[network.key] +const util = require('../util') +const Logger = require('../logger') + + +/** + * Wrapper for bitcoind rpc client + */ +class RpcClient { + + /** + * Constructor + */ + constructor() { + // Initiliaze the rpc client + this.client = new rpc({ + host: keys.bitcoind.rpc.host, + port: keys.bitcoind.rpc.port + }) + + this.client.set('user', keys.bitcoind.rpc.user) + this.client.set('pass', keys.bitcoind.rpc.pass) + + // Initialize a proxy postprocessing api calls + return new Proxy(this, { + get: function(target, name, receiver) { + const origMethod = target.client[name] + + return async function(...args) { + const result = await origMethod.apply(target.client, args) + + if (result.result) { + return result.result + } else if (result.error) { + throw result.error + } else { + throw 'A problem was met with a request sent to bitcoind RPC API' + } + } + } + }) + } + + /** + * Check if an error returned by bitcoin-rpc-client + * is a connection error. + * @param {string} err - error message + * @returns {boolean} returns true if message related to a connection error + */ + static isConnectionError(err) { + if (typeof err != 'string') + return false + + const isTimeoutError = (err.indexOf('connect ETIMEDOUT') != -1) + const isConnRejected = (err.indexOf('Connection Rejected') != -1) + + return (isTimeoutError || isConnRejected) + } + + /** + * Check if the rpc api is ready to process requests + * @returns {Promise} + */ + static async waitForBitcoindRpcApi() { + let client = new RpcClient() + + try { + await client.getblockchaininfo() + } catch(e) { + client = null + Logger.info('Bitcoind RPC API is still unreachable. New attempt in 20s.') + return util.delay(20000).then(() => { + return RpcClient.waitForBitcoindRpcApi() + }) + } + } + +} + +module.exports = RpcClient diff --git a/lib/bitcoind-rpc/transactions.js b/lib/bitcoind-rpc/transactions.js new file mode 100644 index 0000000..275dab8 --- /dev/null +++ b/lib/bitcoind-rpc/transactions.js @@ -0,0 +1,215 @@ +/*! + * lib/bitcoind-rpc/transactions.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const _ = require('lodash') +const LRU = require('lru-cache') +const errors = require('../errors') +const Logger = require('../logger') +const util = require('../util') +const RpcClient = require('./rpc-client') +const rpcLatestBlock = require('./latest-block') + + +/** + * A singleton providing information about transactions + */ +class Transactions { + + /** + * Constructor + */ + constructor() { + // Caches + this.txCache = LRU({ + // Maximum number of transactions to store + max: 10000, + // Function used to compute length of item + length: (n, key) => 1, + // Maximum age for items in the cache. Items do not expire + maxAge: Infinity + }) + + this.prevCache = LRU({ + // Maximum number of transactions to store + max: 100000, + // Function used to compute length of item + length: (n, key) => 1, + // Maximum age for items in the cache. Items do not expire + maxAge: Infinity + }) + + + // Initialize the rpc client + this.rpcClient = new RpcClient() + } + + /** + * Get the transaction for a given txid + * @param {string} txid - txid of the transaction to be retrieved + * @param {boolean} fees - true if fees must be computed, false otherwise + * @returns {Promise} + */ + async getTransaction(txid, fees) { + // Return transaction from cache when possible + if (this.txCache.has(txid)) + return this.txCache.get(txid) + + try { + const tx = await this.rpcClient.getrawtransaction(txid, true) + + const ret = { + txid: tx.txid, + size: tx.size, + vsize: tx.vsize, + version: tx.version, + locktime: tx.locktime, + inputs: [], + outputs: [] + } + + if (!ret.vsize) + delete ret.vsize + + if (tx.time) + ret.created = tx.time + + // Process block informations + if (tx.blockhash && tx.confirmations && tx.blocktime) { + ret.block = { + height: rpcLatestBlock.height - tx.confirmations + 1, + hash: tx.blockhash, + time: tx.blocktime + } + } + + let inAmount = 0 + let outAmount = 0 + + // Process the inputs + ret.inputs = await this._getInputs(tx, fees) + inAmount = ret.inputs.reduce((prev, cur) => prev + cur.outpoint.value, 0) + + // Process the outputs + ret.outputs = await this._getOutputs(tx) + outAmount = ret.outputs.reduce((prev, cur) => prev + cur.value, 0) + + // Process the fees (if needed) + if (fees) { + ret.fees = inAmount - outAmount + if (ret.fees > 0 && ret.size) + ret.feerate = Math.round(ret.fees / ret.size) + if (ret.fees > 0 && ret.vsize) + ret.vfeerate = Math.round(ret.fees / ret.vsize) + } + + // Store in cache + if (ret.block && ret.block.hash) + this.txCache.set(txid, ret) + + return ret + + } catch(e) { + Logger.error(e, 'Transaction.getTransaction()') + return Promise.reject(errors.generic.GEN) + } + } + + /** + * Extract information about the inputs of a transaction + * @param {object} tx - transaction + * @param {boolean} fees - true if fees must be computed, false otherwise + * @returns {Promise} return an array of inputs (object[]) + */ + async _getInputs(tx, fees) { + const inputs = [] + let n = 0 + + await util.seriesCall(tx.vin, async input => { + const txin = { + n, + seq: input.sequence, + } + + if (input.coinbase) { + txin.coinbase = input.coinbase + } else { + txin.outpoint = { + txid: input.txid, + vout: input.vout + } + txin.sig = input.scriptSig.hex + } + + if (input.txinwitness) + txin.witness = input.txinwitness + + if (fees && txin.outpoint) { + const inTxid = txin.outpoint.txid + let ptx + + if (this.prevCache.has(inTxid)) { + ptx = this.prevCache.get(inTxid) + } else { + ptx = await this.rpcClient.getrawtransaction(inTxid, true) + if (ptx.blockhash && ptx.confirmations && ptx.blocktime) { + ptx.height = rpcLatestBlock.height - ptx.confirmations + 1 + this.prevCache.set(inTxid, ptx) + } + } + + const outpoint = ptx.vout[txin.outpoint.vout] + txin.outpoint.value = Math.round(outpoint.value * 1e8) + txin.outpoint.scriptpubkey = outpoint.scriptPubKey.hex + inputs.push(txin) + n++ + + } else { + inputs.push(txin) + n++ + } + }) + + return inputs + } + + /** + * Extract information about the outputs of a transaction + * @param {object} tx - transaction + * @param {boolean} fees - true if fees must be computed, false otherwise + * @returns {Promise} return an array of outputs (object[]) + */ + async _getOutputs(tx) { + const outputs = [] + let n = 0 + + for (let output of tx.vout) { + const pk = output.scriptPubKey + const amount = Math.round(output.value * 1e8) + + let o = { + n, + value: amount, + scriptpubkey: pk.hex, + type: pk.type + } + + if (pk.addresses) { + if (pk.addresses.length == 1) + o.address = pk.addresses[0] + else + o.addresses = pk.addresses + } + + outputs.push(o) + n++ + } + + return outputs + } + +} + +module.exports = new Transactions() diff --git a/lib/db/mysql-db-wrapper.js b/lib/db/mysql-db-wrapper.js new file mode 100644 index 0000000..6171341 --- /dev/null +++ b/lib/db/mysql-db-wrapper.js @@ -0,0 +1,1974 @@ +/*! + * lib/db.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const mysql = require('mysql') +const path = require('path') +const Logger = require('../logger') +const util = require('../util') +const errors = require('../errors') +const hdaHelper = require('../bitcoin/hd-accounts-helper') +const network = require('../bitcoin/network') +const keys = require('../../keys/')[network.key] +const keysDb = keys.db +const debug = !!(process.argv.indexOf('db-debug') > -1) +const queryDebug = !!(process.argv.indexOf('dbquery-debug') > -1) + + +/** + * Subqueries used by getAddrAndXpubsNbTransactions() + */ +const SUBQUERY_TXIDS_ADDR = '( \ + SELECT `transactions`.`txnTxid` AS txnTxid \ + FROM `outputs` \ + INNER JOIN `transactions` ON `transactions`.`txnID` = `outputs`.`txnID` \ + INNER JOIN `addresses` ON `addresses`.`addrID` = `outputs`.`addrID` \ + WHERE `addresses`.`addrAddress` IN (?) \ +) UNION ( \ + SELECT `transactions`.`txnTxid` AS txnTxid \ + FROM `inputs` \ + INNER JOIN `transactions` ON `transactions`.`txnID` = `inputs`.`txnID` \ + INNER JOIN `outputs` ON `outputs`.`outID` = `inputs`.`outID` \ + INNER JOIN `addresses` ON `addresses`.`addrID` = `outputs`.`addrID` \ + WHERE `addresses`.`addrAddress` IN (?) \ +)' + +const SUBQUERY_TXIDS_XPUBS = '( \ + SELECT `transactions`.`txnTxid` AS txnTxid \ + FROM `outputs` \ + INNER JOIN `transactions` ON `transactions`.`txnID` = `outputs`.`txnID` \ + INNER JOIN `hd_addresses` ON `hd_addresses`.`addrID` = `outputs`.`addrID` \ + INNER JOIN `hd` ON `hd`.`hdID` = `hd_addresses`.`hdID` \ + WHERE `hd`.`hdXpub` IN (?) \ +) UNION ( \ + SELECT `transactions`.`txnTxid` AS txnTxid \ + FROM `inputs` \ + INNER JOIN `transactions` ON `transactions`.`txnID` = `inputs`.`txnID` \ + INNER JOIN `outputs` ON `outputs`.`outID` = `inputs`.`outID` \ + INNER JOIN `hd_addresses` ON `hd_addresses`.`addrID` = `outputs`.`addrID` \ + INNER JOIN `hd` ON `hd`.`hdID` = `hd_addresses`.`hdID` \ + WHERE `hd`.`hdXpub` IN (?) \ +)' + +/** + * Subqueries used by getTxsByAddrAndXpubs() + */ +const SUBQUERY_TXS_ADDR = '(\ + SELECT \ + `transactions`.`txnID` AS `txnID`, \ + `transactions`.`txnTxid` AS `txnTxid`, \ + `transactions`.`txnVersion` AS `txnVersion`, \ + `transactions`.`txnLocktime` AS `txnLocktime`, \ + `blocks`.`blockHeight` AS `blockHeight`, \ + LEAST(`transactions`.`txnCreated`, IFNULL(`blocks`.`blockTime`, 32503680000)) AS `time` \ + FROM `transactions` \ + INNER JOIN `outputs` ON `outputs`.`txnID` = `transactions`.`txnID` \ + INNER JOIN `addresses` ON `addresses`.`addrID` = `outputs`.`addrID` \ + LEFT JOIN `blocks` ON `transactions`.`blockID` = `blocks`.`blockID` \ + WHERE `addresses`.`addrAddress` IN (?) \ +) UNION DISTINCT (\ + SELECT \ + `transactions`.`txnID` AS `txnID`, \ + `transactions`.`txnTxid` AS `txnTxid`, \ + `transactions`.`txnVersion` AS `txnVersion`, \ + `transactions`.`txnLocktime` AS `txnLocktime`, \ + `blocks`.`blockHeight` AS `blockHeight`, \ + LEAST(`transactions`.`txnCreated`, IFNULL(`blocks`.`blockTime`, 32503680000)) AS `time` \ + FROM `transactions` \ + INNER JOIN `inputs` ON `inputs`.`txnID` = `transactions`.`txnID` \ + INNER JOIN `outputs` ON `outputs`.`outID` = `inputs`.`outID` \ + INNER JOIN `addresses` ON `addresses`.`addrID` = `outputs`.`addrID` \ + LEFT JOIN `blocks` ON `transactions`.`blockID` = `blocks`.`blockID` \ + WHERE `addresses`.`addrAddress` IN (?) \ +)' + +const SUBQUERY_TXS_XPUB = '(\ + SELECT \ + `transactions`.`txnID` AS `txnID`, \ + `transactions`.`txnTxid` AS `txnTxid`, \ + `transactions`.`txnVersion` AS `txnVersion`, \ + `transactions`.`txnLocktime` AS `txnLocktime`, \ + `blocks`.`blockHeight` AS `blockHeight`, \ + LEAST(`transactions`.`txnCreated`, IFNULL(`blocks`.`blockTime`, 32503680000)) AS `time` \ + FROM `transactions` \ + INNER JOIN `outputs` ON `outputs`.`txnID` = `transactions`.`txnID` \ + INNER JOIN `addresses` ON `addresses`.`addrID` = `outputs`.`addrID` \ + INNER JOIN `hd_addresses` ON `hd_addresses`.`addrID` = `addresses`.`addrID` \ + INNER JOIN `hd` ON `hd`.`hdID` = `hd_addresses`.`hdID` \ + LEFT JOIN `blocks` ON `transactions`.`blockID` = `blocks`.`blockID` \ + WHERE `hd`.`hdXpub` IN (?) \ +) UNION DISTINCT (\ + SELECT \ + `transactions`.`txnID` AS `txnID`, \ + `transactions`.`txnTxid` AS `txnTxid`, \ + `transactions`.`txnVersion` AS `txnVersion`, \ + `transactions`.`txnLocktime` AS `txnLocktime`, \ + `blocks`.`blockHeight` AS `blockHeight`, \ + LEAST(`transactions`.`txnCreated`, IFNULL(`blocks`.`blockTime`, 32503680000)) AS `time` \ + FROM `transactions` \ + INNER JOIN `inputs` ON `inputs`.`txnID` = `transactions`.`txnID` \ + INNER JOIN `outputs` ON `outputs`.`outID` = `inputs`.`outID` \ + INNER JOIN `addresses` ON `addresses`.`addrID` = `outputs`.`addrID` \ + INNER JOIN `hd_addresses` ON `hd_addresses`.`addrID` = `addresses`.`addrID` \ + INNER JOIN `hd` ON `hd`.`hdID` = `hd_addresses`.`hdID` \ + LEFT JOIN `blocks` ON `transactions`.`blockID` = `blocks`.`blockID` \ + WHERE `hd`.`hdXpub` IN (?) \ +)' + +const SUBQUERY_UTXOS_ADDR = '(\ + SELECT \ + `transactions`.`txnID` AS `txnID`, \ + null AS `outIndex`, \ + null AS `outAmount`, \ + null AS `outAddress`, \ + `inputs`.`inIndex` AS `inIndex`, \ + `inputs`.`inSequence` AS `inSequence`, \ + `prevTx`.`txnTxid` AS `inOutTxid`, \ + `outputs`.`outIndex` AS `inOutIndex`, \ + `outputs`.`outAmount` AS `inOutAmount`, \ + `addresses`.`addrAddress` AS `inOutAddress`, \ + null AS `hdAddrChain`, \ + null AS `hdAddrIndex`, \ + null AS `hdXpub` \ + FROM `transactions` \ + INNER JOIN `inputs` ON `inputs`.`txnID` = `transactions`.`txnID` \ + INNER JOIN `outputs` ON `outputs`.`outID` = `inputs`.`outID` \ + INNER JOIN `transactions` AS `prevTx` ON `prevTx`.`txnID` = `outputs`.`txnID` \ + INNER JOIN `addresses` ON `addresses`.`addrID` = `outputs`.`addrID` \ + WHERE \ + `transactions`.`txnID` IN (?) AND \ + `addresses`.`addrAddress` IN (?) \ +) UNION ( \ + SELECT \ + `transactions`.`txnID` AS `txnID`, \ + `outputs`.`outIndex` AS `outIndex`, \ + `outputs`.`outAmount` AS `outAmount`, \ + `addresses`.`addrAddress` AS `outAddress`, \ + null AS `inIndex`, \ + null AS `inSequence`, \ + null AS `inOutTxid`, \ + null AS `inOutIndex`, \ + null AS `inOutAmount`, \ + null AS `inOutAddress`, \ + null AS `hdAddrChain`, \ + null AS `hdAddrIndex`, \ + null AS `hdXpub` \ + FROM `transactions` \ + INNER JOIN `outputs` ON `outputs`.`txnID` = `transactions`.`txnID` \ + INNER JOIN `addresses` ON `addresses`.`addrID` = `outputs`.`addrID` \ + WHERE \ + `transactions`.`txnID` IN (?) AND \ + `addresses`.`addrAddress` IN (?) \ +)' + +const SUBQUERY_UTXOS_XPUB = '(\ + SELECT \ + `transactions`.`txnID` AS `txnID`, \ + null AS `outIndex`, \ + null AS `outAmount`, \ + null AS `outAddress`, \ + `inputs`.`inIndex` AS `inIndex`, \ + `inputs`.`inSequence` AS `inSequence`, \ + `prevTx`.`txnTxid` AS `inOutTxid`, \ + `outputs`.`outIndex` AS `inOutIndex`, \ + `outputs`.`outAmount` AS `inOutAmount`, \ + `addresses`.`addrAddress` AS `inOutAddress`, \ + `hd_addresses`.`hdAddrChain` AS `hdAddrChain`, \ + `hd_addresses`.`hdAddrIndex` AS `hdAddrIndex`, \ + `hd`.`hdXpub` AS `hdXpub` \ + FROM `transactions` \ + INNER JOIN `inputs` ON `inputs`.`txnID` = `transactions`.`txnID` \ + INNER JOIN `outputs` ON `outputs`.`outID` = `inputs`.`outID` \ + INNER JOIN `transactions` AS `prevTx` ON `prevTx`.`txnID` = `outputs`.`txnID` \ + INNER JOIN `addresses` ON `addresses`.`addrID` = `outputs`.`addrID` \ + INNER JOIN `hd_addresses` ON `hd_addresses`.`addrID` = `addresses`.`addrID` \ + INNER JOIN `hd` ON `hd`.`hdID` = `hd_addresses`.`hdID` \ + WHERE \ + `transactions`.`txnID` IN (?) AND \ + `hd`.`hdXpub` IN (?) \ +) UNION ( \ + SELECT \ + `transactions`.`txnID` AS `txnID`, \ + `outputs`.`outIndex` AS `outIndex`, \ + `outputs`.`outAmount` AS `outAmount`, \ + `addresses`.`addrAddress` AS `outAddress`, \ + null AS `inIndex`, \ + null AS `inSequence`, \ + null AS `inOutTxid`, \ + null AS `inOutIndex`, \ + null AS `inOutAmount`, \ + null AS `inOutAddress`, \ + `hd_addresses`.`hdAddrChain` AS `hdAddrChain`, \ + `hd_addresses`.`hdAddrIndex` AS `hdAddrIndex`, \ + `hd`.`hdXpub` AS `hdXpub` \ + FROM `transactions` \ + INNER JOIN `outputs` ON `outputs`.`txnID` = `transactions`.`txnID` \ + INNER JOIN `addresses` ON `addresses`.`addrID` = `outputs`.`addrID` \ + INNER JOIN `hd_addresses` ON `hd_addresses`.`addrID` = `addresses`.`addrID` \ + INNER JOIN `hd` ON `hd`.`hdID` = `hd_addresses`.`hdID` \ + WHERE \ + `transactions`.`txnID` IN (?) AND \ + `hd`.`hdXpub` IN (?) \ +)' + +const SUBQUERY_GET_TX_OUTS = 'SELECT \ + `transactions`.`txnID`, \ + `transactions`.`txnTxid`, \ + `transactions`.`txnCreated`, \ + `transactions`.`txnVersion`, \ + `transactions`.`txnLocktime`, \ + `blocks`.`blockHeight`, \ + `blocks`.`blockTime`, \ + `outputs`.`outID`, \ + `outputs`.`outIndex`, \ + `outputs`.`outAmount`, \ + `outputs`.`outScript`, \ + `addresses`.`addrAddress`, \ + `hd_addresses`.`hdAddrChain`, \ + `hd_addresses`.`hdAddrIndex`, \ + `hd`.`hdXpub` \ + FROM `transactions` \ + INNER JOIN `outputs` ON `transactions`.`txnID` = `outputs`.`txnID` \ + INNER JOIN `addresses` ON `outputs`.`addrID` = `addresses`.`addrID` \ + LEFT JOIN `hd_addresses` ON `outputs`.`addrID` = `hd_addresses`.`addrID` \ + LEFT JOIN `hd` ON `hd_addresses`.`hdID` = `hd`.`hdID` \ + LEFT JOIN `blocks` ON `transactions`.`blockID` = `blocks`.`blockID` \ + WHERE `transactions`.`txnTxid` = ? \ + ORDER BY `outputs`.`outIndex` ASC' + +const SUBQUERY_GET_TX_INS = 'SELECT \ + `t_in`.`txnTxid`, \ + `t_in`.`txnCreated`, \ + `t_in`.`txnVersion`, \ + `t_in`.`txnLocktime`, \ + `blocks`.`blockHeight`, \ + `blocks`.`blockTime`, \ + `t_out`.`txnTxid` AS `prevOutTxid`, \ + `inputs`.`inIndex`, \ + `inputs`.`inSequence`, \ + `inputs`.`outID`, \ + `outputs`.`outIndex`, \ + `outputs`.`outAmount`, \ + `outputs`.`outScript`, \ + `addresses`.`addrAddress`, \ + `hd_addresses`.`hdAddrChain`, \ + `hd_addresses`.`hdAddrIndex`, \ + `hd`.`hdXpub` \ + FROM `inputs` \ + INNER JOIN `outputs` ON `outputs`.`outID` = `inputs`.`outID` \ + INNER JOIN `transactions` AS `t_in` ON `t_in`.`txnID` = `inputs`.`txnID` \ + INNER JOIN `transactions` AS `t_out` ON `t_out`.`txnID` = `outputs`.`txnID` \ + INNER JOIN `addresses` ON `outputs`.`addrID` = `addresses`.`addrID` \ + LEFT JOIN `hd_addresses` ON `outputs`.`addrID` = `hd_addresses`.`addrID` \ + LEFT JOIN `hd` ON `hd_addresses`.`hdID` = `hd`.`hdID` \ + LEFT JOIN `blocks` ON `t_in`.`blockID` = `blocks`.`blockID` \ + WHERE `t_in`.`txnTxid` = ? \ + ORDER BY `inputs`.`inIndex` ASC' + + +/** + * A singleton providing a MySQL db wrapper + * Node-mysql doc: https://github.com/felixge/node-mysql + */ +class MySqlDbWrapper { + + /** + * Constructor + */ + constructor() { + this.dbConfig = null + // Db connections pool + this.pool = null + // Lock preventing multiple reconnects + this.lockReconnect = false + // Timer managing reconnects + this.timerReconnect = null + } + + /** + * Connect the wrapper to the database + * @param {object} dbConfig - database configuration + */ + connect(dbConfig) { + this.dbConfig = dbConfig + + try { + if (this.pool) + this.handleReconnect() + else + this.handleConnect() + } catch(e) { + this.handleReconnect() + } + + setInterval(this.ping.bind(this), 30000) + } + + /** + * Connect the wrapper to the mysql server + */ + handleConnect() { + try { + this.pool = mysql.createPool(this.dbConfig) + Logger.info(`Created a database pool of ${this.dbConfig.connectionLimit} connections`) + + if (debug) { + this.pool.on('acquire', function (conn) { + Logger.info(`Connection ${conn.threadId} acquired`) + }) + this.pool.on('enqueue', function (conn) { + Logger.info('Waiting for a new connection slot') + }) + this.pool.on('release', function (conn) { + Logger.info(`Connection ${conn.threadId} released`) + }) + } + } catch(e) { + Logger.error(err, 'MySqlDbWrapper.handleConnect() : Problem met while trying to initialize a new pool') + throw e + } + } + + /** + * Reconnect the wrapper to the mysql server + */ + handleReconnect() { + if (this.pool) { + // Manage the lock + if (this.lockReconnect) + return + + this.lockReconnect = true + + if (this.timerReconnect) + clearTimeout(this.timerReconnect) + + // Destroy previous pool + this.pool.end(err => { + if (err) { + Logger.error(err, 'MySqlDbWrapper.handleReconnect() : Problem met while terminating the pool') + this.timerReconnect = setTimeout(this.handleReconnect.bind(this), 2000) + } else { + this.handleConnect() + } + this.lockReconnect = false + }) + } + } + + /** + * Ping the mysql server + */ + ping() { + debug && Logger.info(`MySqlDbWrapper.ping() : ${this.pool._freeConnections.length} free connections`) + + // Iterate over all free connections + // which might have been disconnected by the mysql server + for (let c of this.pool._freeConnections) { + c.query('SELECT 1', (err, res, fields) => { + if (debug && err) { + Logger.error(err, `MySqlDbWrapper.ping() : Ping Error`) + } + }) + } + } + + /** + * Send a query + */ + async _query(query) { + queryDebug && Logger.info(query) + + return new Promise((resolve, reject) => { + try { + this.pool.query(query, null, (err, result, fields) => { + if (err) { + this.queryError(err, query) + reject(err) + } else { + queryDebug && Logger.info(result) + resolve(result) + } + }) + } catch(err) { + this.queryError(err, query) + reject(err) + } + }) + } + + /** + * Log a query error + */ + queryError(err, query) { + Logger.error(err, 'MySqlDbWrapper.query() : Query Error') + Logger.error(query) + } + + /** + * Get the ID of an address + * @param {string} address - bitcoin address + * @returns {integer} returns the address id + */ + async getAddressId(address) { + const sqlQuery = 'SELECT `addrID` FROM `addresses` WHERE `addrAddress` = ?' + const params = address + const query = mysql.format(sqlQuery, params) + const result = await this._query(query) + + if (result.length > 0) + return result[0].addrID + + throw errors.db.ERROR_NO_ADDRESS + } + + /** + * Get the ID of an Address. Ensures that the address exists. + * @param {string} address - bitcoin address + * @returns {integer} returns the address id + */ + async ensureAddressId(address) { + const sqlQuery = 'SELECT `addrID` FROM `addresses` WHERE `addrAddress` = ?' + const params = address + const query = mysql.format(sqlQuery, params) + const result = await this._query(query) + + if (result.length > 0) + return result[0].addrID + + const sqlQuery2 = 'INSERT INTO `addresses` SET ?' + const params2 = { addrAddress: address } + const query2 = mysql.format(sqlQuery2, params2) + const result2 = await this._query(query2) + return result2.insertId + } + + /** + * Get the IDs of an array of Addresses + * @param {string[]} addresses - array of bitcoin addresses + * @returns {object} returns a map of addresses to IDs: {[address]: 100} + */ + async getAddressesIds(addresses) { + const ret = {} + + if (addresses.length == 0) + return ret + + const sqlQuery = 'SELECT * FROM `addresses` WHERE `addrAddress` IN (?)' + const params = [addresses] + const query = mysql.format(sqlQuery, params) + const result = await this._query(query) + + for (let r of result) + ret[r.addrAddress] = r.addrID + + return ret + } + + /** + * Bulk insert addresses. + * @param {string[]} addresses - array of bitcoin addresses + */ + async addAddresses(addresses) { + if (addresses.length == 0) + return [] + + const sqlQuery = 'INSERT IGNORE INTO `addresses` (addrAddress) VALUES ?' + const params = [addresses.map(a => [a])] + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + /** + * Bulk select address entries + * @param {string[]} addresses - array of bitcoin addresses + */ + async getAddresses(addresses) { + if (addresses.length == 0) + return [] + + const sqlQuery = 'SELECT * FROM `addresses` WHERE `addrAddress` IN (?)' + const params = [addresses] + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + /** + * Get address balance. + * @param {string} address - bitcoin address + * @returns {integer} returns the balance of the address + */ + async getAddressBalance(address) { + if (address == null) + return null + + const sqlQuery = 'SELECT SUM(`outputs`.`outAmount`) as balance \ + FROM `addresses` \ + INNER JOIN `outputs` ON `outputs`.`addrID` = `addresses`.`addrID` \ + LEFT JOIN `inputs` ON `outputs`.`outID` = `inputs`.`outID` \ + WHERE \ + `inputs`.`outID` IS NULL AND \ + `addresses`.`addrAddress` = ?' + + const params = address + const query = mysql.format(sqlQuery, params) + const results = await this._query(query) + + if (results.length == 1) { + const balance = results[0].balance + return (balance == null) ? 0 : balance + } + + return null + } + + /** + * Get the number of transactions for an address. + * @param {string} address - bitcoin address + * @returns {integer} returns the number of transactions for the address + */ + async getAddressNbTransactions(address) { + if(address == null) + return null + + const sqlQuery = 'SELECT COUNT(DISTINCT `r`.`txnTxid`) AS nbTxs \ + FROM ( \ + ( \ + SELECT `transactions`.`txnTxid` AS txnTxid \ + FROM `outputs` \ + INNER JOIN `transactions` ON `transactions`.`txnID` = `outputs`.`txnID` \ + INNER JOIN `addresses` ON `outputs`.`addrID` = `addresses`.`addrID` \ + WHERE `addresses`.`addrAddress` = ? \ + ) UNION ( \ + SELECT `transactions`.`txnTxid` AS txnTxid \ + FROM `inputs` \ + INNER JOIN `transactions` ON `transactions`.`txnID` = `inputs`.`txnID` \ + INNER JOIN `outputs` ON `outputs`.`outID` = `inputs`.`outID` \ + INNER JOIN `addresses` ON `outputs`.`addrID` = `addresses`.`addrID` \ + WHERE `addresses`.`addrAddress` = ? \ + ) \ + ) AS `r`' + + const params = [address, address] + const query = mysql.format(sqlQuery, params) + const results = await this._query(query) + + if (results.length == 1) { + const nbTxs = results[0].nbTxs + return (nbTxs == null) ? 0 : nbTxs + } + + return null + } + + /** + * Get an HD account. + * @param {string} xpub - xpub + * @returns {integer} returns {hdID, hdXpub, hdCreated, hdType} + * throws if no record of xpub + */ + async getHDAccount(xpub) { + const sqlQuery = 'SELECT * FROM `hd` WHERE `hdXpub` = ?' + const params = xpub + const query = mysql.format(sqlQuery, params) + const result = await this._query(query) + + if (result.length > 0) + return result[0] + + throw errors.db.ERROR_NO_HD_ACCOUNT + } + + /** + * Get the ID of an HD account + * @param {string} xpub - xpub + * @returns {integer} returns the id of the hd account + */ + async getHDAccountId(xpub) { + const sqlQuery = 'SELECT `hdID` FROM `hd` WHERE `hdXpub` = ?' + const params = xpub + const query = mysql.format(sqlQuery, params) + const result = await this._query(query) + + if (result.length > 0) + return result[0].hdID + + throw errors.db.ERROR_NO_HD_ACCOUNT + } + + /** + * Get the ID of an HD account. Ensures that the account exists. + * @param {string} xpub - xpub + * @param {string} type - hd account type + * @returns {integer} returns the id of the hd account + */ + async ensureHDAccountId(xpub, type) { + const info = hdaHelper.classify(type) + + if (info.type === null) + throw errors.xpub.SEGWIT + + // Get the ID of the xpub + const sqlQuery = 'SELECT `hdID` FROM `hd` WHERE `hdXpub` = ?' + const params = xpub + const query = mysql.format(sqlQuery, params) + const result = await this._query(query) + + if (result.length > 0) + return result[0].hdID + + const sqlQuery2 = 'INSERT INTO `hd` SET ?' + const params2 = { + hdXpub: xpub, + hdCreated: util.unix(), + hdType: type, + } + const query2 = mysql.format(sqlQuery2, params2) + const result2 = await this._query(query2) + return result2.insertId + + } + + /** + * Lock the type of a hd account + * @param {string} xpub - xpub + * @returns {boolean} locked - true for locking, false for unlocking + */ + async setLockHDAccountType(xpub, locked) { + locked = !!locked + + const account = await this.getHDAccount(xpub) + const info = hdaHelper.classify(account.hdType) + + if (info.locked == locked) + return true + + const hdType = hdaHelper.makeType(account.hdType, locked) + const sqlQuery = 'UPDATE `hd` SET `hdType` = ? WHERE `hdXpub` = ?' + const params = [hdType, xpub] + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + /** + * Delete a hd account + * @param {string} xpub - xpub + */ + async deleteHDAccount(xpub) { + try { + // Check that this HD account exists + await this.getHDAccountId(xpub) + + // Delete addresses associated with this xpub. + // Address deletion cascades into transaction inputs & outputs. + const sqlQuery = 'DELETE `addresses`.* FROM `addresses` \ + INNER JOIN `hd_addresses` ON `hd_addresses`.`addrID` = `addresses`.`addrID` \ + INNER JOIN `hd` ON `hd_addresses`.`hdID` = `hd`.`hdID` \ + WHERE `hd`.`hdXpub` = ?' + const params = xpub + const query = mysql.format(sqlQuery, params) + await this._query(query) + + // Delete HD account entry + const sqlQuery2 = 'DELETE FROM `hd` WHERE `hdXpub` = ?' + const params2 = xpub + const query2 = mysql.format(sqlQuery2, params2) + return this._query(query2) + + } catch(e) {} + } + + /** + * Add an address a hd account + * @param {string} address - bitcoin address + * @param {string} xpub - xpub + * @param {integer} chain - derivation chain + * @param {index} index - derivation index for the address + */ + async addAddressToHDAccount(address, xpub, chain, index) { + const results = await Promise.all([ + this.ensureAddressId(address), + this.getHDAccountId(xpub) + ]) + + const addrID = results[0] + const hdID = results[1] + + if (hdID == null) + throw null + + const sqlQuery = 'INSERT INTO `hd_addresses` SET ?' + const params = { + hdID: hdID, + addrID: addrID, + hdAddrChain: chain, + hdAddrIndex: index + } + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + /** + * Bulk-add addresses to an HD account. + * @param {string} xpub - xpub + * @param {object[]} addressData - array of {address: '...', chain: , index: } + * which is the output of the HDAccountsHelper.deriveAddresses() + */ + async addAddressesToHDAccount(xpub, addressData) { + if (addressData.length == 0) + return + + const addresses = addressData.map(d => d.address) + const hdID = await this.getHDAccountId(xpub) + + // Bulk insert addresses + await this.addAddresses(addresses) + + // Bulk get address IDs + const addrIdMap = await this.getAddressesIds(addresses) + + // Convert input addressData into database entry format + const data = [] + for (let entry of addressData) { + data.push([ + hdID, + addrIdMap[entry.address], + entry.chain, + entry.index + ]) + } + + const sqlQuery = 'INSERT IGNORE INTO `hd_addresses` \ + (hdID, addrID, hdAddrChain, hdAddrIndex) VALUES ?' + const params = [data] + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + /** + * Get hd accounts associated to a list of addresses + * @param {string[]} addresses - array of bitcoin addresses + * @returns {object[]} + */ + async getUngroupedHDAccountsByAddresses(addresses) { + if (addresses.length == 0) return {} + + const sqlQuery = 'SELECT \ + `hd`.`hdID`, \ + `hd`.`hdXpub`, \ + `hd`.`hdType`, \ + `addresses`.`addrID`, \ + `addresses`.`addrAddress`, \ + `hd_addresses`.`hdAddrChain`, \ + `hd_addresses`.`hdAddrIndex` \ + FROM `addresses` \ + LEFT JOIN `hd_addresses` ON `hd_addresses`.`addrID` = `addresses`.`addrID` \ + LEFT JOIN `hd` ON `hd_addresses`.`hdID` = `hd`.`hdID` \ + WHERE `addresses`.`addrAddress` IN (?) \ + AND `addresses`.`addrAddress` NOT IN (SELECT addrAddress FROM banned_addresses)' + + const params = [addresses] + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + /** + * Get any HD accounts that own the input addresses + * If addresses are known but not associated with an HD account, + * theyare returned in the `loose` category + * @param {string[]} addresses - array of bitcoin addresses + * @returns {object} + * { + * hd: { + * [xpub]: { + * hdID: N, + * hdType: M, + * addresses:[...] + * }, + * ... + * } + * loose:[...] + * } + */ + async getHDAccountsByAddresses(addresses) { + const ret = { + hd: {}, + loose: [] + } + + if (addresses.length == 0) + return ret + + const sqlQuery = 'SELECT \ + `hd`.`hdID`, \ + `hd`.`hdXpub`, \ + `hd`.`hdType`, \ + `addresses`.`addrID`, \ + `addresses`.`addrAddress`, \ + `hd_addresses`.`hdAddrChain`, \ + `hd_addresses`.`hdAddrIndex` \ + FROM `addresses` \ + LEFT JOIN `hd_addresses` ON `hd_addresses`.`addrID` = `addresses`.`addrID` \ + LEFT JOIN `hd` ON `hd_addresses`.`hdID` = `hd`.`hdID` \ + WHERE `addresses`.`addrAddress` IN (?) \ + AND `addresses`.`addrAddress` NOT IN (SELECT addrAddress FROM banned_addresses)' + + const params = [addresses] + const query = mysql.format(sqlQuery, params) + const results = await this._query(query) + + for (let r of results) { + if (r.hdXpub == null) { + ret.loose.push({ + addrID: r.addrID, + addrAddress: r.addrAddress, + }) + } else { + if (!ret.hd[r.hdXpub]) { + ret.hd[r.hdXpub] = { + hdID: r.hdID, + hdType: r.hdType, + addresses: [] + } + } + + ret.hd[r.hdXpub].addresses.push({ + addrID: r.addrID, + addrAddress: r.addrAddress, + hdAddrChain: r.hdAddrChain, + hdAddrIndex: r.hdAddrIndex, + }) + } + } + + return ret + } + + /** + * Get an HD account balance + * @param {string} xpub - xpub + * @returns {integer} returns the balance of the hd account + */ + async getHDAccountBalance(xpub) { + const sqlQuery = 'SELECT \ + SUM(`outputs`.`outAmount`) as balance \ + FROM `hd_addresses` \ + INNER JOIN `addresses` ON `hd_addresses`.`addrID` = `addresses`.`addrID` \ + INNER JOIN `hd` ON `hd_addresses`.`hdID` = `hd`.`hdID` \ + INNER JOIN `outputs` ON `outputs`.`addrID` = `addresses`.`addrID` \ + LEFT JOIN `inputs` ON `outputs`.`outID` = `inputs`.`outID` \ + WHERE `inputs`.`outID` IS NULL \ + AND `hd`.`hdXpub` = ?' + + const params = xpub + const query = mysql.format(sqlQuery, params) + const results = await this._query(query) + + if (results.length == 1) + return (results[0].balance == null) ? 0 : results[0].balance + + return null + } + + /** + * Get next unused address indices for each HD chain of an account + * @param {string} xpub - xpub + * @returns {integer[]} returns an array of unused indices + * [M/0/X, M/1/Y] -> [X,Y] + */ + async getHDAccountNextUnusedIndices(xpub) { + const sqlQuery = 'SELECT \ + `hd_addresses`.`hdAddrChain`, \ + MAX(`hd_addresses`.`hdAddrIndex`) + 1 AS `nextUnusedIndex` \ + FROM `hd_addresses` \ + INNER JOIN `addresses` ON `hd_addresses`.`addrID` = `addresses`.`addrID` \ + INNER JOIN `hd` ON `hd_addresses`.`hdID` = `hd`.`hdID` \ + INNER JOIN `outputs` ON `outputs`.`addrID` = `addresses`.`addrID` \ + WHERE `hd`.`hdXpub` = ? \ + GROUP BY `hd_addresses`.`hdAddrChain`' + + const params = xpub + const query = mysql.format(sqlQuery, params) + const results = await this._query(query) + + const ret = [0, 0] + + for (let r of results) + if ([0,1].indexOf(r.hdAddrChain) > -1) + ret[r.hdAddrChain] = r.nextUnusedIndex + + return ret + } + + /** + * Get the maximum derived address index for each HD chain of an account + * @param {string} xpub - xpub + * @returns {integer[]} returns an array of derived indices + * [M/0/X, M/1/Y] -> [X,Y] + */ + async getHDAccountDerivedIndices(xpub) { + const sqlQuery = 'SELECT \ + `hd_addresses`.`hdAddrChain`, \ + MAX(`hd_addresses`.`hdAddrIndex`) AS `maxDerivedIndex` \ + FROM `hd_addresses` \ + INNER JOIN `addresses` ON `hd_addresses`.`addrID` = `addresses`.`addrID` \ + INNER JOIN `hd` ON `hd_addresses`.`hdID` = `hd`.`hdID` \ + WHERE `hd`.`hdXpub` = ? \ + GROUP BY `hd_addresses`.`hdAddrChain`' + + const params = xpub + const query = mysql.format(sqlQuery, params) + const results = await this._query(query) + + const ret = [-1, -1] + + for (let r of results) + if ([0,1].indexOf(r.hdAddrChain) > -1) + ret[r.hdAddrChain] = r.maxDerivedIndex + + return ret + } + + /** + * Get the number of transactions for an HD account + * @param {string} xpub - xpub + * @returns {integer} returns the balance of the hd account + */ + async getHDAccountNbTransactions(xpub) { + const sqlQuery = 'SELECT COUNT(DISTINCT `r`.`txnTxid`) AS nbTxs \ + FROM ( \ + ( \ + SELECT `transactions`.`txnTxid` AS txnTxid \ + FROM `outputs` \ + INNER JOIN `transactions` ON `transactions`.`txnID` = `outputs`.`txnID` \ + INNER JOIN `hd_addresses` ON `hd_addresses`.`addrID` = `outputs`.`addrID` \ + INNER JOIN `hd` ON `hd`.`hdID` = `hd_addresses`.`hdID` \ + WHERE `hd`.`hdXpub` = ? \ + ) UNION ( \ + SELECT `transactions`.`txnTxid` AS txnTxid \ + FROM `inputs` \ + INNER JOIN `transactions` ON `transactions`.`txnID` = `inputs`.`txnID` \ + INNER JOIN `outputs` ON `outputs`.`outID` = `inputs`.`outID` \ + INNER JOIN `hd_addresses` ON `hd_addresses`.`addrID` = `outputs`.`addrID` \ + INNER JOIN `hd` ON `hd`.`hdID` = `hd_addresses`.`hdID` \ + WHERE `hd`.`hdXpub` = ? \ + ) \ + ) AS `r`' + + const params = [xpub, xpub] + const query = mysql.format(sqlQuery, params) + const results = await this._query(query) + + if (results.length == 1) + return (results[0].nbTxs == null) ? 0 : results[0].nbTxs + + return null + } + + /** + * Get the number of transactions for a list of addresses and HD accounts + * @param {string[]} addresses - array of bitcoin addresses + * @param {string[]} xpubs - array of xpubs + * @returns {int} returns the number of transactions + */ + async getAddrAndXpubsNbTransactions(addresses, xpubs) { + if ( + (!addresses || addresses.length == 0) + && (!xpubs || xpubs.length == 0) + ) { + return [] + } + + // Prepares subqueries for the query + // retrieving txs of interest + let subQuery = '' + let subQueries = [] + + if (addresses && addresses.length > 0) { + const params = [addresses, addresses] + subQuery = mysql.format(SUBQUERY_TXIDS_ADDR, params) + subQueries.push(subQuery) + } + + if (xpubs && xpubs.length > 0) { + const params = [xpubs, xpubs] + subQuery = mysql.format(SUBQUERY_TXIDS_XPUBS, params) + subQueries.push(subQuery) + } + + subQuery = subQueries.join(' UNION ') + + const sqlQuery = 'SELECT COUNT(DISTINCT `r`.`txnTxid`) AS nbTxs \ + FROM (' + subQuery + ') AS `r`' + + let query = mysql.format(sqlQuery) + const results = await this._query(query) + + if (results.length == 1) + return (results[0].nbTxs == null) ? 0 : results[0].nbTxs + + return null + } + + /** + * Get the transactions for a list of addresses and HD accounts + * @param {string[]} addresses - array of bitcoin addresses + * @param {string[]} xpubs - array of xpubs + * @returns {object[]} returns an array of transactions + */ + async getTxsByAddrAndXpubs(addresses, xpubs, page, nbTxsPerPage) { + if ( + (!addresses || addresses.length == 0) + && (!xpubs || xpubs.length == 0) + ) { + return [] + } + + // Manages the paging + if (page == null) + page = 0 + + if (nbTxsPerPage == null) + nbTxsPerPage = keys.multiaddr.transactions + + const skip = page * nbTxsPerPage + + // Prepares subqueries for the query + // retrieving txs of interest + let subQuery = '' + let subQueries = [] + + if (addresses && addresses.length > 0) { + const params = [addresses, addresses] + subQuery = mysql.format(SUBQUERY_TXS_ADDR, params) + subQueries.push(subQuery) + } + + if (xpubs && xpubs.length > 0) { + const params = [xpubs, xpubs] + subQuery = mysql.format(SUBQUERY_TXS_XPUB, params) + subQueries.push(subQuery) + } + + subQuery = subQueries.join(' UNION DISTINCT ') + + // Get a page of transactions + const sqlQuery = 'SELECT \ + `txs`.`txnID`, \ + `txs`.`txnTxid`, \ + `txs`.`txnVersion`, \ + `txs`.`txnLocktime`, \ + `txs`.`blockHeight`, \ + `txs`.`time` \ + FROM (' + subQuery + ') AS txs \ + ORDER BY `txs`.`time` DESC, `txs`.`txnID` DESC \ + LIMIT ?,?' + const params = [skip, nbTxsPerPage] + let query = mysql.format(sqlQuery, params) + const txs = await this._query(query) + + const txsIds = txs.map(t => t.txnID) + + if (txsIds.length == 0) + return [] + + // Prepares subqueries for + // the query retrieving utxos of interest + let subQuery2 = '' + let subQueries2 = [] + + if (addresses && addresses.length > 0) { + const params2 = [txsIds, addresses, txsIds, addresses] + subQuery2 = mysql.format(SUBQUERY_UTXOS_ADDR, params2) + subQueries2.push(subQuery2) + } + + if (xpubs && xpubs.length > 0) { + const params2 = [txsIds, xpubs, txsIds, xpubs] + subQuery2 = mysql.format(SUBQUERY_UTXOS_XPUB, params2) + subQueries2.push(subQuery2) + } + + subQuery2 = subQueries2.join(' UNION DISTINCT ') + + // Get inputs and outputs of interest + const sqlQuery2 = 'SELECT * \ + FROM (' + subQuery2 + ') AS `utxos` \ + ORDER BY `utxos`.`outIndex` ASC, `utxos`.`inIndex` ASC' + + let query2 = mysql.format(sqlQuery2) + const utxos = await this._query(query2) + + return this.assembleTransactions(txs, utxos) + } + + /** + * Initialize a transaction object returned as response to queries + * @param {object} tx - transaction data retrieved from db + * @returns {object} returns the transaction stub + */ + _transactionStub(tx) { + let ret = { + hash: tx.txnTxid, + time: (tx.time < 32503680000) ? tx.time : 0, + version: tx.txnVersion, + locktime: tx.txnLocktime, + result: 0, + inputs: [], + out: [] + } + + if (tx.blockHeight != null) + ret.block_height = tx.blockHeight + + return ret + } + + /** + * Initialize an input object returned as part of a response + * @param {object} input - input data retrieved from db + * @returns {object} returns the input stub + */ + _inputStub(input) { + let ret = { + vin: input.inIndex, + sequence: input.inSequence, + prev_out: { + txid: input.inOutTxid, + vout: input.inOutIndex, + value: input.inOutAmount, + addr: input.inOutAddress + } + } + + if (input.hdXpub && input.hdXpub !== null) { + ret.prev_out.xpub = { + m: input.hdXpub, + path: ['M', input.hdAddrChain, input.hdAddrIndex].join('/') + } + } + + return ret + } + + /** + * Initialize an output object returned as part of a response + * @param {object} output - output data retrieved from db + * @returns {object} returns the output stub + */ + _outputStub(output) { + let ret = { + n: output.outIndex, + value: output.outAmount, + addr: output.outAddress + } + + if (output.hdXpub && output.hdXpub !== null) { + ret.xpub = { + m: output.hdXpub, + path: ['M', output.hdAddrChain, output.hdAddrIndex].join('/') + } + } + + return ret + } + + /** + * Take query results for txs and utxos and combine into transaction data + * @param {object[]} txs - array of transaction data retrieved from db + * @param {object[]} utxos - array of utxos data retrieved from db + * @returns {object[]} returns an array of transaction objects + */ + assembleTransactions(txs, utxos) { + const txns = {} + + for (let tx of txs) { + if (!txns[tx.txnID]) + txns[tx.txnID] = this._transactionStub(tx) + } + + for (let u of utxos) { + if (u.txnID != null && txns[u.txnID]) { + if (u.inIndex != null) { + txns[u.txnID].result -= u.inOutAmount + txns[u.txnID].inputs.push(this._inputStub(u)) + } else if (u.outIndex != null) { + txns[u.txnID].result += u.outAmount + txns[u.txnID].out.push(this._outputStub(u)) + } + } + } + + // Return transactions in descending time order, most recent first + const ret = Object.keys(txns).map(key => txns[key]) + ret.sort((a,b) => b.time - a.time) + return ret + } + + /** + * Get a list of unspent outputs for given hd account + * @param {string} xpub - xpub + * @returns {object[]} returns an array of utxos objects + * {txnTxid, txnVersion, txnLocktime, outIndex, outAmount, outScript, addrAddress} + */ + async getHDAccountUnspentOutputs(xpub) { + const sqlQuery = 'SELECT \ + `txnTxid`, \ + `txnVersion`, \ + `txnLocktime`, \ + `blockHeight`, \ + `outIndex`, \ + `outAmount`, \ + `outScript`, \ + `addrAddress`, \ + `hdAddrChain`, \ + `hdAddrIndex` \ + FROM `outputs` \ + INNER JOIN `addresses` ON `outputs`.`addrID` = `addresses`.`addrID` \ + INNER JOIN `hd_addresses` ON `outputs`.`addrID` = `hd_addresses`.`addrID` \ + INNER JOIN `hd` ON `hd_addresses`.`hdID` = `hd`.`hdID` \ + INNER JOIN `transactions` ON `outputs`.`txnID` = `transactions`.`txnID` \ + LEFT JOIN `blocks` ON `transactions`.`blockID` = `blocks`.`blockID` \ + LEFT JOIN `inputs` ON `outputs`.`outID` = `inputs`.`outID` \ + WHERE `inputs`.`outID` IS NULL \ + AND `hd`.`hdXpub` = ?' + + const params = xpub + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + /** + * Get addresses that belong to a given hd account + * @param {string[]} addresses - array of bitcoin addresses + * @returns {object} returns a dictionary {[address]: hdXpub, ...} + */ + async getXpubByAddresses(addresses) { + const ret = {} + + if (addresses.length == 0) + return ret + + const sqlQuery = 'SELECT `hd`.`hdXpub`, `addresses`.`addrAddress` \ + FROM `addresses` \ + INNER JOIN `hd_addresses` ON `hd_addresses`.`addrID` = `addresses`.`addrID` \ + INNER JOIN `hd` ON `hd_addresses`.`hdID` = `hd`.`hdID` \ + WHERE `addresses`.`addrAddress` IN (?)' + + const params = [addresses] + const query = mysql.format(sqlQuery, params) + const results = await this._query(query) + + for (let r of results) + ret[r.addrAddress] = r.hdXpub + + return ret + } + + /** + * Get the mysql ID of a transaction. Ensures that the transaction exists. + * @param {string} txid - txid of a transaction + * @returns {integer} returns the transaction id (mysql id) + */ + async ensureTransactionId(txid) { + const sqlQuery = 'INSERT IGNORE INTO `transactions` SET ?' + const params = { + txnTxid: txid, + txnCreated: util.unix() + } + const query = mysql.format(sqlQuery, params) + const result = await this._query(query) + + // Successful insertion + if (result.insertId > 0) + return result.insertId + + // Transaction already in db + const sqlQuery2 = 'SELECT `txnID` FROM `transactions` WHERE `txnTxid` = ?' + const params2 = txid + const query2 = mysql.format(sqlQuery2, params2) + const result2 = await this._query(query2) + + if (result2.length > 0) + return result2[0].txnID + + throw 'Problem met while trying to insert a new transaction' + } + + /** + * Get the mysql ID of a transaction + * @param {string} txid - txid of a transaction + * @returns {integer} returns the transaction id (mysql id) + */ + async getTransactionId(txid) { + const sqlQuery = 'SELECT `txnID` FROM `transactions` WHERE `txnTxid` = ?' + const params = txid + const query = mysql.format(sqlQuery, params) + const result = await this._query(query) + return (result.length == 0) ? null : result[0].txnID + } + + /** + * Get the mysql IDs of a set of transactions + * @param {string[]} txid - array of transactions txids + * @returns {integer[]} returns an array of transaction ids (mysql ids) + */ + async getTransactionsById(txnIDs) { + if (txnIDs.length == 0) + return [] + + const sqlQuery = 'SELECT * FROM `transactions` WHERE `txnID` IN (?)' + const params = [txnIDs] + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + /** + * Insert a new transaction in db + * @param {object} tx - {txid, version, locktime} + */ + async addTransaction(tx) { + if (!tx.created) + tx.created = util.unix() + + const sqlQuery = 'INSERT INTO `transactions` \ + (txnTxid, txnCreated, txnVersion, txnLocktime) VALUES (?) \ + ON DUPLICATE KEY UPDATE txnVersion = VALUES(txnVersion)' + + const params = [[ + tx.txid, + tx.created, + tx.version, + tx.locktime + ]] + + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + /** + * Get a transaction for a given txid + * @param {string} txid - txid of the transaction + */ + async getTransaction(txid) { + // Get transaction outputs + const outputsQuery = mysql.format(SUBQUERY_GET_TX_OUTS, txid) + // Get transaction inputs + const inputsQuery = mysql.format(SUBQUERY_GET_TX_INS, txid) + + const results = await Promise.all([ + this._query(outputsQuery), + this._query(inputsQuery) + ]) + + const tx = { + hash: txid, + time: Infinity, + version: 0, + locktime: 0, + inputs: [], + out: [], + block_height: null, + } + + // Process the outputs + for (let output of results[0]) { + tx.version = output.txnVersion + tx.locktime = output.txnLocktime + + if (output.blockTime != null) + tx.time = Math.min(tx.time, output.blockTime) + + tx.time = Math.min(tx.time, output.txnCreated) + + if (output.blockHeight != null) + tx.block_height = output.blockHeight + + const fmt = { + n: output.outIndex, + value: output.outAmount, + addr: output.addrAddress, + script: output.outScript, + } + + if (output.hdXpub) { + fmt.xpub = { + m: output.hdXpub, + path: ['M', output.hdAddrChain, output.hdAddrIndex].join('/') + } + } + + tx.out.push(fmt) + } + + // Process the inputs + for (let input of results[1]) { + tx.version = input.txnVersion + tx.locktime = input.txnLocktime + + if (input.blockTime != null) + tx.time = Math.min(tx.time, input.blockTime) + + tx.time = Math.min(tx.time, input.txnCreated) + + if (input.blockHeight != null) + tx.block_height = input.blockHeight + + const fmt = { + vin: input.inIndex, + prev_out: { + txid: input.prevOutTxid, + vout: input.outIndex, + value: input.outAmount, + addr: input.addrAddress, + script: input.outScript, + }, + sequence: input.inSequence + } + + if (input.hdXpub) { + fmt.prev_out.xpub = { + m: input.hdXpub, + path: ['M', input.hdAddrChain, input.hdAddrIndex].join('/') + } + } + + tx.inputs.push(fmt) + } + + // Remove block height if null + if (tx.block_height == null) + delete tx.block_height + + return tx + } + + /** + * Get the unconfirmed transactions + * @returns {object[]} returns an array of transactions data + */ + async getUnconfirmedTransactions() { + const query = 'SELECT * FROM `transactions` WHERE blockID IS NULL' + return this._query(query) + } + + /** + * Get all transactions + * @returns {object[]} returns an array of transactions data + */ + async getTransactions() { + const query = 'SELECT * FROM `transactions`' + return this._query(query) + } + + /** + * Get the inputs of a transaction + * @param {string} txnID - mysql id of the transaction + * @returns {object[]} returns an array of inputs + */ + async getTxInputs(txnID) { + const sqlQuery = 'SELECT * FROM `inputs` WHERE `txnID` = ?' + const params = txnID + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + /** + * Batch confirm txids in a block + * @param {string[]} txnTxidArray - array of transaction txids + * @param {integer} blockID - mysql id of the blck + */ + async confirmTransactions(txnTxidArray, blockID) { + if (txnTxidArray.length == 0) + return + + const sqlQuery = 'UPDATE `transactions` SET `blockID` = ? WHERE `txnTxid` IN (?)' + const params = [blockID, txnTxidArray] + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + /** + * Get the transactions confirmed after a given height + * @param {integer]} height - block height + * @param {object[]} returns an array of transactions + */ + async getTransactionsConfirmedAfterHeight(height) { + const sqlQuery = 'SELECT `transactions`.* FROM `transactions` \ + INNER JOIN `blocks` ON `blocks`.`blockID` = `transactions`.`blockID` \ + WHERE `blocks`.`blockHeight` > ?' + const params = height + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + /** + * Delete the transactions confirmed after a given height + * @param {integer]} height - block height + */ + async deleteTransactionsConfirmedAfterHeight(height) { + const sqlQuery = 'DELETE `transactions`.* FROM `transactions` \ + INNER JOIN `blocks` ON `blocks`.`blockID` = `transactions`.`blockID` \ + WHERE `blocks`.`blockHeight` > ?' + const params = height + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + /** + * Batch unconfirm a set of transactions + * @param {string[]} txnTxidArray - array of transaction txids + */ + async unconfirmTransactions(txnTxidArray) { + if (txnTxidArray.length == 0) + return + + const sqlQuery = 'UPDATE `transactions` SET `blockID` = NULL WHERE `txnTxid` IN (?)' + const params = [txnTxidArray] + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + /** + * Delete a transaction identified by its txid + * @param {string} txid - txid of the transaction + */ + async deleteTransaction(txid) { + const sqlQuery = 'DELETE `transactions`.* FROM `transactions` WHERE `transactions`.`txnTxid` = ?' + const params = txid + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + /** + * Delete a set of transactions identified by their mysql ids + * @param {integer[]} txnIDs - mysql ids of the transactions + */ + async deleteTransactionsByID(txnIDs) { + if (txnIDs.length == 0) + return [] + + const sqlQuery = 'DELETE `transactions`.* FROM `transactions` WHERE `transactions`.`txnID` in (?)' + const params = [txnIDs] + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + /** + * Insert transaction outputs associated with known addresses + * @param {object[]} outputs - array of {txnID, addrID, outIndex, outAmount, outScript} + */ + async addOutputs(outputs) { + if (outputs.length == 0) + return + + const sqlQuery = 'INSERT IGNORE INTO `outputs` \ + (txnID, addrID, outIndex, outAmount, outScript) VALUES ?' + + const params = [outputs.map(o => [o.txnID, o.addrID, o.outIndex, o.outAmount, o.outScript])] + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + /** + * Get a list of outputs identified by their txid and index. + * The presence of spendingTxnID and spendingInID not null indicate that an + * input spending the transaction output index is already in the database and + * may indicate a DOUBLE SPEND. + * @param {object[]} spends - array of {txid,index} + * @returns {object[]} returns a array of output objects + * {addrAddress, outID, outAmount, txnTxid, outIndex, spendingTxnID/null, spendingInID/null} + */ + async getOutputSpends(spends) { + if (spends.length == 0) + return [] + + const whereClauses = + spends.map(s => '(`txnTxid`=' + this.pool.escape(s.txid) + ' AND `outIndex`=' + this.pool.escape(s.index) + ')') + + const whereClause = whereClauses.join(' OR ') + + const sqlQuery = 'SELECT \ + `addrAddress`, \ + `outputs`.`outID`, \ + `outAmount`, \ + `txnTxid`, \ + `outIndex`, \ + `inputs`.`txnID` AS `spendingTxnID` \ + FROM `outputs` \ + INNER JOIN `addresses` ON `outputs`.`addrID` = `addresses`.`addrID` \ + INNER JOIN `transactions` ON `outputs`.`txnID` = `transactions`.`txnID` \ + LEFT JOIN `inputs` ON `inputs`.`outID` = `outputs`.outID \ + WHERE ' + whereClause + + const query = mysql.format(sqlQuery) + return this._query(query) + } + + /** + * Get a list of mysql ids for outputs identified by their txid and index. + * @param {object[]} spends - array of {txid,vout} + * @returns {object[]} returns a array of output objects + * {outID, txnTxid, outIndex} + */ + async getOutputIds(spends) { + if (spends.length == 0) + return [] + + const whereClauses = + spends.map((s) => '(`txnTxid`=' + this.pool.escape(s.txid) + ' AND `outIndex`=' + this.pool.escape(s.vout) + ')') + + const whereClause = whereClauses.join(' OR ') + + const sqlQuery = 'SELECT \ + `outID`, \ + `txnTxid`, \ + `outIndex` \ + FROM `outputs` \ + INNER JOIN `transactions` ON `outputs`.`txnID` = `transactions`.`txnID` \ + WHERE ' + whereClause + + const query = mysql.format(sqlQuery) + return this._query(query) + } + + /** + * Get a list of unspent outputs for a list of addresses + * @param {string[]} addresses - array of bitcoin addresses + * @returns {object[]} returns a array of output objects + * {txnTxid, outIndex, outAmount, outScript, addrAddress} + */ + async getUnspentOutputs(addresses) { + if (addresses.length == 0) + return [] + + const sqlQuery = 'SELECT \ + `txnTxid`, \ + `txnVersion`, \ + `txnLocktime`, \ + `blockHeight`, \ + `outIndex`, \ + `outAmount`, \ + `outScript`, \ + `addrAddress` \ + FROM `outputs` \ + INNER JOIN `addresses` ON `outputs`.`addrID` = `addresses`.`addrID` \ + INNER JOIN `transactions` ON `outputs`.`txnID` = `transactions`.`txnID` \ + LEFT JOIN `blocks` ON `transactions`.`blockID` = `blocks`.`blockID` \ + LEFT JOIN `inputs` ON `outputs`.`outID` = `inputs`.`outID` \ + WHERE `inputs`.`outID` IS NULL \ + AND (`addrAddress`) IN (?)' + + const params = [addresses] + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + /** + * Insert transaction inputs that spend known Outputs + * @param {object[]} inputs - array of input objects + * {txnID, outID, inIndex, inSequence} + */ + async addInputs(inputs) { + if (inputs.length == 0) + return + + const sqlQuery = 'INSERT INTO `inputs` \ + (txnID, outID, inIndex, inSequence) VALUES ? \ + ON DUPLICATE KEY UPDATE outID = VALUES(outID)' + + const params = [inputs.map((i) => [i.txnID, i.outID, i.inIndex, i.inSequence])] + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + /** + * Insert a new block + * @param {object} block - block object + * {blockHash, blockParent, blockHeight, blockTime} + * block.blockParent is an ID, which may be obtained with DB.getBlockByHash + */ + async addBlock(block) { + const sqlQuery = 'INSERT IGNORE INTO `blocks` SET ?' + const params = block + const query = mysql.format(sqlQuery, params) + const result = await this._query(query) + + // Successful insertion + if (result.insertId > 0) + return result.insertId + + // Block already in db + const sqlQuery2 = 'SELECT `blockID` FROM `blocks` WHERE `blockHash` = ?' + const params2 = block.blockHash + const query2 = mysql.format(sqlQuery2, params2) + const result2 = await this._query(query2) + + if (result2.length > 0) + return result2[0].blockID + + throw 'Problem met while trying to insert a new block' + } + + /** + * Get a block identified by the block hash + * @param {string} hash - block hash + * @returns {object} returns the block + */ + async getBlockByHash(hash) { + const sqlQuery = 'SELECT * FROM `blocks` WHERE `blockHash` = ?' + const params = hash + const query = mysql.format(sqlQuery, params) + const result = await this._query(query) + return (result.length == 1) ? result[0] : null + } + + /** + * Get details about all blocks at a given block height + * @param {integer} height - block height + * @returns {object[]} returns an array of blocks + */ + async getBlocksByHeight(height) { + const sqlQuery = 'SELECT * FROM `blocks` WHERE `blockHeight` = ?' + const params = height + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + /** + * Delete the blocks after a given block height + * @param {integer} height - block height + */ + async deleteBlocksAfterHeight(height) { + const sqlQuery = 'DELETE FROM `blocks` WHERE `blockHeight` > ?' + const params = height + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + /** + * Gets the last block + * @returns {object} returns the last block + */ + async getHighestBlock() { + try { + const results = await this.getLastBlocks(1) + if (results == null || results.length == 0) + return { + blockID: null, + blockHeight: 0 + } + else + return results[0] + } catch(err) { + return null + } + } + + /** + * Gets the N last blocks + * @param {integer} n - number of blocks to be retrieved + * @returns {object[]} returns an array of the n last blocks + */ + async getLastBlocks(n) { + const sqlQuery = 'SELECT * FROM `blocks` ORDER BY `blockHeight` DESC LIMIT ?' + const params = n + let query = mysql.format(sqlQuery, n) + return this._query(query) + } + + + /** + * Get all scheduled transactions + * @returns {object[]} returns an array of scheduled transactions + */ + async getScheduledTransactions() { + const sqlQuery = 'SELECT * FROM `scheduled_transactions` ORDER BY `schTrigger`, `schCreated`' + return this._query(sqlQuery) + } + + /** + * Get the mysql ID of a scheduled transaction + * @param {string} txid - txid of a scheduled transaction + * @returns {integer} returns the scheduled transaction id (mysql id) + */ + async getScheduledTransactionId(txid) { + const sqlQuery = 'SELECT `schID` FROM `scheduled_transactions` WHERE `schTxid` = ?' + const params = txid + const query = mysql.format(sqlQuery, params) + const result = await this._query(query) + return (result.length == 0) ? null : result[0].txnID + } + + /** + * Insert a new scheduled transaction in db + * @param {object} tx - {txid, created, rawTx, parentId, delay, trigger} + */ + async addScheduledTransaction(tx) { + if (!tx.created) + tx.created = util.unix() + + const sqlQuery = 'INSERT INTO `scheduled_transactions` \ + (schTxid, schCreated, schRaw, schParentID, schParentTxid, schDelay, schTrigger) VALUES (?)' + + const params = [[ + tx.txid, + tx.created, + tx.rawTx, + tx.parentId, + tx.parentTxid, + tx.delay, + tx.trigger + ]] + + const query = mysql.format(sqlQuery, params) + const result = await this._query(query) + + if (result.insertId > 0) + return result.insertId + + throw 'Problem met while trying to insert a new scheduled transaction' + } + + /** + * Delete a scheduled transaction + * @param {string} txid - scheduled transaction txid + */ + async deleteScheduledTransaction(txid) { + const sqlQuery = 'DELETE `scheduled_transactions`.* \ + FROM `scheduled_transactions` \ + WHERE `scheduled_transactions`.`schTxid` = ?' + const params = txid + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + /** + * Get scheduled transactions + * with a trigger lower than a given block height + * @param {integer} height - block height + */ + async getActivatedScheduledTransactions(height) { + const sqlQuery = 'SELECT * FROM `scheduled_transactions` \ + WHERE `schTrigger` <= ? AND `schParentID` IS NULL' + const params = height + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + /** + * Get the scheduled transaction having a given parentID + * @param {integer} parentId - parent ID + * @returns {object[]} returns an array of scheduled transactions + */ + async getNextScheduledTransactions(parentId) { + const sqlQuery = 'SELECT * FROM `scheduled_transactions` \ + WHERE `schParentID` = ?' + const params = parentId + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + /** + * Update the trigger of a scheduled transaction + * identified by its ID + * @param {integer} id - id of the scheduled transaction + * @param {integer} trigger - new trigger + */ + async updateTriggerScheduledTransaction(id, trigger) { + const sqlQuery = 'UPDATE `scheduled_transactions` \ + SET `schTrigger` = ? \ + WHERE `schID` = ?' + const params = [trigger, id] + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + + /** + * MAINTENANCE FUNCTIONS + */ + + async getInvalidAccountTimes() { + const sqlQuery = 'SELECT \ + `hd`.`hdID`, \ + `hdCreated`, \ + min(`txnCreated`) as `earliest` \ + FROM `hd` \ + INNER JOIN `hd_addresses` ON `hd_addresses`.`hdID` = `hd`.`hdID` \ + INNER JOIN `addresses` ON `hd_addresses`.`addrID` = `addresses`.`addrID` \ + INNER JOIN `outputs` ON `outputs`.`addrID` = `hd_addresses`.`addrID` \ + INNER JOIN `transactions` ON `outputs`.`txnID` = `transactions`.`txnID` \ + WHERE `hd`.`hdCreated` > `transactions`.`txnCreated` \ + GROUP BY `hd`.`hdID` LIMIT 100' + + return this._query(sqlQuery) + } + + async getInvalidTxTimes() { + const sqlQuery = 'SELECT \ + `txnID`, \ + `txnCreated`, \ + `blockTime` \ + FROM `transactions` \ + INNER JOIN `blocks` ON `transactions`.`blockID` = `blocks`.`blockID` \ + WHERE `transactions`.`txnCreated` > `blocks`.`blockTime` \ + LIMIT 100' + + return this._query(sqlQuery) + } + + async setHDTime(hdID, hdCreated) { + const sqlQuery = 'UPDATE `hd` SET `hdCreated` = ? WHERE `hdID` = ?' + const params = [hdCreated, hdID] + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + async setTransactionTime(txnID, txnCreated) { + const sqlQuery = 'UPDATE `transactions` SET `txnCreated` = ? WHERE `txnID` = ?' + const params = [txnCreated, txnID] + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + async updateInputSequence(inID, inSequence) { + const sqlQuery = 'UPDATE `inputs` SET `inSequence` = ? WHERE `inID` = ?' + const params = [inSequence, inID] + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + async getOutputsWithoutScript() { + const sqlQuery = 'SELECT \ + `txnTxid`, \ + `outIndex`, \ + `outId` \ + FROM `outputs` \ + INNER JOIN `transactions` ON `outputs`.`txnID` = `transactions`.`txnID` \ + WHERE length(`outputs`.`outScript`) = 0 \ + ORDER BY `outId` \ + LIMIT 100' + + return this._query(sqlQuery) + } + + async updateOutputScript(outID, outScript) { + const sqlQuery = 'UPDATE `outputs` SET `outScript` = ? WHERE `outID` = ?' + const params = [outScript, outID] + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + + async setBlockParent(hash, blockID) { + const sqlQuery = 'UPDATE `blocks` SET `blockParent` = ? WHERE `blockHash` = ?' + const params = [blockID, hash] + const query = mysql.format(sqlQuery, params) + return this._query(query) + } + +} + +module.exports = new MySqlDbWrapper() diff --git a/lib/errors.js b/lib/errors.js new file mode 100644 index 0000000..10b4a92 --- /dev/null +++ b/lib/errors.js @@ -0,0 +1,80 @@ +/*! + * lib/error.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +/** + * Dictionary of error codes + */ +module.exports = { + get: { + UNKNXPUB: 'Unknown xpub. Create with POST /xpub', + DISALLOWED: 'GET not allowed. Use POST', + }, + body: { + NODATA: 'No body data', + NOXPUB: 'Missing body parameter "xpub"', + NOTYPE: 'Missing body parameter "type"', + NOADDR: 'Missing body parameter "address"', + NOMSG: 'Missing body parameter "message"', + NOSIG: 'Missing body parameter "signature"', + NOSCRIPT: 'Missing body parameter "script"', + SCRIPTSIZE: 'Too many entries in the script', + NOTX: 'Missing body parameter "tx"', + INVTYPE: 'Invalid value for parameter "type"', + INVDATA: 'Invalid request arguments' + }, + sig: { + INVSIG: 'Invalid bitcoin signature', + INVMSG: 'Invalid message content', + INVADDR: 'Incorrect bitcoin address used for signature', + }, + tx: { + PARSE: 'Unable to parse transaction hex', + SEND: 'Unable to broadcast transaction', + TXID: 'Malformed txid', + }, + address: { + INVALID: 'Invalid address', + }, + xpub: { + INVALID: 'Invalid xpub', + CHAIN: 'Invalid chain', + PRIVKEY: 'No private keys', + CREATE: 'Unable to create new HD account', + RESTORE: 'Unable to restore HD account', + OVERLAP: 'Import in progress', + SEGWIT: 'Invalid value for SegWit support type', + LOCKED: 'Unable to complete operation (locked xpub)' + }, + txout: { + VOUT: 'Invalid vout', + NOTFOUND: 'Unspent output not found', + }, + multiaddr: { + NOACT: 'Missing parameter "active"', + INVALID: 'No valid active entries', + AMBIG: 'Ambiguous "new" parameter: pass only one xpub', + }, + generic: { + GEN: 'Error', + DB: 'Database Error', + }, + auth: { + INVALID_CONF: 'Missing configuration parameter', + INVALID_JWT: 'Invalid JSON Web Token', + INVALID_PRF: 'Your current access rights do not allow this operation', + MISSING_JWT: 'Missing JSON Web Token', + TECH_ISSUE: 'A technical problem was encountered. Unable to authenticate the user' + }, + db: { + ERROR_NO_ADDRESS: 'ERROR_NO_ADDRESS', + ERROR_NO_HD_ACCOUNT: 'ERROR_NO_HD_ACCOUNT' + }, + pushtx: { + NLOCK_MISMATCH: 'nLockTime in script does not match nLockTime in transaction', + SCHEDULED_TOO_FAR: 'nLockTime is set to far in the future', + SCHEDULED_BAD_ORDER: 'Order of hop and nLockTime values must be consistent' + } +} diff --git a/lib/fork-pool.js b/lib/fork-pool.js new file mode 100644 index 0000000..16df8e5 --- /dev/null +++ b/lib/fork-pool.js @@ -0,0 +1,85 @@ +/*! + * lib/fork-pool.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const os = require('os') +const childProcess = require('child_process') +const genericPool = require('generic-pool') +const Logger = require('./logger') + + +/** + * A class managing a pool of child processes + * Inspired from fork-pool by Andrew Sliwinski + * https://github.com/thisandagain/fork-pool/ + */ +class ForkPool { + + /** + * Constructor + */ + constructor(path, options) { + if (!options) { + this._networkKey = '' + this._options = { + max: os.cpus().length / 2, + min: os.cpus().length / 2, + acquireTimeoutMillis: 60000 + } + } else { + this._networkKey = options.networkKey + this._options = options + } + + const factory = { + create: () => { + return childProcess.fork(path, [this._networkKey]) + }, + destroy: (cp) => { + cp.kill() + } + } + + this.pool = genericPool.createPool(factory, this._options) + Logger.info(`Created ${this._options.min} child processes for addresses derivation (max = ${this._options.max})`) + } + + /** + * Enqueue a new task to be processed by a child process + * @param {object} data - data to be passed to the child process + * @returns {Promise} + */ + async enqueue(data) { + let cp + const pool = this.pool + + return new Promise(async (resolve, reject) => { + try { + cp = await pool.acquire() + + cp.send(data) + + cp.once('message', async msg => { + pool.release(cp) + resolve(msg) + }) + + } catch(e) { + reject(e) + } + }) + } + + /** + * Drain the pool + */ + async drain() { + await this.pool.drain() + await this.pool.clear() + } + +} + +module.exports = ForkPool diff --git a/lib/http-server/http-server.js b/lib/http-server/http-server.js new file mode 100644 index 0000000..93ccb3b --- /dev/null +++ b/lib/http-server/http-server.js @@ -0,0 +1,242 @@ +/*! + * lib/http-server/http-server.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const fs = require('fs') +const https = require('https') +const express = require('express') +const helmet = require('helmet') +const Logger = require('../logger') + + +/** + * HTTP server + */ +class HttpServer { + + /** + * Constructor + * @param {int} port - port used by the http server + * @param {object} httpsOptions - https options + */ + constructor(port, httpsOptions) { + // Initialize server port + this.port = port + + // Store https options + this.httpsOptions = httpsOptions + + // Listening server instance + this.server = null + + // Initialize the express app + this.app = express() + this.app.set('trust proxy', 'loopback') + + // Middlewares for json responses and requests logging + this.app.use('/static', express.static('../static')); + this.app.use(HttpServer.setJSONResponse) + this.app.use(HttpServer.requestLogger) + this.app.use(HttpServer.setCrossOrigin) + this.app.use(HttpServer.setConnection) + this.app.use(helmet(HttpServer.HELMET_POLICY)) + } + + + /** + * Start the http server + * @returns {object} returns the listening server instance + */ + start() { + // Error handler, should be final middleware + this.app.use(function(err, req, res, next) { + if (res.headersSent) return next(err) + Logger.error(err.stack, 'HttpServer.start()') + const ret = {status: 'Server error'} + HttpServer.sendError(res, ret, 500) + }) + + if (this.httpsOptions == null || !this.httpsOptions.active) { + // Start a http server + this.server = this.app.listen(this.port, () => { + Logger.info('HTTP server listening on port ' + this.port) + }) + } else { + // Start a https server + const options = { + key: fs.readFileSync(this.httpsOptions.keypath), + cert: fs.readFileSync(this.httpsOptions.certpath), + requestCert: false, + rejectUnauthorized: false + } + + if (this.httpsOptions.capath) + options.ca = fs.readFileSync(this.httpsOptions.capath) + + if (this.httpsOptions.passphrase) + options.passphrase = this.httpsOptions.passphrase + + this.server = https.createServer(options, this.app).listen(this.port, () => { + Logger.info('HTTPS server listening on port ' + this.port) + }) + } + + this.server.timeout = 600 * 1000 + // @see https://github.com/nodejs/node/issues/13391 + this.server.keepAliveTimeout = 0 + + return this.server + } + + /** + * Stop the http server + */ + stop() { + if (this.app === null) return + this.app.close() + } + + /** + * Return a http response without data + * @param {object} res - http response object + */ + static sendOk(res) { + const ret = {status: 'ok'} + res.status(200).json(ret) + } + + /** + * Return a http response without status + * @param {object} res - http response object + */ + static sendOkDataOnly(res, data) { + res.status(200).json(data) + } + + /** + * Return a http response with status and data + * @param {object} res - http response object + * @param {object} data - data object + */ + static sendOkData(res, data) { + const ret = { + status: 'ok', + data: data + } + res.status(200).json(ret) + } + + /** + * Return a http response with raw data + * @param {object} res - http response object + * @param {object} data - data object + */ + static sendRawData(res, data) { + res.status(200).send(data) + } + + /** + * Return an error response + * @param {object} res - http response object + * @param {object} data - data object + */ + static sendError(res, data, errorCode) { + if (errorCode == null) + errorCode = 400 + + const ret = { + status: 'error', + error: data + } + + res.status(errorCode).json(ret) + } + + /* + * A middleware returning an authorization error response + * @param {string} err - error + * @param {object} req - http request object + * @param {object} res - http response object + * @param {function} next - callback function + */ + static sendAuthError(err, req, res, next) { + if (err) { + HttpServer.sendError(res, err, 401) + } + } + + /** + * Express middleware returnsing a json response + * @param {object} req - http request object + * @param {object} res - http response object + * @param {function} next - next middleware + */ + static setJSONResponse(req, res, next) { + res.set('Content-Type', 'application/json') + next() + } + + /** + * Express middleware adding cors header + * @param {object} req - http request object + * @param {object} res - http response object + * @param {function} next - next middleware + */ + static setCrossOrigin(req, res, next) { + res.set('Access-Control-Allow-Origin', '*') + next() + } + + /** + * Express middleware adding connection header + * @param {object} req - http request object + * @param {object} res - http response object + * @param {function} next - next middleware + */ + static setConnection(req, res, next) { + res.set('Connection', 'close') + next() + } + + /** + * Express middleware logging url and methods called + * @param {object} req - http request object + * @param {object} res - http response object + * @param {function} next - next middleware + */ + static requestLogger(req, res, next) { + Logger.info(`${req.method} ${req.url}`) + next() + } + +} + +/** + * Helmet Policy + */ +HttpServer.HELMET_POLICY = { + 'contentSecurityPolicy' : { + 'directives': { + 'defaultSrc': ['"self"'], + 'styleSrc' : ['"self"', '"unsafe-inline"'], + 'img-src' : ['"self" data:'] + }, + 'browserSniff': false, + 'disableAndroid': true + }, + 'dnsPrefetchControl': true, + 'frameguard': true, + 'hidePoweredBy': true, + 'hpkp': false, + 'hsts': true, + 'ieNoOpen': true, + 'noCache': true, + 'noSniff': true, + 'referrerPolicy': true, + 'xssFilter': true +} + + +module.exports = HttpServer diff --git a/lib/logger.js b/lib/logger.js new file mode 100644 index 0000000..363504e --- /dev/null +++ b/lib/logger.js @@ -0,0 +1,67 @@ +/*! + * lib/logger.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const util = require('./util') + + +/** + * Class providing static methods for logging + */ +class Logger { + + /** + * Log a message in the console + * @param {string/object} msg + * @param {boolean} json - true if msg is a json object, false otherwise + */ + static info(msg, json) { + const logEntry = Logger._formatLog(msg, json) + console.log(logEntry) + } + + /** + * Log an error message + * @param {object} e - error + * @param {string} msg - message associated to the error + */ + static error(e, msg) { + const logEntry = Logger._formatLog(msg) + console.error(logEntry) + + //const errorEntry = Logger._formatLog(e) + if (e) { + console.error(e) + } + } + + + /** + * Format log entry + * @param {string/object} msg + * @param {boolean} json - true if msg is a json object, false otherwise + */ + static _formatLog(msg, json) { + json = json || false + const data = json ? JSON.stringify(msg, null, 2) : msg + + const memUse = process.memoryUsage() + const mib = util.pad100(util.toMb(memUse.rss)) + + const D = new Date() + const y = D.getUTCFullYear() + const m = util.pad10(D.getUTCMonth() + 1) + const d = util.pad10(D.getUTCDate()) + const h = util.pad10(D.getUTCHours()) + const mn = util.pad10(D.getUTCMinutes()) + const s = util.pad10(D.getUTCSeconds()) + const ms = util.pad100(D.getUTCMilliseconds()) + + const parts = ['[', y, m, d, ' ', h, ':', mn, ':', s, '.', ms, ' ', mib, ' MiB', '] ', data] + return parts.join('') + } +} + +module.exports = Logger diff --git a/lib/remote-importer/bitcoind-wrapper.js b/lib/remote-importer/bitcoind-wrapper.js new file mode 100644 index 0000000..f330a37 --- /dev/null +++ b/lib/remote-importer/bitcoind-wrapper.js @@ -0,0 +1,129 @@ +/*! + * lib/remote-importer/bitcoind-wrapper.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const bitcoin = require('bitcoinjs-lib') +const RpcClient = require('../bitcoind-rpc/rpc-client') +const Logger = require('../logger') +const network = require('../bitcoin/network') +const activeNet = network.network +const keys = require('../../keys')[network.key] +const Wrapper = require('./wrapper') + +/** + * Wrapper for a local bitcoind RPC API + */ +class BitcoindWrapper extends Wrapper { + + /** + * Constructor + */ + constructor() { + super(null, null) + // RPC client + this.client = new RpcClient() + } + + /** + * Send a request to the RPC API + * @param {array} descriptors - array of output descriptors + * expected by scantxoutset() + * @returns {Promise} + */ + async _get(descriptors) { + return this.client.cmd('scantxoutset', 'start', descriptors) + } + + /** + * Translate a scriptPubKey into an address + * @param {string} scriptPubKey - ScriptPubKey in hex format + * @returns {string} returns the bitcoin address corresponding to the scriptPubKey + */ + _xlatScriptPubKey(scriptPubKey) { + const bScriptPubKey = Buffer.from(scriptPubKey, 'hex') + return bitcoin.address.fromOutputScript(bScriptPubKey, activeNet) + } + + /** + * Retrieve information for a given address + * @param {string} address - bitcoin address + * @param {boolean} filterAddr - True if an upper bound should be used + * for #transactions associated to the address, False otherwise + * @returns {Promise} returns an object + * { address: , txids: , ntx: } + */ + async getAddress(address, filterAddr) { + const ret = { + address: address, + ntx: 0, + txids: [] + } + + const descriptor = `addr(${address})` + const results = await this._get([descriptor]) + + for (let r of results.unspents) { + ret.txids.push(r.txid) + ret.ntx++ + } + + if (filterAddr && ret.ntx > keys.addrFilterThreshold) { + Logger.info(` import of ${address} rejected (too many transactions - ${ret.ntx})`) + return { + address: address, + ntx: 0, + txids: [] + } + } + + return ret + } + + /** + * Retrieve information for a given list of addresses + * @param {string} addresses - array of bitcoin addresses + * @param {boolean} filterAddr - True if an upper bound should be used + * for #transactions associated to the address, False otherwise + * @returns {Promise} returns an array of objects + * { address: , txids: , ntx: } + */ + async getAddresses(addresses, filterAddr) { + const ret = {} + + // Send a batch request for all the addresses + const descriptors = addresses.map(a => `addr(${a})`) + + const results = await this._get(descriptors) + + for (let r of results.unspents) { + const addr = this._xlatScriptPubKey(r.scriptPubKey) + + if (!ret[addr]) { + ret[addr] = { + address: addr, + ntx: 0, + txids: [] + } + } + + ret[addr].txids.push(r.txid) + ret[addr].ntx++ + } + + const aRet = Object.values(ret) + + for (let i in aRet) { + if (filterAddr && aRet[i].ntx > keys.addrFilterThreshold) { + Logger.info(` import of ${aRet[i].address} rejected (too many transactions - ${aRet[i].ntx})`) + aRet.splice(i, 1) + } + } + + return aRet + } + +} + +module.exports = BitcoindWrapper diff --git a/lib/remote-importer/btccom-wrapper.js b/lib/remote-importer/btccom-wrapper.js new file mode 100644 index 0000000..cc86d38 --- /dev/null +++ b/lib/remote-importer/btccom-wrapper.js @@ -0,0 +1,122 @@ +/*! + * lib/remote-importer\btccom-wrapper.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const rp = require('request-promise-native') +const addrHelper = require('../bitcoin/addresses-helper') +const util = require('../util') +const Logger = require('../logger') +const network = require('../bitcoin/network') +const keys = require('../../keys')[network.key] +const Wrapper = require('./wrapper') + + +/** + * Wrapper for the btc.com block explorer APIs + */ +class BtcComWrapper extends Wrapper { + + /** + * Constructor + */ + constructor(url) { + super(url, keys.explorers.socks5Proxy) + } + + /** + * Send a GET request to the API + * @param {string} route + * @returns {Promise} + */ + async _get(route) { + const params = { + url: `${this.base}${route}`, + method: 'GET', + json: true, + timeout: 15000 + } + + // Sets socks proxy agent if required + if (keys.explorers.socks5Proxy != null) + params['agent'] = this.socksProxyAgent + + return rp(params) + } + + /** + * Get a page of transactions related to a given address + * @param {string} address - bitcoin address + * @param {integer} page - page index + * @returns {Promise} + */ + async _getTxsForAddress(address, page) { + const uri = `/address/${address}/tx?page=${page}&verbose=1` + const results = await this._get(uri) + return results.data.list.map(tx => tx.hash) + } + + /** + * Retrieve information for a given address + * @param {string} address - bitcoin address + * @param {boolean} filterAddr - True if an upper bound should be used + * for #transactions associated to the address, False otherwise + * @returns {Promise} returns an object + * { address: , txids: , ntx: } + */ + async getAddress(address, filterAddr) { + // Extracts the scripthash from the bech32 address + // (btc.com api manages scripthashes, not bech32 addresses) + const scripthash = addrHelper.getScriptHashFromBech32(address) + + const uri = `/address/${scripthash}` + const result = await this._get(uri) + + const ret = { + address: address, + ntx: result.data.tx_count, + txids: [] + } + + // Check if we should filter this address + if (filterAddr && ret.ntx > keys.addrFilterThreshold) { + Logger.info(` import of ${ret.address} rejected (too many transactions - ${ret.ntx})`) + return ret + } + + const nbPagesApi = Math.ceil(ret.ntx / BtcComWrapper.NB_TXS_PER_PAGE) + const nbPages = Math.min(20, nbPagesApi) + + const aPages = new Array(nbPages) + const listPages = Array.from(aPages, (val, idx) => idx + 1) + + const results = await util.seriesCall(listPages, idx => { + return this._getTxsForAddress(scripthash, idx) + }) + + for (let txids of results) + ret.txids = ret.txids.concat(txids) + + return ret + } + + /** + * Retrieve information for a given list of addresses + * @param {string} addresses - array of bitcoin addresses + * @param {boolean} filterAddr - True if an upper bound should be used + * for #transactions associated to the address, False otherwise + * @returns {Promise} returns an array of objects + * { address: , txids: , ntx: } + */ + async getAddresses(addresses, filterAddr) { + // Not implemented for this api + throw "Not implemented" + } + +} + +// BTC.COM acepts a max of 50txs per page +BtcComWrapper.NB_TXS_PER_PAGE = 50 + +module.exports = BtcComWrapper diff --git a/lib/remote-importer/insight-wrapper.js b/lib/remote-importer/insight-wrapper.js new file mode 100644 index 0000000..c11045f --- /dev/null +++ b/lib/remote-importer/insight-wrapper.js @@ -0,0 +1,90 @@ +/*! + * lib/remote-importer/insight-wrapper.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const rp = require('request-promise-native') +const Logger = require('../logger') +const network = require('../bitcoin/network') +const keys = require('../../keys')[network.key] +const Wrapper = require('./wrapper') + + +/** + * Wrapper for the Insight block explorer APIs + */ +class InsightWrapper extends Wrapper { + + /** + * Constructor + */ + constructor(url) { + super(url, keys.explorers.socks5Proxy) + } + + /** + * Send a GET request to the API + * @param {string} route + * @returns {Promise} + */ + async _get(route) { + const params = { + url: `${this.base}${route}`, + method: 'GET', + json: true, + timeout: 15000 + } + + // Sets socks proxy agent if required + if (keys.explorers.socks5Proxy != null) + params['agent'] = this.socksProxyAgent + + return rp(params) + } + + /** + * Retrieve information for a given address + * @param {string} address - bitcoin address + * @param {boolean} filterAddr - True if an upper bound should be used + * for #transactions associated to the address, False otherwise + * @returns {Promise} returns an object + * { address: , txids: , ntx: } + */ + async getAddress(address, filterAddr) { + const uri = `/addr/${address}` + // Param filterAddr isn't used for insight + const result = await this._get(uri) + + const ret = { + address: result.addrStr, + txids: [], + ntx: result.txApperances + } + + // Check if we should filter this address + if (filterAddr && ret.ntx > keys.addrFilterThreshold) { + Logger.info(` import of ${ret.address} rejected (too many transactions - ${ret.ntx})`) + return ret + } + + ret.txids = result.transactions + return ret + } + + /** + * Retrieve information for a given list of addresses + * @param {string} addresses - array of bitcoin addresses + * @param {boolean} filterAddr - True if an upper bound should be used + * for #transactions associated to the address, False otherwise + * @returns {Promise} returns an array of objects + * { address: , txids: , ntx: } + */ + async getAddresses(addresses, filterAddr) { + // Not implemented for this api + throw "Not implemented" + } + +} + +module.exports = InsightWrapper diff --git a/lib/remote-importer/oxt-wrapper.js b/lib/remote-importer/oxt-wrapper.js new file mode 100644 index 0000000..dd63fa7 --- /dev/null +++ b/lib/remote-importer/oxt-wrapper.js @@ -0,0 +1,114 @@ +/*! + * lib/remote-importer/oxt-wrapper.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const rp = require('request-promise-native') +const Logger = require('../logger') +const network = require('../bitcoin/network') +const keys = require('../../keys')[network.key] +const Wrapper = require('./wrapper') + + +/** + * Wrapper for the oxt.me block explorer APIs + */ +class OxtWrapper extends Wrapper { + + /** + * Constructor + */ + constructor(url) { + super(url, keys.explorers.socks5Proxy) + } + + /** + * Send a GET request to the API + * @param {string} route + * @returns {Promise} + */ + async _get(route) { + const params = { + url: `${this.base}${route}`, + method: 'GET', + json: true, + timeout: 15000 + } + + // Sets socks proxy agent if required + if (keys.explorers.socks5Proxy != null) + params['agent'] = this.socksProxyAgent + + return rp(params) + } + + /** + * Retrieve information for a given address + * @param {string} address - bitcoin address + * @param {boolean} filterAddr - True if an upper bound should be used + * for #transactions associated to the address, False otherwise + * @returns {Promise} returns an object + * { address: , txids: , ntx: } + */ + async getAddress(address, filterAddr) { + // Try to retrieve more txs than the 1000 managed by the backend + const uri = `/addresses/${address}/txids?count=${keys.addrFilterThreshold + 1}` + const result = await this._get(uri) + + const ret = { + address: address, + ntx: result.count, + txids: [] + } + + // Check if we should filter this address + if (filterAddr && ret.ntx > keys.addrFilterThreshold) { + Logger.info(` import of ${ret.address} rejected (too many transactions - ${ret.ntx})`) + return ret + } + + ret.txids = result.data.map(t => t.txid) + return ret + } + + /** + * Retrieve information for a given list of addresses + * @param {string} addresses - array of bitcoin addresses + * @param {boolean} filterAddr - True if an upper bound should be used + * for #transactions associated to the address, False otherwise + * @returns {Promise} returns an array of objects + * { address: , txids: , ntx: } + */ + async getAddresses(addresses, filterAddr) { + const ret = [] + + // Send a batch request for all the addresses + // For each address, try to retrieve more txs than the 1000 managed by the backend + const strAddr = addresses.join(',') + const uri = `/addresses/multi/txids?count=${keys.addrFilterThreshold + 1}&addresses=${strAddr}` + const results = await this._get(uri) + + for (let r of results.data) { + const retAddr = { + address: r.address, + ntx: r.txids.length, + txids: [] + } + + // Check if we should filter this address + if (filterAddr && retAddr.ntx > keys.addrFilterThreshold) { + Logger.info(` import of ${retAddr.address} rejected (too many transactions - ${retAddr.ntx})`) + } else { + retAddr.txids = r.txids + } + + ret.push(retAddr) + } + + return ret + } + +} + +module.exports = OxtWrapper diff --git a/lib/remote-importer/remote-importer.js b/lib/remote-importer/remote-importer.js new file mode 100644 index 0000000..c48a8db --- /dev/null +++ b/lib/remote-importer/remote-importer.js @@ -0,0 +1,436 @@ +/*! + * lib/remote-importer/remote-importer.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const _ = require('lodash') +const Logger = require('../logger') +const errors = require('../errors') +const util = require('../util') +const db = require('../db/mysql-db-wrapper') +const rpcTxns = require('../bitcoind-rpc/transactions') +const hdaHelper = require('../bitcoin/hd-accounts-helper') +const network = require('../bitcoin/network') +const keys = require('../../keys')[network.key] +const gap = keys.gap + +let Sources + +if (network.key == 'bitcoin') { + Sources = require('./sources-mainnet') +} else { + Sources = require('./sources-testnet') +} + + +/** + * A singleton providing tools + * for importing HD and loose addresses from remote sources + */ +class RemoteImporter { + + /** + * Constructor + */ + constructor() { + // Guard against overlapping imports + this.importing = {} + this.sources = new Sources() + } + + /** + * Clear the guard + * @param {string} xpub - HDAccount + */ + clearGuard(xpub) { + if (this.importing[xpub]) + delete this.importing[xpub] + } + + /** + * Process the relations between a list of transactions + * @param {object[]} txs - array of transaction objects + * @returns {object} returns a object with 3 mappings + * {txMap: {], txChildren: {}, txParents: {}} + */ + _processTxsRelations(txs) { + const txMap = {} + const txChildren = {} + const txParents = {} + + for (let tx of txs) { + let txid = tx.txid + + // Populate txMap + txMap[txid] = tx + + // Create parent-child transaction associations + if (!txChildren[txid]) + txChildren[txid] = [] + + if (!txParents[txid]) + txParents[txid] = [] + + for (let i in tx.inputs) { + const input = tx.inputs[i] + let prev = input.outpoint.txid + if (!txMap[prev]) continue + + if (txParents[txid].indexOf(prev) == -1) + txParents[txid].push(prev) + + if (!txChildren[prev]) + txChildren[prev] = [] + + if (txChildren[prev].indexOf(txid) == -1) + txChildren[prev].push(txid) + } + } + + return { + txMap: txMap, + txChildren: txChildren, + txParents: txParents + } + } + + /** + * Import a list of transactions associated to a list of addresses + * @param {object[]} addresses - array of addresses objects + * @param {object[]} txns - array of transaction objects + * @returns {Promise} + */ + async _importTransactions(addresses, txns) { + const addrIdMap = await db.getAddressesIds(addresses) + + // The transactions array must be topologically ordered, such that + // entries earlier in the array MUST NOT depend upon any entry later + // in the array. + const txMaps = this._processTxsRelations(txns) + const txOrdered = util.topologicalOrdering(txMaps.txParents, txMaps.txChildren) + const aTxs = [] + + for (let txid of txOrdered) + if (txMaps.txMap[txid]) + aTxs.push(txMaps.txMap[txid]) + + return util.seriesCall(aTxs, tx => this.addTransaction(tx, addrIdMap)) + } + + /** + * Import an HD account from remote sources + * @param {string} xpub - HD Account + * @param {string} type - type of HD Account + * @param {integer} gapLimit - (optional) gap limit for derivation + * @param {integer} startIndex - (optional) rescan shall start from this index + */ + async importHDAccount(xpub, type, gapLimit, startIndex) { + if (!hdaHelper.isValid(xpub)) + return Promise.reject(errors.xpub.INVALID) + + if (this.importing[xpub]) { + Logger.info(` Import overlap for ${xpub}`) + return Promise.reject(errors.xpub.OVERLAP) + } + + this.importing[xpub] = true + + const ts = hdaHelper.typeString(type) + Logger.info(`Importing ${xpub} ${ts}`) + + const t0 = Date.now() + const chains = [0,1] + + let gaps = [gap.external, gap.internal] + // Allow custom higher gap limits + // for local scans relying on bitcoind + if (gapLimit && keys.explorers.bitcoind) + gaps = [gapLimit, gapLimit] + + startIndex = (startIndex == null) ? -1 : startIndex - 1 + + const addrIdMap = {} + let txns = [] + let addresses = [] + + try { + const results = await util.seriesCall(chains, chain => { + return this.xpubScan(xpub, chain, startIndex, startIndex, gaps[chain], type) + }) + + // Accumulate addresses and transactions from all chains + for (let result of results) { + txns = txns.concat(result.transactions) + addresses = addresses.concat(result.addresses) + } + + // Store the hdaccount and the addresses into the database + await db.ensureHDAccountId(xpub, type) + await db.addAddressesToHDAccount(xpub, addresses) + + // Store the transaction into the database + const aAddresses = addresses.map(a => a.address) + await this._importTransactions(aAddresses, txns) + + } catch(e) { + Logger.error(e, `RemoteImporter.importHDAccount() : xpub ${xpub}`) + } finally { + Logger.info(` xpub import done in ${((Date.now() - t0)/1000).toFixed(1)}s`) + delete this.importing[xpub] + return true + } + } + + /** + * Recursive scan of xpub addresses & transactions + * + * 0. HD chain c on [0,1] + * Gap limit G + * Last derived d = -1 + * Last used u = -1 + * 1. Derive addresses M/c/{A}, with A on [d+1, u+G], set d = u + G + * 2. Look up transactions T for M/c/{A} from remote + * 3. If |T| = 0, go to 5 + * 4. Set u = highest chain index of used address, go to 1 + * 5. Store all in database + * + * @returns {object} returns + * { + * addresses: [{address, chain, index}], + * transactions: [{ + * txid, + * version, + * locktime, + * created, // if known + * block: 'abcdef', // if confirmed + * outputs: [{index, amount, script, address}], + * inputs: [{index,outpoint:{txid,index},seq}] + * }], + * } + */ + async xpubScan(xpub, c, d, u, G, type, txids) { + txids = txids || {} + + const ret = { + addresses: [], + transactions: [], + } + + // Check that next derived isn't after last used + gap limit + if (d + 1 > u + G) return ret + + // Derive the required number of new addresses + const A = _.range(d + 1, u + G + 1) + ret.addresses = await hdaHelper.deriveAddresses(xpub, c, A, type) + + // Update derived index + d = u + G + Logger.info(` derived M/${c}/${A.join(',')}`) + + const addrMap = {} + for (let a of ret.addresses) + addrMap[a.address] = a + + const aAddresses = ret.addresses.map(a => a.address) + + try { + const results = await this.sources.getAddresses(aAddresses) + + let gotTransactions = false + const scanTx = [] + + for (let r of results) { + if (r.ntx == 0) continue + + // Address is used. Update used parameter + u = Math.max(u, addrMap[r.address].index) + gotTransactions = true + // TODO: Handle pathological case of many address transactions + while (r.txids.length > 0) { + let txid = r.txids.pop() + if (!txids[txid]) + scanTx.push(txid) + } + } + + Logger.info(` Got ${scanTx.length} transactions`) + + await util.seriesCall(scanTx, async txid => { + try { + const tx = await rpcTxns.getTransaction(txid, false) + if (tx == null) { + Logger.info(` got null for ${txid}`) + return null + } + ret.transactions.push(tx) + txids[tx.txid] = true + } catch(e) { + Logger.error(e, `RemoteImporter.xpubScan() : rawTransaction error, txid ${txid}`) + } + }) + + if (gotTransactions) { + // We must go deeper + const result = await this.xpubScan(xpub, c, d, u, G, type, txids) + // Accumulate results from further down the rabbit hole + for (let a of result.addresses) + ret.addresses.push(a) + for (let t of result.transactions) + ret.transactions.push(t) + } + + } catch(e) { + Logger.error(e, `RemoteImporter.xpubScan() : xpub ${xpub} ${c} ${d} ${u} ${G}`) + } finally { + // Push everything up the rabbit hole + return ret + } + } + + /** + * Import a list of addresses + * @param {string[]} candidates - addresses to be imported + * @param {boolean} filterAddr - True if addresses should be filtered, False otherwise + */ + async importAddresses(candidates, filterAddr) { + const t0 = Date.now() + const txns = [] + const addresses = [] + const imported = [] + + for (let address of candidates) { + if (!this.importing[address]) { + addresses.push(address) + this.importing[address] = true + } else { + Logger.info(`Note: Import overlap for ${address}. Skipping`) + } + } + + if (addresses.length == 0) + return true + + Logger.info(`Importing ${addresses.join(',')}`) + + try { + const scanTx = [] + const results = await this.sources.getAddresses(addresses, filterAddr) + + for (let r of results) { + // Mark the address as imported + imported.push(r.address) + if (r.ntx == 0) continue + // TODO: Handle pathological case of many address transactions + while (r.txids.length > 0) { + let txid = r.txids.pop() + if (scanTx.indexOf(txid) == -1) + scanTx.push(txid) + } + } + + Logger.info(` Got ${scanTx.length} transactions`) + + // Get transaction s data from bitcoind + await util.seriesCall(scanTx, async txid => { + const tx = await rpcTxns.getTransaction(txid, false) + if (tx == null) { + Logger.info(` got null for ${txid}`) + return null + } + txns.push(tx) + }) + + // Import addresses and transactions into the database + await db.addAddresses(imported) + await this._importTransactions(addresses, txns) + + } catch(e) { + Logger.error(e, `RemoteImporter.importAddresses() : ${candidates.join(',')}`) + + } finally { + const dt = Date.now() - t0 + const ts = (dt/1000).toFixed(1) + const N = addresses.length + + if (N > 0) + Logger.info(` Imported ${N} addresses in ${ts}s (${(dt/N).toFixed(0)} ms/addr)`) + + for (let address of addresses) + delete this.importing[address] + + return true + } + } + + /** + * Add a transaction to the database. + * @param {object} tx - transaction object + * @params {Promise} + */ + async addTransaction(tx, addrIdMap) { + const outputs = [] + + try { + // Store the transaction into the database + await db.addTransaction(tx) + + // Confirm the transaction + if (tx.block) { + const block = await db.getBlockByHash(tx.block.hash) + if (block) + await db.confirmTransactions([tx.txid], block.blockID) + } + + // Retrieve the database id for the transaction + let txnID = await db.ensureTransactionId(tx.txid) + + // Process the outputs + for (let output of tx.outputs) { + if (addrIdMap[output.address]) { + outputs.push({ + txnID, + addrID: addrIdMap[output.address], + outIndex: output.n, + outAmount: output.value, + outScript: output.scriptpubkey, + }) + } + } + + await db.addOutputs(outputs) + + // Process the inputs + // Get any outputs spent by the inputs of this transaction, add those + // database outIDs to the corresponding transaction inputs, and store. + const res = await db.getOutputIds(tx.inputs.map(input => input.outpoint)) + + const spent = {} + const inputs = [] + + for (let r of res) + spent[`${r.txnTxid}-${r.outIndex}`] = r.outID + + for (let input of tx.inputs) { + let key = `${input.outpoint.txid}-${input.outpoint.vout}` + if (spent[key]) { + inputs.push({ + outID: spent[key], + txnID, + inIndex: input.n, + inSequence: input.seq + }) + } + } + + await db.addInputs(inputs) + + } catch(e) { + Logger.error(e, `RemoteImporter.addTransaction() : xpub ${tx.txid}`) + Logger.error(null, JSON.stringify(tx,null,2)) + } + } + +} + +module.exports = new RemoteImporter() diff --git a/lib/remote-importer/sources-mainnet.js b/lib/remote-importer/sources-mainnet.js new file mode 100644 index 0000000..64bb932 --- /dev/null +++ b/lib/remote-importer/sources-mainnet.js @@ -0,0 +1,112 @@ +/*! + * lib/remote-importer/sources.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const addrHelper = require('../bitcoin/addresses-helper') +const network = require('../bitcoin/network') +const util = require('../util') +const Logger = require('../logger') +const keys = require('../../keys')[network.key] +const Sources = require('./sources') +const BitcoindWrapper = require('./bitcoind-wrapper') +const OxtWrapper = require('./oxt-wrapper') + + +/** + * Remote data sources for mainnet + */ +class SourcesMainnet extends Sources { + + /** + * Constructor + */ + constructor() { + super() + // Initializes external source + this.source = null + this._initSource() + } + + /** + * Initialize the external data source + */ + _initSource() { + if (keys.explorers.bitcoind == 'active') { + // If local bitcoind option is activated + // we'll use the local node as our unique source + this.source = new BitcoindWrapper() + Logger.info('Activated Bitcoind as the data source for imports') + } else { + // Otherwise, we'll use the rest api provided by OXT + this.source = new OxtWrapper(keys.explorers.oxt) + Logger.info('Activated OXT API as the data source for imports') + } + } + + /** + * Retrieve information for a given address + * @param {string} address - bitcoin address + * @param {boolean} filterAddr - True if an upper bound should be used + * for #transactions associated to the address, False otherwise + * @returns {Promise} returns an object + * { address: , txids: , ntx: } + */ + async getAddress(address, filterAddr) { + const ret = { + address, + txids: [], + ntx: 0 + } + + try { + const result = await this.source.getAddress(address, filterAddr) + + if (result.ntx) + ret.ntx = result.ntx + else if (result.txids) + ret.ntx = result.txids.length + + if (result.txids) + ret.txids = result.txids + + } catch(e) { + //Logger.error(e, `SourcesMainnet.getAddress() : ${address} from ${this.source.base}`) + Logger.error(null, `SourcesMainnet.getAddress() : ${address} from ${this.source.base}`) + } finally { + return ret + } + } + + /** + * Retrieve information for a list of addresses + * @param {string[]} addresses - array of bitcoin address + * @param {boolean} filterAddr - True if an upper bound should be used + * for #transactions associated to the address, False otherwise + * @returns {Promise} returns an object + * { address: , txids: , ntx: } + */ + async getAddresses(addresses, filterAddr) { + const ret = [] + + try { + const results = await this.source.getAddresses(addresses, filterAddr) + + for (let r of results) { + // Filter addresses with too many txs + if (!filterAddr || (r.ntx <= keys.addrFilterThreshold)) + ret.push(r) + } + + } catch(e) { + //Logger.error(e, `SourcesMainnet.getAddresses() : ${addresses} from ${this.source.base}`) + Logger.error(null, `SourcesMainnet.getAddresses() : ${addresses} from ${this.source.base}`) + } finally { + return ret + } + } + +} + +module.exports = SourcesMainnet diff --git a/lib/remote-importer/sources-testnet.js b/lib/remote-importer/sources-testnet.js new file mode 100644 index 0000000..2dfbd01 --- /dev/null +++ b/lib/remote-importer/sources-testnet.js @@ -0,0 +1,153 @@ +/*! + * lib/remote-importer/sources-testnet.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const addrHelper = require('../bitcoin/addresses-helper') +const network = require('../bitcoin/network') +const util = require('../util') +const Logger = require('../logger') +const keys = require('../../keys')[network.key] +const Sources = require('./sources') +const BitcoindWrapper = require('./bitcoind-wrapper') +const InsightWrapper = require('./insight-wrapper') +const BtcComWrapper = require('./btccom-wrapper') + + +/** + * Remote data sources for testnet polled round-robin to spread load + */ +class SourcesTestnet extends Sources { + + /** + * Constructor + */ + constructor() { + super() + this.sources = [] + this.index = 0 + this.sourceBech32 = null + this.isBitcoindActive = false + // Initializes external sources + this._initSources() + } + + /** + * Initialize the external data sources + */ + _initSources() { + if (keys.explorers.bitcoind == 'active') { + // If local bitcoind option is activated + // we'll use the local node as our unique source + this.sourceBech32 = new BitcoindWrapper() + this.sources.push(this.sourceBech32) + this.isBitcoindActive = true + } else { + // Otherwise, we use a set of insight servers + btc.com for bech32 addresses + this.sourceBech32 = new BtcComWrapper(keys.explorers.btccom) + for (let url of keys.explorers.insight) + this.sources.push(new InsightWrapper(url)) + this.isBitcoindActive = false + } + } + + /** + * Get the next source index + * @returns {integer} returns the next source index + */ + nextIndex() { + this.index++ + if (this.index >= this.sources.length) + this.index = 0 + return this.index + } + + /** + * Retrieve information for a given address + * @param {string} address - bitcoin address + * @param {boolean} filterAddr - True if an upper bound should be used + * for #transactions associated to the address, False otherwise + * @returns {Promise} returns an object + * { address: , txids: , ntx: } + */ + async getAddress(address, filterAddr) { + let source = '' + + const isBech32 = addrHelper.isBech32(address) + + const ret = { + address, + txids: [], + ntx: 0 + } + + try { + source = isBech32 ? this.sourceBech32 : this.sources[this.nextIndex()] + const result = await source.getAddress(address, filterAddr) + + if (result.ntx) + ret.ntx = result.ntx + else if (result.txids) + ret.ntx = result.txids.length + + if (result.txids) + ret.txids = result.txids + + return ret + + } catch(e) { + Logger.error(e, `SourcesTestnet.getAddress() : ${address} from ${source.base}`) + if (!isBech32 && this.sources.length > 1) { + // Try again with another source + return this.getAddress(address, filterAddr) + } else { + return ret + } + } + } + + /** + * Retrieve information for a list of addresses + * @param {string[]} addresses - array of bitcoin address + * @param {boolean} filterAddr - True if an upper bound should be used + * for #transactions associated to the address, False otherwise + * @returns {Promise} returns an object + * { address: , txids: , ntx: } + */ + async getAddresses(addresses, filterAddr) { + const ret = [] + + try { + if (this.isBitcoindActive) { + const source = this.sources[0] + const results = await source.getAddresses(addresses, filterAddr) + for (let r of results) { + // Filter addresses with too many txs + if (!filterAddr || (r.ntx <= keys.addrFilterThreshold)) + ret.push(r) + } + } else { + const lists = util.splitList(addresses, this.sources.length) + await util.seriesCall(lists, async list => { + const results = await Promise.all(list.map(a => { + return this.getAddress(a, filterAddr) + })) + + for (let r of results) { + // Filter addresses with too many txs + if (!filterAddr || (r.ntx <= keys.addrFilterThreshold)) + ret.push(r) + } + }) + } + } catch (e) { + Logger.error(e, `SourcesTestnet.getAddresses() : Addr list = ${addresses}`) + } finally { + return ret + } + } + +} + +module.exports = SourcesTestnet diff --git a/lib/remote-importer/sources.js b/lib/remote-importer/sources.js new file mode 100644 index 0000000..f070c8a --- /dev/null +++ b/lib/remote-importer/sources.js @@ -0,0 +1,40 @@ +/*! + * lib/remote-importer/sources.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + + +/** + * Abstract class defining a list of blockchain explorer providing a remote API + */ +class Sources { + + /** + * Constructor + */ + constructor() {} + + /** + * Retrieve information for a given address + * @param {string} address - bitcoin address + * @param {boolean} filterAddr - True if an upper bound should be used + * for #transactions associated to the address, False otherwise + * @returns {Promise} returns an object + * { address: , txids: , ntx: } + */ + async getAddress(address, filterAddr) {} + + /** + * Retrieve information for a list of addresses + * @param {string[]} addresses - array of bitcoin address + * @param {boolean} filterAddr - True if an upper bound should be used + * for #transactions associated to the address, False otherwise + * @returns {Promise} returns an object + * { address: , txids: , ntx: } + */ + async getAddresses(addresses, filterAddr) {} + +} + +module.exports = Sources diff --git a/lib/remote-importer/wrapper.js b/lib/remote-importer/wrapper.js new file mode 100644 index 0000000..4272e09 --- /dev/null +++ b/lib/remote-importer/wrapper.js @@ -0,0 +1,47 @@ +/*! + * lib/remote-importer/wrapper.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const SocksProxyAgent = require('socks-proxy-agent') +const network = require('../bitcoin/network') +const keys = require('../../keys')[network.key] + + +/** + * Abstract class defining a wrapper for a remote API + */ +class Wrapper { + + /** + * Constructor + */ + constructor(url, socks5Proxy) { + this.base = url + this.socksProxyAgent = socks5Proxy ? new SocksProxyAgent(socks5Proxy) : null + } + + /** + * Retrieve information for a given address + * @param {string} address - bitcoin address + * @param {boolean} filterAddr - True if an upper bound should be used + * for #transactions associated to the address, False otherwise + * @returns {Promise} returns an object + * { address: , txids: , ntx: } + */ + async getAddress(address, filterAddr) {} + + /** + * Retrieve information for a given list of addresses + * @param {string} addresses - array of bitcoin addresses + * @param {boolean} filterAddr - True if an upper bound should be used + * for #transactions associated to the address, False otherwise + * @returns {Promise} returns an array of objects + * { address: , txids: , ntx: } + */ + async getAddresses(addresses, filterAddr) {} + +} + +module.exports = Wrapper diff --git a/lib/util.js b/lib/util.js new file mode 100644 index 0000000..199da0b --- /dev/null +++ b/lib/util.js @@ -0,0 +1,368 @@ +/*! + * lib/util.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +/** + * Class providing utility functions as static methods + */ +class Util { + + /** + * Constructor + */ + constructor() {} + + /** + * Topological ordering of DAG + * https://en.wikipedia.org/wiki/Topological_sorting + * + * Kahn's algorithm + * + * L ← Empty list that will contain the sorted elements + * S ← Set of all nodes with no incoming edge + * while S is non-empty do + * remove a node n from S + * add n to tail of L + * for each node m with an edge e from n to m do + * remove edge e from the graph + * if m has no other incoming edges then + * insert m into S + * + * @param {object} parents - map of {[key]: [incoming edge keys]} + * @param {object} children - a map of {[key]: [outgoing edge keys]} + * @returns {object} + * if graph has edges then + * return error (graph has at least one cycle) + * else + * return L (a topologically sorted order) + */ + static topologicalOrdering(parents, children) { + const S = [] + + for (let node in parents) { + if (parents[node].length == 0) { + // Node has no parent (incoming edges) + S.push(node) + } + } + + const L = [] + + while (S.length > 0) { + const node = S.pop() + L.push(node) + + // Loop over nodes that depend on node + for (let child of children[node]) { + let i = parents[child].indexOf(node) + if (i > -1) + parents[child].splice(i, 1) + + if (parents[child].length == 0) + S.push(child) + } + } + return L + } + + /** + * Serialize a series of asynchronous calls to a function + * over a list of objects + * ref: http://www.joezimjs.com/javascript/patterns-asynchronous-programming-promises/ + */ + static seriesCall(list, fn) { + const results = [] + + return list.reduce((memo, item) => { + return memo.then(() => { + return fn(item) + }).then(result => { + results.push(result) + }) + }, + Promise.resolve() + ).then(function() { + return results + }) + } + + /** + * Delay the call to a function + */ + static delay(ms, v) { + return new Promise(resolve => { + setTimeout(resolve.bind(null, v), ms) + }) + } + + /** + * Splits a list into a list of lists each with maximum length LIMIT + */ + static splitList(list, limit) { + if (list.length <= limit) { + return [list] + } else { + const lists = [] + // How many lists to create? + const count = Math.ceil(list.length / limit) + // How many elements per list (max)? + const els = Math.ceil(list.length / count) + + for (let i=0; i < count; i++) { + lists.push(list.slice(i * els, (i+1) * els)) + } + return lists + } + } + + /** + * Check if a string is a valid hex value + */ + static isHashStr(hash) { + const hexRegExp = new RegExp(/^[0-9a-f]*$/, 'i') + return (typeof hash !== "string") ? false : hexRegExp.test(hash) + } + + /** + * Check if a string is a well formed 256 bits hash + */ + static is256Hash(hash) { + return Util.isHashStr(hash) && hash.length == 64 + } + + /** + * Sum an array of values + */ + static sum(arr) { + return arr.reduce((memo, val) => { return memo + val }, 0) + } + + /** + * Mean of an array of values + */ + static mean(arr) { + if (arr.length == 0) + return NaN + return sum(arr) / arr.length + } + + /** + * Compare 2 values (asc order) + */ + static cmpAsc(a, b) { + return a - b + } + + /** + * Compare 2 values (desc order) + */ + static cmpDesc(a,b) { + return b - a + } + + /** + * Median of an array of values + */ + static median(arr, sorted) { + if (arr.length == 0) return NaN + if (arr.length == 1) return arr[0] + + if (!sorted) + arr.sort(Util.cmpAsc) + + const midpoint = Math.floor(arr.length / 2) + + if (arr.length % 2) { + // Odd-length array + return arr[midpoint] + } else { + // Even-length array + return (arr[midpoint-1] + arr[midpoint]) / 2.0 + } + } + + /** + * Median Absolute Deviation of an array of values + */ + static mad(arr, sorted) { + const med = Util.median(arr, sorted) + // Deviations from the median + const dev = [] + for (let val of arr) + dev.push(Math.abs(val - med)) + return Util.median(dev) + } + + /** + * Quartiles of an array of values + */ + static quartiles(arr, sorted) { + const q = [NaN,NaN,NaN] + + if (arr.length < 3) return q + + if (!sorted) + arr.sort(Util.cmpAsc) + + // Set median + q[1] = Util.median(arr, true) + + const midpoint = Math.floor(arr.length / 2) + + if (arr.length % 2) { + // Odd-length array + const mod4 = arr.length % 4 + const n = Math.floor(arr.length / 4) + + if (mod4 == 1) { + q[0] = (arr[n-1] + 3 * arr[n]) / 4 + q[2] = (3 * arr[3*n] + arr[3*n+1]) / 4 + } else if (mod4 == 3) { + q[0] = (3 * arr[n] + arr[n+1]) / 4 + q[2] = (arr[3*n+1] + 3 * arr[3*n+2]) / 4 + } + + } else { + // Even-length array. Slices are already sorted + q[0] = Util.median(arr.slice(0, midpoint), true) + q[2] = Util.median(arr.slice(midpoint), true) + } + + return q + } + + /** + * Obtain the value of the PCT-th percentile, where PCT on [0,100] + */ + static percentile(arr, pct, sorted) { + if (arr.length < 2) return NaN + + if (!sorted) + arr.sort(Util.cmpAsc) + + const N = arr.length + const p = pct/100.0 + + let x // target rank + + if (p <= 1 / (N + 1)) { + x = 1 + } else if (p < N / (N + 1)) { + x = p * (N + 1) + } else { + x = N + } + + // "Floor-x" + const fx = Math.floor(x) - 1 + + // "Mod-x" + const mx = x % 1 + + if (fx + 1 >= N) { + return arr[fx] + } else { + // Linear interpolation between two array values + return arr[fx] + mx * (arr[fx+1] - arr[fx]) + } + } + + /** + * Convert bytes to Mb + */ + static toMb(bytes) { + return +(bytes / Util.MB).toFixed(0) + } + + /** + * Convert a date to a unix timestamp + */ + static unix() { + return (Date.now() / 1000) | 0 + } + + /** + * Convert a value to a padded string (10 chars) + */ + static pad10(v) { + return (v < 10) ? `0${v}` : `${v}` + } + + /** + * Convert a value to a padded string (100 chars) + */ + static pad100(v) { + if (v < 10) return `00${v}` + if (v < 100) return `0${v}` + return `${v}` + } + + /** + * Convert a value to a padded string (1000 chars) + */ + static pad1000(v) { + if (v < 10) return `000${v}` + if (v < 100) return `00${v}` + if (v < 1000) return `0${v}` + return `${v}` + } + + /** + * Left pad + */ + static leftPad(number, places, fill) { + number = Math.round(number) + places = Math.round(places) + fill = fill || ' ' + + if (number < 0) return number + + const mag = (number > 0) ? (Math.floor(Math.log10(number)) + 1) : 1 + const parts = [] + + for(let i=0; i < (places - mag); i++) { + parts.push(fill) + } + + parts.push(number) + return parts.join('') + } + + /** + * Display a time period, in seconds, as DDD:HH:MM:SS[.MS] + */ + static timePeriod(period, milliseconds) { + milliseconds = !!milliseconds + + const whole = Math.floor(period) + const ms = 1000*(period - whole) + const s = whole % 60 + const m = (whole >= 60) ? Math.floor(whole / 60) % 60 : 0 + const h = (whole >= 3600) ? Math.floor(whole / 3600) % 24 : 0 + const d = (whole >= 86400) ? Math.floor(whole / 86400) : 0 + + const parts = [Util.pad10(h), Util.pad10(m), Util.pad10(s)] + + if (d > 0) + parts.splice(0, 0, Util.pad100(d)) + + const str = parts.join(':') + + if (milliseconds) { + return str + '.' + Util.pad100(ms) + } else { + return str + } + } + +} + +/** + * 1Mb in bytes + */ +Util.MB = 1024*1024 + + +module.exports = Util diff --git a/lib/wallet/address-info.js b/lib/wallet/address-info.js new file mode 100644 index 0000000..53bf15a --- /dev/null +++ b/lib/wallet/address-info.js @@ -0,0 +1,152 @@ +/*! + * lib/wallet/address-info.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const db = require('../db/mysql-db-wrapper') +const hdaHelper = require('../bitcoin/hd-accounts-helper') + + +/** + * A class storing information about the actibity of an address + */ +class AddressInfo { + + /** + * Constructor + * @param {object} address - bitcoin address + */ + constructor(address) { + // Initializes properties + this.address = address + this.pubkey = null + this.finalBalance = 0 + this.nTx = 0 + this.unspentOutputs = [] + + this.tracked = false, + this.type = 'untracked' + this.xpub = null + this.path = null + this.segwit = null + this.txs = [] + } + + /** + * Load information about the address + * @returns {Promise} + */ + async loadInfo() { + const balance = await db.getAddressBalance(this.address) + if (balance !== null) + this.finalBalance = balance + + const nbTxs = await db.getAddressNbTransactions(this.address) + if (nbTxs !== null) + this.nTx = nbTxs + } + + /** + * Load information about the address + * (extended form) + * @returns {Promise} + */ + async loadInfoExtended() { + const res = await db.getHDAccountsByAddresses([this.address]) + + for (let xpub in res.hd) { + const xpubType = hdaHelper.classify(res.hd[xpub].hdType).type + const info = res.hd[xpub].addresses[0] + this.tracked = true + this.type = 'hd' + this.xpub = xpub + this.segwit = (xpubType === hdaHelper.BIP49 || xpubType === hdaHelper.BIP84) + this.path = ['M', info.hdAddrChain, info.hdAddrIndex].join('/') + } + + if (res.loose.indexOf(this.address) > -1) { + this.tracked = true + this.type = 'loose' + } + + return this.loadInfo() + } + + /** + * Loads a partial list of transactions for this address + * @param {integer} page - page index + * @param {integer} count - number of transactions per page + * @returns {Promise} + */ + async loadTransactions(page, count) { + this.txs = await db.getTxsByAddrAndXpubs([this.address]) + } + + /** + * Load the utxos associated to the address + * @returns {Promise - object[]} + */ + async loadUtxos() { + this.unspentOutputs = [] + + const res = await db.getUnspentOutputs([this.address]) + + for (let r of res) { + this.unspentOutputs.push({ + txid: r.txnTxid, + vout: r.outIndex, + amount: r.outAmount, + }) + } + + // Order the utxos + this.unspentOutputs.sort((a,b) => b.confirmations - a.confirmations) + return this.unspentOutputs + } + + /** + * Return a plain old js object with address properties + * @returns {object} + */ + toPojo() { + const ret = { + address: this.address, + final_balance: this.finalBalance, + n_tx: this.nTx + } + + if (this.pubkey) + ret.pubkey = this.pubkey + + return ret + } + + /** + * Return a plain old js object with address properties + * (extended version) + * @returns {object} + */ + toPojoExtended() { + const ret = { + address: this.address, + tracked: this.tracked, + type: this.type, + balance: this.finalBalance, + xpub: this.xpub, + path: this.path, + segwit: this.segwit, + n_tx: this.nTx, + txids: this.txs.map(t => t.hash), + utxo: this.unspentOutputs + } + + if (this.pubkey) + ret.pubkey = this.pubkey + + return ret + } + +} + +module.exports = AddressInfo diff --git a/lib/wallet/hd-account-info.js b/lib/wallet/hd-account-info.js new file mode 100644 index 0000000..27df4cb --- /dev/null +++ b/lib/wallet/hd-account-info.js @@ -0,0 +1,187 @@ +/*! + * lib/wallet/hd-account-info.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const errors = require('../errors') +const db = require('../db/mysql-db-wrapper') +const hdaHelper = require('../bitcoin/hd-accounts-helper') +const hdaService = require('../bitcoin/hd-accounts-service') +const rpcLatestBlock = require('../bitcoind-rpc/latest-block') + + +/** + * A class storing information about the actibity of a hd account + */ +class HdAccountInfo { + + /** + * Constructor + * @param {object} xpub - xpub + */ + constructor(xpub) { + // Initializes properties + this.xpub = xpub + this.address = xpub + this.account = 0 + this.depth = 0 + this.finalBalance = 0 + this.accountIndex = 0 + this.changeIndex = 0 + this.accountDerivedIndex = 0 + this.changeDerivedIndex = 0 + this.nTx = 0 + this.unspentOutputs = [] + this.derivation = null + this.created = null + this.tracked = false + } + + /** + * Ensure the hd account exists in database + * Otherwise, tries to import it with BIP44 derivation + * @returns {Promise - integer} return the internal id of the hd account + * or null if it doesn't exist + */ + async ensureHdAccount() { + try { + const id = await db.getHDAccountId(this.xpub) + return id + } catch(e) { + if (e == errors.db.ERROR_NO_HD_ACCOUNT) { + try { + // Default to BIP44 import + return hdaService.restoreHdAccount(this.xpub, hdaHelper.BIP44) + } catch(e) { + return null + } + } + return null + } + } + + /** + * Load information about the hd account + * @returns {Promise} + */ + async loadInfo() { + try { + const id = await db.getHDAccountId(this.xpub) + //if (id == null) return false + + const account = await db.getHDAccount(this.xpub) + this.created = account.hdCreated + this.derivation = hdaHelper.typeString(account.hdType) + this.tracked = true + + this.finalBalance = await db.getHDAccountBalance(this.xpub) + + const unusedIdx = await db.getHDAccountNextUnusedIndices(this.xpub) + this.accountIndex = unusedIdx[0] + this.changeIndex = unusedIdx[1] + + const derivedIdx = await db.getHDAccountDerivedIndices(this.xpub) + this.accountDerivedIndex = derivedIdx[0] + this.changeDerivedIndex = derivedIdx[1] + + this.nTx = await db.getHDAccountNbTransactions(this.xpub) + + const node = hdaHelper.getNode(this.xpub) + const index = node[2].index + const threshold = Math.pow(2,31) + const hardened = (index >= threshold) + this.account = hardened ? (index - threshold) : index + this.depth = node[2].depth + + return true + + } catch(e) { + return false + } + } + + /** + * Load the utxos associated to the hd account + * @returns {Promise - object[]} + */ + async loadUtxos() { + this.unspentOutputs = [] + + const utxos = await db.getHDAccountUnspentOutputs(this.xpub) + + for (let utxo of utxos) { + const conf = + (utxo.blockHeight == null) + ? 0 + : (rpcLatestBlock.height - utxo.blockHeight + 1) + + const entry = { + tx_hash: utxo.txnTxid, + tx_output_n: utxo.outIndex, + tx_version: utxo.txnVersion, + tx_locktime: utxo.txnLocktime, + value: utxo.outAmount, + script: utxo.outScript, + addr: utxo.addrAddress, + confirmations: conf, + xpub: { + m: this.xpub, + path: ['M', utxo.hdAddrChain, utxo.hdAddrIndex].join('/') + } + } + + this.unspentOutputs.push(entry) + } + + // Order the utxos + this.unspentOutputs.sort((a,b) => b.confirmations - a.confirmations) + + return this.unspentOutputs + } + + /** + * Return a plain old js object with hd account properties + * @returns {object} + */ + toPojo() { + return { + address: this.address, + final_balance: this.finalBalance, + account_index: this.accountIndex, + change_index: this.changeIndex, + n_tx: this.nTx, + derivation: this.derivation, + created: this.created + } + } + + /** + * Return a plain old js object with hd account properties + * (extended version) + * @returns {object} + */ + toPojoExtended() { + return { + xpub: this.xpub, + tracked: this.tracked, + balance: this.finalBalance, + unused: { + external: this.accountIndex, + internal: this.changeIndex, + }, + derived: { + external: this.accountDerivedIndex, + internal: this.changeDerivedIndex, + }, + n_tx: this.nTx, + derivation: this.derivation, + account: this.account, + depth: this.depth, + created: (new Date(this.created * 1000)).toGMTString() + } + } + +} + +module.exports = HdAccountInfo diff --git a/lib/wallet/wallet-entities.js b/lib/wallet/wallet-entities.js new file mode 100644 index 0000000..0f88f52 --- /dev/null +++ b/lib/wallet/wallet-entities.js @@ -0,0 +1,88 @@ +/*! + * lib/wallet/wallet-entities.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + + +/** + * A class storing entities (xpubs, addresses, pubkeys) + * defining a (full|partial) wallet + */ +class WalletEntities { + + /** + * Constructor + */ + constructor() { + this.pubkeys = [] + this.addrs = [] + this.xpubs = [] + this.ypubs = [] + this.zpubs = [] + } + + /** + * Add a new hd account + * with its translation as an xpub + * @param {string} xpub - xpub or tpub + * @param {string} ypub - ypub or upub or false + * @param {string} zpub - zpub or vpub or false + */ + addHdAccount(xpub, ypub, zpub) { + this.xpubs.push(xpub) + this.ypubs.push(ypub) + this.zpubs.push(zpub) + } + + /** + * Add a new address/pubkey + * @param {string} address - bitcoin address + * @param {string} pubkey - pubkey associated to the address or false + */ + addAddress(address, pubkey) { + this.addrs.push(address) + this.pubkeys.push(pubkey) + } + + /** + * Update the pubkey associated to a given address + * @param {string} address - bitcoin address + * @param {string} pubkey - public key + */ + updatePubKey(address, pubkey) { + const idxAddr = this.addrs.indexOf(address) + if (idxAddr > -1) + this.pubkeys[idxAddr] = pubkey + } + + /** + * Checks if a xpub is already listed + * @param {string} xpub + * @returns {boolean} returns true if the xpub is already listed, false otherwise + */ + hasXPub(xpub) { + return (this.xpubs.indexOf(xpub) > -1) + } + + /** + * Checks if an address is already listed + * @param {string} address - bitcoin address + * @returns {boolean} returns true if the address is already listed, false otherwise + */ + hasAddress(address) { + return (this.addrs.indexOf(address) > -1) + } + + /** + * Checks if a pubkey is already listed + * @param {string} pubkey - public key + * @returns {boolean} returns true if the pubkey is already listed, false otherwise + */ + hasPubKey(pubkey) { + return (this.pubkeys.indexOf(pubkey) > -1) + } + +} + +module.exports = WalletEntities \ No newline at end of file diff --git a/lib/wallet/wallet-info.js b/lib/wallet/wallet-info.js new file mode 100644 index 0000000..54693c4 --- /dev/null +++ b/lib/wallet/wallet-info.js @@ -0,0 +1,309 @@ +/*! + * lib/wallet/wallet-info.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const db = require('../db/mysql-db-wrapper') +const util = require('../util') +const rpcLatestBlock = require('../bitcoind-rpc/latest-block') +const addrService = require('../bitcoin/addresses-service') +const HdAccountInfo = require('./hd-account-info') +const AddressInfo = require('./address-info') + + +/** + * A class storing information about a (full|partial) wallet + * Provides a set of methods allowing to retrieve specific information + */ +class WalletInfo { + + /** + * Constructor + * @param {object} entities - wallet entities (hdaccounts, addresses, pubkeys) + */ + constructor(entities) { + // Initializes wallet properties + this.entities = entities + + this.wallet = { + finalBalance: 0 + } + + this.info = { + latestBlock: { + height: rpcLatestBlock.height, + hash: rpcLatestBlock.hash, + time: rpcLatestBlock.time, + } + } + + this.addresses = [] + this.txs = [] + this.unspentOutputs = [] + this.nTx = 0 + } + + + /** + * Ensure hd accounts exist in database + * @returns {Promise} + */ + async ensureHdAccounts() { + return util.seriesCall(this.entities.xpubs, async xpub => { + const hdaInfo = new HdAccountInfo(xpub) + return hdaInfo.ensureHdAccount() + }) + } + + /** + * Load information about the hd accounts + * @returns {Promise} + */ + async loadHdAccountsInfo() { + return util.seriesCall(this.entities.xpubs, async xpub => { + const hdaInfo = new HdAccountInfo(xpub) + await hdaInfo.loadInfo() + this.wallet.finalBalance += hdaInfo.finalBalance + this.addresses.push(hdaInfo) + }) + } + + /** + * Ensure addresses exist in database + * @returns {Promise} + */ + async ensureAddresses() { + const importAddrs = [] + + const addrIdMap = await db.getAddressesIds(this.entities.addrs) + + for (let addr of this.entities.addrs) { + if (!addrIdMap[addr]) + importAddrs.push(addr) + } + + // Import new addresses + return addrService.restoreAddresses(importAddrs, true) + } + + /** + * Filter addresses that belong to an active hd account + * @returns {Promise} + */ + async filterAddresses() { + const res = await db.getXpubByAddresses(this.entities.addrs) + + for (let addr in res) { + let xpub = res[addr] + if (this.entities.xpubs.indexOf(xpub) > -1) { + let i = this.entities.addrs.indexOf(addr) + if (i > -1) { + this.entities.addrs.splice(i, 1) + this.entities.pubkeys.splice(i, 1) + } + } + } + } + + /** + * Load information about the addresses + * @returns {Promise} + */ + async loadAddressesInfo() { + return util.seriesCall(this.entities.addrs, async address => { + const addrInfo = new AddressInfo(address) + await addrInfo.loadInfo() + this.wallet.finalBalance += addrInfo.finalBalance + this.addresses.push(addrInfo) + }) + } + + /** + * Loads a partial list of transactions for this wallet + * @param {integer} page - page index + * @param {integer} count - number of transactions per page + * @param {boolean} txBalance - True if past wallet balance + * should be computed for each transaction + * @returns {Promise} + */ + async loadTransactions(page, count, txBalance) { + this.txs = await db.getTxsByAddrAndXpubs( + this.entities.addrs, + this.entities.xpubs, + page, + count + ) + + if (txBalance) { + // Computes wallet balance after each transaction + let balance = this.wallet.finalBalance + for (let i = 0; i < this.txs.length; i++) { + this.txs[i].balance = balance + balance -= this.txs[i].result + } + } + } + + /** + * Loads the number of transactions for this wallet + * @returns {Promise} + */ + async loadNbTransactions() { + const nbTxs = await db.getAddrAndXpubsNbTransactions( + this.entities.addrs, + this.entities.xpubs + ) + + if (nbTxs !== null) + this.nTx = nbTxs + } + + /** + * Loads the list of unspent outputs for this wallet + * @returns {Promise} + */ + async loadUtxos() { + // Load the utxos for the hd accounts + await util.seriesCall(this.entities.xpubs, async xpub => { + const hdaInfo = new HdAccountInfo(xpub) + const utxos = await hdaInfo.loadUtxos() + for (let utxo of utxos) + this.unspentOutputs.push(utxo) + }) + + // Load the utxos for the addresses + const utxos = await db.getUnspentOutputs(this.entities.addrs) + + for (let utxo of utxos) { + const conf = + (utxo.blockHeight == null) + ? 0 + : (rpcLatestBlock.height - utxo.blockHeight + 1) + + const entry = { + tx_hash: utxo.txnTxid, + tx_output_n: utxo.outIndex, + tx_version: utxo.txnVersion, + tx_locktime: utxo.txnLocktime, + value: utxo.outAmount, + script: utxo.outScript, + addr: utxo.addrAddress, + confirmations: conf + } + + this.unspentOutputs.push(entry) + } + + // Order the utxos + this.unspentOutputs.sort((a,b) => b.confirmations - a.confirmations) + } + + /** + * Post process addresses and public keys + */ + postProcessAddresses() { + for (let b = 0; b < this.entities.pubkeys.length; b++) { + const pk = this.entities.pubkeys[b] + + if (pk) { + const address = this.entities.addrs[b] + + // Add pubkeys in this.addresses + for (let c = 0; c < this.addresses.length; c++) { + if (address == this.addresses[c].address) + this.addresses[c].pubkey = pk + } + + // Add pubkeys in this.txs + for (let d = 0; d < this.txs.length; d++) { + // inputs + for (let e = 0; e < this.txs[d].inputs.length; e++) { + if (address == this.txs[d].inputs[e].prev_out.addr) + this.txs[d].inputs[e].prev_out.pubkey = pk + } + // outputs + for (let e = 0; e < this.txs[d].out.length; e++) { + if (address == this.txs[d].out[e].addr) + this.txs[d].out[e].pubkey = pk + } + } + + // Add pubkeys in this.unspentOutputs + for (let f = 0; f < this.unspentOutputs.length; f++) { + if (address == this.unspentOutputs[f].addr) { + this.unspentOutputs[f].pubkey = pk + } + } + } + } + } + + /** + * Post process hd accounts (xpubs translations) + */ + postProcessHdAccounts() { + for (let b = 0; b < this.entities.xpubs.length; b++) { + const entityXPub = this.entities.xpubs[b] + const entityYPub = this.entities.ypubs[b] + const entityZPub = this.entities.zpubs[b] + + if (entityYPub || entityZPub) { + const tgtXPub = entityYPub ? entityYPub : entityZPub + + // Translate xpub => ypub/zpub in this.addresses + for (let c = 0; c < this.addresses.length; c++) { + if (entityXPub == this.addresses[c].address) + this.addresses[c].address = tgtXPub + } + + // Translate xpub => ypub/zpub in this.txs + for (let d = 0; d < this.txs.length; d++) { + // inputs + for (let e = 0; e < this.txs[d].inputs.length; e++) { + const xpub = this.txs[d].inputs[e].prev_out.xpub + if (xpub && (xpub.m == entityXPub)) + this.txs[d].inputs[e].prev_out.xpub.m = tgtXPub + } + + // outputs + for (let e = 0; e < this.txs[d].out.length; e++) { + const xpub = this.txs[d].out[e].xpub + if (xpub && (xpub.m == entityXPub)) + this.txs[d].out[e].xpub.m = tgtXPub + } + } + + // Translate xpub => ypub/zpub in this.unspentOutputs + for (let f = 0; f < this.unspentOutputs.length; f++) { + const xpub = this.unspentOutputs[f].xpub + if (xpub && (xpub.m == entityXPub)) { + this.unspentOutputs[f].xpub.m = tgtXPub + } + } + } + } + } + + /** + * Return a plain old js object with wallet properties + * @returns {object} + */ + toPojo() { + return { + wallet: { + final_balance: this.wallet.finalBalance + }, + info: { + latest_block: this.info.latestBlock + }, + addresses: this.addresses.map(a => a.toPojo()), + txs: this.txs, + unspent_outputs: this.unspentOutputs, + n_tx: this.nTx + } + } + +} + +module.exports = WalletInfo diff --git a/lib/wallet/wallet-service.js b/lib/wallet/wallet-service.js new file mode 100644 index 0000000..9d1745d --- /dev/null +++ b/lib/wallet/wallet-service.js @@ -0,0 +1,301 @@ +/*! + * lib/wallet/wallet-service.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const util = require('../util') +const Logger = require('../logger') +const db = require('../db/mysql-db-wrapper') +const hdaService = require('../bitcoin/hd-accounts-service') +const hdaHelper = require('../bitcoin/hd-accounts-helper') +const WalletInfo = require('./wallet-info') + + +/** + * A singleton providing a wallets service + */ +class WalletService { + + /** + * Constructor + */ + constructor() {} + + /** + * Get wallet information + * @param {object} active - mapping of active entities + * @param {object} legacy - mapping of new legacy addresses + * @param {object} bip49 - mapping of new bip49 addresses + * @param {object} bip84 - mapping of new bip84 addresses + * @param {object} pubkeys - mapping of new pubkeys/addresses + * @returns {Promise} + */ + async getWalletInfo(active, legacy, bip49, bip84, pubkeys) { + // Check parameters + const validParams = this._checkEntities(active, legacy, bip49, bip84, pubkeys) + + if (!validParams) { + const info = new WalletInfo() + const ret = this._formatGetWalletInfoResult(info) + return Promise.resolve(ret) + } + + // Merge all entities into active mapping + active = this._mergeEntities(active, legacy, bip49, bip84, pubkeys) + + // Initialize a WalletInfo object + const walletInfo = new WalletInfo(active) + + try { + // Add the new xpubs + await util.seriesCall(legacy.xpubs, this._newBIP44) + await util.seriesCall(bip49.xpubs, this._newBIP49) + await util.seriesCall(bip84.xpubs, this._newBIP84) + // Load hd accounts info + await walletInfo.ensureHdAccounts() + await walletInfo.loadHdAccountsInfo() + // Add the new addresses + await db.addAddresses(legacy.addrs) + await db.addAddresses(bip49.addrs) + await db.addAddresses(bip84.addrs) + await db.addAddresses(pubkeys.addrs) + // Ensure addresses exist and filter them + await walletInfo.ensureAddresses() + //await this._forceEnsureAddressesForActivePubkeys(active) + await walletInfo.filterAddresses() + await walletInfo.loadAddressesInfo() + // Load the most recent transactions + await walletInfo.loadTransactions(0, null, true) + // Postprocessing + await walletInfo.postProcessAddresses() + await walletInfo.postProcessHdAccounts() + // Format the result + return this._formatGetWalletInfoResult(walletInfo) + + } catch(e) { + Logger.error(e, 'WalletService.getWalletInfo()') + return Promise.reject({status:'error', error:'internal server error'}) + } + } + + /** + * Prepares the result to be returned by getWalletInfo() + * @param {WalletInfo} info + * @returns {object} + */ + _formatGetWalletInfoResult(info) { + let ret = info.toPojo() + + delete ret['n_tx'] + delete ret['unspent_outputs'] + + ret.addresses = ret.addresses.map(x => { + delete x['derivation'] + delete x['created'] + return x + }) + + return ret + } + + /** + * Get wallet unspent outputs + * @param {object} active - mapping of active entities + * @param {object} legacy - mapping of new legacy addresses + * @param {object} bip49 - mapping of new bip49 addresses + * @param {object} bip84 - mapping of new bip84 addresses + * @param {object} pubkeys - mapping of new pubkeys/addresses + * @returns {Promise} + */ + async getWalletUtxos(active, legacy, bip49, bip84, pubkeys) { + const ret = { + unspent_outputs: [] + } + + // Check parameters + const validParams = this._checkEntities(active, legacy, bip49, bip84, pubkeys) + if (!validParams) + return Promise.resolve(ret) + + // Merge all entities into active mapping + active = this._mergeEntities(active, legacy, bip49, bip84, pubkeys) + + // Initialize a WalletInfo object + const walletInfo = new WalletInfo(active) + + try { + // Add the new xpubs + await util.seriesCall(legacy.xpubs, this._newBIP44) + await util.seriesCall(bip49.xpubs, this._newBIP49) + await util.seriesCall(bip84.xpubs, this._newBIP84) + // Ensure hd accounts exist + await walletInfo.ensureHdAccounts() + // Add the new addresses + await db.addAddresses(legacy.addrs) + await db.addAddresses(bip49.addrs) + await db.addAddresses(bip84.addrs) + await db.addAddresses(pubkeys.addrs) + // Ensure addresses exist and filter them + await walletInfo.ensureAddresses() + //await this._forceEnsureAddressesForActivePubkeys(active) + await walletInfo.filterAddresses() + // Load the utxos + await walletInfo.loadUtxos() + // Postprocessing + await walletInfo.postProcessAddresses() + await walletInfo.postProcessHdAccounts() + // Format the result + ret.unspent_outputs = walletInfo.unspentOutputs + return ret + + } catch(e) { + Logger.error(e, 'WalletService.getWalletUtxos()') + return Promise.reject({status: 'error', error: 'internal server error'}) + } + } + + /** + * Get a subset of wallet transaction + * @param {object} entities - mapping of active entities + * @param {integer} page - page of transactions to be returned + * @param {integer} count - number of transactions returned per page + * @returns {Promise} + */ + async getWalletTransactions(entities, page, count) { + const ret = { + n_tx: 0, + page: page, + n_tx_page: count, + txs: [] + } + + // Check parameters + if (entities.xpubs.length == 0 && entities.addrs.length == 0) + return ret + + // Initialize a WalletInfo object + const walletInfo = new WalletInfo(entities) + + try { + // Filter the addresses + await walletInfo.filterAddresses() + // Load the number of transactions + await walletInfo.loadNbTransactions() + // Load the requested page of transactions + await walletInfo.loadTransactions(page, count, false) + // Postprocessing + await walletInfo.postProcessAddresses() + await walletInfo.postProcessHdAccounts() + // Format the result + ret.n_tx = walletInfo.nTx + ret.txs = walletInfo.txs + return ret + + } catch(e) { + Logger.error(e, 'WalletService.getWalletTransactions()') + return Promise.reject({status:'error', error:'internal server error'}) + } + } + + /** + * Force addresses derived from an active pubkey to be stored in database + * @param {object} active - mapping of active entities + * @returns {Promise} + */ + async _forceEnsureAddressesForActivePubkeys(active) { + const filteredAddrs = [] + for (let i in active.addrs) { + if (active.pubkeys[i]) { + filteredAddrs.push(active.addrs[i]) + } + } + return db.addAddresses(filteredAddrs) + } + + /** + * Check entities + * @param {object} active - mapping of active entities + * @param {object} legacy - mapping of new legacy addresses + * @param {object} bip49 - mapping of new bip49 addresses + * @param {object} bip84 - mapping of new bip84 addresses + * @param {object} pubkeys - mapping of new pubkeys/addresses + * @returns {boolean} return true if conditions are met, false otherwise + */ + _checkEntities(active, legacy, bip49, bip84, pubkeys) { + const allEmpty = active.xpubs.length == 0 + && active.addrs.length == 0 + && legacy.xpubs.length == 0 + && legacy.addrs.length == 0 + && pubkeys.addrs.length == 0 + && bip49.xpubs.length == 0 + && bip84.xpubs.length == 0 + + return !allEmpty + } + + /** + * Merge all entities into active mapping + * @param {object} active - mapping of active entities + * @param {object} legacy - mapping of new legacy entities + * @param {object} bip49 - mapping of new bip49 entities + * @param {object} bip84 - mapping of new bip84 entities + * @param {object} pubkeys - mapping of new pubkeys + */ + _mergeEntities(active, legacy, bip49, bip84, pubkeys) { + // Put all xpub into active.xpubs + active.xpubs = active.xpubs + .concat(legacy.xpubs) + .concat(bip49.xpubs) + .concat(bip84.xpubs) + + // Put addresses and pubkeys into active + // but avoid duplicates + for (let source of [legacy, pubkeys]) { + for (let idxSource in source.addrs) { + const addr = source.addrs[idxSource] + const pubkey = source.pubkeys[idxSource] + const idxActive = active.addrs.indexOf(addr) + + if (idxActive == -1) { + active.addrs.push(addr) + active.pubkeys.push(pubkey) + } else if (pubkey) { + active.pubkeys[idxActive] = pubkey + } + } + } + + return active + } + + /** + * Create a new BIP44 hd account into the database + * @param {string} xpub + * @returns {Promise} + */ + async _newBIP44(xpub) { + return hdaService.createHdAccount(xpub, hdaHelper.BIP44) + } + + /** + * Create a new BIP49 hd account into the database + * @param {string} xpub + * @returns {Promise} + */ + async _newBIP49(xpub) { + return hdaService.createHdAccount(xpub, hdaHelper.BIP49) + } + + /** + * Create a new BIP84 hd account into the database + * @param {string} xpub + * @returns {Promise} + */ + async _newBIP84(xpub) { + return hdaService.createHdAccount(xpub, hdaHelper.BIP84) + } + +} + +module.exports = new WalletService() diff --git a/package.json b/package.json new file mode 100644 index 0000000..8109687 --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "samourai-dojo", + "version": "1.0.0", + "description": "Backend server for Samourai Wallet", + "main": "accounts/index.js", + "scripts": { + "test": "mocha --recursive --reporter spec" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@github.com:Samourai-Wallet/samourai-dojo.git" + }, + "author": "Katana Cryptographic Ltd.", + "license": "AGPL-3.0-only", + "homepage": "https://github.com/Samourai-Wallet/samourai-dojo", + "dependencies": { + "async-sema": "2.1.2", + "bip39": "2.4.0", + "bitcoind-rpc-client": "0.3.1", + "bitcoinjs-lib": "3.2.0", + "bitcoinjs-message": "1.0.1", + "body-parser": "1.18.3", + "express": "4.16.3", + "express-jwt": "5.3.1", + "generic-pool": "3.4.2", + "heapdump": "0.3.9", + "helmet": "3.12.1", + "lru-cache": "4.0.2", + "mysql": "2.16.0", + "passport": "0.4.0", + "passport-localapikey-update": "0.6.0", + "request": "2.88.0", + "request-promise-native": "1.0.5", + "socks-proxy-agent": "4.0.1", + "validator": "10.8.0", + "websocket": "1.0.28", + "zeromq": "4.2.0" + }, + "devDependencies": { + "mocha": "^3.5.0" + } +} diff --git a/pushtx/index-orchestrator.js b/pushtx/index-orchestrator.js new file mode 100644 index 0000000..3123de5 --- /dev/null +++ b/pushtx/index-orchestrator.js @@ -0,0 +1,49 @@ +/*! + * pushtx/index-orchestrator.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +(async () => { + + 'use strict' + + const Logger = require('../lib/logger') + const db = require('../lib/db/mysql-db-wrapper') + const RpcClient = require('../lib/bitcoind-rpc/rpc-client') + const network = require('../lib/bitcoin/network') + const keys = require('../keys')[network.key] + const Orchestrator = require('./orchestrator') + const pushTxProcessor = require('./pushtx-processor') + + + /** + * PushTx Orchestrator + */ + Logger.info('Process ID: ' + process.pid) + Logger.info('Preparing the pushTx Orchestrator') + + // Wait for Bitcoind RPC API + // being ready to process requests + await RpcClient.waitForBitcoindRpcApi() + + // Initialize the db wrapper + const dbConfig = { + connectionLimit: keys.db.connectionLimitPushTxOrchestrator, + acquireTimeout: keys.db.acquireTimeout, + host: keys.db.host, + user: keys.db.user, + password: keys.db.pass, + database: keys.db.database + } + + db.connect(dbConfig) + + // Initialize notification sockets of singleton pushTxProcessor + pushTxProcessor.initNotifications({ + uriSocket: `tcp://*:${keys.ports.orchestrator}` + }) + + // Initialize and start the orchestrator + const orchestrator = new Orchestrator() + orchestrator.start() + +})() diff --git a/pushtx/index.js b/pushtx/index.js new file mode 100644 index 0000000..865bcaa --- /dev/null +++ b/pushtx/index.js @@ -0,0 +1,57 @@ +/*! + * pushtx/index.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +(async () => { + + 'use strict' + + const Logger = require('../lib/logger') + const db = require('../lib/db/mysql-db-wrapper') + const RpcClient = require('../lib/bitcoind-rpc/rpc-client') + const network = require('../lib/bitcoin/network') + const keys = require('../keys')[network.key] + const HttpServer = require('../lib/http-server/http-server') + const PushTxRestApi = require('./pushtx-rest-api') + const pushTxProcessor = require('./pushtx-processor') + + + /** + * PushTx API + */ + Logger.info('Process ID: ' + process.pid) + Logger.info('Preparing the pushTx API') + + // Wait for Bitcoind RPC API + // being ready to process requests + await RpcClient.waitForBitcoindRpcApi() + + // Initialize the db wrapper + const dbConfig = { + connectionLimit: keys.db.connectionLimitPushTxApi, + acquireTimeout: keys.db.acquireTimeout, + host: keys.db.host, + user: keys.db.user, + password: keys.db.pass, + database: keys.db.database + } + + db.connect(dbConfig) + + // Initialize notification sockets of singleton pushTxProcessor + pushTxProcessor.initNotifications({ + uriSocket: `tcp://*:${keys.ports.notifpushtx}` + }) + + // Initialize the http server + const port = keys.ports.pushtx + const httpsOptions = keys.https.pushtx + const httpServer = new HttpServer(port, httpsOptions) + + // Initialize the PushTx rest api + const pushtxRestApi = new PushTxRestApi(httpServer) + + // Start the http server + httpServer.start() + +})() diff --git a/pushtx/orchestrator.js b/pushtx/orchestrator.js new file mode 100644 index 0000000..0a8c779 --- /dev/null +++ b/pushtx/orchestrator.js @@ -0,0 +1,182 @@ +/*! + * pushtx/orchestrator.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const zmq = require('zeromq') +const Sema = require('async-sema') +const Logger = require('../lib/logger') +const db = require('../lib/db/mysql-db-wrapper') +const RpcClient = require('../lib/bitcoind-rpc/rpc-client') +const network = require('../lib/bitcoin/network') +const keys = require('../keys')[network.key] +const pushTxProcessor = require('./pushtx-processor') + + +/** + * A class orchestrating the push of scheduled transactions + */ +class Orchestrator { + + /** + * Constructor + */ + constructor() { + // RPC client + this.rpcClient = new RpcClient() + // ZeroMQ socket for bitcoind blocks messages + this.blkSock = null + // Initialize a semaphor protecting the onBlockHash() method + this._onBlockHashSemaphor = new Sema(1, { capacity: 50 }) + } + + /** + * Start processing the blockchain + * @returns {Promise} + */ + start() { + this.initSockets() + } + + /** + * Start processing the blockchain + */ + async stop() {} + + /** + * Initialiaze ZMQ sockets + */ + initSockets() { + // Socket listening to bitcoind Blocks messages + this.blkSock = zmq.socket('sub') + this.blkSock.connect(keys.bitcoind.zmqBlk) + this.blkSock.subscribe('hashblock') + + this.blkSock.on('message', (topic, message) => { + switch (topic.toString()) { + case 'hashblock': + this.onBlockHash(message) + break + default: + Logger.info(topic.toString()) + } + }) + + Logger.info('Listening for blocks') + } + + /** + * Push Transactions if triggered by new block + * @param {Buffer} buf - block hash + */ + async onBlockHash(buf) { + try { + // Acquire the semaphor + await this._onBlockHashSemaphor.acquire() + + // Retrieve the block height + const blockHash = buf.toString('hex') + const header = await this.rpcClient.getblockheader(blockHash, true) + const height = header.height + + Logger.info(`Block ${height} ${blockHash}`) + + // Retrieve the transactions triggered by this block + let txs = await db.getActivatedScheduledTransactions(height) + + while (txs && txs.length > 0) { + let rpcConnOk = true + + for (let tx of txs) { + let hasParentTx = (tx.schParentTxid != null) && (tx.schParentTxid != '') + let parentTx = null + + // Check if previous transaction has been confirmed + if (hasParentTx) { + try { + parentTx = await this.rpcClient.getrawtransaction(tx.schParentTxid, true) + } catch(e) { + Logger.error(e, 'Transaction.getTransaction()') + } + } + + if ((!hasParentTx) || (parentTx && parentTx.confirmations && (parentTx.confirmations >= tx.schDelay))) { + // Push the transaction + try { + await pushTxProcessor.pushTx(tx.schRaw) + Logger.info(`Pushed scheduled transaction ${tx.schTxid}`) + } catch(e) { + const msg = 'A problem was met while trying to push a scheduled transaction' + Logger.error(e, `Orchestrator.onBlockHash() : ${msg}`) + // Check if it's an issue with the connection to the RPC API + // (=> immediately stop the loop) + if (RpcClient.isConnectionError(e)) { + Logger.info('Connection issue') + rpcConnOk = false + break + } + } + + // Update triggers of next transactions if needed + if (tx.schTrigger < height) { + const shift = height - tx.schTrigger + try { + await this.updateTriggers(tx.schID, shift) + } catch(e) { + const msg = 'A problem was met while shifting scheduled transactions' + Logger.error(e, `Orchestrator.onBlockHash() : ${msg}`) + } + } + + // Delete the transaction + try { + await db.deleteScheduledTransaction(tx.schTxid) + } catch(e) { + const msg = 'A problem was met while trying to delete a scheduled transaction' + Logger.error(e, `Orchestrator.onBlockHash() : ${msg}`) + } + } + } + + // If a connection issue was detected, then stop the loop + if (!rpcConnOk) + break + + // Check if more transactions have to be pushed + txs = await db.getActivatedScheduledTransactions(height) + } + + } catch(e) { + Logger.error(e, 'Orchestrator.onBlockHash() : Error') + } finally { + // Release the semaphor + await this._onBlockHashSemaphor.release() + } + } + + /** + * Update triggers in chain of transactions + * following a transaction identified by its txid + * @param {integer} parentId - parent id + * @param {integer} shift - delta to be added to the triggers + */ + async updateTriggers(parentId, shift) { + if (shift == 0) + return + + const txs = await db.getNextScheduledTransactions(parentId) + + for (let tx of txs) { + // Update the trigger of the transaction + const newTrigger = tx.schTrigger + shift + await db.updateTriggerScheduledTransaction(tx.schID, newTrigger) + // Update the triggers of next transactions in the chain + await this.updateTriggers(tx.schID, shift) + Logger.info(`Rescheduled tx ${tx.schTxid} (trigger=${newTrigger})`) + } + } + +} + +module.exports = Orchestrator diff --git a/pushtx/pushtx-processor.js b/pushtx/pushtx-processor.js new file mode 100644 index 0000000..a6f2125 --- /dev/null +++ b/pushtx/pushtx-processor.js @@ -0,0 +1,77 @@ +/*! + * pushtx/pushtx-processor.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const bitcoin = require('bitcoinjs-lib') +const zmq = require('zeromq') +const Logger = require('../lib/logger') +const errors = require('../lib/errors') +const RpcClient = require('../lib/bitcoind-rpc/rpc-client') +const network = require('../lib/bitcoin/network') +const keys = require('../keys')[network.key] +const status = require('./status') + + +/** + * A singleton providing a wrapper + * for pushing transactions with the local bitcoind + */ +class PushTxProcessor { + + /** + * Constructor + */ + constructor() { + this.notifSock = null + // Initialize the rpc client + this.rpcClient = new RpcClient() + } + + /** + * Initialize the sockets for notifications + */ + initNotifications(config) { + // Notification socket for the tracker + this.notifSock = zmq.socket('pub') + this.notifSock.bindSync(config.uriSocket) + } + + /** + * Push transactions to the Bitcoin network + * @param {string} rawtx - raw bitcoin transaction in hex format + * @returns {string} returns the txid of the transaction + */ + async pushTx(rawtx) { + let value = 0 + + // Attempt to parse incoming TX hex as a bitcoin Transaction + try { + const tx = bitcoin.Transaction.fromHex(rawtx) + for (let output of tx.outs) + value += output.value + Logger.info('Push for ' + (value / 1e8).toFixed(8) + ' BTC') + } catch(e) { + throw errors.tx.PARSE + } + + // At this point, the raw hex parses as a legitimate transaction. + // Attempt to send via RPC to the bitcoind instance + try { + const txid = await this.rpcClient.sendrawtransaction(rawtx) + Logger.info('Pushed!') + // Update the stats + status.updateStats(value) + // Notify the tracker + this.notifSock.send(['pushtx', rawtx]) + return txid + } catch(err) { + Logger.info('Push failed') + throw err + } + } + +} + +module.exports = new PushTxProcessor() diff --git a/pushtx/pushtx-rest-api.js b/pushtx/pushtx-rest-api.js new file mode 100644 index 0000000..92cfe24 --- /dev/null +++ b/pushtx/pushtx-rest-api.js @@ -0,0 +1,223 @@ +/*! + * pushtx/pushtx-rest-api.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const qs = require('querystring') +const validator = require('validator') +const bodyParser = require('body-parser') +const Logger = require('../lib/logger') +const errors = require('../lib/errors') +const authMgr = require('../lib/auth/authorizations-manager') +const HttpServer = require('../lib/http-server/http-server') +const network = require('../lib/bitcoin/network') +const keys = require('../keys')[network.key] +const status = require('./status') +const pushTxProcessor = require('./pushtx-processor') +const TransactionsScheduler = require('./transactions-scheduler') + + +/** + * PushTx API endpoints + */ +class PushTxRestApi { + + /** + * Constructor + * @param {pushtx.HttpServer} httpServer - HTTP server + */ + constructor(httpServer) { + this.httpServer = httpServer + this.scheduler = new TransactionsScheduler() + + // Establish routes + const jsonParser = bodyParser.json() + + // Establish routes. Proxy server strips /pushtx + this.httpServer.app.post( + '/schedule', + jsonParser, + authMgr.checkAuthentication.bind(authMgr), + this.postScheduleTxs.bind(this), + HttpServer.sendAuthError + ) + + this.httpServer.app.post( + '/', + authMgr.checkAuthentication.bind(authMgr), + this.postPushTx.bind(this), + HttpServer.sendAuthError + ) + + this.httpServer.app.get( + '/', + authMgr.checkAuthentication.bind(authMgr), + this.getPushTx.bind(this), + HttpServer.sendAuthError + ) + + this.httpServer.app.get( + `/${keys.prefixes.statusPushtx}/`, + authMgr.checkHasAdminProfile.bind(authMgr), + this.getStatus.bind(this), + HttpServer.sendAuthError + ) + + this.httpServer.app.get( + `/${keys.prefixes.statusPushtx}/schedule`, + authMgr.checkHasAdminProfile.bind(authMgr), + this.getStatusSchedule.bind(this), + HttpServer.sendAuthError + ) + + // Handle unknown paths, returning a help message + this.httpServer.app.get( + '/*', + authMgr.checkAuthentication.bind(authMgr), + this.getHelp.bind(this), + HttpServer.sendAuthError + ) + } + + /** + * Handle Help GET request + * @param {object} req - http request object + * @param {object} res - http response object + */ + getHelp(req, res) { + const ret = {endpoints: ['/pushtx', '/pushtx/schedule']} + HttpServer.sendError(res, ret, 404) + } + + /** + * Handle Status GET request + * @param {object} req - http request object + * @param {object} res - http response object + */ + async getStatus(req, res) { + try { + const currStatus = await status.getCurrent() + HttpServer.sendOkData(res, currStatus) + } catch(e) { + this._traceError(res, e) + } + } + + /** + * Handle status/schedule GET request + * @param {object} req - http request object + * @param {object} res - http response object + */ + async getStatusSchedule(req, res) { + try { + const ret = await status.getScheduledTransactions() + HttpServer.sendOkData(res, ret) + } catch(e) { + this._traceError(res, e) + } + } + + /** + * Handle pushTx GET request + * @param {object} req - http request object + * @param {object} res - http response object + */ + getPushTx(req, res) { + const ret = errors.get.DISALLOWED + HttpServer.sendError(res, ret, 405) + } + + /** + * Handle POST requests + * Push transactions to the Bitcoin network + * @param {object} req - http request object + * @param {object} res - http response object + */ + postPushTx(req, res) { + // Accumulate POST data + const chunks = [] + + req.on('data', chunk => { + chunks.push(chunk) + }) + + req.on('end', async () => { + const body = chunks.join('') + const query = qs.parse(body) + + if (!query.tx) + return this._traceError(res, errors.body.NOTX) + + if (!validator.isHexadecimal(query.tx)) + return this._traceError(res, errors.body.INVDATA) + + try { + const txid = await pushTxProcessor.pushTx(query.tx) + HttpServer.sendOkData(res, txid) + } catch(e) { + this._traceError(res, e) + } + }) + } + + /** + * Schedule a list of transactions + * for delayed pushes + */ + async postScheduleTxs(req, res) { + // Check request arguments + if (!req.body) + return this._traceError(res, errors.body.NODATA) + + if (!req.body.script) + return this._traceError(res, errors.body.NOSCRIPT) + + try { + await this.scheduler.schedule(req.body.script) + HttpServer.sendOk(res) + } catch(e) { + this._traceError(res, e) + } + } + + /** + * Trace an error during push + * @param {object} res - http response object + * @param {object} err - error object + */ + _traceError(res, err) { + let ret = null + + try { + if (err.message) { + let msg = {} + try { + msg = JSON.parse(err.message) + } catch(e) {} + + if (msg.code && msg.message) { + Logger.error(null, 'Error ' + msg.code + ': ' + msg.message) + ret = { + message: msg.message, + code: msg.code + } + } else { + Logger.error(err.message, 'ERROR') + ret = err.message + } + } else { + Logger.error(err, 'ERROR') + ret = err + } + } catch (e) { + Logger.error(e, 'ERROR') + ret = e + } finally { + HttpServer.sendError(res, ret) + } + } + +} + +module.exports = PushTxRestApi diff --git a/pushtx/status.js b/pushtx/status.js new file mode 100644 index 0000000..0d17947 --- /dev/null +++ b/pushtx/status.js @@ -0,0 +1,129 @@ +/*! + * pushtx/status.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const bitcoin = require('bitcoinjs-lib') +const util = require('../lib/util') +const Logger = require('../lib/logger') +const db = require('../lib/db/mysql-db-wrapper') +const network = require('../lib/bitcoin/network') +const keys = require('../keys')[network.key] +const RpcClient = require('../lib/bitcoind-rpc/rpc-client') + + +/** + * Default values for status object + */ +const DEFAULT_STATUS = { + uptime: 0, + memory: 0, + bitcoind: { + up: false, + conn: -1, + blocks: -1, + version: -1, + protocolversion: -1, + relayfee: 0, + testnet: false + }, + push: { + count: 0, + amount: 0 + } +} + +/** + * Singleton providing information about the pushtx service + */ +class Status { + + /** + * Constructor + */ + constructor() { + this.startTime = Date.now() + this.status = JSON.parse(JSON.stringify(DEFAULT_STATUS)) + this.stats = { + amount: 0, + count: 0 + } + this.rpcClient = new RpcClient() + } + + /** + * Update the stats + * @param {Number} amount - amount sent (in BTC) + */ + updateStats(amount) { + this.stats.count++ + this.stats.amount += amount + } + + /** + * Get current status + * @returns {Promise} status object + */ + async getCurrent() { + const mem = process.memoryUsage() + this.status.memory = util.toMb(mem.rss) + + this.status.uptime = +((Date.now() - this.startTime) / 1000).toFixed(1) + + this.status.push.amount = +((this.stats.amount / 1e8).toFixed(3)) + this.status.push.count = this.stats.count + + try { + await this._refreshNetworkInfo() + await this._refreshBlockchainInfo() + } catch (e) { + Logger.error(e, 'Status.getCurrent() : Error') + } finally { + return this.status + } + } + + /** + * Get scheduled transactions + */ + async getScheduledTransactions() { + const ret = { + nbTxs: 0, + txs: [] + } + + try { + ret.txs = await db.getScheduledTransactions() + ret.nbTxs = ret.txs.length + } catch(e) { + // + } finally { + return ret + } + } + + /** + * Refresh network info + */ + async _refreshNetworkInfo() { + const info = await this.rpcClient.getNetworkInfo() + this.status.bitcoind.conn = info.connections + this.status.bitcoind.version = info.version + this.status.bitcoind.protocolversion = info.protocolversion + this.status.bitcoind.relayfee = info.relayfee + } + + /** + * Refresh blockchain info + */ + async _refreshBlockchainInfo() { + const info = await this.rpcClient.getBlockchainInfo() + this.status.bitcoind.blocks = info.blocks + this.status.bitcoind.testnet = (info.chain != 'main') + this.status.bitcoind.up = true + } + +} + +module.exports = new Status() diff --git a/pushtx/transactions-scheduler.js b/pushtx/transactions-scheduler.js new file mode 100644 index 0000000..3ebcfec --- /dev/null +++ b/pushtx/transactions-scheduler.js @@ -0,0 +1,128 @@ +/*! + * pushtx/pushtx-rest-api.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const bitcoin = require('bitcoinjs-lib') +const Logger = require('../lib/logger') +const errors = require('../lib/errors') +const db = require('../lib/db/mysql-db-wrapper') +const network = require('../lib/bitcoin/network') +const keys = require('../keys')[network.key] +const RpcClient = require('../lib/bitcoind-rpc/rpc-client') +const pushTxProcessor = require('./pushtx-processor') + + +/** + * A class scheduling delayed push of transactions + */ +class TransactionsScheduler { + + /** + * Constructor + */ + constructor() { + this.rpcClient = new RpcClient() + } + + /** + * Schedule a set of transactions + * according to a given sequential script + * @param {object} script - scheduling script + */ + async schedule(script) { + try { + // Check script length + if (script.length > keys.txsScheduler.maxNbEntries) + throw errors.body.SCRIPTSIZE + + // Order transactions by increasing hop values and nlocktime + script.sort((a,b) => a.hop - b.hop || a.nlocktime - b.nlocktime) + + // Get the height of last block seen + const info = await this.rpcClient.getBlockchainInfo() + const lastHeight = info.blocks + + // Get the nLockTime associated to the first transaction + const nltTx0 = script[0].nlocktime + + // Check that nltTx0 is in allowed range of blocks + if (nltTx0 > lastHeight + keys.txsScheduler.maxDeltaHeight) + throw errors.pushtx.SCHEDULED_TOO_FAR + + // Compute base height for this script + const baseHeight = Math.max(lastHeight, nltTx0) + + // Iterate over the transactions for a few validations + let lastHopProcessed = -1 + let lastLockTimeProcessed = -1 + + for (let entry of script) { + // Compute delta height (entry.nlocktime - nltTx0) + entry.delta = entry.nlocktime - nltTx0 + // Check that delta is in allowed range + if (entry.delta > keys.txsScheduler.maxDeltaHeight) + throw errors.pushtx.SCHEDULED_TOO_FAR + // Decode the transaction + const tx = bitcoin.Transaction.fromHex(entry.tx) + // Check that nlocktimes are matching + if (!(tx.locktime && tx.locktime == entry.nlocktime)) { + const msg = `TransactionsScheduler.schedule() : nLockTime mismatch : ${tx.locktime} - ${entry.nlocktime}` + Logger.error(null, msg) + throw errors.pushtx.NLOCK_MISMATCH + } + // Check that order of hop and nlocktime values are consistent + if (entry.hop != lastHopProcessed) { + if (entry.nlocktime < lastLockTimeProcessed) + throw errors.pushtx.SCHEDULED_BAD_ORDER + } + lastHopProcessed = entry.hop + lastLockTimeProcessed = entry.nlocktime + // Update scheduled height if needed + if (baseHeight != nltTx0) + entry.nlocktime = baseHeight + entry.delta + } + + let parentTxid = null + let parentNlocktime = baseHeight + + // Check if first transactions should be sent immediately + while ((script.length > 0) && (script[0].nlocktime <= lastHeight) && (script[0].delta == 0)) { + await pushTxProcessor.pushTx(script[0].tx) + const tx = bitcoin.Transaction.fromHex(script[0].tx) + parentTxid = tx.getId() + parentNlocktime = script[0].nlocktime + script.splice(0,1) + } + + // Store others transactions in database + let parentId = null + + for (let entry of script) { + const tx = bitcoin.Transaction.fromHex(entry.tx) + + const objTx = { + txid: tx.getId(), + created: null, + rawTx: entry.tx, + parentId: parentId, + parentTxid: parentTxid, + delay: entry.nlocktime - parentNlocktime, // Store delay relative to previous transaction + trigger: entry.nlocktime + } + + parentId = await db.addScheduledTransaction(objTx) + Logger.info(`Registered scheduled tx ${objTx.txid} (trigger=${objTx.trigger})`) + parentTxid = tx.getId() + parentNlocktime = entry.nlocktime + } + + } catch(e) { + throw e + } + } + +} + +module.exports = TransactionsScheduler diff --git a/restart-example.sh b/restart-example.sh new file mode 100644 index 0000000..7777075 --- /dev/null +++ b/restart-example.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +DIRPATH=$(dirname -- $(readlink -fn -- "$0")) + +forever stop 0 +forever stop 0 +forever stop 0 +forever stop 0 + +cd $DIRPATH/accounts +forever start -a -l forever.log -o output.log -e error.log index.js +cd $DIRPATH/pushtx +forever start -a -l forever.log -o output.log -e error.log index.js +forever start -a -l forever.log -o output.log -e error.log index-orchestrator.js +cd $DIRPATH/tracker +forever start -a -l forever.log -o output.log -e error.log index.js diff --git a/scripts/create-first-blocks.js b/scripts/create-first-blocks.js new file mode 100644 index 0000000..e103420 --- /dev/null +++ b/scripts/create-first-blocks.js @@ -0,0 +1,84 @@ +/*! + * scripts/createfirstblocks.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const bitcoin = require('bitcoinjs-lib') +const Logger = require('../lib/logger') +const util = require('../lib/util') +const db = require('../lib/db/mysql-db-wrapper') +const network = require('../lib/bitcoin/network') +const RpcClient = require('../lib/bitcoind-rpc/rpc-client') +const keys = require('../keys')[network.key] + + +/** + * Script inserting first blocks into the database + * (without a scan of transactions) + */ + +// RPC Client requests data from bitcoind +let client = new RpcClient() + +// Database id of the previous block +let prevID = null; + + +async function processBlock(height) { + Logger.info('Start processing block ' + height) + + const blockHash = await client.getblockhash(height) + + if (blockHash) { + const header = await client.getblockheader(blockHash, true) + + if (header) { + const dbBlock = { + blockHeight: header.height, + blockHash: header.hash, + blockTime: header.time, + blockParent: prevID + } + + prevID = await db.addBlock(dbBlock) + Logger.info('Successfully processed block ' + height) + + } else { + Logger.error(null, 'Unable to retrieve header of block ' + height) + return Promise.reject() + } + + } else { + Logger.error(null, 'Unable to find hash of block ' + height) + return Promise.reject() + } +} + + +async function run(heights) { + return util.seriesCall(heights, processBlock) +} + + +/** + * Launch the script + */ + +// Retrieves command line arguments +if (process.argv.length < 3) { + Logger.error(null, 'Missing arguments. Command = node create-first-blocks.js ') + return +} + +// Create list of integers from 0 to index passed as parameter (included) +const n = parseInt(process.argv[2]) +const heights = Array.from(Array(n).keys()) + +Logger.info('Start processing') + +const startupTimeout = setTimeout(async function() { + return run(heights).then(() => { + Logger.info('Processing completed') + }) +}, 1500) diff --git a/scripts/delete-data-banned-addresses.js b/scripts/delete-data-banned-addresses.js new file mode 100644 index 0000000..e8b3f85 --- /dev/null +++ b/scripts/delete-data-banned-addresses.js @@ -0,0 +1,79 @@ +/*! + * scripts/delete-data-banned-addresses.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const mysql = require('mysql') +const Logger = require('../lib/logger') +const util = require('../lib/util') +const db = require('../lib/db/mysql-db-wrapper') + + +/** + * Script deleting all data related to addresses registered in the ban list + */ + +async function getBannedAddresses() { + const query = mysql.format('SELECT `addrAddress` FROM `banned_addresses`') + return db._query(query) +} + + +async function deleteAddress(address) { + const addr = address.addrAddress + Logger.info('Start deletion of address ' + addr) + const query = mysql.format( + 'DELETE `addresses`.* FROM `addresses` WHERE `addresses`.`addrAddress` = ?', + addr + ) + const ret = await db._query(query) + Logger.info('Completed deletion of address ' + addr) + return ret +} + + +async function getUnlinkedTransactions() { + const query = mysql.format( + 'SELECT `transactions`.`txnTxid` \ + FROM `transactions` \ + WHERE `transactions`.`txnID` NOT IN (SELECT `outputs`.`txnID` FROM `outputs`) \ + AND `transactions`.`txnID` NOT IN (SELECT `inputs`.`txnID` FROM `inputs`)' + ) + return db._query(query) +} + + +async function deleteTransaction(tx) { + const txid = tx.txnTxid + Logger.info('Start deletion of transaction ' + txid) + await db.deleteTransaction(txid) + Logger.info('Completed deletion of transaction ' + txid) +} + + +async function run() { + // Get a list of banned addresses + const addresses = await getBannedAddresses() + // Delete addresses, outputs, inputs + // related to a banned address + await util.seriesCall(addresses, deleteAddress) + // Get a list of unlinked transactions + const txs = await getUnlinkedTransactions() + // Deletes the transactions + await util.seriesCall(txs, deleteTransaction) +} + + +/** + * Launch the script + */ + +Logger.info('Start processing') + +const startupTimeout = setTimeout(async function() { + return run().then(() => { + Logger.info('Processing completed') + }) +}, 1500) + diff --git a/scripts/generate-passphrase.js b/scripts/generate-passphrase.js new file mode 100644 index 0000000..feb821f --- /dev/null +++ b/scripts/generate-passphrase.js @@ -0,0 +1,25 @@ +/*! + * scripts/generate_passphrase.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const bip39 = require('bip39') + + +/** + * Script generating a strong random passphrase (128-bits of entropy) + * Useful for the generation of a strong api key or oa strong jwt secret (see /keys/index-example.js). + */ + +function run() { + const mnemonic = bip39.generateMnemonic() + console.log(`Generated passphrase = ${mnemonic}`) +} + + +/** + * Launch the script + */ +run() + diff --git a/scripts/import-hd-accounts.js b/scripts/import-hd-accounts.js new file mode 100644 index 0000000..2731ad4 --- /dev/null +++ b/scripts/import-hd-accounts.js @@ -0,0 +1,58 @@ +/*! + * scripts/import-hd-accounts.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const util = require('../lib/util') +const Logger = require('../lib/logger') +const db = require('../lib/db/mysql-db-wrapper') +const hdaHelper = require('../lib/bitcoin/hd-accounts-helper') +const hdaService = require('../lib/bitcoin/hd-accounts-service') +const apiHelper = require('../accounts/api-helper') + + +/** + * Script importing a list of hdaccounts (xpubs, ypubs, zpubs) into the database + * Used to declare the xpub, ypub, zpub into the database before the initial setup + */ + +async function run(strEntities) { + const entities = apiHelper.parseEntities(strEntities) + + if (entities.xpubs.length > 0) { + for (let i = 0; i < entities.xpubs.length; i++) { + const xpub = entities.xpubs[i] + + let scheme = hdaHelper.BIP44 + + if (entities.ypubs[i]) + scheme = hdaHelper.BIP49 + else if (entities.zpubs[i]) + scheme = hdaHelper.BIP84 + + await hdaService.createHdAccount(xpub, scheme) + } + } +} + + +/** + * Launch the script + */ + +// Retrieves command line arguments +if (process.argv.length < 3) { + Logger.error(null, 'Missing arguments. Command = import-hd-accounts.js |||...') + return +} + +Logger.info('Start processing') + +const entities = process.argv[2] + +const startupTimeout = setTimeout(async function() { + return run(entities).then(() => { + Logger.info('Process completed') + }) +}, 1500) diff --git a/scripts/patches/revert-hd-accounts.js b/scripts/patches/revert-hd-accounts.js new file mode 100644 index 0000000..efdd3de --- /dev/null +++ b/scripts/patches/revert-hd-accounts.js @@ -0,0 +1,105 @@ +/*! + * scripts/patches/translate-hd-accounts.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const mysql = require('mysql') +const bitcoin = require('bitcoinjs-lib') +const bs58check = require('bs58check') +const bs58 = require('bs58') +const db = require('../../lib/db/mysql-db-wrapper') +const hdaHelper = require('../../lib/bitcoin/hd-accounts-helper') + + +/** + * Translate a ypub or zpub into a xpub + */ +function xlatXPUB(xpub) { + const decoded = bs58check.decode(xpub) + const ver = decoded.readInt32BE() + + let xlatVer = 0 + + if (ver == hdaHelper.MAGIC_XPUB || ver == hdaHelper.MAGIC_YPUB || ver == hdaHelper.MAGIC_ZPUB) { + xlatVer = hdaHelper.MAGIC_XPUB + } else if (ver == hdaHelper.MAGIC_TPUB || ver == hdaHelper.MAGIC_UPUB || ver == hdaHelper.MAGIC_VPUB) { + xlatVer = hdaHelper.MAGIC_TPUB + } + + let b = Buffer.alloc(4) + b.writeInt32BE(xlatVer) + + decoded.writeInt32BE(xlatVer, 0) + + const checksum = bitcoin.crypto.hash256(decoded).slice(0, 4) + const xlatXpub = Buffer.alloc(decoded.length + checksum.length) + + decoded.copy(xlatXpub, 0, 0, decoded.length) + + checksum.copy(xlatXpub, xlatXpub.length - 4, 0, checksum.length) + + const encoded = bs58.encode(xlatXpub) + return encoded +} + +/** + * Retrieve hd accounts from db + */ +async function getHdAccounts() { + const sqlQuery = 'SELECT `hdID`, `hdXpub`, `hdType` FROM `hd`' + const query = mysql.format(sqlQuery) + return db._query(query) +} + +/** + * Update the xpub of a hdaccount + */ +async function updateHdAccount(hdId, xpub) { + const sqlQuery = 'UPDATE `hd` SET `hdXpub` = ? WHERE `hdID` = ?' + const params = [xpub, hdId] + const query = mysql.format(sqlQuery, params) + return db._query(query) +} + +/** + * Script translating when needed + * xpubs stored in db into ypub and zpub + */ +async function run() { + try { + const hdAccounts = await getHdAccounts() + + for (let account of hdAccounts) { + const hdId = account.hdID + const xpub = account.hdXpub + const info = hdaHelper.classify(account.hdType) + const scheme = info.type + + if ((scheme == hdaHelper.BIP49) || (scheme == hdaHelper.BIP84)) { + try { + const xlatedXpub = xlatXPUB(xpub) + await updateHdAccount(hdId, xlatedXpub) + console.log(`Updated ${hdId} (${xpub} => ${xlatedXpub})`) + } catch(e) { + console.log('A problem was met') + console.log(e) + } + } + } + } catch(e) { + console.log('A problem was met') + console.log(e) + } +} + +/** + * Launch the script + */ +console.log('Start processing') + +const startupTimeout = setTimeout(async function() { + return run().then(() => { + console.log('Process completed') + }) +}, 1500) \ No newline at end of file diff --git a/scripts/patches/translate-hd-accounts.js b/scripts/patches/translate-hd-accounts.js new file mode 100644 index 0000000..b1891c2 --- /dev/null +++ b/scripts/patches/translate-hd-accounts.js @@ -0,0 +1,109 @@ +/*! + * scripts/patches/translate-hd-accounts.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const mysql = require('mysql') +const bitcoin = require('bitcoinjs-lib') +const bs58check = require('bs58check') +const bs58 = require('bs58') +const db = require('../../lib/db/mysql-db-wrapper') +const hdaHelper = require('../../lib/bitcoin/hd-accounts-helper') + + +/** + * Translate a xpub into a ypub or zpub + */ +function xlatXPUB(xpub, targetType) { + const decoded = bs58check.decode(xpub) + const ver = decoded.readInt32BE() + + let xlatVer = 0 + + if (ver == hdaHelper.MAGIC_XPUB) { + + if (targetType == hdaHelper.BIP49) + xlatVer = hdaHelper.MAGIC_YPUB + else if (targetType == hdaHelper.BIP84) + xlatVer = hdaHelper.MAGIC_ZPUB + + } else if (ver == hdaHelper.MAGIC_TPUB) { + + if (targetType == hdaHelper.BIP49) + xlatVer = hdaHelper.MAGIC_UPUB + else if (targetType == hdaHelper.BIP84) + xlatVer = hdaHelper.MAGIC_VPUB + } + + let b = Buffer.alloc(4) + b.writeInt32BE(xlatVer) + + decoded.writeInt32BE(xlatVer, 0) + + const checksum = bitcoin.crypto.hash256(decoded).slice(0, 4) + const xlatXpub = Buffer.alloc(decoded.length + checksum.length) + + decoded.copy(xlatXpub, 0, 0, decoded.length) + + checksum.copy(xlatXpub, xlatXpub.length - 4, 0, checksum.length) + + const encoded = bs58.encode(xlatXpub) + return encoded +} + +/** + * Retrieve hd accounts from db + */ +async function getHdAccounts() { + const sqlQuery = 'SELECT `hdID`, `hdXpub`, `hdType` FROM `hd`' + const query = mysql.format(sqlQuery) + return db._query(query) +} + +/** + * Update the xpub of a hdaccount + */ +async function updateHdAccount(hdId, xpub) { + const sqlQuery = 'UPDATE `hd` SET `hdXpub` = ? WHERE `hdID` = ?' + const params = [xpub, hdId] + const query = mysql.format(sqlQuery, params) + return db._query(query) +} + +/** + * Script translating when needed + * xpubs stored in db into ypub and zpub + */ +async function run() { + try { + const hdAccounts = await getHdAccounts() + + for (let account of hdAccounts) { + const hdId = account.hdID + const xpub = account.hdXpub + const info = hdaHelper.classify(account.hdType) + const scheme = info.type + + if ((scheme == hdaHelper.BIP49) || (scheme == hdaHelper.BIP84)) { + const xlatedXpub = xlatXPUB(xpub, scheme) + await updateHdAccount(hdId, xlatedXpub) + console.log(`Updated ${hdId} (${xpub} => ${xlatedXpub})`) + } + } + } catch(e) { + console.log('A problem was met') + console.log(e) + } +} + +/** + * Launch the script + */ +console.log('Start processing') + +const startupTimeout = setTimeout(async function() { + return run().then(() => { + console.log('Process completed') + }) +}, 1500) \ No newline at end of file diff --git a/scripts/rescan-blocks.js b/scripts/rescan-blocks.js new file mode 100644 index 0000000..db00ea5 --- /dev/null +++ b/scripts/rescan-blocks.js @@ -0,0 +1,42 @@ +/*! + * scripts/tracker.index.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const Logger = require('../lib/logger') +const BlockchainProcessor = require('../tracker/blockchain-processor') + + +/** + * Script executing a rescan of the chain from a given block + */ + +async function run(height) { + const processor = new BlockchainProcessor() + // Rewind the chain + await processor.rewind(height - 1) + // Catchup + await processor.catchup() +} + + +/** + * Launch the script + */ + +// Retrieves command line arguments +if (process.argv.length < 3) { + Logger.error(null, 'Missing arguments. Command = node rescan-blocks.js ') + return +} + +Logger.info('Start processing') + +const height = parseInt(process.argv[2]) + +const startupTimeout = setTimeout(async function() { + return run(height).then(() => { + Logger.info('Process completed') + }) +}, 1500) diff --git a/static/admin/conf/index.js b/static/admin/conf/index.js new file mode 100644 index 0000000..5284553 --- /dev/null +++ b/static/admin/conf/index.js @@ -0,0 +1,25 @@ +var conf = { + + // Admin tool + adminTool: { + baseUri: '/admin' + //baseUri: '/static/admin' + }, + + // API + api: { + baseUri: '/v2' + //baseUri: '' + }, + + // Url prefixes + prefixes: { + // Prefix for /support endpoint + support: 'support', + // Prefix for /status endpoint + status: 'status', + // Prefix for pushtx /status endpoint + statusPushtx: 'status' + } + +}; \ No newline at end of file diff --git a/static/admin/css/bootstrap-theme.css b/static/admin/css/bootstrap-theme.css new file mode 100644 index 0000000..31d8882 --- /dev/null +++ b/static/admin/css/bootstrap-theme.css @@ -0,0 +1,587 @@ +/*! + * Bootstrap v3.3.7 (http://getbootstrap.com) + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ +.btn-default, +.btn-primary, +.btn-success, +.btn-info, +.btn-warning, +.btn-danger { + text-shadow: 0 -1px 0 rgba(0, 0, 0, .2); + -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075); +} +.btn-default:active, +.btn-primary:active, +.btn-success:active, +.btn-info:active, +.btn-warning:active, +.btn-danger:active, +.btn-default.active, +.btn-primary.active, +.btn-success.active, +.btn-info.active, +.btn-warning.active, +.btn-danger.active { + -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); + box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); +} +.btn-default.disabled, +.btn-primary.disabled, +.btn-success.disabled, +.btn-info.disabled, +.btn-warning.disabled, +.btn-danger.disabled, +.btn-default[disabled], +.btn-primary[disabled], +.btn-success[disabled], +.btn-info[disabled], +.btn-warning[disabled], +.btn-danger[disabled], +fieldset[disabled] .btn-default, +fieldset[disabled] .btn-primary, +fieldset[disabled] .btn-success, +fieldset[disabled] .btn-info, +fieldset[disabled] .btn-warning, +fieldset[disabled] .btn-danger { + -webkit-box-shadow: none; + box-shadow: none; +} +.btn-default .badge, +.btn-primary .badge, +.btn-success .badge, +.btn-info .badge, +.btn-warning .badge, +.btn-danger .badge { + text-shadow: none; +} +.btn:active, +.btn.active { + background-image: none; +} +.btn-default { + text-shadow: 0 1px 0 #fff; + background-image: -webkit-linear-gradient(top, #fff 0%, #e0e0e0 100%); + background-image: -o-linear-gradient(top, #fff 0%, #e0e0e0 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#e0e0e0)); + background-image: linear-gradient(to bottom, #fff 0%, #e0e0e0 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + background-repeat: repeat-x; + border-color: #dbdbdb; + border-color: #ccc; +} +.btn-default:hover, +.btn-default:focus { + background-color: #e0e0e0; + background-position: 0 -15px; +} +.btn-default:active, +.btn-default.active { + background-color: #e0e0e0; + border-color: #dbdbdb; +} +.btn-default.disabled, +.btn-default[disabled], +fieldset[disabled] .btn-default, +.btn-default.disabled:hover, +.btn-default[disabled]:hover, +fieldset[disabled] .btn-default:hover, +.btn-default.disabled:focus, +.btn-default[disabled]:focus, +fieldset[disabled] .btn-default:focus, +.btn-default.disabled.focus, +.btn-default[disabled].focus, +fieldset[disabled] .btn-default.focus, +.btn-default.disabled:active, +.btn-default[disabled]:active, +fieldset[disabled] .btn-default:active, +.btn-default.disabled.active, +.btn-default[disabled].active, +fieldset[disabled] .btn-default.active { + background-color: #e0e0e0; + background-image: none; +} +.btn-primary { + background-image: -webkit-linear-gradient(top, #337ab7 0%, #265a88 100%); + background-image: -o-linear-gradient(top, #337ab7 0%, #265a88 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#265a88)); + background-image: linear-gradient(to bottom, #337ab7 0%, #265a88 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + background-repeat: repeat-x; + border-color: #245580; +} +.btn-primary:hover, +.btn-primary:focus { + background-color: #265a88; + background-position: 0 -15px; +} +.btn-primary:active, +.btn-primary.active { + background-color: #265a88; + border-color: #245580; +} +.btn-primary.disabled, +.btn-primary[disabled], +fieldset[disabled] .btn-primary, +.btn-primary.disabled:hover, +.btn-primary[disabled]:hover, +fieldset[disabled] .btn-primary:hover, +.btn-primary.disabled:focus, +.btn-primary[disabled]:focus, +fieldset[disabled] .btn-primary:focus, +.btn-primary.disabled.focus, +.btn-primary[disabled].focus, +fieldset[disabled] .btn-primary.focus, +.btn-primary.disabled:active, +.btn-primary[disabled]:active, +fieldset[disabled] .btn-primary:active, +.btn-primary.disabled.active, +.btn-primary[disabled].active, +fieldset[disabled] .btn-primary.active { + background-color: #265a88; + background-image: none; +} +.btn-success { + background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%); + background-image: -o-linear-gradient(top, #5cb85c 0%, #419641 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#419641)); + background-image: linear-gradient(to bottom, #5cb85c 0%, #419641 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + background-repeat: repeat-x; + border-color: #3e8f3e; +} +.btn-success:hover, +.btn-success:focus { + background-color: #419641; + background-position: 0 -15px; +} +.btn-success:active, +.btn-success.active { + background-color: #419641; + border-color: #3e8f3e; +} +.btn-success.disabled, +.btn-success[disabled], +fieldset[disabled] .btn-success, +.btn-success.disabled:hover, +.btn-success[disabled]:hover, +fieldset[disabled] .btn-success:hover, +.btn-success.disabled:focus, +.btn-success[disabled]:focus, +fieldset[disabled] .btn-success:focus, +.btn-success.disabled.focus, +.btn-success[disabled].focus, +fieldset[disabled] .btn-success.focus, +.btn-success.disabled:active, +.btn-success[disabled]:active, +fieldset[disabled] .btn-success:active, +.btn-success.disabled.active, +.btn-success[disabled].active, +fieldset[disabled] .btn-success.active { + background-color: #419641; + background-image: none; +} +.btn-info { + background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%); + background-image: -o-linear-gradient(top, #5bc0de 0%, #2aabd2 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#2aabd2)); + background-image: linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + background-repeat: repeat-x; + border-color: #28a4c9; +} +.btn-info:hover, +.btn-info:focus { + background-color: #2aabd2; + background-position: 0 -15px; +} +.btn-info:active, +.btn-info.active { + background-color: #2aabd2; + border-color: #28a4c9; +} +.btn-info.disabled, +.btn-info[disabled], +fieldset[disabled] .btn-info, +.btn-info.disabled:hover, +.btn-info[disabled]:hover, +fieldset[disabled] .btn-info:hover, +.btn-info.disabled:focus, +.btn-info[disabled]:focus, +fieldset[disabled] .btn-info:focus, +.btn-info.disabled.focus, +.btn-info[disabled].focus, +fieldset[disabled] .btn-info.focus, +.btn-info.disabled:active, +.btn-info[disabled]:active, +fieldset[disabled] .btn-info:active, +.btn-info.disabled.active, +.btn-info[disabled].active, +fieldset[disabled] .btn-info.active { + background-color: #2aabd2; + background-image: none; +} +.btn-warning { + background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%); + background-image: -o-linear-gradient(top, #f0ad4e 0%, #eb9316 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#eb9316)); + background-image: linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + background-repeat: repeat-x; + border-color: #e38d13; +} +.btn-warning:hover, +.btn-warning:focus { + background-color: #eb9316; + background-position: 0 -15px; +} +.btn-warning:active, +.btn-warning.active { + background-color: #eb9316; + border-color: #e38d13; +} +.btn-warning.disabled, +.btn-warning[disabled], +fieldset[disabled] .btn-warning, +.btn-warning.disabled:hover, +.btn-warning[disabled]:hover, +fieldset[disabled] .btn-warning:hover, +.btn-warning.disabled:focus, +.btn-warning[disabled]:focus, +fieldset[disabled] .btn-warning:focus, +.btn-warning.disabled.focus, +.btn-warning[disabled].focus, +fieldset[disabled] .btn-warning.focus, +.btn-warning.disabled:active, +.btn-warning[disabled]:active, +fieldset[disabled] .btn-warning:active, +.btn-warning.disabled.active, +.btn-warning[disabled].active, +fieldset[disabled] .btn-warning.active { + background-color: #eb9316; + background-image: none; +} +.btn-danger { + background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%); + background-image: -o-linear-gradient(top, #d9534f 0%, #c12e2a 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c12e2a)); + background-image: linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + background-repeat: repeat-x; + border-color: #b92c28; +} +.btn-danger:hover, +.btn-danger:focus { + background-color: #c12e2a; + background-position: 0 -15px; +} +.btn-danger:active, +.btn-danger.active { + background-color: #c12e2a; + border-color: #b92c28; +} +.btn-danger.disabled, +.btn-danger[disabled], +fieldset[disabled] .btn-danger, +.btn-danger.disabled:hover, +.btn-danger[disabled]:hover, +fieldset[disabled] .btn-danger:hover, +.btn-danger.disabled:focus, +.btn-danger[disabled]:focus, +fieldset[disabled] .btn-danger:focus, +.btn-danger.disabled.focus, +.btn-danger[disabled].focus, +fieldset[disabled] .btn-danger.focus, +.btn-danger.disabled:active, +.btn-danger[disabled]:active, +fieldset[disabled] .btn-danger:active, +.btn-danger.disabled.active, +.btn-danger[disabled].active, +fieldset[disabled] .btn-danger.active { + background-color: #c12e2a; + background-image: none; +} +.thumbnail, +.img-thumbnail { + -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075); + box-shadow: 0 1px 2px rgba(0, 0, 0, .075); +} +.dropdown-menu > li > a:hover, +.dropdown-menu > li > a:focus { + background-color: #e8e8e8; + background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); + background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8)); + background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0); + background-repeat: repeat-x; +} +.dropdown-menu > .active > a, +.dropdown-menu > .active > a:hover, +.dropdown-menu > .active > a:focus { + background-color: #2e6da4; + background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%); + background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4)); + background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0); + background-repeat: repeat-x; +} +.navbar-default { + background-image: -webkit-linear-gradient(top, #fff 0%, #f8f8f8 100%); + background-image: -o-linear-gradient(top, #fff 0%, #f8f8f8 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#f8f8f8)); + background-image: linear-gradient(to bottom, #fff 0%, #f8f8f8 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + background-repeat: repeat-x; + border-radius: 4px; + -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075); +} +.navbar-default .navbar-nav > .open > a, +.navbar-default .navbar-nav > .active > a { + background-image: -webkit-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%); + background-image: -o-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#dbdbdb), to(#e2e2e2)); + background-image: linear-gradient(to bottom, #dbdbdb 0%, #e2e2e2 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0); + background-repeat: repeat-x; + -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075); + box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075); +} +.navbar-brand, +.navbar-nav > li > a { + text-shadow: 0 1px 0 rgba(255, 255, 255, .25); +} +.navbar-inverse { + background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222 100%); + background-image: -o-linear-gradient(top, #3c3c3c 0%, #222 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#3c3c3c), to(#222)); + background-image: linear-gradient(to bottom, #3c3c3c 0%, #222 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + background-repeat: repeat-x; + border-radius: 4px; +} +.navbar-inverse .navbar-nav > .open > a, +.navbar-inverse .navbar-nav > .active > a { + background-image: -webkit-linear-gradient(top, #080808 0%, #0f0f0f 100%); + background-image: -o-linear-gradient(top, #080808 0%, #0f0f0f 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#080808), to(#0f0f0f)); + background-image: linear-gradient(to bottom, #080808 0%, #0f0f0f 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0); + background-repeat: repeat-x; + -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25); + box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25); +} +.navbar-inverse .navbar-brand, +.navbar-inverse .navbar-nav > li > a { + text-shadow: 0 -1px 0 rgba(0, 0, 0, .25); +} +.navbar-static-top, +.navbar-fixed-top, +.navbar-fixed-bottom { + border-radius: 0; +} +@media (max-width: 767px) { + .navbar .navbar-nav .open .dropdown-menu > .active > a, + .navbar .navbar-nav .open .dropdown-menu > .active > a:hover, + .navbar .navbar-nav .open .dropdown-menu > .active > a:focus { + color: #fff; + background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%); + background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4)); + background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0); + background-repeat: repeat-x; + } +} +.alert { + text-shadow: 0 1px 0 rgba(255, 255, 255, .2); + -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05); +} +.alert-success { + background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%); + background-image: -o-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#c8e5bc)); + background-image: linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0); + background-repeat: repeat-x; + border-color: #b2dba1; +} +.alert-info { + background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%); + background-image: -o-linear-gradient(top, #d9edf7 0%, #b9def0 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#b9def0)); + background-image: linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0); + background-repeat: repeat-x; + border-color: #9acfea; +} +.alert-warning { + background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%); + background-image: -o-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#f8efc0)); + background-image: linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0); + background-repeat: repeat-x; + border-color: #f5e79e; +} +.alert-danger { + background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%); + background-image: -o-linear-gradient(top, #f2dede 0%, #e7c3c3 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#e7c3c3)); + background-image: linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0); + background-repeat: repeat-x; + border-color: #dca7a7; +} +.progress { + background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%); + background-image: -o-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#ebebeb), to(#f5f5f5)); + background-image: linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0); + background-repeat: repeat-x; +} +.progress-bar { + background-image: -webkit-linear-gradient(top, #337ab7 0%, #286090 100%); + background-image: -o-linear-gradient(top, #337ab7 0%, #286090 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#286090)); + background-image: linear-gradient(to bottom, #337ab7 0%, #286090 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0); + background-repeat: repeat-x; +} +.progress-bar-success { + background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%); + background-image: -o-linear-gradient(top, #5cb85c 0%, #449d44 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#449d44)); + background-image: linear-gradient(to bottom, #5cb85c 0%, #449d44 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0); + background-repeat: repeat-x; +} +.progress-bar-info { + background-image: -webkit-linear-gradient(top, #5bc0de 0%, #31b0d5 100%); + background-image: -o-linear-gradient(top, #5bc0de 0%, #31b0d5 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#31b0d5)); + background-image: linear-gradient(to bottom, #5bc0de 0%, #31b0d5 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0); + background-repeat: repeat-x; +} +.progress-bar-warning { + background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #ec971f 100%); + background-image: -o-linear-gradient(top, #f0ad4e 0%, #ec971f 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#ec971f)); + background-image: linear-gradient(to bottom, #f0ad4e 0%, #ec971f 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0); + background-repeat: repeat-x; +} +.progress-bar-danger { + background-image: -webkit-linear-gradient(top, #d9534f 0%, #c9302c 100%); + background-image: -o-linear-gradient(top, #d9534f 0%, #c9302c 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c9302c)); + background-image: linear-gradient(to bottom, #d9534f 0%, #c9302c 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0); + background-repeat: repeat-x; +} +.progress-bar-striped { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); +} +.list-group { + border-radius: 4px; + -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075); + box-shadow: 0 1px 2px rgba(0, 0, 0, .075); +} +.list-group-item.active, +.list-group-item.active:hover, +.list-group-item.active:focus { + text-shadow: 0 -1px 0 #286090; + background-image: -webkit-linear-gradient(top, #337ab7 0%, #2b669a 100%); + background-image: -o-linear-gradient(top, #337ab7 0%, #2b669a 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2b669a)); + background-image: linear-gradient(to bottom, #337ab7 0%, #2b669a 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0); + background-repeat: repeat-x; + border-color: #2b669a; +} +.list-group-item.active .badge, +.list-group-item.active:hover .badge, +.list-group-item.active:focus .badge { + text-shadow: none; +} +.panel { + -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .05); + box-shadow: 0 1px 2px rgba(0, 0, 0, .05); +} +.panel-default > .panel-heading { + background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); + background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8)); + background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0); + background-repeat: repeat-x; +} +.panel-primary > .panel-heading { + background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%); + background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4)); + background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0); + background-repeat: repeat-x; +} +.panel-success > .panel-heading { + background-image: -webkit-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%); + background-image: -o-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#d0e9c6)); + background-image: linear-gradient(to bottom, #dff0d8 0%, #d0e9c6 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0); + background-repeat: repeat-x; +} +.panel-info > .panel-heading { + background-image: -webkit-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%); + background-image: -o-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#c4e3f3)); + background-image: linear-gradient(to bottom, #d9edf7 0%, #c4e3f3 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0); + background-repeat: repeat-x; +} +.panel-warning > .panel-heading { + background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%); + background-image: -o-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#faf2cc)); + background-image: linear-gradient(to bottom, #fcf8e3 0%, #faf2cc 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0); + background-repeat: repeat-x; +} +.panel-danger > .panel-heading { + background-image: -webkit-linear-gradient(top, #f2dede 0%, #ebcccc 100%); + background-image: -o-linear-gradient(top, #f2dede 0%, #ebcccc 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#ebcccc)); + background-image: linear-gradient(to bottom, #f2dede 0%, #ebcccc 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0); + background-repeat: repeat-x; +} +.well { + background-image: -webkit-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%); + background-image: -o-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#e8e8e8), to(#f5f5f5)); + background-image: linear-gradient(to bottom, #e8e8e8 0%, #f5f5f5 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0); + background-repeat: repeat-x; + border-color: #dcdcdc; + -webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1); + box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1); +} +/*# sourceMappingURL=bootstrap-theme.css.map */ diff --git a/static/admin/css/bootstrap-theme.min.css b/static/admin/css/bootstrap-theme.min.css new file mode 100644 index 0000000..5e39401 --- /dev/null +++ b/static/admin/css/bootstrap-theme.min.css @@ -0,0 +1,6 @@ +/*! + * Bootstrap v3.3.7 (http://getbootstrap.com) + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */.btn-danger,.btn-default,.btn-info,.btn-primary,.btn-success,.btn-warning{text-shadow:0 -1px 0 rgba(0,0,0,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075)}.btn-danger.active,.btn-danger:active,.btn-default.active,.btn-default:active,.btn-info.active,.btn-info:active,.btn-primary.active,.btn-primary:active,.btn-success.active,.btn-success:active,.btn-warning.active,.btn-warning:active{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-danger.disabled,.btn-danger[disabled],.btn-default.disabled,.btn-default[disabled],.btn-info.disabled,.btn-info[disabled],.btn-primary.disabled,.btn-primary[disabled],.btn-success.disabled,.btn-success[disabled],.btn-warning.disabled,.btn-warning[disabled],fieldset[disabled] .btn-danger,fieldset[disabled] .btn-default,fieldset[disabled] .btn-info,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-success,fieldset[disabled] .btn-warning{-webkit-box-shadow:none;box-shadow:none}.btn-danger .badge,.btn-default .badge,.btn-info .badge,.btn-primary .badge,.btn-success .badge,.btn-warning .badge{text-shadow:none}.btn.active,.btn:active{background-image:none}.btn-default{text-shadow:0 1px 0 #fff;background-image:-webkit-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-o-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#e0e0e0));background-image:linear-gradient(to bottom,#fff 0,#e0e0e0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#dbdbdb;border-color:#ccc}.btn-default:focus,.btn-default:hover{background-color:#e0e0e0;background-position:0 -15px}.btn-default.active,.btn-default:active{background-color:#e0e0e0;border-color:#dbdbdb}.btn-default.disabled,.btn-default.disabled.active,.btn-default.disabled.focus,.btn-default.disabled:active,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled],.btn-default[disabled].active,.btn-default[disabled].focus,.btn-default[disabled]:active,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default,fieldset[disabled] .btn-default.active,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:active,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#e0e0e0;background-image:none}.btn-primary{background-image:-webkit-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-o-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#265a88));background-image:linear-gradient(to bottom,#337ab7 0,#265a88 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#245580}.btn-primary:focus,.btn-primary:hover{background-color:#265a88;background-position:0 -15px}.btn-primary.active,.btn-primary:active{background-color:#265a88;border-color:#245580}.btn-primary.disabled,.btn-primary.disabled.active,.btn-primary.disabled.focus,.btn-primary.disabled:active,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled],.btn-primary[disabled].active,.btn-primary[disabled].focus,.btn-primary[disabled]:active,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-primary.active,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:active,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#265a88;background-image:none}.btn-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#419641));background-image:linear-gradient(to bottom,#5cb85c 0,#419641 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#3e8f3e}.btn-success:focus,.btn-success:hover{background-color:#419641;background-position:0 -15px}.btn-success.active,.btn-success:active{background-color:#419641;border-color:#3e8f3e}.btn-success.disabled,.btn-success.disabled.active,.btn-success.disabled.focus,.btn-success.disabled:active,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled],.btn-success[disabled].active,.btn-success[disabled].focus,.btn-success[disabled]:active,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success,fieldset[disabled] .btn-success.active,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:active,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#419641;background-image:none}.btn-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#2aabd2));background-image:linear-gradient(to bottom,#5bc0de 0,#2aabd2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#28a4c9}.btn-info:focus,.btn-info:hover{background-color:#2aabd2;background-position:0 -15px}.btn-info.active,.btn-info:active{background-color:#2aabd2;border-color:#28a4c9}.btn-info.disabled,.btn-info.disabled.active,.btn-info.disabled.focus,.btn-info.disabled:active,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled],.btn-info[disabled].active,.btn-info[disabled].focus,.btn-info[disabled]:active,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info,fieldset[disabled] .btn-info.active,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:active,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#2aabd2;background-image:none}.btn-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#eb9316));background-image:linear-gradient(to bottom,#f0ad4e 0,#eb9316 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#e38d13}.btn-warning:focus,.btn-warning:hover{background-color:#eb9316;background-position:0 -15px}.btn-warning.active,.btn-warning:active{background-color:#eb9316;border-color:#e38d13}.btn-warning.disabled,.btn-warning.disabled.active,.btn-warning.disabled.focus,.btn-warning.disabled:active,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled],.btn-warning[disabled].active,.btn-warning[disabled].focus,.btn-warning[disabled]:active,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning,fieldset[disabled] .btn-warning.active,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:active,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#eb9316;background-image:none}.btn-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c12e2a));background-image:linear-gradient(to bottom,#d9534f 0,#c12e2a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#b92c28}.btn-danger:focus,.btn-danger:hover{background-color:#c12e2a;background-position:0 -15px}.btn-danger.active,.btn-danger:active{background-color:#c12e2a;border-color:#b92c28}.btn-danger.disabled,.btn-danger.disabled.active,.btn-danger.disabled.focus,.btn-danger.disabled:active,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled],.btn-danger[disabled].active,.btn-danger[disabled].focus,.btn-danger[disabled]:active,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger,fieldset[disabled] .btn-danger.active,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:active,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#c12e2a;background-image:none}.img-thumbnail,.thumbnail{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{background-color:#e8e8e8;background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{background-color:#2e6da4;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.navbar-default{background-image:-webkit-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-o-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#f8f8f8));background-image:linear-gradient(to bottom,#fff 0,#f8f8f8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075)}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-o-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dbdbdb),to(#e2e2e2));background-image:linear-gradient(to bottom,#dbdbdb 0,#e2e2e2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.075);box-shadow:inset 0 3px 9px rgba(0,0,0,.075)}.navbar-brand,.navbar-nav>li>a{text-shadow:0 1px 0 rgba(255,255,255,.25)}.navbar-inverse{background-image:-webkit-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-o-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#3c3c3c),to(#222));background-image:linear-gradient(to bottom,#3c3c3c 0,#222 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-radius:4px}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-o-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#080808),to(#0f0f0f));background-image:linear-gradient(to bottom,#080808 0,#0f0f0f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.25);box-shadow:inset 0 3px 9px rgba(0,0,0,.25)}.navbar-inverse .navbar-brand,.navbar-inverse .navbar-nav>li>a{text-shadow:0 -1px 0 rgba(0,0,0,.25)}.navbar-fixed-bottom,.navbar-fixed-top,.navbar-static-top{border-radius:0}@media (max-width:767px){.navbar .navbar-nav .open .dropdown-menu>.active>a,.navbar .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}}.alert{text-shadow:0 1px 0 rgba(255,255,255,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05);box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05)}.alert-success{background-image:-webkit-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#c8e5bc));background-image:linear-gradient(to bottom,#dff0d8 0,#c8e5bc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);background-repeat:repeat-x;border-color:#b2dba1}.alert-info{background-image:-webkit-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#b9def0));background-image:linear-gradient(to bottom,#d9edf7 0,#b9def0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);background-repeat:repeat-x;border-color:#9acfea}.alert-warning{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#f8efc0));background-image:linear-gradient(to bottom,#fcf8e3 0,#f8efc0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);background-repeat:repeat-x;border-color:#f5e79e}.alert-danger{background-image:-webkit-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-o-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#e7c3c3));background-image:linear-gradient(to bottom,#f2dede 0,#e7c3c3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);background-repeat:repeat-x;border-color:#dca7a7}.progress{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#ebebeb),to(#f5f5f5));background-image:linear-gradient(to bottom,#ebebeb 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x}.progress-bar{background-image:-webkit-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-o-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#286090));background-image:linear-gradient(to bottom,#337ab7 0,#286090 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);background-repeat:repeat-x}.progress-bar-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#449d44));background-image:linear-gradient(to bottom,#5cb85c 0,#449d44 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);background-repeat:repeat-x}.progress-bar-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#31b0d5));background-image:linear-gradient(to bottom,#5bc0de 0,#31b0d5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);background-repeat:repeat-x}.progress-bar-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#ec971f));background-image:linear-gradient(to bottom,#f0ad4e 0,#ec971f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);background-repeat:repeat-x}.progress-bar-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c9302c));background-image:linear-gradient(to bottom,#d9534f 0,#c9302c 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);background-repeat:repeat-x}.progress-bar-striped{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.list-group{border-radius:4px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{text-shadow:0 -1px 0 #286090;background-image:-webkit-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2b669a));background-image:linear-gradient(to bottom,#337ab7 0,#2b669a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);background-repeat:repeat-x;border-color:#2b669a}.list-group-item.active .badge,.list-group-item.active:focus .badge,.list-group-item.active:hover .badge{text-shadow:none}.panel{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.05);box-shadow:0 1px 2px rgba(0,0,0,.05)}.panel-default>.panel-heading{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.panel-primary>.panel-heading{background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.panel-success>.panel-heading{background-image:-webkit-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#d0e9c6));background-image:linear-gradient(to bottom,#dff0d8 0,#d0e9c6 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);background-repeat:repeat-x}.panel-info>.panel-heading{background-image:-webkit-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#c4e3f3));background-image:linear-gradient(to bottom,#d9edf7 0,#c4e3f3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);background-repeat:repeat-x}.panel-warning>.panel-heading{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#faf2cc));background-image:linear-gradient(to bottom,#fcf8e3 0,#faf2cc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);background-repeat:repeat-x}.panel-danger>.panel-heading{background-image:-webkit-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-o-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#ebcccc));background-image:linear-gradient(to bottom,#f2dede 0,#ebcccc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);background-repeat:repeat-x}.well{background-image:-webkit-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#e8e8e8),to(#f5f5f5));background-image:linear-gradient(to bottom,#e8e8e8 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x;border-color:#dcdcdc;-webkit-box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1)} +/*# sourceMappingURL=bootstrap-theme.min.css.map */ \ No newline at end of file diff --git a/static/admin/css/bootstrap.css b/static/admin/css/bootstrap.css new file mode 100644 index 0000000..6167622 --- /dev/null +++ b/static/admin/css/bootstrap.css @@ -0,0 +1,6757 @@ +/*! + * Bootstrap v3.3.7 (http://getbootstrap.com) + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ +/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */ +html { + font-family: sans-serif; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; +} +body { + margin: 0; +} +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +main, +menu, +nav, +section, +summary { + display: block; +} +audio, +canvas, +progress, +video { + display: inline-block; + vertical-align: baseline; +} +audio:not([controls]) { + display: none; + height: 0; +} +[hidden], +template { + display: none; +} +a { + background-color: transparent; +} +a:active, +a:hover { + outline: 0; +} +abbr[title] { + border-bottom: 1px dotted; +} +b, +strong { + font-weight: bold; +} +dfn { + font-style: italic; +} +h1 { + margin: .67em 0; + font-size: 2em; +} +mark { + color: #000; + background: #ff0; +} +small { + font-size: 80%; +} +sub, +sup { + position: relative; + font-size: 75%; + line-height: 0; + vertical-align: baseline; +} +sup { + top: -.5em; +} +sub { + bottom: -.25em; +} +img { + border: 0; +} +svg:not(:root) { + overflow: hidden; +} +figure { + margin: 1em 40px; +} +hr { + height: 0; + -webkit-box-sizing: content-box; + -moz-box-sizing: content-box; + box-sizing: content-box; +} +pre { + overflow: auto; +} +code, +kbd, +pre, +samp { + font-family: monospace, monospace; + font-size: 1em; +} +button, +input, +optgroup, +select, +textarea { + margin: 0; + font: inherit; + color: inherit; +} +button { + overflow: visible; +} +button, +select { + text-transform: none; +} +button, +html input[type="button"], +input[type="reset"], +input[type="submit"] { + -webkit-appearance: button; + cursor: pointer; +} +button[disabled], +html input[disabled] { + cursor: default; +} +button::-moz-focus-inner, +input::-moz-focus-inner { + padding: 0; + border: 0; +} +input { + line-height: normal; +} +input[type="checkbox"], +input[type="radio"] { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + padding: 0; +} +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + height: auto; +} +input[type="search"] { + -webkit-box-sizing: content-box; + -moz-box-sizing: content-box; + box-sizing: content-box; + -webkit-appearance: textfield; +} +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} +fieldset { + padding: .35em .625em .75em; + margin: 0 2px; + border: 1px solid #c0c0c0; +} +legend { + padding: 0; + border: 0; +} +textarea { + overflow: auto; +} +optgroup { + font-weight: bold; +} +table { + border-spacing: 0; + border-collapse: collapse; +} +td, +th { + padding: 0; +} +/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */ +@media print { + *, + *:before, + *:after { + color: #000 !important; + text-shadow: none !important; + background: transparent !important; + -webkit-box-shadow: none !important; + box-shadow: none !important; + } + a, + a:visited { + text-decoration: underline; + } + a[href]:after { + content: " (" attr(href) ")"; + } + abbr[title]:after { + content: " (" attr(title) ")"; + } + a[href^="#"]:after, + a[href^="javascript:"]:after { + content: ""; + } + pre, + blockquote { + border: 1px solid #999; + + page-break-inside: avoid; + } + thead { + display: table-header-group; + } + tr, + img { + page-break-inside: avoid; + } + img { + max-width: 100% !important; + } + p, + h2, + h3 { + orphans: 3; + widows: 3; + } + h2, + h3 { + page-break-after: avoid; + } + .navbar { + display: none; + } + .btn > .caret, + .dropup > .btn > .caret { + border-top-color: #000 !important; + } + .label { + border: 1px solid #000; + } + .table { + border-collapse: collapse !important; + } + .table td, + .table th { + background-color: #fff !important; + } + .table-bordered th, + .table-bordered td { + border: 1px solid #ddd !important; + } +} +@font-face { + font-family: 'Glyphicons Halflings'; + + src: url('../fonts/glyphicons-halflings-regular.eot'); + src: url('../fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('../fonts/glyphicons-halflings-regular.woff2') format('woff2'), url('../fonts/glyphicons-halflings-regular.woff') format('woff'), url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'), url('../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg'); +} +.glyphicon { + position: relative; + top: 1px; + display: inline-block; + font-family: 'Glyphicons Halflings'; + font-style: normal; + font-weight: normal; + line-height: 1; + + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.glyphicon-asterisk:before { + content: "\002a"; +} +.glyphicon-plus:before { + content: "\002b"; +} +.glyphicon-euro:before, +.glyphicon-eur:before { + content: "\20ac"; +} +.glyphicon-minus:before { + content: "\2212"; +} +.glyphicon-cloud:before { + content: "\2601"; +} +.glyphicon-envelope:before { + content: "\2709"; +} +.glyphicon-pencil:before { + content: "\270f"; +} +.glyphicon-glass:before { + content: "\e001"; +} +.glyphicon-music:before { + content: "\e002"; +} +.glyphicon-search:before { + content: "\e003"; +} +.glyphicon-heart:before { + content: "\e005"; +} +.glyphicon-star:before { + content: "\e006"; +} +.glyphicon-star-empty:before { + content: "\e007"; +} +.glyphicon-user:before { + content: "\e008"; +} +.glyphicon-film:before { + content: "\e009"; +} +.glyphicon-th-large:before { + content: "\e010"; +} +.glyphicon-th:before { + content: "\e011"; +} +.glyphicon-th-list:before { + content: "\e012"; +} +.glyphicon-ok:before { + content: "\e013"; +} +.glyphicon-remove:before { + content: "\e014"; +} +.glyphicon-zoom-in:before { + content: "\e015"; +} +.glyphicon-zoom-out:before { + content: "\e016"; +} +.glyphicon-off:before { + content: "\e017"; +} +.glyphicon-signal:before { + content: "\e018"; +} +.glyphicon-cog:before { + content: "\e019"; +} +.glyphicon-trash:before { + content: "\e020"; +} +.glyphicon-home:before { + content: "\e021"; +} +.glyphicon-file:before { + content: "\e022"; +} +.glyphicon-time:before { + content: "\e023"; +} +.glyphicon-road:before { + content: "\e024"; +} +.glyphicon-download-alt:before { + content: "\e025"; +} +.glyphicon-download:before { + content: "\e026"; +} +.glyphicon-upload:before { + content: "\e027"; +} +.glyphicon-inbox:before { + content: "\e028"; +} +.glyphicon-play-circle:before { + content: "\e029"; +} +.glyphicon-repeat:before { + content: "\e030"; +} +.glyphicon-refresh:before { + content: "\e031"; +} +.glyphicon-list-alt:before { + content: "\e032"; +} +.glyphicon-lock:before { + content: "\e033"; +} +.glyphicon-flag:before { + content: "\e034"; +} +.glyphicon-headphones:before { + content: "\e035"; +} +.glyphicon-volume-off:before { + content: "\e036"; +} +.glyphicon-volume-down:before { + content: "\e037"; +} +.glyphicon-volume-up:before { + content: "\e038"; +} +.glyphicon-qrcode:before { + content: "\e039"; +} +.glyphicon-barcode:before { + content: "\e040"; +} +.glyphicon-tag:before { + content: "\e041"; +} +.glyphicon-tags:before { + content: "\e042"; +} +.glyphicon-book:before { + content: "\e043"; +} +.glyphicon-bookmark:before { + content: "\e044"; +} +.glyphicon-print:before { + content: "\e045"; +} +.glyphicon-camera:before { + content: "\e046"; +} +.glyphicon-font:before { + content: "\e047"; +} +.glyphicon-bold:before { + content: "\e048"; +} +.glyphicon-italic:before { + content: "\e049"; +} +.glyphicon-text-height:before { + content: "\e050"; +} +.glyphicon-text-width:before { + content: "\e051"; +} +.glyphicon-align-left:before { + content: "\e052"; +} +.glyphicon-align-center:before { + content: "\e053"; +} +.glyphicon-align-right:before { + content: "\e054"; +} +.glyphicon-align-justify:before { + content: "\e055"; +} +.glyphicon-list:before { + content: "\e056"; +} +.glyphicon-indent-left:before { + content: "\e057"; +} +.glyphicon-indent-right:before { + content: "\e058"; +} +.glyphicon-facetime-video:before { + content: "\e059"; +} +.glyphicon-picture:before { + content: "\e060"; +} +.glyphicon-map-marker:before { + content: "\e062"; +} +.glyphicon-adjust:before { + content: "\e063"; +} +.glyphicon-tint:before { + content: "\e064"; +} +.glyphicon-edit:before { + content: "\e065"; +} +.glyphicon-share:before { + content: "\e066"; +} +.glyphicon-check:before { + content: "\e067"; +} +.glyphicon-move:before { + content: "\e068"; +} +.glyphicon-step-backward:before { + content: "\e069"; +} +.glyphicon-fast-backward:before { + content: "\e070"; +} +.glyphicon-backward:before { + content: "\e071"; +} +.glyphicon-play:before { + content: "\e072"; +} +.glyphicon-pause:before { + content: "\e073"; +} +.glyphicon-stop:before { + content: "\e074"; +} +.glyphicon-forward:before { + content: "\e075"; +} +.glyphicon-fast-forward:before { + content: "\e076"; +} +.glyphicon-step-forward:before { + content: "\e077"; +} +.glyphicon-eject:before { + content: "\e078"; +} +.glyphicon-chevron-left:before { + content: "\e079"; +} +.glyphicon-chevron-right:before { + content: "\e080"; +} +.glyphicon-plus-sign:before { + content: "\e081"; +} +.glyphicon-minus-sign:before { + content: "\e082"; +} +.glyphicon-remove-sign:before { + content: "\e083"; +} +.glyphicon-ok-sign:before { + content: "\e084"; +} +.glyphicon-question-sign:before { + content: "\e085"; +} +.glyphicon-info-sign:before { + content: "\e086"; +} +.glyphicon-screenshot:before { + content: "\e087"; +} +.glyphicon-remove-circle:before { + content: "\e088"; +} +.glyphicon-ok-circle:before { + content: "\e089"; +} +.glyphicon-ban-circle:before { + content: "\e090"; +} +.glyphicon-arrow-left:before { + content: "\e091"; +} +.glyphicon-arrow-right:before { + content: "\e092"; +} +.glyphicon-arrow-up:before { + content: "\e093"; +} +.glyphicon-arrow-down:before { + content: "\e094"; +} +.glyphicon-share-alt:before { + content: "\e095"; +} +.glyphicon-resize-full:before { + content: "\e096"; +} +.glyphicon-resize-small:before { + content: "\e097"; +} +.glyphicon-exclamation-sign:before { + content: "\e101"; +} +.glyphicon-gift:before { + content: "\e102"; +} +.glyphicon-leaf:before { + content: "\e103"; +} +.glyphicon-fire:before { + content: "\e104"; +} +.glyphicon-eye-open:before { + content: "\e105"; +} +.glyphicon-eye-close:before { + content: "\e106"; +} +.glyphicon-warning-sign:before { + content: "\e107"; +} +.glyphicon-plane:before { + content: "\e108"; +} +.glyphicon-calendar:before { + content: "\e109"; +} +.glyphicon-random:before { + content: "\e110"; +} +.glyphicon-comment:before { + content: "\e111"; +} +.glyphicon-magnet:before { + content: "\e112"; +} +.glyphicon-chevron-up:before { + content: "\e113"; +} +.glyphicon-chevron-down:before { + content: "\e114"; +} +.glyphicon-retweet:before { + content: "\e115"; +} +.glyphicon-shopping-cart:before { + content: "\e116"; +} +.glyphicon-folder-close:before { + content: "\e117"; +} +.glyphicon-folder-open:before { + content: "\e118"; +} +.glyphicon-resize-vertical:before { + content: "\e119"; +} +.glyphicon-resize-horizontal:before { + content: "\e120"; +} +.glyphicon-hdd:before { + content: "\e121"; +} +.glyphicon-bullhorn:before { + content: "\e122"; +} +.glyphicon-bell:before { + content: "\e123"; +} +.glyphicon-certificate:before { + content: "\e124"; +} +.glyphicon-thumbs-up:before { + content: "\e125"; +} +.glyphicon-thumbs-down:before { + content: "\e126"; +} +.glyphicon-hand-right:before { + content: "\e127"; +} +.glyphicon-hand-left:before { + content: "\e128"; +} +.glyphicon-hand-up:before { + content: "\e129"; +} +.glyphicon-hand-down:before { + content: "\e130"; +} +.glyphicon-circle-arrow-right:before { + content: "\e131"; +} +.glyphicon-circle-arrow-left:before { + content: "\e132"; +} +.glyphicon-circle-arrow-up:before { + content: "\e133"; +} +.glyphicon-circle-arrow-down:before { + content: "\e134"; +} +.glyphicon-globe:before { + content: "\e135"; +} +.glyphicon-wrench:before { + content: "\e136"; +} +.glyphicon-tasks:before { + content: "\e137"; +} +.glyphicon-filter:before { + content: "\e138"; +} +.glyphicon-briefcase:before { + content: "\e139"; +} +.glyphicon-fullscreen:before { + content: "\e140"; +} +.glyphicon-dashboard:before { + content: "\e141"; +} +.glyphicon-paperclip:before { + content: "\e142"; +} +.glyphicon-heart-empty:before { + content: "\e143"; +} +.glyphicon-link:before { + content: "\e144"; +} +.glyphicon-phone:before { + content: "\e145"; +} +.glyphicon-pushpin:before { + content: "\e146"; +} +.glyphicon-usd:before { + content: "\e148"; +} +.glyphicon-gbp:before { + content: "\e149"; +} +.glyphicon-sort:before { + content: "\e150"; +} +.glyphicon-sort-by-alphabet:before { + content: "\e151"; +} +.glyphicon-sort-by-alphabet-alt:before { + content: "\e152"; +} +.glyphicon-sort-by-order:before { + content: "\e153"; +} +.glyphicon-sort-by-order-alt:before { + content: "\e154"; +} +.glyphicon-sort-by-attributes:before { + content: "\e155"; +} +.glyphicon-sort-by-attributes-alt:before { + content: "\e156"; +} +.glyphicon-unchecked:before { + content: "\e157"; +} +.glyphicon-expand:before { + content: "\e158"; +} +.glyphicon-collapse-down:before { + content: "\e159"; +} +.glyphicon-collapse-up:before { + content: "\e160"; +} +.glyphicon-log-in:before { + content: "\e161"; +} +.glyphicon-flash:before { + content: "\e162"; +} +.glyphicon-log-out:before { + content: "\e163"; +} +.glyphicon-new-window:before { + content: "\e164"; +} +.glyphicon-record:before { + content: "\e165"; +} +.glyphicon-save:before { + content: "\e166"; +} +.glyphicon-open:before { + content: "\e167"; +} +.glyphicon-saved:before { + content: "\e168"; +} +.glyphicon-import:before { + content: "\e169"; +} +.glyphicon-export:before { + content: "\e170"; +} +.glyphicon-send:before { + content: "\e171"; +} +.glyphicon-floppy-disk:before { + content: "\e172"; +} +.glyphicon-floppy-saved:before { + content: "\e173"; +} +.glyphicon-floppy-remove:before { + content: "\e174"; +} +.glyphicon-floppy-save:before { + content: "\e175"; +} +.glyphicon-floppy-open:before { + content: "\e176"; +} +.glyphicon-credit-card:before { + content: "\e177"; +} +.glyphicon-transfer:before { + content: "\e178"; +} +.glyphicon-cutlery:before { + content: "\e179"; +} +.glyphicon-header:before { + content: "\e180"; +} +.glyphicon-compressed:before { + content: "\e181"; +} +.glyphicon-earphone:before { + content: "\e182"; +} +.glyphicon-phone-alt:before { + content: "\e183"; +} +.glyphicon-tower:before { + content: "\e184"; +} +.glyphicon-stats:before { + content: "\e185"; +} +.glyphicon-sd-video:before { + content: "\e186"; +} +.glyphicon-hd-video:before { + content: "\e187"; +} +.glyphicon-subtitles:before { + content: "\e188"; +} +.glyphicon-sound-stereo:before { + content: "\e189"; +} +.glyphicon-sound-dolby:before { + content: "\e190"; +} +.glyphicon-sound-5-1:before { + content: "\e191"; +} +.glyphicon-sound-6-1:before { + content: "\e192"; +} +.glyphicon-sound-7-1:before { + content: "\e193"; +} +.glyphicon-copyright-mark:before { + content: "\e194"; +} +.glyphicon-registration-mark:before { + content: "\e195"; +} +.glyphicon-cloud-download:before { + content: "\e197"; +} +.glyphicon-cloud-upload:before { + content: "\e198"; +} +.glyphicon-tree-conifer:before { + content: "\e199"; +} +.glyphicon-tree-deciduous:before { + content: "\e200"; +} +.glyphicon-cd:before { + content: "\e201"; +} +.glyphicon-save-file:before { + content: "\e202"; +} +.glyphicon-open-file:before { + content: "\e203"; +} +.glyphicon-level-up:before { + content: "\e204"; +} +.glyphicon-copy:before { + content: "\e205"; +} +.glyphicon-paste:before { + content: "\e206"; +} +.glyphicon-alert:before { + content: "\e209"; +} +.glyphicon-equalizer:before { + content: "\e210"; +} +.glyphicon-king:before { + content: "\e211"; +} +.glyphicon-queen:before { + content: "\e212"; +} +.glyphicon-pawn:before { + content: "\e213"; +} +.glyphicon-bishop:before { + content: "\e214"; +} +.glyphicon-knight:before { + content: "\e215"; +} +.glyphicon-baby-formula:before { + content: "\e216"; +} +.glyphicon-tent:before { + content: "\26fa"; +} +.glyphicon-blackboard:before { + content: "\e218"; +} +.glyphicon-bed:before { + content: "\e219"; +} +.glyphicon-apple:before { + content: "\f8ff"; +} +.glyphicon-erase:before { + content: "\e221"; +} +.glyphicon-hourglass:before { + content: "\231b"; +} +.glyphicon-lamp:before { + content: "\e223"; +} +.glyphicon-duplicate:before { + content: "\e224"; +} +.glyphicon-piggy-bank:before { + content: "\e225"; +} +.glyphicon-scissors:before { + content: "\e226"; +} +.glyphicon-bitcoin:before { + content: "\e227"; +} +.glyphicon-btc:before { + content: "\e227"; +} +.glyphicon-xbt:before { + content: "\e227"; +} +.glyphicon-yen:before { + content: "\00a5"; +} +.glyphicon-jpy:before { + content: "\00a5"; +} +.glyphicon-ruble:before { + content: "\20bd"; +} +.glyphicon-rub:before { + content: "\20bd"; +} +.glyphicon-scale:before { + content: "\e230"; +} +.glyphicon-ice-lolly:before { + content: "\e231"; +} +.glyphicon-ice-lolly-tasted:before { + content: "\e232"; +} +.glyphicon-education:before { + content: "\e233"; +} +.glyphicon-option-horizontal:before { + content: "\e234"; +} +.glyphicon-option-vertical:before { + content: "\e235"; +} +.glyphicon-menu-hamburger:before { + content: "\e236"; +} +.glyphicon-modal-window:before { + content: "\e237"; +} +.glyphicon-oil:before { + content: "\e238"; +} +.glyphicon-grain:before { + content: "\e239"; +} +.glyphicon-sunglasses:before { + content: "\e240"; +} +.glyphicon-text-size:before { + content: "\e241"; +} +.glyphicon-text-color:before { + content: "\e242"; +} +.glyphicon-text-background:before { + content: "\e243"; +} +.glyphicon-object-align-top:before { + content: "\e244"; +} +.glyphicon-object-align-bottom:before { + content: "\e245"; +} +.glyphicon-object-align-horizontal:before { + content: "\e246"; +} +.glyphicon-object-align-left:before { + content: "\e247"; +} +.glyphicon-object-align-vertical:before { + content: "\e248"; +} +.glyphicon-object-align-right:before { + content: "\e249"; +} +.glyphicon-triangle-right:before { + content: "\e250"; +} +.glyphicon-triangle-left:before { + content: "\e251"; +} +.glyphicon-triangle-bottom:before { + content: "\e252"; +} +.glyphicon-triangle-top:before { + content: "\e253"; +} +.glyphicon-console:before { + content: "\e254"; +} +.glyphicon-superscript:before { + content: "\e255"; +} +.glyphicon-subscript:before { + content: "\e256"; +} +.glyphicon-menu-left:before { + content: "\e257"; +} +.glyphicon-menu-right:before { + content: "\e258"; +} +.glyphicon-menu-down:before { + content: "\e259"; +} +.glyphicon-menu-up:before { + content: "\e260"; +} +* { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +*:before, +*:after { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +html { + font-size: 10px; + + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); +} +body { + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 1.42857143; + color: #333; + background-color: #fff; +} +input, +button, +select, +textarea { + font-family: inherit; + font-size: inherit; + line-height: inherit; +} +a { + color: #337ab7; + text-decoration: none; +} +a:hover, +a:focus { + color: #23527c; + text-decoration: underline; +} +a:focus { + outline: 5px auto -webkit-focus-ring-color; + outline-offset: -2px; +} +figure { + margin: 0; +} +img { + vertical-align: middle; +} +.img-responsive, +.thumbnail > img, +.thumbnail a > img, +.carousel-inner > .item > img, +.carousel-inner > .item > a > img { + display: block; + max-width: 100%; + height: auto; +} +.img-rounded { + border-radius: 6px; +} +.img-thumbnail { + display: inline-block; + max-width: 100%; + height: auto; + padding: 4px; + line-height: 1.42857143; + background-color: #fff; + border: 1px solid #ddd; + border-radius: 4px; + -webkit-transition: all .2s ease-in-out; + -o-transition: all .2s ease-in-out; + transition: all .2s ease-in-out; +} +.img-circle { + border-radius: 50%; +} +hr { + margin-top: 20px; + margin-bottom: 20px; + border: 0; + border-top: 1px solid #eee; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} +.sr-only-focusable:active, +.sr-only-focusable:focus { + position: static; + width: auto; + height: auto; + margin: 0; + overflow: visible; + clip: auto; +} +[role="button"] { + cursor: pointer; +} +h1, +h2, +h3, +h4, +h5, +h6, +.h1, +.h2, +.h3, +.h4, +.h5, +.h6 { + font-family: inherit; + font-weight: 500; + line-height: 1.1; + color: inherit; +} +h1 small, +h2 small, +h3 small, +h4 small, +h5 small, +h6 small, +.h1 small, +.h2 small, +.h3 small, +.h4 small, +.h5 small, +.h6 small, +h1 .small, +h2 .small, +h3 .small, +h4 .small, +h5 .small, +h6 .small, +.h1 .small, +.h2 .small, +.h3 .small, +.h4 .small, +.h5 .small, +.h6 .small { + font-weight: normal; + line-height: 1; + color: #777; +} +h1, +.h1, +h2, +.h2, +h3, +.h3 { + margin-top: 20px; + margin-bottom: 10px; +} +h1 small, +.h1 small, +h2 small, +.h2 small, +h3 small, +.h3 small, +h1 .small, +.h1 .small, +h2 .small, +.h2 .small, +h3 .small, +.h3 .small { + font-size: 65%; +} +h4, +.h4, +h5, +.h5, +h6, +.h6 { + margin-top: 10px; + margin-bottom: 10px; +} +h4 small, +.h4 small, +h5 small, +.h5 small, +h6 small, +.h6 small, +h4 .small, +.h4 .small, +h5 .small, +.h5 .small, +h6 .small, +.h6 .small { + font-size: 75%; +} +h1, +.h1 { + font-size: 36px; +} +h2, +.h2 { + font-size: 30px; +} +h3, +.h3 { + font-size: 24px; +} +h4, +.h4 { + font-size: 18px; +} +h5, +.h5 { + font-size: 14px; +} +h6, +.h6 { + font-size: 12px; +} +p { + margin: 0 0 10px; +} +.lead { + margin-bottom: 20px; + font-size: 16px; + font-weight: 300; + line-height: 1.4; +} +@media (min-width: 768px) { + .lead { + font-size: 21px; + } +} +small, +.small { + font-size: 85%; +} +mark, +.mark { + padding: .2em; + background-color: #fcf8e3; +} +.text-left { + text-align: left; +} +.text-right { + text-align: right; +} +.text-center { + text-align: center; +} +.text-justify { + text-align: justify; +} +.text-nowrap { + white-space: nowrap; +} +.text-lowercase { + text-transform: lowercase; +} +.text-uppercase { + text-transform: uppercase; +} +.text-capitalize { + text-transform: capitalize; +} +.text-muted { + color: #777; +} +.text-primary { + color: #337ab7; +} +a.text-primary:hover, +a.text-primary:focus { + color: #286090; +} +.text-success { + color: #3c763d; +} +a.text-success:hover, +a.text-success:focus { + color: #2b542c; +} +.text-info { + color: #31708f; +} +a.text-info:hover, +a.text-info:focus { + color: #245269; +} +.text-warning { + color: #8a6d3b; +} +a.text-warning:hover, +a.text-warning:focus { + color: #66512c; +} +.text-danger { + color: #a94442; +} +a.text-danger:hover, +a.text-danger:focus { + color: #843534; +} +.bg-primary { + color: #fff; + background-color: #337ab7; +} +a.bg-primary:hover, +a.bg-primary:focus { + background-color: #286090; +} +.bg-success { + background-color: #dff0d8; +} +a.bg-success:hover, +a.bg-success:focus { + background-color: #c1e2b3; +} +.bg-info { + background-color: #d9edf7; +} +a.bg-info:hover, +a.bg-info:focus { + background-color: #afd9ee; +} +.bg-warning { + background-color: #fcf8e3; +} +a.bg-warning:hover, +a.bg-warning:focus { + background-color: #f7ecb5; +} +.bg-danger { + background-color: #f2dede; +} +a.bg-danger:hover, +a.bg-danger:focus { + background-color: #e4b9b9; +} +.page-header { + padding-bottom: 9px; + margin: 40px 0 20px; + border-bottom: 1px solid #eee; +} +ul, +ol { + margin-top: 0; + margin-bottom: 10px; +} +ul ul, +ol ul, +ul ol, +ol ol { + margin-bottom: 0; +} +.list-unstyled { + padding-left: 0; + list-style: none; +} +.list-inline { + padding-left: 0; + margin-left: -5px; + list-style: none; +} +.list-inline > li { + display: inline-block; + padding-right: 5px; + padding-left: 5px; +} +dl { + margin-top: 0; + margin-bottom: 20px; +} +dt, +dd { + line-height: 1.42857143; +} +dt { + font-weight: bold; +} +dd { + margin-left: 0; +} +@media (min-width: 768px) { + .dl-horizontal dt { + float: left; + width: 160px; + overflow: hidden; + clear: left; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; + } + .dl-horizontal dd { + margin-left: 180px; + } +} +abbr[title], +abbr[data-original-title] { + cursor: help; + border-bottom: 1px dotted #777; +} +.initialism { + font-size: 90%; + text-transform: uppercase; +} +blockquote { + padding: 10px 20px; + margin: 0 0 20px; + font-size: 17.5px; + border-left: 5px solid #eee; +} +blockquote p:last-child, +blockquote ul:last-child, +blockquote ol:last-child { + margin-bottom: 0; +} +blockquote footer, +blockquote small, +blockquote .small { + display: block; + font-size: 80%; + line-height: 1.42857143; + color: #777; +} +blockquote footer:before, +blockquote small:before, +blockquote .small:before { + content: '\2014 \00A0'; +} +.blockquote-reverse, +blockquote.pull-right { + padding-right: 15px; + padding-left: 0; + text-align: right; + border-right: 5px solid #eee; + border-left: 0; +} +.blockquote-reverse footer:before, +blockquote.pull-right footer:before, +.blockquote-reverse small:before, +blockquote.pull-right small:before, +.blockquote-reverse .small:before, +blockquote.pull-right .small:before { + content: ''; +} +.blockquote-reverse footer:after, +blockquote.pull-right footer:after, +.blockquote-reverse small:after, +blockquote.pull-right small:after, +.blockquote-reverse .small:after, +blockquote.pull-right .small:after { + content: '\00A0 \2014'; +} +address { + margin-bottom: 20px; + font-style: normal; + line-height: 1.42857143; +} +code, +kbd, +pre, +samp { + font-family: Menlo, Monaco, Consolas, "Courier New", monospace; +} +code { + padding: 2px 4px; + font-size: 90%; + color: #c7254e; + background-color: #f9f2f4; + border-radius: 4px; +} +kbd { + padding: 2px 4px; + font-size: 90%; + color: #fff; + background-color: #333; + border-radius: 3px; + -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .25); + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .25); +} +kbd kbd { + padding: 0; + font-size: 100%; + font-weight: bold; + -webkit-box-shadow: none; + box-shadow: none; +} +pre { + display: block; + padding: 9.5px; + margin: 0 0 10px; + font-size: 13px; + line-height: 1.42857143; + color: #333; + word-break: break-all; + word-wrap: break-word; + background-color: #f5f5f5; + border: 1px solid #ccc; + border-radius: 4px; +} +pre code { + padding: 0; + font-size: inherit; + color: inherit; + white-space: pre-wrap; + background-color: transparent; + border-radius: 0; +} +.pre-scrollable { + max-height: 340px; + overflow-y: scroll; +} +.container { + padding-right: 15px; + padding-left: 15px; + margin-right: auto; + margin-left: auto; +} +@media (min-width: 768px) { + .container { + width: 750px; + } +} +@media (min-width: 992px) { + .container { + width: 970px; + } +} +@media (min-width: 1200px) { + .container { + width: 1170px; + } +} +.container-fluid { + padding-right: 15px; + padding-left: 15px; + margin-right: auto; + margin-left: auto; +} +.row { + margin-right: -15px; + margin-left: -15px; +} +.col-xs-1, .col-sm-1, .col-md-1, .col-lg-1, .col-xs-2, .col-sm-2, .col-md-2, .col-lg-2, .col-xs-3, .col-sm-3, .col-md-3, .col-lg-3, .col-xs-4, .col-sm-4, .col-md-4, .col-lg-4, .col-xs-5, .col-sm-5, .col-md-5, .col-lg-5, .col-xs-6, .col-sm-6, .col-md-6, .col-lg-6, .col-xs-7, .col-sm-7, .col-md-7, .col-lg-7, .col-xs-8, .col-sm-8, .col-md-8, .col-lg-8, .col-xs-9, .col-sm-9, .col-md-9, .col-lg-9, .col-xs-10, .col-sm-10, .col-md-10, .col-lg-10, .col-xs-11, .col-sm-11, .col-md-11, .col-lg-11, .col-xs-12, .col-sm-12, .col-md-12, .col-lg-12 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; +} +.col-xs-1, .col-xs-2, .col-xs-3, .col-xs-4, .col-xs-5, .col-xs-6, .col-xs-7, .col-xs-8, .col-xs-9, .col-xs-10, .col-xs-11, .col-xs-12 { + float: left; +} +.col-xs-12 { + width: 100%; +} +.col-xs-11 { + width: 91.66666667%; +} +.col-xs-10 { + width: 83.33333333%; +} +.col-xs-9 { + width: 75%; +} +.col-xs-8 { + width: 66.66666667%; +} +.col-xs-7 { + width: 58.33333333%; +} +.col-xs-6 { + width: 50%; +} +.col-xs-5 { + width: 41.66666667%; +} +.col-xs-4 { + width: 33.33333333%; +} +.col-xs-3 { + width: 25%; +} +.col-xs-2 { + width: 16.66666667%; +} +.col-xs-1 { + width: 8.33333333%; +} +.col-xs-pull-12 { + right: 100%; +} +.col-xs-pull-11 { + right: 91.66666667%; +} +.col-xs-pull-10 { + right: 83.33333333%; +} +.col-xs-pull-9 { + right: 75%; +} +.col-xs-pull-8 { + right: 66.66666667%; +} +.col-xs-pull-7 { + right: 58.33333333%; +} +.col-xs-pull-6 { + right: 50%; +} +.col-xs-pull-5 { + right: 41.66666667%; +} +.col-xs-pull-4 { + right: 33.33333333%; +} +.col-xs-pull-3 { + right: 25%; +} +.col-xs-pull-2 { + right: 16.66666667%; +} +.col-xs-pull-1 { + right: 8.33333333%; +} +.col-xs-pull-0 { + right: auto; +} +.col-xs-push-12 { + left: 100%; +} +.col-xs-push-11 { + left: 91.66666667%; +} +.col-xs-push-10 { + left: 83.33333333%; +} +.col-xs-push-9 { + left: 75%; +} +.col-xs-push-8 { + left: 66.66666667%; +} +.col-xs-push-7 { + left: 58.33333333%; +} +.col-xs-push-6 { + left: 50%; +} +.col-xs-push-5 { + left: 41.66666667%; +} +.col-xs-push-4 { + left: 33.33333333%; +} +.col-xs-push-3 { + left: 25%; +} +.col-xs-push-2 { + left: 16.66666667%; +} +.col-xs-push-1 { + left: 8.33333333%; +} +.col-xs-push-0 { + left: auto; +} +.col-xs-offset-12 { + margin-left: 100%; +} +.col-xs-offset-11 { + margin-left: 91.66666667%; +} +.col-xs-offset-10 { + margin-left: 83.33333333%; +} +.col-xs-offset-9 { + margin-left: 75%; +} +.col-xs-offset-8 { + margin-left: 66.66666667%; +} +.col-xs-offset-7 { + margin-left: 58.33333333%; +} +.col-xs-offset-6 { + margin-left: 50%; +} +.col-xs-offset-5 { + margin-left: 41.66666667%; +} +.col-xs-offset-4 { + margin-left: 33.33333333%; +} +.col-xs-offset-3 { + margin-left: 25%; +} +.col-xs-offset-2 { + margin-left: 16.66666667%; +} +.col-xs-offset-1 { + margin-left: 8.33333333%; +} +.col-xs-offset-0 { + margin-left: 0; +} +@media (min-width: 768px) { + .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12 { + float: left; + } + .col-sm-12 { + width: 100%; + } + .col-sm-11 { + width: 91.66666667%; + } + .col-sm-10 { + width: 83.33333333%; + } + .col-sm-9 { + width: 75%; + } + .col-sm-8 { + width: 66.66666667%; + } + .col-sm-7 { + width: 58.33333333%; + } + .col-sm-6 { + width: 50%; + } + .col-sm-5 { + width: 41.66666667%; + } + .col-sm-4 { + width: 33.33333333%; + } + .col-sm-3 { + width: 25%; + } + .col-sm-2 { + width: 16.66666667%; + } + .col-sm-1 { + width: 8.33333333%; + } + .col-sm-pull-12 { + right: 100%; + } + .col-sm-pull-11 { + right: 91.66666667%; + } + .col-sm-pull-10 { + right: 83.33333333%; + } + .col-sm-pull-9 { + right: 75%; + } + .col-sm-pull-8 { + right: 66.66666667%; + } + .col-sm-pull-7 { + right: 58.33333333%; + } + .col-sm-pull-6 { + right: 50%; + } + .col-sm-pull-5 { + right: 41.66666667%; + } + .col-sm-pull-4 { + right: 33.33333333%; + } + .col-sm-pull-3 { + right: 25%; + } + .col-sm-pull-2 { + right: 16.66666667%; + } + .col-sm-pull-1 { + right: 8.33333333%; + } + .col-sm-pull-0 { + right: auto; + } + .col-sm-push-12 { + left: 100%; + } + .col-sm-push-11 { + left: 91.66666667%; + } + .col-sm-push-10 { + left: 83.33333333%; + } + .col-sm-push-9 { + left: 75%; + } + .col-sm-push-8 { + left: 66.66666667%; + } + .col-sm-push-7 { + left: 58.33333333%; + } + .col-sm-push-6 { + left: 50%; + } + .col-sm-push-5 { + left: 41.66666667%; + } + .col-sm-push-4 { + left: 33.33333333%; + } + .col-sm-push-3 { + left: 25%; + } + .col-sm-push-2 { + left: 16.66666667%; + } + .col-sm-push-1 { + left: 8.33333333%; + } + .col-sm-push-0 { + left: auto; + } + .col-sm-offset-12 { + margin-left: 100%; + } + .col-sm-offset-11 { + margin-left: 91.66666667%; + } + .col-sm-offset-10 { + margin-left: 83.33333333%; + } + .col-sm-offset-9 { + margin-left: 75%; + } + .col-sm-offset-8 { + margin-left: 66.66666667%; + } + .col-sm-offset-7 { + margin-left: 58.33333333%; + } + .col-sm-offset-6 { + margin-left: 50%; + } + .col-sm-offset-5 { + margin-left: 41.66666667%; + } + .col-sm-offset-4 { + margin-left: 33.33333333%; + } + .col-sm-offset-3 { + margin-left: 25%; + } + .col-sm-offset-2 { + margin-left: 16.66666667%; + } + .col-sm-offset-1 { + margin-left: 8.33333333%; + } + .col-sm-offset-0 { + margin-left: 0; + } +} +@media (min-width: 992px) { + .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12 { + float: left; + } + .col-md-12 { + width: 100%; + } + .col-md-11 { + width: 91.66666667%; + } + .col-md-10 { + width: 83.33333333%; + } + .col-md-9 { + width: 75%; + } + .col-md-8 { + width: 66.66666667%; + } + .col-md-7 { + width: 58.33333333%; + } + .col-md-6 { + width: 50%; + } + .col-md-5 { + width: 41.66666667%; + } + .col-md-4 { + width: 33.33333333%; + } + .col-md-3 { + width: 25%; + } + .col-md-2 { + width: 16.66666667%; + } + .col-md-1 { + width: 8.33333333%; + } + .col-md-pull-12 { + right: 100%; + } + .col-md-pull-11 { + right: 91.66666667%; + } + .col-md-pull-10 { + right: 83.33333333%; + } + .col-md-pull-9 { + right: 75%; + } + .col-md-pull-8 { + right: 66.66666667%; + } + .col-md-pull-7 { + right: 58.33333333%; + } + .col-md-pull-6 { + right: 50%; + } + .col-md-pull-5 { + right: 41.66666667%; + } + .col-md-pull-4 { + right: 33.33333333%; + } + .col-md-pull-3 { + right: 25%; + } + .col-md-pull-2 { + right: 16.66666667%; + } + .col-md-pull-1 { + right: 8.33333333%; + } + .col-md-pull-0 { + right: auto; + } + .col-md-push-12 { + left: 100%; + } + .col-md-push-11 { + left: 91.66666667%; + } + .col-md-push-10 { + left: 83.33333333%; + } + .col-md-push-9 { + left: 75%; + } + .col-md-push-8 { + left: 66.66666667%; + } + .col-md-push-7 { + left: 58.33333333%; + } + .col-md-push-6 { + left: 50%; + } + .col-md-push-5 { + left: 41.66666667%; + } + .col-md-push-4 { + left: 33.33333333%; + } + .col-md-push-3 { + left: 25%; + } + .col-md-push-2 { + left: 16.66666667%; + } + .col-md-push-1 { + left: 8.33333333%; + } + .col-md-push-0 { + left: auto; + } + .col-md-offset-12 { + margin-left: 100%; + } + .col-md-offset-11 { + margin-left: 91.66666667%; + } + .col-md-offset-10 { + margin-left: 83.33333333%; + } + .col-md-offset-9 { + margin-left: 75%; + } + .col-md-offset-8 { + margin-left: 66.66666667%; + } + .col-md-offset-7 { + margin-left: 58.33333333%; + } + .col-md-offset-6 { + margin-left: 50%; + } + .col-md-offset-5 { + margin-left: 41.66666667%; + } + .col-md-offset-4 { + margin-left: 33.33333333%; + } + .col-md-offset-3 { + margin-left: 25%; + } + .col-md-offset-2 { + margin-left: 16.66666667%; + } + .col-md-offset-1 { + margin-left: 8.33333333%; + } + .col-md-offset-0 { + margin-left: 0; + } +} +@media (min-width: 1200px) { + .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12 { + float: left; + } + .col-lg-12 { + width: 100%; + } + .col-lg-11 { + width: 91.66666667%; + } + .col-lg-10 { + width: 83.33333333%; + } + .col-lg-9 { + width: 75%; + } + .col-lg-8 { + width: 66.66666667%; + } + .col-lg-7 { + width: 58.33333333%; + } + .col-lg-6 { + width: 50%; + } + .col-lg-5 { + width: 41.66666667%; + } + .col-lg-4 { + width: 33.33333333%; + } + .col-lg-3 { + width: 25%; + } + .col-lg-2 { + width: 16.66666667%; + } + .col-lg-1 { + width: 8.33333333%; + } + .col-lg-pull-12 { + right: 100%; + } + .col-lg-pull-11 { + right: 91.66666667%; + } + .col-lg-pull-10 { + right: 83.33333333%; + } + .col-lg-pull-9 { + right: 75%; + } + .col-lg-pull-8 { + right: 66.66666667%; + } + .col-lg-pull-7 { + right: 58.33333333%; + } + .col-lg-pull-6 { + right: 50%; + } + .col-lg-pull-5 { + right: 41.66666667%; + } + .col-lg-pull-4 { + right: 33.33333333%; + } + .col-lg-pull-3 { + right: 25%; + } + .col-lg-pull-2 { + right: 16.66666667%; + } + .col-lg-pull-1 { + right: 8.33333333%; + } + .col-lg-pull-0 { + right: auto; + } + .col-lg-push-12 { + left: 100%; + } + .col-lg-push-11 { + left: 91.66666667%; + } + .col-lg-push-10 { + left: 83.33333333%; + } + .col-lg-push-9 { + left: 75%; + } + .col-lg-push-8 { + left: 66.66666667%; + } + .col-lg-push-7 { + left: 58.33333333%; + } + .col-lg-push-6 { + left: 50%; + } + .col-lg-push-5 { + left: 41.66666667%; + } + .col-lg-push-4 { + left: 33.33333333%; + } + .col-lg-push-3 { + left: 25%; + } + .col-lg-push-2 { + left: 16.66666667%; + } + .col-lg-push-1 { + left: 8.33333333%; + } + .col-lg-push-0 { + left: auto; + } + .col-lg-offset-12 { + margin-left: 100%; + } + .col-lg-offset-11 { + margin-left: 91.66666667%; + } + .col-lg-offset-10 { + margin-left: 83.33333333%; + } + .col-lg-offset-9 { + margin-left: 75%; + } + .col-lg-offset-8 { + margin-left: 66.66666667%; + } + .col-lg-offset-7 { + margin-left: 58.33333333%; + } + .col-lg-offset-6 { + margin-left: 50%; + } + .col-lg-offset-5 { + margin-left: 41.66666667%; + } + .col-lg-offset-4 { + margin-left: 33.33333333%; + } + .col-lg-offset-3 { + margin-left: 25%; + } + .col-lg-offset-2 { + margin-left: 16.66666667%; + } + .col-lg-offset-1 { + margin-left: 8.33333333%; + } + .col-lg-offset-0 { + margin-left: 0; + } +} +table { + background-color: transparent; +} +caption { + padding-top: 8px; + padding-bottom: 8px; + color: #777; + text-align: left; +} +th { + text-align: left; +} +.table { + width: 100%; + max-width: 100%; + margin-bottom: 20px; +} +.table > thead > tr > th, +.table > tbody > tr > th, +.table > tfoot > tr > th, +.table > thead > tr > td, +.table > tbody > tr > td, +.table > tfoot > tr > td { + padding: 8px; + line-height: 1.42857143; + vertical-align: top; + border-top: 1px solid #ddd; +} +.table > thead > tr > th { + vertical-align: bottom; + border-bottom: 2px solid #ddd; +} +.table > caption + thead > tr:first-child > th, +.table > colgroup + thead > tr:first-child > th, +.table > thead:first-child > tr:first-child > th, +.table > caption + thead > tr:first-child > td, +.table > colgroup + thead > tr:first-child > td, +.table > thead:first-child > tr:first-child > td { + border-top: 0; +} +.table > tbody + tbody { + border-top: 2px solid #ddd; +} +.table .table { + background-color: #fff; +} +.table-condensed > thead > tr > th, +.table-condensed > tbody > tr > th, +.table-condensed > tfoot > tr > th, +.table-condensed > thead > tr > td, +.table-condensed > tbody > tr > td, +.table-condensed > tfoot > tr > td { + padding: 5px; +} +.table-bordered { + border: 1px solid #ddd; +} +.table-bordered > thead > tr > th, +.table-bordered > tbody > tr > th, +.table-bordered > tfoot > tr > th, +.table-bordered > thead > tr > td, +.table-bordered > tbody > tr > td, +.table-bordered > tfoot > tr > td { + border: 1px solid #ddd; +} +.table-bordered > thead > tr > th, +.table-bordered > thead > tr > td { + border-bottom-width: 2px; +} +.table-striped > tbody > tr:nth-of-type(odd) { + background-color: #f9f9f9; +} +.table-hover > tbody > tr:hover { + background-color: #f5f5f5; +} +table col[class*="col-"] { + position: static; + display: table-column; + float: none; +} +table td[class*="col-"], +table th[class*="col-"] { + position: static; + display: table-cell; + float: none; +} +.table > thead > tr > td.active, +.table > tbody > tr > td.active, +.table > tfoot > tr > td.active, +.table > thead > tr > th.active, +.table > tbody > tr > th.active, +.table > tfoot > tr > th.active, +.table > thead > tr.active > td, +.table > tbody > tr.active > td, +.table > tfoot > tr.active > td, +.table > thead > tr.active > th, +.table > tbody > tr.active > th, +.table > tfoot > tr.active > th { + background-color: #f5f5f5; +} +.table-hover > tbody > tr > td.active:hover, +.table-hover > tbody > tr > th.active:hover, +.table-hover > tbody > tr.active:hover > td, +.table-hover > tbody > tr:hover > .active, +.table-hover > tbody > tr.active:hover > th { + background-color: #e8e8e8; +} +.table > thead > tr > td.success, +.table > tbody > tr > td.success, +.table > tfoot > tr > td.success, +.table > thead > tr > th.success, +.table > tbody > tr > th.success, +.table > tfoot > tr > th.success, +.table > thead > tr.success > td, +.table > tbody > tr.success > td, +.table > tfoot > tr.success > td, +.table > thead > tr.success > th, +.table > tbody > tr.success > th, +.table > tfoot > tr.success > th { + background-color: #dff0d8; +} +.table-hover > tbody > tr > td.success:hover, +.table-hover > tbody > tr > th.success:hover, +.table-hover > tbody > tr.success:hover > td, +.table-hover > tbody > tr:hover > .success, +.table-hover > tbody > tr.success:hover > th { + background-color: #d0e9c6; +} +.table > thead > tr > td.info, +.table > tbody > tr > td.info, +.table > tfoot > tr > td.info, +.table > thead > tr > th.info, +.table > tbody > tr > th.info, +.table > tfoot > tr > th.info, +.table > thead > tr.info > td, +.table > tbody > tr.info > td, +.table > tfoot > tr.info > td, +.table > thead > tr.info > th, +.table > tbody > tr.info > th, +.table > tfoot > tr.info > th { + background-color: #d9edf7; +} +.table-hover > tbody > tr > td.info:hover, +.table-hover > tbody > tr > th.info:hover, +.table-hover > tbody > tr.info:hover > td, +.table-hover > tbody > tr:hover > .info, +.table-hover > tbody > tr.info:hover > th { + background-color: #c4e3f3; +} +.table > thead > tr > td.warning, +.table > tbody > tr > td.warning, +.table > tfoot > tr > td.warning, +.table > thead > tr > th.warning, +.table > tbody > tr > th.warning, +.table > tfoot > tr > th.warning, +.table > thead > tr.warning > td, +.table > tbody > tr.warning > td, +.table > tfoot > tr.warning > td, +.table > thead > tr.warning > th, +.table > tbody > tr.warning > th, +.table > tfoot > tr.warning > th { + background-color: #fcf8e3; +} +.table-hover > tbody > tr > td.warning:hover, +.table-hover > tbody > tr > th.warning:hover, +.table-hover > tbody > tr.warning:hover > td, +.table-hover > tbody > tr:hover > .warning, +.table-hover > tbody > tr.warning:hover > th { + background-color: #faf2cc; +} +.table > thead > tr > td.danger, +.table > tbody > tr > td.danger, +.table > tfoot > tr > td.danger, +.table > thead > tr > th.danger, +.table > tbody > tr > th.danger, +.table > tfoot > tr > th.danger, +.table > thead > tr.danger > td, +.table > tbody > tr.danger > td, +.table > tfoot > tr.danger > td, +.table > thead > tr.danger > th, +.table > tbody > tr.danger > th, +.table > tfoot > tr.danger > th { + background-color: #f2dede; +} +.table-hover > tbody > tr > td.danger:hover, +.table-hover > tbody > tr > th.danger:hover, +.table-hover > tbody > tr.danger:hover > td, +.table-hover > tbody > tr:hover > .danger, +.table-hover > tbody > tr.danger:hover > th { + background-color: #ebcccc; +} +.table-responsive { + min-height: .01%; + overflow-x: auto; +} +@media screen and (max-width: 767px) { + .table-responsive { + width: 100%; + margin-bottom: 15px; + overflow-y: hidden; + -ms-overflow-style: -ms-autohiding-scrollbar; + border: 1px solid #ddd; + } + .table-responsive > .table { + margin-bottom: 0; + } + .table-responsive > .table > thead > tr > th, + .table-responsive > .table > tbody > tr > th, + .table-responsive > .table > tfoot > tr > th, + .table-responsive > .table > thead > tr > td, + .table-responsive > .table > tbody > tr > td, + .table-responsive > .table > tfoot > tr > td { + white-space: nowrap; + } + .table-responsive > .table-bordered { + border: 0; + } + .table-responsive > .table-bordered > thead > tr > th:first-child, + .table-responsive > .table-bordered > tbody > tr > th:first-child, + .table-responsive > .table-bordered > tfoot > tr > th:first-child, + .table-responsive > .table-bordered > thead > tr > td:first-child, + .table-responsive > .table-bordered > tbody > tr > td:first-child, + .table-responsive > .table-bordered > tfoot > tr > td:first-child { + border-left: 0; + } + .table-responsive > .table-bordered > thead > tr > th:last-child, + .table-responsive > .table-bordered > tbody > tr > th:last-child, + .table-responsive > .table-bordered > tfoot > tr > th:last-child, + .table-responsive > .table-bordered > thead > tr > td:last-child, + .table-responsive > .table-bordered > tbody > tr > td:last-child, + .table-responsive > .table-bordered > tfoot > tr > td:last-child { + border-right: 0; + } + .table-responsive > .table-bordered > tbody > tr:last-child > th, + .table-responsive > .table-bordered > tfoot > tr:last-child > th, + .table-responsive > .table-bordered > tbody > tr:last-child > td, + .table-responsive > .table-bordered > tfoot > tr:last-child > td { + border-bottom: 0; + } +} +fieldset { + min-width: 0; + padding: 0; + margin: 0; + border: 0; +} +legend { + display: block; + width: 100%; + padding: 0; + margin-bottom: 20px; + font-size: 21px; + line-height: inherit; + color: #333; + border: 0; + border-bottom: 1px solid #e5e5e5; +} +label { + display: inline-block; + max-width: 100%; + margin-bottom: 5px; + font-weight: bold; +} +input[type="search"] { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +input[type="radio"], +input[type="checkbox"] { + margin: 4px 0 0; + margin-top: 1px \9; + line-height: normal; +} +input[type="file"] { + display: block; +} +input[type="range"] { + display: block; + width: 100%; +} +select[multiple], +select[size] { + height: auto; +} +input[type="file"]:focus, +input[type="radio"]:focus, +input[type="checkbox"]:focus { + outline: 5px auto -webkit-focus-ring-color; + outline-offset: -2px; +} +output { + display: block; + padding-top: 7px; + font-size: 14px; + line-height: 1.42857143; + color: #555; +} +.form-control { + display: block; + width: 100%; + height: 34px; + padding: 6px 12px; + font-size: 14px; + line-height: 1.42857143; + color: #555; + background-color: #fff; + background-image: none; + border: 1px solid #ccc; + border-radius: 4px; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); + -webkit-transition: border-color ease-in-out .15s, -webkit-box-shadow ease-in-out .15s; + -o-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; + transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; +} +.form-control:focus { + border-color: #66afe9; + outline: 0; + -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, .6); + box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, .6); +} +.form-control::-moz-placeholder { + color: #999; + opacity: 1; +} +.form-control:-ms-input-placeholder { + color: #999; +} +.form-control::-webkit-input-placeholder { + color: #999; +} +.form-control::-ms-expand { + background-color: transparent; + border: 0; +} +.form-control[disabled], +.form-control[readonly], +fieldset[disabled] .form-control { + background-color: #eee; + opacity: 1; +} +.form-control[disabled], +fieldset[disabled] .form-control { + cursor: not-allowed; +} +textarea.form-control { + height: auto; +} +input[type="search"] { + -webkit-appearance: none; +} +@media screen and (-webkit-min-device-pixel-ratio: 0) { + input[type="date"].form-control, + input[type="time"].form-control, + input[type="datetime-local"].form-control, + input[type="month"].form-control { + line-height: 34px; + } + input[type="date"].input-sm, + input[type="time"].input-sm, + input[type="datetime-local"].input-sm, + input[type="month"].input-sm, + .input-group-sm input[type="date"], + .input-group-sm input[type="time"], + .input-group-sm input[type="datetime-local"], + .input-group-sm input[type="month"] { + line-height: 30px; + } + input[type="date"].input-lg, + input[type="time"].input-lg, + input[type="datetime-local"].input-lg, + input[type="month"].input-lg, + .input-group-lg input[type="date"], + .input-group-lg input[type="time"], + .input-group-lg input[type="datetime-local"], + .input-group-lg input[type="month"] { + line-height: 46px; + } +} +.form-group { + margin-bottom: 15px; +} +.radio, +.checkbox { + position: relative; + display: block; + margin-top: 10px; + margin-bottom: 10px; +} +.radio label, +.checkbox label { + min-height: 20px; + padding-left: 20px; + margin-bottom: 0; + font-weight: normal; + cursor: pointer; +} +.radio input[type="radio"], +.radio-inline input[type="radio"], +.checkbox input[type="checkbox"], +.checkbox-inline input[type="checkbox"] { + position: absolute; + margin-top: 4px \9; + margin-left: -20px; +} +.radio + .radio, +.checkbox + .checkbox { + margin-top: -5px; +} +.radio-inline, +.checkbox-inline { + position: relative; + display: inline-block; + padding-left: 20px; + margin-bottom: 0; + font-weight: normal; + vertical-align: middle; + cursor: pointer; +} +.radio-inline + .radio-inline, +.checkbox-inline + .checkbox-inline { + margin-top: 0; + margin-left: 10px; +} +input[type="radio"][disabled], +input[type="checkbox"][disabled], +input[type="radio"].disabled, +input[type="checkbox"].disabled, +fieldset[disabled] input[type="radio"], +fieldset[disabled] input[type="checkbox"] { + cursor: not-allowed; +} +.radio-inline.disabled, +.checkbox-inline.disabled, +fieldset[disabled] .radio-inline, +fieldset[disabled] .checkbox-inline { + cursor: not-allowed; +} +.radio.disabled label, +.checkbox.disabled label, +fieldset[disabled] .radio label, +fieldset[disabled] .checkbox label { + cursor: not-allowed; +} +.form-control-static { + min-height: 34px; + padding-top: 7px; + padding-bottom: 7px; + margin-bottom: 0; +} +.form-control-static.input-lg, +.form-control-static.input-sm { + padding-right: 0; + padding-left: 0; +} +.input-sm { + height: 30px; + padding: 5px 10px; + font-size: 12px; + line-height: 1.5; + border-radius: 3px; +} +select.input-sm { + height: 30px; + line-height: 30px; +} +textarea.input-sm, +select[multiple].input-sm { + height: auto; +} +.form-group-sm .form-control { + height: 30px; + padding: 5px 10px; + font-size: 12px; + line-height: 1.5; + border-radius: 3px; +} +.form-group-sm select.form-control { + height: 30px; + line-height: 30px; +} +.form-group-sm textarea.form-control, +.form-group-sm select[multiple].form-control { + height: auto; +} +.form-group-sm .form-control-static { + height: 30px; + min-height: 32px; + padding: 6px 10px; + font-size: 12px; + line-height: 1.5; +} +.input-lg { + height: 46px; + padding: 10px 16px; + font-size: 18px; + line-height: 1.3333333; + border-radius: 6px; +} +select.input-lg { + height: 46px; + line-height: 46px; +} +textarea.input-lg, +select[multiple].input-lg { + height: auto; +} +.form-group-lg .form-control { + height: 46px; + padding: 10px 16px; + font-size: 18px; + line-height: 1.3333333; + border-radius: 6px; +} +.form-group-lg select.form-control { + height: 46px; + line-height: 46px; +} +.form-group-lg textarea.form-control, +.form-group-lg select[multiple].form-control { + height: auto; +} +.form-group-lg .form-control-static { + height: 46px; + min-height: 38px; + padding: 11px 16px; + font-size: 18px; + line-height: 1.3333333; +} +.has-feedback { + position: relative; +} +.has-feedback .form-control { + padding-right: 42.5px; +} +.form-control-feedback { + position: absolute; + top: 0; + right: 0; + z-index: 2; + display: block; + width: 34px; + height: 34px; + line-height: 34px; + text-align: center; + pointer-events: none; +} +.input-lg + .form-control-feedback, +.input-group-lg + .form-control-feedback, +.form-group-lg .form-control + .form-control-feedback { + width: 46px; + height: 46px; + line-height: 46px; +} +.input-sm + .form-control-feedback, +.input-group-sm + .form-control-feedback, +.form-group-sm .form-control + .form-control-feedback { + width: 30px; + height: 30px; + line-height: 30px; +} +.has-success .help-block, +.has-success .control-label, +.has-success .radio, +.has-success .checkbox, +.has-success .radio-inline, +.has-success .checkbox-inline, +.has-success.radio label, +.has-success.checkbox label, +.has-success.radio-inline label, +.has-success.checkbox-inline label { + color: #3c763d; +} +.has-success .form-control { + border-color: #3c763d; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); +} +.has-success .form-control:focus { + border-color: #2b542c; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #67b168; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #67b168; +} +.has-success .input-group-addon { + color: #3c763d; + background-color: #dff0d8; + border-color: #3c763d; +} +.has-success .form-control-feedback { + color: #3c763d; +} +.has-warning .help-block, +.has-warning .control-label, +.has-warning .radio, +.has-warning .checkbox, +.has-warning .radio-inline, +.has-warning .checkbox-inline, +.has-warning.radio label, +.has-warning.checkbox label, +.has-warning.radio-inline label, +.has-warning.checkbox-inline label { + color: #8a6d3b; +} +.has-warning .form-control { + border-color: #8a6d3b; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); +} +.has-warning .form-control:focus { + border-color: #66512c; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #c0a16b; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #c0a16b; +} +.has-warning .input-group-addon { + color: #8a6d3b; + background-color: #fcf8e3; + border-color: #8a6d3b; +} +.has-warning .form-control-feedback { + color: #8a6d3b; +} +.has-error .help-block, +.has-error .control-label, +.has-error .radio, +.has-error .checkbox, +.has-error .radio-inline, +.has-error .checkbox-inline, +.has-error.radio label, +.has-error.checkbox label, +.has-error.radio-inline label, +.has-error.checkbox-inline label { + color: #a94442; +} +.has-error .form-control { + border-color: #a94442; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); +} +.has-error .form-control:focus { + border-color: #843534; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #ce8483; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #ce8483; +} +.has-error .input-group-addon { + color: #a94442; + background-color: #f2dede; + border-color: #a94442; +} +.has-error .form-control-feedback { + color: #a94442; +} +.has-feedback label ~ .form-control-feedback { + top: 25px; +} +.has-feedback label.sr-only ~ .form-control-feedback { + top: 0; +} +.help-block { + display: block; + margin-top: 5px; + margin-bottom: 10px; + color: #737373; +} +@media (min-width: 768px) { + .form-inline .form-group { + display: inline-block; + margin-bottom: 0; + vertical-align: middle; + } + .form-inline .form-control { + display: inline-block; + width: auto; + vertical-align: middle; + } + .form-inline .form-control-static { + display: inline-block; + } + .form-inline .input-group { + display: inline-table; + vertical-align: middle; + } + .form-inline .input-group .input-group-addon, + .form-inline .input-group .input-group-btn, + .form-inline .input-group .form-control { + width: auto; + } + .form-inline .input-group > .form-control { + width: 100%; + } + .form-inline .control-label { + margin-bottom: 0; + vertical-align: middle; + } + .form-inline .radio, + .form-inline .checkbox { + display: inline-block; + margin-top: 0; + margin-bottom: 0; + vertical-align: middle; + } + .form-inline .radio label, + .form-inline .checkbox label { + padding-left: 0; + } + .form-inline .radio input[type="radio"], + .form-inline .checkbox input[type="checkbox"] { + position: relative; + margin-left: 0; + } + .form-inline .has-feedback .form-control-feedback { + top: 0; + } +} +.form-horizontal .radio, +.form-horizontal .checkbox, +.form-horizontal .radio-inline, +.form-horizontal .checkbox-inline { + padding-top: 7px; + margin-top: 0; + margin-bottom: 0; +} +.form-horizontal .radio, +.form-horizontal .checkbox { + min-height: 27px; +} +.form-horizontal .form-group { + margin-right: -15px; + margin-left: -15px; +} +@media (min-width: 768px) { + .form-horizontal .control-label { + padding-top: 7px; + margin-bottom: 0; + text-align: right; + } +} +.form-horizontal .has-feedback .form-control-feedback { + right: 15px; +} +@media (min-width: 768px) { + .form-horizontal .form-group-lg .control-label { + padding-top: 11px; + font-size: 18px; + } +} +@media (min-width: 768px) { + .form-horizontal .form-group-sm .control-label { + padding-top: 6px; + font-size: 12px; + } +} +.btn { + display: inline-block; + padding: 6px 12px; + margin-bottom: 0; + font-size: 14px; + font-weight: normal; + line-height: 1.42857143; + text-align: center; + white-space: nowrap; + vertical-align: middle; + -ms-touch-action: manipulation; + touch-action: manipulation; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + background-image: none; + border: 1px solid transparent; + border-radius: 4px; +} +.btn:focus, +.btn:active:focus, +.btn.active:focus, +.btn.focus, +.btn:active.focus, +.btn.active.focus { + outline: 5px auto -webkit-focus-ring-color; + outline-offset: -2px; +} +.btn:hover, +.btn:focus, +.btn.focus { + color: #333; + text-decoration: none; +} +.btn:active, +.btn.active { + background-image: none; + outline: 0; + -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); + box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); +} +.btn.disabled, +.btn[disabled], +fieldset[disabled] .btn { + cursor: not-allowed; + filter: alpha(opacity=65); + -webkit-box-shadow: none; + box-shadow: none; + opacity: .65; +} +a.btn.disabled, +fieldset[disabled] a.btn { + pointer-events: none; +} +.btn-default { + color: #333; + background-color: #fff; + border-color: #ccc; +} +.btn-default:focus, +.btn-default.focus { + color: #333; + background-color: #e6e6e6; + border-color: #8c8c8c; +} +.btn-default:hover { + color: #333; + background-color: #e6e6e6; + border-color: #adadad; +} +.btn-default:active, +.btn-default.active, +.open > .dropdown-toggle.btn-default { + color: #333; + background-color: #e6e6e6; + border-color: #adadad; +} +.btn-default:active:hover, +.btn-default.active:hover, +.open > .dropdown-toggle.btn-default:hover, +.btn-default:active:focus, +.btn-default.active:focus, +.open > .dropdown-toggle.btn-default:focus, +.btn-default:active.focus, +.btn-default.active.focus, +.open > .dropdown-toggle.btn-default.focus { + color: #333; + background-color: #d4d4d4; + border-color: #8c8c8c; +} +.btn-default:active, +.btn-default.active, +.open > .dropdown-toggle.btn-default { + background-image: none; +} +.btn-default.disabled:hover, +.btn-default[disabled]:hover, +fieldset[disabled] .btn-default:hover, +.btn-default.disabled:focus, +.btn-default[disabled]:focus, +fieldset[disabled] .btn-default:focus, +.btn-default.disabled.focus, +.btn-default[disabled].focus, +fieldset[disabled] .btn-default.focus { + background-color: #fff; + border-color: #ccc; +} +.btn-default .badge { + color: #fff; + background-color: #333; +} +.btn-primary { + color: #fff; + background-color: #337ab7; + border-color: #2e6da4; +} +.btn-primary:focus, +.btn-primary.focus { + color: #fff; + background-color: #286090; + border-color: #122b40; +} +.btn-primary:hover { + color: #fff; + background-color: #286090; + border-color: #204d74; +} +.btn-primary:active, +.btn-primary.active, +.open > .dropdown-toggle.btn-primary { + color: #fff; + background-color: #286090; + border-color: #204d74; +} +.btn-primary:active:hover, +.btn-primary.active:hover, +.open > .dropdown-toggle.btn-primary:hover, +.btn-primary:active:focus, +.btn-primary.active:focus, +.open > .dropdown-toggle.btn-primary:focus, +.btn-primary:active.focus, +.btn-primary.active.focus, +.open > .dropdown-toggle.btn-primary.focus { + color: #fff; + background-color: #204d74; + border-color: #122b40; +} +.btn-primary:active, +.btn-primary.active, +.open > .dropdown-toggle.btn-primary { + background-image: none; +} +.btn-primary.disabled:hover, +.btn-primary[disabled]:hover, +fieldset[disabled] .btn-primary:hover, +.btn-primary.disabled:focus, +.btn-primary[disabled]:focus, +fieldset[disabled] .btn-primary:focus, +.btn-primary.disabled.focus, +.btn-primary[disabled].focus, +fieldset[disabled] .btn-primary.focus { + background-color: #337ab7; + border-color: #2e6da4; +} +.btn-primary .badge { + color: #337ab7; + background-color: #fff; +} +.btn-success { + color: #fff; + background-color: #5cb85c; + border-color: #4cae4c; +} +.btn-success:focus, +.btn-success.focus { + color: #fff; + background-color: #449d44; + border-color: #255625; +} +.btn-success:hover { + color: #fff; + background-color: #449d44; + border-color: #398439; +} +.btn-success:active, +.btn-success.active, +.open > .dropdown-toggle.btn-success { + color: #fff; + background-color: #449d44; + border-color: #398439; +} +.btn-success:active:hover, +.btn-success.active:hover, +.open > .dropdown-toggle.btn-success:hover, +.btn-success:active:focus, +.btn-success.active:focus, +.open > .dropdown-toggle.btn-success:focus, +.btn-success:active.focus, +.btn-success.active.focus, +.open > .dropdown-toggle.btn-success.focus { + color: #fff; + background-color: #398439; + border-color: #255625; +} +.btn-success:active, +.btn-success.active, +.open > .dropdown-toggle.btn-success { + background-image: none; +} +.btn-success.disabled:hover, +.btn-success[disabled]:hover, +fieldset[disabled] .btn-success:hover, +.btn-success.disabled:focus, +.btn-success[disabled]:focus, +fieldset[disabled] .btn-success:focus, +.btn-success.disabled.focus, +.btn-success[disabled].focus, +fieldset[disabled] .btn-success.focus { + background-color: #5cb85c; + border-color: #4cae4c; +} +.btn-success .badge { + color: #5cb85c; + background-color: #fff; +} +.btn-info { + color: #fff; + background-color: #5bc0de; + border-color: #46b8da; +} +.btn-info:focus, +.btn-info.focus { + color: #fff; + background-color: #31b0d5; + border-color: #1b6d85; +} +.btn-info:hover { + color: #fff; + background-color: #31b0d5; + border-color: #269abc; +} +.btn-info:active, +.btn-info.active, +.open > .dropdown-toggle.btn-info { + color: #fff; + background-color: #31b0d5; + border-color: #269abc; +} +.btn-info:active:hover, +.btn-info.active:hover, +.open > .dropdown-toggle.btn-info:hover, +.btn-info:active:focus, +.btn-info.active:focus, +.open > .dropdown-toggle.btn-info:focus, +.btn-info:active.focus, +.btn-info.active.focus, +.open > .dropdown-toggle.btn-info.focus { + color: #fff; + background-color: #269abc; + border-color: #1b6d85; +} +.btn-info:active, +.btn-info.active, +.open > .dropdown-toggle.btn-info { + background-image: none; +} +.btn-info.disabled:hover, +.btn-info[disabled]:hover, +fieldset[disabled] .btn-info:hover, +.btn-info.disabled:focus, +.btn-info[disabled]:focus, +fieldset[disabled] .btn-info:focus, +.btn-info.disabled.focus, +.btn-info[disabled].focus, +fieldset[disabled] .btn-info.focus { + background-color: #5bc0de; + border-color: #46b8da; +} +.btn-info .badge { + color: #5bc0de; + background-color: #fff; +} +.btn-warning { + color: #fff; + background-color: #f0ad4e; + border-color: #eea236; +} +.btn-warning:focus, +.btn-warning.focus { + color: #fff; + background-color: #ec971f; + border-color: #985f0d; +} +.btn-warning:hover { + color: #fff; + background-color: #ec971f; + border-color: #d58512; +} +.btn-warning:active, +.btn-warning.active, +.open > .dropdown-toggle.btn-warning { + color: #fff; + background-color: #ec971f; + border-color: #d58512; +} +.btn-warning:active:hover, +.btn-warning.active:hover, +.open > .dropdown-toggle.btn-warning:hover, +.btn-warning:active:focus, +.btn-warning.active:focus, +.open > .dropdown-toggle.btn-warning:focus, +.btn-warning:active.focus, +.btn-warning.active.focus, +.open > .dropdown-toggle.btn-warning.focus { + color: #fff; + background-color: #d58512; + border-color: #985f0d; +} +.btn-warning:active, +.btn-warning.active, +.open > .dropdown-toggle.btn-warning { + background-image: none; +} +.btn-warning.disabled:hover, +.btn-warning[disabled]:hover, +fieldset[disabled] .btn-warning:hover, +.btn-warning.disabled:focus, +.btn-warning[disabled]:focus, +fieldset[disabled] .btn-warning:focus, +.btn-warning.disabled.focus, +.btn-warning[disabled].focus, +fieldset[disabled] .btn-warning.focus { + background-color: #f0ad4e; + border-color: #eea236; +} +.btn-warning .badge { + color: #f0ad4e; + background-color: #fff; +} +.btn-danger { + color: #fff; + background-color: #d9534f; + border-color: #d43f3a; +} +.btn-danger:focus, +.btn-danger.focus { + color: #fff; + background-color: #c9302c; + border-color: #761c19; +} +.btn-danger:hover { + color: #fff; + background-color: #c9302c; + border-color: #ac2925; +} +.btn-danger:active, +.btn-danger.active, +.open > .dropdown-toggle.btn-danger { + color: #fff; + background-color: #c9302c; + border-color: #ac2925; +} +.btn-danger:active:hover, +.btn-danger.active:hover, +.open > .dropdown-toggle.btn-danger:hover, +.btn-danger:active:focus, +.btn-danger.active:focus, +.open > .dropdown-toggle.btn-danger:focus, +.btn-danger:active.focus, +.btn-danger.active.focus, +.open > .dropdown-toggle.btn-danger.focus { + color: #fff; + background-color: #ac2925; + border-color: #761c19; +} +.btn-danger:active, +.btn-danger.active, +.open > .dropdown-toggle.btn-danger { + background-image: none; +} +.btn-danger.disabled:hover, +.btn-danger[disabled]:hover, +fieldset[disabled] .btn-danger:hover, +.btn-danger.disabled:focus, +.btn-danger[disabled]:focus, +fieldset[disabled] .btn-danger:focus, +.btn-danger.disabled.focus, +.btn-danger[disabled].focus, +fieldset[disabled] .btn-danger.focus { + background-color: #d9534f; + border-color: #d43f3a; +} +.btn-danger .badge { + color: #d9534f; + background-color: #fff; +} +.btn-link { + font-weight: normal; + color: #337ab7; + border-radius: 0; +} +.btn-link, +.btn-link:active, +.btn-link.active, +.btn-link[disabled], +fieldset[disabled] .btn-link { + background-color: transparent; + -webkit-box-shadow: none; + box-shadow: none; +} +.btn-link, +.btn-link:hover, +.btn-link:focus, +.btn-link:active { + border-color: transparent; +} +.btn-link:hover, +.btn-link:focus { + color: #23527c; + text-decoration: underline; + background-color: transparent; +} +.btn-link[disabled]:hover, +fieldset[disabled] .btn-link:hover, +.btn-link[disabled]:focus, +fieldset[disabled] .btn-link:focus { + color: #777; + text-decoration: none; +} +.btn-lg, +.btn-group-lg > .btn { + padding: 10px 16px; + font-size: 18px; + line-height: 1.3333333; + border-radius: 6px; +} +.btn-sm, +.btn-group-sm > .btn { + padding: 5px 10px; + font-size: 12px; + line-height: 1.5; + border-radius: 3px; +} +.btn-xs, +.btn-group-xs > .btn { + padding: 1px 5px; + font-size: 12px; + line-height: 1.5; + border-radius: 3px; +} +.btn-block { + display: block; + width: 100%; +} +.btn-block + .btn-block { + margin-top: 5px; +} +input[type="submit"].btn-block, +input[type="reset"].btn-block, +input[type="button"].btn-block { + width: 100%; +} +.fade { + opacity: 0; + -webkit-transition: opacity .15s linear; + -o-transition: opacity .15s linear; + transition: opacity .15s linear; +} +.fade.in { + opacity: 1; +} +.collapse { + display: none; +} +.collapse.in { + display: block; +} +tr.collapse.in { + display: table-row; +} +tbody.collapse.in { + display: table-row-group; +} +.collapsing { + position: relative; + height: 0; + overflow: hidden; + -webkit-transition-timing-function: ease; + -o-transition-timing-function: ease; + transition-timing-function: ease; + -webkit-transition-duration: .35s; + -o-transition-duration: .35s; + transition-duration: .35s; + -webkit-transition-property: height, visibility; + -o-transition-property: height, visibility; + transition-property: height, visibility; +} +.caret { + display: inline-block; + width: 0; + height: 0; + margin-left: 2px; + vertical-align: middle; + border-top: 4px dashed; + border-top: 4px solid \9; + border-right: 4px solid transparent; + border-left: 4px solid transparent; +} +.dropup, +.dropdown { + position: relative; +} +.dropdown-toggle:focus { + outline: 0; +} +.dropdown-menu { + position: absolute; + top: 100%; + left: 0; + z-index: 1000; + display: none; + float: left; + min-width: 160px; + padding: 5px 0; + margin: 2px 0 0; + font-size: 14px; + text-align: left; + list-style: none; + background-color: #fff; + -webkit-background-clip: padding-box; + background-clip: padding-box; + border: 1px solid #ccc; + border: 1px solid rgba(0, 0, 0, .15); + border-radius: 4px; + -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, .175); + box-shadow: 0 6px 12px rgba(0, 0, 0, .175); +} +.dropdown-menu.pull-right { + right: 0; + left: auto; +} +.dropdown-menu .divider { + height: 1px; + margin: 9px 0; + overflow: hidden; + background-color: #e5e5e5; +} +.dropdown-menu > li > a { + display: block; + padding: 3px 20px; + clear: both; + font-weight: normal; + line-height: 1.42857143; + color: #333; + white-space: nowrap; +} +.dropdown-menu > li > a:hover, +.dropdown-menu > li > a:focus { + color: #262626; + text-decoration: none; + background-color: #f5f5f5; +} +.dropdown-menu > .active > a, +.dropdown-menu > .active > a:hover, +.dropdown-menu > .active > a:focus { + color: #fff; + text-decoration: none; + background-color: #337ab7; + outline: 0; +} +.dropdown-menu > .disabled > a, +.dropdown-menu > .disabled > a:hover, +.dropdown-menu > .disabled > a:focus { + color: #777; +} +.dropdown-menu > .disabled > a:hover, +.dropdown-menu > .disabled > a:focus { + text-decoration: none; + cursor: not-allowed; + background-color: transparent; + background-image: none; + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); +} +.open > .dropdown-menu { + display: block; +} +.open > a { + outline: 0; +} +.dropdown-menu-right { + right: 0; + left: auto; +} +.dropdown-menu-left { + right: auto; + left: 0; +} +.dropdown-header { + display: block; + padding: 3px 20px; + font-size: 12px; + line-height: 1.42857143; + color: #777; + white-space: nowrap; +} +.dropdown-backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 990; +} +.pull-right > .dropdown-menu { + right: 0; + left: auto; +} +.dropup .caret, +.navbar-fixed-bottom .dropdown .caret { + content: ""; + border-top: 0; + border-bottom: 4px dashed; + border-bottom: 4px solid \9; +} +.dropup .dropdown-menu, +.navbar-fixed-bottom .dropdown .dropdown-menu { + top: auto; + bottom: 100%; + margin-bottom: 2px; +} +@media (min-width: 768px) { + .navbar-right .dropdown-menu { + right: 0; + left: auto; + } + .navbar-right .dropdown-menu-left { + right: auto; + left: 0; + } +} +.btn-group, +.btn-group-vertical { + position: relative; + display: inline-block; + vertical-align: middle; +} +.btn-group > .btn, +.btn-group-vertical > .btn { + position: relative; + float: left; +} +.btn-group > .btn:hover, +.btn-group-vertical > .btn:hover, +.btn-group > .btn:focus, +.btn-group-vertical > .btn:focus, +.btn-group > .btn:active, +.btn-group-vertical > .btn:active, +.btn-group > .btn.active, +.btn-group-vertical > .btn.active { + z-index: 2; +} +.btn-group .btn + .btn, +.btn-group .btn + .btn-group, +.btn-group .btn-group + .btn, +.btn-group .btn-group + .btn-group { + margin-left: -1px; +} +.btn-toolbar { + margin-left: -5px; +} +.btn-toolbar .btn, +.btn-toolbar .btn-group, +.btn-toolbar .input-group { + float: left; +} +.btn-toolbar > .btn, +.btn-toolbar > .btn-group, +.btn-toolbar > .input-group { + margin-left: 5px; +} +.btn-group > .btn:not(:first-child):not(:last-child):not(.dropdown-toggle) { + border-radius: 0; +} +.btn-group > .btn:first-child { + margin-left: 0; +} +.btn-group > .btn:first-child:not(:last-child):not(.dropdown-toggle) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} +.btn-group > .btn:last-child:not(:first-child), +.btn-group > .dropdown-toggle:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} +.btn-group > .btn-group { + float: left; +} +.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn { + border-radius: 0; +} +.btn-group > .btn-group:first-child:not(:last-child) > .btn:last-child, +.btn-group > .btn-group:first-child:not(:last-child) > .dropdown-toggle { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} +.btn-group > .btn-group:last-child:not(:first-child) > .btn:first-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} +.btn-group .dropdown-toggle:active, +.btn-group.open .dropdown-toggle { + outline: 0; +} +.btn-group > .btn + .dropdown-toggle { + padding-right: 8px; + padding-left: 8px; +} +.btn-group > .btn-lg + .dropdown-toggle { + padding-right: 12px; + padding-left: 12px; +} +.btn-group.open .dropdown-toggle { + -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); + box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); +} +.btn-group.open .dropdown-toggle.btn-link { + -webkit-box-shadow: none; + box-shadow: none; +} +.btn .caret { + margin-left: 0; +} +.btn-lg .caret { + border-width: 5px 5px 0; + border-bottom-width: 0; +} +.dropup .btn-lg .caret { + border-width: 0 5px 5px; +} +.btn-group-vertical > .btn, +.btn-group-vertical > .btn-group, +.btn-group-vertical > .btn-group > .btn { + display: block; + float: none; + width: 100%; + max-width: 100%; +} +.btn-group-vertical > .btn-group > .btn { + float: none; +} +.btn-group-vertical > .btn + .btn, +.btn-group-vertical > .btn + .btn-group, +.btn-group-vertical > .btn-group + .btn, +.btn-group-vertical > .btn-group + .btn-group { + margin-top: -1px; + margin-left: 0; +} +.btn-group-vertical > .btn:not(:first-child):not(:last-child) { + border-radius: 0; +} +.btn-group-vertical > .btn:first-child:not(:last-child) { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.btn-group-vertical > .btn:last-child:not(:first-child) { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 4px; + border-bottom-left-radius: 4px; +} +.btn-group-vertical > .btn-group:not(:first-child):not(:last-child) > .btn { + border-radius: 0; +} +.btn-group-vertical > .btn-group:first-child:not(:last-child) > .btn:last-child, +.btn-group-vertical > .btn-group:first-child:not(:last-child) > .dropdown-toggle { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.btn-group-vertical > .btn-group:last-child:not(:first-child) > .btn:first-child { + border-top-left-radius: 0; + border-top-right-radius: 0; +} +.btn-group-justified { + display: table; + width: 100%; + table-layout: fixed; + border-collapse: separate; +} +.btn-group-justified > .btn, +.btn-group-justified > .btn-group { + display: table-cell; + float: none; + width: 1%; +} +.btn-group-justified > .btn-group .btn { + width: 100%; +} +.btn-group-justified > .btn-group .dropdown-menu { + left: auto; +} +[data-toggle="buttons"] > .btn input[type="radio"], +[data-toggle="buttons"] > .btn-group > .btn input[type="radio"], +[data-toggle="buttons"] > .btn input[type="checkbox"], +[data-toggle="buttons"] > .btn-group > .btn input[type="checkbox"] { + position: absolute; + clip: rect(0, 0, 0, 0); + pointer-events: none; +} +.input-group { + position: relative; + display: table; + border-collapse: separate; +} +.input-group[class*="col-"] { + float: none; + padding-right: 0; + padding-left: 0; +} +.input-group .form-control { + position: relative; + z-index: 2; + float: left; + width: 100%; + margin-bottom: 0; +} +.input-group .form-control:focus { + z-index: 3; +} +.input-group-lg > .form-control, +.input-group-lg > .input-group-addon, +.input-group-lg > .input-group-btn > .btn { + height: 46px; + padding: 10px 16px; + font-size: 18px; + line-height: 1.3333333; + border-radius: 6px; +} +select.input-group-lg > .form-control, +select.input-group-lg > .input-group-addon, +select.input-group-lg > .input-group-btn > .btn { + height: 46px; + line-height: 46px; +} +textarea.input-group-lg > .form-control, +textarea.input-group-lg > .input-group-addon, +textarea.input-group-lg > .input-group-btn > .btn, +select[multiple].input-group-lg > .form-control, +select[multiple].input-group-lg > .input-group-addon, +select[multiple].input-group-lg > .input-group-btn > .btn { + height: auto; +} +.input-group-sm > .form-control, +.input-group-sm > .input-group-addon, +.input-group-sm > .input-group-btn > .btn { + height: 30px; + padding: 5px 10px; + font-size: 12px; + line-height: 1.5; + border-radius: 3px; +} +select.input-group-sm > .form-control, +select.input-group-sm > .input-group-addon, +select.input-group-sm > .input-group-btn > .btn { + height: 30px; + line-height: 30px; +} +textarea.input-group-sm > .form-control, +textarea.input-group-sm > .input-group-addon, +textarea.input-group-sm > .input-group-btn > .btn, +select[multiple].input-group-sm > .form-control, +select[multiple].input-group-sm > .input-group-addon, +select[multiple].input-group-sm > .input-group-btn > .btn { + height: auto; +} +.input-group-addon, +.input-group-btn, +.input-group .form-control { + display: table-cell; +} +.input-group-addon:not(:first-child):not(:last-child), +.input-group-btn:not(:first-child):not(:last-child), +.input-group .form-control:not(:first-child):not(:last-child) { + border-radius: 0; +} +.input-group-addon, +.input-group-btn { + width: 1%; + white-space: nowrap; + vertical-align: middle; +} +.input-group-addon { + padding: 6px 12px; + font-size: 14px; + font-weight: normal; + line-height: 1; + color: #555; + text-align: center; + background-color: #eee; + border: 1px solid #ccc; + border-radius: 4px; +} +.input-group-addon.input-sm { + padding: 5px 10px; + font-size: 12px; + border-radius: 3px; +} +.input-group-addon.input-lg { + padding: 10px 16px; + font-size: 18px; + border-radius: 6px; +} +.input-group-addon input[type="radio"], +.input-group-addon input[type="checkbox"] { + margin-top: 0; +} +.input-group .form-control:first-child, +.input-group-addon:first-child, +.input-group-btn:first-child > .btn, +.input-group-btn:first-child > .btn-group > .btn, +.input-group-btn:first-child > .dropdown-toggle, +.input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle), +.input-group-btn:last-child > .btn-group:not(:last-child) > .btn { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} +.input-group-addon:first-child { + border-right: 0; +} +.input-group .form-control:last-child, +.input-group-addon:last-child, +.input-group-btn:last-child > .btn, +.input-group-btn:last-child > .btn-group > .btn, +.input-group-btn:last-child > .dropdown-toggle, +.input-group-btn:first-child > .btn:not(:first-child), +.input-group-btn:first-child > .btn-group:not(:first-child) > .btn { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} +.input-group-addon:last-child { + border-left: 0; +} +.input-group-btn { + position: relative; + font-size: 0; + white-space: nowrap; +} +.input-group-btn > .btn { + position: relative; +} +.input-group-btn > .btn + .btn { + margin-left: -1px; +} +.input-group-btn > .btn:hover, +.input-group-btn > .btn:focus, +.input-group-btn > .btn:active { + z-index: 2; +} +.input-group-btn:first-child > .btn, +.input-group-btn:first-child > .btn-group { + margin-right: -1px; +} +.input-group-btn:last-child > .btn, +.input-group-btn:last-child > .btn-group { + z-index: 2; + margin-left: -1px; +} +.nav { + padding-left: 0; + margin-bottom: 0; + list-style: none; +} +.nav > li { + position: relative; + display: block; +} +.nav > li > a { + position: relative; + display: block; + padding: 10px 15px; +} +.nav > li > a:hover, +.nav > li > a:focus { + text-decoration: none; + background-color: #eee; +} +.nav > li.disabled > a { + color: #777; +} +.nav > li.disabled > a:hover, +.nav > li.disabled > a:focus { + color: #777; + text-decoration: none; + cursor: not-allowed; + background-color: transparent; +} +.nav .open > a, +.nav .open > a:hover, +.nav .open > a:focus { + background-color: #eee; + border-color: #337ab7; +} +.nav .nav-divider { + height: 1px; + margin: 9px 0; + overflow: hidden; + background-color: #e5e5e5; +} +.nav > li > a > img { + max-width: none; +} +.nav-tabs { + border-bottom: 1px solid #ddd; +} +.nav-tabs > li { + float: left; + margin-bottom: -1px; +} +.nav-tabs > li > a { + margin-right: 2px; + line-height: 1.42857143; + border: 1px solid transparent; + border-radius: 4px 4px 0 0; +} +.nav-tabs > li > a:hover { + border-color: #eee #eee #ddd; +} +.nav-tabs > li.active > a, +.nav-tabs > li.active > a:hover, +.nav-tabs > li.active > a:focus { + color: #555; + cursor: default; + background-color: #fff; + border: 1px solid #ddd; + border-bottom-color: transparent; +} +.nav-tabs.nav-justified { + width: 100%; + border-bottom: 0; +} +.nav-tabs.nav-justified > li { + float: none; +} +.nav-tabs.nav-justified > li > a { + margin-bottom: 5px; + text-align: center; +} +.nav-tabs.nav-justified > .dropdown .dropdown-menu { + top: auto; + left: auto; +} +@media (min-width: 768px) { + .nav-tabs.nav-justified > li { + display: table-cell; + width: 1%; + } + .nav-tabs.nav-justified > li > a { + margin-bottom: 0; + } +} +.nav-tabs.nav-justified > li > a { + margin-right: 0; + border-radius: 4px; +} +.nav-tabs.nav-justified > .active > a, +.nav-tabs.nav-justified > .active > a:hover, +.nav-tabs.nav-justified > .active > a:focus { + border: 1px solid #ddd; +} +@media (min-width: 768px) { + .nav-tabs.nav-justified > li > a { + border-bottom: 1px solid #ddd; + border-radius: 4px 4px 0 0; + } + .nav-tabs.nav-justified > .active > a, + .nav-tabs.nav-justified > .active > a:hover, + .nav-tabs.nav-justified > .active > a:focus { + border-bottom-color: #fff; + } +} +.nav-pills > li { + float: left; +} +.nav-pills > li > a { + border-radius: 4px; +} +.nav-pills > li + li { + margin-left: 2px; +} +.nav-pills > li.active > a, +.nav-pills > li.active > a:hover, +.nav-pills > li.active > a:focus { + color: #fff; + background-color: #337ab7; +} +.nav-stacked > li { + float: none; +} +.nav-stacked > li + li { + margin-top: 2px; + margin-left: 0; +} +.nav-justified { + width: 100%; +} +.nav-justified > li { + float: none; +} +.nav-justified > li > a { + margin-bottom: 5px; + text-align: center; +} +.nav-justified > .dropdown .dropdown-menu { + top: auto; + left: auto; +} +@media (min-width: 768px) { + .nav-justified > li { + display: table-cell; + width: 1%; + } + .nav-justified > li > a { + margin-bottom: 0; + } +} +.nav-tabs-justified { + border-bottom: 0; +} +.nav-tabs-justified > li > a { + margin-right: 0; + border-radius: 4px; +} +.nav-tabs-justified > .active > a, +.nav-tabs-justified > .active > a:hover, +.nav-tabs-justified > .active > a:focus { + border: 1px solid #ddd; +} +@media (min-width: 768px) { + .nav-tabs-justified > li > a { + border-bottom: 1px solid #ddd; + border-radius: 4px 4px 0 0; + } + .nav-tabs-justified > .active > a, + .nav-tabs-justified > .active > a:hover, + .nav-tabs-justified > .active > a:focus { + border-bottom-color: #fff; + } +} +.tab-content > .tab-pane { + display: none; +} +.tab-content > .active { + display: block; +} +.nav-tabs .dropdown-menu { + margin-top: -1px; + border-top-left-radius: 0; + border-top-right-radius: 0; +} +.navbar { + position: relative; + min-height: 50px; + margin-bottom: 20px; + border: 1px solid transparent; +} +@media (min-width: 768px) { + .navbar { + border-radius: 4px; + } +} +@media (min-width: 768px) { + .navbar-header { + float: left; + } +} +.navbar-collapse { + padding-right: 15px; + padding-left: 15px; + overflow-x: visible; + -webkit-overflow-scrolling: touch; + border-top: 1px solid transparent; + -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1); +} +.navbar-collapse.in { + overflow-y: auto; +} +@media (min-width: 768px) { + .navbar-collapse { + width: auto; + border-top: 0; + -webkit-box-shadow: none; + box-shadow: none; + } + .navbar-collapse.collapse { + display: block !important; + height: auto !important; + padding-bottom: 0; + overflow: visible !important; + } + .navbar-collapse.in { + overflow-y: visible; + } + .navbar-fixed-top .navbar-collapse, + .navbar-static-top .navbar-collapse, + .navbar-fixed-bottom .navbar-collapse { + padding-right: 0; + padding-left: 0; + } +} +.navbar-fixed-top .navbar-collapse, +.navbar-fixed-bottom .navbar-collapse { + max-height: 340px; +} +@media (max-device-width: 480px) and (orientation: landscape) { + .navbar-fixed-top .navbar-collapse, + .navbar-fixed-bottom .navbar-collapse { + max-height: 200px; + } +} +.container > .navbar-header, +.container-fluid > .navbar-header, +.container > .navbar-collapse, +.container-fluid > .navbar-collapse { + margin-right: -15px; + margin-left: -15px; +} +@media (min-width: 768px) { + .container > .navbar-header, + .container-fluid > .navbar-header, + .container > .navbar-collapse, + .container-fluid > .navbar-collapse { + margin-right: 0; + margin-left: 0; + } +} +.navbar-static-top { + z-index: 1000; + border-width: 0 0 1px; +} +@media (min-width: 768px) { + .navbar-static-top { + border-radius: 0; + } +} +.navbar-fixed-top, +.navbar-fixed-bottom { + position: fixed; + right: 0; + left: 0; + z-index: 1030; +} +@media (min-width: 768px) { + .navbar-fixed-top, + .navbar-fixed-bottom { + border-radius: 0; + } +} +.navbar-fixed-top { + top: 0; + border-width: 0 0 1px; +} +.navbar-fixed-bottom { + bottom: 0; + margin-bottom: 0; + border-width: 1px 0 0; +} +.navbar-brand { + float: left; + height: 50px; + padding: 15px 15px; + font-size: 18px; + line-height: 20px; +} +.navbar-brand:hover, +.navbar-brand:focus { + text-decoration: none; +} +.navbar-brand > img { + display: block; +} +@media (min-width: 768px) { + .navbar > .container .navbar-brand, + .navbar > .container-fluid .navbar-brand { + margin-left: -15px; + } +} +.navbar-toggle { + position: relative; + float: right; + padding: 9px 10px; + margin-top: 8px; + margin-right: 15px; + margin-bottom: 8px; + background-color: transparent; + background-image: none; + border: 1px solid transparent; + border-radius: 4px; +} +.navbar-toggle:focus { + outline: 0; +} +.navbar-toggle .icon-bar { + display: block; + width: 22px; + height: 2px; + border-radius: 1px; +} +.navbar-toggle .icon-bar + .icon-bar { + margin-top: 4px; +} +@media (min-width: 768px) { + .navbar-toggle { + display: none; + } +} +.navbar-nav { + margin: 7.5px -15px; +} +.navbar-nav > li > a { + padding-top: 10px; + padding-bottom: 10px; + line-height: 20px; +} +@media (max-width: 767px) { + .navbar-nav .open .dropdown-menu { + position: static; + float: none; + width: auto; + margin-top: 0; + background-color: transparent; + border: 0; + -webkit-box-shadow: none; + box-shadow: none; + } + .navbar-nav .open .dropdown-menu > li > a, + .navbar-nav .open .dropdown-menu .dropdown-header { + padding: 5px 15px 5px 25px; + } + .navbar-nav .open .dropdown-menu > li > a { + line-height: 20px; + } + .navbar-nav .open .dropdown-menu > li > a:hover, + .navbar-nav .open .dropdown-menu > li > a:focus { + background-image: none; + } +} +@media (min-width: 768px) { + .navbar-nav { + float: left; + margin: 0; + } + .navbar-nav > li { + float: left; + } + .navbar-nav > li > a { + padding-top: 15px; + padding-bottom: 15px; + } +} +.navbar-form { + padding: 10px 15px; + margin-top: 8px; + margin-right: -15px; + margin-bottom: 8px; + margin-left: -15px; + border-top: 1px solid transparent; + border-bottom: 1px solid transparent; + -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1), 0 1px 0 rgba(255, 255, 255, .1); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1), 0 1px 0 rgba(255, 255, 255, .1); +} +@media (min-width: 768px) { + .navbar-form .form-group { + display: inline-block; + margin-bottom: 0; + vertical-align: middle; + } + .navbar-form .form-control { + display: inline-block; + width: auto; + vertical-align: middle; + } + .navbar-form .form-control-static { + display: inline-block; + } + .navbar-form .input-group { + display: inline-table; + vertical-align: middle; + } + .navbar-form .input-group .input-group-addon, + .navbar-form .input-group .input-group-btn, + .navbar-form .input-group .form-control { + width: auto; + } + .navbar-form .input-group > .form-control { + width: 100%; + } + .navbar-form .control-label { + margin-bottom: 0; + vertical-align: middle; + } + .navbar-form .radio, + .navbar-form .checkbox { + display: inline-block; + margin-top: 0; + margin-bottom: 0; + vertical-align: middle; + } + .navbar-form .radio label, + .navbar-form .checkbox label { + padding-left: 0; + } + .navbar-form .radio input[type="radio"], + .navbar-form .checkbox input[type="checkbox"] { + position: relative; + margin-left: 0; + } + .navbar-form .has-feedback .form-control-feedback { + top: 0; + } +} +@media (max-width: 767px) { + .navbar-form .form-group { + margin-bottom: 5px; + } + .navbar-form .form-group:last-child { + margin-bottom: 0; + } +} +@media (min-width: 768px) { + .navbar-form { + width: auto; + padding-top: 0; + padding-bottom: 0; + margin-right: 0; + margin-left: 0; + border: 0; + -webkit-box-shadow: none; + box-shadow: none; + } +} +.navbar-nav > li > .dropdown-menu { + margin-top: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; +} +.navbar-fixed-bottom .navbar-nav > li > .dropdown-menu { + margin-bottom: 0; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.navbar-btn { + margin-top: 8px; + margin-bottom: 8px; +} +.navbar-btn.btn-sm { + margin-top: 10px; + margin-bottom: 10px; +} +.navbar-btn.btn-xs { + margin-top: 14px; + margin-bottom: 14px; +} +.navbar-text { + margin-top: 15px; + margin-bottom: 15px; +} +@media (min-width: 768px) { + .navbar-text { + float: left; + margin-right: 15px; + margin-left: 15px; + } +} +@media (min-width: 768px) { + .navbar-left { + float: left !important; + } + .navbar-right { + float: right !important; + margin-right: -15px; + } + .navbar-right ~ .navbar-right { + margin-right: 0; + } +} +.navbar-default { + background-color: #f8f8f8; + border-color: #e7e7e7; +} +.navbar-default .navbar-brand { + color: #777; +} +.navbar-default .navbar-brand:hover, +.navbar-default .navbar-brand:focus { + color: #5e5e5e; + background-color: transparent; +} +.navbar-default .navbar-text { + color: #777; +} +.navbar-default .navbar-nav > li > a { + color: #777; +} +.navbar-default .navbar-nav > li > a:hover, +.navbar-default .navbar-nav > li > a:focus { + color: #333; + background-color: transparent; +} +.navbar-default .navbar-nav > .active > a, +.navbar-default .navbar-nav > .active > a:hover, +.navbar-default .navbar-nav > .active > a:focus { + color: #555; + background-color: #e7e7e7; +} +.navbar-default .navbar-nav > .disabled > a, +.navbar-default .navbar-nav > .disabled > a:hover, +.navbar-default .navbar-nav > .disabled > a:focus { + color: #ccc; + background-color: transparent; +} +.navbar-default .navbar-toggle { + border-color: #ddd; +} +.navbar-default .navbar-toggle:hover, +.navbar-default .navbar-toggle:focus { + background-color: #ddd; +} +.navbar-default .navbar-toggle .icon-bar { + background-color: #888; +} +.navbar-default .navbar-collapse, +.navbar-default .navbar-form { + border-color: #e7e7e7; +} +.navbar-default .navbar-nav > .open > a, +.navbar-default .navbar-nav > .open > a:hover, +.navbar-default .navbar-nav > .open > a:focus { + color: #555; + background-color: #e7e7e7; +} +@media (max-width: 767px) { + .navbar-default .navbar-nav .open .dropdown-menu > li > a { + color: #777; + } + .navbar-default .navbar-nav .open .dropdown-menu > li > a:hover, + .navbar-default .navbar-nav .open .dropdown-menu > li > a:focus { + color: #333; + background-color: transparent; + } + .navbar-default .navbar-nav .open .dropdown-menu > .active > a, + .navbar-default .navbar-nav .open .dropdown-menu > .active > a:hover, + .navbar-default .navbar-nav .open .dropdown-menu > .active > a:focus { + color: #555; + background-color: #e7e7e7; + } + .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a, + .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:hover, + .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:focus { + color: #ccc; + background-color: transparent; + } +} +.navbar-default .navbar-link { + color: #777; +} +.navbar-default .navbar-link:hover { + color: #333; +} +.navbar-default .btn-link { + color: #777; +} +.navbar-default .btn-link:hover, +.navbar-default .btn-link:focus { + color: #333; +} +.navbar-default .btn-link[disabled]:hover, +fieldset[disabled] .navbar-default .btn-link:hover, +.navbar-default .btn-link[disabled]:focus, +fieldset[disabled] .navbar-default .btn-link:focus { + color: #ccc; +} +.navbar-inverse { + background-color: #222; + border-color: #080808; +} +.navbar-inverse .navbar-brand { + color: #9d9d9d; +} +.navbar-inverse .navbar-brand:hover, +.navbar-inverse .navbar-brand:focus { + color: #fff; + background-color: transparent; +} +.navbar-inverse .navbar-text { + color: #9d9d9d; +} +.navbar-inverse .navbar-nav > li > a { + color: #9d9d9d; +} +.navbar-inverse .navbar-nav > li > a:hover, +.navbar-inverse .navbar-nav > li > a:focus { + color: #fff; + background-color: transparent; +} +.navbar-inverse .navbar-nav > .active > a, +.navbar-inverse .navbar-nav > .active > a:hover, +.navbar-inverse .navbar-nav > .active > a:focus { + color: #fff; + background-color: #080808; +} +.navbar-inverse .navbar-nav > .disabled > a, +.navbar-inverse .navbar-nav > .disabled > a:hover, +.navbar-inverse .navbar-nav > .disabled > a:focus { + color: #444; + background-color: transparent; +} +.navbar-inverse .navbar-toggle { + border-color: #333; +} +.navbar-inverse .navbar-toggle:hover, +.navbar-inverse .navbar-toggle:focus { + background-color: #333; +} +.navbar-inverse .navbar-toggle .icon-bar { + background-color: #fff; +} +.navbar-inverse .navbar-collapse, +.navbar-inverse .navbar-form { + border-color: #101010; +} +.navbar-inverse .navbar-nav > .open > a, +.navbar-inverse .navbar-nav > .open > a:hover, +.navbar-inverse .navbar-nav > .open > a:focus { + color: #fff; + background-color: #080808; +} +@media (max-width: 767px) { + .navbar-inverse .navbar-nav .open .dropdown-menu > .dropdown-header { + border-color: #080808; + } + .navbar-inverse .navbar-nav .open .dropdown-menu .divider { + background-color: #080808; + } + .navbar-inverse .navbar-nav .open .dropdown-menu > li > a { + color: #9d9d9d; + } + .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:hover, + .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:focus { + color: #fff; + background-color: transparent; + } + .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a, + .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:hover, + .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:focus { + color: #fff; + background-color: #080808; + } + .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a, + .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:hover, + .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:focus { + color: #444; + background-color: transparent; + } +} +.navbar-inverse .navbar-link { + color: #9d9d9d; +} +.navbar-inverse .navbar-link:hover { + color: #fff; +} +.navbar-inverse .btn-link { + color: #9d9d9d; +} +.navbar-inverse .btn-link:hover, +.navbar-inverse .btn-link:focus { + color: #fff; +} +.navbar-inverse .btn-link[disabled]:hover, +fieldset[disabled] .navbar-inverse .btn-link:hover, +.navbar-inverse .btn-link[disabled]:focus, +fieldset[disabled] .navbar-inverse .btn-link:focus { + color: #444; +} +.breadcrumb { + padding: 8px 15px; + margin-bottom: 20px; + list-style: none; + background-color: #f5f5f5; + border-radius: 4px; +} +.breadcrumb > li { + display: inline-block; +} +.breadcrumb > li + li:before { + padding: 0 5px; + color: #ccc; + content: "/\00a0"; +} +.breadcrumb > .active { + color: #777; +} +.pagination { + display: inline-block; + padding-left: 0; + margin: 20px 0; + border-radius: 4px; +} +.pagination > li { + display: inline; +} +.pagination > li > a, +.pagination > li > span { + position: relative; + float: left; + padding: 6px 12px; + margin-left: -1px; + line-height: 1.42857143; + color: #337ab7; + text-decoration: none; + background-color: #fff; + border: 1px solid #ddd; +} +.pagination > li:first-child > a, +.pagination > li:first-child > span { + margin-left: 0; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; +} +.pagination > li:last-child > a, +.pagination > li:last-child > span { + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; +} +.pagination > li > a:hover, +.pagination > li > span:hover, +.pagination > li > a:focus, +.pagination > li > span:focus { + z-index: 2; + color: #23527c; + background-color: #eee; + border-color: #ddd; +} +.pagination > .active > a, +.pagination > .active > span, +.pagination > .active > a:hover, +.pagination > .active > span:hover, +.pagination > .active > a:focus, +.pagination > .active > span:focus { + z-index: 3; + color: #fff; + cursor: default; + background-color: #337ab7; + border-color: #337ab7; +} +.pagination > .disabled > span, +.pagination > .disabled > span:hover, +.pagination > .disabled > span:focus, +.pagination > .disabled > a, +.pagination > .disabled > a:hover, +.pagination > .disabled > a:focus { + color: #777; + cursor: not-allowed; + background-color: #fff; + border-color: #ddd; +} +.pagination-lg > li > a, +.pagination-lg > li > span { + padding: 10px 16px; + font-size: 18px; + line-height: 1.3333333; +} +.pagination-lg > li:first-child > a, +.pagination-lg > li:first-child > span { + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; +} +.pagination-lg > li:last-child > a, +.pagination-lg > li:last-child > span { + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; +} +.pagination-sm > li > a, +.pagination-sm > li > span { + padding: 5px 10px; + font-size: 12px; + line-height: 1.5; +} +.pagination-sm > li:first-child > a, +.pagination-sm > li:first-child > span { + border-top-left-radius: 3px; + border-bottom-left-radius: 3px; +} +.pagination-sm > li:last-child > a, +.pagination-sm > li:last-child > span { + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; +} +.pager { + padding-left: 0; + margin: 20px 0; + text-align: center; + list-style: none; +} +.pager li { + display: inline; +} +.pager li > a, +.pager li > span { + display: inline-block; + padding: 5px 14px; + background-color: #fff; + border: 1px solid #ddd; + border-radius: 15px; +} +.pager li > a:hover, +.pager li > a:focus { + text-decoration: none; + background-color: #eee; +} +.pager .next > a, +.pager .next > span { + float: right; +} +.pager .previous > a, +.pager .previous > span { + float: left; +} +.pager .disabled > a, +.pager .disabled > a:hover, +.pager .disabled > a:focus, +.pager .disabled > span { + color: #777; + cursor: not-allowed; + background-color: #fff; +} +.label { + display: inline; + padding: .2em .6em .3em; + font-size: 75%; + font-weight: bold; + line-height: 1; + color: #fff; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: .25em; +} +a.label:hover, +a.label:focus { + color: #fff; + text-decoration: none; + cursor: pointer; +} +.label:empty { + display: none; +} +.btn .label { + position: relative; + top: -1px; +} +.label-default { + background-color: #777; +} +.label-default[href]:hover, +.label-default[href]:focus { + background-color: #5e5e5e; +} +.label-primary { + background-color: #337ab7; +} +.label-primary[href]:hover, +.label-primary[href]:focus { + background-color: #286090; +} +.label-success { + background-color: #5cb85c; +} +.label-success[href]:hover, +.label-success[href]:focus { + background-color: #449d44; +} +.label-info { + background-color: #5bc0de; +} +.label-info[href]:hover, +.label-info[href]:focus { + background-color: #31b0d5; +} +.label-warning { + background-color: #f0ad4e; +} +.label-warning[href]:hover, +.label-warning[href]:focus { + background-color: #ec971f; +} +.label-danger { + background-color: #d9534f; +} +.label-danger[href]:hover, +.label-danger[href]:focus { + background-color: #c9302c; +} +.badge { + display: inline-block; + min-width: 10px; + padding: 3px 7px; + font-size: 12px; + font-weight: bold; + line-height: 1; + color: #fff; + text-align: center; + white-space: nowrap; + vertical-align: middle; + background-color: #777; + border-radius: 10px; +} +.badge:empty { + display: none; +} +.btn .badge { + position: relative; + top: -1px; +} +.btn-xs .badge, +.btn-group-xs > .btn .badge { + top: 0; + padding: 1px 5px; +} +a.badge:hover, +a.badge:focus { + color: #fff; + text-decoration: none; + cursor: pointer; +} +.list-group-item.active > .badge, +.nav-pills > .active > a > .badge { + color: #337ab7; + background-color: #fff; +} +.list-group-item > .badge { + float: right; +} +.list-group-item > .badge + .badge { + margin-right: 5px; +} +.nav-pills > li > a > .badge { + margin-left: 3px; +} +.jumbotron { + padding-top: 30px; + padding-bottom: 30px; + margin-bottom: 30px; + color: inherit; + background-color: #eee; +} +.jumbotron h1, +.jumbotron .h1 { + color: inherit; +} +.jumbotron p { + margin-bottom: 15px; + font-size: 21px; + font-weight: 200; +} +.jumbotron > hr { + border-top-color: #d5d5d5; +} +.container .jumbotron, +.container-fluid .jumbotron { + padding-right: 15px; + padding-left: 15px; + border-radius: 6px; +} +.jumbotron .container { + max-width: 100%; +} +@media screen and (min-width: 768px) { + .jumbotron { + padding-top: 48px; + padding-bottom: 48px; + } + .container .jumbotron, + .container-fluid .jumbotron { + padding-right: 60px; + padding-left: 60px; + } + .jumbotron h1, + .jumbotron .h1 { + font-size: 63px; + } +} +.thumbnail { + display: block; + padding: 4px; + margin-bottom: 20px; + line-height: 1.42857143; + background-color: #fff; + border: 1px solid #ddd; + border-radius: 4px; + -webkit-transition: border .2s ease-in-out; + -o-transition: border .2s ease-in-out; + transition: border .2s ease-in-out; +} +.thumbnail > img, +.thumbnail a > img { + margin-right: auto; + margin-left: auto; +} +a.thumbnail:hover, +a.thumbnail:focus, +a.thumbnail.active { + border-color: #337ab7; +} +.thumbnail .caption { + padding: 9px; + color: #333; +} +.alert { + padding: 15px; + margin-bottom: 20px; + border: 1px solid transparent; + border-radius: 4px; +} +.alert h4 { + margin-top: 0; + color: inherit; +} +.alert .alert-link { + font-weight: bold; +} +.alert > p, +.alert > ul { + margin-bottom: 0; +} +.alert > p + p { + margin-top: 5px; +} +.alert-dismissable, +.alert-dismissible { + padding-right: 35px; +} +.alert-dismissable .close, +.alert-dismissible .close { + position: relative; + top: -2px; + right: -21px; + color: inherit; +} +.alert-success { + color: #3c763d; + background-color: #dff0d8; + border-color: #d6e9c6; +} +.alert-success hr { + border-top-color: #c9e2b3; +} +.alert-success .alert-link { + color: #2b542c; +} +.alert-info { + color: #31708f; + background-color: #d9edf7; + border-color: #bce8f1; +} +.alert-info hr { + border-top-color: #a6e1ec; +} +.alert-info .alert-link { + color: #245269; +} +.alert-warning { + color: #8a6d3b; + background-color: #fcf8e3; + border-color: #faebcc; +} +.alert-warning hr { + border-top-color: #f7e1b5; +} +.alert-warning .alert-link { + color: #66512c; +} +.alert-danger { + color: #a94442; + background-color: #f2dede; + border-color: #ebccd1; +} +.alert-danger hr { + border-top-color: #e4b9c0; +} +.alert-danger .alert-link { + color: #843534; +} +@-webkit-keyframes progress-bar-stripes { + from { + background-position: 40px 0; + } + to { + background-position: 0 0; + } +} +@-o-keyframes progress-bar-stripes { + from { + background-position: 40px 0; + } + to { + background-position: 0 0; + } +} +@keyframes progress-bar-stripes { + from { + background-position: 40px 0; + } + to { + background-position: 0 0; + } +} +.progress { + height: 20px; + margin-bottom: 20px; + overflow: hidden; + background-color: #f5f5f5; + border-radius: 4px; + -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, .1); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, .1); +} +.progress-bar { + float: left; + width: 0; + height: 100%; + font-size: 12px; + line-height: 20px; + color: #fff; + text-align: center; + background-color: #337ab7; + -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .15); + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .15); + -webkit-transition: width .6s ease; + -o-transition: width .6s ease; + transition: width .6s ease; +} +.progress-striped .progress-bar, +.progress-bar-striped { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + -webkit-background-size: 40px 40px; + background-size: 40px 40px; +} +.progress.active .progress-bar, +.progress-bar.active { + -webkit-animation: progress-bar-stripes 2s linear infinite; + -o-animation: progress-bar-stripes 2s linear infinite; + animation: progress-bar-stripes 2s linear infinite; +} +.progress-bar-success { + background-color: #5cb85c; +} +.progress-striped .progress-bar-success { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); +} +.progress-bar-info { + background-color: #5bc0de; +} +.progress-striped .progress-bar-info { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); +} +.progress-bar-warning { + background-color: #f0ad4e; +} +.progress-striped .progress-bar-warning { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); +} +.progress-bar-danger { + background-color: #d9534f; +} +.progress-striped .progress-bar-danger { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); +} +.media { + margin-top: 15px; +} +.media:first-child { + margin-top: 0; +} +.media, +.media-body { + overflow: hidden; + zoom: 1; +} +.media-body { + width: 10000px; +} +.media-object { + display: block; +} +.media-object.img-thumbnail { + max-width: none; +} +.media-right, +.media > .pull-right { + padding-left: 10px; +} +.media-left, +.media > .pull-left { + padding-right: 10px; +} +.media-left, +.media-right, +.media-body { + display: table-cell; + vertical-align: top; +} +.media-middle { + vertical-align: middle; +} +.media-bottom { + vertical-align: bottom; +} +.media-heading { + margin-top: 0; + margin-bottom: 5px; +} +.media-list { + padding-left: 0; + list-style: none; +} +.list-group { + padding-left: 0; + margin-bottom: 20px; +} +.list-group-item { + position: relative; + display: block; + padding: 10px 15px; + margin-bottom: -1px; + background-color: #fff; + border: 1px solid #ddd; +} +.list-group-item:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; +} +.list-group-item:last-child { + margin-bottom: 0; + border-bottom-right-radius: 4px; + border-bottom-left-radius: 4px; +} +a.list-group-item, +button.list-group-item { + color: #555; +} +a.list-group-item .list-group-item-heading, +button.list-group-item .list-group-item-heading { + color: #333; +} +a.list-group-item:hover, +button.list-group-item:hover, +a.list-group-item:focus, +button.list-group-item:focus { + color: #555; + text-decoration: none; + background-color: #f5f5f5; +} +button.list-group-item { + width: 100%; + text-align: left; +} +.list-group-item.disabled, +.list-group-item.disabled:hover, +.list-group-item.disabled:focus { + color: #777; + cursor: not-allowed; + background-color: #eee; +} +.list-group-item.disabled .list-group-item-heading, +.list-group-item.disabled:hover .list-group-item-heading, +.list-group-item.disabled:focus .list-group-item-heading { + color: inherit; +} +.list-group-item.disabled .list-group-item-text, +.list-group-item.disabled:hover .list-group-item-text, +.list-group-item.disabled:focus .list-group-item-text { + color: #777; +} +.list-group-item.active, +.list-group-item.active:hover, +.list-group-item.active:focus { + z-index: 2; + color: #fff; + background-color: #337ab7; + border-color: #337ab7; +} +.list-group-item.active .list-group-item-heading, +.list-group-item.active:hover .list-group-item-heading, +.list-group-item.active:focus .list-group-item-heading, +.list-group-item.active .list-group-item-heading > small, +.list-group-item.active:hover .list-group-item-heading > small, +.list-group-item.active:focus .list-group-item-heading > small, +.list-group-item.active .list-group-item-heading > .small, +.list-group-item.active:hover .list-group-item-heading > .small, +.list-group-item.active:focus .list-group-item-heading > .small { + color: inherit; +} +.list-group-item.active .list-group-item-text, +.list-group-item.active:hover .list-group-item-text, +.list-group-item.active:focus .list-group-item-text { + color: #c7ddef; +} +.list-group-item-success { + color: #3c763d; + background-color: #dff0d8; +} +a.list-group-item-success, +button.list-group-item-success { + color: #3c763d; +} +a.list-group-item-success .list-group-item-heading, +button.list-group-item-success .list-group-item-heading { + color: inherit; +} +a.list-group-item-success:hover, +button.list-group-item-success:hover, +a.list-group-item-success:focus, +button.list-group-item-success:focus { + color: #3c763d; + background-color: #d0e9c6; +} +a.list-group-item-success.active, +button.list-group-item-success.active, +a.list-group-item-success.active:hover, +button.list-group-item-success.active:hover, +a.list-group-item-success.active:focus, +button.list-group-item-success.active:focus { + color: #fff; + background-color: #3c763d; + border-color: #3c763d; +} +.list-group-item-info { + color: #31708f; + background-color: #d9edf7; +} +a.list-group-item-info, +button.list-group-item-info { + color: #31708f; +} +a.list-group-item-info .list-group-item-heading, +button.list-group-item-info .list-group-item-heading { + color: inherit; +} +a.list-group-item-info:hover, +button.list-group-item-info:hover, +a.list-group-item-info:focus, +button.list-group-item-info:focus { + color: #31708f; + background-color: #c4e3f3; +} +a.list-group-item-info.active, +button.list-group-item-info.active, +a.list-group-item-info.active:hover, +button.list-group-item-info.active:hover, +a.list-group-item-info.active:focus, +button.list-group-item-info.active:focus { + color: #fff; + background-color: #31708f; + border-color: #31708f; +} +.list-group-item-warning { + color: #8a6d3b; + background-color: #fcf8e3; +} +a.list-group-item-warning, +button.list-group-item-warning { + color: #8a6d3b; +} +a.list-group-item-warning .list-group-item-heading, +button.list-group-item-warning .list-group-item-heading { + color: inherit; +} +a.list-group-item-warning:hover, +button.list-group-item-warning:hover, +a.list-group-item-warning:focus, +button.list-group-item-warning:focus { + color: #8a6d3b; + background-color: #faf2cc; +} +a.list-group-item-warning.active, +button.list-group-item-warning.active, +a.list-group-item-warning.active:hover, +button.list-group-item-warning.active:hover, +a.list-group-item-warning.active:focus, +button.list-group-item-warning.active:focus { + color: #fff; + background-color: #8a6d3b; + border-color: #8a6d3b; +} +.list-group-item-danger { + color: #a94442; + background-color: #f2dede; +} +a.list-group-item-danger, +button.list-group-item-danger { + color: #a94442; +} +a.list-group-item-danger .list-group-item-heading, +button.list-group-item-danger .list-group-item-heading { + color: inherit; +} +a.list-group-item-danger:hover, +button.list-group-item-danger:hover, +a.list-group-item-danger:focus, +button.list-group-item-danger:focus { + color: #a94442; + background-color: #ebcccc; +} +a.list-group-item-danger.active, +button.list-group-item-danger.active, +a.list-group-item-danger.active:hover, +button.list-group-item-danger.active:hover, +a.list-group-item-danger.active:focus, +button.list-group-item-danger.active:focus { + color: #fff; + background-color: #a94442; + border-color: #a94442; +} +.list-group-item-heading { + margin-top: 0; + margin-bottom: 5px; +} +.list-group-item-text { + margin-bottom: 0; + line-height: 1.3; +} +.panel { + margin-bottom: 20px; + background-color: #fff; + border: 1px solid transparent; + border-radius: 4px; + -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, .05); + box-shadow: 0 1px 1px rgba(0, 0, 0, .05); +} +.panel-body { + padding: 15px; +} +.panel-heading { + padding: 10px 15px; + border-bottom: 1px solid transparent; + border-top-left-radius: 3px; + border-top-right-radius: 3px; +} +.panel-heading > .dropdown .dropdown-toggle { + color: inherit; +} +.panel-title { + margin-top: 0; + margin-bottom: 0; + font-size: 16px; + color: inherit; +} +.panel-title > a, +.panel-title > small, +.panel-title > .small, +.panel-title > small > a, +.panel-title > .small > a { + color: inherit; +} +.panel-footer { + padding: 10px 15px; + background-color: #f5f5f5; + border-top: 1px solid #ddd; + border-bottom-right-radius: 3px; + border-bottom-left-radius: 3px; +} +.panel > .list-group, +.panel > .panel-collapse > .list-group { + margin-bottom: 0; +} +.panel > .list-group .list-group-item, +.panel > .panel-collapse > .list-group .list-group-item { + border-width: 1px 0; + border-radius: 0; +} +.panel > .list-group:first-child .list-group-item:first-child, +.panel > .panel-collapse > .list-group:first-child .list-group-item:first-child { + border-top: 0; + border-top-left-radius: 3px; + border-top-right-radius: 3px; +} +.panel > .list-group:last-child .list-group-item:last-child, +.panel > .panel-collapse > .list-group:last-child .list-group-item:last-child { + border-bottom: 0; + border-bottom-right-radius: 3px; + border-bottom-left-radius: 3px; +} +.panel > .panel-heading + .panel-collapse > .list-group .list-group-item:first-child { + border-top-left-radius: 0; + border-top-right-radius: 0; +} +.panel-heading + .list-group .list-group-item:first-child { + border-top-width: 0; +} +.list-group + .panel-footer { + border-top-width: 0; +} +.panel > .table, +.panel > .table-responsive > .table, +.panel > .panel-collapse > .table { + margin-bottom: 0; +} +.panel > .table caption, +.panel > .table-responsive > .table caption, +.panel > .panel-collapse > .table caption { + padding-right: 15px; + padding-left: 15px; +} +.panel > .table:first-child, +.panel > .table-responsive:first-child > .table:first-child { + border-top-left-radius: 3px; + border-top-right-radius: 3px; +} +.panel > .table:first-child > thead:first-child > tr:first-child, +.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child, +.panel > .table:first-child > tbody:first-child > tr:first-child, +.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child { + border-top-left-radius: 3px; + border-top-right-radius: 3px; +} +.panel > .table:first-child > thead:first-child > tr:first-child td:first-child, +.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:first-child, +.panel > .table:first-child > tbody:first-child > tr:first-child td:first-child, +.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:first-child, +.panel > .table:first-child > thead:first-child > tr:first-child th:first-child, +.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:first-child, +.panel > .table:first-child > tbody:first-child > tr:first-child th:first-child, +.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:first-child { + border-top-left-radius: 3px; +} +.panel > .table:first-child > thead:first-child > tr:first-child td:last-child, +.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:last-child, +.panel > .table:first-child > tbody:first-child > tr:first-child td:last-child, +.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:last-child, +.panel > .table:first-child > thead:first-child > tr:first-child th:last-child, +.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:last-child, +.panel > .table:first-child > tbody:first-child > tr:first-child th:last-child, +.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:last-child { + border-top-right-radius: 3px; +} +.panel > .table:last-child, +.panel > .table-responsive:last-child > .table:last-child { + border-bottom-right-radius: 3px; + border-bottom-left-radius: 3px; +} +.panel > .table:last-child > tbody:last-child > tr:last-child, +.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child, +.panel > .table:last-child > tfoot:last-child > tr:last-child, +.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child { + border-bottom-right-radius: 3px; + border-bottom-left-radius: 3px; +} +.panel > .table:last-child > tbody:last-child > tr:last-child td:first-child, +.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:first-child, +.panel > .table:last-child > tfoot:last-child > tr:last-child td:first-child, +.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:first-child, +.panel > .table:last-child > tbody:last-child > tr:last-child th:first-child, +.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:first-child, +.panel > .table:last-child > tfoot:last-child > tr:last-child th:first-child, +.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:first-child { + border-bottom-left-radius: 3px; +} +.panel > .table:last-child > tbody:last-child > tr:last-child td:last-child, +.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:last-child, +.panel > .table:last-child > tfoot:last-child > tr:last-child td:last-child, +.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:last-child, +.panel > .table:last-child > tbody:last-child > tr:last-child th:last-child, +.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:last-child, +.panel > .table:last-child > tfoot:last-child > tr:last-child th:last-child, +.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:last-child { + border-bottom-right-radius: 3px; +} +.panel > .panel-body + .table, +.panel > .panel-body + .table-responsive, +.panel > .table + .panel-body, +.panel > .table-responsive + .panel-body { + border-top: 1px solid #ddd; +} +.panel > .table > tbody:first-child > tr:first-child th, +.panel > .table > tbody:first-child > tr:first-child td { + border-top: 0; +} +.panel > .table-bordered, +.panel > .table-responsive > .table-bordered { + border: 0; +} +.panel > .table-bordered > thead > tr > th:first-child, +.panel > .table-responsive > .table-bordered > thead > tr > th:first-child, +.panel > .table-bordered > tbody > tr > th:first-child, +.panel > .table-responsive > .table-bordered > tbody > tr > th:first-child, +.panel > .table-bordered > tfoot > tr > th:first-child, +.panel > .table-responsive > .table-bordered > tfoot > tr > th:first-child, +.panel > .table-bordered > thead > tr > td:first-child, +.panel > .table-responsive > .table-bordered > thead > tr > td:first-child, +.panel > .table-bordered > tbody > tr > td:first-child, +.panel > .table-responsive > .table-bordered > tbody > tr > td:first-child, +.panel > .table-bordered > tfoot > tr > td:first-child, +.panel > .table-responsive > .table-bordered > tfoot > tr > td:first-child { + border-left: 0; +} +.panel > .table-bordered > thead > tr > th:last-child, +.panel > .table-responsive > .table-bordered > thead > tr > th:last-child, +.panel > .table-bordered > tbody > tr > th:last-child, +.panel > .table-responsive > .table-bordered > tbody > tr > th:last-child, +.panel > .table-bordered > tfoot > tr > th:last-child, +.panel > .table-responsive > .table-bordered > tfoot > tr > th:last-child, +.panel > .table-bordered > thead > tr > td:last-child, +.panel > .table-responsive > .table-bordered > thead > tr > td:last-child, +.panel > .table-bordered > tbody > tr > td:last-child, +.panel > .table-responsive > .table-bordered > tbody > tr > td:last-child, +.panel > .table-bordered > tfoot > tr > td:last-child, +.panel > .table-responsive > .table-bordered > tfoot > tr > td:last-child { + border-right: 0; +} +.panel > .table-bordered > thead > tr:first-child > td, +.panel > .table-responsive > .table-bordered > thead > tr:first-child > td, +.panel > .table-bordered > tbody > tr:first-child > td, +.panel > .table-responsive > .table-bordered > tbody > tr:first-child > td, +.panel > .table-bordered > thead > tr:first-child > th, +.panel > .table-responsive > .table-bordered > thead > tr:first-child > th, +.panel > .table-bordered > tbody > tr:first-child > th, +.panel > .table-responsive > .table-bordered > tbody > tr:first-child > th { + border-bottom: 0; +} +.panel > .table-bordered > tbody > tr:last-child > td, +.panel > .table-responsive > .table-bordered > tbody > tr:last-child > td, +.panel > .table-bordered > tfoot > tr:last-child > td, +.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > td, +.panel > .table-bordered > tbody > tr:last-child > th, +.panel > .table-responsive > .table-bordered > tbody > tr:last-child > th, +.panel > .table-bordered > tfoot > tr:last-child > th, +.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > th { + border-bottom: 0; +} +.panel > .table-responsive { + margin-bottom: 0; + border: 0; +} +.panel-group { + margin-bottom: 20px; +} +.panel-group .panel { + margin-bottom: 0; + border-radius: 4px; +} +.panel-group .panel + .panel { + margin-top: 5px; +} +.panel-group .panel-heading { + border-bottom: 0; +} +.panel-group .panel-heading + .panel-collapse > .panel-body, +.panel-group .panel-heading + .panel-collapse > .list-group { + border-top: 1px solid #ddd; +} +.panel-group .panel-footer { + border-top: 0; +} +.panel-group .panel-footer + .panel-collapse .panel-body { + border-bottom: 1px solid #ddd; +} +.panel-default { + border-color: #ddd; +} +.panel-default > .panel-heading { + color: #333; + background-color: #f5f5f5; + border-color: #ddd; +} +.panel-default > .panel-heading + .panel-collapse > .panel-body { + border-top-color: #ddd; +} +.panel-default > .panel-heading .badge { + color: #f5f5f5; + background-color: #333; +} +.panel-default > .panel-footer + .panel-collapse > .panel-body { + border-bottom-color: #ddd; +} +.panel-primary { + border-color: #337ab7; +} +.panel-primary > .panel-heading { + color: #fff; + background-color: #337ab7; + border-color: #337ab7; +} +.panel-primary > .panel-heading + .panel-collapse > .panel-body { + border-top-color: #337ab7; +} +.panel-primary > .panel-heading .badge { + color: #337ab7; + background-color: #fff; +} +.panel-primary > .panel-footer + .panel-collapse > .panel-body { + border-bottom-color: #337ab7; +} +.panel-success { + border-color: #d6e9c6; +} +.panel-success > .panel-heading { + color: #3c763d; + background-color: #dff0d8; + border-color: #d6e9c6; +} +.panel-success > .panel-heading + .panel-collapse > .panel-body { + border-top-color: #d6e9c6; +} +.panel-success > .panel-heading .badge { + color: #dff0d8; + background-color: #3c763d; +} +.panel-success > .panel-footer + .panel-collapse > .panel-body { + border-bottom-color: #d6e9c6; +} +.panel-info { + border-color: #bce8f1; +} +.panel-info > .panel-heading { + color: #31708f; + background-color: #d9edf7; + border-color: #bce8f1; +} +.panel-info > .panel-heading + .panel-collapse > .panel-body { + border-top-color: #bce8f1; +} +.panel-info > .panel-heading .badge { + color: #d9edf7; + background-color: #31708f; +} +.panel-info > .panel-footer + .panel-collapse > .panel-body { + border-bottom-color: #bce8f1; +} +.panel-warning { + border-color: #faebcc; +} +.panel-warning > .panel-heading { + color: #8a6d3b; + background-color: #fcf8e3; + border-color: #faebcc; +} +.panel-warning > .panel-heading + .panel-collapse > .panel-body { + border-top-color: #faebcc; +} +.panel-warning > .panel-heading .badge { + color: #fcf8e3; + background-color: #8a6d3b; +} +.panel-warning > .panel-footer + .panel-collapse > .panel-body { + border-bottom-color: #faebcc; +} +.panel-danger { + border-color: #ebccd1; +} +.panel-danger > .panel-heading { + color: #a94442; + background-color: #f2dede; + border-color: #ebccd1; +} +.panel-danger > .panel-heading + .panel-collapse > .panel-body { + border-top-color: #ebccd1; +} +.panel-danger > .panel-heading .badge { + color: #f2dede; + background-color: #a94442; +} +.panel-danger > .panel-footer + .panel-collapse > .panel-body { + border-bottom-color: #ebccd1; +} +.embed-responsive { + position: relative; + display: block; + height: 0; + padding: 0; + overflow: hidden; +} +.embed-responsive .embed-responsive-item, +.embed-responsive iframe, +.embed-responsive embed, +.embed-responsive object, +.embed-responsive video { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 100%; + height: 100%; + border: 0; +} +.embed-responsive-16by9 { + padding-bottom: 56.25%; +} +.embed-responsive-4by3 { + padding-bottom: 75%; +} +.well { + min-height: 20px; + padding: 19px; + margin-bottom: 20px; + background-color: #f5f5f5; + border: 1px solid #e3e3e3; + border-radius: 4px; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .05); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .05); +} +.well blockquote { + border-color: #ddd; + border-color: rgba(0, 0, 0, .15); +} +.well-lg { + padding: 24px; + border-radius: 6px; +} +.well-sm { + padding: 9px; + border-radius: 3px; +} +.close { + float: right; + font-size: 21px; + font-weight: bold; + line-height: 1; + color: #000; + text-shadow: 0 1px 0 #fff; + filter: alpha(opacity=20); + opacity: .2; +} +.close:hover, +.close:focus { + color: #000; + text-decoration: none; + cursor: pointer; + filter: alpha(opacity=50); + opacity: .5; +} +button.close { + -webkit-appearance: none; + padding: 0; + cursor: pointer; + background: transparent; + border: 0; +} +.modal-open { + overflow: hidden; +} +.modal { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1050; + display: none; + overflow: hidden; + -webkit-overflow-scrolling: touch; + outline: 0; +} +.modal.fade .modal-dialog { + -webkit-transition: -webkit-transform .3s ease-out; + -o-transition: -o-transform .3s ease-out; + transition: transform .3s ease-out; + -webkit-transform: translate(0, -25%); + -ms-transform: translate(0, -25%); + -o-transform: translate(0, -25%); + transform: translate(0, -25%); +} +.modal.in .modal-dialog { + -webkit-transform: translate(0, 0); + -ms-transform: translate(0, 0); + -o-transform: translate(0, 0); + transform: translate(0, 0); +} +.modal-open .modal { + overflow-x: hidden; + overflow-y: auto; +} +.modal-dialog { + position: relative; + width: auto; + margin: 10px; +} +.modal-content { + position: relative; + background-color: #fff; + -webkit-background-clip: padding-box; + background-clip: padding-box; + border: 1px solid #999; + border: 1px solid rgba(0, 0, 0, .2); + border-radius: 6px; + outline: 0; + -webkit-box-shadow: 0 3px 9px rgba(0, 0, 0, .5); + box-shadow: 0 3px 9px rgba(0, 0, 0, .5); +} +.modal-backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1040; + background-color: #000; +} +.modal-backdrop.fade { + filter: alpha(opacity=0); + opacity: 0; +} +.modal-backdrop.in { + filter: alpha(opacity=50); + opacity: .5; +} +.modal-header { + padding: 15px; + border-bottom: 1px solid #e5e5e5; +} +.modal-header .close { + margin-top: -2px; +} +.modal-title { + margin: 0; + line-height: 1.42857143; +} +.modal-body { + position: relative; + padding: 15px; +} +.modal-footer { + padding: 15px; + text-align: right; + border-top: 1px solid #e5e5e5; +} +.modal-footer .btn + .btn { + margin-bottom: 0; + margin-left: 5px; +} +.modal-footer .btn-group .btn + .btn { + margin-left: -1px; +} +.modal-footer .btn-block + .btn-block { + margin-left: 0; +} +.modal-scrollbar-measure { + position: absolute; + top: -9999px; + width: 50px; + height: 50px; + overflow: scroll; +} +@media (min-width: 768px) { + .modal-dialog { + width: 600px; + margin: 30px auto; + } + .modal-content { + -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, .5); + box-shadow: 0 5px 15px rgba(0, 0, 0, .5); + } + .modal-sm { + width: 300px; + } +} +@media (min-width: 992px) { + .modal-lg { + width: 900px; + } +} +.tooltip { + position: absolute; + z-index: 1070; + display: block; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 12px; + font-style: normal; + font-weight: normal; + line-height: 1.42857143; + text-align: left; + text-align: start; + text-decoration: none; + text-shadow: none; + text-transform: none; + letter-spacing: normal; + word-break: normal; + word-spacing: normal; + word-wrap: normal; + white-space: normal; + filter: alpha(opacity=0); + opacity: 0; + + line-break: auto; +} +.tooltip.in { + filter: alpha(opacity=90); + opacity: .9; +} +.tooltip.top { + padding: 5px 0; + margin-top: -3px; +} +.tooltip.right { + padding: 0 5px; + margin-left: 3px; +} +.tooltip.bottom { + padding: 5px 0; + margin-top: 3px; +} +.tooltip.left { + padding: 0 5px; + margin-left: -3px; +} +.tooltip-inner { + max-width: 200px; + padding: 3px 8px; + color: #fff; + text-align: center; + background-color: #000; + border-radius: 4px; +} +.tooltip-arrow { + position: absolute; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; +} +.tooltip.top .tooltip-arrow { + bottom: 0; + left: 50%; + margin-left: -5px; + border-width: 5px 5px 0; + border-top-color: #000; +} +.tooltip.top-left .tooltip-arrow { + right: 5px; + bottom: 0; + margin-bottom: -5px; + border-width: 5px 5px 0; + border-top-color: #000; +} +.tooltip.top-right .tooltip-arrow { + bottom: 0; + left: 5px; + margin-bottom: -5px; + border-width: 5px 5px 0; + border-top-color: #000; +} +.tooltip.right .tooltip-arrow { + top: 50%; + left: 0; + margin-top: -5px; + border-width: 5px 5px 5px 0; + border-right-color: #000; +} +.tooltip.left .tooltip-arrow { + top: 50%; + right: 0; + margin-top: -5px; + border-width: 5px 0 5px 5px; + border-left-color: #000; +} +.tooltip.bottom .tooltip-arrow { + top: 0; + left: 50%; + margin-left: -5px; + border-width: 0 5px 5px; + border-bottom-color: #000; +} +.tooltip.bottom-left .tooltip-arrow { + top: 0; + right: 5px; + margin-top: -5px; + border-width: 0 5px 5px; + border-bottom-color: #000; +} +.tooltip.bottom-right .tooltip-arrow { + top: 0; + left: 5px; + margin-top: -5px; + border-width: 0 5px 5px; + border-bottom-color: #000; +} +.popover { + position: absolute; + top: 0; + left: 0; + z-index: 1060; + display: none; + max-width: 276px; + padding: 1px; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 14px; + font-style: normal; + font-weight: normal; + line-height: 1.42857143; + text-align: left; + text-align: start; + text-decoration: none; + text-shadow: none; + text-transform: none; + letter-spacing: normal; + word-break: normal; + word-spacing: normal; + word-wrap: normal; + white-space: normal; + background-color: #fff; + -webkit-background-clip: padding-box; + background-clip: padding-box; + border: 1px solid #ccc; + border: 1px solid rgba(0, 0, 0, .2); + border-radius: 6px; + -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, .2); + box-shadow: 0 5px 10px rgba(0, 0, 0, .2); + + line-break: auto; +} +.popover.top { + margin-top: -10px; +} +.popover.right { + margin-left: 10px; +} +.popover.bottom { + margin-top: 10px; +} +.popover.left { + margin-left: -10px; +} +.popover-title { + padding: 8px 14px; + margin: 0; + font-size: 14px; + background-color: #f7f7f7; + border-bottom: 1px solid #ebebeb; + border-radius: 5px 5px 0 0; +} +.popover-content { + padding: 9px 14px; +} +.popover > .arrow, +.popover > .arrow:after { + position: absolute; + display: block; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; +} +.popover > .arrow { + border-width: 11px; +} +.popover > .arrow:after { + content: ""; + border-width: 10px; +} +.popover.top > .arrow { + bottom: -11px; + left: 50%; + margin-left: -11px; + border-top-color: #999; + border-top-color: rgba(0, 0, 0, .25); + border-bottom-width: 0; +} +.popover.top > .arrow:after { + bottom: 1px; + margin-left: -10px; + content: " "; + border-top-color: #fff; + border-bottom-width: 0; +} +.popover.right > .arrow { + top: 50%; + left: -11px; + margin-top: -11px; + border-right-color: #999; + border-right-color: rgba(0, 0, 0, .25); + border-left-width: 0; +} +.popover.right > .arrow:after { + bottom: -10px; + left: 1px; + content: " "; + border-right-color: #fff; + border-left-width: 0; +} +.popover.bottom > .arrow { + top: -11px; + left: 50%; + margin-left: -11px; + border-top-width: 0; + border-bottom-color: #999; + border-bottom-color: rgba(0, 0, 0, .25); +} +.popover.bottom > .arrow:after { + top: 1px; + margin-left: -10px; + content: " "; + border-top-width: 0; + border-bottom-color: #fff; +} +.popover.left > .arrow { + top: 50%; + right: -11px; + margin-top: -11px; + border-right-width: 0; + border-left-color: #999; + border-left-color: rgba(0, 0, 0, .25); +} +.popover.left > .arrow:after { + right: 1px; + bottom: -10px; + content: " "; + border-right-width: 0; + border-left-color: #fff; +} +.carousel { + position: relative; +} +.carousel-inner { + position: relative; + width: 100%; + overflow: hidden; +} +.carousel-inner > .item { + position: relative; + display: none; + -webkit-transition: .6s ease-in-out left; + -o-transition: .6s ease-in-out left; + transition: .6s ease-in-out left; +} +.carousel-inner > .item > img, +.carousel-inner > .item > a > img { + line-height: 1; +} +@media all and (transform-3d), (-webkit-transform-3d) { + .carousel-inner > .item { + -webkit-transition: -webkit-transform .6s ease-in-out; + -o-transition: -o-transform .6s ease-in-out; + transition: transform .6s ease-in-out; + + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + -webkit-perspective: 1000px; + perspective: 1000px; + } + .carousel-inner > .item.next, + .carousel-inner > .item.active.right { + left: 0; + -webkit-transform: translate3d(100%, 0, 0); + transform: translate3d(100%, 0, 0); + } + .carousel-inner > .item.prev, + .carousel-inner > .item.active.left { + left: 0; + -webkit-transform: translate3d(-100%, 0, 0); + transform: translate3d(-100%, 0, 0); + } + .carousel-inner > .item.next.left, + .carousel-inner > .item.prev.right, + .carousel-inner > .item.active { + left: 0; + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + } +} +.carousel-inner > .active, +.carousel-inner > .next, +.carousel-inner > .prev { + display: block; +} +.carousel-inner > .active { + left: 0; +} +.carousel-inner > .next, +.carousel-inner > .prev { + position: absolute; + top: 0; + width: 100%; +} +.carousel-inner > .next { + left: 100%; +} +.carousel-inner > .prev { + left: -100%; +} +.carousel-inner > .next.left, +.carousel-inner > .prev.right { + left: 0; +} +.carousel-inner > .active.left { + left: -100%; +} +.carousel-inner > .active.right { + left: 100%; +} +.carousel-control { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 15%; + font-size: 20px; + color: #fff; + text-align: center; + text-shadow: 0 1px 2px rgba(0, 0, 0, .6); + background-color: rgba(0, 0, 0, 0); + filter: alpha(opacity=50); + opacity: .5; +} +.carousel-control.left { + background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, .0001) 100%); + background-image: -o-linear-gradient(left, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, .0001) 100%); + background-image: -webkit-gradient(linear, left top, right top, from(rgba(0, 0, 0, .5)), to(rgba(0, 0, 0, .0001))); + background-image: linear-gradient(to right, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, .0001) 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1); + background-repeat: repeat-x; +} +.carousel-control.right { + right: 0; + left: auto; + background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, .0001) 0%, rgba(0, 0, 0, .5) 100%); + background-image: -o-linear-gradient(left, rgba(0, 0, 0, .0001) 0%, rgba(0, 0, 0, .5) 100%); + background-image: -webkit-gradient(linear, left top, right top, from(rgba(0, 0, 0, .0001)), to(rgba(0, 0, 0, .5))); + background-image: linear-gradient(to right, rgba(0, 0, 0, .0001) 0%, rgba(0, 0, 0, .5) 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1); + background-repeat: repeat-x; +} +.carousel-control:hover, +.carousel-control:focus { + color: #fff; + text-decoration: none; + filter: alpha(opacity=90); + outline: 0; + opacity: .9; +} +.carousel-control .icon-prev, +.carousel-control .icon-next, +.carousel-control .glyphicon-chevron-left, +.carousel-control .glyphicon-chevron-right { + position: absolute; + top: 50%; + z-index: 5; + display: inline-block; + margin-top: -10px; +} +.carousel-control .icon-prev, +.carousel-control .glyphicon-chevron-left { + left: 50%; + margin-left: -10px; +} +.carousel-control .icon-next, +.carousel-control .glyphicon-chevron-right { + right: 50%; + margin-right: -10px; +} +.carousel-control .icon-prev, +.carousel-control .icon-next { + width: 20px; + height: 20px; + font-family: serif; + line-height: 1; +} +.carousel-control .icon-prev:before { + content: '\2039'; +} +.carousel-control .icon-next:before { + content: '\203a'; +} +.carousel-indicators { + position: absolute; + bottom: 10px; + left: 50%; + z-index: 15; + width: 60%; + padding-left: 0; + margin-left: -30%; + text-align: center; + list-style: none; +} +.carousel-indicators li { + display: inline-block; + width: 10px; + height: 10px; + margin: 1px; + text-indent: -999px; + cursor: pointer; + background-color: #000 \9; + background-color: rgba(0, 0, 0, 0); + border: 1px solid #fff; + border-radius: 10px; +} +.carousel-indicators .active { + width: 12px; + height: 12px; + margin: 0; + background-color: #fff; +} +.carousel-caption { + position: absolute; + right: 15%; + bottom: 20px; + left: 15%; + z-index: 10; + padding-top: 20px; + padding-bottom: 20px; + color: #fff; + text-align: center; + text-shadow: 0 1px 2px rgba(0, 0, 0, .6); +} +.carousel-caption .btn { + text-shadow: none; +} +@media screen and (min-width: 768px) { + .carousel-control .glyphicon-chevron-left, + .carousel-control .glyphicon-chevron-right, + .carousel-control .icon-prev, + .carousel-control .icon-next { + width: 30px; + height: 30px; + margin-top: -10px; + font-size: 30px; + } + .carousel-control .glyphicon-chevron-left, + .carousel-control .icon-prev { + margin-left: -10px; + } + .carousel-control .glyphicon-chevron-right, + .carousel-control .icon-next { + margin-right: -10px; + } + .carousel-caption { + right: 20%; + left: 20%; + padding-bottom: 30px; + } + .carousel-indicators { + bottom: 20px; + } +} +.clearfix:before, +.clearfix:after, +.dl-horizontal dd:before, +.dl-horizontal dd:after, +.container:before, +.container:after, +.container-fluid:before, +.container-fluid:after, +.row:before, +.row:after, +.form-horizontal .form-group:before, +.form-horizontal .form-group:after, +.btn-toolbar:before, +.btn-toolbar:after, +.btn-group-vertical > .btn-group:before, +.btn-group-vertical > .btn-group:after, +.nav:before, +.nav:after, +.navbar:before, +.navbar:after, +.navbar-header:before, +.navbar-header:after, +.navbar-collapse:before, +.navbar-collapse:after, +.pager:before, +.pager:after, +.panel-body:before, +.panel-body:after, +.modal-header:before, +.modal-header:after, +.modal-footer:before, +.modal-footer:after { + display: table; + content: " "; +} +.clearfix:after, +.dl-horizontal dd:after, +.container:after, +.container-fluid:after, +.row:after, +.form-horizontal .form-group:after, +.btn-toolbar:after, +.btn-group-vertical > .btn-group:after, +.nav:after, +.navbar:after, +.navbar-header:after, +.navbar-collapse:after, +.pager:after, +.panel-body:after, +.modal-header:after, +.modal-footer:after { + clear: both; +} +.center-block { + display: block; + margin-right: auto; + margin-left: auto; +} +.pull-right { + float: right !important; +} +.pull-left { + float: left !important; +} +.hide { + display: none !important; +} +.show { + display: block !important; +} +.invisible { + visibility: hidden; +} +.text-hide { + font: 0/0 a; + color: transparent; + text-shadow: none; + background-color: transparent; + border: 0; +} +.hidden { + display: none !important; +} +.affix { + position: fixed; +} +@-ms-viewport { + width: device-width; +} +.visible-xs, +.visible-sm, +.visible-md, +.visible-lg { + display: none !important; +} +.visible-xs-block, +.visible-xs-inline, +.visible-xs-inline-block, +.visible-sm-block, +.visible-sm-inline, +.visible-sm-inline-block, +.visible-md-block, +.visible-md-inline, +.visible-md-inline-block, +.visible-lg-block, +.visible-lg-inline, +.visible-lg-inline-block { + display: none !important; +} +@media (max-width: 767px) { + .visible-xs { + display: block !important; + } + table.visible-xs { + display: table !important; + } + tr.visible-xs { + display: table-row !important; + } + th.visible-xs, + td.visible-xs { + display: table-cell !important; + } +} +@media (max-width: 767px) { + .visible-xs-block { + display: block !important; + } +} +@media (max-width: 767px) { + .visible-xs-inline { + display: inline !important; + } +} +@media (max-width: 767px) { + .visible-xs-inline-block { + display: inline-block !important; + } +} +@media (min-width: 768px) and (max-width: 991px) { + .visible-sm { + display: block !important; + } + table.visible-sm { + display: table !important; + } + tr.visible-sm { + display: table-row !important; + } + th.visible-sm, + td.visible-sm { + display: table-cell !important; + } +} +@media (min-width: 768px) and (max-width: 991px) { + .visible-sm-block { + display: block !important; + } +} +@media (min-width: 768px) and (max-width: 991px) { + .visible-sm-inline { + display: inline !important; + } +} +@media (min-width: 768px) and (max-width: 991px) { + .visible-sm-inline-block { + display: inline-block !important; + } +} +@media (min-width: 992px) and (max-width: 1199px) { + .visible-md { + display: block !important; + } + table.visible-md { + display: table !important; + } + tr.visible-md { + display: table-row !important; + } + th.visible-md, + td.visible-md { + display: table-cell !important; + } +} +@media (min-width: 992px) and (max-width: 1199px) { + .visible-md-block { + display: block !important; + } +} +@media (min-width: 992px) and (max-width: 1199px) { + .visible-md-inline { + display: inline !important; + } +} +@media (min-width: 992px) and (max-width: 1199px) { + .visible-md-inline-block { + display: inline-block !important; + } +} +@media (min-width: 1200px) { + .visible-lg { + display: block !important; + } + table.visible-lg { + display: table !important; + } + tr.visible-lg { + display: table-row !important; + } + th.visible-lg, + td.visible-lg { + display: table-cell !important; + } +} +@media (min-width: 1200px) { + .visible-lg-block { + display: block !important; + } +} +@media (min-width: 1200px) { + .visible-lg-inline { + display: inline !important; + } +} +@media (min-width: 1200px) { + .visible-lg-inline-block { + display: inline-block !important; + } +} +@media (max-width: 767px) { + .hidden-xs { + display: none !important; + } +} +@media (min-width: 768px) and (max-width: 991px) { + .hidden-sm { + display: none !important; + } +} +@media (min-width: 992px) and (max-width: 1199px) { + .hidden-md { + display: none !important; + } +} +@media (min-width: 1200px) { + .hidden-lg { + display: none !important; + } +} +.visible-print { + display: none !important; +} +@media print { + .visible-print { + display: block !important; + } + table.visible-print { + display: table !important; + } + tr.visible-print { + display: table-row !important; + } + th.visible-print, + td.visible-print { + display: table-cell !important; + } +} +.visible-print-block { + display: none !important; +} +@media print { + .visible-print-block { + display: block !important; + } +} +.visible-print-inline { + display: none !important; +} +@media print { + .visible-print-inline { + display: inline !important; + } +} +.visible-print-inline-block { + display: none !important; +} +@media print { + .visible-print-inline-block { + display: inline-block !important; + } +} +@media print { + .hidden-print { + display: none !important; + } +} +/*# sourceMappingURL=bootstrap.css.map */ diff --git a/static/admin/css/bootstrap.min.css b/static/admin/css/bootstrap.min.css new file mode 100644 index 0000000..ed3905e --- /dev/null +++ b/static/admin/css/bootstrap.min.css @@ -0,0 +1,6 @@ +/*! + * Bootstrap v3.3.7 (http://getbootstrap.com) + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + *//*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{margin:.67em 0;font-size:2em}mark{color:#000;background:#ff0}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{height:0;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{margin:0;font:inherit;color:inherit}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{padding:0;border:0}input{line-height:normal}input[type=checkbox],input[type=radio]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;-webkit-appearance:textfield}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{padding:.35em .625em .75em;margin:0 2px;border:1px solid silver}legend{padding:0;border:0}textarea{overflow:auto}optgroup{font-weight:700}table{border-spacing:0;border-collapse:collapse}td,th{padding:0}/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */@media print{*,:after,:before{color:#000!important;text-shadow:none!important;background:0 0!important;-webkit-box-shadow:none!important;box-shadow:none!important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="javascript:"]:after,a[href^="#"]:after{content:""}blockquote,pre{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}img{max-width:100%!important}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}.navbar{display:none}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000!important}.label{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #ddd!important}}@font-face{font-family:'Glyphicons Halflings';src:url(../fonts/glyphicons-halflings-regular.eot);src:url(../fonts/glyphicons-halflings-regular.eot?#iefix) format('embedded-opentype'),url(../fonts/glyphicons-halflings-regular.woff2) format('woff2'),url(../fonts/glyphicons-halflings-regular.woff) format('woff'),url(../fonts/glyphicons-halflings-regular.ttf) format('truetype'),url(../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular) format('svg')}.glyphicon{position:relative;top:1px;display:inline-block;font-family:'Glyphicons Halflings';font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.glyphicon-asterisk:before{content:"\002a"}.glyphicon-plus:before{content:"\002b"}.glyphicon-eur:before,.glyphicon-euro:before{content:"\20ac"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphicon-resize-small:before{content:"\e097"}.glyphicon-exclamation-sign:before{content:"\e101"}.glyphicon-gift:before{content:"\e102"}.glyphicon-leaf:before{content:"\e103"}.glyphicon-fire:before{content:"\e104"}.glyphicon-eye-open:before{content:"\e105"}.glyphicon-eye-close:before{content:"\e106"}.glyphicon-warning-sign:before{content:"\e107"}.glyphicon-plane:before{content:"\e108"}.glyphicon-calendar:before{content:"\e109"}.glyphicon-random:before{content:"\e110"}.glyphicon-comment:before{content:"\e111"}.glyphicon-magnet:before{content:"\e112"}.glyphicon-chevron-up:before{content:"\e113"}.glyphicon-chevron-down:before{content:"\e114"}.glyphicon-retweet:before{content:"\e115"}.glyphicon-shopping-cart:before{content:"\e116"}.glyphicon-folder-close:before{content:"\e117"}.glyphicon-folder-open:before{content:"\e118"}.glyphicon-resize-vertical:before{content:"\e119"}.glyphicon-resize-horizontal:before{content:"\e120"}.glyphicon-hdd:before{content:"\e121"}.glyphicon-bullhorn:before{content:"\e122"}.glyphicon-bell:before{content:"\e123"}.glyphicon-certificate:before{content:"\e124"}.glyphicon-thumbs-up:before{content:"\e125"}.glyphicon-thumbs-down:before{content:"\e126"}.glyphicon-hand-right:before{content:"\e127"}.glyphicon-hand-left:before{content:"\e128"}.glyphicon-hand-up:before{content:"\e129"}.glyphicon-hand-down:before{content:"\e130"}.glyphicon-circle-arrow-right:before{content:"\e131"}.glyphicon-circle-arrow-left:before{content:"\e132"}.glyphicon-circle-arrow-up:before{content:"\e133"}.glyphicon-circle-arrow-down:before{content:"\e134"}.glyphicon-globe:before{content:"\e135"}.glyphicon-wrench:before{content:"\e136"}.glyphicon-tasks:before{content:"\e137"}.glyphicon-filter:before{content:"\e138"}.glyphicon-briefcase:before{content:"\e139"}.glyphicon-fullscreen:before{content:"\e140"}.glyphicon-dashboard:before{content:"\e141"}.glyphicon-paperclip:before{content:"\e142"}.glyphicon-heart-empty:before{content:"\e143"}.glyphicon-link:before{content:"\e144"}.glyphicon-phone:before{content:"\e145"}.glyphicon-pushpin:before{content:"\e146"}.glyphicon-usd:before{content:"\e148"}.glyphicon-gbp:before{content:"\e149"}.glyphicon-sort:before{content:"\e150"}.glyphicon-sort-by-alphabet:before{content:"\e151"}.glyphicon-sort-by-alphabet-alt:before{content:"\e152"}.glyphicon-sort-by-order:before{content:"\e153"}.glyphicon-sort-by-order-alt:before{content:"\e154"}.glyphicon-sort-by-attributes:before{content:"\e155"}.glyphicon-sort-by-attributes-alt:before{content:"\e156"}.glyphicon-unchecked:before{content:"\e157"}.glyphicon-expand:before{content:"\e158"}.glyphicon-collapse-down:before{content:"\e159"}.glyphicon-collapse-up:before{content:"\e160"}.glyphicon-log-in:before{content:"\e161"}.glyphicon-flash:before{content:"\e162"}.glyphicon-log-out:before{content:"\e163"}.glyphicon-new-window:before{content:"\e164"}.glyphicon-record:before{content:"\e165"}.glyphicon-save:before{content:"\e166"}.glyphicon-open:before{content:"\e167"}.glyphicon-saved:before{content:"\e168"}.glyphicon-import:before{content:"\e169"}.glyphicon-export:before{content:"\e170"}.glyphicon-send:before{content:"\e171"}.glyphicon-floppy-disk:before{content:"\e172"}.glyphicon-floppy-saved:before{content:"\e173"}.glyphicon-floppy-remove:before{content:"\e174"}.glyphicon-floppy-save:before{content:"\e175"}.glyphicon-floppy-open:before{content:"\e176"}.glyphicon-credit-card:before{content:"\e177"}.glyphicon-transfer:before{content:"\e178"}.glyphicon-cutlery:before{content:"\e179"}.glyphicon-header:before{content:"\e180"}.glyphicon-compressed:before{content:"\e181"}.glyphicon-earphone:before{content:"\e182"}.glyphicon-phone-alt:before{content:"\e183"}.glyphicon-tower:before{content:"\e184"}.glyphicon-stats:before{content:"\e185"}.glyphicon-sd-video:before{content:"\e186"}.glyphicon-hd-video:before{content:"\e187"}.glyphicon-subtitles:before{content:"\e188"}.glyphicon-sound-stereo:before{content:"\e189"}.glyphicon-sound-dolby:before{content:"\e190"}.glyphicon-sound-5-1:before{content:"\e191"}.glyphicon-sound-6-1:before{content:"\e192"}.glyphicon-sound-7-1:before{content:"\e193"}.glyphicon-copyright-mark:before{content:"\e194"}.glyphicon-registration-mark:before{content:"\e195"}.glyphicon-cloud-download:before{content:"\e197"}.glyphicon-cloud-upload:before{content:"\e198"}.glyphicon-tree-conifer:before{content:"\e199"}.glyphicon-tree-deciduous:before{content:"\e200"}.glyphicon-cd:before{content:"\e201"}.glyphicon-save-file:before{content:"\e202"}.glyphicon-open-file:before{content:"\e203"}.glyphicon-level-up:before{content:"\e204"}.glyphicon-copy:before{content:"\e205"}.glyphicon-paste:before{content:"\e206"}.glyphicon-alert:before{content:"\e209"}.glyphicon-equalizer:before{content:"\e210"}.glyphicon-king:before{content:"\e211"}.glyphicon-queen:before{content:"\e212"}.glyphicon-pawn:before{content:"\e213"}.glyphicon-bishop:before{content:"\e214"}.glyphicon-knight:before{content:"\e215"}.glyphicon-baby-formula:before{content:"\e216"}.glyphicon-tent:before{content:"\26fa"}.glyphicon-blackboard:before{content:"\e218"}.glyphicon-bed:before{content:"\e219"}.glyphicon-apple:before{content:"\f8ff"}.glyphicon-erase:before{content:"\e221"}.glyphicon-hourglass:before{content:"\231b"}.glyphicon-lamp:before{content:"\e223"}.glyphicon-duplicate:before{content:"\e224"}.glyphicon-piggy-bank:before{content:"\e225"}.glyphicon-scissors:before{content:"\e226"}.glyphicon-bitcoin:before{content:"\e227"}.glyphicon-btc:before{content:"\e227"}.glyphicon-xbt:before{content:"\e227"}.glyphicon-yen:before{content:"\00a5"}.glyphicon-jpy:before{content:"\00a5"}.glyphicon-ruble:before{content:"\20bd"}.glyphicon-rub:before{content:"\20bd"}.glyphicon-scale:before{content:"\e230"}.glyphicon-ice-lolly:before{content:"\e231"}.glyphicon-ice-lolly-tasted:before{content:"\e232"}.glyphicon-education:before{content:"\e233"}.glyphicon-option-horizontal:before{content:"\e234"}.glyphicon-option-vertical:before{content:"\e235"}.glyphicon-menu-hamburger:before{content:"\e236"}.glyphicon-modal-window:before{content:"\e237"}.glyphicon-oil:before{content:"\e238"}.glyphicon-grain:before{content:"\e239"}.glyphicon-sunglasses:before{content:"\e240"}.glyphicon-text-size:before{content:"\e241"}.glyphicon-text-color:before{content:"\e242"}.glyphicon-text-background:before{content:"\e243"}.glyphicon-object-align-top:before{content:"\e244"}.glyphicon-object-align-bottom:before{content:"\e245"}.glyphicon-object-align-horizontal:before{content:"\e246"}.glyphicon-object-align-left:before{content:"\e247"}.glyphicon-object-align-vertical:before{content:"\e248"}.glyphicon-object-align-right:before{content:"\e249"}.glyphicon-triangle-right:before{content:"\e250"}.glyphicon-triangle-left:before{content:"\e251"}.glyphicon-triangle-bottom:before{content:"\e252"}.glyphicon-triangle-top:before{content:"\e253"}.glyphicon-console:before{content:"\e254"}.glyphicon-superscript:before{content:"\e255"}.glyphicon-subscript:before{content:"\e256"}.glyphicon-menu-left:before{content:"\e257"}.glyphicon-menu-right:before{content:"\e258"}.glyphicon-menu-down:before{content:"\e259"}.glyphicon-menu-up:before{content:"\e260"}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}:after,:before{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:10px;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.42857143;color:#333;background-color:#fff}button,input,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#337ab7;text-decoration:none}a:focus,a:hover{color:#23527c;text-decoration:underline}a:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}figure{margin:0}img{vertical-align:middle}.carousel-inner>.item>a>img,.carousel-inner>.item>img,.img-responsive,.thumbnail a>img,.thumbnail>img{display:block;max-width:100%;height:auto}.img-rounded{border-radius:6px}.img-thumbnail{display:inline-block;max-width:100%;height:auto;padding:4px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:all .2s ease-in-out;-o-transition:all .2s ease-in-out;transition:all .2s ease-in-out}.img-circle{border-radius:50%}hr{margin-top:20px;margin-bottom:20px;border:0;border-top:1px solid #eee}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}[role=button]{cursor:pointer}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{font-family:inherit;font-weight:500;line-height:1.1;color:inherit}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-weight:400;line-height:1;color:#777}.h1,.h2,.h3,h1,h2,h3{margin-top:20px;margin-bottom:10px}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small{font-size:65%}.h4,.h5,.h6,h4,h5,h6{margin-top:10px;margin-bottom:10px}.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-size:75%}.h1,h1{font-size:36px}.h2,h2{font-size:30px}.h3,h3{font-size:24px}.h4,h4{font-size:18px}.h5,h5{font-size:14px}.h6,h6{font-size:12px}p{margin:0 0 10px}.lead{margin-bottom:20px;font-size:16px;font-weight:300;line-height:1.4}@media (min-width:768px){.lead{font-size:21px}}.small,small{font-size:85%}.mark,mark{padding:.2em;background-color:#fcf8e3}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-nowrap{white-space:nowrap}.text-lowercase{text-transform:lowercase}.text-uppercase{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-muted{color:#777}.text-primary{color:#337ab7}a.text-primary:focus,a.text-primary:hover{color:#286090}.text-success{color:#3c763d}a.text-success:focus,a.text-success:hover{color:#2b542c}.text-info{color:#31708f}a.text-info:focus,a.text-info:hover{color:#245269}.text-warning{color:#8a6d3b}a.text-warning:focus,a.text-warning:hover{color:#66512c}.text-danger{color:#a94442}a.text-danger:focus,a.text-danger:hover{color:#843534}.bg-primary{color:#fff;background-color:#337ab7}a.bg-primary:focus,a.bg-primary:hover{background-color:#286090}.bg-success{background-color:#dff0d8}a.bg-success:focus,a.bg-success:hover{background-color:#c1e2b3}.bg-info{background-color:#d9edf7}a.bg-info:focus,a.bg-info:hover{background-color:#afd9ee}.bg-warning{background-color:#fcf8e3}a.bg-warning:focus,a.bg-warning:hover{background-color:#f7ecb5}.bg-danger{background-color:#f2dede}a.bg-danger:focus,a.bg-danger:hover{background-color:#e4b9b9}.page-header{padding-bottom:9px;margin:40px 0 20px;border-bottom:1px solid #eee}ol,ul{margin-top:0;margin-bottom:10px}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;margin-left:-5px;list-style:none}.list-inline>li{display:inline-block;padding-right:5px;padding-left:5px}dl{margin-top:0;margin-bottom:20px}dd,dt{line-height:1.42857143}dt{font-weight:700}dd{margin-left:0}@media (min-width:768px){.dl-horizontal dt{float:left;width:160px;overflow:hidden;clear:left;text-align:right;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}}abbr[data-original-title],abbr[title]{cursor:help;border-bottom:1px dotted #777}.initialism{font-size:90%;text-transform:uppercase}blockquote{padding:10px 20px;margin:0 0 20px;font-size:17.5px;border-left:5px solid #eee}blockquote ol:last-child,blockquote p:last-child,blockquote ul:last-child{margin-bottom:0}blockquote .small,blockquote footer,blockquote small{display:block;font-size:80%;line-height:1.42857143;color:#777}blockquote .small:before,blockquote footer:before,blockquote small:before{content:'\2014 \00A0'}.blockquote-reverse,blockquote.pull-right{padding-right:15px;padding-left:0;text-align:right;border-right:5px solid #eee;border-left:0}.blockquote-reverse .small:before,.blockquote-reverse footer:before,.blockquote-reverse small:before,blockquote.pull-right .small:before,blockquote.pull-right footer:before,blockquote.pull-right small:before{content:''}.blockquote-reverse .small:after,.blockquote-reverse footer:after,.blockquote-reverse small:after,blockquote.pull-right .small:after,blockquote.pull-right footer:after,blockquote.pull-right small:after{content:'\00A0 \2014'}address{margin-bottom:20px;font-style:normal;line-height:1.42857143}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,"Courier New",monospace}code{padding:2px 4px;font-size:90%;color:#c7254e;background-color:#f9f2f4;border-radius:4px}kbd{padding:2px 4px;font-size:90%;color:#fff;background-color:#333;border-radius:3px;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.25);box-shadow:inset 0 -1px 0 rgba(0,0,0,.25)}kbd kbd{padding:0;font-size:100%;font-weight:700;-webkit-box-shadow:none;box-shadow:none}pre{display:block;padding:9.5px;margin:0 0 10px;font-size:13px;line-height:1.42857143;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border-radius:0}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:768px){.container{width:750px}}@media (min-width:992px){.container{width:970px}}@media (min-width:1200px){.container{width:1170px}}.container-fluid{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.row{margin-right:-15px;margin-left:-15px}.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{position:relative;min-height:1px;padding-right:15px;padding-left:15px}.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{float:left}.col-xs-12{width:100%}.col-xs-11{width:91.66666667%}.col-xs-10{width:83.33333333%}.col-xs-9{width:75%}.col-xs-8{width:66.66666667%}.col-xs-7{width:58.33333333%}.col-xs-6{width:50%}.col-xs-5{width:41.66666667%}.col-xs-4{width:33.33333333%}.col-xs-3{width:25%}.col-xs-2{width:16.66666667%}.col-xs-1{width:8.33333333%}.col-xs-pull-12{right:100%}.col-xs-pull-11{right:91.66666667%}.col-xs-pull-10{right:83.33333333%}.col-xs-pull-9{right:75%}.col-xs-pull-8{right:66.66666667%}.col-xs-pull-7{right:58.33333333%}.col-xs-pull-6{right:50%}.col-xs-pull-5{right:41.66666667%}.col-xs-pull-4{right:33.33333333%}.col-xs-pull-3{right:25%}.col-xs-pull-2{right:16.66666667%}.col-xs-pull-1{right:8.33333333%}.col-xs-pull-0{right:auto}.col-xs-push-12{left:100%}.col-xs-push-11{left:91.66666667%}.col-xs-push-10{left:83.33333333%}.col-xs-push-9{left:75%}.col-xs-push-8{left:66.66666667%}.col-xs-push-7{left:58.33333333%}.col-xs-push-6{left:50%}.col-xs-push-5{left:41.66666667%}.col-xs-push-4{left:33.33333333%}.col-xs-push-3{left:25%}.col-xs-push-2{left:16.66666667%}.col-xs-push-1{left:8.33333333%}.col-xs-push-0{left:auto}.col-xs-offset-12{margin-left:100%}.col-xs-offset-11{margin-left:91.66666667%}.col-xs-offset-10{margin-left:83.33333333%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-8{margin-left:66.66666667%}.col-xs-offset-7{margin-left:58.33333333%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-5{margin-left:41.66666667%}.col-xs-offset-4{margin-left:33.33333333%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-2{margin-left:16.66666667%}.col-xs-offset-1{margin-left:8.33333333%}.col-xs-offset-0{margin-left:0}@media (min-width:768px){.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9{float:left}.col-sm-12{width:100%}.col-sm-11{width:91.66666667%}.col-sm-10{width:83.33333333%}.col-sm-9{width:75%}.col-sm-8{width:66.66666667%}.col-sm-7{width:58.33333333%}.col-sm-6{width:50%}.col-sm-5{width:41.66666667%}.col-sm-4{width:33.33333333%}.col-sm-3{width:25%}.col-sm-2{width:16.66666667%}.col-sm-1{width:8.33333333%}.col-sm-pull-12{right:100%}.col-sm-pull-11{right:91.66666667%}.col-sm-pull-10{right:83.33333333%}.col-sm-pull-9{right:75%}.col-sm-pull-8{right:66.66666667%}.col-sm-pull-7{right:58.33333333%}.col-sm-pull-6{right:50%}.col-sm-pull-5{right:41.66666667%}.col-sm-pull-4{right:33.33333333%}.col-sm-pull-3{right:25%}.col-sm-pull-2{right:16.66666667%}.col-sm-pull-1{right:8.33333333%}.col-sm-pull-0{right:auto}.col-sm-push-12{left:100%}.col-sm-push-11{left:91.66666667%}.col-sm-push-10{left:83.33333333%}.col-sm-push-9{left:75%}.col-sm-push-8{left:66.66666667%}.col-sm-push-7{left:58.33333333%}.col-sm-push-6{left:50%}.col-sm-push-5{left:41.66666667%}.col-sm-push-4{left:33.33333333%}.col-sm-push-3{left:25%}.col-sm-push-2{left:16.66666667%}.col-sm-push-1{left:8.33333333%}.col-sm-push-0{left:auto}.col-sm-offset-12{margin-left:100%}.col-sm-offset-11{margin-left:91.66666667%}.col-sm-offset-10{margin-left:83.33333333%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-8{margin-left:66.66666667%}.col-sm-offset-7{margin-left:58.33333333%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-5{margin-left:41.66666667%}.col-sm-offset-4{margin-left:33.33333333%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-2{margin-left:16.66666667%}.col-sm-offset-1{margin-left:8.33333333%}.col-sm-offset-0{margin-left:0}}@media (min-width:992px){.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9{float:left}.col-md-12{width:100%}.col-md-11{width:91.66666667%}.col-md-10{width:83.33333333%}.col-md-9{width:75%}.col-md-8{width:66.66666667%}.col-md-7{width:58.33333333%}.col-md-6{width:50%}.col-md-5{width:41.66666667%}.col-md-4{width:33.33333333%}.col-md-3{width:25%}.col-md-2{width:16.66666667%}.col-md-1{width:8.33333333%}.col-md-pull-12{right:100%}.col-md-pull-11{right:91.66666667%}.col-md-pull-10{right:83.33333333%}.col-md-pull-9{right:75%}.col-md-pull-8{right:66.66666667%}.col-md-pull-7{right:58.33333333%}.col-md-pull-6{right:50%}.col-md-pull-5{right:41.66666667%}.col-md-pull-4{right:33.33333333%}.col-md-pull-3{right:25%}.col-md-pull-2{right:16.66666667%}.col-md-pull-1{right:8.33333333%}.col-md-pull-0{right:auto}.col-md-push-12{left:100%}.col-md-push-11{left:91.66666667%}.col-md-push-10{left:83.33333333%}.col-md-push-9{left:75%}.col-md-push-8{left:66.66666667%}.col-md-push-7{left:58.33333333%}.col-md-push-6{left:50%}.col-md-push-5{left:41.66666667%}.col-md-push-4{left:33.33333333%}.col-md-push-3{left:25%}.col-md-push-2{left:16.66666667%}.col-md-push-1{left:8.33333333%}.col-md-push-0{left:auto}.col-md-offset-12{margin-left:100%}.col-md-offset-11{margin-left:91.66666667%}.col-md-offset-10{margin-left:83.33333333%}.col-md-offset-9{margin-left:75%}.col-md-offset-8{margin-left:66.66666667%}.col-md-offset-7{margin-left:58.33333333%}.col-md-offset-6{margin-left:50%}.col-md-offset-5{margin-left:41.66666667%}.col-md-offset-4{margin-left:33.33333333%}.col-md-offset-3{margin-left:25%}.col-md-offset-2{margin-left:16.66666667%}.col-md-offset-1{margin-left:8.33333333%}.col-md-offset-0{margin-left:0}}@media (min-width:1200px){.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9{float:left}.col-lg-12{width:100%}.col-lg-11{width:91.66666667%}.col-lg-10{width:83.33333333%}.col-lg-9{width:75%}.col-lg-8{width:66.66666667%}.col-lg-7{width:58.33333333%}.col-lg-6{width:50%}.col-lg-5{width:41.66666667%}.col-lg-4{width:33.33333333%}.col-lg-3{width:25%}.col-lg-2{width:16.66666667%}.col-lg-1{width:8.33333333%}.col-lg-pull-12{right:100%}.col-lg-pull-11{right:91.66666667%}.col-lg-pull-10{right:83.33333333%}.col-lg-pull-9{right:75%}.col-lg-pull-8{right:66.66666667%}.col-lg-pull-7{right:58.33333333%}.col-lg-pull-6{right:50%}.col-lg-pull-5{right:41.66666667%}.col-lg-pull-4{right:33.33333333%}.col-lg-pull-3{right:25%}.col-lg-pull-2{right:16.66666667%}.col-lg-pull-1{right:8.33333333%}.col-lg-pull-0{right:auto}.col-lg-push-12{left:100%}.col-lg-push-11{left:91.66666667%}.col-lg-push-10{left:83.33333333%}.col-lg-push-9{left:75%}.col-lg-push-8{left:66.66666667%}.col-lg-push-7{left:58.33333333%}.col-lg-push-6{left:50%}.col-lg-push-5{left:41.66666667%}.col-lg-push-4{left:33.33333333%}.col-lg-push-3{left:25%}.col-lg-push-2{left:16.66666667%}.col-lg-push-1{left:8.33333333%}.col-lg-push-0{left:auto}.col-lg-offset-12{margin-left:100%}.col-lg-offset-11{margin-left:91.66666667%}.col-lg-offset-10{margin-left:83.33333333%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-8{margin-left:66.66666667%}.col-lg-offset-7{margin-left:58.33333333%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-5{margin-left:41.66666667%}.col-lg-offset-4{margin-left:33.33333333%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-2{margin-left:16.66666667%}.col-lg-offset-1{margin-left:8.33333333%}.col-lg-offset-0{margin-left:0}}table{background-color:transparent}caption{padding-top:8px;padding-bottom:8px;color:#777;text-align:left}th{text-align:left}.table{width:100%;max-width:100%;margin-bottom:20px}.table>tbody>tr>td,.table>tbody>tr>th,.table>tfoot>tr>td,.table>tfoot>tr>th,.table>thead>tr>td,.table>thead>tr>th{padding:8px;line-height:1.42857143;vertical-align:top;border-top:1px solid #ddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.table>caption+thead>tr:first-child>td,.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>td,.table>thead:first-child>tr:first-child>th{border-top:0}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#fff}.table-condensed>tbody>tr>td,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>td,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>thead>tr>th{padding:5px}.table-bordered{border:1px solid #ddd}.table-bordered>tbody>tr>td,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>td,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border:1px solid #ddd}.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border-bottom-width:2px}.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9}.table-hover>tbody>tr:hover{background-color:#f5f5f5}table col[class*=col-]{position:static;display:table-column;float:none}table td[class*=col-],table th[class*=col-]{position:static;display:table-cell;float:none}.table>tbody>tr.active>td,.table>tbody>tr.active>th,.table>tbody>tr>td.active,.table>tbody>tr>th.active,.table>tfoot>tr.active>td,.table>tfoot>tr.active>th,.table>tfoot>tr>td.active,.table>tfoot>tr>th.active,.table>thead>tr.active>td,.table>thead>tr.active>th,.table>thead>tr>td.active,.table>thead>tr>th.active{background-color:#f5f5f5}.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr.active:hover>th,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover{background-color:#e8e8e8}.table>tbody>tr.success>td,.table>tbody>tr.success>th,.table>tbody>tr>td.success,.table>tbody>tr>th.success,.table>tfoot>tr.success>td,.table>tfoot>tr.success>th,.table>tfoot>tr>td.success,.table>tfoot>tr>th.success,.table>thead>tr.success>td,.table>thead>tr.success>th,.table>thead>tr>td.success,.table>thead>tr>th.success{background-color:#dff0d8}.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr.success:hover>th,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover{background-color:#d0e9c6}.table>tbody>tr.info>td,.table>tbody>tr.info>th,.table>tbody>tr>td.info,.table>tbody>tr>th.info,.table>tfoot>tr.info>td,.table>tfoot>tr.info>th,.table>tfoot>tr>td.info,.table>tfoot>tr>th.info,.table>thead>tr.info>td,.table>thead>tr.info>th,.table>thead>tr>td.info,.table>thead>tr>th.info{background-color:#d9edf7}.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr.info:hover>th,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover{background-color:#c4e3f3}.table>tbody>tr.warning>td,.table>tbody>tr.warning>th,.table>tbody>tr>td.warning,.table>tbody>tr>th.warning,.table>tfoot>tr.warning>td,.table>tfoot>tr.warning>th,.table>tfoot>tr>td.warning,.table>tfoot>tr>th.warning,.table>thead>tr.warning>td,.table>thead>tr.warning>th,.table>thead>tr>td.warning,.table>thead>tr>th.warning{background-color:#fcf8e3}.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr.warning:hover>th,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover{background-color:#faf2cc}.table>tbody>tr.danger>td,.table>tbody>tr.danger>th,.table>tbody>tr>td.danger,.table>tbody>tr>th.danger,.table>tfoot>tr.danger>td,.table>tfoot>tr.danger>th,.table>tfoot>tr>td.danger,.table>tfoot>tr>th.danger,.table>thead>tr.danger>td,.table>thead>tr.danger>th,.table>thead>tr>td.danger,.table>thead>tr>th.danger{background-color:#f2dede}.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr.danger:hover>th,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover{background-color:#ebcccc}.table-responsive{min-height:.01%;overflow-x:auto}@media screen and (max-width:767px){.table-responsive{width:100%;margin-bottom:15px;overflow-y:hidden;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ddd}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>td,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>thead>tr>th{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;padding:0;margin-bottom:20px;font-size:21px;line-height:inherit;color:#333;border:0;border-bottom:1px solid #e5e5e5}label{display:inline-block;max-width:100%;margin-bottom:5px;font-weight:700}input[type=search]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type=checkbox],input[type=radio]{margin:4px 0 0;margin-top:1px\9;line-height:normal}input[type=file]{display:block}input[type=range]{display:block;width:100%}select[multiple],select[size]{height:auto}input[type=file]:focus,input[type=checkbox]:focus,input[type=radio]:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}output{display:block;padding-top:7px;font-size:14px;line-height:1.42857143;color:#555}.form-control{display:block;width:100%;height:34px;padding:6px 12px;font-size:14px;line-height:1.42857143;color:#555;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075);-webkit-transition:border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s;-o-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s}.form-control:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6)}.form-control::-moz-placeholder{color:#999;opacity:1}.form-control:-ms-input-placeholder{color:#999}.form-control::-webkit-input-placeholder{color:#999}.form-control::-ms-expand{background-color:transparent;border:0}.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{background-color:#eee;opacity:1}.form-control[disabled],fieldset[disabled] .form-control{cursor:not-allowed}textarea.form-control{height:auto}input[type=search]{-webkit-appearance:none}@media screen and (-webkit-min-device-pixel-ratio:0){input[type=date].form-control,input[type=time].form-control,input[type=datetime-local].form-control,input[type=month].form-control{line-height:34px}.input-group-sm input[type=date],.input-group-sm input[type=time],.input-group-sm input[type=datetime-local],.input-group-sm input[type=month],input[type=date].input-sm,input[type=time].input-sm,input[type=datetime-local].input-sm,input[type=month].input-sm{line-height:30px}.input-group-lg input[type=date],.input-group-lg input[type=time],.input-group-lg input[type=datetime-local],.input-group-lg input[type=month],input[type=date].input-lg,input[type=time].input-lg,input[type=datetime-local].input-lg,input[type=month].input-lg{line-height:46px}}.form-group{margin-bottom:15px}.checkbox,.radio{position:relative;display:block;margin-top:10px;margin-bottom:10px}.checkbox label,.radio label{min-height:20px;padding-left:20px;margin-bottom:0;font-weight:400;cursor:pointer}.checkbox input[type=checkbox],.checkbox-inline input[type=checkbox],.radio input[type=radio],.radio-inline input[type=radio]{position:absolute;margin-top:4px\9;margin-left:-20px}.checkbox+.checkbox,.radio+.radio{margin-top:-5px}.checkbox-inline,.radio-inline{position:relative;display:inline-block;padding-left:20px;margin-bottom:0;font-weight:400;vertical-align:middle;cursor:pointer}.checkbox-inline+.checkbox-inline,.radio-inline+.radio-inline{margin-top:0;margin-left:10px}fieldset[disabled] input[type=checkbox],fieldset[disabled] input[type=radio],input[type=checkbox].disabled,input[type=checkbox][disabled],input[type=radio].disabled,input[type=radio][disabled]{cursor:not-allowed}.checkbox-inline.disabled,.radio-inline.disabled,fieldset[disabled] .checkbox-inline,fieldset[disabled] .radio-inline{cursor:not-allowed}.checkbox.disabled label,.radio.disabled label,fieldset[disabled] .checkbox label,fieldset[disabled] .radio label{cursor:not-allowed}.form-control-static{min-height:34px;padding-top:7px;padding-bottom:7px;margin-bottom:0}.form-control-static.input-lg,.form-control-static.input-sm{padding-right:0;padding-left:0}.input-sm{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-sm{height:30px;line-height:30px}select[multiple].input-sm,textarea.input-sm{height:auto}.form-group-sm .form-control{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.form-group-sm select.form-control{height:30px;line-height:30px}.form-group-sm select[multiple].form-control,.form-group-sm textarea.form-control{height:auto}.form-group-sm .form-control-static{height:30px;min-height:32px;padding:6px 10px;font-size:12px;line-height:1.5}.input-lg{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-lg{height:46px;line-height:46px}select[multiple].input-lg,textarea.input-lg{height:auto}.form-group-lg .form-control{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.form-group-lg select.form-control{height:46px;line-height:46px}.form-group-lg select[multiple].form-control,.form-group-lg textarea.form-control{height:auto}.form-group-lg .form-control-static{height:46px;min-height:38px;padding:11px 16px;font-size:18px;line-height:1.3333333}.has-feedback{position:relative}.has-feedback .form-control{padding-right:42.5px}.form-control-feedback{position:absolute;top:0;right:0;z-index:2;display:block;width:34px;height:34px;line-height:34px;text-align:center;pointer-events:none}.form-group-lg .form-control+.form-control-feedback,.input-group-lg+.form-control-feedback,.input-lg+.form-control-feedback{width:46px;height:46px;line-height:46px}.form-group-sm .form-control+.form-control-feedback,.input-group-sm+.form-control-feedback,.input-sm+.form-control-feedback{width:30px;height:30px;line-height:30px}.has-success .checkbox,.has-success .checkbox-inline,.has-success .control-label,.has-success .help-block,.has-success .radio,.has-success .radio-inline,.has-success.checkbox label,.has-success.checkbox-inline label,.has-success.radio label,.has-success.radio-inline label{color:#3c763d}.has-success .form-control{border-color:#3c763d;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-success .form-control:focus{border-color:#2b542c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168}.has-success .input-group-addon{color:#3c763d;background-color:#dff0d8;border-color:#3c763d}.has-success .form-control-feedback{color:#3c763d}.has-warning .checkbox,.has-warning .checkbox-inline,.has-warning .control-label,.has-warning .help-block,.has-warning .radio,.has-warning .radio-inline,.has-warning.checkbox label,.has-warning.checkbox-inline label,.has-warning.radio label,.has-warning.radio-inline label{color:#8a6d3b}.has-warning .form-control{border-color:#8a6d3b;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-warning .form-control:focus{border-color:#66512c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b}.has-warning .input-group-addon{color:#8a6d3b;background-color:#fcf8e3;border-color:#8a6d3b}.has-warning .form-control-feedback{color:#8a6d3b}.has-error .checkbox,.has-error .checkbox-inline,.has-error .control-label,.has-error .help-block,.has-error .radio,.has-error .radio-inline,.has-error.checkbox label,.has-error.checkbox-inline label,.has-error.radio label,.has-error.radio-inline label{color:#a94442}.has-error .form-control{border-color:#a94442;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-error .form-control:focus{border-color:#843534;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483}.has-error .input-group-addon{color:#a94442;background-color:#f2dede;border-color:#a94442}.has-error .form-control-feedback{color:#a94442}.has-feedback label~.form-control-feedback{top:25px}.has-feedback label.sr-only~.form-control-feedback{top:0}.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#737373}@media (min-width:768px){.form-inline .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-static{display:inline-block}.form-inline .input-group{display:inline-table;vertical-align:middle}.form-inline .input-group .form-control,.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn{width:auto}.form-inline .input-group>.form-control{width:100%}.form-inline .control-label{margin-bottom:0;vertical-align:middle}.form-inline .checkbox,.form-inline .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.form-inline .checkbox label,.form-inline .radio label{padding-left:0}.form-inline .checkbox input[type=checkbox],.form-inline .radio input[type=radio]{position:relative;margin-left:0}.form-inline .has-feedback .form-control-feedback{top:0}}.form-horizontal .checkbox,.form-horizontal .checkbox-inline,.form-horizontal .radio,.form-horizontal .radio-inline{padding-top:7px;margin-top:0;margin-bottom:0}.form-horizontal .checkbox,.form-horizontal .radio{min-height:27px}.form-horizontal .form-group{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.form-horizontal .control-label{padding-top:7px;margin-bottom:0;text-align:right}}.form-horizontal .has-feedback .form-control-feedback{right:15px}@media (min-width:768px){.form-horizontal .form-group-lg .control-label{padding-top:11px;font-size:18px}}@media (min-width:768px){.form-horizontal .form-group-sm .control-label{padding-top:6px;font-size:12px}}.btn{display:inline-block;padding:6px 12px;margin-bottom:0;font-size:14px;font-weight:400;line-height:1.42857143;text-align:center;white-space:nowrap;vertical-align:middle;-ms-touch-action:manipulation;touch-action:manipulation;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-image:none;border:1px solid transparent;border-radius:4px}.btn.active.focus,.btn.active:focus,.btn.focus,.btn:active.focus,.btn:active:focus,.btn:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}.btn.focus,.btn:focus,.btn:hover{color:#333;text-decoration:none}.btn.active,.btn:active{background-image:none;outline:0;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{cursor:not-allowed;filter:alpha(opacity=65);-webkit-box-shadow:none;box-shadow:none;opacity:.65}a.btn.disabled,fieldset[disabled] a.btn{pointer-events:none}.btn-default{color:#333;background-color:#fff;border-color:#ccc}.btn-default.focus,.btn-default:focus{color:#333;background-color:#e6e6e6;border-color:#8c8c8c}.btn-default:hover{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active.focus,.btn-default.active:focus,.btn-default.active:hover,.btn-default:active.focus,.btn-default:active:focus,.btn-default:active:hover,.open>.dropdown-toggle.btn-default.focus,.open>.dropdown-toggle.btn-default:focus,.open>.dropdown-toggle.btn-default:hover{color:#333;background-color:#d4d4d4;border-color:#8c8c8c}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{background-image:none}.btn-default.disabled.focus,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled].focus,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#fff;border-color:#ccc}.btn-default .badge{color:#fff;background-color:#333}.btn-primary{color:#fff;background-color:#337ab7;border-color:#2e6da4}.btn-primary.focus,.btn-primary:focus{color:#fff;background-color:#286090;border-color:#122b40}.btn-primary:hover{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active.focus,.btn-primary.active:focus,.btn-primary.active:hover,.btn-primary:active.focus,.btn-primary:active:focus,.btn-primary:active:hover,.open>.dropdown-toggle.btn-primary.focus,.open>.dropdown-toggle.btn-primary:focus,.open>.dropdown-toggle.btn-primary:hover{color:#fff;background-color:#204d74;border-color:#122b40}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{background-image:none}.btn-primary.disabled.focus,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled].focus,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#337ab7;border-color:#2e6da4}.btn-primary .badge{color:#337ab7;background-color:#fff}.btn-success{color:#fff;background-color:#5cb85c;border-color:#4cae4c}.btn-success.focus,.btn-success:focus{color:#fff;background-color:#449d44;border-color:#255625}.btn-success:hover{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active.focus,.btn-success.active:focus,.btn-success.active:hover,.btn-success:active.focus,.btn-success:active:focus,.btn-success:active:hover,.open>.dropdown-toggle.btn-success.focus,.open>.dropdown-toggle.btn-success:focus,.open>.dropdown-toggle.btn-success:hover{color:#fff;background-color:#398439;border-color:#255625}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{background-image:none}.btn-success.disabled.focus,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled].focus,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#5cb85c;border-color:#4cae4c}.btn-success .badge{color:#5cb85c;background-color:#fff}.btn-info{color:#fff;background-color:#5bc0de;border-color:#46b8da}.btn-info.focus,.btn-info:focus{color:#fff;background-color:#31b0d5;border-color:#1b6d85}.btn-info:hover{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active.focus,.btn-info.active:focus,.btn-info.active:hover,.btn-info:active.focus,.btn-info:active:focus,.btn-info:active:hover,.open>.dropdown-toggle.btn-info.focus,.open>.dropdown-toggle.btn-info:focus,.open>.dropdown-toggle.btn-info:hover{color:#fff;background-color:#269abc;border-color:#1b6d85}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{background-image:none}.btn-info.disabled.focus,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled].focus,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#5bc0de;border-color:#46b8da}.btn-info .badge{color:#5bc0de;background-color:#fff}.btn-warning{color:#fff;background-color:#f0ad4e;border-color:#eea236}.btn-warning.focus,.btn-warning:focus{color:#fff;background-color:#ec971f;border-color:#985f0d}.btn-warning:hover{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active.focus,.btn-warning.active:focus,.btn-warning.active:hover,.btn-warning:active.focus,.btn-warning:active:focus,.btn-warning:active:hover,.open>.dropdown-toggle.btn-warning.focus,.open>.dropdown-toggle.btn-warning:focus,.open>.dropdown-toggle.btn-warning:hover{color:#fff;background-color:#d58512;border-color:#985f0d}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{background-image:none}.btn-warning.disabled.focus,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled].focus,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#f0ad4e;border-color:#eea236}.btn-warning .badge{color:#f0ad4e;background-color:#fff}.btn-danger{color:#fff;background-color:#d9534f;border-color:#d43f3a}.btn-danger.focus,.btn-danger:focus{color:#fff;background-color:#c9302c;border-color:#761c19}.btn-danger:hover{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active.focus,.btn-danger.active:focus,.btn-danger.active:hover,.btn-danger:active.focus,.btn-danger:active:focus,.btn-danger:active:hover,.open>.dropdown-toggle.btn-danger.focus,.open>.dropdown-toggle.btn-danger:focus,.open>.dropdown-toggle.btn-danger:hover{color:#fff;background-color:#ac2925;border-color:#761c19}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{background-image:none}.btn-danger.disabled.focus,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled].focus,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#d9534f;border-color:#d43f3a}.btn-danger .badge{color:#d9534f;background-color:#fff}.btn-link{font-weight:400;color:#337ab7;border-radius:0}.btn-link,.btn-link.active,.btn-link:active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link:active,.btn-link:focus,.btn-link:hover{border-color:transparent}.btn-link:focus,.btn-link:hover{color:#23527c;text-decoration:underline;background-color:transparent}.btn-link[disabled]:focus,.btn-link[disabled]:hover,fieldset[disabled] .btn-link:focus,fieldset[disabled] .btn-link:hover{color:#777;text-decoration:none}.btn-group-lg>.btn,.btn-lg{padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.btn-group-sm>.btn,.btn-sm{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.btn-group-xs>.btn,.btn-xs{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:5px}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{opacity:0;-webkit-transition:opacity .15s linear;-o-transition:opacity .15s linear;transition:opacity .15s linear}.fade.in{opacity:1}.collapse{display:none}.collapse.in{display:block}tr.collapse.in{display:table-row}tbody.collapse.in{display:table-row-group}.collapsing{position:relative;height:0;overflow:hidden;-webkit-transition-timing-function:ease;-o-transition-timing-function:ease;transition-timing-function:ease;-webkit-transition-duration:.35s;-o-transition-duration:.35s;transition-duration:.35s;-webkit-transition-property:height,visibility;-o-transition-property:height,visibility;transition-property:height,visibility}.caret{display:inline-block;width:0;height:0;margin-left:2px;vertical-align:middle;border-top:4px dashed;border-top:4px solid\9;border-right:4px solid transparent;border-left:4px solid transparent}.dropdown,.dropup{position:relative}.dropdown-toggle:focus{outline:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:160px;padding:5px 0;margin:2px 0 0;font-size:14px;text-align:left;list-style:none;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.15);border-radius:4px;-webkit-box-shadow:0 6px 12px rgba(0,0,0,.175);box-shadow:0 6px 12px rgba(0,0,0,.175)}.dropdown-menu.pull-right{right:0;left:auto}.dropdown-menu .divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.dropdown-menu>li>a{display:block;padding:3px 20px;clear:both;font-weight:400;line-height:1.42857143;color:#333;white-space:nowrap}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{color:#262626;text-decoration:none;background-color:#f5f5f5}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{color:#fff;text-decoration:none;background-color:#337ab7;outline:0}.dropdown-menu>.disabled>a,.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{color:#777}.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{text-decoration:none;cursor:not-allowed;background-color:transparent;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.open>.dropdown-menu{display:block}.open>a{outline:0}.dropdown-menu-right{right:0;left:auto}.dropdown-menu-left{right:auto;left:0}.dropdown-header{display:block;padding:3px 20px;font-size:12px;line-height:1.42857143;color:#777;white-space:nowrap}.dropdown-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:990}.pull-right>.dropdown-menu{right:0;left:auto}.dropup .caret,.navbar-fixed-bottom .dropdown .caret{content:"";border-top:0;border-bottom:4px dashed;border-bottom:4px solid\9}.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{top:auto;bottom:100%;margin-bottom:2px}@media (min-width:768px){.navbar-right .dropdown-menu{right:0;left:auto}.navbar-right .dropdown-menu-left{right:auto;left:0}}.btn-group,.btn-group-vertical{position:relative;display:inline-block;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;float:left}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:2}.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group{margin-left:-1px}.btn-toolbar{margin-left:-5px}.btn-toolbar .btn,.btn-toolbar .btn-group,.btn-toolbar .input-group{float:left}.btn-toolbar>.btn,.btn-toolbar>.btn-group,.btn-toolbar>.input-group{margin-left:5px}.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){border-radius:0}.btn-group>.btn:first-child{margin-left:0}.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn:last-child:not(:first-child),.btn-group>.dropdown-toggle:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.btn-group>.btn-group{float:left}.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-bottom-left-radius:0}.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0}.btn-group>.btn+.dropdown-toggle{padding-right:8px;padding-left:8px}.btn-group>.btn-lg+.dropdown-toggle{padding-right:12px;padding-left:12px}.btn-group.open .dropdown-toggle{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-group.open .dropdown-toggle.btn-link{-webkit-box-shadow:none;box-shadow:none}.btn .caret{margin-left:0}.btn-lg .caret{border-width:5px 5px 0;border-bottom-width:0}.dropup .btn-lg .caret{border-width:0 5px 5px}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group,.btn-group-vertical>.btn-group>.btn{display:block;float:none;width:100%;max-width:100%}.btn-group-vertical>.btn-group>.btn{float:none}.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0}.btn-group-vertical>.btn:not(:first-child):not(:last-child){border-radius:0}.btn-group-vertical>.btn:first-child:not(:last-child){border-top-left-radius:4px;border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:last-child:not(:first-child){border-top-left-radius:0;border-top-right-radius:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group-vertical>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group-vertical>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-top-right-radius:0}.btn-group-justified{display:table;width:100%;table-layout:fixed;border-collapse:separate}.btn-group-justified>.btn,.btn-group-justified>.btn-group{display:table-cell;float:none;width:1%}.btn-group-justified>.btn-group .btn{width:100%}.btn-group-justified>.btn-group .dropdown-menu{left:auto}[data-toggle=buttons]>.btn input[type=checkbox],[data-toggle=buttons]>.btn input[type=radio],[data-toggle=buttons]>.btn-group>.btn input[type=checkbox],[data-toggle=buttons]>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:table;border-collapse:separate}.input-group[class*=col-]{float:none;padding-right:0;padding-left:0}.input-group .form-control{position:relative;z-index:2;float:left;width:100%;margin-bottom:0}.input-group .form-control:focus{z-index:3}.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-group-lg>.form-control,select.input-group-lg>.input-group-addon,select.input-group-lg>.input-group-btn>.btn{height:46px;line-height:46px}select[multiple].input-group-lg>.form-control,select[multiple].input-group-lg>.input-group-addon,select[multiple].input-group-lg>.input-group-btn>.btn,textarea.input-group-lg>.form-control,textarea.input-group-lg>.input-group-addon,textarea.input-group-lg>.input-group-btn>.btn{height:auto}.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-group-sm>.form-control,select.input-group-sm>.input-group-addon,select.input-group-sm>.input-group-btn>.btn{height:30px;line-height:30px}select[multiple].input-group-sm>.form-control,select[multiple].input-group-sm>.input-group-addon,select[multiple].input-group-sm>.input-group-btn>.btn,textarea.input-group-sm>.form-control,textarea.input-group-sm>.input-group-addon,textarea.input-group-sm>.input-group-btn>.btn{height:auto}.input-group .form-control,.input-group-addon,.input-group-btn{display:table-cell}.input-group .form-control:not(:first-child):not(:last-child),.input-group-addon:not(:first-child):not(:last-child),.input-group-btn:not(:first-child):not(:last-child){border-radius:0}.input-group-addon,.input-group-btn{width:1%;white-space:nowrap;vertical-align:middle}.input-group-addon{padding:6px 12px;font-size:14px;font-weight:400;line-height:1;color:#555;text-align:center;background-color:#eee;border:1px solid #ccc;border-radius:4px}.input-group-addon.input-sm{padding:5px 10px;font-size:12px;border-radius:3px}.input-group-addon.input-lg{padding:10px 16px;font-size:18px;border-radius:6px}.input-group-addon input[type=checkbox],.input-group-addon input[type=radio]{margin-top:0}.input-group .form-control:first-child,.input-group-addon:first-child,.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group>.btn,.input-group-btn:first-child>.dropdown-toggle,.input-group-btn:last-child>.btn-group:not(:last-child)>.btn,.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.input-group-addon:first-child{border-right:0}.input-group .form-control:last-child,.input-group-addon:last-child,.input-group-btn:first-child>.btn-group:not(:first-child)>.btn,.input-group-btn:first-child>.btn:not(:first-child),.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group>.btn,.input-group-btn:last-child>.dropdown-toggle{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-addon:last-child{border-left:0}.input-group-btn{position:relative;font-size:0;white-space:nowrap}.input-group-btn>.btn{position:relative}.input-group-btn>.btn+.btn{margin-left:-1px}.input-group-btn>.btn:active,.input-group-btn>.btn:focus,.input-group-btn>.btn:hover{z-index:2}.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group{margin-right:-1px}.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group{z-index:2;margin-left:-1px}.nav{padding-left:0;margin-bottom:0;list-style:none}.nav>li{position:relative;display:block}.nav>li>a{position:relative;display:block;padding:10px 15px}.nav>li>a:focus,.nav>li>a:hover{text-decoration:none;background-color:#eee}.nav>li.disabled>a{color:#777}.nav>li.disabled>a:focus,.nav>li.disabled>a:hover{color:#777;text-decoration:none;cursor:not-allowed;background-color:transparent}.nav .open>a,.nav .open>a:focus,.nav .open>a:hover{background-color:#eee;border-color:#337ab7}.nav .nav-divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.nav>li>a>img{max-width:none}.nav-tabs{border-bottom:1px solid #ddd}.nav-tabs>li{float:left;margin-bottom:-1px}.nav-tabs>li>a{margin-right:2px;line-height:1.42857143;border:1px solid transparent;border-radius:4px 4px 0 0}.nav-tabs>li>a:hover{border-color:#eee #eee #ddd}.nav-tabs>li.active>a,.nav-tabs>li.active>a:focus,.nav-tabs>li.active>a:hover{color:#555;cursor:default;background-color:#fff;border:1px solid #ddd;border-bottom-color:transparent}.nav-tabs.nav-justified{width:100%;border-bottom:0}.nav-tabs.nav-justified>li{float:none}.nav-tabs.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-tabs.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-tabs.nav-justified>li{display:table-cell;width:1%}.nav-tabs.nav-justified>li>a{margin-bottom:0}}.nav-tabs.nav-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs.nav-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border-bottom-color:#fff}}.nav-pills>li{float:left}.nav-pills>li>a{border-radius:4px}.nav-pills>li+li{margin-left:2px}.nav-pills>li.active>a,.nav-pills>li.active>a:focus,.nav-pills>li.active>a:hover{color:#fff;background-color:#337ab7}.nav-stacked>li{float:none}.nav-stacked>li+li{margin-top:2px;margin-left:0}.nav-justified{width:100%}.nav-justified>li{float:none}.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-justified>li{display:table-cell;width:1%}.nav-justified>li>a{margin-bottom:0}}.nav-tabs-justified{border-bottom:0}.nav-tabs-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border-bottom-color:#fff}}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.navbar{position:relative;min-height:50px;margin-bottom:20px;border:1px solid transparent}@media (min-width:768px){.navbar{border-radius:4px}}@media (min-width:768px){.navbar-header{float:left}}.navbar-collapse{padding-right:15px;padding-left:15px;overflow-x:visible;-webkit-overflow-scrolling:touch;border-top:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1)}.navbar-collapse.in{overflow-y:auto}@media (min-width:768px){.navbar-collapse{width:auto;border-top:0;-webkit-box-shadow:none;box-shadow:none}.navbar-collapse.collapse{display:block!important;height:auto!important;padding-bottom:0;overflow:visible!important}.navbar-collapse.in{overflow-y:visible}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse,.navbar-static-top .navbar-collapse{padding-right:0;padding-left:0}}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:340px}@media (max-device-width:480px) and (orientation:landscape){.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:200px}}.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:0;margin-left:0}}.navbar-static-top{z-index:1000;border-width:0 0 1px}@media (min-width:768px){.navbar-static-top{border-radius:0}}.navbar-fixed-bottom,.navbar-fixed-top{position:fixed;right:0;left:0;z-index:1030}@media (min-width:768px){.navbar-fixed-bottom,.navbar-fixed-top{border-radius:0}}.navbar-fixed-top{top:0;border-width:0 0 1px}.navbar-fixed-bottom{bottom:0;margin-bottom:0;border-width:1px 0 0}.navbar-brand{float:left;height:50px;padding:15px 15px;font-size:18px;line-height:20px}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-brand>img{display:block}@media (min-width:768px){.navbar>.container .navbar-brand,.navbar>.container-fluid .navbar-brand{margin-left:-15px}}.navbar-toggle{position:relative;float:right;padding:9px 10px;margin-top:8px;margin-right:15px;margin-bottom:8px;background-color:transparent;background-image:none;border:1px solid transparent;border-radius:4px}.navbar-toggle:focus{outline:0}.navbar-toggle .icon-bar{display:block;width:22px;height:2px;border-radius:1px}.navbar-toggle .icon-bar+.icon-bar{margin-top:4px}@media (min-width:768px){.navbar-toggle{display:none}}.navbar-nav{margin:7.5px -15px}.navbar-nav>li>a{padding-top:10px;padding-bottom:10px;line-height:20px}@media (max-width:767px){.navbar-nav .open .dropdown-menu{position:static;float:none;width:auto;margin-top:0;background-color:transparent;border:0;-webkit-box-shadow:none;box-shadow:none}.navbar-nav .open .dropdown-menu .dropdown-header,.navbar-nav .open .dropdown-menu>li>a{padding:5px 15px 5px 25px}.navbar-nav .open .dropdown-menu>li>a{line-height:20px}.navbar-nav .open .dropdown-menu>li>a:focus,.navbar-nav .open .dropdown-menu>li>a:hover{background-image:none}}@media (min-width:768px){.navbar-nav{float:left;margin:0}.navbar-nav>li{float:left}.navbar-nav>li>a{padding-top:15px;padding-bottom:15px}}.navbar-form{padding:10px 15px;margin-top:8px;margin-right:-15px;margin-bottom:8px;margin-left:-15px;border-top:1px solid transparent;border-bottom:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1)}@media (min-width:768px){.navbar-form .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.navbar-form .form-control{display:inline-block;width:auto;vertical-align:middle}.navbar-form .form-control-static{display:inline-block}.navbar-form .input-group{display:inline-table;vertical-align:middle}.navbar-form .input-group .form-control,.navbar-form .input-group .input-group-addon,.navbar-form .input-group .input-group-btn{width:auto}.navbar-form .input-group>.form-control{width:100%}.navbar-form .control-label{margin-bottom:0;vertical-align:middle}.navbar-form .checkbox,.navbar-form .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.navbar-form .checkbox label,.navbar-form .radio label{padding-left:0}.navbar-form .checkbox input[type=checkbox],.navbar-form .radio input[type=radio]{position:relative;margin-left:0}.navbar-form .has-feedback .form-control-feedback{top:0}}@media (max-width:767px){.navbar-form .form-group{margin-bottom:5px}.navbar-form .form-group:last-child{margin-bottom:0}}@media (min-width:768px){.navbar-form{width:auto;padding-top:0;padding-bottom:0;margin-right:0;margin-left:0;border:0;-webkit-box-shadow:none;box-shadow:none}}.navbar-nav>li>.dropdown-menu{margin-top:0;border-top-left-radius:0;border-top-right-radius:0}.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu{margin-bottom:0;border-top-left-radius:4px;border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.navbar-btn{margin-top:8px;margin-bottom:8px}.navbar-btn.btn-sm{margin-top:10px;margin-bottom:10px}.navbar-btn.btn-xs{margin-top:14px;margin-bottom:14px}.navbar-text{margin-top:15px;margin-bottom:15px}@media (min-width:768px){.navbar-text{float:left;margin-right:15px;margin-left:15px}}@media (min-width:768px){.navbar-left{float:left!important}.navbar-right{float:right!important;margin-right:-15px}.navbar-right~.navbar-right{margin-right:0}}.navbar-default{background-color:#f8f8f8;border-color:#e7e7e7}.navbar-default .navbar-brand{color:#777}.navbar-default .navbar-brand:focus,.navbar-default .navbar-brand:hover{color:#5e5e5e;background-color:transparent}.navbar-default .navbar-text{color:#777}.navbar-default .navbar-nav>li>a{color:#777}.navbar-default .navbar-nav>li>a:focus,.navbar-default .navbar-nav>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:focus,.navbar-default .navbar-nav>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:focus,.navbar-default .navbar-nav>.disabled>a:hover{color:#ccc;background-color:transparent}.navbar-default .navbar-toggle{border-color:#ddd}.navbar-default .navbar-toggle:focus,.navbar-default .navbar-toggle:hover{background-color:#ddd}.navbar-default .navbar-toggle .icon-bar{background-color:#888}.navbar-default .navbar-collapse,.navbar-default .navbar-form{border-color:#e7e7e7}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:focus,.navbar-default .navbar-nav>.open>a:hover{color:#555;background-color:#e7e7e7}@media (max-width:767px){.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#777}.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#ccc;background-color:transparent}}.navbar-default .navbar-link{color:#777}.navbar-default .navbar-link:hover{color:#333}.navbar-default .btn-link{color:#777}.navbar-default .btn-link:focus,.navbar-default .btn-link:hover{color:#333}.navbar-default .btn-link[disabled]:focus,.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-default .btn-link:focus,fieldset[disabled] .navbar-default .btn-link:hover{color:#ccc}.navbar-inverse{background-color:#222;border-color:#080808}.navbar-inverse .navbar-brand{color:#9d9d9d}.navbar-inverse .navbar-brand:focus,.navbar-inverse .navbar-brand:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-text{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a:focus,.navbar-inverse .navbar-nav>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:focus,.navbar-inverse .navbar-nav>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:focus,.navbar-inverse .navbar-nav>.disabled>a:hover{color:#444;background-color:transparent}.navbar-inverse .navbar-toggle{border-color:#333}.navbar-inverse .navbar-toggle:focus,.navbar-inverse .navbar-toggle:hover{background-color:#333}.navbar-inverse .navbar-toggle .icon-bar{background-color:#fff}.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{border-color:#101010}.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:focus,.navbar-inverse .navbar-nav>.open>a:hover{color:#fff;background-color:#080808}@media (max-width:767px){.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#444;background-color:transparent}}.navbar-inverse .navbar-link{color:#9d9d9d}.navbar-inverse .navbar-link:hover{color:#fff}.navbar-inverse .btn-link{color:#9d9d9d}.navbar-inverse .btn-link:focus,.navbar-inverse .btn-link:hover{color:#fff}.navbar-inverse .btn-link[disabled]:focus,.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-inverse .btn-link:focus,fieldset[disabled] .navbar-inverse .btn-link:hover{color:#444}.breadcrumb{padding:8px 15px;margin-bottom:20px;list-style:none;background-color:#f5f5f5;border-radius:4px}.breadcrumb>li{display:inline-block}.breadcrumb>li+li:before{padding:0 5px;color:#ccc;content:"/\00a0"}.breadcrumb>.active{color:#777}.pagination{display:inline-block;padding-left:0;margin:20px 0;border-radius:4px}.pagination>li{display:inline}.pagination>li>a,.pagination>li>span{position:relative;float:left;padding:6px 12px;margin-left:-1px;line-height:1.42857143;color:#337ab7;text-decoration:none;background-color:#fff;border:1px solid #ddd}.pagination>li:first-child>a,.pagination>li:first-child>span{margin-left:0;border-top-left-radius:4px;border-bottom-left-radius:4px}.pagination>li:last-child>a,.pagination>li:last-child>span{border-top-right-radius:4px;border-bottom-right-radius:4px}.pagination>li>a:focus,.pagination>li>a:hover,.pagination>li>span:focus,.pagination>li>span:hover{z-index:2;color:#23527c;background-color:#eee;border-color:#ddd}.pagination>.active>a,.pagination>.active>a:focus,.pagination>.active>a:hover,.pagination>.active>span,.pagination>.active>span:focus,.pagination>.active>span:hover{z-index:3;color:#fff;cursor:default;background-color:#337ab7;border-color:#337ab7}.pagination>.disabled>a,.pagination>.disabled>a:focus,.pagination>.disabled>a:hover,.pagination>.disabled>span,.pagination>.disabled>span:focus,.pagination>.disabled>span:hover{color:#777;cursor:not-allowed;background-color:#fff;border-color:#ddd}.pagination-lg>li>a,.pagination-lg>li>span{padding:10px 16px;font-size:18px;line-height:1.3333333}.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{border-top-left-radius:6px;border-bottom-left-radius:6px}.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{border-top-right-radius:6px;border-bottom-right-radius:6px}.pagination-sm>li>a,.pagination-sm>li>span{padding:5px 10px;font-size:12px;line-height:1.5}.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{border-top-left-radius:3px;border-bottom-left-radius:3px}.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{border-top-right-radius:3px;border-bottom-right-radius:3px}.pager{padding-left:0;margin:20px 0;text-align:center;list-style:none}.pager li{display:inline}.pager li>a,.pager li>span{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;border-radius:15px}.pager li>a:focus,.pager li>a:hover{text-decoration:none;background-color:#eee}.pager .next>a,.pager .next>span{float:right}.pager .previous>a,.pager .previous>span{float:left}.pager .disabled>a,.pager .disabled>a:focus,.pager .disabled>a:hover,.pager .disabled>span{color:#777;cursor:not-allowed;background-color:#fff}.label{display:inline;padding:.2em .6em .3em;font-size:75%;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25em}a.label:focus,a.label:hover{color:#fff;text-decoration:none;cursor:pointer}.label:empty{display:none}.btn .label{position:relative;top:-1px}.label-default{background-color:#777}.label-default[href]:focus,.label-default[href]:hover{background-color:#5e5e5e}.label-primary{background-color:#337ab7}.label-primary[href]:focus,.label-primary[href]:hover{background-color:#286090}.label-success{background-color:#5cb85c}.label-success[href]:focus,.label-success[href]:hover{background-color:#449d44}.label-info{background-color:#5bc0de}.label-info[href]:focus,.label-info[href]:hover{background-color:#31b0d5}.label-warning{background-color:#f0ad4e}.label-warning[href]:focus,.label-warning[href]:hover{background-color:#ec971f}.label-danger{background-color:#d9534f}.label-danger[href]:focus,.label-danger[href]:hover{background-color:#c9302c}.badge{display:inline-block;min-width:10px;padding:3px 7px;font-size:12px;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:middle;background-color:#777;border-radius:10px}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.btn-group-xs>.btn .badge,.btn-xs .badge{top:0;padding:1px 5px}a.badge:focus,a.badge:hover{color:#fff;text-decoration:none;cursor:pointer}.list-group-item.active>.badge,.nav-pills>.active>a>.badge{color:#337ab7;background-color:#fff}.list-group-item>.badge{float:right}.list-group-item>.badge+.badge{margin-right:5px}.nav-pills>li>a>.badge{margin-left:3px}.jumbotron{padding-top:30px;padding-bottom:30px;margin-bottom:30px;color:inherit;background-color:#eee}.jumbotron .h1,.jumbotron h1{color:inherit}.jumbotron p{margin-bottom:15px;font-size:21px;font-weight:200}.jumbotron>hr{border-top-color:#d5d5d5}.container .jumbotron,.container-fluid .jumbotron{padding-right:15px;padding-left:15px;border-radius:6px}.jumbotron .container{max-width:100%}@media screen and (min-width:768px){.jumbotron{padding-top:48px;padding-bottom:48px}.container .jumbotron,.container-fluid .jumbotron{padding-right:60px;padding-left:60px}.jumbotron .h1,.jumbotron h1{font-size:63px}}.thumbnail{display:block;padding:4px;margin-bottom:20px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:border .2s ease-in-out;-o-transition:border .2s ease-in-out;transition:border .2s ease-in-out}.thumbnail a>img,.thumbnail>img{margin-right:auto;margin-left:auto}a.thumbnail.active,a.thumbnail:focus,a.thumbnail:hover{border-color:#337ab7}.thumbnail .caption{padding:9px;color:#333}.alert{padding:15px;margin-bottom:20px;border:1px solid transparent;border-radius:4px}.alert h4{margin-top:0;color:inherit}.alert .alert-link{font-weight:700}.alert>p,.alert>ul{margin-bottom:0}.alert>p+p{margin-top:5px}.alert-dismissable,.alert-dismissible{padding-right:35px}.alert-dismissable .close,.alert-dismissible .close{position:relative;top:-2px;right:-21px;color:inherit}.alert-success{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.alert-success hr{border-top-color:#c9e2b3}.alert-success .alert-link{color:#2b542c}.alert-info{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.alert-info hr{border-top-color:#a6e1ec}.alert-info .alert-link{color:#245269}.alert-warning{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.alert-warning hr{border-top-color:#f7e1b5}.alert-warning .alert-link{color:#66512c}.alert-danger{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.alert-danger hr{border-top-color:#e4b9c0}.alert-danger .alert-link{color:#843534}@-webkit-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@-o-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}.progress{height:20px;margin-bottom:20px;overflow:hidden;background-color:#f5f5f5;border-radius:4px;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}.progress-bar{float:left;width:0;height:100%;font-size:12px;line-height:20px;color:#fff;text-align:center;background-color:#337ab7;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);-webkit-transition:width .6s ease;-o-transition:width .6s ease;transition:width .6s ease}.progress-bar-striped,.progress-striped .progress-bar{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);-webkit-background-size:40px 40px;background-size:40px 40px}.progress-bar.active,.progress.active .progress-bar{-webkit-animation:progress-bar-stripes 2s linear infinite;-o-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite}.progress-bar-success{background-color:#5cb85c}.progress-striped .progress-bar-success{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-info{background-color:#5bc0de}.progress-striped .progress-bar-info{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-warning{background-color:#f0ad4e}.progress-striped .progress-bar-warning{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-danger{background-color:#d9534f}.progress-striped .progress-bar-danger{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.media{margin-top:15px}.media:first-child{margin-top:0}.media,.media-body{overflow:hidden;zoom:1}.media-body{width:10000px}.media-object{display:block}.media-object.img-thumbnail{max-width:none}.media-right,.media>.pull-right{padding-left:10px}.media-left,.media>.pull-left{padding-right:10px}.media-body,.media-left,.media-right{display:table-cell;vertical-align:top}.media-middle{vertical-align:middle}.media-bottom{vertical-align:bottom}.media-heading{margin-top:0;margin-bottom:5px}.media-list{padding-left:0;list-style:none}.list-group{padding-left:0;margin-bottom:20px}.list-group-item{position:relative;display:block;padding:10px 15px;margin-bottom:-1px;background-color:#fff;border:1px solid #ddd}.list-group-item:first-child{border-top-left-radius:4px;border-top-right-radius:4px}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}a.list-group-item,button.list-group-item{color:#555}a.list-group-item .list-group-item-heading,button.list-group-item .list-group-item-heading{color:#333}a.list-group-item:focus,a.list-group-item:hover,button.list-group-item:focus,button.list-group-item:hover{color:#555;text-decoration:none;background-color:#f5f5f5}button.list-group-item{width:100%;text-align:left}.list-group-item.disabled,.list-group-item.disabled:focus,.list-group-item.disabled:hover{color:#777;cursor:not-allowed;background-color:#eee}.list-group-item.disabled .list-group-item-heading,.list-group-item.disabled:focus .list-group-item-heading,.list-group-item.disabled:hover .list-group-item-heading{color:inherit}.list-group-item.disabled .list-group-item-text,.list-group-item.disabled:focus .list-group-item-text,.list-group-item.disabled:hover .list-group-item-text{color:#777}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{z-index:2;color:#fff;background-color:#337ab7;border-color:#337ab7}.list-group-item.active .list-group-item-heading,.list-group-item.active .list-group-item-heading>.small,.list-group-item.active .list-group-item-heading>small,.list-group-item.active:focus .list-group-item-heading,.list-group-item.active:focus .list-group-item-heading>.small,.list-group-item.active:focus .list-group-item-heading>small,.list-group-item.active:hover .list-group-item-heading,.list-group-item.active:hover .list-group-item-heading>.small,.list-group-item.active:hover .list-group-item-heading>small{color:inherit}.list-group-item.active .list-group-item-text,.list-group-item.active:focus .list-group-item-text,.list-group-item.active:hover .list-group-item-text{color:#c7ddef}.list-group-item-success{color:#3c763d;background-color:#dff0d8}a.list-group-item-success,button.list-group-item-success{color:#3c763d}a.list-group-item-success .list-group-item-heading,button.list-group-item-success .list-group-item-heading{color:inherit}a.list-group-item-success:focus,a.list-group-item-success:hover,button.list-group-item-success:focus,button.list-group-item-success:hover{color:#3c763d;background-color:#d0e9c6}a.list-group-item-success.active,a.list-group-item-success.active:focus,a.list-group-item-success.active:hover,button.list-group-item-success.active,button.list-group-item-success.active:focus,button.list-group-item-success.active:hover{color:#fff;background-color:#3c763d;border-color:#3c763d}.list-group-item-info{color:#31708f;background-color:#d9edf7}a.list-group-item-info,button.list-group-item-info{color:#31708f}a.list-group-item-info .list-group-item-heading,button.list-group-item-info .list-group-item-heading{color:inherit}a.list-group-item-info:focus,a.list-group-item-info:hover,button.list-group-item-info:focus,button.list-group-item-info:hover{color:#31708f;background-color:#c4e3f3}a.list-group-item-info.active,a.list-group-item-info.active:focus,a.list-group-item-info.active:hover,button.list-group-item-info.active,button.list-group-item-info.active:focus,button.list-group-item-info.active:hover{color:#fff;background-color:#31708f;border-color:#31708f}.list-group-item-warning{color:#8a6d3b;background-color:#fcf8e3}a.list-group-item-warning,button.list-group-item-warning{color:#8a6d3b}a.list-group-item-warning .list-group-item-heading,button.list-group-item-warning .list-group-item-heading{color:inherit}a.list-group-item-warning:focus,a.list-group-item-warning:hover,button.list-group-item-warning:focus,button.list-group-item-warning:hover{color:#8a6d3b;background-color:#faf2cc}a.list-group-item-warning.active,a.list-group-item-warning.active:focus,a.list-group-item-warning.active:hover,button.list-group-item-warning.active,button.list-group-item-warning.active:focus,button.list-group-item-warning.active:hover{color:#fff;background-color:#8a6d3b;border-color:#8a6d3b}.list-group-item-danger{color:#a94442;background-color:#f2dede}a.list-group-item-danger,button.list-group-item-danger{color:#a94442}a.list-group-item-danger .list-group-item-heading,button.list-group-item-danger .list-group-item-heading{color:inherit}a.list-group-item-danger:focus,a.list-group-item-danger:hover,button.list-group-item-danger:focus,button.list-group-item-danger:hover{color:#a94442;background-color:#ebcccc}a.list-group-item-danger.active,a.list-group-item-danger.active:focus,a.list-group-item-danger.active:hover,button.list-group-item-danger.active,button.list-group-item-danger.active:focus,button.list-group-item-danger.active:hover{color:#fff;background-color:#a94442;border-color:#a94442}.list-group-item-heading{margin-top:0;margin-bottom:5px}.list-group-item-text{margin-bottom:0;line-height:1.3}.panel{margin-bottom:20px;background-color:#fff;border:1px solid transparent;border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0,0,0,.05);box-shadow:0 1px 1px rgba(0,0,0,.05)}.panel-body{padding:15px}.panel-heading{padding:10px 15px;border-bottom:1px solid transparent;border-top-left-radius:3px;border-top-right-radius:3px}.panel-heading>.dropdown .dropdown-toggle{color:inherit}.panel-title{margin-top:0;margin-bottom:0;font-size:16px;color:inherit}.panel-title>.small,.panel-title>.small>a,.panel-title>a,.panel-title>small,.panel-title>small>a{color:inherit}.panel-footer{padding:10px 15px;background-color:#f5f5f5;border-top:1px solid #ddd;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.list-group,.panel>.panel-collapse>.list-group{margin-bottom:0}.panel>.list-group .list-group-item,.panel>.panel-collapse>.list-group .list-group-item{border-width:1px 0;border-radius:0}.panel>.list-group:first-child .list-group-item:first-child,.panel>.panel-collapse>.list-group:first-child .list-group-item:first-child{border-top:0;border-top-left-radius:3px;border-top-right-radius:3px}.panel>.list-group:last-child .list-group-item:last-child,.panel>.panel-collapse>.list-group:last-child .list-group-item:last-child{border-bottom:0;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.panel-heading+.panel-collapse>.list-group .list-group-item:first-child{border-top-left-radius:0;border-top-right-radius:0}.panel-heading+.list-group .list-group-item:first-child{border-top-width:0}.list-group+.panel-footer{border-top-width:0}.panel>.panel-collapse>.table,.panel>.table,.panel>.table-responsive>.table{margin-bottom:0}.panel>.panel-collapse>.table caption,.panel>.table caption,.panel>.table-responsive>.table caption{padding-right:15px;padding-left:15px}.panel>.table-responsive:first-child>.table:first-child,.panel>.table:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child,.panel>.table:first-child>thead:first-child>tr:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table:first-child>thead:first-child>tr:first-child th:first-child{border-top-left-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table:first-child>thead:first-child>tr:first-child th:last-child{border-top-right-radius:3px}.panel>.table-responsive:last-child>.table:last-child,.panel>.table:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:first-child{border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:last-child{border-bottom-right-radius:3px}.panel>.panel-body+.table,.panel>.panel-body+.table-responsive,.panel>.table+.panel-body,.panel>.table-responsive+.panel-body{border-top:1px solid #ddd}.panel>.table>tbody:first-child>tr:first-child td,.panel>.table>tbody:first-child>tr:first-child th{border-top:0}.panel>.table-bordered,.panel>.table-responsive>.table-bordered{border:0}.panel>.table-bordered>tbody>tr>td:first-child,.panel>.table-bordered>tbody>tr>th:first-child,.panel>.table-bordered>tfoot>tr>td:first-child,.panel>.table-bordered>tfoot>tr>th:first-child,.panel>.table-bordered>thead>tr>td:first-child,.panel>.table-bordered>thead>tr>th:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,.panel>.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.panel>.table-bordered>tbody>tr>td:last-child,.panel>.table-bordered>tbody>tr>th:last-child,.panel>.table-bordered>tfoot>tr>td:last-child,.panel>.table-bordered>tfoot>tr>th:last-child,.panel>.table-bordered>thead>tr>td:last-child,.panel>.table-bordered>thead>tr>th:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,.panel>.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.panel>.table-bordered>tbody>tr:first-child>td,.panel>.table-bordered>tbody>tr:first-child>th,.panel>.table-bordered>thead>tr:first-child>td,.panel>.table-bordered>thead>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>th,.panel>.table-responsive>.table-bordered>thead>tr:first-child>td,.panel>.table-responsive>.table-bordered>thead>tr:first-child>th{border-bottom:0}.panel>.table-bordered>tbody>tr:last-child>td,.panel>.table-bordered>tbody>tr:last-child>th,.panel>.table-bordered>tfoot>tr:last-child>td,.panel>.table-bordered>tfoot>tr:last-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}.panel>.table-responsive{margin-bottom:0;border:0}.panel-group{margin-bottom:20px}.panel-group .panel{margin-bottom:0;border-radius:4px}.panel-group .panel+.panel{margin-top:5px}.panel-group .panel-heading{border-bottom:0}.panel-group .panel-heading+.panel-collapse>.list-group,.panel-group .panel-heading+.panel-collapse>.panel-body{border-top:1px solid #ddd}.panel-group .panel-footer{border-top:0}.panel-group .panel-footer+.panel-collapse .panel-body{border-bottom:1px solid #ddd}.panel-default{border-color:#ddd}.panel-default>.panel-heading{color:#333;background-color:#f5f5f5;border-color:#ddd}.panel-default>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ddd}.panel-default>.panel-heading .badge{color:#f5f5f5;background-color:#333}.panel-default>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ddd}.panel-primary{border-color:#337ab7}.panel-primary>.panel-heading{color:#fff;background-color:#337ab7;border-color:#337ab7}.panel-primary>.panel-heading+.panel-collapse>.panel-body{border-top-color:#337ab7}.panel-primary>.panel-heading .badge{color:#337ab7;background-color:#fff}.panel-primary>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#337ab7}.panel-success{border-color:#d6e9c6}.panel-success>.panel-heading{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.panel-success>.panel-heading+.panel-collapse>.panel-body{border-top-color:#d6e9c6}.panel-success>.panel-heading .badge{color:#dff0d8;background-color:#3c763d}.panel-success>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#d6e9c6}.panel-info{border-color:#bce8f1}.panel-info>.panel-heading{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.panel-info>.panel-heading+.panel-collapse>.panel-body{border-top-color:#bce8f1}.panel-info>.panel-heading .badge{color:#d9edf7;background-color:#31708f}.panel-info>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#bce8f1}.panel-warning{border-color:#faebcc}.panel-warning>.panel-heading{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.panel-warning>.panel-heading+.panel-collapse>.panel-body{border-top-color:#faebcc}.panel-warning>.panel-heading .badge{color:#fcf8e3;background-color:#8a6d3b}.panel-warning>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#faebcc}.panel-danger{border-color:#ebccd1}.panel-danger>.panel-heading{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.panel-danger>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ebccd1}.panel-danger>.panel-heading .badge{color:#f2dede;background-color:#a94442}.panel-danger>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ebccd1}.embed-responsive{position:relative;display:block;height:0;padding:0;overflow:hidden}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-16by9{padding-bottom:56.25%}.embed-responsive-4by3{padding-bottom:75%}.well{min-height:20px;padding:19px;margin-bottom:20px;background-color:#f5f5f5;border:1px solid #e3e3e3;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.05);box-shadow:inset 0 1px 1px rgba(0,0,0,.05)}.well blockquote{border-color:#ddd;border-color:rgba(0,0,0,.15)}.well-lg{padding:24px;border-radius:6px}.well-sm{padding:9px;border-radius:3px}.close{float:right;font-size:21px;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;filter:alpha(opacity=20);opacity:.2}.close:focus,.close:hover{color:#000;text-decoration:none;cursor:pointer;filter:alpha(opacity=50);opacity:.5}button.close{-webkit-appearance:none;padding:0;cursor:pointer;background:0 0;border:0}.modal-open{overflow:hidden}.modal{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1050;display:none;overflow:hidden;-webkit-overflow-scrolling:touch;outline:0}.modal.fade .modal-dialog{-webkit-transition:-webkit-transform .3s ease-out;-o-transition:-o-transform .3s ease-out;transition:transform .3s ease-out;-webkit-transform:translate(0,-25%);-ms-transform:translate(0,-25%);-o-transform:translate(0,-25%);transform:translate(0,-25%)}.modal.in .modal-dialog{-webkit-transform:translate(0,0);-ms-transform:translate(0,0);-o-transform:translate(0,0);transform:translate(0,0)}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal-dialog{position:relative;width:auto;margin:10px}.modal-content{position:relative;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #999;border:1px solid rgba(0,0,0,.2);border-radius:6px;outline:0;-webkit-box-shadow:0 3px 9px rgba(0,0,0,.5);box-shadow:0 3px 9px rgba(0,0,0,.5)}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;background-color:#000}.modal-backdrop.fade{filter:alpha(opacity=0);opacity:0}.modal-backdrop.in{filter:alpha(opacity=50);opacity:.5}.modal-header{padding:15px;border-bottom:1px solid #e5e5e5}.modal-header .close{margin-top:-2px}.modal-title{margin:0;line-height:1.42857143}.modal-body{position:relative;padding:15px}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}.modal-footer .btn+.btn{margin-bottom:0;margin-left:5px}.modal-footer .btn-group .btn+.btn{margin-left:-1px}.modal-footer .btn-block+.btn-block{margin-left:0}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:768px){.modal-dialog{width:600px;margin:30px auto}.modal-content{-webkit-box-shadow:0 5px 15px rgba(0,0,0,.5);box-shadow:0 5px 15px rgba(0,0,0,.5)}.modal-sm{width:300px}}@media (min-width:992px){.modal-lg{width:900px}}.tooltip{position:absolute;z-index:1070;display:block;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:12px;font-style:normal;font-weight:400;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;filter:alpha(opacity=0);opacity:0;line-break:auto}.tooltip.in{filter:alpha(opacity=90);opacity:.9}.tooltip.top{padding:5px 0;margin-top:-3px}.tooltip.right{padding:0 5px;margin-left:3px}.tooltip.bottom{padding:5px 0;margin-top:3px}.tooltip.left{padding:0 5px;margin-left:-3px}.tooltip-inner{max-width:200px;padding:3px 8px;color:#fff;text-align:center;background-color:#000;border-radius:4px}.tooltip-arrow{position:absolute;width:0;height:0;border-color:transparent;border-style:solid}.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-left .tooltip-arrow{right:5px;bottom:0;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-right .tooltip-arrow{bottom:0;left:5px;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#000}.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#000}.tooltip.bottom .tooltip-arrow{top:0;left:50%;margin-left:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-left .tooltip-arrow{top:0;right:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-right .tooltip-arrow{top:0;left:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.popover{position:absolute;top:0;left:0;z-index:1060;display:none;max-width:276px;padding:1px;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;font-style:normal;font-weight:400;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.2);border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,.2);box-shadow:0 5px 10px rgba(0,0,0,.2);line-break:auto}.popover.top{margin-top:-10px}.popover.right{margin-left:10px}.popover.bottom{margin-top:10px}.popover.left{margin-left:-10px}.popover-title{padding:8px 14px;margin:0;font-size:14px;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-radius:5px 5px 0 0}.popover-content{padding:9px 14px}.popover>.arrow,.popover>.arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid}.popover>.arrow{border-width:11px}.popover>.arrow:after{content:"";border-width:10px}.popover.top>.arrow{bottom:-11px;left:50%;margin-left:-11px;border-top-color:#999;border-top-color:rgba(0,0,0,.25);border-bottom-width:0}.popover.top>.arrow:after{bottom:1px;margin-left:-10px;content:" ";border-top-color:#fff;border-bottom-width:0}.popover.right>.arrow{top:50%;left:-11px;margin-top:-11px;border-right-color:#999;border-right-color:rgba(0,0,0,.25);border-left-width:0}.popover.right>.arrow:after{bottom:-10px;left:1px;content:" ";border-right-color:#fff;border-left-width:0}.popover.bottom>.arrow{top:-11px;left:50%;margin-left:-11px;border-top-width:0;border-bottom-color:#999;border-bottom-color:rgba(0,0,0,.25)}.popover.bottom>.arrow:after{top:1px;margin-left:-10px;content:" ";border-top-width:0;border-bottom-color:#fff}.popover.left>.arrow{top:50%;right:-11px;margin-top:-11px;border-right-width:0;border-left-color:#999;border-left-color:rgba(0,0,0,.25)}.popover.left>.arrow:after{right:1px;bottom:-10px;content:" ";border-right-width:0;border-left-color:#fff}.carousel{position:relative}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner>.item{position:relative;display:none;-webkit-transition:.6s ease-in-out left;-o-transition:.6s ease-in-out left;transition:.6s ease-in-out left}.carousel-inner>.item>a>img,.carousel-inner>.item>img{line-height:1}@media all and (transform-3d),(-webkit-transform-3d){.carousel-inner>.item{-webkit-transition:-webkit-transform .6s ease-in-out;-o-transition:-o-transform .6s ease-in-out;transition:transform .6s ease-in-out;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-perspective:1000px;perspective:1000px}.carousel-inner>.item.active.right,.carousel-inner>.item.next{left:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}.carousel-inner>.item.active.left,.carousel-inner>.item.prev{left:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}.carousel-inner>.item.active,.carousel-inner>.item.next.left,.carousel-inner>.item.prev.right{left:0;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}.carousel-inner>.active,.carousel-inner>.next,.carousel-inner>.prev{display:block}.carousel-inner>.active{left:0}.carousel-inner>.next,.carousel-inner>.prev{position:absolute;top:0;width:100%}.carousel-inner>.next{left:100%}.carousel-inner>.prev{left:-100%}.carousel-inner>.next.left,.carousel-inner>.prev.right{left:0}.carousel-inner>.active.left{left:-100%}.carousel-inner>.active.right{left:100%}.carousel-control{position:absolute;top:0;bottom:0;left:0;width:15%;font-size:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6);background-color:rgba(0,0,0,0);filter:alpha(opacity=50);opacity:.5}.carousel-control.left{background-image:-webkit-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.5)),to(rgba(0,0,0,.0001)));background-image:linear-gradient(to right,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);background-repeat:repeat-x}.carousel-control.right{right:0;left:auto;background-image:-webkit-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.0001)),to(rgba(0,0,0,.5)));background-image:linear-gradient(to right,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1);background-repeat:repeat-x}.carousel-control:focus,.carousel-control:hover{color:#fff;text-decoration:none;filter:alpha(opacity=90);outline:0;opacity:.9}.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{position:absolute;top:50%;z-index:5;display:inline-block;margin-top:-10px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{left:50%;margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{right:50%;margin-right:-10px}.carousel-control .icon-next,.carousel-control .icon-prev{width:20px;height:20px;font-family:serif;line-height:1}.carousel-control .icon-prev:before{content:'\2039'}.carousel-control .icon-next:before{content:'\203a'}.carousel-indicators{position:absolute;bottom:10px;left:50%;z-index:15;width:60%;padding-left:0;margin-left:-30%;text-align:center;list-style:none}.carousel-indicators li{display:inline-block;width:10px;height:10px;margin:1px;text-indent:-999px;cursor:pointer;background-color:#000\9;background-color:rgba(0,0,0,0);border:1px solid #fff;border-radius:10px}.carousel-indicators .active{width:12px;height:12px;margin:0;background-color:#fff}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6)}.carousel-caption .btn{text-shadow:none}@media screen and (min-width:768px){.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{width:30px;height:30px;margin-top:-10px;font-size:30px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{margin-right:-10px}.carousel-caption{right:20%;left:20%;padding-bottom:30px}.carousel-indicators{bottom:20px}}.btn-group-vertical>.btn-group:after,.btn-group-vertical>.btn-group:before,.btn-toolbar:after,.btn-toolbar:before,.clearfix:after,.clearfix:before,.container-fluid:after,.container-fluid:before,.container:after,.container:before,.dl-horizontal dd:after,.dl-horizontal dd:before,.form-horizontal .form-group:after,.form-horizontal .form-group:before,.modal-footer:after,.modal-footer:before,.modal-header:after,.modal-header:before,.nav:after,.nav:before,.navbar-collapse:after,.navbar-collapse:before,.navbar-header:after,.navbar-header:before,.navbar:after,.navbar:before,.pager:after,.pager:before,.panel-body:after,.panel-body:before,.row:after,.row:before{display:table;content:" "}.btn-group-vertical>.btn-group:after,.btn-toolbar:after,.clearfix:after,.container-fluid:after,.container:after,.dl-horizontal dd:after,.form-horizontal .form-group:after,.modal-footer:after,.modal-header:after,.nav:after,.navbar-collapse:after,.navbar-header:after,.navbar:after,.pager:after,.panel-body:after,.row:after{clear:both}.center-block{display:block;margin-right:auto;margin-left:auto}.pull-right{float:right!important}.pull-left{float:left!important}.hide{display:none!important}.show{display:block!important}.invisible{visibility:hidden}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.hidden{display:none!important}.affix{position:fixed}@-ms-viewport{width:device-width}.visible-lg,.visible-md,.visible-sm,.visible-xs{display:none!important}.visible-lg-block,.visible-lg-inline,.visible-lg-inline-block,.visible-md-block,.visible-md-inline,.visible-md-inline-block,.visible-sm-block,.visible-sm-inline,.visible-sm-inline-block,.visible-xs-block,.visible-xs-inline,.visible-xs-inline-block{display:none!important}@media (max-width:767px){.visible-xs{display:block!important}table.visible-xs{display:table!important}tr.visible-xs{display:table-row!important}td.visible-xs,th.visible-xs{display:table-cell!important}}@media (max-width:767px){.visible-xs-block{display:block!important}}@media (max-width:767px){.visible-xs-inline{display:inline!important}}@media (max-width:767px){.visible-xs-inline-block{display:inline-block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm{display:block!important}table.visible-sm{display:table!important}tr.visible-sm{display:table-row!important}td.visible-sm,th.visible-sm{display:table-cell!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-block{display:block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline{display:inline!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline-block{display:inline-block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md{display:block!important}table.visible-md{display:table!important}tr.visible-md{display:table-row!important}td.visible-md,th.visible-md{display:table-cell!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-block{display:block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline{display:inline!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline-block{display:inline-block!important}}@media (min-width:1200px){.visible-lg{display:block!important}table.visible-lg{display:table!important}tr.visible-lg{display:table-row!important}td.visible-lg,th.visible-lg{display:table-cell!important}}@media (min-width:1200px){.visible-lg-block{display:block!important}}@media (min-width:1200px){.visible-lg-inline{display:inline!important}}@media (min-width:1200px){.visible-lg-inline-block{display:inline-block!important}}@media (max-width:767px){.hidden-xs{display:none!important}}@media (min-width:768px) and (max-width:991px){.hidden-sm{display:none!important}}@media (min-width:992px) and (max-width:1199px){.hidden-md{display:none!important}}@media (min-width:1200px){.hidden-lg{display:none!important}}.visible-print{display:none!important}@media print{.visible-print{display:block!important}table.visible-print{display:table!important}tr.visible-print{display:table-row!important}td.visible-print,th.visible-print{display:table-cell!important}}.visible-print-block{display:none!important}@media print{.visible-print-block{display:block!important}}.visible-print-inline{display:none!important}@media print{.visible-print-inline{display:inline!important}}.visible-print-inline-block{display:none!important}@media print{.visible-print-inline-block{display:inline-block!important}}@media print{.hidden-print{display:none!important}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/static/admin/css/style.css b/static/admin/css/style.css new file mode 100644 index 0000000..3aa4ea0 --- /dev/null +++ b/static/admin/css/style.css @@ -0,0 +1,457 @@ +body{ + font-family: "Unica One", sans-serif; + font-size: 12px; + line-height: 1.3; + background-color: #1a1d1f; +} + +h1 { + font-size: 24px; + margin-left: 20px; +} + +input, select { + padding: 5px; + border: 0; + margin-bottom: 16px; + width: 100%; + border: 1px solid #bfbfbf; + background-color: #1a1d1f; + font-size: 12px; + color: #e0e0e3; +} + +input[type="checkbox"]{ + width: 20px; + zoom: 1.3; + -moz-transform: scale(1.3); + -webkit-transform: scale(1.3); +} + +a, a:visited { + color: #a1a1a1; +} + +a:hover { + color: #2196f3; +} + +.mini-icon { + height: 16px; + width: 16px; +} + +.medium-icon { + height: 32px; + width: 32px; +} + + +/* ALIGNMENTS */ +.left { + text-align: left!important; +} + +.right { + text-align: right!important; +} + +.center { + text-align: center!important; +} + +.justify { + text-align: justify!important; +} + + +.small { + font-size: 11px; +} + +.beta { + font-size: 11px; + color: #9f9f9f; +} + +/* BUTTON SUCCESS */ +.btn-success { + background-image: -webkit-linear-gradient(top, #2196f3 0%, #1186e3 100%); + background-image: -o-linear-gradient(top, #2196f3 0%, #1186e3 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#2196f3), to(#1186e3)); + background-image: linear-gradient(to bottom, #2196f3 0%, #1186e3 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff2196f3', endColorstr='#ff1186e3', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + background-repeat: repeat-x; + border-color: #2196f3; + background-color: #1186e3; +} + +.btn-success:hover, +.btn-success:focus { + background-color: #1186e3; + background-position: 0 -15px; +} + +.btn-success:active, +.btn-success.active { + background-color: #1186e3; + border-color: #2196f3; +} + +.btn-success.disabled, +.btn-success[disabled], +fieldset[disabled] .btn-success, +.btn-success.disabled:hover, +.btn-success[disabled]:hover, +fieldset[disabled] .btn-success:hover, +.btn-success.disabled:focus, +.btn-success[disabled]:focus, +fieldset[disabled] .btn-success:focus, +.btn-success.disabled.focus, +.btn-success[disabled].focus, +fieldset[disabled] .btn-success.focus, +.btn-success.disabled:active, +.btn-success[disabled]:active, +fieldset[disabled] .btn-success:active, +.btn-success.disabled.active, +.btn-success[disabled].active, +fieldset[disabled] .btn-success.active { + background-color: #1186e3; + background-image: none; +} + + +/* SEARCH BOX */ +.search-input { + width: 500px; +} + +.search-btn-icon { + background-image: url('/icons/ic_search_white_24dp_2x.png'); + background-clip: padding-box; + background-size: cover; + width: 28px; + height: 28px; + vertical-align: middle; + margin: 0!important; + color: transparent; + background-color: transparent; + border-color: transparent; + box-shadow: none; + webkit-box-shadow: none; + text-shadow: none; + webkit-text-shadow: none; +} + +.search-btn-icon:hover, +.search-btn-icon:focus { + outline: 0; +} + +table.spaced tr th, +table.spaced tr td { + padding: 5px; +} + + +/* COMMON PAGES */ +.container #welcome-msg { + text-align: center; +} + +.container h3 { + margin-bottom: 20px; + margin-top: 0; +} + +.container span { + display: block; + margin-left: auto; + margin-right: auto; +} + +.container span.label-field { + font-size: 12px; + margin-bottom: 2px; + padding-left: 2px; +} + +.container button { + display: inline-block; + margin-left: 8px; + margin-right: 8px; + margin-bottom: 8px; +} + +.container #welcome-msg { + margin-bottom: 60px; + padding-bottom: 10px; +} + +.container #welcome-msg h1 { + color: #e0e0e3; +} + +.container div.box-content { + color: #e0e0e3; + background-color: rgba(255, 255, 255, 0.1); + text-align: left; + padding: 30px; +} + +.container div.box-content-transp { + color: #e0e0e3; + background: transparent; + text-align: left; + padding: 30px; +} + +.container div.title-section { + color: #e0e0e3; + background: transparent; + text-align: left; + margin-bottom: 20px; + border-bottom: 1px solid #bfbfbf; +} + + +.container div.box-actions { + margin-top: 10px; + text-align: center; +} + +.container .optional-actions { + margin-top: 30px; + text-align: center; + font-size: 11px; +} + +.container .optional-actions a { + margin: 0 5px; +} + + +.container #body, +.container #form { + padding-top: 20px; + background-color: rgba(255, 255, 255, 0.1); +} + +.container #body { + border-bottom: 1px solid #bfbfbf; +} + +/* Navigation tab menu */ +.container #tab-menu div { + padding-left: 0; + padding-right: 0; +} + +.container .nav-pills { + /*border-bottom: 1px solid #bfbfbf;*/ + color: #e0e0e3; + display: flex; + overflow: hidden; +} + +.container .nav-pills > li { + padding-left: 0; + padding-right: 0; +} + +.container .nav-pills > li > a { + color: #cfd8dc; + border-radius: 0; + margin-left: 0; + text-decoration: none; + cursor: pointer; + padding: 6px; +} + +.container .nav-pills > li > a:hover { + color: #fff; + background-color: transparent; +} + +.container .nav-pills > li.active > a, +.container .nav-pills > li.active > a:hover { + color: #fff; + border-top: 1px solid #fff; + background-color: rgba(255, 255, 255, 0.1); + text-decoration: none; + cursor: default; +} + + +/* HEADER */ +.container #header { + height: 60px; + border-bottom-width: 3px; + border-bottom-color: #b0bec5; + border-bottom-style: solid; + display: flex; + display: -ms-flexbox; + align-items: center; + -ms-flex-align: center; +} + +.container #header .title { + color: #e0e0e3; + margin-left: 0!important; +} + +.container #header .login-box { + text-align: right; +} + +.container #header .login-box a, +.container #header .login-box .login, +.container #header .login-box .wallet-blc { + color: #e0e0e3; + font-size: 12px; +} + +.container #header .login-box a, +.container #header .login-box .login { + display: inline-block; +} + +.container #header .login-box a { + vertical-align: middle; +} + +.container #header span { + display:inline; +} + + +/* MESSAGES */ +.container div.msg-boxes { + text-align: center; + margin-top: 20px; + font-size: 14px; +} + +.container div.msg-boxes .msg { + color: #e0e0e3; +} + +.container div.msg-boxes .msg-error { + color: #c76464; +} + +.container div.msg-boxes .msg-info { + color: #52c152; +} + + +/* LOGIN PAGE */ +#login-page { + padding: 100px 0; +} + +#login-page #signin { + margin-top: 10px; +} + +#login-page input { + width: 100%; +} + +#login-page span { + display:inline; +} + + +#body { + padding: 40px; + color: #efefef; +} + +/* PAIRING */ +#qr-label { + margin: 0 0 20px 0; +} + +#qr-pairing { + width: 276px; + height: 276px; + padding: 10px; + background-color: #fff; + margin: auto; +} + +/* FORM FIED*/ +#cell-args, +#cell-args2, +#cell-args3 { + display: inline-block; +} + +.halfwidth { + width: 50%; + min-width: 50%; +} + +.fullwidth { + width: 100%; + min-width: 100%; +} + +#cell-args2, +#cell-args3 { + width: 24%; + min-width: 24%; +} + +/* JSON DATA */ +.json-data-container { + max-width: 100%; + word-wrap: break-word; + overflow: visible; + margin-top: 20px; +} + +#json-data { + text-align: left; + min-height: 400px; + max-width: 945px; + outline: 1px solid #252525; + border: none; + padding: 5px; + margin: 5px; + color: lightgreen; + background-color: #252525; +} + +#json-data span { + display: inline; + max-width: 800px; + word-wrap: break-word; + overflow: visible; +} + +#json-data .string { color: lightgreen; } +#json-data .number { color: lightgreen; } +#json-data .boolean { color: lightgreen; } +#json-data .null { color: lightgreen; } +#json-data .key { color: lightgreen; } +#json-data .info { color: lightskyblue; } +#json-data .error { color: orangered; } + + + +/* SPACERS */ +.spacer10 { height: 10px; width: 100%; font-size: 0; margin: 0; padding: 0; border: 0; display: block; } +.spacer20 { height: 20px; width: 100%; font-size: 0; margin: 0; padding: 0; border: 0; display: block; } +.spacer25 { height: 25px; width: 100%; font-size: 0; margin: 0; padding: 0; border: 0; display: block; } +.spacer30 { height: 30px; width: 100%; font-size: 0; margin: 0; padding: 0; border: 0; display: block; } +.spacer40 { height: 40px; width: 100%; font-size: 0; margin: 0; padding: 0; border: 0; display: block; } +.spacer50 { height: 50px; width: 100%; font-size: 0; margin: 0; padding: 0; border: 0; display: block; } +.spacer60 { height: 60px; width: 100%; font-size: 0; margin: 0; padding: 0; border: 0; display: block; } +.spacer70 { height: 70px; width: 100%; font-size: 0; margin: 0; padding: 0; border: 0; display: block; } +.spacer80 { height: 80px; width: 100%; font-size: 0; margin: 0; padding: 0; border: 0; display: block; } +.spacer90 { height: 90px; width: 100%; font-size: 0; margin: 0; padding: 0; border: 0; display: block; } +.spacer110 { height: 110px; width: 100%; font-size: 0; margin: 0; padding: 0; border: 0; display: block; } + diff --git a/static/admin/icons/ic_power_settings_new_white_24dp_1x.png b/static/admin/icons/ic_power_settings_new_white_24dp_1x.png new file mode 100644 index 0000000000000000000000000000000000000000..b9b855f7d0a2b43a571eb5e392c3c9b7db742222 GIT binary patch literal 274 zcmV+t0qy>YP)gktwbr^k8%Le@_7~>|@ zv;iKgV1;%Pbg-&|4;&;x6Q3byCP9ued{QSlp)QtT)3-@5#3=mMwWDbIu`MoQgPZ;g zZtpxZ!#uVcJ504|)n_Q2#FuY}H+RnDa$Q4P6w&4TzGa~v^Jw)w)c4>;skAf-TK$Xh Y1*I$TG2FA{E&u=k07*qoM6N<$f{-+Df&c&j literal 0 HcmV?d00001 diff --git a/static/admin/icons/ic_power_settings_new_white_24dp_2x.png b/static/admin/icons/ic_power_settings_new_white_24dp_2x.png new file mode 100644 index 0000000000000000000000000000000000000000..6ea9162ab26b1b42311d505e934cd8df5bc4d06e GIT binary patch literal 556 zcmV+{0@MA8P)aauG1uq2Pqh?xOaN=EEc6$Bv0q0%p#*bh?_`QCSX?H800MTUaxquh)5ky_(3 zs3R+>2crIfqzFIgvg)E93X&Z22c$)|W_u6VkYw7=zC}a@+N=Q`>g<~JfEke-yWC_> zB|WVNY>Q;p2{0$=9X;SiBz;bReo5cz0k0xyZ~`<*`b`hWi=@H{P+4kmJ>Xp=F(-g0 z>AW8BCXyy6Ktj^F;6v_r+988dozY8nMKb3ESdi+i;EQ(R1SpM}lwQ)N#%{Z{eW#3y zA}xNh3qh<~12zqYT_nPU8h71R_PuJKG?aTJ7W*=NbMwMj!&w^g{bKnxs^q)5JtjY>((4lF}O?9;JMBJt; z8n~hyMNXM#fCL^M2?kkE*Q)gPaofJGW)0I=HU2q2AR{~lnm-zKj?cXq=Ui)^CK=fG uMwnKn*yfrXdGh4Qutmyu=7bT(AI2wuPj3$EJQURc0000Fp(Y>@1qblq zyhsC#90*^12V5>VY8h&QK$VG?Pi(1y@4Rq*6GIRvKoA59dkF#^0YhOcAds&l2()Gc z0x741K&&2FO%GLpA84K%=s|%i@OP+<3k5EhJoL@IL7>am{(UHZOog0)KtkMip;~6B z3Gx&=-EsuIyuI02UmEbp-{2E$&G7kk8K`Vk^=P6NNyD)!D((4v_sW> z>ALxJ#R2{=mniSw{Xlw6{3=iR^7Yk;s~D~t>?N_5%|L&#&69^>R|}r)J@#FD+>*P@ zq$)PGs5Y{eJJ52F<*g-B^v2}g|DS&)lXeK!yfUghv}{L?KRj22y1Gs5rXc65R5ce(w*GlbhROhBS8I5*j)a zzSuh$J?<8TR3}jjr#eI5gA}R%@_XFypzi!@9^EQ$c;Bl$jrt#&%;!rvbEv=KATR}-l_|lSKKwtodP(YP1|J#Sl zpNfEJ-|`X$yH!rr-Oslz^h(Aw;H8tlRK2j1`0)4X@!r9op^4Eu+8sC9+1FM#*L7aH zb^7Lbb2>(RIZ+1Fwb|m3@9uTzF48lZgDf?7eylv7wZp3s_G{|vl_U9J{<|8Kw&{dwcfYtkLXWlRP{&W?*0-b9}H_ zSkxGupRWaV^54V9fmQM~nkH0>bQdXWQ z`K*Ee5<$DNx*^Dt_zUcqHm}UVIbTj=MIYH7fPO;*Y>tmh4!a?p%;)bIIvEli+)`6l z_nr9E*uYz+C0On2{5E5!XO2IYKNpq)r39waL)J@;IT1g`Nl9<6Ad=P61}Qt^9ot%` ztumfvpxkFvYHDkx6=g4e-%uQ=u9!bd!*6N_pzX3FU-^Nh;YJfrfi3t-t5;s zo}N0`@0wrbp4(1WOxVqsZ>S}tcZNUF&fmh4h$EHk_V&2z;w$eaV`?yu@8rhph5i0T*2WBr)j3)?c2Xe z!^8dg5wwaZCk z2y6_96UN^&dIkp9gM)*=&Q&(Rv&Oi_;?oyExF;|2JtqA)M)roV59j^9;+x9!#bXaI z@rJ4%@9+5xPyD#At*wS6f7;vMe~l5cfQ?N7#t)!h*Fo{Ur%0&s+1at0-}1i;^@wr8^zE!Cg$31FVezMFJ@B3wBtU0^5yf3Rx$x8+-q3Js6Y$PWgaM_}#vIu5~iOx=IZv*Wp zm%OZ^esG5t``S7Xl|}WRqoArxatY`j-^p6=?wfFhy|dUK*lsO|Nf+&Ig1As)0BjX? zu(`VWp{OXD5E)=$k+Di9Q){QJHw`Ki(40hO`d8M~Q%0%gvIwu2m^UdwgF`p?c?>18 zyUsp+>gQDUc?}q`sYw~k&Pmy!QZcXooYS!-oc!gr6?aLcGtsUjiMqvDknQ%96i%vY zkLKemSFQ|CPv0|zQyKQh`_$Ak#eXZPm~Y-_*yvjGMKHW-pQK~yKBjY>cZ?6Q4 z0KQS0gCFoY@EM-`sc&jJe@yjT@~2RmqX#!@-*El|R6W^GbaNQH4Q3MILu((<5gPLj zlAc$B#l#G|SslHAu*H26#jULStJ&DuOday;ZB*EB z8a(HAc5=cb?8~olFb)wtFas4v*P@}!zXayfi{qsdd8_^`Wx5ad@}){oRCjAal8tvF zHx~H=B^{*)!hNHB{r!i2{?zw2)!sSl)zsKVH4oX;MR&o~+cw{2y0;?J$aSTY`L-!H zp?2In&6_J0$<-f(sx+ox*|PPr53O^9plY&;7|c(2p98!vV9YnvyI0KGMZMYhHj8(# zdM;y~sdK)qj3M+rb3^>X^*lDbQ|`eYmz- zzgWMIudk${q~@Ik^W4Gun;LC4P~{!Ph-xx})DP;A8RJHmq9-QXi)G zmrJS=Bl~5qCP8M$_&cjP$pN!$HWZRPA1M=r#sigH3zpg0+24N>Q#xCm&8OPi+vAP? z%DWYVQI+zaKCvFmZJYfzY-}yArZ)9WN9UV&ywt9~QJ!kT6`K9{qU%ID1WgWQjtpU? zg;UO+UEY)*Vh|PxKBjHpS%DlTtm5qkhDJu-1vOgymnZlB9NdhC7S12#cycL_ z&)wZQkVYw#)U04C2kYeFWPjiCNv=`V!JBUi27?w3-GuiUR1Cu0CD+53BjES1Tuu5+ zah^Pv_)Cb8?q2+tQ`Eqj7}wENxH6NHC7^}`(z&xM()K+3z{#b&I5aO8dgln%i5~aN znz2PGGFvqIbN+c1Zv8rEb2*!ko?HA&RkYWM7#D##q5n(p;Vi2z%wBpc$j-s;=tQN$pF6&&w)Pd0l*6{RwiYzBd7xn{$)P_Tx|-eNc|H0oP$`X>AE82Q;|`OH zylCgwwp_=0ck>(Zx)O(TtnM>Pus=GGvYnmXowK3k-SM-tQ#-qhVJ0pG z&;@p;aW$^<-}SY%_YbWGoghh%99#d+?Y>o!+dK{=&B!b=)ScVkS7P#nE?i@J7aWH? zf$WGd9g1y%J?3Pyn*2x0$3rt?fq0>r z7vdj-Imc*7uq1tc9aONZlDr%Fq1Qc5O2oYVi|^E~V_*9BnCm4Aw5^V~i>ZfIZrit+ zr0|CLvAg199#5&a-0@GK3mZwFwfTjFi~`yCmkkVY6BD#2>bd6)?d=6-oFP>eOi5@0 zI2c#NZuV(&+L{y^z>OX~1Gk5WdrIP+vJ zg{VX`ogF!8_X%;Ry`b?vf;+&;@K>h$##shgS$Bw9^>xp6MZ#^7p7P9&DC+dDJrSb* zC~3se@v%E=(q~cyoViVoAp3sjLdD>dcWJrqxNNvmrym8Nzchl5j!q&&Lu2ZCcIV=T z8F%yk7wf+#>gVmv&2bEw-bg_AwB>e({*2=(Hko@y-S}gc#1u}=bm>d^#La_2#tYK> z>c1}~2f>R?cz!ivlc3KZnwyV%66_||z#4{!&n`lHsL*J8w!g$?oVs7>lKRaSXk zq13d&cR!A#MYaU7Qj21G4%C$4&Gc50`nn+^f0%MEbIdC)9|f@;d<{vJa*!MmVoJ(r z)+9`SrqBmHBYPtued2_{3@< ze$4vr@~Z#)T|0u53V? zl{eaE2HRi7ng)-0|6Qq~dzfroGNJSSh7%?G-D!Qd3yc)~nwP&*>?QbDesYp+x^f>L ze477iWW?adpKha<)4;j)5bw&TJbc0-Ld_j3G`DC>etyUhZ6USlg zWg>%Y+=&a<;i(b4@7VuU=yvQmss@YQnlKJM^1dMLF6$}d(Jb|W1jOmC-6JCC=cu~m zAh=qcB{o;Oupzxyye%d<*@R?os7txRH-7BQb?7@glKLdvn* zorZzs-CAE?-%9)5VQAr~F57a(H+Mv`Ew?d%Z_F{kHiJ{vOj*_t+2*rd2R27Zzo`!Z^Y&Ze?H)%t<62{y{%m z(O@q@1n9yU|IKLY!AzB0`XCkU3Snw-sts!Sm@bolD|MA%FTH9%O@MFYsJOS1P0W5A z;;%zYoB@Rcly1m_4$;)sU9%$ejZT-gyqUTi+hQ99i(9|Aj!vo+mLrym>FA`%wWdn$ z)E+o8#LQ*UjL3h@Yi$y}`-fdr=G2<0yQ}24OTdB|@d9Od@2y*9h_rlN1=hbwz|CvG zXrHinV@*TDS3fovL@ra(VqNTmk|Y`1YUL`I(ck0V1AfxSEe4GP)UB>Su3&H6O>tC2 z4osq}k!ktrqwy2CZX>1Z^62mrj^)00t~mNX+dJWHSlG*q5G5vgKu6Z}hbCUZkrjwy z^)-uN>C|8^2m(kA-QLRs)&-`_s)eAxRbi1)EIkF2TmM2U;Q*?cNs8q2&A+BRMd5@{P;b7!rclQyN z;_z!TdiNIMNxtABbe0|P#MPufw(|$H6~Yp^h5nH(>jrg~9}M%Ab^Hg>b8G@P6I;`!&~78k42R+*`dduca-1e?)RekS5$dC=a|Biiz1C0HQ_Vi<<&r(j7PhCG+uWrFQ=?Tu_LHdn+(qr8a8bvmY>FO4^88X2;qZM1$!tFUq@h z-=kU7F0P~+*i;r`a~#Cm?oU6tcyOF@;87Db|43*;T+`6XXb-_y_9^}Z^w&yOx$lQS zr}r5(Yhi3&U=JT5Jrf9JK_Q_4`6vJnHaQw@%^vLTtGHKrFEf$^N}`qSrd|3|kwwQ5 zW+QweCaFRHm+JTUE{#gg)C&wz+#ufg2DS_E4=*oq0Lt^X4#IIk>KcJFlZ9%Wn_aX% zUZ3<9?TDIHQSjNQkkBwS+>A|U^)szNMXNl8Xf;vC4w_^&PO9^{gX8hzCAXY0?gUmd z5}>n@Q51uwB=Z@3b`#*!0uMIkR*Q4(&d-7Yx`k9!GW6)*O$q7;TmbwYvPHT>3#I7q zn2+Bsb$yFPc?ul)MaJ#KNiCTGM;JYfRk{O%@za+>8z4CtTr}#1CNDlq$-AVy)b>}q z&cZS)QCw!Mg)YlEE}A3`HH?ew7X~cM3Ms!(as54nJCQH?!Z(?FA4|-8=Dwx&WfW~P z)-S+j&EWUj$aj8zbs}prK2OKqoY%2 z&@W(!k0zvxlU{v{9>TH}H%)Y{;>Ez~w;%@Bw-vXcFWTGhh~6swrxh@B|v&CDKGqdcjLiki+mW9TV!B?-;; z|C?d@;0Z?weoNqGX4v93DhmZM;=P(zRge+WBf2zb9k#y4J~b>%k!2r;?)JOs@>~}2 zK?{cbN-fUZ;m8oA3l)V|f(hVz2E9)kJfto!Nxa8?#JqSxc@mOsps16^2ABUAxPYx> z%gW;4&;?E4v+?L{=%gY4HN}L8!DT*)*Lqr7H)R}CWvUT^-((yfJbelc386UJAMku; z8;70*yateI^07kRi)ITQ9!qZ~2wmbext+|LbX9UzkufAUx4Aipj_$IssHlVPHhqla zn+KMb_qA-94$WKj7E8 z!>@GnC)jfA+vb(mX=!>7ZJCy{0&jl%_N^{6`O*tQy7m8d5pNG9Ac#$Zlo_Q#*nIdr zQzz(RhFmaLo1QA}7myK@!qw|3!>$hhW6Wi0-{GYJ3$6(pp+mgBXpu zp|J9lyI~l->;2E~hBzqLZiDIksiyEb6BAvm0Zlmfjo-h&mt4zB+*K7`K9Am`gZ~Yd zWhttxoc0rW$2$W67C?2W-hl#*Y_V|%&3EiJKLbbN#aE9v00Q$>!oD9+DBBEYL#w)b^ zYTDQ!5NIO3cTMiOUkwDtXaSsQkXqV4J5#rKmI=&`5O-erCmV1?NRs}-+Sk4?%*u{1 zccJb4LkPkfR^M7#84iWm;<9{psVV??1wsyGCd|A6bo&MXFARfa*Y)152Lbv5Q=FHk zon-I9wFhALoCgUJfI0i{G70lPd6a$dej=+M)Hwca0c#^Xx!lys0PtC|vYb^gBjx|y z+`Q>2iI`~>NaK!h{ci`IY#qv-p6s-=G^n)_A=L4wmO6ubw5(jjWhFpmR{BVea1P6{ zsT7P5%CGY=P~h0(%FVoo!h-=_Qm6q$+@;L*;dCrqZ&E#&l=u5hymyP{|3BLmrCaXL3kOGA` znXE{|{nF2+rIj#kP9)+mu&8cm1KN zN_k=T5RsPb=13L#FPkX?;+ZdAs3DIxGc_Q4gRB^Vik{{NKKLGa=sb zAvdOr8d#s_gKdgE_sOD!(bJ~Am#&C0TXS`+1+)jjTA-c>{M6$(*yuZ2?ga~>cnflc z^3MPT?OlL7ghI-C-ioW8F~av!@9|Y=>wz8V0m&v77JkQb-)_Vr#*%DXMIqJzWB_a^ zds3I^`K8X>y+uvlK_K{6mLWSjB%OtS%07JXz{6QbXGEIhYljVlF09wZb^$Du`SYQI zn9nRMH^#;JQWmMsjiGxVDg}UXoBp+9qY7Ym*<@waIhI*ZCM5t-8v%&83?zWE5v=xbaZJBR7?#N4ig>B|FzCf54Yr9UPkqiC5}!8h#d zS2Ii++x5)Mra~|!y(R}#KN=82B^!#~Dr++}Geh9w;shaxseSsEmX_NvFV0S`P|Bhr zna+tI^{qTipB&+&&yPH=8z|5H=n1Qm_jr}5cr8rtgA4&{^98~NUU3}T7(>rR*Zy

`-P? zQBfhrtD@sMMu*0gmQ;oYg_O9Lz3WJ?831#BL@f2GEDu^Mj`?v}%^e(`?<5;k2)F(j zRXKA&IjwNcE4rSlYecPG+4*`svC46f15UPJRON@9clvQO8|l43_pOZ_ytPDq5%@fI zTgk1GY@xccjZ*b%8C*`J_&K8MMtkOe=h_I6M!&`0QkvgeeDgLQO?8-tSBRG~tmBx| z0RT09u7EAq!t!B}mWwu9%lO1CaHw+2=Gg(BKbuiis4zeT_Vh>XHaWpYLR>39ynXI_8&NTauQvLk}YIw_5lEl{jEyi`n%vc2iBY zHL{5T-(;_#G@)mce(+{!2SJ-f%<`n=U3HJITl>#biY-UIa|cQ05YyQ!^h=*l&NM^) z{aHEWE9T=BtOSxUiGEpa*OT<1b~$7kbhwo+`YRwfbY$d-b$uXEtu$>7?}2&IO0}*v z8s%m2S+T#;qF>$yX#U%lSj=UKrZ*=iFNWl27$zO!#%*yA-`xkCOUixYm_Cq@qedYMbB~{l$L#- zQVBh~lbx!1dNvN;73!SGs}SFLG+TkXZ6$oMINMJUNZLnFGtc|LyTx@JsnLL+*Z?Ku zd09chnmYgoMbGhag!JNs+J`XMg5^sf(yv#d;$pr%$_gGhWM*MWs5|Vy^Q@z?t^knd z0r67UdITUsqc;AKkRH)F0aPv~G$&MBQ5{3J_wLx?|k9GtTqf zoen|m_;ODFD;|Yp3$L#LA2@H3(oiA4V@My$mG~_dmfOqbnDyt^p?mgEp@0doS`}U z4&JRH>rUj&Bcxwqm?^ImXu(NBVVlNwQ8tn%+DvYH$3cdbPv0O(^1DatpTmOehKu-F zAGh`U@gcwG7)D-A3Z(+NyQqK}215Fbpg_(bpMWLwDGu*rLspV{DN z$WmIMDh^`(rC0eh?&JmKfXe0_7O_$=a`+(ZGZ>zOo!VM`uf}0eT3UJzjUdRJ=|ex9 zv;gXcN_4RXsatn`|IQ0gJ`3dfgM>Qg8{ONA=5iEIItVYCnnp&1afBOuy@8>WE)YCBbLv9>p`8JR7U44jqxc%?H!CLZlTnP%lkNRaVJ#hdy>AVng@psd%?v;ad^>Tb zDF^_CAy7Ppzq-sCtn@mqmI~_qEzQ(-)<)P;V(YHtc?N429DuI!&>bn|36d`91Y(nd zx!-GA1$g=0>tmkcr4?2MWt-@kN02JXN3>U7wNn91+4_s1!OeqC(!?{gONVkshHf^v z_3GaiOb}^5N}3xmkTmcxPGTUl-e!%~r0xb)uJNC{09mI&>9%uxYS_`CjhJjmfZ?n- zkfu~8s0A@jHp31HS`2pG2UkQ-TGZbk}3CE=ue@`|>WUKhU#)K{q zDnV>*f(em^>^&Rqf^8o5RDER8+4FBpz>WC1rLc6Gs8vjV%h?DNs zGo(UD!M|3wx%uSnj~~(``;R}R@Z`GKEjorw=4j~S5wpjSXUb#WIkp%_V;Eh*w7{^4 zc+U2_o*s7CJ;-q?D96fC5C$y4d(C_iu>Ev$F7SASIhEV|e4uz0sER2o)^i>|eCQiV zRyLc+l7O(1#394Z#P^!!t9{=APSF=(xUogyKk?t_{c3%?k#e2bTdgaKM?*{LfY4Tt zEpSlX&SQjDKs)7yRm|F^rbmqrq_d-PLgOj%jlZ|(p0H_Lj@G|Iq + + + + + + DOJO MAINTENANCE TOOL + + + + + + + + + + + + + +

+ +
+
+ +

DOJO // MAINTENANCE TOOL beta

+
+
+ + +
+
+
+ +
+ +
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/static/admin/index.js b/static/admin/index.js new file mode 100644 index 0000000..58a8519 --- /dev/null +++ b/static/admin/index.js @@ -0,0 +1,51 @@ +/* + * Signin + */ +function login() { + let apiKey = $('#apikey').val(); + let dataJson = { + 'apikey': apiKey + }; + + // Checks input fields + if (!apiKey) { + lib_msg.displayErrors('API key is mandatory'); + return; + } + + lib_msg.displayMessage('Processing...'); + + let deferred = lib_api.signin(dataJson); + deferred.then( + function (result) { + const auth = result['authorizations']; + const accessToken = auth['access_token']; + lib_auth.setAccessToken(accessToken); + const refreshToken = auth['refresh_token']; + lib_auth.setRefreshToken(refreshToken); + sessionStorage.setItem('activeTab', ''); + lib_msg.displayInfo('Successfully connected to your backend'); + // Redirection to default page + lib_cmn.goToDefaultPage(); + }, + function (jqxhr) { + let msg = lib_msg.extractJqxhrErrorMsg(jqxhr); + lib_msg.displayErrors(msg); + } + ); +} + +/* + * onPageLoaded + */ +$(document).ready(function() { + // Sets the event handlers + $('#apikey').keyup(function(evt) { + if (evt.keyCode === 13) { + login(); + } + }); + $('#signin').click(function() { + login(); + }); +}); \ No newline at end of file diff --git a/static/admin/lib/api-wrapper.js b/static/admin/lib/api-wrapper.js new file mode 100644 index 0000000..e69eca7 --- /dev/null +++ b/static/admin/lib/api-wrapper.js @@ -0,0 +1,228 @@ +var lib_api = { + + /** + * Base URI + */ + baseUri: conf['api']['baseUri'], + + /** + * Authentication + */ + signin: function(data) { + let uri = this.baseUri + '/auth/login'; + return this.sendPostUriEncoded(uri, data); + }, + + /** + * Gets a new access token + */ + refreshToken: function(data) { + let uri = this.baseUri + '/auth/refresh'; + return this.sendPostUriEncoded(uri, data); + }, + + /** + * API Status + */ + getApiStatus: function() { + let prefix = conf['prefixes']['status']; + let uri = this.baseUri + '/' + prefix; + return this.sendGetUriEncoded(uri, {}); + }, + + /** + * Get pairing info + */ + getPairingInfo: function() { + let prefix = conf['prefixes']['support']; + let uri = this.baseUri + '/' + prefix + '/pairing'; + return this.sendGetUriEncoded(uri, {}); + }, + + /** + * PushTx Status + */ + getPushtxStatus: function() { + let prefix = conf['prefixes']['statusPushtx']; + let uri = this.baseUri + '/pushtx/' + prefix; + //let uri = 'http://127.0.0.1:8081/' + prefix; + return this.sendGetUriEncoded(uri, {}); + }, + + /** + * Orchestrztor Status + */ + getOrchestratorStatus: function() { + let prefix = conf['prefixes']['statusPushtx']; + let uri = this.baseUri + '/pushtx/' + prefix + '/schedule'; + //let uri = 'http://127.0.0.1:8081/' + prefix + '/schedule'; + return this.sendGetUriEncoded(uri, {}); + }, + + /** + * Gets information about an address + */ + getAddressInfo: function(address) { + let prefix = conf['prefixes']['support']; + let uri = this.baseUri + '/' + prefix + '/address/' + address + '/info'; + return this.sendGetUriEncoded(uri, {}); + }, + + /** + * Rescans an address + */ + getAddressRescan: function(address) { + let prefix = conf['prefixes']['support']; + let uri = this.baseUri + '/' + prefix + '/address/' + address + '/rescan'; + return this.sendGetUriEncoded(uri, {}); + }, + + /** + * Gets information about a xpub + */ + getXpubInfo: function(xpub) { + let prefix = conf['prefixes']['support']; + let uri = this.baseUri + '/' + prefix + '/xpub/' + xpub + '/info'; + return this.sendGetUriEncoded(uri, {}); + }, + + /** + * Rescans a xpub + */ + getXpubRescan: function(xpub, nbAddr, startIdx) { + let prefix = conf['prefixes']['support']; + let uri = this.baseUri + '/' + prefix + '/xpub/' + xpub + '/rescan'; + return this.sendGetUriEncoded( + uri, + { + 'gap': nbAddr, + 'startidx': startIdx + } + ); + }, + + /** + * Notifies the server of the new HD account for tracking. + */ + postXpub: function(arguments) { + let uri = this.baseUri + '/xpub'; + return this.sendPostUriEncoded(uri, arguments); + }, + + /** + * Multiaddr + */ + getMultiaddr: function(arguments) { + let uri = this.baseUri + '/multiaddr'; + return this.sendGetUriEncoded(uri, arguments); + }, + + /** + * Unspent + */ + getUnspent: function(arguments) { + let uri = this.baseUri + '/unspent'; + return this.sendGetUriEncoded(uri, arguments); + }, + + /** + * Transaction + */ + getTransaction: function(txid) { + let uri = this.baseUri + '/tx/' + txid; + return this.sendGetUriEncoded(uri, {}); + }, + + + /** + * HTTP requests methods + */ + sendGetUriEncoded: function(uri, data) { + data['at'] = lib_auth.getAccessToken(); + + let deferred = $.Deferred(), + dataString = $.param(data); + + $.when($.ajax({ + url: uri, + method: 'GET', + data: dataString, + contentType: "application/x-www-form-urlencoded; charset=utf-8" + })) + .done(function (result) { + deferred.resolve(result); + }) + .fail(function (jqxhr, textStatus, error) { + deferred.reject(jqxhr); + }); + + return deferred.promise(); + }, + + sendPostUriEncoded: function(uri, data) { + data['at'] = lib_auth.getAccessToken(); + + let deferred = $.Deferred(), + dataString = $.param(data); + + $.when($.ajax({ + url: uri, + method: 'POST', + data: dataString, + contentType: "application/x-www-form-urlencoded; charset=utf-8" + })) + .done(function (result) { + deferred.resolve(result); + }) + .fail(function (jqxhr, textStatus, error) { + deferred.reject(jqxhr); + }); + + return deferred.promise(); + }, + + sendGetJson: function(uri, data) { + data['at'] = lib_auth.getAccessToken(); + + let deferred = $.Deferred(); + + $.when($.ajax({ + url: uri, + method: 'GET', + data: data, + })) + .done(function (result) { + deferred.resolve(result); + }) + .fail(function (jqxhr, textStatus, error) { + deferred.reject(jqxhr); + }); + + return deferred.promise(); + }, + + + sendPostJson: function(uri, data) { + data['at'] = lib_auth.getAccessToken(); + + let deferred = $.Deferred(), + dataString = JSON.stringify(data); + + $.when($.ajax({ + url: uri, + method: 'POST', + data: dataString, + contentType: "application/json; charset=utf-8", + dataType: 'json' + })) + .done(function (result) { + deferred.resolve(result); + }) + .fail(function (jqxhr, textStatus, error) { + deferred.reject(jqxhr); + }); + + return deferred.promise(); + } + +} diff --git a/static/admin/lib/auth-utils.js b/static/admin/lib/auth-utils.js new file mode 100644 index 0000000..c087418 --- /dev/null +++ b/static/admin/lib/auth-utils.js @@ -0,0 +1,101 @@ +var lib_auth = { + + /* SessionStorage Key used for access token */ + SESSION_STORE_ACCESS_TOKEN: 'access_token', + + /* SessionStorage Key used for the timestamp of the access token */ + SESSION_STORE_ACCESS_TOKEN_TS: 'access_token_ts', + + /* SessionStorage Key used for refresh token */ + SESSION_STORE_REFRESH_TOKEN: 'refresh_token', + + /* JWT Scheme */ + JWT_SCHEME: 'Bearer', + + + /* + * Retrieves access token from session storage + */ + getAccessToken: function() { + return sessionStorage.getItem(this.SESSION_STORE_ACCESS_TOKEN); + }, + + /* + * Stores access token in session storage + */ + setAccessToken: function(token) { + const now = new Date(); + sessionStorage.setItem(this.SESSION_STORE_ACCESS_TOKEN_TS, now.getTime()); + sessionStorage.setItem(this.SESSION_STORE_ACCESS_TOKEN, token); + }, + + /* + * Retrieves refresh token from session storage + */ + getRefreshToken: function() { + return sessionStorage.getItem(this.SESSION_STORE_REFRESH_TOKEN); + }, + + /* + * Stores refresh token in session storage + */ + setRefreshToken: function(token) { + sessionStorage.setItem(this.SESSION_STORE_REFRESH_TOKEN, token); + }, + + /* + * Refreshes the access token + */ + refreshAccessToken: function() { + if (!this.isAuthenticated()) { + return; + } + + const now = new Date(); + const atts = sessionStorage.getItem(this.SESSION_STORE_ACCESS_TOKEN_TS); + const timeElapsed = (now.getTime() - atts) / 1000; + + // Refresh the access token if more than 10mn + if (timeElapsed > 600) { + const dataJson = { + 'rt': this.getRefreshToken() + }; + + let self = this; + + let deferred = lib_api.refreshToken(dataJson); + + deferred.then( + function (result) { + const auth = result['authorizations']; + const accessToken = auth['access_token']; + self.setAccessToken(accessToken); + }, + function (jqxhr) { + // Do nothing + } + ); + } + }, + + /* + * Checks if user is authenticated + */ + isAuthenticated: function() { + // Checks that an access token is stored in session storage + let token = this.getAccessToken(); + return (token && (token != 'null')) ? true : false; + }, + + /* + * Local logout + */ + logout: function() { + // Clears session storage + this.setRefreshToken(null); + this.setAccessToken(null); + sessionStorage.setItem('activeTab', ''); + lib_cmn.goToHomePage(); + } + +} diff --git a/static/admin/lib/bootstrap.js b/static/admin/lib/bootstrap.js new file mode 100644 index 0000000..8a2e99a --- /dev/null +++ b/static/admin/lib/bootstrap.js @@ -0,0 +1,2377 @@ +/*! + * Bootstrap v3.3.7 (http://getbootstrap.com) + * Copyright 2011-2016 Twitter, Inc. + * Licensed under the MIT license + */ + +if (typeof jQuery === 'undefined') { + throw new Error('Bootstrap\'s JavaScript requires jQuery') +} + ++function ($) { + 'use strict'; + var version = $.fn.jquery.split(' ')[0].split('.') + if ((version[0] < 2 && version[1] < 9) || (version[0] == 1 && version[1] == 9 && version[2] < 1) || (version[0] > 3)) { + throw new Error('Bootstrap\'s JavaScript requires jQuery version 1.9.1 or higher, but lower than version 4') + } +}(jQuery); + +/* ======================================================================== + * Bootstrap: transition.js v3.3.7 + * http://getbootstrap.com/javascript/#transitions + * ======================================================================== + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // CSS TRANSITION SUPPORT (Shoutout: http://www.modernizr.com/) + // ============================================================ + + function transitionEnd() { + var el = document.createElement('bootstrap') + + var transEndEventNames = { + WebkitTransition : 'webkitTransitionEnd', + MozTransition : 'transitionend', + OTransition : 'oTransitionEnd otransitionend', + transition : 'transitionend' + } + + for (var name in transEndEventNames) { + if (el.style[name] !== undefined) { + return { end: transEndEventNames[name] } + } + } + + return false // explicit for ie8 ( ._.) + } + + // http://blog.alexmaccaw.com/css-transitions + $.fn.emulateTransitionEnd = function (duration) { + var called = false + var $el = this + $(this).one('bsTransitionEnd', function () { called = true }) + var callback = function () { if (!called) $($el).trigger($.support.transition.end) } + setTimeout(callback, duration) + return this + } + + $(function () { + $.support.transition = transitionEnd() + + if (!$.support.transition) return + + $.event.special.bsTransitionEnd = { + bindType: $.support.transition.end, + delegateType: $.support.transition.end, + handle: function (e) { + if ($(e.target).is(this)) return e.handleObj.handler.apply(this, arguments) + } + } + }) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: alert.js v3.3.7 + * http://getbootstrap.com/javascript/#alerts + * ======================================================================== + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // ALERT CLASS DEFINITION + // ====================== + + var dismiss = '[data-dismiss="alert"]' + var Alert = function (el) { + $(el).on('click', dismiss, this.close) + } + + Alert.VERSION = '3.3.7' + + Alert.TRANSITION_DURATION = 150 + + Alert.prototype.close = function (e) { + var $this = $(this) + var selector = $this.attr('data-target') + + if (!selector) { + selector = $this.attr('href') + selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7 + } + + var $parent = $(selector === '#' ? [] : selector) + + if (e) e.preventDefault() + + if (!$parent.length) { + $parent = $this.closest('.alert') + } + + $parent.trigger(e = $.Event('close.bs.alert')) + + if (e.isDefaultPrevented()) return + + $parent.removeClass('in') + + function removeElement() { + // detach from parent, fire event then clean up data + $parent.detach().trigger('closed.bs.alert').remove() + } + + $.support.transition && $parent.hasClass('fade') ? + $parent + .one('bsTransitionEnd', removeElement) + .emulateTransitionEnd(Alert.TRANSITION_DURATION) : + removeElement() + } + + + // ALERT PLUGIN DEFINITION + // ======================= + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.alert') + + if (!data) $this.data('bs.alert', (data = new Alert(this))) + if (typeof option == 'string') data[option].call($this) + }) + } + + var old = $.fn.alert + + $.fn.alert = Plugin + $.fn.alert.Constructor = Alert + + + // ALERT NO CONFLICT + // ================= + + $.fn.alert.noConflict = function () { + $.fn.alert = old + return this + } + + + // ALERT DATA-API + // ============== + + $(document).on('click.bs.alert.data-api', dismiss, Alert.prototype.close) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: button.js v3.3.7 + * http://getbootstrap.com/javascript/#buttons + * ======================================================================== + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // BUTTON PUBLIC CLASS DEFINITION + // ============================== + + var Button = function (element, options) { + this.$element = $(element) + this.options = $.extend({}, Button.DEFAULTS, options) + this.isLoading = false + } + + Button.VERSION = '3.3.7' + + Button.DEFAULTS = { + loadingText: 'loading...' + } + + Button.prototype.setState = function (state) { + var d = 'disabled' + var $el = this.$element + var val = $el.is('input') ? 'val' : 'html' + var data = $el.data() + + state += 'Text' + + if (data.resetText == null) $el.data('resetText', $el[val]()) + + // push to event loop to allow forms to submit + setTimeout($.proxy(function () { + $el[val](data[state] == null ? this.options[state] : data[state]) + + if (state == 'loadingText') { + this.isLoading = true + $el.addClass(d).attr(d, d).prop(d, true) + } else if (this.isLoading) { + this.isLoading = false + $el.removeClass(d).removeAttr(d).prop(d, false) + } + }, this), 0) + } + + Button.prototype.toggle = function () { + var changed = true + var $parent = this.$element.closest('[data-toggle="buttons"]') + + if ($parent.length) { + var $input = this.$element.find('input') + if ($input.prop('type') == 'radio') { + if ($input.prop('checked')) changed = false + $parent.find('.active').removeClass('active') + this.$element.addClass('active') + } else if ($input.prop('type') == 'checkbox') { + if (($input.prop('checked')) !== this.$element.hasClass('active')) changed = false + this.$element.toggleClass('active') + } + $input.prop('checked', this.$element.hasClass('active')) + if (changed) $input.trigger('change') + } else { + this.$element.attr('aria-pressed', !this.$element.hasClass('active')) + this.$element.toggleClass('active') + } + } + + + // BUTTON PLUGIN DEFINITION + // ======================== + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.button') + var options = typeof option == 'object' && option + + if (!data) $this.data('bs.button', (data = new Button(this, options))) + + if (option == 'toggle') data.toggle() + else if (option) data.setState(option) + }) + } + + var old = $.fn.button + + $.fn.button = Plugin + $.fn.button.Constructor = Button + + + // BUTTON NO CONFLICT + // ================== + + $.fn.button.noConflict = function () { + $.fn.button = old + return this + } + + + // BUTTON DATA-API + // =============== + + $(document) + .on('click.bs.button.data-api', '[data-toggle^="button"]', function (e) { + var $btn = $(e.target).closest('.btn') + Plugin.call($btn, 'toggle') + if (!($(e.target).is('input[type="radio"], input[type="checkbox"]'))) { + // Prevent double click on radios, and the double selections (so cancellation) on checkboxes + e.preventDefault() + // The target component still receive the focus + if ($btn.is('input,button')) $btn.trigger('focus') + else $btn.find('input:visible,button:visible').first().trigger('focus') + } + }) + .on('focus.bs.button.data-api blur.bs.button.data-api', '[data-toggle^="button"]', function (e) { + $(e.target).closest('.btn').toggleClass('focus', /^focus(in)?$/.test(e.type)) + }) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: carousel.js v3.3.7 + * http://getbootstrap.com/javascript/#carousel + * ======================================================================== + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // CAROUSEL CLASS DEFINITION + // ========================= + + var Carousel = function (element, options) { + this.$element = $(element) + this.$indicators = this.$element.find('.carousel-indicators') + this.options = options + this.paused = null + this.sliding = null + this.interval = null + this.$active = null + this.$items = null + + this.options.keyboard && this.$element.on('keydown.bs.carousel', $.proxy(this.keydown, this)) + + this.options.pause == 'hover' && !('ontouchstart' in document.documentElement) && this.$element + .on('mouseenter.bs.carousel', $.proxy(this.pause, this)) + .on('mouseleave.bs.carousel', $.proxy(this.cycle, this)) + } + + Carousel.VERSION = '3.3.7' + + Carousel.TRANSITION_DURATION = 600 + + Carousel.DEFAULTS = { + interval: 5000, + pause: 'hover', + wrap: true, + keyboard: true + } + + Carousel.prototype.keydown = function (e) { + if (/input|textarea/i.test(e.target.tagName)) return + switch (e.which) { + case 37: this.prev(); break + case 39: this.next(); break + default: return + } + + e.preventDefault() + } + + Carousel.prototype.cycle = function (e) { + e || (this.paused = false) + + this.interval && clearInterval(this.interval) + + this.options.interval + && !this.paused + && (this.interval = setInterval($.proxy(this.next, this), this.options.interval)) + + return this + } + + Carousel.prototype.getItemIndex = function (item) { + this.$items = item.parent().children('.item') + return this.$items.index(item || this.$active) + } + + Carousel.prototype.getItemForDirection = function (direction, active) { + var activeIndex = this.getItemIndex(active) + var willWrap = (direction == 'prev' && activeIndex === 0) + || (direction == 'next' && activeIndex == (this.$items.length - 1)) + if (willWrap && !this.options.wrap) return active + var delta = direction == 'prev' ? -1 : 1 + var itemIndex = (activeIndex + delta) % this.$items.length + return this.$items.eq(itemIndex) + } + + Carousel.prototype.to = function (pos) { + var that = this + var activeIndex = this.getItemIndex(this.$active = this.$element.find('.item.active')) + + if (pos > (this.$items.length - 1) || pos < 0) return + + if (this.sliding) return this.$element.one('slid.bs.carousel', function () { that.to(pos) }) // yes, "slid" + if (activeIndex == pos) return this.pause().cycle() + + return this.slide(pos > activeIndex ? 'next' : 'prev', this.$items.eq(pos)) + } + + Carousel.prototype.pause = function (e) { + e || (this.paused = true) + + if (this.$element.find('.next, .prev').length && $.support.transition) { + this.$element.trigger($.support.transition.end) + this.cycle(true) + } + + this.interval = clearInterval(this.interval) + + return this + } + + Carousel.prototype.next = function () { + if (this.sliding) return + return this.slide('next') + } + + Carousel.prototype.prev = function () { + if (this.sliding) return + return this.slide('prev') + } + + Carousel.prototype.slide = function (type, next) { + var $active = this.$element.find('.item.active') + var $next = next || this.getItemForDirection(type, $active) + var isCycling = this.interval + var direction = type == 'next' ? 'left' : 'right' + var that = this + + if ($next.hasClass('active')) return (this.sliding = false) + + var relatedTarget = $next[0] + var slideEvent = $.Event('slide.bs.carousel', { + relatedTarget: relatedTarget, + direction: direction + }) + this.$element.trigger(slideEvent) + if (slideEvent.isDefaultPrevented()) return + + this.sliding = true + + isCycling && this.pause() + + if (this.$indicators.length) { + this.$indicators.find('.active').removeClass('active') + var $nextIndicator = $(this.$indicators.children()[this.getItemIndex($next)]) + $nextIndicator && $nextIndicator.addClass('active') + } + + var slidEvent = $.Event('slid.bs.carousel', { relatedTarget: relatedTarget, direction: direction }) // yes, "slid" + if ($.support.transition && this.$element.hasClass('slide')) { + $next.addClass(type) + $next[0].offsetWidth // force reflow + $active.addClass(direction) + $next.addClass(direction) + $active + .one('bsTransitionEnd', function () { + $next.removeClass([type, direction].join(' ')).addClass('active') + $active.removeClass(['active', direction].join(' ')) + that.sliding = false + setTimeout(function () { + that.$element.trigger(slidEvent) + }, 0) + }) + .emulateTransitionEnd(Carousel.TRANSITION_DURATION) + } else { + $active.removeClass('active') + $next.addClass('active') + this.sliding = false + this.$element.trigger(slidEvent) + } + + isCycling && this.cycle() + + return this + } + + + // CAROUSEL PLUGIN DEFINITION + // ========================== + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.carousel') + var options = $.extend({}, Carousel.DEFAULTS, $this.data(), typeof option == 'object' && option) + var action = typeof option == 'string' ? option : options.slide + + if (!data) $this.data('bs.carousel', (data = new Carousel(this, options))) + if (typeof option == 'number') data.to(option) + else if (action) data[action]() + else if (options.interval) data.pause().cycle() + }) + } + + var old = $.fn.carousel + + $.fn.carousel = Plugin + $.fn.carousel.Constructor = Carousel + + + // CAROUSEL NO CONFLICT + // ==================== + + $.fn.carousel.noConflict = function () { + $.fn.carousel = old + return this + } + + + // CAROUSEL DATA-API + // ================= + + var clickHandler = function (e) { + var href + var $this = $(this) + var $target = $($this.attr('data-target') || (href = $this.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '')) // strip for ie7 + if (!$target.hasClass('carousel')) return + var options = $.extend({}, $target.data(), $this.data()) + var slideIndex = $this.attr('data-slide-to') + if (slideIndex) options.interval = false + + Plugin.call($target, options) + + if (slideIndex) { + $target.data('bs.carousel').to(slideIndex) + } + + e.preventDefault() + } + + $(document) + .on('click.bs.carousel.data-api', '[data-slide]', clickHandler) + .on('click.bs.carousel.data-api', '[data-slide-to]', clickHandler) + + $(window).on('load', function () { + $('[data-ride="carousel"]').each(function () { + var $carousel = $(this) + Plugin.call($carousel, $carousel.data()) + }) + }) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: collapse.js v3.3.7 + * http://getbootstrap.com/javascript/#collapse + * ======================================================================== + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + +/* jshint latedef: false */ + ++function ($) { + 'use strict'; + + // COLLAPSE PUBLIC CLASS DEFINITION + // ================================ + + var Collapse = function (element, options) { + this.$element = $(element) + this.options = $.extend({}, Collapse.DEFAULTS, options) + this.$trigger = $('[data-toggle="collapse"][href="#' + element.id + '"],' + + '[data-toggle="collapse"][data-target="#' + element.id + '"]') + this.transitioning = null + + if (this.options.parent) { + this.$parent = this.getParent() + } else { + this.addAriaAndCollapsedClass(this.$element, this.$trigger) + } + + if (this.options.toggle) this.toggle() + } + + Collapse.VERSION = '3.3.7' + + Collapse.TRANSITION_DURATION = 350 + + Collapse.DEFAULTS = { + toggle: true + } + + Collapse.prototype.dimension = function () { + var hasWidth = this.$element.hasClass('width') + return hasWidth ? 'width' : 'height' + } + + Collapse.prototype.show = function () { + if (this.transitioning || this.$element.hasClass('in')) return + + var activesData + var actives = this.$parent && this.$parent.children('.panel').children('.in, .collapsing') + + if (actives && actives.length) { + activesData = actives.data('bs.collapse') + if (activesData && activesData.transitioning) return + } + + var startEvent = $.Event('show.bs.collapse') + this.$element.trigger(startEvent) + if (startEvent.isDefaultPrevented()) return + + if (actives && actives.length) { + Plugin.call(actives, 'hide') + activesData || actives.data('bs.collapse', null) + } + + var dimension = this.dimension() + + this.$element + .removeClass('collapse') + .addClass('collapsing')[dimension](0) + .attr('aria-expanded', true) + + this.$trigger + .removeClass('collapsed') + .attr('aria-expanded', true) + + this.transitioning = 1 + + var complete = function () { + this.$element + .removeClass('collapsing') + .addClass('collapse in')[dimension]('') + this.transitioning = 0 + this.$element + .trigger('shown.bs.collapse') + } + + if (!$.support.transition) return complete.call(this) + + var scrollSize = $.camelCase(['scroll', dimension].join('-')) + + this.$element + .one('bsTransitionEnd', $.proxy(complete, this)) + .emulateTransitionEnd(Collapse.TRANSITION_DURATION)[dimension](this.$element[0][scrollSize]) + } + + Collapse.prototype.hide = function () { + if (this.transitioning || !this.$element.hasClass('in')) return + + var startEvent = $.Event('hide.bs.collapse') + this.$element.trigger(startEvent) + if (startEvent.isDefaultPrevented()) return + + var dimension = this.dimension() + + this.$element[dimension](this.$element[dimension]())[0].offsetHeight + + this.$element + .addClass('collapsing') + .removeClass('collapse in') + .attr('aria-expanded', false) + + this.$trigger + .addClass('collapsed') + .attr('aria-expanded', false) + + this.transitioning = 1 + + var complete = function () { + this.transitioning = 0 + this.$element + .removeClass('collapsing') + .addClass('collapse') + .trigger('hidden.bs.collapse') + } + + if (!$.support.transition) return complete.call(this) + + this.$element + [dimension](0) + .one('bsTransitionEnd', $.proxy(complete, this)) + .emulateTransitionEnd(Collapse.TRANSITION_DURATION) + } + + Collapse.prototype.toggle = function () { + this[this.$element.hasClass('in') ? 'hide' : 'show']() + } + + Collapse.prototype.getParent = function () { + return $(this.options.parent) + .find('[data-toggle="collapse"][data-parent="' + this.options.parent + '"]') + .each($.proxy(function (i, element) { + var $element = $(element) + this.addAriaAndCollapsedClass(getTargetFromTrigger($element), $element) + }, this)) + .end() + } + + Collapse.prototype.addAriaAndCollapsedClass = function ($element, $trigger) { + var isOpen = $element.hasClass('in') + + $element.attr('aria-expanded', isOpen) + $trigger + .toggleClass('collapsed', !isOpen) + .attr('aria-expanded', isOpen) + } + + function getTargetFromTrigger($trigger) { + var href + var target = $trigger.attr('data-target') + || (href = $trigger.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '') // strip for ie7 + + return $(target) + } + + + // COLLAPSE PLUGIN DEFINITION + // ========================== + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.collapse') + var options = $.extend({}, Collapse.DEFAULTS, $this.data(), typeof option == 'object' && option) + + if (!data && options.toggle && /show|hide/.test(option)) options.toggle = false + if (!data) $this.data('bs.collapse', (data = new Collapse(this, options))) + if (typeof option == 'string') data[option]() + }) + } + + var old = $.fn.collapse + + $.fn.collapse = Plugin + $.fn.collapse.Constructor = Collapse + + + // COLLAPSE NO CONFLICT + // ==================== + + $.fn.collapse.noConflict = function () { + $.fn.collapse = old + return this + } + + + // COLLAPSE DATA-API + // ================= + + $(document).on('click.bs.collapse.data-api', '[data-toggle="collapse"]', function (e) { + var $this = $(this) + + if (!$this.attr('data-target')) e.preventDefault() + + var $target = getTargetFromTrigger($this) + var data = $target.data('bs.collapse') + var option = data ? 'toggle' : $this.data() + + Plugin.call($target, option) + }) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: dropdown.js v3.3.7 + * http://getbootstrap.com/javascript/#dropdowns + * ======================================================================== + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // DROPDOWN CLASS DEFINITION + // ========================= + + var backdrop = '.dropdown-backdrop' + var toggle = '[data-toggle="dropdown"]' + var Dropdown = function (element) { + $(element).on('click.bs.dropdown', this.toggle) + } + + Dropdown.VERSION = '3.3.7' + + function getParent($this) { + var selector = $this.attr('data-target') + + if (!selector) { + selector = $this.attr('href') + selector = selector && /#[A-Za-z]/.test(selector) && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7 + } + + var $parent = selector && $(selector) + + return $parent && $parent.length ? $parent : $this.parent() + } + + function clearMenus(e) { + if (e && e.which === 3) return + $(backdrop).remove() + $(toggle).each(function () { + var $this = $(this) + var $parent = getParent($this) + var relatedTarget = { relatedTarget: this } + + if (!$parent.hasClass('open')) return + + if (e && e.type == 'click' && /input|textarea/i.test(e.target.tagName) && $.contains($parent[0], e.target)) return + + $parent.trigger(e = $.Event('hide.bs.dropdown', relatedTarget)) + + if (e.isDefaultPrevented()) return + + $this.attr('aria-expanded', 'false') + $parent.removeClass('open').trigger($.Event('hidden.bs.dropdown', relatedTarget)) + }) + } + + Dropdown.prototype.toggle = function (e) { + var $this = $(this) + + if ($this.is('.disabled, :disabled')) return + + var $parent = getParent($this) + var isActive = $parent.hasClass('open') + + clearMenus() + + if (!isActive) { + if ('ontouchstart' in document.documentElement && !$parent.closest('.navbar-nav').length) { + // if mobile we use a backdrop because click events don't delegate + $(document.createElement('div')) + .addClass('dropdown-backdrop') + .insertAfter($(this)) + .on('click', clearMenus) + } + + var relatedTarget = { relatedTarget: this } + $parent.trigger(e = $.Event('show.bs.dropdown', relatedTarget)) + + if (e.isDefaultPrevented()) return + + $this + .trigger('focus') + .attr('aria-expanded', 'true') + + $parent + .toggleClass('open') + .trigger($.Event('shown.bs.dropdown', relatedTarget)) + } + + return false + } + + Dropdown.prototype.keydown = function (e) { + if (!/(38|40|27|32)/.test(e.which) || /input|textarea/i.test(e.target.tagName)) return + + var $this = $(this) + + e.preventDefault() + e.stopPropagation() + + if ($this.is('.disabled, :disabled')) return + + var $parent = getParent($this) + var isActive = $parent.hasClass('open') + + if (!isActive && e.which != 27 || isActive && e.which == 27) { + if (e.which == 27) $parent.find(toggle).trigger('focus') + return $this.trigger('click') + } + + var desc = ' li:not(.disabled):visible a' + var $items = $parent.find('.dropdown-menu' + desc) + + if (!$items.length) return + + var index = $items.index(e.target) + + if (e.which == 38 && index > 0) index-- // up + if (e.which == 40 && index < $items.length - 1) index++ // down + if (!~index) index = 0 + + $items.eq(index).trigger('focus') + } + + + // DROPDOWN PLUGIN DEFINITION + // ========================== + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.dropdown') + + if (!data) $this.data('bs.dropdown', (data = new Dropdown(this))) + if (typeof option == 'string') data[option].call($this) + }) + } + + var old = $.fn.dropdown + + $.fn.dropdown = Plugin + $.fn.dropdown.Constructor = Dropdown + + + // DROPDOWN NO CONFLICT + // ==================== + + $.fn.dropdown.noConflict = function () { + $.fn.dropdown = old + return this + } + + + // APPLY TO STANDARD DROPDOWN ELEMENTS + // =================================== + + $(document) + .on('click.bs.dropdown.data-api', clearMenus) + .on('click.bs.dropdown.data-api', '.dropdown form', function (e) { e.stopPropagation() }) + .on('click.bs.dropdown.data-api', toggle, Dropdown.prototype.toggle) + .on('keydown.bs.dropdown.data-api', toggle, Dropdown.prototype.keydown) + .on('keydown.bs.dropdown.data-api', '.dropdown-menu', Dropdown.prototype.keydown) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: modal.js v3.3.7 + * http://getbootstrap.com/javascript/#modals + * ======================================================================== + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // MODAL CLASS DEFINITION + // ====================== + + var Modal = function (element, options) { + this.options = options + this.$body = $(document.body) + this.$element = $(element) + this.$dialog = this.$element.find('.modal-dialog') + this.$backdrop = null + this.isShown = null + this.originalBodyPad = null + this.scrollbarWidth = 0 + this.ignoreBackdropClick = false + + if (this.options.remote) { + this.$element + .find('.modal-content') + .load(this.options.remote, $.proxy(function () { + this.$element.trigger('loaded.bs.modal') + }, this)) + } + } + + Modal.VERSION = '3.3.7' + + Modal.TRANSITION_DURATION = 300 + Modal.BACKDROP_TRANSITION_DURATION = 150 + + Modal.DEFAULTS = { + backdrop: true, + keyboard: true, + show: true + } + + Modal.prototype.toggle = function (_relatedTarget) { + return this.isShown ? this.hide() : this.show(_relatedTarget) + } + + Modal.prototype.show = function (_relatedTarget) { + var that = this + var e = $.Event('show.bs.modal', { relatedTarget: _relatedTarget }) + + this.$element.trigger(e) + + if (this.isShown || e.isDefaultPrevented()) return + + this.isShown = true + + this.checkScrollbar() + this.setScrollbar() + this.$body.addClass('modal-open') + + this.escape() + this.resize() + + this.$element.on('click.dismiss.bs.modal', '[data-dismiss="modal"]', $.proxy(this.hide, this)) + + this.$dialog.on('mousedown.dismiss.bs.modal', function () { + that.$element.one('mouseup.dismiss.bs.modal', function (e) { + if ($(e.target).is(that.$element)) that.ignoreBackdropClick = true + }) + }) + + this.backdrop(function () { + var transition = $.support.transition && that.$element.hasClass('fade') + + if (!that.$element.parent().length) { + that.$element.appendTo(that.$body) // don't move modals dom position + } + + that.$element + .show() + .scrollTop(0) + + that.adjustDialog() + + if (transition) { + that.$element[0].offsetWidth // force reflow + } + + that.$element.addClass('in') + + that.enforceFocus() + + var e = $.Event('shown.bs.modal', { relatedTarget: _relatedTarget }) + + transition ? + that.$dialog // wait for modal to slide in + .one('bsTransitionEnd', function () { + that.$element.trigger('focus').trigger(e) + }) + .emulateTransitionEnd(Modal.TRANSITION_DURATION) : + that.$element.trigger('focus').trigger(e) + }) + } + + Modal.prototype.hide = function (e) { + if (e) e.preventDefault() + + e = $.Event('hide.bs.modal') + + this.$element.trigger(e) + + if (!this.isShown || e.isDefaultPrevented()) return + + this.isShown = false + + this.escape() + this.resize() + + $(document).off('focusin.bs.modal') + + this.$element + .removeClass('in') + .off('click.dismiss.bs.modal') + .off('mouseup.dismiss.bs.modal') + + this.$dialog.off('mousedown.dismiss.bs.modal') + + $.support.transition && this.$element.hasClass('fade') ? + this.$element + .one('bsTransitionEnd', $.proxy(this.hideModal, this)) + .emulateTransitionEnd(Modal.TRANSITION_DURATION) : + this.hideModal() + } + + Modal.prototype.enforceFocus = function () { + $(document) + .off('focusin.bs.modal') // guard against infinite focus loop + .on('focusin.bs.modal', $.proxy(function (e) { + if (document !== e.target && + this.$element[0] !== e.target && + !this.$element.has(e.target).length) { + this.$element.trigger('focus') + } + }, this)) + } + + Modal.prototype.escape = function () { + if (this.isShown && this.options.keyboard) { + this.$element.on('keydown.dismiss.bs.modal', $.proxy(function (e) { + e.which == 27 && this.hide() + }, this)) + } else if (!this.isShown) { + this.$element.off('keydown.dismiss.bs.modal') + } + } + + Modal.prototype.resize = function () { + if (this.isShown) { + $(window).on('resize.bs.modal', $.proxy(this.handleUpdate, this)) + } else { + $(window).off('resize.bs.modal') + } + } + + Modal.prototype.hideModal = function () { + var that = this + this.$element.hide() + this.backdrop(function () { + that.$body.removeClass('modal-open') + that.resetAdjustments() + that.resetScrollbar() + that.$element.trigger('hidden.bs.modal') + }) + } + + Modal.prototype.removeBackdrop = function () { + this.$backdrop && this.$backdrop.remove() + this.$backdrop = null + } + + Modal.prototype.backdrop = function (callback) { + var that = this + var animate = this.$element.hasClass('fade') ? 'fade' : '' + + if (this.isShown && this.options.backdrop) { + var doAnimate = $.support.transition && animate + + this.$backdrop = $(document.createElement('div')) + .addClass('modal-backdrop ' + animate) + .appendTo(this.$body) + + this.$element.on('click.dismiss.bs.modal', $.proxy(function (e) { + if (this.ignoreBackdropClick) { + this.ignoreBackdropClick = false + return + } + if (e.target !== e.currentTarget) return + this.options.backdrop == 'static' + ? this.$element[0].focus() + : this.hide() + }, this)) + + if (doAnimate) this.$backdrop[0].offsetWidth // force reflow + + this.$backdrop.addClass('in') + + if (!callback) return + + doAnimate ? + this.$backdrop + .one('bsTransitionEnd', callback) + .emulateTransitionEnd(Modal.BACKDROP_TRANSITION_DURATION) : + callback() + + } else if (!this.isShown && this.$backdrop) { + this.$backdrop.removeClass('in') + + var callbackRemove = function () { + that.removeBackdrop() + callback && callback() + } + $.support.transition && this.$element.hasClass('fade') ? + this.$backdrop + .one('bsTransitionEnd', callbackRemove) + .emulateTransitionEnd(Modal.BACKDROP_TRANSITION_DURATION) : + callbackRemove() + + } else if (callback) { + callback() + } + } + + // these following methods are used to handle overflowing modals + + Modal.prototype.handleUpdate = function () { + this.adjustDialog() + } + + Modal.prototype.adjustDialog = function () { + var modalIsOverflowing = this.$element[0].scrollHeight > document.documentElement.clientHeight + + this.$element.css({ + paddingLeft: !this.bodyIsOverflowing && modalIsOverflowing ? this.scrollbarWidth : '', + paddingRight: this.bodyIsOverflowing && !modalIsOverflowing ? this.scrollbarWidth : '' + }) + } + + Modal.prototype.resetAdjustments = function () { + this.$element.css({ + paddingLeft: '', + paddingRight: '' + }) + } + + Modal.prototype.checkScrollbar = function () { + var fullWindowWidth = window.innerWidth + if (!fullWindowWidth) { // workaround for missing window.innerWidth in IE8 + var documentElementRect = document.documentElement.getBoundingClientRect() + fullWindowWidth = documentElementRect.right - Math.abs(documentElementRect.left) + } + this.bodyIsOverflowing = document.body.clientWidth < fullWindowWidth + this.scrollbarWidth = this.measureScrollbar() + } + + Modal.prototype.setScrollbar = function () { + var bodyPad = parseInt((this.$body.css('padding-right') || 0), 10) + this.originalBodyPad = document.body.style.paddingRight || '' + if (this.bodyIsOverflowing) this.$body.css('padding-right', bodyPad + this.scrollbarWidth) + } + + Modal.prototype.resetScrollbar = function () { + this.$body.css('padding-right', this.originalBodyPad) + } + + Modal.prototype.measureScrollbar = function () { // thx walsh + var scrollDiv = document.createElement('div') + scrollDiv.className = 'modal-scrollbar-measure' + this.$body.append(scrollDiv) + var scrollbarWidth = scrollDiv.offsetWidth - scrollDiv.clientWidth + this.$body[0].removeChild(scrollDiv) + return scrollbarWidth + } + + + // MODAL PLUGIN DEFINITION + // ======================= + + function Plugin(option, _relatedTarget) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.modal') + var options = $.extend({}, Modal.DEFAULTS, $this.data(), typeof option == 'object' && option) + + if (!data) $this.data('bs.modal', (data = new Modal(this, options))) + if (typeof option == 'string') data[option](_relatedTarget) + else if (options.show) data.show(_relatedTarget) + }) + } + + var old = $.fn.modal + + $.fn.modal = Plugin + $.fn.modal.Constructor = Modal + + + // MODAL NO CONFLICT + // ================= + + $.fn.modal.noConflict = function () { + $.fn.modal = old + return this + } + + + // MODAL DATA-API + // ============== + + $(document).on('click.bs.modal.data-api', '[data-toggle="modal"]', function (e) { + var $this = $(this) + var href = $this.attr('href') + var $target = $($this.attr('data-target') || (href && href.replace(/.*(?=#[^\s]+$)/, ''))) // strip for ie7 + var option = $target.data('bs.modal') ? 'toggle' : $.extend({ remote: !/#/.test(href) && href }, $target.data(), $this.data()) + + if ($this.is('a')) e.preventDefault() + + $target.one('show.bs.modal', function (showEvent) { + if (showEvent.isDefaultPrevented()) return // only register focus restorer if modal will actually get shown + $target.one('hidden.bs.modal', function () { + $this.is(':visible') && $this.trigger('focus') + }) + }) + Plugin.call($target, option, this) + }) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: tooltip.js v3.3.7 + * http://getbootstrap.com/javascript/#tooltip + * Inspired by the original jQuery.tipsy by Jason Frame + * ======================================================================== + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // TOOLTIP PUBLIC CLASS DEFINITION + // =============================== + + var Tooltip = function (element, options) { + this.type = null + this.options = null + this.enabled = null + this.timeout = null + this.hoverState = null + this.$element = null + this.inState = null + + this.init('tooltip', element, options) + } + + Tooltip.VERSION = '3.3.7' + + Tooltip.TRANSITION_DURATION = 150 + + Tooltip.DEFAULTS = { + animation: true, + placement: 'top', + selector: false, + template: '', + trigger: 'hover focus', + title: '', + delay: 0, + html: false, + container: false, + viewport: { + selector: 'body', + padding: 0 + } + } + + Tooltip.prototype.init = function (type, element, options) { + this.enabled = true + this.type = type + this.$element = $(element) + this.options = this.getOptions(options) + this.$viewport = this.options.viewport && $($.isFunction(this.options.viewport) ? this.options.viewport.call(this, this.$element) : (this.options.viewport.selector || this.options.viewport)) + this.inState = { click: false, hover: false, focus: false } + + if (this.$element[0] instanceof document.constructor && !this.options.selector) { + throw new Error('`selector` option must be specified when initializing ' + this.type + ' on the window.document object!') + } + + var triggers = this.options.trigger.split(' ') + + for (var i = triggers.length; i--;) { + var trigger = triggers[i] + + if (trigger == 'click') { + this.$element.on('click.' + this.type, this.options.selector, $.proxy(this.toggle, this)) + } else if (trigger != 'manual') { + var eventIn = trigger == 'hover' ? 'mouseenter' : 'focusin' + var eventOut = trigger == 'hover' ? 'mouseleave' : 'focusout' + + this.$element.on(eventIn + '.' + this.type, this.options.selector, $.proxy(this.enter, this)) + this.$element.on(eventOut + '.' + this.type, this.options.selector, $.proxy(this.leave, this)) + } + } + + this.options.selector ? + (this._options = $.extend({}, this.options, { trigger: 'manual', selector: '' })) : + this.fixTitle() + } + + Tooltip.prototype.getDefaults = function () { + return Tooltip.DEFAULTS + } + + Tooltip.prototype.getOptions = function (options) { + options = $.extend({}, this.getDefaults(), this.$element.data(), options) + + if (options.delay && typeof options.delay == 'number') { + options.delay = { + show: options.delay, + hide: options.delay + } + } + + return options + } + + Tooltip.prototype.getDelegateOptions = function () { + var options = {} + var defaults = this.getDefaults() + + this._options && $.each(this._options, function (key, value) { + if (defaults[key] != value) options[key] = value + }) + + return options + } + + Tooltip.prototype.enter = function (obj) { + var self = obj instanceof this.constructor ? + obj : $(obj.currentTarget).data('bs.' + this.type) + + if (!self) { + self = new this.constructor(obj.currentTarget, this.getDelegateOptions()) + $(obj.currentTarget).data('bs.' + this.type, self) + } + + if (obj instanceof $.Event) { + self.inState[obj.type == 'focusin' ? 'focus' : 'hover'] = true + } + + if (self.tip().hasClass('in') || self.hoverState == 'in') { + self.hoverState = 'in' + return + } + + clearTimeout(self.timeout) + + self.hoverState = 'in' + + if (!self.options.delay || !self.options.delay.show) return self.show() + + self.timeout = setTimeout(function () { + if (self.hoverState == 'in') self.show() + }, self.options.delay.show) + } + + Tooltip.prototype.isInStateTrue = function () { + for (var key in this.inState) { + if (this.inState[key]) return true + } + + return false + } + + Tooltip.prototype.leave = function (obj) { + var self = obj instanceof this.constructor ? + obj : $(obj.currentTarget).data('bs.' + this.type) + + if (!self) { + self = new this.constructor(obj.currentTarget, this.getDelegateOptions()) + $(obj.currentTarget).data('bs.' + this.type, self) + } + + if (obj instanceof $.Event) { + self.inState[obj.type == 'focusout' ? 'focus' : 'hover'] = false + } + + if (self.isInStateTrue()) return + + clearTimeout(self.timeout) + + self.hoverState = 'out' + + if (!self.options.delay || !self.options.delay.hide) return self.hide() + + self.timeout = setTimeout(function () { + if (self.hoverState == 'out') self.hide() + }, self.options.delay.hide) + } + + Tooltip.prototype.show = function () { + var e = $.Event('show.bs.' + this.type) + + if (this.hasContent() && this.enabled) { + this.$element.trigger(e) + + var inDom = $.contains(this.$element[0].ownerDocument.documentElement, this.$element[0]) + if (e.isDefaultPrevented() || !inDom) return + var that = this + + var $tip = this.tip() + + var tipId = this.getUID(this.type) + + this.setContent() + $tip.attr('id', tipId) + this.$element.attr('aria-describedby', tipId) + + if (this.options.animation) $tip.addClass('fade') + + var placement = typeof this.options.placement == 'function' ? + this.options.placement.call(this, $tip[0], this.$element[0]) : + this.options.placement + + var autoToken = /\s?auto?\s?/i + var autoPlace = autoToken.test(placement) + if (autoPlace) placement = placement.replace(autoToken, '') || 'top' + + $tip + .detach() + .css({ top: 0, left: 0, display: 'block' }) + .addClass(placement) + .data('bs.' + this.type, this) + + this.options.container ? $tip.appendTo(this.options.container) : $tip.insertAfter(this.$element) + this.$element.trigger('inserted.bs.' + this.type) + + var pos = this.getPosition() + var actualWidth = $tip[0].offsetWidth + var actualHeight = $tip[0].offsetHeight + + if (autoPlace) { + var orgPlacement = placement + var viewportDim = this.getPosition(this.$viewport) + + placement = placement == 'bottom' && pos.bottom + actualHeight > viewportDim.bottom ? 'top' : + placement == 'top' && pos.top - actualHeight < viewportDim.top ? 'bottom' : + placement == 'right' && pos.right + actualWidth > viewportDim.width ? 'left' : + placement == 'left' && pos.left - actualWidth < viewportDim.left ? 'right' : + placement + + $tip + .removeClass(orgPlacement) + .addClass(placement) + } + + var calculatedOffset = this.getCalculatedOffset(placement, pos, actualWidth, actualHeight) + + this.applyPlacement(calculatedOffset, placement) + + var complete = function () { + var prevHoverState = that.hoverState + that.$element.trigger('shown.bs.' + that.type) + that.hoverState = null + + if (prevHoverState == 'out') that.leave(that) + } + + $.support.transition && this.$tip.hasClass('fade') ? + $tip + .one('bsTransitionEnd', complete) + .emulateTransitionEnd(Tooltip.TRANSITION_DURATION) : + complete() + } + } + + Tooltip.prototype.applyPlacement = function (offset, placement) { + var $tip = this.tip() + var width = $tip[0].offsetWidth + var height = $tip[0].offsetHeight + + // manually read margins because getBoundingClientRect includes difference + var marginTop = parseInt($tip.css('margin-top'), 10) + var marginLeft = parseInt($tip.css('margin-left'), 10) + + // we must check for NaN for ie 8/9 + if (isNaN(marginTop)) marginTop = 0 + if (isNaN(marginLeft)) marginLeft = 0 + + offset.top += marginTop + offset.left += marginLeft + + // $.fn.offset doesn't round pixel values + // so we use setOffset directly with our own function B-0 + $.offset.setOffset($tip[0], $.extend({ + using: function (props) { + $tip.css({ + top: Math.round(props.top), + left: Math.round(props.left) + }) + } + }, offset), 0) + + $tip.addClass('in') + + // check to see if placing tip in new offset caused the tip to resize itself + var actualWidth = $tip[0].offsetWidth + var actualHeight = $tip[0].offsetHeight + + if (placement == 'top' && actualHeight != height) { + offset.top = offset.top + height - actualHeight + } + + var delta = this.getViewportAdjustedDelta(placement, offset, actualWidth, actualHeight) + + if (delta.left) offset.left += delta.left + else offset.top += delta.top + + var isVertical = /top|bottom/.test(placement) + var arrowDelta = isVertical ? delta.left * 2 - width + actualWidth : delta.top * 2 - height + actualHeight + var arrowOffsetPosition = isVertical ? 'offsetWidth' : 'offsetHeight' + + $tip.offset(offset) + this.replaceArrow(arrowDelta, $tip[0][arrowOffsetPosition], isVertical) + } + + Tooltip.prototype.replaceArrow = function (delta, dimension, isVertical) { + this.arrow() + .css(isVertical ? 'left' : 'top', 50 * (1 - delta / dimension) + '%') + .css(isVertical ? 'top' : 'left', '') + } + + Tooltip.prototype.setContent = function () { + var $tip = this.tip() + var title = this.getTitle() + + $tip.find('.tooltip-inner')[this.options.html ? 'html' : 'text'](title) + $tip.removeClass('fade in top bottom left right') + } + + Tooltip.prototype.hide = function (callback) { + var that = this + var $tip = $(this.$tip) + var e = $.Event('hide.bs.' + this.type) + + function complete() { + if (that.hoverState != 'in') $tip.detach() + if (that.$element) { // TODO: Check whether guarding this code with this `if` is really necessary. + that.$element + .removeAttr('aria-describedby') + .trigger('hidden.bs.' + that.type) + } + callback && callback() + } + + this.$element.trigger(e) + + if (e.isDefaultPrevented()) return + + $tip.removeClass('in') + + $.support.transition && $tip.hasClass('fade') ? + $tip + .one('bsTransitionEnd', complete) + .emulateTransitionEnd(Tooltip.TRANSITION_DURATION) : + complete() + + this.hoverState = null + + return this + } + + Tooltip.prototype.fixTitle = function () { + var $e = this.$element + if ($e.attr('title') || typeof $e.attr('data-original-title') != 'string') { + $e.attr('data-original-title', $e.attr('title') || '').attr('title', '') + } + } + + Tooltip.prototype.hasContent = function () { + return this.getTitle() + } + + Tooltip.prototype.getPosition = function ($element) { + $element = $element || this.$element + + var el = $element[0] + var isBody = el.tagName == 'BODY' + + var elRect = el.getBoundingClientRect() + if (elRect.width == null) { + // width and height are missing in IE8, so compute them manually; see https://github.com/twbs/bootstrap/issues/14093 + elRect = $.extend({}, elRect, { width: elRect.right - elRect.left, height: elRect.bottom - elRect.top }) + } + var isSvg = window.SVGElement && el instanceof window.SVGElement + // Avoid using $.offset() on SVGs since it gives incorrect results in jQuery 3. + // See https://github.com/twbs/bootstrap/issues/20280 + var elOffset = isBody ? { top: 0, left: 0 } : (isSvg ? null : $element.offset()) + var scroll = { scroll: isBody ? document.documentElement.scrollTop || document.body.scrollTop : $element.scrollTop() } + var outerDims = isBody ? { width: $(window).width(), height: $(window).height() } : null + + return $.extend({}, elRect, scroll, outerDims, elOffset) + } + + Tooltip.prototype.getCalculatedOffset = function (placement, pos, actualWidth, actualHeight) { + return placement == 'bottom' ? { top: pos.top + pos.height, left: pos.left + pos.width / 2 - actualWidth / 2 } : + placement == 'top' ? { top: pos.top - actualHeight, left: pos.left + pos.width / 2 - actualWidth / 2 } : + placement == 'left' ? { top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left - actualWidth } : + /* placement == 'right' */ { top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left + pos.width } + + } + + Tooltip.prototype.getViewportAdjustedDelta = function (placement, pos, actualWidth, actualHeight) { + var delta = { top: 0, left: 0 } + if (!this.$viewport) return delta + + var viewportPadding = this.options.viewport && this.options.viewport.padding || 0 + var viewportDimensions = this.getPosition(this.$viewport) + + if (/right|left/.test(placement)) { + var topEdgeOffset = pos.top - viewportPadding - viewportDimensions.scroll + var bottomEdgeOffset = pos.top + viewportPadding - viewportDimensions.scroll + actualHeight + if (topEdgeOffset < viewportDimensions.top) { // top overflow + delta.top = viewportDimensions.top - topEdgeOffset + } else if (bottomEdgeOffset > viewportDimensions.top + viewportDimensions.height) { // bottom overflow + delta.top = viewportDimensions.top + viewportDimensions.height - bottomEdgeOffset + } + } else { + var leftEdgeOffset = pos.left - viewportPadding + var rightEdgeOffset = pos.left + viewportPadding + actualWidth + if (leftEdgeOffset < viewportDimensions.left) { // left overflow + delta.left = viewportDimensions.left - leftEdgeOffset + } else if (rightEdgeOffset > viewportDimensions.right) { // right overflow + delta.left = viewportDimensions.left + viewportDimensions.width - rightEdgeOffset + } + } + + return delta + } + + Tooltip.prototype.getTitle = function () { + var title + var $e = this.$element + var o = this.options + + title = $e.attr('data-original-title') + || (typeof o.title == 'function' ? o.title.call($e[0]) : o.title) + + return title + } + + Tooltip.prototype.getUID = function (prefix) { + do prefix += ~~(Math.random() * 1000000) + while (document.getElementById(prefix)) + return prefix + } + + Tooltip.prototype.tip = function () { + if (!this.$tip) { + this.$tip = $(this.options.template) + if (this.$tip.length != 1) { + throw new Error(this.type + ' `template` option must consist of exactly 1 top-level element!') + } + } + return this.$tip + } + + Tooltip.prototype.arrow = function () { + return (this.$arrow = this.$arrow || this.tip().find('.tooltip-arrow')) + } + + Tooltip.prototype.enable = function () { + this.enabled = true + } + + Tooltip.prototype.disable = function () { + this.enabled = false + } + + Tooltip.prototype.toggleEnabled = function () { + this.enabled = !this.enabled + } + + Tooltip.prototype.toggle = function (e) { + var self = this + if (e) { + self = $(e.currentTarget).data('bs.' + this.type) + if (!self) { + self = new this.constructor(e.currentTarget, this.getDelegateOptions()) + $(e.currentTarget).data('bs.' + this.type, self) + } + } + + if (e) { + self.inState.click = !self.inState.click + if (self.isInStateTrue()) self.enter(self) + else self.leave(self) + } else { + self.tip().hasClass('in') ? self.leave(self) : self.enter(self) + } + } + + Tooltip.prototype.destroy = function () { + var that = this + clearTimeout(this.timeout) + this.hide(function () { + that.$element.off('.' + that.type).removeData('bs.' + that.type) + if (that.$tip) { + that.$tip.detach() + } + that.$tip = null + that.$arrow = null + that.$viewport = null + that.$element = null + }) + } + + + // TOOLTIP PLUGIN DEFINITION + // ========================= + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.tooltip') + var options = typeof option == 'object' && option + + if (!data && /destroy|hide/.test(option)) return + if (!data) $this.data('bs.tooltip', (data = new Tooltip(this, options))) + if (typeof option == 'string') data[option]() + }) + } + + var old = $.fn.tooltip + + $.fn.tooltip = Plugin + $.fn.tooltip.Constructor = Tooltip + + + // TOOLTIP NO CONFLICT + // =================== + + $.fn.tooltip.noConflict = function () { + $.fn.tooltip = old + return this + } + +}(jQuery); + +/* ======================================================================== + * Bootstrap: popover.js v3.3.7 + * http://getbootstrap.com/javascript/#popovers + * ======================================================================== + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // POPOVER PUBLIC CLASS DEFINITION + // =============================== + + var Popover = function (element, options) { + this.init('popover', element, options) + } + + if (!$.fn.tooltip) throw new Error('Popover requires tooltip.js') + + Popover.VERSION = '3.3.7' + + Popover.DEFAULTS = $.extend({}, $.fn.tooltip.Constructor.DEFAULTS, { + placement: 'right', + trigger: 'click', + content: '', + template: '' + }) + + + // NOTE: POPOVER EXTENDS tooltip.js + // ================================ + + Popover.prototype = $.extend({}, $.fn.tooltip.Constructor.prototype) + + Popover.prototype.constructor = Popover + + Popover.prototype.getDefaults = function () { + return Popover.DEFAULTS + } + + Popover.prototype.setContent = function () { + var $tip = this.tip() + var title = this.getTitle() + var content = this.getContent() + + $tip.find('.popover-title')[this.options.html ? 'html' : 'text'](title) + $tip.find('.popover-content').children().detach().end()[ // we use append for html objects to maintain js events + this.options.html ? (typeof content == 'string' ? 'html' : 'append') : 'text' + ](content) + + $tip.removeClass('fade top bottom left right in') + + // IE8 doesn't accept hiding via the `:empty` pseudo selector, we have to do + // this manually by checking the contents. + if (!$tip.find('.popover-title').html()) $tip.find('.popover-title').hide() + } + + Popover.prototype.hasContent = function () { + return this.getTitle() || this.getContent() + } + + Popover.prototype.getContent = function () { + var $e = this.$element + var o = this.options + + return $e.attr('data-content') + || (typeof o.content == 'function' ? + o.content.call($e[0]) : + o.content) + } + + Popover.prototype.arrow = function () { + return (this.$arrow = this.$arrow || this.tip().find('.arrow')) + } + + + // POPOVER PLUGIN DEFINITION + // ========================= + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.popover') + var options = typeof option == 'object' && option + + if (!data && /destroy|hide/.test(option)) return + if (!data) $this.data('bs.popover', (data = new Popover(this, options))) + if (typeof option == 'string') data[option]() + }) + } + + var old = $.fn.popover + + $.fn.popover = Plugin + $.fn.popover.Constructor = Popover + + + // POPOVER NO CONFLICT + // =================== + + $.fn.popover.noConflict = function () { + $.fn.popover = old + return this + } + +}(jQuery); + +/* ======================================================================== + * Bootstrap: scrollspy.js v3.3.7 + * http://getbootstrap.com/javascript/#scrollspy + * ======================================================================== + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // SCROLLSPY CLASS DEFINITION + // ========================== + + function ScrollSpy(element, options) { + this.$body = $(document.body) + this.$scrollElement = $(element).is(document.body) ? $(window) : $(element) + this.options = $.extend({}, ScrollSpy.DEFAULTS, options) + this.selector = (this.options.target || '') + ' .nav li > a' + this.offsets = [] + this.targets = [] + this.activeTarget = null + this.scrollHeight = 0 + + this.$scrollElement.on('scroll.bs.scrollspy', $.proxy(this.process, this)) + this.refresh() + this.process() + } + + ScrollSpy.VERSION = '3.3.7' + + ScrollSpy.DEFAULTS = { + offset: 10 + } + + ScrollSpy.prototype.getScrollHeight = function () { + return this.$scrollElement[0].scrollHeight || Math.max(this.$body[0].scrollHeight, document.documentElement.scrollHeight) + } + + ScrollSpy.prototype.refresh = function () { + var that = this + var offsetMethod = 'offset' + var offsetBase = 0 + + this.offsets = [] + this.targets = [] + this.scrollHeight = this.getScrollHeight() + + if (!$.isWindow(this.$scrollElement[0])) { + offsetMethod = 'position' + offsetBase = this.$scrollElement.scrollTop() + } + + this.$body + .find(this.selector) + .map(function () { + var $el = $(this) + var href = $el.data('target') || $el.attr('href') + var $href = /^#./.test(href) && $(href) + + return ($href + && $href.length + && $href.is(':visible') + && [[$href[offsetMethod]().top + offsetBase, href]]) || null + }) + .sort(function (a, b) { return a[0] - b[0] }) + .each(function () { + that.offsets.push(this[0]) + that.targets.push(this[1]) + }) + } + + ScrollSpy.prototype.process = function () { + var scrollTop = this.$scrollElement.scrollTop() + this.options.offset + var scrollHeight = this.getScrollHeight() + var maxScroll = this.options.offset + scrollHeight - this.$scrollElement.height() + var offsets = this.offsets + var targets = this.targets + var activeTarget = this.activeTarget + var i + + if (this.scrollHeight != scrollHeight) { + this.refresh() + } + + if (scrollTop >= maxScroll) { + return activeTarget != (i = targets[targets.length - 1]) && this.activate(i) + } + + if (activeTarget && scrollTop < offsets[0]) { + this.activeTarget = null + return this.clear() + } + + for (i = offsets.length; i--;) { + activeTarget != targets[i] + && scrollTop >= offsets[i] + && (offsets[i + 1] === undefined || scrollTop < offsets[i + 1]) + && this.activate(targets[i]) + } + } + + ScrollSpy.prototype.activate = function (target) { + this.activeTarget = target + + this.clear() + + var selector = this.selector + + '[data-target="' + target + '"],' + + this.selector + '[href="' + target + '"]' + + var active = $(selector) + .parents('li') + .addClass('active') + + if (active.parent('.dropdown-menu').length) { + active = active + .closest('li.dropdown') + .addClass('active') + } + + active.trigger('activate.bs.scrollspy') + } + + ScrollSpy.prototype.clear = function () { + $(this.selector) + .parentsUntil(this.options.target, '.active') + .removeClass('active') + } + + + // SCROLLSPY PLUGIN DEFINITION + // =========================== + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.scrollspy') + var options = typeof option == 'object' && option + + if (!data) $this.data('bs.scrollspy', (data = new ScrollSpy(this, options))) + if (typeof option == 'string') data[option]() + }) + } + + var old = $.fn.scrollspy + + $.fn.scrollspy = Plugin + $.fn.scrollspy.Constructor = ScrollSpy + + + // SCROLLSPY NO CONFLICT + // ===================== + + $.fn.scrollspy.noConflict = function () { + $.fn.scrollspy = old + return this + } + + + // SCROLLSPY DATA-API + // ================== + + $(window).on('load.bs.scrollspy.data-api', function () { + $('[data-spy="scroll"]').each(function () { + var $spy = $(this) + Plugin.call($spy, $spy.data()) + }) + }) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: tab.js v3.3.7 + * http://getbootstrap.com/javascript/#tabs + * ======================================================================== + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // TAB CLASS DEFINITION + // ==================== + + var Tab = function (element) { + // jscs:disable requireDollarBeforejQueryAssignment + this.element = $(element) + // jscs:enable requireDollarBeforejQueryAssignment + } + + Tab.VERSION = '3.3.7' + + Tab.TRANSITION_DURATION = 150 + + Tab.prototype.show = function () { + var $this = this.element + var $ul = $this.closest('ul:not(.dropdown-menu)') + var selector = $this.data('target') + + if (!selector) { + selector = $this.attr('href') + selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7 + } + + if ($this.parent('li').hasClass('active')) return + + var $previous = $ul.find('.active:last a') + var hideEvent = $.Event('hide.bs.tab', { + relatedTarget: $this[0] + }) + var showEvent = $.Event('show.bs.tab', { + relatedTarget: $previous[0] + }) + + $previous.trigger(hideEvent) + $this.trigger(showEvent) + + if (showEvent.isDefaultPrevented() || hideEvent.isDefaultPrevented()) return + + var $target = $(selector) + + this.activate($this.closest('li'), $ul) + this.activate($target, $target.parent(), function () { + $previous.trigger({ + type: 'hidden.bs.tab', + relatedTarget: $this[0] + }) + $this.trigger({ + type: 'shown.bs.tab', + relatedTarget: $previous[0] + }) + }) + } + + Tab.prototype.activate = function (element, container, callback) { + var $active = container.find('> .active') + var transition = callback + && $.support.transition + && ($active.length && $active.hasClass('fade') || !!container.find('> .fade').length) + + function next() { + $active + .removeClass('active') + .find('> .dropdown-menu > .active') + .removeClass('active') + .end() + .find('[data-toggle="tab"]') + .attr('aria-expanded', false) + + element + .addClass('active') + .find('[data-toggle="tab"]') + .attr('aria-expanded', true) + + if (transition) { + element[0].offsetWidth // reflow for transition + element.addClass('in') + } else { + element.removeClass('fade') + } + + if (element.parent('.dropdown-menu').length) { + element + .closest('li.dropdown') + .addClass('active') + .end() + .find('[data-toggle="tab"]') + .attr('aria-expanded', true) + } + + callback && callback() + } + + $active.length && transition ? + $active + .one('bsTransitionEnd', next) + .emulateTransitionEnd(Tab.TRANSITION_DURATION) : + next() + + $active.removeClass('in') + } + + + // TAB PLUGIN DEFINITION + // ===================== + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.tab') + + if (!data) $this.data('bs.tab', (data = new Tab(this))) + if (typeof option == 'string') data[option]() + }) + } + + var old = $.fn.tab + + $.fn.tab = Plugin + $.fn.tab.Constructor = Tab + + + // TAB NO CONFLICT + // =============== + + $.fn.tab.noConflict = function () { + $.fn.tab = old + return this + } + + + // TAB DATA-API + // ============ + + var clickHandler = function (e) { + e.preventDefault() + Plugin.call($(this), 'show') + } + + $(document) + .on('click.bs.tab.data-api', '[data-toggle="tab"]', clickHandler) + .on('click.bs.tab.data-api', '[data-toggle="pill"]', clickHandler) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: affix.js v3.3.7 + * http://getbootstrap.com/javascript/#affix + * ======================================================================== + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // AFFIX CLASS DEFINITION + // ====================== + + var Affix = function (element, options) { + this.options = $.extend({}, Affix.DEFAULTS, options) + + this.$target = $(this.options.target) + .on('scroll.bs.affix.data-api', $.proxy(this.checkPosition, this)) + .on('click.bs.affix.data-api', $.proxy(this.checkPositionWithEventLoop, this)) + + this.$element = $(element) + this.affixed = null + this.unpin = null + this.pinnedOffset = null + + this.checkPosition() + } + + Affix.VERSION = '3.3.7' + + Affix.RESET = 'affix affix-top affix-bottom' + + Affix.DEFAULTS = { + offset: 0, + target: window + } + + Affix.prototype.getState = function (scrollHeight, height, offsetTop, offsetBottom) { + var scrollTop = this.$target.scrollTop() + var position = this.$element.offset() + var targetHeight = this.$target.height() + + if (offsetTop != null && this.affixed == 'top') return scrollTop < offsetTop ? 'top' : false + + if (this.affixed == 'bottom') { + if (offsetTop != null) return (scrollTop + this.unpin <= position.top) ? false : 'bottom' + return (scrollTop + targetHeight <= scrollHeight - offsetBottom) ? false : 'bottom' + } + + var initializing = this.affixed == null + var colliderTop = initializing ? scrollTop : position.top + var colliderHeight = initializing ? targetHeight : height + + if (offsetTop != null && scrollTop <= offsetTop) return 'top' + if (offsetBottom != null && (colliderTop + colliderHeight >= scrollHeight - offsetBottom)) return 'bottom' + + return false + } + + Affix.prototype.getPinnedOffset = function () { + if (this.pinnedOffset) return this.pinnedOffset + this.$element.removeClass(Affix.RESET).addClass('affix') + var scrollTop = this.$target.scrollTop() + var position = this.$element.offset() + return (this.pinnedOffset = position.top - scrollTop) + } + + Affix.prototype.checkPositionWithEventLoop = function () { + setTimeout($.proxy(this.checkPosition, this), 1) + } + + Affix.prototype.checkPosition = function () { + if (!this.$element.is(':visible')) return + + var height = this.$element.height() + var offset = this.options.offset + var offsetTop = offset.top + var offsetBottom = offset.bottom + var scrollHeight = Math.max($(document).height(), $(document.body).height()) + + if (typeof offset != 'object') offsetBottom = offsetTop = offset + if (typeof offsetTop == 'function') offsetTop = offset.top(this.$element) + if (typeof offsetBottom == 'function') offsetBottom = offset.bottom(this.$element) + + var affix = this.getState(scrollHeight, height, offsetTop, offsetBottom) + + if (this.affixed != affix) { + if (this.unpin != null) this.$element.css('top', '') + + var affixType = 'affix' + (affix ? '-' + affix : '') + var e = $.Event(affixType + '.bs.affix') + + this.$element.trigger(e) + + if (e.isDefaultPrevented()) return + + this.affixed = affix + this.unpin = affix == 'bottom' ? this.getPinnedOffset() : null + + this.$element + .removeClass(Affix.RESET) + .addClass(affixType) + .trigger(affixType.replace('affix', 'affixed') + '.bs.affix') + } + + if (affix == 'bottom') { + this.$element.offset({ + top: scrollHeight - height - offsetBottom + }) + } + } + + + // AFFIX PLUGIN DEFINITION + // ======================= + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.affix') + var options = typeof option == 'object' && option + + if (!data) $this.data('bs.affix', (data = new Affix(this, options))) + if (typeof option == 'string') data[option]() + }) + } + + var old = $.fn.affix + + $.fn.affix = Plugin + $.fn.affix.Constructor = Affix + + + // AFFIX NO CONFLICT + // ================= + + $.fn.affix.noConflict = function () { + $.fn.affix = old + return this + } + + + // AFFIX DATA-API + // ============== + + $(window).on('load', function () { + $('[data-spy="affix"]').each(function () { + var $spy = $(this) + var data = $spy.data() + + data.offset = data.offset || {} + + if (data.offsetBottom != null) data.offset.bottom = data.offsetBottom + if (data.offsetTop != null) data.offset.top = data.offsetTop + + Plugin.call($spy, data) + }) + }) + +}(jQuery); diff --git a/static/admin/lib/bootstrap.min.js b/static/admin/lib/bootstrap.min.js new file mode 100644 index 0000000..9bcd2fc --- /dev/null +++ b/static/admin/lib/bootstrap.min.js @@ -0,0 +1,7 @@ +/*! + * Bootstrap v3.3.7 (http://getbootstrap.com) + * Copyright 2011-2016 Twitter, Inc. + * Licensed under the MIT license + */ +if("undefined"==typeof jQuery)throw new Error("Bootstrap's JavaScript requires jQuery");+function(a){"use strict";var b=a.fn.jquery.split(" ")[0].split(".");if(b[0]<2&&b[1]<9||1==b[0]&&9==b[1]&&b[2]<1||b[0]>3)throw new Error("Bootstrap's JavaScript requires jQuery version 1.9.1 or higher, but lower than version 4")}(jQuery),+function(a){"use strict";function b(){var a=document.createElement("bootstrap"),b={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd otransitionend",transition:"transitionend"};for(var c in b)if(void 0!==a.style[c])return{end:b[c]};return!1}a.fn.emulateTransitionEnd=function(b){var c=!1,d=this;a(this).one("bsTransitionEnd",function(){c=!0});var e=function(){c||a(d).trigger(a.support.transition.end)};return setTimeout(e,b),this},a(function(){a.support.transition=b(),a.support.transition&&(a.event.special.bsTransitionEnd={bindType:a.support.transition.end,delegateType:a.support.transition.end,handle:function(b){if(a(b.target).is(this))return b.handleObj.handler.apply(this,arguments)}})})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var c=a(this),e=c.data("bs.alert");e||c.data("bs.alert",e=new d(this)),"string"==typeof b&&e[b].call(c)})}var c='[data-dismiss="alert"]',d=function(b){a(b).on("click",c,this.close)};d.VERSION="3.3.7",d.TRANSITION_DURATION=150,d.prototype.close=function(b){function c(){g.detach().trigger("closed.bs.alert").remove()}var e=a(this),f=e.attr("data-target");f||(f=e.attr("href"),f=f&&f.replace(/.*(?=#[^\s]*$)/,""));var g=a("#"===f?[]:f);b&&b.preventDefault(),g.length||(g=e.closest(".alert")),g.trigger(b=a.Event("close.bs.alert")),b.isDefaultPrevented()||(g.removeClass("in"),a.support.transition&&g.hasClass("fade")?g.one("bsTransitionEnd",c).emulateTransitionEnd(d.TRANSITION_DURATION):c())};var e=a.fn.alert;a.fn.alert=b,a.fn.alert.Constructor=d,a.fn.alert.noConflict=function(){return a.fn.alert=e,this},a(document).on("click.bs.alert.data-api",c,d.prototype.close)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.button"),f="object"==typeof b&&b;e||d.data("bs.button",e=new c(this,f)),"toggle"==b?e.toggle():b&&e.setState(b)})}var c=function(b,d){this.$element=a(b),this.options=a.extend({},c.DEFAULTS,d),this.isLoading=!1};c.VERSION="3.3.7",c.DEFAULTS={loadingText:"loading..."},c.prototype.setState=function(b){var c="disabled",d=this.$element,e=d.is("input")?"val":"html",f=d.data();b+="Text",null==f.resetText&&d.data("resetText",d[e]()),setTimeout(a.proxy(function(){d[e](null==f[b]?this.options[b]:f[b]),"loadingText"==b?(this.isLoading=!0,d.addClass(c).attr(c,c).prop(c,!0)):this.isLoading&&(this.isLoading=!1,d.removeClass(c).removeAttr(c).prop(c,!1))},this),0)},c.prototype.toggle=function(){var a=!0,b=this.$element.closest('[data-toggle="buttons"]');if(b.length){var c=this.$element.find("input");"radio"==c.prop("type")?(c.prop("checked")&&(a=!1),b.find(".active").removeClass("active"),this.$element.addClass("active")):"checkbox"==c.prop("type")&&(c.prop("checked")!==this.$element.hasClass("active")&&(a=!1),this.$element.toggleClass("active")),c.prop("checked",this.$element.hasClass("active")),a&&c.trigger("change")}else this.$element.attr("aria-pressed",!this.$element.hasClass("active")),this.$element.toggleClass("active")};var d=a.fn.button;a.fn.button=b,a.fn.button.Constructor=c,a.fn.button.noConflict=function(){return a.fn.button=d,this},a(document).on("click.bs.button.data-api",'[data-toggle^="button"]',function(c){var d=a(c.target).closest(".btn");b.call(d,"toggle"),a(c.target).is('input[type="radio"], input[type="checkbox"]')||(c.preventDefault(),d.is("input,button")?d.trigger("focus"):d.find("input:visible,button:visible").first().trigger("focus"))}).on("focus.bs.button.data-api blur.bs.button.data-api",'[data-toggle^="button"]',function(b){a(b.target).closest(".btn").toggleClass("focus",/^focus(in)?$/.test(b.type))})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.carousel"),f=a.extend({},c.DEFAULTS,d.data(),"object"==typeof b&&b),g="string"==typeof b?b:f.slide;e||d.data("bs.carousel",e=new c(this,f)),"number"==typeof b?e.to(b):g?e[g]():f.interval&&e.pause().cycle()})}var c=function(b,c){this.$element=a(b),this.$indicators=this.$element.find(".carousel-indicators"),this.options=c,this.paused=null,this.sliding=null,this.interval=null,this.$active=null,this.$items=null,this.options.keyboard&&this.$element.on("keydown.bs.carousel",a.proxy(this.keydown,this)),"hover"==this.options.pause&&!("ontouchstart"in document.documentElement)&&this.$element.on("mouseenter.bs.carousel",a.proxy(this.pause,this)).on("mouseleave.bs.carousel",a.proxy(this.cycle,this))};c.VERSION="3.3.7",c.TRANSITION_DURATION=600,c.DEFAULTS={interval:5e3,pause:"hover",wrap:!0,keyboard:!0},c.prototype.keydown=function(a){if(!/input|textarea/i.test(a.target.tagName)){switch(a.which){case 37:this.prev();break;case 39:this.next();break;default:return}a.preventDefault()}},c.prototype.cycle=function(b){return b||(this.paused=!1),this.interval&&clearInterval(this.interval),this.options.interval&&!this.paused&&(this.interval=setInterval(a.proxy(this.next,this),this.options.interval)),this},c.prototype.getItemIndex=function(a){return this.$items=a.parent().children(".item"),this.$items.index(a||this.$active)},c.prototype.getItemForDirection=function(a,b){var c=this.getItemIndex(b),d="prev"==a&&0===c||"next"==a&&c==this.$items.length-1;if(d&&!this.options.wrap)return b;var e="prev"==a?-1:1,f=(c+e)%this.$items.length;return this.$items.eq(f)},c.prototype.to=function(a){var b=this,c=this.getItemIndex(this.$active=this.$element.find(".item.active"));if(!(a>this.$items.length-1||a<0))return this.sliding?this.$element.one("slid.bs.carousel",function(){b.to(a)}):c==a?this.pause().cycle():this.slide(a>c?"next":"prev",this.$items.eq(a))},c.prototype.pause=function(b){return b||(this.paused=!0),this.$element.find(".next, .prev").length&&a.support.transition&&(this.$element.trigger(a.support.transition.end),this.cycle(!0)),this.interval=clearInterval(this.interval),this},c.prototype.next=function(){if(!this.sliding)return this.slide("next")},c.prototype.prev=function(){if(!this.sliding)return this.slide("prev")},c.prototype.slide=function(b,d){var e=this.$element.find(".item.active"),f=d||this.getItemForDirection(b,e),g=this.interval,h="next"==b?"left":"right",i=this;if(f.hasClass("active"))return this.sliding=!1;var j=f[0],k=a.Event("slide.bs.carousel",{relatedTarget:j,direction:h});if(this.$element.trigger(k),!k.isDefaultPrevented()){if(this.sliding=!0,g&&this.pause(),this.$indicators.length){this.$indicators.find(".active").removeClass("active");var l=a(this.$indicators.children()[this.getItemIndex(f)]);l&&l.addClass("active")}var m=a.Event("slid.bs.carousel",{relatedTarget:j,direction:h});return a.support.transition&&this.$element.hasClass("slide")?(f.addClass(b),f[0].offsetWidth,e.addClass(h),f.addClass(h),e.one("bsTransitionEnd",function(){f.removeClass([b,h].join(" ")).addClass("active"),e.removeClass(["active",h].join(" ")),i.sliding=!1,setTimeout(function(){i.$element.trigger(m)},0)}).emulateTransitionEnd(c.TRANSITION_DURATION)):(e.removeClass("active"),f.addClass("active"),this.sliding=!1,this.$element.trigger(m)),g&&this.cycle(),this}};var d=a.fn.carousel;a.fn.carousel=b,a.fn.carousel.Constructor=c,a.fn.carousel.noConflict=function(){return a.fn.carousel=d,this};var e=function(c){var d,e=a(this),f=a(e.attr("data-target")||(d=e.attr("href"))&&d.replace(/.*(?=#[^\s]+$)/,""));if(f.hasClass("carousel")){var g=a.extend({},f.data(),e.data()),h=e.attr("data-slide-to");h&&(g.interval=!1),b.call(f,g),h&&f.data("bs.carousel").to(h),c.preventDefault()}};a(document).on("click.bs.carousel.data-api","[data-slide]",e).on("click.bs.carousel.data-api","[data-slide-to]",e),a(window).on("load",function(){a('[data-ride="carousel"]').each(function(){var c=a(this);b.call(c,c.data())})})}(jQuery),+function(a){"use strict";function b(b){var c,d=b.attr("data-target")||(c=b.attr("href"))&&c.replace(/.*(?=#[^\s]+$)/,"");return a(d)}function c(b){return this.each(function(){var c=a(this),e=c.data("bs.collapse"),f=a.extend({},d.DEFAULTS,c.data(),"object"==typeof b&&b);!e&&f.toggle&&/show|hide/.test(b)&&(f.toggle=!1),e||c.data("bs.collapse",e=new d(this,f)),"string"==typeof b&&e[b]()})}var d=function(b,c){this.$element=a(b),this.options=a.extend({},d.DEFAULTS,c),this.$trigger=a('[data-toggle="collapse"][href="#'+b.id+'"],[data-toggle="collapse"][data-target="#'+b.id+'"]'),this.transitioning=null,this.options.parent?this.$parent=this.getParent():this.addAriaAndCollapsedClass(this.$element,this.$trigger),this.options.toggle&&this.toggle()};d.VERSION="3.3.7",d.TRANSITION_DURATION=350,d.DEFAULTS={toggle:!0},d.prototype.dimension=function(){var a=this.$element.hasClass("width");return a?"width":"height"},d.prototype.show=function(){if(!this.transitioning&&!this.$element.hasClass("in")){var b,e=this.$parent&&this.$parent.children(".panel").children(".in, .collapsing");if(!(e&&e.length&&(b=e.data("bs.collapse"),b&&b.transitioning))){var f=a.Event("show.bs.collapse");if(this.$element.trigger(f),!f.isDefaultPrevented()){e&&e.length&&(c.call(e,"hide"),b||e.data("bs.collapse",null));var g=this.dimension();this.$element.removeClass("collapse").addClass("collapsing")[g](0).attr("aria-expanded",!0),this.$trigger.removeClass("collapsed").attr("aria-expanded",!0),this.transitioning=1;var h=function(){this.$element.removeClass("collapsing").addClass("collapse in")[g](""),this.transitioning=0,this.$element.trigger("shown.bs.collapse")};if(!a.support.transition)return h.call(this);var i=a.camelCase(["scroll",g].join("-"));this.$element.one("bsTransitionEnd",a.proxy(h,this)).emulateTransitionEnd(d.TRANSITION_DURATION)[g](this.$element[0][i])}}}},d.prototype.hide=function(){if(!this.transitioning&&this.$element.hasClass("in")){var b=a.Event("hide.bs.collapse");if(this.$element.trigger(b),!b.isDefaultPrevented()){var c=this.dimension();this.$element[c](this.$element[c]())[0].offsetHeight,this.$element.addClass("collapsing").removeClass("collapse in").attr("aria-expanded",!1),this.$trigger.addClass("collapsed").attr("aria-expanded",!1),this.transitioning=1;var e=function(){this.transitioning=0,this.$element.removeClass("collapsing").addClass("collapse").trigger("hidden.bs.collapse")};return a.support.transition?void this.$element[c](0).one("bsTransitionEnd",a.proxy(e,this)).emulateTransitionEnd(d.TRANSITION_DURATION):e.call(this)}}},d.prototype.toggle=function(){this[this.$element.hasClass("in")?"hide":"show"]()},d.prototype.getParent=function(){return a(this.options.parent).find('[data-toggle="collapse"][data-parent="'+this.options.parent+'"]').each(a.proxy(function(c,d){var e=a(d);this.addAriaAndCollapsedClass(b(e),e)},this)).end()},d.prototype.addAriaAndCollapsedClass=function(a,b){var c=a.hasClass("in");a.attr("aria-expanded",c),b.toggleClass("collapsed",!c).attr("aria-expanded",c)};var e=a.fn.collapse;a.fn.collapse=c,a.fn.collapse.Constructor=d,a.fn.collapse.noConflict=function(){return a.fn.collapse=e,this},a(document).on("click.bs.collapse.data-api",'[data-toggle="collapse"]',function(d){var e=a(this);e.attr("data-target")||d.preventDefault();var f=b(e),g=f.data("bs.collapse"),h=g?"toggle":e.data();c.call(f,h)})}(jQuery),+function(a){"use strict";function b(b){var c=b.attr("data-target");c||(c=b.attr("href"),c=c&&/#[A-Za-z]/.test(c)&&c.replace(/.*(?=#[^\s]*$)/,""));var d=c&&a(c);return d&&d.length?d:b.parent()}function c(c){c&&3===c.which||(a(e).remove(),a(f).each(function(){var d=a(this),e=b(d),f={relatedTarget:this};e.hasClass("open")&&(c&&"click"==c.type&&/input|textarea/i.test(c.target.tagName)&&a.contains(e[0],c.target)||(e.trigger(c=a.Event("hide.bs.dropdown",f)),c.isDefaultPrevented()||(d.attr("aria-expanded","false"),e.removeClass("open").trigger(a.Event("hidden.bs.dropdown",f)))))}))}function d(b){return this.each(function(){var c=a(this),d=c.data("bs.dropdown");d||c.data("bs.dropdown",d=new g(this)),"string"==typeof b&&d[b].call(c)})}var e=".dropdown-backdrop",f='[data-toggle="dropdown"]',g=function(b){a(b).on("click.bs.dropdown",this.toggle)};g.VERSION="3.3.7",g.prototype.toggle=function(d){var e=a(this);if(!e.is(".disabled, :disabled")){var f=b(e),g=f.hasClass("open");if(c(),!g){"ontouchstart"in document.documentElement&&!f.closest(".navbar-nav").length&&a(document.createElement("div")).addClass("dropdown-backdrop").insertAfter(a(this)).on("click",c);var h={relatedTarget:this};if(f.trigger(d=a.Event("show.bs.dropdown",h)),d.isDefaultPrevented())return;e.trigger("focus").attr("aria-expanded","true"),f.toggleClass("open").trigger(a.Event("shown.bs.dropdown",h))}return!1}},g.prototype.keydown=function(c){if(/(38|40|27|32)/.test(c.which)&&!/input|textarea/i.test(c.target.tagName)){var d=a(this);if(c.preventDefault(),c.stopPropagation(),!d.is(".disabled, :disabled")){var e=b(d),g=e.hasClass("open");if(!g&&27!=c.which||g&&27==c.which)return 27==c.which&&e.find(f).trigger("focus"),d.trigger("click");var h=" li:not(.disabled):visible a",i=e.find(".dropdown-menu"+h);if(i.length){var j=i.index(c.target);38==c.which&&j>0&&j--,40==c.which&&jdocument.documentElement.clientHeight;this.$element.css({paddingLeft:!this.bodyIsOverflowing&&a?this.scrollbarWidth:"",paddingRight:this.bodyIsOverflowing&&!a?this.scrollbarWidth:""})},c.prototype.resetAdjustments=function(){this.$element.css({paddingLeft:"",paddingRight:""})},c.prototype.checkScrollbar=function(){var a=window.innerWidth;if(!a){var b=document.documentElement.getBoundingClientRect();a=b.right-Math.abs(b.left)}this.bodyIsOverflowing=document.body.clientWidth
',trigger:"hover focus",title:"",delay:0,html:!1,container:!1,viewport:{selector:"body",padding:0}},c.prototype.init=function(b,c,d){if(this.enabled=!0,this.type=b,this.$element=a(c),this.options=this.getOptions(d),this.$viewport=this.options.viewport&&a(a.isFunction(this.options.viewport)?this.options.viewport.call(this,this.$element):this.options.viewport.selector||this.options.viewport),this.inState={click:!1,hover:!1,focus:!1},this.$element[0]instanceof document.constructor&&!this.options.selector)throw new Error("`selector` option must be specified when initializing "+this.type+" on the window.document object!");for(var e=this.options.trigger.split(" "),f=e.length;f--;){var g=e[f];if("click"==g)this.$element.on("click."+this.type,this.options.selector,a.proxy(this.toggle,this));else if("manual"!=g){var h="hover"==g?"mouseenter":"focusin",i="hover"==g?"mouseleave":"focusout";this.$element.on(h+"."+this.type,this.options.selector,a.proxy(this.enter,this)),this.$element.on(i+"."+this.type,this.options.selector,a.proxy(this.leave,this))}}this.options.selector?this._options=a.extend({},this.options,{trigger:"manual",selector:""}):this.fixTitle()},c.prototype.getDefaults=function(){return c.DEFAULTS},c.prototype.getOptions=function(b){return b=a.extend({},this.getDefaults(),this.$element.data(),b),b.delay&&"number"==typeof b.delay&&(b.delay={show:b.delay,hide:b.delay}),b},c.prototype.getDelegateOptions=function(){var b={},c=this.getDefaults();return this._options&&a.each(this._options,function(a,d){c[a]!=d&&(b[a]=d)}),b},c.prototype.enter=function(b){var c=b instanceof this.constructor?b:a(b.currentTarget).data("bs."+this.type);return c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c)),b instanceof a.Event&&(c.inState["focusin"==b.type?"focus":"hover"]=!0),c.tip().hasClass("in")||"in"==c.hoverState?void(c.hoverState="in"):(clearTimeout(c.timeout),c.hoverState="in",c.options.delay&&c.options.delay.show?void(c.timeout=setTimeout(function(){"in"==c.hoverState&&c.show()},c.options.delay.show)):c.show())},c.prototype.isInStateTrue=function(){for(var a in this.inState)if(this.inState[a])return!0;return!1},c.prototype.leave=function(b){var c=b instanceof this.constructor?b:a(b.currentTarget).data("bs."+this.type);if(c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c)),b instanceof a.Event&&(c.inState["focusout"==b.type?"focus":"hover"]=!1),!c.isInStateTrue())return clearTimeout(c.timeout),c.hoverState="out",c.options.delay&&c.options.delay.hide?void(c.timeout=setTimeout(function(){"out"==c.hoverState&&c.hide()},c.options.delay.hide)):c.hide()},c.prototype.show=function(){var b=a.Event("show.bs."+this.type);if(this.hasContent()&&this.enabled){this.$element.trigger(b);var d=a.contains(this.$element[0].ownerDocument.documentElement,this.$element[0]);if(b.isDefaultPrevented()||!d)return;var e=this,f=this.tip(),g=this.getUID(this.type);this.setContent(),f.attr("id",g),this.$element.attr("aria-describedby",g),this.options.animation&&f.addClass("fade");var h="function"==typeof this.options.placement?this.options.placement.call(this,f[0],this.$element[0]):this.options.placement,i=/\s?auto?\s?/i,j=i.test(h);j&&(h=h.replace(i,"")||"top"),f.detach().css({top:0,left:0,display:"block"}).addClass(h).data("bs."+this.type,this),this.options.container?f.appendTo(this.options.container):f.insertAfter(this.$element),this.$element.trigger("inserted.bs."+this.type);var k=this.getPosition(),l=f[0].offsetWidth,m=f[0].offsetHeight;if(j){var n=h,o=this.getPosition(this.$viewport);h="bottom"==h&&k.bottom+m>o.bottom?"top":"top"==h&&k.top-mo.width?"left":"left"==h&&k.left-lg.top+g.height&&(e.top=g.top+g.height-i)}else{var j=b.left-f,k=b.left+f+c;jg.right&&(e.left=g.left+g.width-k)}return e},c.prototype.getTitle=function(){var a,b=this.$element,c=this.options;return a=b.attr("data-original-title")||("function"==typeof c.title?c.title.call(b[0]):c.title)},c.prototype.getUID=function(a){do a+=~~(1e6*Math.random());while(document.getElementById(a));return a},c.prototype.tip=function(){if(!this.$tip&&(this.$tip=a(this.options.template),1!=this.$tip.length))throw new Error(this.type+" `template` option must consist of exactly 1 top-level element!");return this.$tip},c.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".tooltip-arrow")},c.prototype.enable=function(){this.enabled=!0},c.prototype.disable=function(){this.enabled=!1},c.prototype.toggleEnabled=function(){this.enabled=!this.enabled},c.prototype.toggle=function(b){var c=this;b&&(c=a(b.currentTarget).data("bs."+this.type),c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c))),b?(c.inState.click=!c.inState.click,c.isInStateTrue()?c.enter(c):c.leave(c)):c.tip().hasClass("in")?c.leave(c):c.enter(c)},c.prototype.destroy=function(){var a=this;clearTimeout(this.timeout),this.hide(function(){a.$element.off("."+a.type).removeData("bs."+a.type),a.$tip&&a.$tip.detach(),a.$tip=null,a.$arrow=null,a.$viewport=null,a.$element=null})};var d=a.fn.tooltip;a.fn.tooltip=b,a.fn.tooltip.Constructor=c,a.fn.tooltip.noConflict=function(){return a.fn.tooltip=d,this}}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.popover"),f="object"==typeof b&&b;!e&&/destroy|hide/.test(b)||(e||d.data("bs.popover",e=new c(this,f)),"string"==typeof b&&e[b]())})}var c=function(a,b){this.init("popover",a,b)};if(!a.fn.tooltip)throw new Error("Popover requires tooltip.js");c.VERSION="3.3.7",c.DEFAULTS=a.extend({},a.fn.tooltip.Constructor.DEFAULTS,{placement:"right",trigger:"click",content:"",template:''}),c.prototype=a.extend({},a.fn.tooltip.Constructor.prototype),c.prototype.constructor=c,c.prototype.getDefaults=function(){return c.DEFAULTS},c.prototype.setContent=function(){var a=this.tip(),b=this.getTitle(),c=this.getContent();a.find(".popover-title")[this.options.html?"html":"text"](b),a.find(".popover-content").children().detach().end()[this.options.html?"string"==typeof c?"html":"append":"text"](c),a.removeClass("fade top bottom left right in"),a.find(".popover-title").html()||a.find(".popover-title").hide()},c.prototype.hasContent=function(){return this.getTitle()||this.getContent()},c.prototype.getContent=function(){var a=this.$element,b=this.options;return a.attr("data-content")||("function"==typeof b.content?b.content.call(a[0]):b.content)},c.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".arrow")};var d=a.fn.popover;a.fn.popover=b,a.fn.popover.Constructor=c,a.fn.popover.noConflict=function(){return a.fn.popover=d,this}}(jQuery),+function(a){"use strict";function b(c,d){this.$body=a(document.body),this.$scrollElement=a(a(c).is(document.body)?window:c),this.options=a.extend({},b.DEFAULTS,d),this.selector=(this.options.target||"")+" .nav li > a",this.offsets=[],this.targets=[],this.activeTarget=null,this.scrollHeight=0,this.$scrollElement.on("scroll.bs.scrollspy",a.proxy(this.process,this)),this.refresh(),this.process()}function c(c){return this.each(function(){var d=a(this),e=d.data("bs.scrollspy"),f="object"==typeof c&&c;e||d.data("bs.scrollspy",e=new b(this,f)),"string"==typeof c&&e[c]()})}b.VERSION="3.3.7",b.DEFAULTS={offset:10},b.prototype.getScrollHeight=function(){return this.$scrollElement[0].scrollHeight||Math.max(this.$body[0].scrollHeight,document.documentElement.scrollHeight)},b.prototype.refresh=function(){var b=this,c="offset",d=0;this.offsets=[],this.targets=[],this.scrollHeight=this.getScrollHeight(),a.isWindow(this.$scrollElement[0])||(c="position",d=this.$scrollElement.scrollTop()),this.$body.find(this.selector).map(function(){var b=a(this),e=b.data("target")||b.attr("href"),f=/^#./.test(e)&&a(e);return f&&f.length&&f.is(":visible")&&[[f[c]().top+d,e]]||null}).sort(function(a,b){return a[0]-b[0]}).each(function(){b.offsets.push(this[0]),b.targets.push(this[1])})},b.prototype.process=function(){var a,b=this.$scrollElement.scrollTop()+this.options.offset,c=this.getScrollHeight(),d=this.options.offset+c-this.$scrollElement.height(),e=this.offsets,f=this.targets,g=this.activeTarget;if(this.scrollHeight!=c&&this.refresh(),b>=d)return g!=(a=f[f.length-1])&&this.activate(a);if(g&&b=e[a]&&(void 0===e[a+1]||b .dropdown-menu > .active").removeClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!1),b.addClass("active").find('[data-toggle="tab"]').attr("aria-expanded",!0),h?(b[0].offsetWidth,b.addClass("in")):b.removeClass("fade"),b.parent(".dropdown-menu").length&&b.closest("li.dropdown").addClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!0),e&&e()}var g=d.find("> .active"),h=e&&a.support.transition&&(g.length&&g.hasClass("fade")||!!d.find("> .fade").length);g.length&&h?g.one("bsTransitionEnd",f).emulateTransitionEnd(c.TRANSITION_DURATION):f(),g.removeClass("in")};var d=a.fn.tab;a.fn.tab=b,a.fn.tab.Constructor=c,a.fn.tab.noConflict=function(){return a.fn.tab=d,this};var e=function(c){c.preventDefault(),b.call(a(this),"show")};a(document).on("click.bs.tab.data-api",'[data-toggle="tab"]',e).on("click.bs.tab.data-api",'[data-toggle="pill"]',e)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.affix"),f="object"==typeof b&&b;e||d.data("bs.affix",e=new c(this,f)),"string"==typeof b&&e[b]()})}var c=function(b,d){this.options=a.extend({},c.DEFAULTS,d),this.$target=a(this.options.target).on("scroll.bs.affix.data-api",a.proxy(this.checkPosition,this)).on("click.bs.affix.data-api",a.proxy(this.checkPositionWithEventLoop,this)),this.$element=a(b),this.affixed=null,this.unpin=null,this.pinnedOffset=null,this.checkPosition()};c.VERSION="3.3.7",c.RESET="affix affix-top affix-bottom",c.DEFAULTS={offset:0,target:window},c.prototype.getState=function(a,b,c,d){var e=this.$target.scrollTop(),f=this.$element.offset(),g=this.$target.height();if(null!=c&&"top"==this.affixed)return e=a-d&&"bottom"},c.prototype.getPinnedOffset=function(){if(this.pinnedOffset)return this.pinnedOffset;this.$element.removeClass(c.RESET).addClass("affix");var a=this.$target.scrollTop(),b=this.$element.offset();return this.pinnedOffset=b.top-a},c.prototype.checkPositionWithEventLoop=function(){setTimeout(a.proxy(this.checkPosition,this),1)},c.prototype.checkPosition=function(){if(this.$element.is(":visible")){var b=this.$element.height(),d=this.options.offset,e=d.top,f=d.bottom,g=Math.max(a(document).height(),a(document.body).height());"object"!=typeof d&&(f=e=d),"function"==typeof e&&(e=d.top(this.$element)),"function"==typeof f&&(f=d.bottom(this.$element));var h=this.getState(g,b,e,f);if(this.affixed!=h){null!=this.unpin&&this.$element.css("top","");var i="affix"+(h?"-"+h:""),j=a.Event(i+".bs.affix");if(this.$element.trigger(j),j.isDefaultPrevented())return;this.affixed=h,this.unpin="bottom"==h?this.getPinnedOffset():null,this.$element.removeClass(c.RESET).addClass(i).trigger(i.replace("affix","affixed")+".bs.affix")}"bottom"==h&&this.$element.offset({top:g-b-f})}};var d=a.fn.affix;a.fn.affix=b,a.fn.affix.Constructor=c,a.fn.affix.noConflict=function(){return a.fn.affix=d,this},a(window).on("load",function(){a('[data-spy="affix"]').each(function(){var c=a(this),d=c.data();d.offset=d.offset||{},null!=d.offsetBottom&&(d.offset.bottom=d.offsetBottom),null!=d.offsetTop&&(d.offset.top=d.offsetTop),b.call(c,d)})})}(jQuery); \ No newline at end of file diff --git a/static/admin/lib/common-script.js b/static/admin/lib/common-script.js new file mode 100644 index 0000000..914d3bc --- /dev/null +++ b/static/admin/lib/common-script.js @@ -0,0 +1,51 @@ +lib_cmn = { + // Utils functions + hasProperty: function(obj, propName) { + /* Checks if an object has a property with given name */ + if ( (obj == null) || (!propName) ) + return false; + else if (obj.hasOwnProperty('propName') || propName in obj) + return true; + else + return false; + }, + + // Go to default page + goToDefaultPage: function() { + const baseUri = conf['adminTool']['baseUri']; + sessionStorage.setItem('activeTab', '#link-pairing'); + window.location = baseUri + '/tool/'; + }, + + // Go to home page + goToHomePage: function() { + sessionStorage.setItem('activeTab', null); + window.location = conf['adminTool']['baseUri'] + '/'; + }, + + // Loads html snippet + includeHTML: function(cb) { + let self = this; + let z, i, elmnt, file, xhttp; + z = document.getElementsByTagName('*'); + for (i = 0; i < z.length; i++) { + elmnt = z[i]; + file = elmnt.getAttribute('include-html'); + if (file) { + xhttp = new XMLHttpRequest(); + xhttp.onreadystatechange = function() { + if (this.readyState == 4 && this.status == 200) { + elmnt.innerHTML = this.responseText; + elmnt.removeAttribute('include-html'); + self.includeHTML(cb); + } + } + xhttp.open('GET', file, true); + xhttp.send(); + return; + } + } + if (cb) cb(); + } + +} diff --git a/static/admin/lib/format-utils.js b/static/admin/lib/format-utils.js new file mode 100644 index 0000000..af66ea4 --- /dev/null +++ b/static/admin/lib/format-utils.js @@ -0,0 +1,62 @@ +lib_fmt = { + + /* + * Returns a stringified version of a cleaned json object + */ + cleanJson: function(json) { + let jsonText = JSON.stringify(json); + jsonText = jsonText.replace(/'/g, '"').replace(/False/g, 'false').replace(/True/g, 'true'); + jsonText = jsonText.replace(/(Decimal\(")([0-9.E\-,]*)("\))/g, '"$2"'); + return jsonText; + }, + + /* + * Highlight syntax of json data + */ + jsonSyntaxHighlight: function(json) { + if (typeof json != 'string') { + json = JSON.stringify(json, undefined, 2); + } + + json = json.replace(/&/g, '&').replace(//g, '>'); + + return json.replace( + /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, + function (match) { + let cls = 'number'; + if (/^"/.test(match)) { + if (/:$/.test(match)) { + cls = 'key'; + } else { + cls = 'string'; + } + } else if (/true|false/.test(match)) { + cls = 'boolean'; + } else if (/null/.test(match)) { + cls = 'null'; + } + return '' + match + ''; + } + ); + }, + + /* + * Format a unix timestamp to locale date string + */ + unixTsToLocaleString: function(ts) { + let tmpDate = new Date(ts*1000); + return tmpDate.toLocaleString(); + }, + + /* + * Format a unix timestamp into a readable date/hour + */ + formatUnixTs: function(ts) { + if (ts == null || ts == 0) + return '-'; + + let tmpDate = new Date(ts*1000), + options = {hour: '2-digit', minute: '2-digit', hour12: false}; + return tmpDate.toLocaleDateString('fr-FR', options); + } +} diff --git a/static/admin/lib/jquery-3.2.1.min.js b/static/admin/lib/jquery-3.2.1.min.js new file mode 100644 index 0000000..644d35e --- /dev/null +++ b/static/admin/lib/jquery-3.2.1.min.js @@ -0,0 +1,4 @@ +/*! jQuery v3.2.1 | (c) JS Foundation and other contributors | jquery.org/license */ +!function(a,b){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=a.document?b(a,!0):function(a){if(!a.document)throw new Error("jQuery requires a window with a document");return b(a)}:b(a)}("undefined"!=typeof window?window:this,function(a,b){"use strict";var c=[],d=a.document,e=Object.getPrototypeOf,f=c.slice,g=c.concat,h=c.push,i=c.indexOf,j={},k=j.toString,l=j.hasOwnProperty,m=l.toString,n=m.call(Object),o={};function p(a,b){b=b||d;var c=b.createElement("script");c.text=a,b.head.appendChild(c).parentNode.removeChild(c)}var q="3.2.1",r=function(a,b){return new r.fn.init(a,b)},s=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,t=/^-ms-/,u=/-([a-z])/g,v=function(a,b){return b.toUpperCase()};r.fn=r.prototype={jquery:q,constructor:r,length:0,toArray:function(){return f.call(this)},get:function(a){return null==a?f.call(this):a<0?this[a+this.length]:this[a]},pushStack:function(a){var b=r.merge(this.constructor(),a);return b.prevObject=this,b},each:function(a){return r.each(this,a)},map:function(a){return this.pushStack(r.map(this,function(b,c){return a.call(b,c,b)}))},slice:function(){return this.pushStack(f.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(a){var b=this.length,c=+a+(a<0?b:0);return this.pushStack(c>=0&&c0&&b-1 in a)}var x=function(a){var b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u="sizzle"+1*new Date,v=a.document,w=0,x=0,y=ha(),z=ha(),A=ha(),B=function(a,b){return a===b&&(l=!0),0},C={}.hasOwnProperty,D=[],E=D.pop,F=D.push,G=D.push,H=D.slice,I=function(a,b){for(var c=0,d=a.length;c+~]|"+K+")"+K+"*"),S=new RegExp("="+K+"*([^\\]'\"]*?)"+K+"*\\]","g"),T=new RegExp(N),U=new RegExp("^"+L+"$"),V={ID:new RegExp("^#("+L+")"),CLASS:new RegExp("^\\.("+L+")"),TAG:new RegExp("^("+L+"|[*])"),ATTR:new RegExp("^"+M),PSEUDO:new RegExp("^"+N),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+K+"*(even|odd|(([+-]|)(\\d*)n|)"+K+"*(?:([+-]|)"+K+"*(\\d+)|))"+K+"*\\)|)","i"),bool:new RegExp("^(?:"+J+")$","i"),needsContext:new RegExp("^"+K+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+K+"*((?:-\\d)?\\d*)"+K+"*\\)|)(?=[^-]|$)","i")},W=/^(?:input|select|textarea|button)$/i,X=/^h\d$/i,Y=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,$=/[+~]/,_=new RegExp("\\\\([\\da-f]{1,6}"+K+"?|("+K+")|.)","ig"),aa=function(a,b,c){var d="0x"+b-65536;return d!==d||c?b:d<0?String.fromCharCode(d+65536):String.fromCharCode(d>>10|55296,1023&d|56320)},ba=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ca=function(a,b){return b?"\0"===a?"\ufffd":a.slice(0,-1)+"\\"+a.charCodeAt(a.length-1).toString(16)+" ":"\\"+a},da=function(){m()},ea=ta(function(a){return a.disabled===!0&&("form"in a||"label"in a)},{dir:"parentNode",next:"legend"});try{G.apply(D=H.call(v.childNodes),v.childNodes),D[v.childNodes.length].nodeType}catch(fa){G={apply:D.length?function(a,b){F.apply(a,H.call(b))}:function(a,b){var c=a.length,d=0;while(a[c++]=b[d++]);a.length=c-1}}}function ga(a,b,d,e){var f,h,j,k,l,o,r,s=b&&b.ownerDocument,w=b?b.nodeType:9;if(d=d||[],"string"!=typeof a||!a||1!==w&&9!==w&&11!==w)return d;if(!e&&((b?b.ownerDocument||b:v)!==n&&m(b),b=b||n,p)){if(11!==w&&(l=Z.exec(a)))if(f=l[1]){if(9===w){if(!(j=b.getElementById(f)))return d;if(j.id===f)return d.push(j),d}else if(s&&(j=s.getElementById(f))&&t(b,j)&&j.id===f)return d.push(j),d}else{if(l[2])return G.apply(d,b.getElementsByTagName(a)),d;if((f=l[3])&&c.getElementsByClassName&&b.getElementsByClassName)return G.apply(d,b.getElementsByClassName(f)),d}if(c.qsa&&!A[a+" "]&&(!q||!q.test(a))){if(1!==w)s=b,r=a;else if("object"!==b.nodeName.toLowerCase()){(k=b.getAttribute("id"))?k=k.replace(ba,ca):b.setAttribute("id",k=u),o=g(a),h=o.length;while(h--)o[h]="#"+k+" "+sa(o[h]);r=o.join(","),s=$.test(a)&&qa(b.parentNode)||b}if(r)try{return G.apply(d,s.querySelectorAll(r)),d}catch(x){}finally{k===u&&b.removeAttribute("id")}}}return i(a.replace(P,"$1"),b,d,e)}function ha(){var a=[];function b(c,e){return a.push(c+" ")>d.cacheLength&&delete b[a.shift()],b[c+" "]=e}return b}function ia(a){return a[u]=!0,a}function ja(a){var b=n.createElement("fieldset");try{return!!a(b)}catch(c){return!1}finally{b.parentNode&&b.parentNode.removeChild(b),b=null}}function ka(a,b){var c=a.split("|"),e=c.length;while(e--)d.attrHandle[c[e]]=b}function la(a,b){var c=b&&a,d=c&&1===a.nodeType&&1===b.nodeType&&a.sourceIndex-b.sourceIndex;if(d)return d;if(c)while(c=c.nextSibling)if(c===b)return-1;return a?1:-1}function ma(a){return function(b){var c=b.nodeName.toLowerCase();return"input"===c&&b.type===a}}function na(a){return function(b){var c=b.nodeName.toLowerCase();return("input"===c||"button"===c)&&b.type===a}}function oa(a){return function(b){return"form"in b?b.parentNode&&b.disabled===!1?"label"in b?"label"in b.parentNode?b.parentNode.disabled===a:b.disabled===a:b.isDisabled===a||b.isDisabled!==!a&&ea(b)===a:b.disabled===a:"label"in b&&b.disabled===a}}function pa(a){return ia(function(b){return b=+b,ia(function(c,d){var e,f=a([],c.length,b),g=f.length;while(g--)c[e=f[g]]&&(c[e]=!(d[e]=c[e]))})})}function qa(a){return a&&"undefined"!=typeof a.getElementsByTagName&&a}c=ga.support={},f=ga.isXML=function(a){var b=a&&(a.ownerDocument||a).documentElement;return!!b&&"HTML"!==b.nodeName},m=ga.setDocument=function(a){var b,e,g=a?a.ownerDocument||a:v;return g!==n&&9===g.nodeType&&g.documentElement?(n=g,o=n.documentElement,p=!f(n),v!==n&&(e=n.defaultView)&&e.top!==e&&(e.addEventListener?e.addEventListener("unload",da,!1):e.attachEvent&&e.attachEvent("onunload",da)),c.attributes=ja(function(a){return a.className="i",!a.getAttribute("className")}),c.getElementsByTagName=ja(function(a){return a.appendChild(n.createComment("")),!a.getElementsByTagName("*").length}),c.getElementsByClassName=Y.test(n.getElementsByClassName),c.getById=ja(function(a){return o.appendChild(a).id=u,!n.getElementsByName||!n.getElementsByName(u).length}),c.getById?(d.filter.ID=function(a){var b=a.replace(_,aa);return function(a){return a.getAttribute("id")===b}},d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c=b.getElementById(a);return c?[c]:[]}}):(d.filter.ID=function(a){var b=a.replace(_,aa);return function(a){var c="undefined"!=typeof a.getAttributeNode&&a.getAttributeNode("id");return c&&c.value===b}},d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c,d,e,f=b.getElementById(a);if(f){if(c=f.getAttributeNode("id"),c&&c.value===a)return[f];e=b.getElementsByName(a),d=0;while(f=e[d++])if(c=f.getAttributeNode("id"),c&&c.value===a)return[f]}return[]}}),d.find.TAG=c.getElementsByTagName?function(a,b){return"undefined"!=typeof b.getElementsByTagName?b.getElementsByTagName(a):c.qsa?b.querySelectorAll(a):void 0}:function(a,b){var c,d=[],e=0,f=b.getElementsByTagName(a);if("*"===a){while(c=f[e++])1===c.nodeType&&d.push(c);return d}return f},d.find.CLASS=c.getElementsByClassName&&function(a,b){if("undefined"!=typeof b.getElementsByClassName&&p)return b.getElementsByClassName(a)},r=[],q=[],(c.qsa=Y.test(n.querySelectorAll))&&(ja(function(a){o.appendChild(a).innerHTML="",a.querySelectorAll("[msallowcapture^='']").length&&q.push("[*^$]="+K+"*(?:''|\"\")"),a.querySelectorAll("[selected]").length||q.push("\\["+K+"*(?:value|"+J+")"),a.querySelectorAll("[id~="+u+"-]").length||q.push("~="),a.querySelectorAll(":checked").length||q.push(":checked"),a.querySelectorAll("a#"+u+"+*").length||q.push(".#.+[+~]")}),ja(function(a){a.innerHTML="";var b=n.createElement("input");b.setAttribute("type","hidden"),a.appendChild(b).setAttribute("name","D"),a.querySelectorAll("[name=d]").length&&q.push("name"+K+"*[*^$|!~]?="),2!==a.querySelectorAll(":enabled").length&&q.push(":enabled",":disabled"),o.appendChild(a).disabled=!0,2!==a.querySelectorAll(":disabled").length&&q.push(":enabled",":disabled"),a.querySelectorAll("*,:x"),q.push(",.*:")})),(c.matchesSelector=Y.test(s=o.matches||o.webkitMatchesSelector||o.mozMatchesSelector||o.oMatchesSelector||o.msMatchesSelector))&&ja(function(a){c.disconnectedMatch=s.call(a,"*"),s.call(a,"[s!='']:x"),r.push("!=",N)}),q=q.length&&new RegExp(q.join("|")),r=r.length&&new RegExp(r.join("|")),b=Y.test(o.compareDocumentPosition),t=b||Y.test(o.contains)?function(a,b){var c=9===a.nodeType?a.documentElement:a,d=b&&b.parentNode;return a===d||!(!d||1!==d.nodeType||!(c.contains?c.contains(d):a.compareDocumentPosition&&16&a.compareDocumentPosition(d)))}:function(a,b){if(b)while(b=b.parentNode)if(b===a)return!0;return!1},B=b?function(a,b){if(a===b)return l=!0,0;var d=!a.compareDocumentPosition-!b.compareDocumentPosition;return d?d:(d=(a.ownerDocument||a)===(b.ownerDocument||b)?a.compareDocumentPosition(b):1,1&d||!c.sortDetached&&b.compareDocumentPosition(a)===d?a===n||a.ownerDocument===v&&t(v,a)?-1:b===n||b.ownerDocument===v&&t(v,b)?1:k?I(k,a)-I(k,b):0:4&d?-1:1)}:function(a,b){if(a===b)return l=!0,0;var c,d=0,e=a.parentNode,f=b.parentNode,g=[a],h=[b];if(!e||!f)return a===n?-1:b===n?1:e?-1:f?1:k?I(k,a)-I(k,b):0;if(e===f)return la(a,b);c=a;while(c=c.parentNode)g.unshift(c);c=b;while(c=c.parentNode)h.unshift(c);while(g[d]===h[d])d++;return d?la(g[d],h[d]):g[d]===v?-1:h[d]===v?1:0},n):n},ga.matches=function(a,b){return ga(a,null,null,b)},ga.matchesSelector=function(a,b){if((a.ownerDocument||a)!==n&&m(a),b=b.replace(S,"='$1']"),c.matchesSelector&&p&&!A[b+" "]&&(!r||!r.test(b))&&(!q||!q.test(b)))try{var d=s.call(a,b);if(d||c.disconnectedMatch||a.document&&11!==a.document.nodeType)return d}catch(e){}return ga(b,n,null,[a]).length>0},ga.contains=function(a,b){return(a.ownerDocument||a)!==n&&m(a),t(a,b)},ga.attr=function(a,b){(a.ownerDocument||a)!==n&&m(a);var e=d.attrHandle[b.toLowerCase()],f=e&&C.call(d.attrHandle,b.toLowerCase())?e(a,b,!p):void 0;return void 0!==f?f:c.attributes||!p?a.getAttribute(b):(f=a.getAttributeNode(b))&&f.specified?f.value:null},ga.escape=function(a){return(a+"").replace(ba,ca)},ga.error=function(a){throw new Error("Syntax error, unrecognized expression: "+a)},ga.uniqueSort=function(a){var b,d=[],e=0,f=0;if(l=!c.detectDuplicates,k=!c.sortStable&&a.slice(0),a.sort(B),l){while(b=a[f++])b===a[f]&&(e=d.push(f));while(e--)a.splice(d[e],1)}return k=null,a},e=ga.getText=function(a){var b,c="",d=0,f=a.nodeType;if(f){if(1===f||9===f||11===f){if("string"==typeof a.textContent)return a.textContent;for(a=a.firstChild;a;a=a.nextSibling)c+=e(a)}else if(3===f||4===f)return a.nodeValue}else while(b=a[d++])c+=e(b);return c},d=ga.selectors={cacheLength:50,createPseudo:ia,match:V,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(_,aa),a[3]=(a[3]||a[4]||a[5]||"").replace(_,aa),"~="===a[2]&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),"nth"===a[1].slice(0,3)?(a[3]||ga.error(a[0]),a[4]=+(a[4]?a[5]+(a[6]||1):2*("even"===a[3]||"odd"===a[3])),a[5]=+(a[7]+a[8]||"odd"===a[3])):a[3]&&ga.error(a[0]),a},PSEUDO:function(a){var b,c=!a[6]&&a[2];return V.CHILD.test(a[0])?null:(a[3]?a[2]=a[4]||a[5]||"":c&&T.test(c)&&(b=g(c,!0))&&(b=c.indexOf(")",c.length-b)-c.length)&&(a[0]=a[0].slice(0,b),a[2]=c.slice(0,b)),a.slice(0,3))}},filter:{TAG:function(a){var b=a.replace(_,aa).toLowerCase();return"*"===a?function(){return!0}:function(a){return a.nodeName&&a.nodeName.toLowerCase()===b}},CLASS:function(a){var b=y[a+" "];return b||(b=new RegExp("(^|"+K+")"+a+"("+K+"|$)"))&&y(a,function(a){return b.test("string"==typeof a.className&&a.className||"undefined"!=typeof a.getAttribute&&a.getAttribute("class")||"")})},ATTR:function(a,b,c){return function(d){var e=ga.attr(d,a);return null==e?"!="===b:!b||(e+="","="===b?e===c:"!="===b?e!==c:"^="===b?c&&0===e.indexOf(c):"*="===b?c&&e.indexOf(c)>-1:"$="===b?c&&e.slice(-c.length)===c:"~="===b?(" "+e.replace(O," ")+" ").indexOf(c)>-1:"|="===b&&(e===c||e.slice(0,c.length+1)===c+"-"))}},CHILD:function(a,b,c,d,e){var f="nth"!==a.slice(0,3),g="last"!==a.slice(-4),h="of-type"===b;return 1===d&&0===e?function(a){return!!a.parentNode}:function(b,c,i){var j,k,l,m,n,o,p=f!==g?"nextSibling":"previousSibling",q=b.parentNode,r=h&&b.nodeName.toLowerCase(),s=!i&&!h,t=!1;if(q){if(f){while(p){m=b;while(m=m[p])if(h?m.nodeName.toLowerCase()===r:1===m.nodeType)return!1;o=p="only"===a&&!o&&"nextSibling"}return!0}if(o=[g?q.firstChild:q.lastChild],g&&s){m=q,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n&&j[2],m=n&&q.childNodes[n];while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if(1===m.nodeType&&++t&&m===b){k[a]=[w,n,t];break}}else if(s&&(m=b,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n),t===!1)while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if((h?m.nodeName.toLowerCase()===r:1===m.nodeType)&&++t&&(s&&(l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),k[a]=[w,t]),m===b))break;return t-=e,t===d||t%d===0&&t/d>=0}}},PSEUDO:function(a,b){var c,e=d.pseudos[a]||d.setFilters[a.toLowerCase()]||ga.error("unsupported pseudo: "+a);return e[u]?e(b):e.length>1?(c=[a,a,"",b],d.setFilters.hasOwnProperty(a.toLowerCase())?ia(function(a,c){var d,f=e(a,b),g=f.length;while(g--)d=I(a,f[g]),a[d]=!(c[d]=f[g])}):function(a){return e(a,0,c)}):e}},pseudos:{not:ia(function(a){var b=[],c=[],d=h(a.replace(P,"$1"));return d[u]?ia(function(a,b,c,e){var f,g=d(a,null,e,[]),h=a.length;while(h--)(f=g[h])&&(a[h]=!(b[h]=f))}):function(a,e,f){return b[0]=a,d(b,null,f,c),b[0]=null,!c.pop()}}),has:ia(function(a){return function(b){return ga(a,b).length>0}}),contains:ia(function(a){return a=a.replace(_,aa),function(b){return(b.textContent||b.innerText||e(b)).indexOf(a)>-1}}),lang:ia(function(a){return U.test(a||"")||ga.error("unsupported lang: "+a),a=a.replace(_,aa).toLowerCase(),function(b){var c;do if(c=p?b.lang:b.getAttribute("xml:lang")||b.getAttribute("lang"))return c=c.toLowerCase(),c===a||0===c.indexOf(a+"-");while((b=b.parentNode)&&1===b.nodeType);return!1}}),target:function(b){var c=a.location&&a.location.hash;return c&&c.slice(1)===b.id},root:function(a){return a===o},focus:function(a){return a===n.activeElement&&(!n.hasFocus||n.hasFocus())&&!!(a.type||a.href||~a.tabIndex)},enabled:oa(!1),disabled:oa(!0),checked:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&!!a.checked||"option"===b&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},empty:function(a){for(a=a.firstChild;a;a=a.nextSibling)if(a.nodeType<6)return!1;return!0},parent:function(a){return!d.pseudos.empty(a)},header:function(a){return X.test(a.nodeName)},input:function(a){return W.test(a.nodeName)},button:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&"button"===a.type||"button"===b},text:function(a){var b;return"input"===a.nodeName.toLowerCase()&&"text"===a.type&&(null==(b=a.getAttribute("type"))||"text"===b.toLowerCase())},first:pa(function(){return[0]}),last:pa(function(a,b){return[b-1]}),eq:pa(function(a,b,c){return[c<0?c+b:c]}),even:pa(function(a,b){for(var c=0;c=0;)a.push(d);return a}),gt:pa(function(a,b,c){for(var d=c<0?c+b:c;++d1?function(b,c,d){var e=a.length;while(e--)if(!a[e](b,c,d))return!1;return!0}:a[0]}function va(a,b,c){for(var d=0,e=b.length;d-1&&(f[j]=!(g[j]=l))}}else r=wa(r===g?r.splice(o,r.length):r),e?e(null,g,r,i):G.apply(g,r)})}function ya(a){for(var b,c,e,f=a.length,g=d.relative[a[0].type],h=g||d.relative[" "],i=g?1:0,k=ta(function(a){return a===b},h,!0),l=ta(function(a){return I(b,a)>-1},h,!0),m=[function(a,c,d){var e=!g&&(d||c!==j)||((b=c).nodeType?k(a,c,d):l(a,c,d));return b=null,e}];i1&&ua(m),i>1&&sa(a.slice(0,i-1).concat({value:" "===a[i-2].type?"*":""})).replace(P,"$1"),c,i0,e=a.length>0,f=function(f,g,h,i,k){var l,o,q,r=0,s="0",t=f&&[],u=[],v=j,x=f||e&&d.find.TAG("*",k),y=w+=null==v?1:Math.random()||.1,z=x.length;for(k&&(j=g===n||g||k);s!==z&&null!=(l=x[s]);s++){if(e&&l){o=0,g||l.ownerDocument===n||(m(l),h=!p);while(q=a[o++])if(q(l,g||n,h)){i.push(l);break}k&&(w=y)}c&&((l=!q&&l)&&r--,f&&t.push(l))}if(r+=s,c&&s!==r){o=0;while(q=b[o++])q(t,u,g,h);if(f){if(r>0)while(s--)t[s]||u[s]||(u[s]=E.call(i));u=wa(u)}G.apply(i,u),k&&!f&&u.length>0&&r+b.length>1&&ga.uniqueSort(i)}return k&&(w=y,j=v),t};return c?ia(f):f}return h=ga.compile=function(a,b){var c,d=[],e=[],f=A[a+" "];if(!f){b||(b=g(a)),c=b.length;while(c--)f=ya(b[c]),f[u]?d.push(f):e.push(f);f=A(a,za(e,d)),f.selector=a}return f},i=ga.select=function(a,b,c,e){var f,i,j,k,l,m="function"==typeof a&&a,n=!e&&g(a=m.selector||a);if(c=c||[],1===n.length){if(i=n[0]=n[0].slice(0),i.length>2&&"ID"===(j=i[0]).type&&9===b.nodeType&&p&&d.relative[i[1].type]){if(b=(d.find.ID(j.matches[0].replace(_,aa),b)||[])[0],!b)return c;m&&(b=b.parentNode),a=a.slice(i.shift().value.length)}f=V.needsContext.test(a)?0:i.length;while(f--){if(j=i[f],d.relative[k=j.type])break;if((l=d.find[k])&&(e=l(j.matches[0].replace(_,aa),$.test(i[0].type)&&qa(b.parentNode)||b))){if(i.splice(f,1),a=e.length&&sa(i),!a)return G.apply(c,e),c;break}}}return(m||h(a,n))(e,b,!p,c,!b||$.test(a)&&qa(b.parentNode)||b),c},c.sortStable=u.split("").sort(B).join("")===u,c.detectDuplicates=!!l,m(),c.sortDetached=ja(function(a){return 1&a.compareDocumentPosition(n.createElement("fieldset"))}),ja(function(a){return a.innerHTML="","#"===a.firstChild.getAttribute("href")})||ka("type|href|height|width",function(a,b,c){if(!c)return a.getAttribute(b,"type"===b.toLowerCase()?1:2)}),c.attributes&&ja(function(a){return a.innerHTML="",a.firstChild.setAttribute("value",""),""===a.firstChild.getAttribute("value")})||ka("value",function(a,b,c){if(!c&&"input"===a.nodeName.toLowerCase())return a.defaultValue}),ja(function(a){return null==a.getAttribute("disabled")})||ka(J,function(a,b,c){var d;if(!c)return a[b]===!0?b.toLowerCase():(d=a.getAttributeNode(b))&&d.specified?d.value:null}),ga}(a);r.find=x,r.expr=x.selectors,r.expr[":"]=r.expr.pseudos,r.uniqueSort=r.unique=x.uniqueSort,r.text=x.getText,r.isXMLDoc=x.isXML,r.contains=x.contains,r.escapeSelector=x.escape;var y=function(a,b,c){var d=[],e=void 0!==c;while((a=a[b])&&9!==a.nodeType)if(1===a.nodeType){if(e&&r(a).is(c))break;d.push(a)}return d},z=function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c},A=r.expr.match.needsContext;function B(a,b){return a.nodeName&&a.nodeName.toLowerCase()===b.toLowerCase()}var C=/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i,D=/^.[^:#\[\.,]*$/;function E(a,b,c){return r.isFunction(b)?r.grep(a,function(a,d){return!!b.call(a,d,a)!==c}):b.nodeType?r.grep(a,function(a){return a===b!==c}):"string"!=typeof b?r.grep(a,function(a){return i.call(b,a)>-1!==c}):D.test(b)?r.filter(b,a,c):(b=r.filter(b,a),r.grep(a,function(a){return i.call(b,a)>-1!==c&&1===a.nodeType}))}r.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?r.find.matchesSelector(d,a)?[d]:[]:r.find.matches(a,r.grep(b,function(a){return 1===a.nodeType}))},r.fn.extend({find:function(a){var b,c,d=this.length,e=this;if("string"!=typeof a)return this.pushStack(r(a).filter(function(){for(b=0;b1?r.uniqueSort(c):c},filter:function(a){return this.pushStack(E(this,a||[],!1))},not:function(a){return this.pushStack(E(this,a||[],!0))},is:function(a){return!!E(this,"string"==typeof a&&A.test(a)?r(a):a||[],!1).length}});var F,G=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/,H=r.fn.init=function(a,b,c){var e,f;if(!a)return this;if(c=c||F,"string"==typeof a){if(e="<"===a[0]&&">"===a[a.length-1]&&a.length>=3?[null,a,null]:G.exec(a),!e||!e[1]&&b)return!b||b.jquery?(b||c).find(a):this.constructor(b).find(a);if(e[1]){if(b=b instanceof r?b[0]:b,r.merge(this,r.parseHTML(e[1],b&&b.nodeType?b.ownerDocument||b:d,!0)),C.test(e[1])&&r.isPlainObject(b))for(e in b)r.isFunction(this[e])?this[e](b[e]):this.attr(e,b[e]);return this}return f=d.getElementById(e[2]),f&&(this[0]=f,this.length=1),this}return a.nodeType?(this[0]=a,this.length=1,this):r.isFunction(a)?void 0!==c.ready?c.ready(a):a(r):r.makeArray(a,this)};H.prototype=r.fn,F=r(d);var I=/^(?:parents|prev(?:Until|All))/,J={children:!0,contents:!0,next:!0,prev:!0};r.fn.extend({has:function(a){var b=r(a,this),c=b.length;return this.filter(function(){for(var a=0;a-1:1===c.nodeType&&r.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?r.uniqueSort(f):f)},index:function(a){return a?"string"==typeof a?i.call(r(a),this[0]):i.call(this,a.jquery?a[0]:a):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(r.uniqueSort(r.merge(this.get(),r(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function K(a,b){while((a=a[b])&&1!==a.nodeType);return a}r.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return y(a,"parentNode")},parentsUntil:function(a,b,c){return y(a,"parentNode",c)},next:function(a){return K(a,"nextSibling")},prev:function(a){return K(a,"previousSibling")},nextAll:function(a){return y(a,"nextSibling")},prevAll:function(a){return y(a,"previousSibling")},nextUntil:function(a,b,c){return y(a,"nextSibling",c)},prevUntil:function(a,b,c){return y(a,"previousSibling",c)},siblings:function(a){return z((a.parentNode||{}).firstChild,a)},children:function(a){return z(a.firstChild)},contents:function(a){return B(a,"iframe")?a.contentDocument:(B(a,"template")&&(a=a.content||a),r.merge([],a.childNodes))}},function(a,b){r.fn[a]=function(c,d){var e=r.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=r.filter(d,e)),this.length>1&&(J[a]||r.uniqueSort(e),I.test(a)&&e.reverse()),this.pushStack(e)}});var L=/[^\x20\t\r\n\f]+/g;function M(a){var b={};return r.each(a.match(L)||[],function(a,c){b[c]=!0}),b}r.Callbacks=function(a){a="string"==typeof a?M(a):r.extend({},a);var b,c,d,e,f=[],g=[],h=-1,i=function(){for(e=e||a.once,d=b=!0;g.length;h=-1){c=g.shift();while(++h-1)f.splice(c,1),c<=h&&h--}),this},has:function(a){return a?r.inArray(a,f)>-1:f.length>0},empty:function(){return f&&(f=[]),this},disable:function(){return e=g=[],f=c="",this},disabled:function(){return!f},lock:function(){return e=g=[],c||b||(f=c=""),this},locked:function(){return!!e},fireWith:function(a,c){return e||(c=c||[],c=[a,c.slice?c.slice():c],g.push(c),b||i()),this},fire:function(){return j.fireWith(this,arguments),this},fired:function(){return!!d}};return j};function N(a){return a}function O(a){throw a}function P(a,b,c,d){var e;try{a&&r.isFunction(e=a.promise)?e.call(a).done(b).fail(c):a&&r.isFunction(e=a.then)?e.call(a,b,c):b.apply(void 0,[a].slice(d))}catch(a){c.apply(void 0,[a])}}r.extend({Deferred:function(b){var c=[["notify","progress",r.Callbacks("memory"),r.Callbacks("memory"),2],["resolve","done",r.Callbacks("once memory"),r.Callbacks("once memory"),0,"resolved"],["reject","fail",r.Callbacks("once memory"),r.Callbacks("once memory"),1,"rejected"]],d="pending",e={state:function(){return d},always:function(){return f.done(arguments).fail(arguments),this},"catch":function(a){return e.then(null,a)},pipe:function(){var a=arguments;return r.Deferred(function(b){r.each(c,function(c,d){var e=r.isFunction(a[d[4]])&&a[d[4]];f[d[1]](function(){var a=e&&e.apply(this,arguments);a&&r.isFunction(a.promise)?a.promise().progress(b.notify).done(b.resolve).fail(b.reject):b[d[0]+"With"](this,e?[a]:arguments)})}),a=null}).promise()},then:function(b,d,e){var f=0;function g(b,c,d,e){return function(){var h=this,i=arguments,j=function(){var a,j;if(!(b=f&&(d!==O&&(h=void 0,i=[a]),c.rejectWith(h,i))}};b?k():(r.Deferred.getStackHook&&(k.stackTrace=r.Deferred.getStackHook()),a.setTimeout(k))}}return r.Deferred(function(a){c[0][3].add(g(0,a,r.isFunction(e)?e:N,a.notifyWith)),c[1][3].add(g(0,a,r.isFunction(b)?b:N)),c[2][3].add(g(0,a,r.isFunction(d)?d:O))}).promise()},promise:function(a){return null!=a?r.extend(a,e):e}},f={};return r.each(c,function(a,b){var g=b[2],h=b[5];e[b[1]]=g.add,h&&g.add(function(){d=h},c[3-a][2].disable,c[0][2].lock),g.add(b[3].fire),f[b[0]]=function(){return f[b[0]+"With"](this===f?void 0:this,arguments),this},f[b[0]+"With"]=g.fireWith}),e.promise(f),b&&b.call(f,f),f},when:function(a){var b=arguments.length,c=b,d=Array(c),e=f.call(arguments),g=r.Deferred(),h=function(a){return function(c){d[a]=this,e[a]=arguments.length>1?f.call(arguments):c,--b||g.resolveWith(d,e)}};if(b<=1&&(P(a,g.done(h(c)).resolve,g.reject,!b),"pending"===g.state()||r.isFunction(e[c]&&e[c].then)))return g.then();while(c--)P(e[c],h(c),g.reject);return g.promise()}});var Q=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;r.Deferred.exceptionHook=function(b,c){a.console&&a.console.warn&&b&&Q.test(b.name)&&a.console.warn("jQuery.Deferred exception: "+b.message,b.stack,c)},r.readyException=function(b){a.setTimeout(function(){throw b})};var R=r.Deferred();r.fn.ready=function(a){return R.then(a)["catch"](function(a){r.readyException(a)}),this},r.extend({isReady:!1,readyWait:1,ready:function(a){(a===!0?--r.readyWait:r.isReady)||(r.isReady=!0,a!==!0&&--r.readyWait>0||R.resolveWith(d,[r]))}}),r.ready.then=R.then;function S(){d.removeEventListener("DOMContentLoaded",S), +a.removeEventListener("load",S),r.ready()}"complete"===d.readyState||"loading"!==d.readyState&&!d.documentElement.doScroll?a.setTimeout(r.ready):(d.addEventListener("DOMContentLoaded",S),a.addEventListener("load",S));var T=function(a,b,c,d,e,f,g){var h=0,i=a.length,j=null==c;if("object"===r.type(c)){e=!0;for(h in c)T(a,b,h,c[h],!0,f,g)}else if(void 0!==d&&(e=!0,r.isFunction(d)||(g=!0),j&&(g?(b.call(a,d),b=null):(j=b,b=function(a,b,c){return j.call(r(a),c)})),b))for(;h1,null,!0)},removeData:function(a){return this.each(function(){X.remove(this,a)})}}),r.extend({queue:function(a,b,c){var d;if(a)return b=(b||"fx")+"queue",d=W.get(a,b),c&&(!d||Array.isArray(c)?d=W.access(a,b,r.makeArray(c)):d.push(c)),d||[]},dequeue:function(a,b){b=b||"fx";var c=r.queue(a,b),d=c.length,e=c.shift(),f=r._queueHooks(a,b),g=function(){r.dequeue(a,b)};"inprogress"===e&&(e=c.shift(),d--),e&&("fx"===b&&c.unshift("inprogress"),delete f.stop,e.call(a,g,f)),!d&&f&&f.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return W.get(a,c)||W.access(a,c,{empty:r.Callbacks("once memory").add(function(){W.remove(a,[b+"queue",c])})})}}),r.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.length\x20\t\r\n\f]+)/i,la=/^$|\/(?:java|ecma)script/i,ma={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};ma.optgroup=ma.option,ma.tbody=ma.tfoot=ma.colgroup=ma.caption=ma.thead,ma.th=ma.td;function na(a,b){var c;return c="undefined"!=typeof a.getElementsByTagName?a.getElementsByTagName(b||"*"):"undefined"!=typeof a.querySelectorAll?a.querySelectorAll(b||"*"):[],void 0===b||b&&B(a,b)?r.merge([a],c):c}function oa(a,b){for(var c=0,d=a.length;c-1)e&&e.push(f);else if(j=r.contains(f.ownerDocument,f),g=na(l.appendChild(f),"script"),j&&oa(g),c){k=0;while(f=g[k++])la.test(f.type||"")&&c.push(f)}return l}!function(){var a=d.createDocumentFragment(),b=a.appendChild(d.createElement("div")),c=d.createElement("input");c.setAttribute("type","radio"),c.setAttribute("checked","checked"),c.setAttribute("name","t"),b.appendChild(c),o.checkClone=b.cloneNode(!0).cloneNode(!0).lastChild.checked,b.innerHTML="",o.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue}();var ra=d.documentElement,sa=/^key/,ta=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,ua=/^([^.]*)(?:\.(.+)|)/;function va(){return!0}function wa(){return!1}function xa(){try{return d.activeElement}catch(a){}}function ya(a,b,c,d,e,f){var g,h;if("object"==typeof b){"string"!=typeof c&&(d=d||c,c=void 0);for(h in b)ya(a,h,c,d,b[h],f);return a}if(null==d&&null==e?(e=c,d=c=void 0):null==e&&("string"==typeof c?(e=d,d=void 0):(e=d,d=c,c=void 0)),e===!1)e=wa;else if(!e)return a;return 1===f&&(g=e,e=function(a){return r().off(a),g.apply(this,arguments)},e.guid=g.guid||(g.guid=r.guid++)),a.each(function(){r.event.add(this,b,e,d,c)})}r.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,n,o,p,q=W.get(a);if(q){c.handler&&(f=c,c=f.handler,e=f.selector),e&&r.find.matchesSelector(ra,e),c.guid||(c.guid=r.guid++),(i=q.events)||(i=q.events={}),(g=q.handle)||(g=q.handle=function(b){return"undefined"!=typeof r&&r.event.triggered!==b.type?r.event.dispatch.apply(a,arguments):void 0}),b=(b||"").match(L)||[""],j=b.length;while(j--)h=ua.exec(b[j])||[],n=p=h[1],o=(h[2]||"").split(".").sort(),n&&(l=r.event.special[n]||{},n=(e?l.delegateType:l.bindType)||n,l=r.event.special[n]||{},k=r.extend({type:n,origType:p,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&r.expr.match.needsContext.test(e),namespace:o.join(".")},f),(m=i[n])||(m=i[n]=[],m.delegateCount=0,l.setup&&l.setup.call(a,d,o,g)!==!1||a.addEventListener&&a.addEventListener(n,g)),l.add&&(l.add.call(a,k),k.handler.guid||(k.handler.guid=c.guid)),e?m.splice(m.delegateCount++,0,k):m.push(k),r.event.global[n]=!0)}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,n,o,p,q=W.hasData(a)&&W.get(a);if(q&&(i=q.events)){b=(b||"").match(L)||[""],j=b.length;while(j--)if(h=ua.exec(b[j])||[],n=p=h[1],o=(h[2]||"").split(".").sort(),n){l=r.event.special[n]||{},n=(d?l.delegateType:l.bindType)||n,m=i[n]||[],h=h[2]&&new RegExp("(^|\\.)"+o.join("\\.(?:.*\\.|)")+"(\\.|$)"),g=f=m.length;while(f--)k=m[f],!e&&p!==k.origType||c&&c.guid!==k.guid||h&&!h.test(k.namespace)||d&&d!==k.selector&&("**"!==d||!k.selector)||(m.splice(f,1),k.selector&&m.delegateCount--,l.remove&&l.remove.call(a,k));g&&!m.length&&(l.teardown&&l.teardown.call(a,o,q.handle)!==!1||r.removeEvent(a,n,q.handle),delete i[n])}else for(n in i)r.event.remove(a,n+b[j],c,d,!0);r.isEmptyObject(i)&&W.remove(a,"handle events")}},dispatch:function(a){var b=r.event.fix(a),c,d,e,f,g,h,i=new Array(arguments.length),j=(W.get(this,"events")||{})[b.type]||[],k=r.event.special[b.type]||{};for(i[0]=b,c=1;c=1))for(;j!==this;j=j.parentNode||this)if(1===j.nodeType&&("click"!==a.type||j.disabled!==!0)){for(f=[],g={},c=0;c-1:r.find(e,this,null,[j]).length),g[e]&&f.push(d);f.length&&h.push({elem:j,handlers:f})}return j=this,i\x20\t\r\n\f]*)[^>]*)\/>/gi,Aa=/\s*$/g;function Ea(a,b){return B(a,"table")&&B(11!==b.nodeType?b:b.firstChild,"tr")?r(">tbody",a)[0]||a:a}function Fa(a){return a.type=(null!==a.getAttribute("type"))+"/"+a.type,a}function Ga(a){var b=Ca.exec(a.type);return b?a.type=b[1]:a.removeAttribute("type"),a}function Ha(a,b){var c,d,e,f,g,h,i,j;if(1===b.nodeType){if(W.hasData(a)&&(f=W.access(a),g=W.set(b,f),j=f.events)){delete g.handle,g.events={};for(e in j)for(c=0,d=j[e].length;c1&&"string"==typeof q&&!o.checkClone&&Ba.test(q))return a.each(function(e){var f=a.eq(e);s&&(b[0]=q.call(this,e,f.html())),Ja(f,b,c,d)});if(m&&(e=qa(b,a[0].ownerDocument,!1,a,d),f=e.firstChild,1===e.childNodes.length&&(e=f),f||d)){for(h=r.map(na(e,"script"),Fa),i=h.length;l")},clone:function(a,b,c){var d,e,f,g,h=a.cloneNode(!0),i=r.contains(a.ownerDocument,a);if(!(o.noCloneChecked||1!==a.nodeType&&11!==a.nodeType||r.isXMLDoc(a)))for(g=na(h),f=na(a),d=0,e=f.length;d0&&oa(g,!i&&na(a,"script")),h},cleanData:function(a){for(var b,c,d,e=r.event.special,f=0;void 0!==(c=a[f]);f++)if(U(c)){if(b=c[W.expando]){if(b.events)for(d in b.events)e[d]?r.event.remove(c,d):r.removeEvent(c,d,b.handle);c[W.expando]=void 0}c[X.expando]&&(c[X.expando]=void 0)}}}),r.fn.extend({detach:function(a){return Ka(this,a,!0)},remove:function(a){return Ka(this,a)},text:function(a){return T(this,function(a){return void 0===a?r.text(this):this.empty().each(function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=a)})},null,a,arguments.length)},append:function(){return Ja(this,arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=Ea(this,a);b.appendChild(a)}})},prepend:function(){return Ja(this,arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=Ea(this,a);b.insertBefore(a,b.firstChild)}})},before:function(){return Ja(this,arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this)})},after:function(){return Ja(this,arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this.nextSibling)})},empty:function(){for(var a,b=0;null!=(a=this[b]);b++)1===a.nodeType&&(r.cleanData(na(a,!1)),a.textContent="");return this},clone:function(a,b){return a=null!=a&&a,b=null==b?a:b,this.map(function(){return r.clone(this,a,b)})},html:function(a){return T(this,function(a){var b=this[0]||{},c=0,d=this.length;if(void 0===a&&1===b.nodeType)return b.innerHTML;if("string"==typeof a&&!Aa.test(a)&&!ma[(ka.exec(a)||["",""])[1].toLowerCase()]){a=r.htmlPrefilter(a);try{for(;c1)}});function _a(a,b,c,d,e){return new _a.prototype.init(a,b,c,d,e)}r.Tween=_a,_a.prototype={constructor:_a,init:function(a,b,c,d,e,f){this.elem=a,this.prop=c,this.easing=e||r.easing._default,this.options=b,this.start=this.now=this.cur(),this.end=d,this.unit=f||(r.cssNumber[c]?"":"px")},cur:function(){var a=_a.propHooks[this.prop];return a&&a.get?a.get(this):_a.propHooks._default.get(this)},run:function(a){var b,c=_a.propHooks[this.prop];return this.options.duration?this.pos=b=r.easing[this.easing](a,this.options.duration*a,0,1,this.options.duration):this.pos=b=a,this.now=(this.end-this.start)*b+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),c&&c.set?c.set(this):_a.propHooks._default.set(this),this}},_a.prototype.init.prototype=_a.prototype,_a.propHooks={_default:{get:function(a){var b;return 1!==a.elem.nodeType||null!=a.elem[a.prop]&&null==a.elem.style[a.prop]?a.elem[a.prop]:(b=r.css(a.elem,a.prop,""),b&&"auto"!==b?b:0)},set:function(a){r.fx.step[a.prop]?r.fx.step[a.prop](a):1!==a.elem.nodeType||null==a.elem.style[r.cssProps[a.prop]]&&!r.cssHooks[a.prop]?a.elem[a.prop]=a.now:r.style(a.elem,a.prop,a.now+a.unit)}}},_a.propHooks.scrollTop=_a.propHooks.scrollLeft={set:function(a){a.elem.nodeType&&a.elem.parentNode&&(a.elem[a.prop]=a.now)}},r.easing={linear:function(a){return a},swing:function(a){return.5-Math.cos(a*Math.PI)/2},_default:"swing"},r.fx=_a.prototype.init,r.fx.step={};var ab,bb,cb=/^(?:toggle|show|hide)$/,db=/queueHooks$/;function eb(){bb&&(d.hidden===!1&&a.requestAnimationFrame?a.requestAnimationFrame(eb):a.setTimeout(eb,r.fx.interval),r.fx.tick())}function fb(){return a.setTimeout(function(){ab=void 0}),ab=r.now()}function gb(a,b){var c,d=0,e={height:a};for(b=b?1:0;d<4;d+=2-b)c=ca[d],e["margin"+c]=e["padding"+c]=a;return b&&(e.opacity=e.width=a),e}function hb(a,b,c){for(var d,e=(kb.tweeners[b]||[]).concat(kb.tweeners["*"]),f=0,g=e.length;f1)},removeAttr:function(a){return this.each(function(){r.removeAttr(this,a)})}}),r.extend({attr:function(a,b,c){var d,e,f=a.nodeType;if(3!==f&&8!==f&&2!==f)return"undefined"==typeof a.getAttribute?r.prop(a,b,c):(1===f&&r.isXMLDoc(a)||(e=r.attrHooks[b.toLowerCase()]||(r.expr.match.bool.test(b)?lb:void 0)),void 0!==c?null===c?void r.removeAttr(a,b):e&&"set"in e&&void 0!==(d=e.set(a,c,b))?d:(a.setAttribute(b,c+""),c):e&&"get"in e&&null!==(d=e.get(a,b))?d:(d=r.find.attr(a,b), +null==d?void 0:d))},attrHooks:{type:{set:function(a,b){if(!o.radioValue&&"radio"===b&&B(a,"input")){var c=a.value;return a.setAttribute("type",b),c&&(a.value=c),b}}}},removeAttr:function(a,b){var c,d=0,e=b&&b.match(L);if(e&&1===a.nodeType)while(c=e[d++])a.removeAttribute(c)}}),lb={set:function(a,b,c){return b===!1?r.removeAttr(a,c):a.setAttribute(c,c),c}},r.each(r.expr.match.bool.source.match(/\w+/g),function(a,b){var c=mb[b]||r.find.attr;mb[b]=function(a,b,d){var e,f,g=b.toLowerCase();return d||(f=mb[g],mb[g]=e,e=null!=c(a,b,d)?g:null,mb[g]=f),e}});var nb=/^(?:input|select|textarea|button)$/i,ob=/^(?:a|area)$/i;r.fn.extend({prop:function(a,b){return T(this,r.prop,a,b,arguments.length>1)},removeProp:function(a){return this.each(function(){delete this[r.propFix[a]||a]})}}),r.extend({prop:function(a,b,c){var d,e,f=a.nodeType;if(3!==f&&8!==f&&2!==f)return 1===f&&r.isXMLDoc(a)||(b=r.propFix[b]||b,e=r.propHooks[b]),void 0!==c?e&&"set"in e&&void 0!==(d=e.set(a,c,b))?d:a[b]=c:e&&"get"in e&&null!==(d=e.get(a,b))?d:a[b]},propHooks:{tabIndex:{get:function(a){var b=r.find.attr(a,"tabindex");return b?parseInt(b,10):nb.test(a.nodeName)||ob.test(a.nodeName)&&a.href?0:-1}}},propFix:{"for":"htmlFor","class":"className"}}),o.optSelected||(r.propHooks.selected={get:function(a){var b=a.parentNode;return b&&b.parentNode&&b.parentNode.selectedIndex,null},set:function(a){var b=a.parentNode;b&&(b.selectedIndex,b.parentNode&&b.parentNode.selectedIndex)}}),r.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){r.propFix[this.toLowerCase()]=this});function pb(a){var b=a.match(L)||[];return b.join(" ")}function qb(a){return a.getAttribute&&a.getAttribute("class")||""}r.fn.extend({addClass:function(a){var b,c,d,e,f,g,h,i=0;if(r.isFunction(a))return this.each(function(b){r(this).addClass(a.call(this,b,qb(this)))});if("string"==typeof a&&a){b=a.match(L)||[];while(c=this[i++])if(e=qb(c),d=1===c.nodeType&&" "+pb(e)+" "){g=0;while(f=b[g++])d.indexOf(" "+f+" ")<0&&(d+=f+" ");h=pb(d),e!==h&&c.setAttribute("class",h)}}return this},removeClass:function(a){var b,c,d,e,f,g,h,i=0;if(r.isFunction(a))return this.each(function(b){r(this).removeClass(a.call(this,b,qb(this)))});if(!arguments.length)return this.attr("class","");if("string"==typeof a&&a){b=a.match(L)||[];while(c=this[i++])if(e=qb(c),d=1===c.nodeType&&" "+pb(e)+" "){g=0;while(f=b[g++])while(d.indexOf(" "+f+" ")>-1)d=d.replace(" "+f+" "," ");h=pb(d),e!==h&&c.setAttribute("class",h)}}return this},toggleClass:function(a,b){var c=typeof a;return"boolean"==typeof b&&"string"===c?b?this.addClass(a):this.removeClass(a):r.isFunction(a)?this.each(function(c){r(this).toggleClass(a.call(this,c,qb(this),b),b)}):this.each(function(){var b,d,e,f;if("string"===c){d=0,e=r(this),f=a.match(L)||[];while(b=f[d++])e.hasClass(b)?e.removeClass(b):e.addClass(b)}else void 0!==a&&"boolean"!==c||(b=qb(this),b&&W.set(this,"__className__",b),this.setAttribute&&this.setAttribute("class",b||a===!1?"":W.get(this,"__className__")||""))})},hasClass:function(a){var b,c,d=0;b=" "+a+" ";while(c=this[d++])if(1===c.nodeType&&(" "+pb(qb(c))+" ").indexOf(b)>-1)return!0;return!1}});var rb=/\r/g;r.fn.extend({val:function(a){var b,c,d,e=this[0];{if(arguments.length)return d=r.isFunction(a),this.each(function(c){var e;1===this.nodeType&&(e=d?a.call(this,c,r(this).val()):a,null==e?e="":"number"==typeof e?e+="":Array.isArray(e)&&(e=r.map(e,function(a){return null==a?"":a+""})),b=r.valHooks[this.type]||r.valHooks[this.nodeName.toLowerCase()],b&&"set"in b&&void 0!==b.set(this,e,"value")||(this.value=e))});if(e)return b=r.valHooks[e.type]||r.valHooks[e.nodeName.toLowerCase()],b&&"get"in b&&void 0!==(c=b.get(e,"value"))?c:(c=e.value,"string"==typeof c?c.replace(rb,""):null==c?"":c)}}}),r.extend({valHooks:{option:{get:function(a){var b=r.find.attr(a,"value");return null!=b?b:pb(r.text(a))}},select:{get:function(a){var b,c,d,e=a.options,f=a.selectedIndex,g="select-one"===a.type,h=g?null:[],i=g?f+1:e.length;for(d=f<0?i:g?f:0;d-1)&&(c=!0);return c||(a.selectedIndex=-1),f}}}}),r.each(["radio","checkbox"],function(){r.valHooks[this]={set:function(a,b){if(Array.isArray(b))return a.checked=r.inArray(r(a).val(),b)>-1}},o.checkOn||(r.valHooks[this].get=function(a){return null===a.getAttribute("value")?"on":a.value})});var sb=/^(?:focusinfocus|focusoutblur)$/;r.extend(r.event,{trigger:function(b,c,e,f){var g,h,i,j,k,m,n,o=[e||d],p=l.call(b,"type")?b.type:b,q=l.call(b,"namespace")?b.namespace.split("."):[];if(h=i=e=e||d,3!==e.nodeType&&8!==e.nodeType&&!sb.test(p+r.event.triggered)&&(p.indexOf(".")>-1&&(q=p.split("."),p=q.shift(),q.sort()),k=p.indexOf(":")<0&&"on"+p,b=b[r.expando]?b:new r.Event(p,"object"==typeof b&&b),b.isTrigger=f?2:3,b.namespace=q.join("."),b.rnamespace=b.namespace?new RegExp("(^|\\.)"+q.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,b.result=void 0,b.target||(b.target=e),c=null==c?[b]:r.makeArray(c,[b]),n=r.event.special[p]||{},f||!n.trigger||n.trigger.apply(e,c)!==!1)){if(!f&&!n.noBubble&&!r.isWindow(e)){for(j=n.delegateType||p,sb.test(j+p)||(h=h.parentNode);h;h=h.parentNode)o.push(h),i=h;i===(e.ownerDocument||d)&&o.push(i.defaultView||i.parentWindow||a)}g=0;while((h=o[g++])&&!b.isPropagationStopped())b.type=g>1?j:n.bindType||p,m=(W.get(h,"events")||{})[b.type]&&W.get(h,"handle"),m&&m.apply(h,c),m=k&&h[k],m&&m.apply&&U(h)&&(b.result=m.apply(h,c),b.result===!1&&b.preventDefault());return b.type=p,f||b.isDefaultPrevented()||n._default&&n._default.apply(o.pop(),c)!==!1||!U(e)||k&&r.isFunction(e[p])&&!r.isWindow(e)&&(i=e[k],i&&(e[k]=null),r.event.triggered=p,e[p](),r.event.triggered=void 0,i&&(e[k]=i)),b.result}},simulate:function(a,b,c){var d=r.extend(new r.Event,c,{type:a,isSimulated:!0});r.event.trigger(d,null,b)}}),r.fn.extend({trigger:function(a,b){return this.each(function(){r.event.trigger(a,b,this)})},triggerHandler:function(a,b){var c=this[0];if(c)return r.event.trigger(a,b,c,!0)}}),r.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(a,b){r.fn[b]=function(a,c){return arguments.length>0?this.on(b,null,a,c):this.trigger(b)}}),r.fn.extend({hover:function(a,b){return this.mouseenter(a).mouseleave(b||a)}}),o.focusin="onfocusin"in a,o.focusin||r.each({focus:"focusin",blur:"focusout"},function(a,b){var c=function(a){r.event.simulate(b,a.target,r.event.fix(a))};r.event.special[b]={setup:function(){var d=this.ownerDocument||this,e=W.access(d,b);e||d.addEventListener(a,c,!0),W.access(d,b,(e||0)+1)},teardown:function(){var d=this.ownerDocument||this,e=W.access(d,b)-1;e?W.access(d,b,e):(d.removeEventListener(a,c,!0),W.remove(d,b))}}});var tb=a.location,ub=r.now(),vb=/\?/;r.parseXML=function(b){var c;if(!b||"string"!=typeof b)return null;try{c=(new a.DOMParser).parseFromString(b,"text/xml")}catch(d){c=void 0}return c&&!c.getElementsByTagName("parsererror").length||r.error("Invalid XML: "+b),c};var wb=/\[\]$/,xb=/\r?\n/g,yb=/^(?:submit|button|image|reset|file)$/i,zb=/^(?:input|select|textarea|keygen)/i;function Ab(a,b,c,d){var e;if(Array.isArray(b))r.each(b,function(b,e){c||wb.test(a)?d(a,e):Ab(a+"["+("object"==typeof e&&null!=e?b:"")+"]",e,c,d)});else if(c||"object"!==r.type(b))d(a,b);else for(e in b)Ab(a+"["+e+"]",b[e],c,d)}r.param=function(a,b){var c,d=[],e=function(a,b){var c=r.isFunction(b)?b():b;d[d.length]=encodeURIComponent(a)+"="+encodeURIComponent(null==c?"":c)};if(Array.isArray(a)||a.jquery&&!r.isPlainObject(a))r.each(a,function(){e(this.name,this.value)});else for(c in a)Ab(c,a[c],b,e);return d.join("&")},r.fn.extend({serialize:function(){return r.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var a=r.prop(this,"elements");return a?r.makeArray(a):this}).filter(function(){var a=this.type;return this.name&&!r(this).is(":disabled")&&zb.test(this.nodeName)&&!yb.test(a)&&(this.checked||!ja.test(a))}).map(function(a,b){var c=r(this).val();return null==c?null:Array.isArray(c)?r.map(c,function(a){return{name:b.name,value:a.replace(xb,"\r\n")}}):{name:b.name,value:c.replace(xb,"\r\n")}}).get()}});var Bb=/%20/g,Cb=/#.*$/,Db=/([?&])_=[^&]*/,Eb=/^(.*?):[ \t]*([^\r\n]*)$/gm,Fb=/^(?:about|app|app-storage|.+-extension|file|res|widget):$/,Gb=/^(?:GET|HEAD)$/,Hb=/^\/\//,Ib={},Jb={},Kb="*/".concat("*"),Lb=d.createElement("a");Lb.href=tb.href;function Mb(a){return function(b,c){"string"!=typeof b&&(c=b,b="*");var d,e=0,f=b.toLowerCase().match(L)||[];if(r.isFunction(c))while(d=f[e++])"+"===d[0]?(d=d.slice(1)||"*",(a[d]=a[d]||[]).unshift(c)):(a[d]=a[d]||[]).push(c)}}function Nb(a,b,c,d){var e={},f=a===Jb;function g(h){var i;return e[h]=!0,r.each(a[h]||[],function(a,h){var j=h(b,c,d);return"string"!=typeof j||f||e[j]?f?!(i=j):void 0:(b.dataTypes.unshift(j),g(j),!1)}),i}return g(b.dataTypes[0])||!e["*"]&&g("*")}function Ob(a,b){var c,d,e=r.ajaxSettings.flatOptions||{};for(c in b)void 0!==b[c]&&((e[c]?a:d||(d={}))[c]=b[c]);return d&&r.extend(!0,a,d),a}function Pb(a,b,c){var d,e,f,g,h=a.contents,i=a.dataTypes;while("*"===i[0])i.shift(),void 0===d&&(d=a.mimeType||b.getResponseHeader("Content-Type"));if(d)for(e in h)if(h[e]&&h[e].test(d)){i.unshift(e);break}if(i[0]in c)f=i[0];else{for(e in c){if(!i[0]||a.converters[e+" "+i[0]]){f=e;break}g||(g=e)}f=f||g}if(f)return f!==i[0]&&i.unshift(f),c[f]}function Qb(a,b,c,d){var e,f,g,h,i,j={},k=a.dataTypes.slice();if(k[1])for(g in a.converters)j[g.toLowerCase()]=a.converters[g];f=k.shift();while(f)if(a.responseFields[f]&&(c[a.responseFields[f]]=b),!i&&d&&a.dataFilter&&(b=a.dataFilter(b,a.dataType)),i=f,f=k.shift())if("*"===f)f=i;else if("*"!==i&&i!==f){if(g=j[i+" "+f]||j["* "+f],!g)for(e in j)if(h=e.split(" "),h[1]===f&&(g=j[i+" "+h[0]]||j["* "+h[0]])){g===!0?g=j[e]:j[e]!==!0&&(f=h[0],k.unshift(h[1]));break}if(g!==!0)if(g&&a["throws"])b=g(b);else try{b=g(b)}catch(l){return{state:"parsererror",error:g?l:"No conversion from "+i+" to "+f}}}return{state:"success",data:b}}r.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:tb.href,type:"GET",isLocal:Fb.test(tb.protocol),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":Kb,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/\bxml\b/,html:/\bhtml/,json:/\bjson\b/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":JSON.parse,"text xml":r.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(a,b){return b?Ob(Ob(a,r.ajaxSettings),b):Ob(r.ajaxSettings,a)},ajaxPrefilter:Mb(Ib),ajaxTransport:Mb(Jb),ajax:function(b,c){"object"==typeof b&&(c=b,b=void 0),c=c||{};var e,f,g,h,i,j,k,l,m,n,o=r.ajaxSetup({},c),p=o.context||o,q=o.context&&(p.nodeType||p.jquery)?r(p):r.event,s=r.Deferred(),t=r.Callbacks("once memory"),u=o.statusCode||{},v={},w={},x="canceled",y={readyState:0,getResponseHeader:function(a){var b;if(k){if(!h){h={};while(b=Eb.exec(g))h[b[1].toLowerCase()]=b[2]}b=h[a.toLowerCase()]}return null==b?null:b},getAllResponseHeaders:function(){return k?g:null},setRequestHeader:function(a,b){return null==k&&(a=w[a.toLowerCase()]=w[a.toLowerCase()]||a,v[a]=b),this},overrideMimeType:function(a){return null==k&&(o.mimeType=a),this},statusCode:function(a){var b;if(a)if(k)y.always(a[y.status]);else for(b in a)u[b]=[u[b],a[b]];return this},abort:function(a){var b=a||x;return e&&e.abort(b),A(0,b),this}};if(s.promise(y),o.url=((b||o.url||tb.href)+"").replace(Hb,tb.protocol+"//"),o.type=c.method||c.type||o.method||o.type,o.dataTypes=(o.dataType||"*").toLowerCase().match(L)||[""],null==o.crossDomain){j=d.createElement("a");try{j.href=o.url,j.href=j.href,o.crossDomain=Lb.protocol+"//"+Lb.host!=j.protocol+"//"+j.host}catch(z){o.crossDomain=!0}}if(o.data&&o.processData&&"string"!=typeof o.data&&(o.data=r.param(o.data,o.traditional)),Nb(Ib,o,c,y),k)return y;l=r.event&&o.global,l&&0===r.active++&&r.event.trigger("ajaxStart"),o.type=o.type.toUpperCase(),o.hasContent=!Gb.test(o.type),f=o.url.replace(Cb,""),o.hasContent?o.data&&o.processData&&0===(o.contentType||"").indexOf("application/x-www-form-urlencoded")&&(o.data=o.data.replace(Bb,"+")):(n=o.url.slice(f.length),o.data&&(f+=(vb.test(f)?"&":"?")+o.data,delete o.data),o.cache===!1&&(f=f.replace(Db,"$1"),n=(vb.test(f)?"&":"?")+"_="+ub++ +n),o.url=f+n),o.ifModified&&(r.lastModified[f]&&y.setRequestHeader("If-Modified-Since",r.lastModified[f]),r.etag[f]&&y.setRequestHeader("If-None-Match",r.etag[f])),(o.data&&o.hasContent&&o.contentType!==!1||c.contentType)&&y.setRequestHeader("Content-Type",o.contentType),y.setRequestHeader("Accept",o.dataTypes[0]&&o.accepts[o.dataTypes[0]]?o.accepts[o.dataTypes[0]]+("*"!==o.dataTypes[0]?", "+Kb+"; q=0.01":""):o.accepts["*"]);for(m in o.headers)y.setRequestHeader(m,o.headers[m]);if(o.beforeSend&&(o.beforeSend.call(p,y,o)===!1||k))return y.abort();if(x="abort",t.add(o.complete),y.done(o.success),y.fail(o.error),e=Nb(Jb,o,c,y)){if(y.readyState=1,l&&q.trigger("ajaxSend",[y,o]),k)return y;o.async&&o.timeout>0&&(i=a.setTimeout(function(){y.abort("timeout")},o.timeout));try{k=!1,e.send(v,A)}catch(z){if(k)throw z;A(-1,z)}}else A(-1,"No Transport");function A(b,c,d,h){var j,m,n,v,w,x=c;k||(k=!0,i&&a.clearTimeout(i),e=void 0,g=h||"",y.readyState=b>0?4:0,j=b>=200&&b<300||304===b,d&&(v=Pb(o,y,d)),v=Qb(o,v,y,j),j?(o.ifModified&&(w=y.getResponseHeader("Last-Modified"),w&&(r.lastModified[f]=w),w=y.getResponseHeader("etag"),w&&(r.etag[f]=w)),204===b||"HEAD"===o.type?x="nocontent":304===b?x="notmodified":(x=v.state,m=v.data,n=v.error,j=!n)):(n=x,!b&&x||(x="error",b<0&&(b=0))),y.status=b,y.statusText=(c||x)+"",j?s.resolveWith(p,[m,x,y]):s.rejectWith(p,[y,x,n]),y.statusCode(u),u=void 0,l&&q.trigger(j?"ajaxSuccess":"ajaxError",[y,o,j?m:n]),t.fireWith(p,[y,x]),l&&(q.trigger("ajaxComplete",[y,o]),--r.active||r.event.trigger("ajaxStop")))}return y},getJSON:function(a,b,c){return r.get(a,b,c,"json")},getScript:function(a,b){return r.get(a,void 0,b,"script")}}),r.each(["get","post"],function(a,b){r[b]=function(a,c,d,e){return r.isFunction(c)&&(e=e||d,d=c,c=void 0),r.ajax(r.extend({url:a,type:b,dataType:e,data:c,success:d},r.isPlainObject(a)&&a))}}),r._evalUrl=function(a){return r.ajax({url:a,type:"GET",dataType:"script",cache:!0,async:!1,global:!1,"throws":!0})},r.fn.extend({wrapAll:function(a){var b;return this[0]&&(r.isFunction(a)&&(a=a.call(this[0])),b=r(a,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&b.insertBefore(this[0]),b.map(function(){var a=this;while(a.firstElementChild)a=a.firstElementChild;return a}).append(this)),this},wrapInner:function(a){return r.isFunction(a)?this.each(function(b){r(this).wrapInner(a.call(this,b))}):this.each(function(){var b=r(this),c=b.contents();c.length?c.wrapAll(a):b.append(a)})},wrap:function(a){var b=r.isFunction(a);return this.each(function(c){r(this).wrapAll(b?a.call(this,c):a)})},unwrap:function(a){return this.parent(a).not("body").each(function(){r(this).replaceWith(this.childNodes)}),this}}),r.expr.pseudos.hidden=function(a){return!r.expr.pseudos.visible(a)},r.expr.pseudos.visible=function(a){return!!(a.offsetWidth||a.offsetHeight||a.getClientRects().length)},r.ajaxSettings.xhr=function(){try{return new a.XMLHttpRequest}catch(b){}};var Rb={0:200,1223:204},Sb=r.ajaxSettings.xhr();o.cors=!!Sb&&"withCredentials"in Sb,o.ajax=Sb=!!Sb,r.ajaxTransport(function(b){var c,d;if(o.cors||Sb&&!b.crossDomain)return{send:function(e,f){var g,h=b.xhr();if(h.open(b.type,b.url,b.async,b.username,b.password),b.xhrFields)for(g in b.xhrFields)h[g]=b.xhrFields[g];b.mimeType&&h.overrideMimeType&&h.overrideMimeType(b.mimeType),b.crossDomain||e["X-Requested-With"]||(e["X-Requested-With"]="XMLHttpRequest");for(g in e)h.setRequestHeader(g,e[g]);c=function(a){return function(){c&&(c=d=h.onload=h.onerror=h.onabort=h.onreadystatechange=null,"abort"===a?h.abort():"error"===a?"number"!=typeof h.status?f(0,"error"):f(h.status,h.statusText):f(Rb[h.status]||h.status,h.statusText,"text"!==(h.responseType||"text")||"string"!=typeof h.responseText?{binary:h.response}:{text:h.responseText},h.getAllResponseHeaders()))}},h.onload=c(),d=h.onerror=c("error"),void 0!==h.onabort?h.onabort=d:h.onreadystatechange=function(){4===h.readyState&&a.setTimeout(function(){c&&d()})},c=c("abort");try{h.send(b.hasContent&&b.data||null)}catch(i){if(c)throw i}},abort:function(){c&&c()}}}),r.ajaxPrefilter(function(a){a.crossDomain&&(a.contents.script=!1)}),r.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/\b(?:java|ecma)script\b/},converters:{"text script":function(a){return r.globalEval(a),a}}}),r.ajaxPrefilter("script",function(a){void 0===a.cache&&(a.cache=!1),a.crossDomain&&(a.type="GET")}),r.ajaxTransport("script",function(a){if(a.crossDomain){var b,c;return{send:function(e,f){b=r(" + + + + + + + + + + +
+ + + +
+ + +
+
+ +
+
+ + +
+
+
+ +
+
+ PAIR YOUR SAMOURAI WALLET WITH YOUR DOJO BY SCANNING THIS QRCODE +
+
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+ +
+
+

+          
+
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/static/admin/tool/index.js b/static/admin/tool/index.js new file mode 100644 index 0000000..39f5b75 --- /dev/null +++ b/static/admin/tool/index.js @@ -0,0 +1,244 @@ +/** + * Display Messages + */ + +function displayInfoMsg(msg) { + const htmlMsg = '' + msg + ''; + $('#json-data').html(htmlMsg); +} + +function displayErrorMsg(msg) { + const htmlMsg = '' + msg + ''; + $('#json-data').html(htmlMsg); +} + +function displayQRPairing() { + const activeTab = sessionStorage.getItem('activeTab'); + processAction(activeTab).then( + function (result) { + if (!result) {return;} + const url = window.location.protocol + '//' + window.location.host + conf['api']['baseUri']; + result['pairing']['url'] = url; + const textJson = JSON.stringify(result, null, 4); + $("#qr-pairing").html('') // clear qrcode first + $('#qr-pairing').qrcode({width: 256, height: 256, text: textJson}); + }, + function (jqxhr) {} + ); +} + +/** + * On tab switched + */ +function initTabs() { + // Activates the current tab + let currentTab = sessionStorage.getItem('activeTab'); + if (!currentTab) { + currentTab = '#link-pairing'; + } + $(currentTab).addClass('active'); + + const tabs = [ + '#link-pairing', + '#link-status-api', + '#link-status-pushtx', + '#link-orchestrator', + '#link-info-xpub', + '#link-rescan-xpub', + '#link-xpub', + '#link-info-address', + '#link-rescan-address', + '#link-multiaddr', + '#link-unspent', + '#link-tx' + ]; + + // Sets event handlers + for (let tab of tabs) { + $(tab).click(function() { + $(sessionStorage.getItem('activeTab')).removeClass('active'); + sessionStorage.setItem('activeTab', tab); + $(tab).addClass('active'); + preparePage(); + }); + } +} + +/** + * Prepares the page content + */ +function preparePage() { + const activeTab = sessionStorage.getItem('activeTab'); + + // Pairing + if (activeTab == '#link-pairing') { + $('#screen-pairing').show(); + $('#form-maintenance').hide(); + displayQRPairing(); + + // Maintenance screens + } else { + $('#form-maintenance').show(); + $('#screen-pairing').hide(); + + let placeholder = '', + placeholder2 = '', + placeholder3 = ''; + + $("#cell-args").removeClass('halfwidth'); + $("#cell-args").addClass('fullwidth'); + $("#cell-args2").hide(); + $("#cell-args3").hide(); + + if (activeTab == '#link-status-api' || + activeTab == '#link-status-pushtx' || + activeTab == '#link-orchestrator' + ) { + $("#row-form-field").hide(); + $("#row-form-button").hide(); + processGo(); + } else { + $("#row-form-field").show(); + $("#row-form-button").show(); + } + + if (activeTab == '#link-info-xpub') { + placeholder = 'ENTER A XPUB, YPUB OR ZPUB'; + } else if (activeTab == '#link-xpub') { + placeholder = 'ENTER /XPUB URL ARGUMENTS (e.g.: xpub=xpub0123456789&segwit=bip84&type=restore&force=true)'; + } else if (activeTab == '#link-info-address') { + placeholder = 'ENTER A BITCOIN ADDRESS'; + } else if (activeTab == '#link-rescan-address') { + placeholder = 'ENTER A BITCOIN ADDRESS'; + } else if (activeTab == '#link-multiaddr') { + placeholder = 'ENTER /MULTIADDR URL ARGUMENTS (e.g.: active=xpub0123456789&new=address2|address3&pubkey=pubkey4)'; + } else if (activeTab == '#link-unspent') { + placeholder = 'ENTER /UNSPENT URL ARGUMENTS (e.g.: active=xpub0123456789&new=address2|address3&pubkey=pubkey4)'; + } else if (activeTab == '#link-tx') { + placeholder = 'ENTER A TRANSACTION TXID'; + } else if (activeTab == '#link-rescan-xpub') { + $("#cell-args").removeClass('fullwidth'); + $("#cell-args").addClass('halfwidth'); + $("#cell-args2").show(); + $("#cell-args3").show(); + placeholder = 'ENTER A XPUB, YPUB OR ZPUB'; + placeholder2 = 'ENTER #ADDR. (DEFAULT=100)'; + placeholder3 = 'ENTER START INDEX (DEFAULT=0)'; + } + + $("#args").attr('placeholder', placeholder); + $('#args').val(''); + $("#args2").attr('placeholder', placeholder2); + $('#args2').val(''); + $("#args3").attr('placeholder', placeholder3); + $('#args3').val(''); + $('#json-data').html(''); + } +} + +/** + * Process action (api calls) + */ +function processAction(activeTab, args, args2, args3) { + if (activeTab == '#link-pairing') + return lib_api.getPairingInfo(); + else if (activeTab == '#link-status-api') + return lib_api.getApiStatus(); + else if (activeTab == '#link-status-pushtx') + return lib_api.getPushtxStatus(); + else if (activeTab == '#link-orchestrator') + return lib_api.getOrchestratorStatus(); + + if (args == '') { + alert('Argument is mandatory'); + return; + } + + if (activeTab == '#link-info-xpub') { + return lib_api.getXpubInfo(args); + } else if (activeTab == '#link-rescan-xpub') { + const nbAddr = (!args2) ? 100 : parseInt(args2); + const startIdx = (!args3) ? 0 : parseInt(args3); + return lib_api.getXpubRescan(args, nbAddr, startIdx); + } else if (activeTab == '#link-info-address') { + return lib_api.getAddressInfo(args); + } else if (activeTab == '#link-rescan-address') { + return lib_api.getAddressRescan(args); + } else if (activeTab == '#link-tx') { + return lib_api.getTransaction(args); + } + + const jsonData = {}; + const aArgs = args.split('&'); + for (let arg of aArgs) { + const aArg = arg.split('='); + jsonData[aArg[0]] = aArg[1]; + } + + if (activeTab == '#link-multiaddr') + return lib_api.getMultiaddr(jsonData); + else if (activeTab == '#link-unspent') + return lib_api.getUnspent(jsonData); + else if (activeTab == '#link-xpub') + return lib_api.postXpub(jsonData); +} + +/** + * Retrieve information about the xpub + */ +function processGo() { + const activeTab = sessionStorage.getItem('activeTab'); + const args = $("#args").val(); + const args2 = $("#args2").val(); + const args3 = $("#args3").val(); + + displayInfoMsg('Processing...'); + + let deferred = processAction(activeTab, args, args2, args3); + + deferred.then( + function (result) { + if (!result) + return; + let textJson = lib_fmt.cleanJson(result); + textJson = JSON.stringify(JSON.parse(textJson), null, 4); + textJson = lib_fmt.jsonSyntaxHighlight(textJson); + $('#json-data').html(textJson); + }, + function (jqxhr) { + let hasErrorMsg = + ('responseJSON' in jqxhr) && + (jqxhr['responseJSON'] != null) && + ('message' in jqxhr['responseJSON']); + + const msg = hasErrorMsg ? jqxhr['responseJSON']['message'] : jqxhr.statusText; + displayErrorMsg(msg); + } + ); +} + +/** + * Processing on loading completed + */ +$(document).ready(function() { + // Refresh the access token if needed + setInterval(() => { + lib_auth.refreshAccessToken(); + }, 300000); + + initTabs(); + preparePage(); + + // Sets the event handlers + $('#args').keyup(function(evt) { + if (evt.keyCode === 13) { + processGo(); + } + }); + $('#btn-go').click(function() { + processGo(); + }); + $('#btn-logout').click(function() { + lib_auth.logout(); + }); +}); diff --git a/test/a-init-network.js b/test/a-init-network.js new file mode 100644 index 0000000..dc7a617 --- /dev/null +++ b/test/a-init-network.js @@ -0,0 +1,29 @@ +/** + * test/a-init-network.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const network = require('../lib/bitcoin/network') +network.key = 'testnet' +network.network = bitcoin.networks.testnet +const hdaHelper = require('../lib/bitcoin/hd-accounts-helper') +const addrHelper = require('../lib/bitcoin/addresses-helper') + + +/** + * Force testnet for all the unit tests + */ +describe('InitTest', function() { + + describe('initTests()', function() { + + it('should successfully initialize testnet', function() { + assert(true) + }) + + }) + +}) diff --git a/test/lib/bitcoin/addresses-helper-test.js b/test/lib/bitcoin/addresses-helper-test.js new file mode 100644 index 0000000..b59b798 --- /dev/null +++ b/test/lib/bitcoin/addresses-helper-test.js @@ -0,0 +1,165 @@ +/*! + * test/lib/bitcoin/addresses-helper.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ + +'use strict' + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const btcMessage = require('bitcoinjs-message') +const network = require('../../../lib/bitcoin/network') +const activeNet = network.network +const addrHelper = require('../../../lib/bitcoin/addresses-helper') + + +/** + * Test vectors + */ + +const ZPUB = 'zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs' + +const VECTOR_1 = [ + [ + '0330d54fd0dd420a6e5f8d3624f5f3482cae350f79d5f0753bf5beef9c2d91af3c', + 'my6RhGaMEf8v9yyQKqiuUYniJLfyU4gzqe', + '2N8ShdHvtvhbbrWPBQkgTqvNtP5Bp33veEi', + 'tb1qcr8te4kr609gcawutmrza0j4xv80jy8zmfp6l0' + ], + [ + '03e775fd51f0dfb8cd865d9ff1cca2a158cf651fe997fdc9fee9c1d3b5e995ea77', + 'munoNuscNJfEbrQyEQt1CmYDeNtQseT378', + '2N6erLsHUv6mpaiHS6UVy3EEtNU1mtgF6Bq', + 'tb1qnjg0jd8228aq7egyzacy8cys3knf9xvrn9d67m' + ], + [ + '03025324888e429ab8e3dbaf1f7802648b9cd01e9b418485c5fa4c1b9b5700e1a6', + 'mmBsCKnjnyGQbHanuXgRRocN43Tmb1TLJG', + '2N6HZAqLDHQGHhb1sFRYkdZMFEijiXD7Yvx', + 'tb1q8c6fshw2dlwun7ekn9qwf37cu2rn755ut76fzv' + ] +] + +const VECTOR_2 = [ + ['0330d54fd0dd420a6e5f8d3624f5f3482cae350f79d5f0753bf5beef9c2d91af3c', true], + ['0239c7029670faa4882bbdf6599127a6e3b39519c3d02bb5825d9db424d647d553', true], + ['046655feed4d214c261e0a6b554395596f1f1476a77d999560e5a8df9b8a1a3515217e88dd05e938efdd71b2cce322bf01da96cd42087b236e8f5043157a9c068e', false] +] + +const VECTOR_3 = [ + ['tb1qcr8te4kr609gcawutmrza0j4xv80jy8zmfp6l0', true], + ['my6RhGaMEf8v9yyQKqiuUYniJLfyU4gzqe', false], + ['2N8ShdHvtvhbbrWPBQkgTqvNtP5Bp33veEi', false] +] + +const VECTOR_4 = [ + ['tb1qcr8te4kr609gcawutmrza0j4xv80jy8zmfp6l0', 'c0cebcd6c3d3ca8c75dc5ec62ebe55330ef910e2'], + ['tb1qnjg0jd8228aq7egyzacy8cys3knf9xvrn9d67m', '9c90f934ea51fa0f6504177043e0908da6929983'], + ['tb1q8c6fshw2dlwun7ekn9qwf37cu2rn755ut76fzv', '3e34985dca6fddc9fb369940e4c7d8e2873f529c'] +] + +// privkey, pubkey, [[msg, sig, expected result]] +const VECTOR_5 = [ + [ + '9eedbdda033d9e34bc5d197011347a1cd69ca10b4b3db5a08e97176c3650b814', + '03fc9f2d8cd6e576e50ca3bc76e64186788075def0eef1f5d8c8dda803c4fcd999', + [ + [ + 'this is a message to be signed', + '207438b235b471b1fdc143924eb2c44e8de7aa870c776402ded6dd414816c6b43c49524df636d8cd3353ce5a15ef18f385fc7a68866f09d6df41a8635c234684f2', + true + ], + [ + 'this is a message to be signed', + '207438b235b471b1fdc143924eb2c44e8de7aa870c776402ded6dd414816c6b43c49524df636d8cd3353ce5a15ef18f385fc7a68866f09d6df41a8635c234684f3', + false + ] + ] + ] +] + + +describe('AddressesHelper', function() { + + describe('p2pkhAddress()', function() { + it('should successfully derive P2PKH addresses from pubkeys', function() { + for (const v of VECTOR_1) { + const pkb = new Buffer(v[0], 'hex') + const addr = addrHelper.p2pkhAddress(pkb) + assert(addr == v[1]) + } + }) + }) + + describe('p2wpkhP2shAddress()', function() { + it('should successfully derive P2WPKH-P2SH addresses from pubkeys', function() { + for (const v of VECTOR_1) { + const pkb = new Buffer(v[0], 'hex') + const addr = addrHelper.p2wpkhP2shAddress(pkb) + assert(addr == v[2]) + } + }) + }) + + describe('p2wpkhAddress()', function() { + it('should successfully derive bech32 addresses from pubkeys', function() { + for (const v of VECTOR_1) { + const pkb = new Buffer(v[0], 'hex') + const addr = addrHelper.p2wpkhAddress(pkb) + assert(addr == v[3]) + } + }) + }) + + describe('isSupportedPubKey()', function() { + it('should successfully detect a compressed pubkey', function() { + for (const v of VECTOR_2) { + assert(addrHelper.isSupportedPubKey(v[0]) == v[1]) + } + }) + }) + + describe('isBech32()', function() { + it('should successfully detect a bech32 address', function() { + for (const v of VECTOR_3) { + assert(addrHelper.isBech32(v[0]) == v[1]) + } + }) + }) + + describe('getScriptHashFromBech32()', function() { + it('should successfully extract the script hash from a bech32 address', function() { + for (const v of VECTOR_4) { + assert(addrHelper.getScriptHashFromBech32(v[0]) == v[1]) + } + }) + }) + + describe('verifySignature()', function() { + it('should successfully verify signatures', function() { + const prefix = activeNet.messagePrefix + + for (const tc of VECTOR_5) { + const privKey = Buffer.from(tc[0], 'hex') + const pubKey = Buffer.from(tc[1], 'hex') + const address = addrHelper.p2pkhAddress(pubKey) + + for (const stc of tc[2]) { + const msg = stc[0] + const targetSig = Buffer.from(stc[1], 'hex') + const expectedResult = stc[2] + + const sig = btcMessage.sign(msg, prefix, privKey, true) + + // Check that library returns valid result + assert((sig.compare(targetSig) == 0) == expectedResult) + + // Check method + const result = addrHelper.verifySignature(msg, address, sig) + assert(result) + } + } + }) + }) + +}) diff --git a/test/lib/bitcoin/hd-accounts-helper-test.js b/test/lib/bitcoin/hd-accounts-helper-test.js new file mode 100644 index 0000000..280718c --- /dev/null +++ b/test/lib/bitcoin/hd-accounts-helper-test.js @@ -0,0 +1,176 @@ +/*! + * test/lib/bitcoin/hd-accounts-helper.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ + +'use strict' + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const network = require('../../../lib/bitcoin/network') +const hdaHelper = require('../../../lib/bitcoin/hd-accounts-helper') + + +/** + * Test vectors + */ + +const XPUB = 'tpubDDDAe7GgFT4fzEzKwWVA4BWo8fiJXQeGEYDTexzo2w6CK1iDoLPYkpEisXo623ieF79GQ3xpcEVN1vcQhX2sysyL8o1XqzBmQb9JReTxQ7w' +const YPUB = 'upub5ELkCsSF68UnAZE7zF9CDztvHeBJiAAhwa4VxEFzZ1CfQRbpy93mkBbUZsqYVpoeEHFwY3fGh9bfftH79ZwbhjUEUBAxQj551TMxVyny4UX' +const ZPUB = 'vpub5ZB1WY7AEp2G1rREpbvpS5zRTcKkenACrgaijd9sw1aYTXR4DoDLNFFcb5o8VjTZdvNkHXFq9oxDZAtfsGMcVy9qLWsNzdtZHBRbtXe87LB' + +const BIP44_VECTORS = [ + [0, 0, 'mmZ5FRccGAkwfKme4JkrsmurnimDLdfmNL'], + [0, 1, 'n3yomLicyrSULiNWFKHsK8erntSpJZEPV6'], + [0, 2, 'mvVYLwjmMuYVWbuTyB9UE6LWah9tevLrrE'], + [0, 3, 'n1CrG3NpdTiFWh8KgsnAGUgn6aEF8xvYY2'], + [0, 4, 'mw3JvPz3wdUVrmTD6WugHgahk97QWnD61L'], + + [1, 0, 'miYMfmg3F3QpBJ48oVzvSi4NVgi93ykJ1L'], + [1, 1, 'mvEnHm9ZFcdnBa5wNfiJ6yVViex8wReDJJ'], + [1, 2, 'muSWDErhMRUHb6nSQqnVLp3TctqsKjKY4G'], + [1, 3, 'mhxsuiLirgVeRT9Nb9iUVrmCTgNDc1tcNa'], + [1, 4, 'mtj8CDwFPa4cfyK9cgfSCaXvDxdszgFFVU'] +] + +const BIP49_VECTORS = [ + [0, 0, '2NCmqrb5eXMYZUxdnY4Dr8h3FKqH6JmWCco'], + [0, 1, '2NCxTGKxDsv9gyC2wjBev85WHP1GN8LCKfR'], + [0, 2, '2N7vmdwgKjVxkivSou6F8Zaj37SxH7jASaC'], + [0, 3, '2NBeYshMWNj5jiMBuk9mfywY2853QKgDJ9k'], + [0, 4, '2MutR6UcnThCUmFJVUrT2z265pNGQcj6DV3'], + + [1, 0, '2MvSusqGmAB5MNz66dVLndV8AVKBvhidCdS'], + [1, 1, '2MxCqx15GTdW8wDXAVSsxnmHTjoqQLEEzQt'], + [1, 2, '2N7megh7h2CiCcGWcXax266BtjxZy5Hovrf'], + [1, 3, '2N8CrDFMsFA7Gs9phdA7xpm3RrDgvk719ro'], + [1, 4, '2Msi1iNCJcxsxX5ENiVzzqWw8GuCJG8zfmV'] +] + +const BIP84_VECTORS = [ + [0, 0, 'tb1qggmkgcrk5zdwm8wlh2nzqv5k7xunv3tqk6w9p0'], + [0, 1, 'tb1q7enwpjlzuc3taq69mkpyqmkwn8d5mtrvmvzl9m'], + [0, 2, 'tb1q53zh56awxvk824msyxhfjtlwg4fwd3s2s5wygh'], + [0, 3, 'tb1q6l6lm298eq5qkwntl42lv2x0vw6yny50ugnuef'], + [0, 4, 'tb1q4fre2as0az62am5eaj30tupv92crqd8yjpu67w'], + + [1, 0, 'tb1qyykyu2y9lx6qt2y6j3nur88ssnpuapnug9zuv4'], + [1, 1, 'tb1q59awztrl7dfn7l38a8uvgrkstrw4lf4fwmz2kt'], + [1, 2, 'tb1qnza9973gp8f7rm9k9yc327zwdvz9wl9sa3yvp7'], + [1, 3, 'tb1qrttk0uzx656uupg9w8f39ec6e6c8wwcts4fanj'], + [1, 4, 'tb1qjrnw8u2pvspm6hq3aa83ff93wevq2zyxqczewy'] +] + +const HD_TYPES_VECTORS = [ + // unlocked + [0, hdaHelper.BIP44, false], + [1, hdaHelper.BIP49, false], + [2, hdaHelper.BIP84, false], + // locked + [128, hdaHelper.BIP44, true], + [129, hdaHelper.BIP49, true], + [130, hdaHelper.BIP84, true], +] + + +describe('HdAccountsHelper', function() { + + describe('isXpub()', function() { + it('should successfully detect a XPUB', function() { + assert(hdaHelper.isXpub(XPUB)) + assert(!hdaHelper.isXpub(YPUB)) + assert(!hdaHelper.isXpub(ZPUB)) + }) + + it('should successfully detect a YPUB', function() { + assert(!hdaHelper.isYpub(XPUB)) + assert(hdaHelper.isYpub(YPUB)) + assert(!hdaHelper.isYpub(ZPUB)) + }) + + it('should successfully detect a ZPUB', function() { + assert(!hdaHelper.isZpub(XPUB)) + assert(!hdaHelper.isZpub(YPUB)) + assert(hdaHelper.isZpub(ZPUB)) + }) + }) + + + describe('isValid()', function() { + it('should successfully validate a valid XPUB', function() { + assert(hdaHelper.isValid(XPUB)) + }) + + it('should successfully validate a valid YPUB', function() { + assert(hdaHelper.isValid(YPUB)) + }) + + it('should successfully validate a valid ZPUB', function() { + assert(hdaHelper.isValid(ZPUB)) + }) + }) + + + describe('classify()', function() { + it('should successfully classify the code stored in db', function() { + for (const v of HD_TYPES_VECTORS) { + const ret = hdaHelper.classify(v[0]) + assert(ret.type == v[1]) + assert(ret.locked == v[2]) + } + }) + }) + + + describe('makeType()', function() { + it('should successfully compute the code stored in db', function() { + for (const v of HD_TYPES_VECTORS) { + const ret = hdaHelper.makeType(v[1], v[2]) + assert(ret == v[0]) + } + }) + }) + + + describe('deriveAddresses()', () => { + it('should successfully derive addresses with BIP44', async () => { + for (const v of BIP44_VECTORS) { + const addresses = await hdaHelper.deriveAddresses(XPUB, v[0], [v[1]], hdaHelper.BIP44) + assert(addresses[0].address == v[2]) + } + }) + + it('should successfully derive addresses with BIP49', async () => { + for (const v of BIP49_VECTORS) { + const addresses = await hdaHelper.deriveAddresses(XPUB, v[0], [v[1]], hdaHelper.BIP49) + assert(addresses[0].address == v[2]) + } + }) + + it('should successfully derive addresses with BIP84', async () => { + for (const v of BIP84_VECTORS) { + const addresses = await hdaHelper.deriveAddresses(XPUB, v[0], [v[1]], hdaHelper.BIP84) + assert(addresses[0].address == v[2]) + } + }) + }) + + + describe('xlatXPUB()', function() { + it('should successfully translate XPUB in YPUB', function() { + const xpubXlated = hdaHelper.xlatXPUB(XPUB) + assert(xpubXlated == XPUB) + }) + + it('should successfully translate YPUB in XPUB', function() { + const ypubXlated = hdaHelper.xlatXPUB(YPUB) + assert(ypubXlated == XPUB) + }) + + it('should successfully translate ZPUB in XPUB', function() { + const zpubXlated = hdaHelper.xlatXPUB(ZPUB) + assert(zpubXlated == XPUB) + }) + }) + +}) diff --git a/tracker/abstract-processor.js b/tracker/abstract-processor.js new file mode 100644 index 0000000..3f46325 --- /dev/null +++ b/tracker/abstract-processor.js @@ -0,0 +1,50 @@ +/*! + * tracker/abstract-processor.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const RpcClient = require('../lib/bitcoind-rpc/rpc-client') + + +/** + * An abstract class for tracker processors + */ +class AbstractProcessor { + + /** + * Constructor + * @param {object} notifSock - ZMQ socket used for notifications + */ + constructor(notifSock) { + // RPC client + this.client = new RpcClient() + // ZeroMQ socket for notifications sent to others components + this.notifSock = notifSock + } + + /** + * Notify a new transaction + * @param {object} tx - bitcoin transaction + */ + notifyTx(tx) { + // Real-time client updates for this transaction. + // Any address input or output present in transaction + // is a potential client to notify. + if (this.notifSock) + this.notifSock.send(['transaction', JSON.stringify(tx)]) + } + + /** + * Notify a new block + * @param {string} header - block header + */ + notifyBlock(header) { + // Notify clients of the block + if (this.notifSock) + this.notifSock.send(['block', JSON.stringify(header)]) + } + +} + +module.exports = AbstractProcessor diff --git a/tracker/block.js b/tracker/block.js new file mode 100644 index 0000000..42f02d8 --- /dev/null +++ b/tracker/block.js @@ -0,0 +1,116 @@ +/*! + * tracker/block.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const bitcoin = require('bitcoinjs-lib') +const util = require('../lib/util') +const Logger = require('../lib/logger') +const db = require('../lib/db/mysql-db-wrapper') +const Transaction = require('./transaction') +const TransactionsBundle = require('./transactions-bundle') + + +/** + * A class allowing to process a transaction + */ +class Block extends TransactionsBundle { + + /** + * Constructor + * @param {string} hex - block in hex format + * @param {string} header - block header + */ + constructor(hex, header) { + super() + this.hex = hex + this.header = header + } + + /** + * Register the block and transactions of interest in db + * @returns {Promise - object[]} returns an array of transactions to be broadcast + */ + async checkBlock() { + Logger.info('Beginning to process new block.') + + let block + const txsForBroadcast = [] + + try { + block = bitcoin.Block.fromHex(this.hex) + this.transactions = block.transactions + } catch (e) { + Logger.error(e, 'Block.checkBlock()') + Logger.error(null, this.header) + return Promise.reject(e) + } + + const t0 = Date.now() + let ntx = 0 + + // Filter transactions + const filteredTxs = await this.prefilterTransactions() + + // Check filtered transactions + // and broadcast notifications + await util.seriesCall(filteredTxs, async tx => { + const filteredTx = new Transaction(tx) + const txCheck = await filteredTx.checkTransaction() + if (txCheck && txCheck.broadcast) + txsForBroadcast.push(txCheck.tx) + }) + + // Retrieve the previous block + // and store the new block into the database + const prevBlock = await db.getBlockByHash(this.header.previousblockhash) + const prevID = (prevBlock && prevBlock.blockID) ? prevBlock.blockID : null + + const blockId = await db.addBlock({ + blockHeight: this.header.height, + blockHash: this.header.hash, + blockTime: this.header.time, + blockParent: prevID + }) + + Logger.info(` Added block ${this.header.height} (id=${blockId})`) + + // Confirms the transactions + const txids = this.transactions.map(t => t.getId()) + ntx = txids.length + const txidLists = util.splitList(txids, 100) + await util.seriesCall(txidLists, list => db.confirmTransactions(list, blockId)) + + // Logs and result returned + const dt = ((Date.now()-t0)/1000).toFixed(1) + const per = ((Date.now()-t0)/ntx).toFixed(0) + Logger.info(` Finished block ${this.header.height}, ${dt}s, ${ntx} tx, ${per}ms/tx`) + + return txsForBroadcast + } + + /** + * Register the block header + * @param {int} prevBlockID - id of previous block + * @returns {Promise} + */ + async checkBlockHeader(prevBlockID) { + Logger.info('Beginning to process new block header.') + + // Insert the block header into the database + const blockId = await db.addBlock({ + blockHeight: this.header.height, + blockHash: this.header.hash, + blockTime: this.header.time, + blockParent: prevBlockID + }) + + Logger.info(` Added block header ${this.header.height} (id=${blockId})`) + + return blockId + } + +} + +module.exports = Block diff --git a/tracker/blockchain-processor.js b/tracker/blockchain-processor.js new file mode 100644 index 0000000..e1266f7 --- /dev/null +++ b/tracker/blockchain-processor.js @@ -0,0 +1,370 @@ +/*! + * tracker/blockchain-processor.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const _ = require('lodash') +const zmq = require('zeromq') +const Sema = require('async-sema') +const util = require('../lib/util') +const Logger = require('../lib/logger') +const db = require('../lib/db/mysql-db-wrapper') +const network = require('../lib/bitcoin/network') +const keys = require('../keys')[network.key] +const AbstractProcessor = require('./abstract-processor') +const Block = require('./block') +const TransactionsBundle = require('./transactions-bundle') + + +/** + * A class allowing to process the blockchain + */ +class BlockchainProcessor extends AbstractProcessor { + + /** + * Constructor + * @param {object} notifSock - ZMQ socket used for notifications + */ + constructor(notifSock) { + super(notifSock) + // ZeroMQ socket for bitcoind blocks messages + this.blkSock = null + // Initialize a semaphor protecting the onBlockHash() method + this._onBlockHashSemaphor = new Sema(1, { capacity: 50 }) + // Flag tracking Initial Block Download Mode + this.isIBD = true + } + + /** + * Start processing the blockchain + * @returns {Promise} + */ + async start() { + await this.catchup() + await this.initSockets() + } + + /** + * Start processing the blockchain + */ + async stop() {} + + /** + * Tracker process startup + * @returns {Promise} + */ + async catchup() { + // Get highest block in the blockchain + const info = await this.client.getblockchaininfo() + + // Consider that we are in IBD mode if bitcoind is far in the past + this.isIBD = info.initialblockdownload && (info.blocks < 550000) + + if (this.isIBD) + return this.catchupIBDMode() + else + return this.catchupNormalMode() + } + + /** + * Tracker process startup (normal mode) + * 1. Grab the latest block height from the daemon + * 2. Pull all block headers after database last known height + * 3. Process those block headers + * + * @returns {Promise} + */ + async catchupIBDMode() { + try { + Logger.info('Tracker Startup (IBD mode)') + + const info = await this.client.getblockchaininfo() + const daemonNbBlocks = info.blocks + const daemonNbHeaders = info.headers + + // Get highest block processed by the tracker + const highest = await db.getHighestBlock() + const dbMaxHeight = highest.blockHeight + let prevBlockId = highest.blockID + + // If no header or block loaded by bitcoind => try later + if (daemonNbHeaders == 0 || daemonNbBlocks == 0) { + Logger.info('New attempt scheduled in 30s (waiting for block headers)') + return util.delay(30000).then(() => { + return this.catchupIBDMode() + }) + + // If we have more blocks to load in db + } else if (daemonNbHeaders - 1 > dbMaxHeight) { + + // If blocks need to be downloaded by bitcoind => try later + if (daemonNbBlocks - 1 <= dbMaxHeight) { + Logger.info('New attempt scheduled in 10s (waiting for blocks)') + return util.delay(10000).then(() => { + return this.catchupIBDMode() + }) + + // If some blocks are ready for an import in db + } else { + const blockRange = _.range(dbMaxHeight + 1, daemonNbBlocks + 1) + + Logger.info(`Sync ${blockRange.length} blocks`) + + await util.seriesCall(blockRange, async height => { + try { + const blockHash = await this.client.getblockhash(height) + const header = await this.client.getblockheader(blockHash, true) + prevBlockId = await this.processBlockHeader(header, prevBlockId) + } catch(e) { + Logger.error(e, 'BlockchainProcessor.catchupIBDMode()') + process.exit() + } + }, 'Tracker syncing', true) + + // Schedule a new iteration (in case more blocks need to be loaded) + Logger.info('Start a new iteration') + return this.catchupIBDMode() + } + + // If we are synced + } else { + this.isIBD = false + } + + } catch(e) { + Logger.error(e, 'BlockchainProcessor.catchupIBDMode()') + throw e + } + } + + /** + * Tracker process startup (normal mode) + * 1. Grab the latest block height from the daemon + * 2. Pull all block headers after database last known height + * 3. Process those block headers + * + * @returns {Promise} + */ + async catchupNormalMode() { + try { + Logger.info('Tracker Startup (normal mode)') + + const info = await this.client.getblockchaininfo() + const daemonNbBlocks = info.blocks + + // Get highest block processed by the tracker + const highest = await db.getHighestBlock() + if (highest == null) return null + if (daemonNbBlocks == highest.blockHeight) return null + + // Compute blocks range to be processed + const blockRange = _.range(highest.blockHeight, daemonNbBlocks + 1) + + Logger.info(`Sync ${blockRange.length} blocks`) + + // Process the blocks + return util.seriesCall(blockRange, async height => { + try { + const hash = await this.client.getblockhash(height) + const header = await this.client.getblockheader(hash) + return this.processBlock(header) + } catch(e) { + Logger.error(e, 'BlockchainProcessor.catchupNormalMode()') + process.exit() + } + }, 'Tracker syncing', true) + + } catch(e) { + Logger.error(e, 'BlockchainProcessor.catchupNormalMode()') + } + } + + /** + * Initialiaze ZMQ sockets + */ + initSockets() { + // Socket listening to bitcoind Blocks messages + this.blkSock = zmq.socket('sub') + this.blkSock.connect(keys.bitcoind.zmqBlk) + this.blkSock.subscribe('hashblock') + + this.blkSock.on('message', (topic, message) => { + switch (topic.toString()) { + case 'hashblock': + this.onBlockHash(message) + break + default: + Logger.info(topic.toString()) + } + }) + + Logger.info('Listening for blocks') + } + + /** + * Upon receipt of a new block hash, retrieve the block header from bitcoind via + * RPC. Continue pulling block headers back through the chain until the database + * contains header.previousblockhash, adding the headers to a stack. If the + * previousblockhash is not found on the first call, this is either a chain + * re-org or the tracker missed blocks during a shutdown. + * + * Once the chain has bottomed out with a known block in the database, delete + * all known database transactions confirmed in blocks at heights greater than + * the last known block height. These transactions are orphaned but may reappear + * in the new chain. Notify relevant accounts of balance updates / + * transaction confirmation counts. + * + * Delete block entries not on the main chain. + * + * Forward-scan through the block headers, pulling the full raw block hex via + * RPC. The raw block contains all transactions and is parsed by bitcoinjs-lib. + * Add the block to the database. Run checkTransaction for each transaction in + * the block that is not in the database. Confirm all transactions in the block. + * + * After each block, query bitcoin against all database unconfirmed outputs + * to see if they remain in the mempool or have been confirmed in blocks. + * Malleated transactions entering the wallet will disappear from the mempool on + * block confirmation. + * + * @param {Buffer} buf - block + * @returns {Promise} + */ + async onBlockHash(buf) { + try { + // Acquire the semaphor + await this._onBlockHashSemaphor.acquire() + + const blockHash = buf.toString('hex') + let headers = null + + try { + const header = await this.client.getblockheader(blockHash, true) + Logger.info(`Block #${header.height} ${blockHash}`) + // Grab all headers between this block and last known + headers = await this.chainBacktrace([header]) + } catch(err) { + Logger.error(err, `BlockchainProcessor.onBlockHash() : error in getblockheader(${blockHash})`) + } + + if(headers == null) + return null + + // Reverse headers to put oldest first + headers.reverse() + + const deepest = headers[0] + const knownHeight = deepest.height - 1 + + // Cancel confirmation of transactions + // and delete blocks after the last known block height + await this.rewind(knownHeight) + + // Process the blocks + return await util.seriesCall(headers, header => { + return this.processBlock(header) + }) + + } catch(e) { + Logger.error(e, 'BlockchainProcessor.onBlockHash()') + } finally { + // Release the semaphor + await this._onBlockHashSemaphor.release() + } + } + + /** + * Zip back up the blockchain until a known prevHash is found, returning all + * block headers from last header in the array to the block after last known. + * @param {object[]} headers - array of block headers + * @returns {Promise} + */ + async chainBacktrace(headers) { + // Block deepest in the blockchain is the last on the list + const deepest = headers[headers.length - 1] + + if (headers.length > 1) + Logger.info(`chainBacktrace @ height ${deepest.height}, ${headers.length} blocks`) + + // Look for previous block in the database + const block = await db.getBlockByHash(deepest.previousblockhash) + + if (block == null) { + // Previous block does not exist in database. Grab from bitcoind + const header = await this.client.getblockheader(deepest.previousblockhash, true) + headers.push(header) + return this.chainBacktrace(headers) + } else { + // Previous block does exist. Return headers + return headers + } + } + + /** + * Cancel confirmation of transactions + * and delete blocks after a given height + * @param {integer} height - height of last block maintained + * @returns {Promise} + */ + async rewind(height) { + // Retrieve transactions confirmed in reorg'd blocks + const txs = await db.getTransactionsConfirmedAfterHeight(height) + + if (txs.length > 0) { + // Cancel confirmation of transactions included in reorg'd blocks + Logger.info(`Backtrace: unconfirm ${txs.length} transactions in reorg`) + const txids = txs.map(t => t.txnTxid) + await db.unconfirmTransactions(txids) + } + + // TODO: get accounts and notify of deletion ? + + await db.deleteBlocksAfterHeight(height) + } + + /** + * Process a block + * @param {object} header - block header + * @returns {Promise} + */ + async processBlock(header) { + try { + // Get raw block hex string from bitcoind + const hex = await this.client.getblock(header.hash, false) + + const block = new Block(hex, header) + + const txsForBroadcast = await block.checkBlock() + + // Send notifications + for (let tx of txsForBroadcast) + this.notifyTx(tx) + + this.notifyBlock(header) + + } catch(e) { + // The show must go on. + // TODO: further notification that this block did not check out + Logger.error(e, 'BlockchainProcessor.processBlock()') + } + } + + /** + * Process a block header + * @param {object} header - block header + * @param {int} prevBlockID - id of previous block + * @returns {Promise} + */ + async processBlockHeader(header, prevBlockID) { + try { + const block = new Block(null, header) + return block.checkBlockHeader(prevBlockID) + } catch(e) { + Logger.error(e, 'BlockchainProcessor.processBlockHeader()') + throw e + } + } + +} + +module.exports = BlockchainProcessor diff --git a/tracker/index.js b/tracker/index.js new file mode 100644 index 0000000..54d2285 --- /dev/null +++ b/tracker/index.js @@ -0,0 +1,40 @@ +/*! + * tracker/index.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +(async () => { + + 'use strict' + + const RpcClient = require('../lib/bitcoind-rpc/rpc-client') + const network = require('../lib/bitcoin/network') + const keys = require('../keys')[network.key] + const db = require('../lib/db/mysql-db-wrapper') + const Logger = require('../lib/logger') + const Tracker = require('./tracker') + + + Logger.info('Process ID: ' + process.pid) + Logger.info('Preparing the tracker') + + // Wait for Bitcoind RPC API + // being ready to process requests + await RpcClient.waitForBitcoindRpcApi() + + // Initialize the db wrapper + const dbConfig = { + connectionLimit: keys.db.connectionLimitTracker, + acquireTimeout: keys.db.acquireTimeout, + host: keys.db.host, + user: keys.db.user, + password: keys.db.pass, + database: keys.db.database + } + + db.connect(dbConfig) + + // Start the tracker + const tracker = new Tracker() + tracker.start() + +})() diff --git a/tracker/mempool-processor.js b/tracker/mempool-processor.js new file mode 100644 index 0000000..8f53148 --- /dev/null +++ b/tracker/mempool-processor.js @@ -0,0 +1,282 @@ +/*! + * tracker/mempool-buffer.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const _ = require('lodash') +const zmq = require('zeromq') +const bitcoin = require('bitcoinjs-lib') +const util = require('../lib/util') +const Logger = require('../lib/logger') +const db = require('../lib/db/mysql-db-wrapper') +const network = require('../lib/bitcoin/network') +const keys = require('../keys')[network.key] +const AbstractProcessor = require('./abstract-processor') +const Transaction = require('./transaction') +const TransactionsBundle = require('./transactions-bundle') + + +/** + * A class managing a buffer for the mempool + */ +class MempoolProcessor extends AbstractProcessor { + + /** + * Constructor + * @param {object} notifSock - ZMQ socket used for notifications + */ + constructor(notifSock) { + super(notifSock) + // Mempool buffer + this.mempoolBuffer = new TransactionsBundle() + // ZeroMQ socket for bitcoind Txs messages + this.txSock = null + // ZeroMQ socket for pushtx messages + this.pushTxSock = null + // ZeroMQ socket for pushtx orchestrator messages + this.orchestratorSock = null + // Flag indicating if processor should process the transactions + // Processor is deactivated if the tracker is late + // (priority is given to the blockchain processor) + this.isActive = false + } + + /** + * Start processing the mempool + * @returns {Promise} + */ + async start() { + this.checkUnconfirmedId = setInterval( + _.bind(this.checkUnconfirmed, this), + keys.tracker.unconfirmedTxsProcessPeriod + ) + + await this.checkUnconfirmed() + + this.initSockets() + + this.processMempoolId = setInterval( + _.bind(this.processMempool, this), + keys.tracker.mempoolProcessPeriod + ) + + await this.processMempool() + + /*this.displayStatsId = setInterval(_.bind(this.displayMempoolStats, this), 60000) + await this.displayMempoolStats()*/ + } + + /** + * Stop processing + */ + async stop() { + clearInterval(this.checkUnconfirmedId) + clearInterval(this.processMempoolId) + //clearInterval(this.displayStatsId) + + resolve(this.txSock.disconnect(keys.bitcoind.zmqTx).close()) + resolve(this.pushTxSock.disconnect(keys.ports.notifpushtx).close()) + resolve(this.orchestratorSock.disconnect(keys.ports.orchestrator).close()) + } + + /** + * Initialiaze ZMQ sockets + */ + async initSockets() { + // Socket listening to pushTx + this.pushTxSock = zmq.socket('sub') + this.pushTxSock.connect(`tcp://127.0.0.1:${keys.ports.notifpushtx}`) + this.pushTxSock.subscribe('pushtx') + + this.pushTxSock.on('message', (topic, message) => { + switch (topic.toString()) { + case 'pushtx': + this.onPushTx(message) + break + default: + Logger.info(topic.toString()) + } + }) + + Logger.info('Listening for pushTx') + + // Socket listening to pushTx Orchestrator + this.orchestratorSock = zmq.socket('sub') + this.orchestratorSock.connect(`tcp://127.0.0.1:${keys.ports.orchestrator}`) + this.orchestratorSock.subscribe('pushtx') + + this.orchestratorSock.on('message', (topic, message) => { + switch (topic.toString()) { + case 'pushtx': + this.onPushTx(message) + break + default: + Logger.info(topic.toString()) + } + }) + + Logger.info('Listening for pushTx orchestrator') + + // Socket listening to bitcoind Txs messages + this.txSock = zmq.socket('sub') + this.txSock.connect(keys.bitcoind.zmqTx) + this.txSock.subscribe('rawtx') + + this.txSock.on('message', (topic, message) => { + switch (topic.toString()) { + case 'rawtx': + this.onTx(message) + break + default: + Logger.info(topic.toString()) + } + }) + + Logger.info('Listening for mempool transactions') + } + + /** + * Process transactions from the mempool buffer + * @returns {Promise} + */ + async processMempool() { + // Refresh the isActive flag + await this._refreshActiveStatus() + + const activeLbl = this.isActive ? 'active' : 'inactive' + Logger.info(`Processing ${activeLbl} Mempool (${this.mempoolBuffer.size()} transactions)`) + + let currentMempool = new TransactionsBundle(this.mempoolBuffer.toArray()) + this.mempoolBuffer.clear() + + const filteredTxs = await currentMempool.prefilterTransactions() + + return util.seriesCall(filteredTxs, async filteredTx => { + const tx = new Transaction(filteredTx) + const txCheck = await tx.checkTransaction() + if (txCheck && txCheck.broadcast) + this.notifyTx(txCheck.tx) + }) + } + + /** + * On reception of a new transaction from bitcoind mempool + * @param {Buffer} buf - transaction + * @returns {Promise} + */ + async onTx(buf) { + if (this.isActive) { + try { + let tx = bitcoin.Transaction.fromBuffer(buf) + this.mempoolBuffer.addTransaction(tx) + } catch (e) { + Logger.error(e, 'MempoolProcessor.onTx()') + return Promise.reject(e) + } + } + + return Promise.resolve() + } + + + /** + * On reception of a new transaction from /pushtx + * @param {Buffer} buf - transaction + * @returns {Promise} + */ + async onPushTx(buf) { + try { + let pushedTx = bitcoin.Transaction.fromHex(buf.toString()) + const txid = pushedTx.getId() + + Logger.info(`Processing tx for pushtx ${txid}`) + + if (!TransactionsBundle.cache.has(txid)) { + // Process the transaction + const tx = new Transaction(pushedTx) + const txCheck = await tx.checkTransaction() + // Notify the transaction if needed + if (txCheck && txCheck.broadcast) + this.notifyTx(txCheck.tx) + } + } catch (e) { + Logger.error(e, 'MempoolProcessor.onPushTx()') + return Promise.reject(e) + } + } + + /** + * Check unconfirmed transactions + * @returns {Promise} + */ + async checkUnconfirmed() { + const t0 = Date.now() + + Logger.info('Processing unconfirmed transactions') + + const unconfirmedTxs = await db.getUnconfirmedTransactions() + + if (unconfirmedTxs.length > 0) { + await util.seriesCall(unconfirmedTxs, tx => { + try { + return this.client.getrawtransaction(tx.txnTxid, true) + .then(async rtx => { + if (!rtx.blockhash) return null + // Transaction is confirmed + const block = await db.getBlockByHash(rtx.blockhash) + if (block && block.blockID) { + Logger.info(`Marking TXID ${tx.txnTxid} confirmed`) + return db.confirmTransactions([tx.txnTxid], block.blockID) + } + }, + () => { + // Transaction not in mempool. Update LRU cache and database + TransactionsBundle.cache.del(tx.txnTxid) + // TODO: Notify clients of orphaned transaction + return db.deleteTransaction(tx.txnTxid) + } + ) + } catch(e) { + Logger.error(e, 'MempoolProcessor.checkUnconfirmed()') + } + }) + } + + // Logs + const ntx = unconfirmedTxs.length + const dt = ((Date.now() - t0) / 1000).toFixed(1) + const per = (ntx == 0) ? 0 : ((Date.now() - t0) / ntx).toFixed(0) + Logger.info(` Finished processing unconfirmed transactions ${dt}s, ${ntx} tx, ${per}ms/tx`) + } + + /** + * Sets the isActive flag + */ + async _refreshActiveStatus() { + // Get highest header in the blockchain + const info = await this.client.getblockchaininfo() + const highestHeader = info.headers + + // Get highest block processed by the tracker + const highestBlock = await db.getHighestBlock() + if (highestBlock == null || highestBlock.blockHeight == 0) { + this.isActive = false + return + } + + // Tolerate a delay of 6 blocks + this.isActive = (highestHeader >= 550000) && (highestHeader <= highestBlock.blockHeight + 6) + } + + /** + * Log mempool statistics + */ + displayMempoolStats() { + Logger.info(`Mempool Size: ${this.mempoolBuffer.size()}`) + } + +} + + +module.exports = MempoolProcessor diff --git a/tracker/tracker.js b/tracker/tracker.js new file mode 100644 index 0000000..8ebaf99 --- /dev/null +++ b/tracker/tracker.js @@ -0,0 +1,55 @@ +/*! + * tracker/tracker.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const zmq = require('zeromq') +const network = require('../lib/bitcoin/network') +const keys = require('../keys')[network.key] +const BlockchainProcessor = require('./blockchain-processor') +const MempoolProcessor = require('./mempool-processor') + + +/** + * A class implementing a process tracking the blockchain + */ +class Tracker { + + /** + * Constructor + */ + constructor() { + // Notification socket for client events + this.notifSock = zmq.socket('pub') + this.notifSock.bindSync(`tcp://*:${keys.ports.tracker}`) + + // Initialize the blockchain processor + // and the mempool buffer + this.blockchainProcessor = new BlockchainProcessor(this.notifSock) + this.mempoolProcessor = new MempoolProcessor(this.notifSock) + } + + /** + * Start the tracker + * @returns {Promise} + */ + async start() { + this.startupTimeout = setTimeout(async function() { + await this.blockchainProcessor.start() + await this.mempoolProcessor.start() + }.bind(this), 1500) + } + + /** + * Stop the tracker + */ + async stop() { + clearTimeout(this.startupTimeout) + await this.blockchainProcessor.stop() + await this.mempoolProcessor.stop() + } + +} + +module.exports = Tracker diff --git a/tracker/transaction.js b/tracker/transaction.js new file mode 100644 index 0000000..e313227 --- /dev/null +++ b/tracker/transaction.js @@ -0,0 +1,399 @@ +/*! + * tracker/transaction.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const _ = require('lodash') +const bitcoin = require('bitcoinjs-lib') +const util = require('../lib/util') +const Logger = require('../lib/logger') +const hdaHelper = require('../lib/bitcoin/hd-accounts-helper') +const db = require('../lib/db/mysql-db-wrapper') +const network = require('../lib/bitcoin/network') +const keys = require('../keys')[network.key] +const gapLimit = [keys.gap.external, keys.gap.internal] +const activeNet = network.network +const TransactionsBundle = require('./transactions-bundle') + + +/** + * A class allowing to process a transaction + */ +class Transaction { + + /** + * Constructor + * @param {bitcoin.Transaction} tx - transaction object + */ + constructor(tx) { + this.tx = tx + this.txid = this.tx.getId() + // Id of transaction stored in db + this.storedTxnID = null + // Should this transaction be broadcast out to connected clients? + this.doBroadcast = false + } + + /** + * Register transaction in db if it's a transaction of interest + * @returns {object} returns a composite result object + * { + * tx: , + * broadcast: + * } + */ + async checkTransaction() { + try { + // Process transaction inputs + await this._processInputs() + + // Process transaction outputs + await this._processOutputs() + + // If this point reached with no errors, + // store the fact that this transaction was checked. + TransactionsBundle.cache.set(this.txid, Date.now()) + + const tx = await db.getTransaction(this.txid) + + return { + tx: tx, + broadcast: this.doBroadcast + } + + } catch(e) { + Logger.error(e, 'Transaction.checkTransaction()') + return Promise.reject(e) + } + } + + /** + * Process transaction inputs + * @returns {Promise} + */ + async _processInputs() { + // Array of inputs spent + const spends = [] + // Store input indices, keyed by `txid-outindex` for easy retrieval + const indexedInputs = {} + // Store database ids of double spend transactions + const doubleSpentTxnIDs = [] + // Store inputs of interest + const inputs = [] + + // Extracts inputs information + let index = 0 + + for (let input of this.tx.ins) { + const spendTxid = Buffer.from(input.hash).reverse().toString('hex') + spends.push({txid:spendTxid, index:input.index}) + indexedInputs[`${spendTxid}-${input.index}`] = index + index++ + } + + // Check if we find some inputs of interest + const results = await db.getOutputSpends(spends) + + if (results.length == 0) + return null + + // Flag the transaction for broadcast + this.doBroadcast = true + + // This transaction is spending an existing output. + // This is value leaving a wallet's addresses. + // Each result contains + // {outID, addrAddress, outAmount, txnTxid, outIndex, spendingTxnID/null} + + // Store the transaction in db + await this._ensureTransaction() + + // Prepare the inputs + for (let r of results) { + const index = indexedInputs[`${r.txnTxid}-${r.outIndex}`] + + inputs.push({ + txnID: this.storedTxnID, + outID: r.outID, + inIndex: index, + inSequence: this.tx.ins[index].sequence + }) + + // Detect potential double spends + if (r.spendingTxnID !== null && r.spendingTxnID != this.storedTxnID) { + Logger.info(`DOUBLE SPEND of ${r.txnTxid}-${r.outIndex} by ${this.txid}!`) + // Delete the existing transaction that has been double-spent: + // since the deepest block keeps its transactions, this will + // eventually work itself out, and the wallet will not show + // two transactions spending the same output. + doubleSpentTxnIDs.push(r.spendingTxnID) + } + } + + // Record the inputs of interest in the database + await db.addInputs(inputs) + + // Process the double spends + if (doubleSpentTxnIDs.length > 0) { + // Get txids to update LRU cache + const txs = await db.getTransactionsById(doubleSpentTxnIDs) + + for (let tx of txs) + TransactionsBundle.cache.del(tx.txnTxid) + + await db.deleteTransactionsByID(doubleSpentTxnIDs) + } + } + + /** + * Process transaction outputs + * @returns {Promise} + */ + async _processOutputs() { + // Store outputs, keyed by address. Values are arrays of outputs + const indexedOutputs = {} + + // Extracts outputs information + let index = 0 + + for (let output of this.tx.outs) { + try { + const address = bitcoin.address.fromOutputScript(output.script, activeNet) + if (!indexedOutputs[address]) + indexedOutputs[address] = [] + + indexedOutputs[address].push({ + index, + value: output.value, + script: output.script.toString('hex'), + }) + } catch(e) {} + index++ + } + + // Array of addresses receiving tx outputs + const addresses = _.keys(indexedOutputs) + + // Store a list of known addresses that received funds + let fundedAddresses = [] + + // Get HD Accounts that own any of the output addresses + const result = await db.getHDAccountsByAddresses(addresses) + + // Get outputs spending to loose addresses first + const aLooseAddr = await this._processOutputsLooseAddresses(result.loose, indexedOutputs) + fundedAddresses = fundedAddresses.concat(aLooseAddr) + + // Get outputs spending to a tracked account + const aHdAcctAddr = await this._processOutputsHdAccounts(result.hd, indexedOutputs) + fundedAddresses = fundedAddresses.concat(aHdAcctAddr) + + if (fundedAddresses.length == 0) + return null + + // Flag the transaction for broadcast + this.doBroadcast = true + + // Add the transaction to the database + await this._ensureTransaction() + + // Associate transaction outputs with known addresses + const outputs = [] + + for (let a of fundedAddresses) { + outputs.push({ + txnID: this.storedTxnID, + addrID: a.addrID, + outIndex: a.outIndex, + outAmount: a.outAmount, + outScript: a.outScript, + }) + } + + await db.addOutputs(outputs) + } + + /** + * Process outputs sending to tracked loose addresses + * @param {object[]} addresses - array of address objects + * @param {object} indexedOutputs - outputs indexed by address + * @returns {Promise - object[]} return an array of funded addresses + * {addrID: ..., outIndex: ..., outAmount: ..., outScript: ...} + */ + async _processOutputsLooseAddresses(addresses, indexedOutputs) { + // Store a list of known addresses that received funds + const fundedAddresses = [] + + // Get outputs spending to loose addresses first + for (let a of addresses) { + if (indexedOutputs[a.addrAddress]) { + for (let output of indexedOutputs[a.addrAddress]) { + fundedAddresses.push({ + addrID: a.addrID, + outIndex: output.index, + outAmount: output.value, + outScript: output.script, + }) + } + } + } + + return Promise.resolve(fundedAddresses) + } + + /** + * Process outputs sending to tracked hd accounts + * @param {object[]} hdAccounts - array of hd account objects + * @param {object} indexedOutputs - outputs indexed by address + * @returns {Promise - object[]} return an array of funded addresses + * {addrID: ..., outIndex: ..., outAmount: ..., outScript: ...} + */ + async _processOutputsHdAccounts(hdAccounts, indexedOutputs) { + // Store a list of known addresses that received funds + const fundedAddresses = [] + const xpubList = _.keys(hdAccounts) + + if (xpubList.length > 0) { + await util.seriesCall(xpubList, async xpub => { + const usedNewAddresses = await this._deriveNewAddresses( + xpub, + hdAccounts[xpub], + indexedOutputs + ) + + const usedNewResults = await db.getAddresses(usedNewAddresses) + + // Append these address results to the hdAccount address list + Array.prototype.push.apply(hdAccounts[xpub].addresses, usedNewResults) + + for (let entry of hdAccounts[xpub].addresses) { + if (indexedOutputs[entry.addrAddress]) { + for (let output of indexedOutputs[entry.addrAddress]) { + fundedAddresses.push({ + addrID: entry.addrID, + outIndex: output.index, + outAmount: output.value, + outScript: output.script, + }) + } + } + } + }) + } + + return fundedAddresses + } + + /** + * Derive new addresses for a hd account + * Check if tx addresses are at or beyond the next unused + * index for the HD chain. Derive additional addresses + * to replace the gap limit and add those addresses to + * the database. Make sure to account for tx sending to + * newly-derived addresses. + * + * @param {string} xpub + * @param {object} hdAccount - hd account object + * @param {object} indexedOutputs - outputs indexed by address + * @returns {Promise - object[]} returns an array of the new addresses used + */ + async _deriveNewAddresses(xpub, hdAccount, indexedOutputs) { + const hdType = hdAccount.hdType + + let derivedIndices = [-1,-1] + + // Get maximum derived address indices for each chain + derivedIndices = await db.getHDAccountDerivedIndices(xpub) + + // Get the next unused chain indices for this account + const unusedIndices = await db.getHDAccountNextUnusedIndices(xpub) + + const newAddresses = [] + const usedNewAddresses = {} + + // Get the maximum used index in the addresses + for (let chain of [0,1]) { + // Get addresses for this account that are on this chain + const chainAddresses = _.filter(hdAccount.addresses, v => { + return v.hdAddrChain == chain + }) + + if (chainAddresses.length == 0) + continue + + // Get the maximum used address on this chain + const chainMaxUsed = _.maxBy(chainAddresses, a => { + return a.hdAddrIndex + }) + + let chainMaxUsedIndex = chainMaxUsed.hdAddrIndex + + // If max used index will not advance the unused index, move on + if (chainMaxUsedIndex < unusedIndices[chain]) + continue + + // If max derived index is beyond max used index plus gap limit, move on + if (derivedIndices[chain] >= chainMaxUsedIndex + gapLimit[chain]) + continue + + let done + + do { + done = true + + // Derive additional addresses beyond the max index... + // ..and including the gap limit beyond the max used + const minIdx = derivedIndices[chain] + 1 + const maxIdx = chainMaxUsedIndex + gapLimit[chain] + 1 + const indices = _.range(minIdx, maxIdx) + + const derived = await hdaHelper.deriveAddresses(xpub, chain, indices, hdType) + Array.prototype.push.apply(newAddresses, derived) + + Logger.info(`Derived hdID(${hdAccount.hdID}) M/${chain}/${indices.join(',')}`) + + // Update view of derived address indices + derivedIndices[chain] = chainMaxUsedIndex + gapLimit[chain] + + // Check derived addresses for use in this transaction + for (let d of derived) { + if (indexedOutputs[d.address]) { + Logger.info(`Derived address already in outputs: M/${d.chain}/${d.index}`) + // This transaction spends to an address + // beyond the original derived gap limit! + chainMaxUsedIndex = d.index + usedNewAddresses[d.address] = d + done = false + } + } + } while (!done) + + } + + await db.addAddressesToHDAccount(xpub, newAddresses) + return _.keys(usedNewAddresses) + } + + + /** + * Store the transaction in database + * @returns {Promise} + */ + async _ensureTransaction() { + if (this.storedTxnID == null) { + this.storedTxnID = await db.ensureTransactionId(this.txid) + + await db.addTransaction({ + txid: this.txid, + version: this.tx.version, + locktime: this.tx.locktime, + }) + + Logger.info(`Storing transaction ${this.txid}`) + } + } + +} + +module.exports = Transaction diff --git a/tracker/transactions-bundle.js b/tracker/transactions-bundle.js new file mode 100644 index 0000000..081eca7 --- /dev/null +++ b/tracker/transactions-bundle.js @@ -0,0 +1,216 @@ +/*! + * tracker/transactions-bundle.js + * Copyright © 2019 – Katana Cryptographic Ltd. All Rights Reserved. + */ +'use strict' + +const _ = require('lodash') +const LRU = require('lru-cache') +const bitcoin = require('bitcoinjs-lib') +const util = require('../lib/util') +const db = require('../lib/db/mysql-db-wrapper') +const network = require('../lib/bitcoin/network') +const keys = require('../keys')[network.key] +const activeNet = network.network + + +/** + * A base class defining a set of transactions (mempool, block) + */ +class TransactionsBundle { + + /** + * Constructor + * @param {object[]} txs - array of bitcoin transaction objects + */ + constructor(txs) { + // List of transactions + this.transactions = (txs == null) ? [] : txs + } + + /** + * Adds a transaction + * @param {object} tx - transaction object + */ + addTransaction(tx) { + if (tx) { + this.transactions.push(tx) + } + } + + /** + * Clear the bundle + */ + clear() { + this.transactions = [] + } + + /** + * Return the bundle as an array of transactions + * @returns {object[]} + */ + toArray() { + return this.transactions.slice() + } + + /** + * Get the size of the bundle + * @returns {integer} return the number of transactions stored in the bundle + */ + size() { + return this.transactions.length + } + + /** + * Find the transactions of interest + * @returns {object[]} returns an array of transactions objects + */ + async prefilterTransactions() { + // Process transactions by slices of 5000 transactions + const MAX_NB_TXS = 5000 + const lists = util.splitList(this.transactions, MAX_NB_TXS) + + const results = await util.seriesCall(lists, list => { + return this._prefilterTransactions(list) + }) + + return _.flatten(results) + } + + /** + * Find the transactions of interest (internal implementation) + * @params {object[]} transactions - array of transactions objects + * @returns {object[]} returns an array of transactions objects + */ + async _prefilterTransactions(transactions) { + let inputs = [] + let outputs = [] + + // Store indices of txs to be processed + let filteredIdxTxs = [] + + // Store txs indices, keyed by `txid-outindex`. + // Values are arrays of txs indices (for double spends) + let indexedInputs = {} + + // Store txs indices, keyed by address. + // Values are arrays of txs indices + let indexedOutputs = {} + + // Stores txs indices, keyed by txids + let indexedTxs = {} + + // + // Prefilter against the outputs + // + + // Index the transaction outputs + for (const i in transactions) { + const tx = transactions[i] + const txid = tx.getId() + + indexedTxs[txid] = i + + // If we already checked this tx + if (TransactionsBundle.cache.has(txid)) + continue + + for (const j in tx.outs) { + try { + const script = tx.outs[j].script + const address = bitcoin.address.fromOutputScript(script, activeNet) + outputs.push(address) + // Index the output + if (!indexedOutputs[address]) + indexedOutputs[address] = [] + indexedOutputs[address].push(i) + } catch (e) {} + } + } + + // Prefilter + const outRes = await db.getUngroupedHDAccountsByAddresses(outputs) + + for (const i in outRes) { + const key = outRes[i].addrAddress + const idxTxs = indexedOutputs[key] + if (idxTxs) { + for (const idxTx of idxTxs) + if (filteredIdxTxs.indexOf(idxTx) == -1) + filteredIdxTxs.push(idxTx) + } + } + + // + // Prefilter against the inputs + // + + // Index the transaction inputs + for (const i in transactions) { + const tx = transactions[i] + const txid = tx.getId() + + // If we already checked this tx + if (TransactionsBundle.cache.has(txid)) + continue + + for (const j in tx.ins) { + const spendHash = tx.ins[j].hash + const spendTxid = Buffer.from(spendHash).reverse().toString('hex') + // Check if this input consumes an output + // generated by a transaction from this block + if (filteredIdxTxs.indexOf(indexedTxs[spendTxid]) > -1 && filteredIdxTxs.indexOf(i) == -1) { + filteredIdxTxs.push(i) + } else { + const spendIdx = tx.ins[j].index + inputs.push({txid: spendTxid, index: spendIdx}) + // Index the input + const key = spendTxid + '-' + spendIdx + if (!indexedInputs[key]) + indexedInputs[key] = [] + indexedInputs[key].push(i) + } + } + } + + // Prefilter + const inRes = await db.getOutputSpends(inputs) + + for (const i in inRes) { + const key = inRes[i].txnTxid + '-' + inRes[i].outIndex + const idxTxs = indexedInputs[key] + if (idxTxs) { + for (const idxTx of idxTxs) + if (filteredIdxTxs.indexOf(idxTx) == -1) + filteredIdxTxs.push(idxTx) + } + } + + // + // Returns the matching transactions + // + filteredIdxTxs.sort((a, b) => a - b); + return filteredIdxTxs.map(x => transactions[x]) + } + +} + +/** + * Cache of txids, for avoiding triple-check behavior. + * ZMQ sends the transaction twice: + * 1. When it enters the mempool + * 2. When it leaves the mempool (mined or orphaned) + * Additionally, the transaction comes in a block + * Orphaned transactions are deleted during the routine check + */ +TransactionsBundle.cache = LRU({ + // Maximum number of txids to store in cache + max: 100000, + // Function used to compute length of item + length: (n, key) => 1, + // Maximum age for items in the cache. Items do not expire + maxAge: Infinity +}) + + +module.exports = TransactionsBundle