diff --git a/.dockerignore b/.dockerignore new file mode 100755 index 0000000..d33e9ab --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.vscode +src/node_modules +src/wwwroot/ \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..3e3d800 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +src/node_modules +src/wwwroot/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 0000000..125fe6d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# Server Build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS server +WORKDIR /work/src + +COPY ./src/Aptabase.csproj /work/src + +ARG TARGETARCH +RUN dotnet restore "./Aptabase.csproj" -a $TARGETARCH + +COPY ./etc/clickhouse /work/etc/clickhouse +COPY ./etc/geoip /work/etc/geoip +COPY ./src /work/src + +RUN dotnet publish "Aptabase.csproj" -a $TARGETARCH -c Release -o /work/publish /p:UseAppHost=false + +# WebApp Build +FROM oven/bun:1 AS webapp +WORKDIR /work + +COPY ./src/package.json ./src/package-lock.json ./ +RUN bun install + +COPY ./src ./ +RUN bun run build + +# Final +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final +WORKDIR /app + +COPY --from=server /work/publish . +COPY --from=webapp /work/wwwroot ./wwwroot +COPY LICENSE . + +ENTRYPOINT ["dotnet", "Aptabase.dll"] diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..f4aa842 --- /dev/null +++ b/LICENSE @@ -0,0 +1,619 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/README.md b/README.md old mode 100644 new mode 100755 index dc18a21..cdf4897 --- a/README.md +++ b/README.md @@ -1 +1,56 @@ -# Zalvena +# Zalvena Service + +A fork of Aptabase Server software, modified to only work with projects using Zalvena. + +## Changes + - Updated app key generation to use `ZV` instead of `SH` + - Updated browserslist + - Updated `node:18` to `over/bun:1` in `Dockerfile` + - Updated the pacakge `@types/node` from `20.8.7` to `22.13.4` + - Updated the pacakge `@types/react` from `18.2.30` to `19.0.10` + - Updated the pacakge `@types/react-dom` from `18.2.14` to `19.0.4` + - Updated the pacakge `@vitejs/plugin-react-swc` from `3.4.0` to `3.8.0` + - Updated the pacakge `autoprefixer` from `10.4.16` to `10.4.20` + - Updated the pacakge `postcss` from `8.4.31` to `8.5.3` + - Updated the pacakge `tailwind-merge` from `1.14.0` to `3.0.1` + - Updated the pacakge `typescript` from `5.2.2` to `5.7.3` + - Updated the pacakge `vite` from `4.5.3` to `6.1.1` + - Updated the pacakge `vite-plugin-mkcert` from `1.17.5` to `1.17.6` + - Updated the pacakge `@aptabase/web` from `0.4.0` to `0.4.3` + - Updated the pacakge `@fontsource/inter` from `5.0.14` to `5.1.1` + - Updated the pacakge `@headlessui/react` from `2.1.2` to `2.2.0` + - Updated the pacakge `@radix-ui/react-accordion` from `1.1.2` to `1.2.3` + - Updated the pacakge `@radix-ui/react-label` from `2.0.2` to `2.1.2` + - Updated the pacakge `@radix-ui/react-popover` from `1.0.7` to `1.1.6` + - Updated the pacakge `@radix-ui/react-select` from `2.0.0` to `2.1.6` + - Updated the pacakge `@radix-ui/react-slot` from `1.0.2` to `1.1.2` + - Updated the pacakge `@radix-ui/react-tabs` from `1.0.4` to `1.1.3` + - Updated the pacakge `@radix-ui/react-tooltip` from `1.0.7` to `1.1.8` + - Updated the pacakge `@tabler/icons-react` from `2.39.0` to `3.30.0` + - Updated the pacakge `@tanstack/react-query` from `5.0.0` to `5.66.7` + - Updated the pacakge `chart.js` from `4.4.0` to `4.4.8` + - Updated the pacakge `chartjs-plugin-annotation` from `3.0.1` to `3.1.0` + - Updated the pacakge `cmdk` from `0.2.1` to `1.0.4` + - Updated the pacakge `date-fns` from `3.6.0` to `4.1.0` + - Updated the pacakge `framer-motion` from `10.16.4` to `12.4.5` + - Updated the pacakge `jotai` from `2.9.1` to `2.12.1` + - Updated the pacakge `react` from `18.2.0` to `19.0.0` + - Updated the pacakge `react-dom` from `18.2.0` to `19.0.0` + - Updated the pacakge `react-markdown` from `9.0.0` to `9.0.3` + - Updated the pacakge `react-router-dom` from `6.17.0` to `7.2.0` + - Updated the pacakge `rehype-highlight` from `7.0.0` to `7.0.2` + - Updated the pacakge `remark-gfm` from `4.0.0` to `4.0.1` + - Updated the pacakge `sonner` from `1.0.3` to `2.0.0` + - Remote sources removed to make this fork comply with SudoVanilla Umbrella Policy + - Default avatar points to https://md.sudovanilla.org/images/icons/Aptabase.jpg + - Flags point to https://md.sudovanilla.org/images/flags/ + - Removed all support elements and Chirp + - Removed instructions screen + - Removed `/etc/tinybird` + - Removed `/src/Properties/` + - Removed `/tests/` + - Removed `/tools/` + +## License + +Aptabase and Zalvena Service is open-source under the [AGPLv3 license](./LICENSE). You can use it for free, but you must share any changes you make to the code. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100755 index 0000000..f41d5ad --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + aptabase_db: + container_name: aptabase_db + image: postgres:15-alpine + restart: always + volumes: + - ./tmp/db-data:/var/lib/postgresql/data + environment: + POSTGRES_USER: aptabase + POSTGRES_PASSWORD: sLPnqXzjupt6pR1dumYrGeVXpVDup49hYRRn2TEUfRmF6eXbBu4CJYl5ynBFxXx8 + aptabase_events_db: + container_name: aptabase_events_db + image: clickhouse/clickhouse-server:23.8.4.69-alpine + restart: always + volumes: + - ./tmp/events-db-data:/var/lib/clickhouse + environment: + CLICKHOUSE_USER: aptabase + CLICKHOUSE_PASSWORD: lBxMObwAWOU8L4ieQPq1ZcbQeUND4FwwgEfvmGstU5o8IZS3XekInmKtcAwqSxgm + ulimits: + nofile: + soft: 262144 + hard: 262144 + aptabase: + container_name: aptabase_app + image: oci.registry.sudovanilla.org/zalvena + restart: always + env_file: + - ./.env + volumes: + - /etc/hosts:/etc/hosts + depends_on: + - aptabase_events_db + - aptabase_db + ports: + - 6521:8080 diff --git a/etc/clickhouse/0001-events.sql b/etc/clickhouse/0001-events.sql new file mode 100755 index 0000000..a670942 --- /dev/null +++ b/etc/clickhouse/0001-events.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS events +( + `app_id` String, + `timestamp` DateTime, + `event_name` String, + `user_id` String, + `session_id` String, + `os_name` LowCardinality(String), + `os_version` String, + `locale` LowCardinality(String), + `app_version` String, + `app_build_number` String, + `engine_name` LowCardinality(String), + `engine_version` String, + `sdk_version` String, + `country_code` LowCardinality(String), + `region_name` LowCardinality(String), + `city` String, + `string_props` String, + `numeric_props` String, + `ttl` DateTime +) +ENGINE = MergeTree() +PARTITION BY toYYYYMM(timestamp) +ORDER BY (app_id, timestamp, event_name) +TTL ttl; \ No newline at end of file diff --git a/etc/clickhouse/0002-monthly_usage_v1_mv.sql b/etc/clickhouse/0002-monthly_usage_v1_mv.sql new file mode 100755 index 0000000..e6beb85 --- /dev/null +++ b/etc/clickhouse/0002-monthly_usage_v1_mv.sql @@ -0,0 +1,8 @@ +CREATE OR REPLACE VIEW monthly_usage_v1 +AS +SELECT + app_id, + toStartOfMonth(timestamp) AS period, + countState() AS events +FROM events +GROUP BY app_id, period \ No newline at end of file diff --git a/etc/clickhouse/0003-sessions_live_v1.sql b/etc/clickhouse/0003-sessions_live_v1.sql new file mode 100755 index 0000000..b05a821 --- /dev/null +++ b/etc/clickhouse/0003-sessions_live_v1.sql @@ -0,0 +1,28 @@ +CREATE OR REPLACE VIEW sessions_live_v1 +AS +SELECT + app_id, + session_id, + user_id, + minSimpleState(e.timestamp) AS min_timestamp, + maxSimpleState(e.timestamp) AS max_timestamp, + anySimpleState(os_name) AS os_name, + anySimpleState(os_version) AS os_version, + anySimpleState(locale) AS locale, + anySimpleState(app_version) AS app_version, + anySimpleState(app_build_number) AS app_build_number, + anySimpleState(engine_name) AS engine_name, + anySimpleState(engine_version) AS engine_version, + anySimpleState(sdk_version) AS sdk_version, + anySimpleState(country_code) AS country_code, + anySimpleState(region_name) AS region_name, + countState() AS events_count, + groupArrayState(event_name) AS events_name, + groupArrayState(e.timestamp) AS events_timestamp, + groupArrayState(string_props) AS events_string_props, + groupArrayState(numeric_props) AS events_numeric_props +FROM events AS e +GROUP BY + app_id, + session_id, + user_id \ No newline at end of file diff --git a/etc/clickhouse/0004-add_device_model_to_events.sql b/etc/clickhouse/0004-add_device_model_to_events.sql new file mode 100755 index 0000000..09938b4 --- /dev/null +++ b/etc/clickhouse/0004-add_device_model_to_events.sql @@ -0,0 +1,2 @@ +ALTER TABLE events +ADD COLUMN IF NOT EXISTS device_model String; \ No newline at end of file diff --git a/etc/clickhouse/queries/billing_historical_usage__v1.liquid b/etc/clickhouse/queries/billing_historical_usage__v1.liquid new file mode 100755 index 0000000..5b9c74e --- /dev/null +++ b/etc/clickhouse/queries/billing_historical_usage__v1.liquid @@ -0,0 +1,7 @@ +SELECT period as Date, + countMerge(events) as Events +FROM monthly_usage_v1 +WHERE app_id IN ('{{app_ids}}') +AND period >= toStartOfMonth(now() - INTERVAL 6 MONTH) +GROUP BY period +ORDER BY period WITH FILL FROM toStartOfMonth(now() - INTERVAL 6 MONTH) TO toStartOfMonth(now() + INTERVAL 1 MONTH) STEP toIntervalMonth(1) \ No newline at end of file diff --git a/etc/clickhouse/queries/billing_usage_per_app__v1.liquid b/etc/clickhouse/queries/billing_usage_per_app__v1.liquid new file mode 100755 index 0000000..39c8d4f --- /dev/null +++ b/etc/clickhouse/queries/billing_usage_per_app__v1.liquid @@ -0,0 +1,5 @@ +SELECT replace(app_id, '_DEBUG', '') as AppId, +countMerge(events) as Count +FROM monthly_usage_v1 +WHERE period = '{{period}}' +GROUP BY replace(app_id, '_DEBUG', '') \ No newline at end of file diff --git a/etc/clickhouse/queries/get_billing_usage__v1.liquid b/etc/clickhouse/queries/get_billing_usage__v1.liquid new file mode 100755 index 0000000..484d5ea --- /dev/null +++ b/etc/clickhouse/queries/get_billing_usage__v1.liquid @@ -0,0 +1,4 @@ +SELECT countMerge(events) as Count +FROM monthly_usage_v1 +WHERE app_id IN ('{{app_ids}}') +AND period = toStartOfMonth(now()) diff --git a/etc/clickhouse/queries/historical_sessions__v1.liquid b/etc/clickhouse/queries/historical_sessions__v1.liquid new file mode 100755 index 0000000..2419390 --- /dev/null +++ b/etc/clickhouse/queries/historical_sessions__v1.liquid @@ -0,0 +1,36 @@ +SELECT session_id as Id, + min(min_timestamp) as StartedAt, + max(max_timestamp) - min(min_timestamp) as Duration, + countMerge(events_count) as EventsCount, + any(app_version) as AppVersion, + any(country_code) as CountryCode, + any(region_name) as RegionName, + any(os_name) as OsName, + any(os_version) as OsVersion +FROM sessions_live_v1 +WHERE app_id = '{{app_id}}' +{% if country_code %} +AND country_code = '{{country_code}}' +{% endif %} +{% if os_name %} +AND os_name = '{{os_name}}' +{% endif %} +{% if app_version %} +AND app_version = '{{app_version}}' +{% endif %} +{% if date_to %} +AND (min_timestamp < '{{date_to}}' OR (min_timestamp = '{{date_to}}' AND session_id < '{{session_id}}')) +{% endif %} +{% if date_from %} +AND (min_timestamp > '{{date_from}}' OR (min_timestamp = '{{date_from}}' AND session_id > '{{session_id}}')) +{% endif %} +GROUP BY session_id +{% if event_name %} +HAVING hasAny(groupArrayMerge(events_name), ['{{event_name}}']) +{% endif %} +{% if date_from %} +ORDER BY StartedAt ASC, Id ASC +{% else %} +ORDER BY StartedAt DESC, Id DESC +{% endif %} +LIMIT 10 \ No newline at end of file diff --git a/etc/clickhouse/queries/key_metrics__v1.liquid b/etc/clickhouse/queries/key_metrics__v1.liquid new file mode 100755 index 0000000..38bf503 --- /dev/null +++ b/etc/clickhouse/queries/key_metrics__v1.liquid @@ -0,0 +1,30 @@ +SELECT uniqExact(user_id) / (date_diff('day', min(min), max(max)) + 1) as DailyUsers, + uniqExact(session_id) as Sessions, + if(isNaN(median(max - min)), 0, median(max - min)) as DurationSeconds, + sum(count) as Events +FROM ( + SELECT min(timestamp) AS min, + max(timestamp) AS max, + user_id, + session_id, + count(*) AS count + FROM events + PREWHERE app_id = '{{app_id}}' + WHERE 1 = 1 + {% if date_from and date_to %} + AND timestamp BETWEEN '{{date_from}}' AND '{{date_to}}' + {% endif %} + {% if country_code %} + AND country_code = '{{country_code}}' + {% endif %} + {% if event_name %} + AND event_name = '{{event_name}}' + {% endif %} + {% if os_name %} + AND os_name = '{{os_name}}' + {% endif %} + {% if app_version %} + AND app_version = '{{app_version}}' + {% endif %} + GROUP BY user_id, session_id +) \ No newline at end of file diff --git a/etc/clickhouse/queries/key_metrics__v2.liquid b/etc/clickhouse/queries/key_metrics__v2.liquid new file mode 100755 index 0000000..6e0a051 --- /dev/null +++ b/etc/clickhouse/queries/key_metrics__v2.liquid @@ -0,0 +1,33 @@ +SELECT uniqExact(user_id) / (date_diff('day', min(min), max(max)) + if(date_diff('day', min(min), max(max)) = 0, 1, 0)) AS DailyUsers, + uniqExact(session_id) as Sessions, + if(isNaN(median(max - min)), 0, median(max - min)) as DurationSeconds, + sum(count) as Events +FROM ( + SELECT min(timestamp) AS min, + max(timestamp) AS max, + user_id, + session_id, + count(*) AS count + FROM events + PREWHERE app_id = '{{app_id}}' + WHERE 1 = 1 + {% if date_from and date_to %} + AND timestamp BETWEEN '{{date_from}}' AND '{{date_to}}' + {% endif %} + {% if country_code %} + AND country_code = '{{country_code}}' + {% endif %} + {% if event_name %} + AND event_name = '{{event_name}}' + {% endif %} + {% if os_name %} + AND os_name = '{{os_name}}' + {% endif %} + {% if device_model %} + AND device_model = '{{device_model}}' + {% endif %} + {% if app_version %} + AND app_version = '{{app_version}}' + {% endif %} + GROUP BY user_id, session_id +) \ No newline at end of file diff --git a/etc/clickhouse/queries/key_metrics_periodic__v1.liquid b/etc/clickhouse/queries/key_metrics_periodic__v1.liquid new file mode 100755 index 0000000..4cc72c5 --- /dev/null +++ b/etc/clickhouse/queries/key_metrics_periodic__v1.liquid @@ -0,0 +1,53 @@ +SELECT + {% if granularity == 'Hour' %} + toStartOfHour(timestamp) + {% elsif granularity == 'Day' %} + toStartOfDay(timestamp) + {% else %} + toStartOfMonth(timestamp) + {% endif %} AS Period, + {% if granularity == 'Month' %} + uniqExact(user_id) / (date_diff('day', Period, toLastDayOfMonth(Period)) + 1) + {% else %} + uniqExact(user_id) + {% endif %} AS Users, + uniqExact(session_id) AS Sessions, + count() AS Events +FROM events +PREWHERE app_id = '{{app_id}}' +WHERE 1 = 1 +{% if date_from and date_to %} +AND timestamp BETWEEN '{{date_from}}' AND '{{date_to}}' +{% endif %} +{% if country_code %} +AND country_code = '{{country_code}}' +{% endif %} +{% if event_name %} +AND event_name = '{{event_name}}' +{% endif %} +{% if os_name %} +AND os_name = '{{os_name}}' +{% endif %} +{% if app_version %} +AND app_version = '{{app_version}}' +{% endif %} +GROUP by Period +ORDER BY Period ASC +WITH FILL +{% if date_from and date_to %} +{% if granularity == 'Hour' %} +FROM toStartOfHour(toDateTime('{{date_from}}')) TO toStartOfHour(toDateTime('{{date_to}}')) +{% elsif granularity == 'Day' %} +FROM toStartOfDay(toDateTime('{{date_from}}')) TO toStartOfDay(toDateTime('{{date_to}}')) +{% else %} +FROM toStartOfMonth(toDateTime('{{date_from}}')) TO toStartOfMonth(toDateTime('{{date_to}}')) +{% endif %} +{% endif %} +STEP +{% if granularity == 'Hour' %} +toIntervalHour(1) +{% elsif granularity == 'Day' %} +toIntervalDay(1) +{% else %} +toIntervalMonth(1) +{% endif %} diff --git a/etc/clickhouse/queries/key_metrics_periodic__v2.liquid b/etc/clickhouse/queries/key_metrics_periodic__v2.liquid new file mode 100755 index 0000000..3db2d20 --- /dev/null +++ b/etc/clickhouse/queries/key_metrics_periodic__v2.liquid @@ -0,0 +1,56 @@ +SELECT + {% if granularity == 'Hour' %} + toStartOfHour(timestamp) + {% elsif granularity == 'Day' %} + toStartOfDay(timestamp) + {% else %} + toStartOfMonth(timestamp) + {% endif %} AS Period, + {% if granularity == 'Month' %} + uniqExact(user_id) / (date_diff('day', Period, toLastDayOfMonth(Period)) + 1) + {% else %} + uniqExact(user_id) + {% endif %} AS Users, + uniqExact(session_id) AS Sessions, + count() AS Events +FROM events +PREWHERE app_id = '{{app_id}}' +WHERE 1 = 1 +{% if date_from and date_to %} +AND timestamp BETWEEN '{{date_from}}' AND '{{date_to}}' +{% endif %} +{% if country_code %} +AND country_code = '{{country_code}}' +{% endif %} +{% if event_name %} +AND event_name = '{{event_name}}' +{% endif %} +{% if os_name %} +AND os_name = '{{os_name}}' +{% endif %} +{% if device_model %} +AND device_model = '{{device_model}}' +{% endif %} +{% if app_version %} +AND app_version = '{{app_version}}' +{% endif %} +GROUP by Period +ORDER BY Period ASC +WITH FILL +{% if date_from and date_to %} +{% if granularity == 'Hour' %} +FROM toStartOfHour(toDateTime('{{date_from}}')) TO toStartOfHour(toDateTime('{{date_to}}')) +{% elsif granularity == 'Day' %} +FROM toStartOfDay(toDateTime('{{date_from}}')) TO toStartOfDay(toDateTime('{{date_to}}')) +{% else %} +FROM toStartOfMonth(toDateTime('{{date_from}}')) TO toStartOfMonth(toDateTime('{{date_to}}')) +{% endif %} +{% endif %} +STEP +{% if granularity == 'Hour' %} +toIntervalHour(1) +{% elsif granularity == 'Day' %} +toIntervalDay(1) +{% else %} +toIntervalMonth(1) +{% endif %} diff --git a/etc/clickhouse/queries/live_geo__v1.liquid b/etc/clickhouse/queries/live_geo__v1.liquid new file mode 100755 index 0000000..c89c9e2 --- /dev/null +++ b/etc/clickhouse/queries/live_geo__v1.liquid @@ -0,0 +1,14 @@ +SELECT uniq(user_id) as Users, +country_code as CountryCode, +region_name as RegionName +FROM ( +SELECT session_id, + min(min_timestamp) AS timestamp, + any(user_id) AS user_id, + any(country_code) AS country_code, + any(region_name) AS region_name +FROM sessions_live_v1 +WHERE app_id = '{{app_id}}' +GROUP BY session_id +HAVING timestamp >= now() - INTERVAL 1 HOUR +) GROUP BY country_code, region_name \ No newline at end of file diff --git a/etc/clickhouse/queries/live_session_details__v1.liquid b/etc/clickhouse/queries/live_session_details__v1.liquid new file mode 100755 index 0000000..7669ef4 --- /dev/null +++ b/etc/clickhouse/queries/live_session_details__v1.liquid @@ -0,0 +1,18 @@ +SELECT session_id as Id, + countMerge(events_count) as EventsCount, + min(min_timestamp) as StartedAt, + max(max_timestamp) - min(min_timestamp) as Duration, + countMerge(events_count) as EventsCount, + any(app_version) as AppVersion, + any(country_code) as CountryCode, + any(region_name) as RegionName, + any(os_name) as OsName, + any(os_version) as OsVersion, + groupArrayMerge(events_name) as EventsName, + groupArrayMerge(events_timestamp) as EventsTimestamp, + groupArrayMerge(events_string_props) as EventsStringProps, + groupArrayMerge(events_numeric_props) as EventsNumericProps +FROM sessions_live_v1 +WHERE app_id = '{{app_id}}' +AND session_id = '{{session_id}}' +GROUP BY session_id \ No newline at end of file diff --git a/etc/clickhouse/queries/live_sessions__v1.liquid b/etc/clickhouse/queries/live_sessions__v1.liquid new file mode 100755 index 0000000..201a230 --- /dev/null +++ b/etc/clickhouse/queries/live_sessions__v1.liquid @@ -0,0 +1,15 @@ +SELECT session_id as Id, + min(min_timestamp) as StartedAt, + max(max_timestamp) - min(min_timestamp) as Duration, + countMerge(events_count) as EventsCount, + any(app_version) as AppVersion, + any(country_code) as CountryCode, + any(region_name) as RegionName, + any(os_name) as OsName, + any(os_version) as OsVersion +FROM sessions_live_v1 +WHERE app_id = '{{app_id}}' +GROUP BY session_id +HAVING StartedAt >= now() - INTERVAL 1 HOUR +ORDER BY Duration DESC +LIMIT 6 \ No newline at end of file diff --git a/etc/clickhouse/queries/monthly_usage__v1.liquid b/etc/clickhouse/queries/monthly_usage__v1.liquid new file mode 100755 index 0000000..19aa24a --- /dev/null +++ b/etc/clickhouse/queries/monthly_usage__v1.liquid @@ -0,0 +1,9 @@ +SELECT toYear(Date) as Year, toMonth(Date) as Month, Events +FROM ( + SELECT period as Date, + countMerge(events) as Events + FROM monthly_usage_v1 + WHERE app_id = '{{app_id}}' + GROUP BY period + ORDER BY period WITH FILL TO toStartOfMonth(now() + INTERVAL 1 MONTH) STEP toIntervalMonth(1) +) ORDER BY Year DESC, Month DESC \ No newline at end of file diff --git a/etc/clickhouse/queries/top_n__v1.liquid b/etc/clickhouse/queries/top_n__v1.liquid new file mode 100755 index 0000000..ed5e3f5 --- /dev/null +++ b/etc/clickhouse/queries/top_n__v1.liquid @@ -0,0 +1,26 @@ +SELECT {{name_column}} as Name, + {% if value_column == 'UniqueSessions' %} + uniqExact(session_id) as Value + {% else %} + count() as Value + {% endif %} +FROM events +PREWHERE app_id = '{{app_id}}' +WHERE 1 = 1 +{% if date_from and date_to %} +AND timestamp BETWEEN '{{date_from}}' AND '{{date_to}}' +{% endif %} +{% if country_code %} +AND country_code = '{{country_code}}' +{% endif %} +{% if event_name %} +AND event_name = '{{event_name}}' +{% endif %} +{% if os_name %} +AND os_name = '{{os_name}}' +{% endif %} +{% if app_version %} +AND app_version = '{{app_version}}' +{% endif %} +GROUP BY Name +ORDER BY Value DESC \ No newline at end of file diff --git a/etc/clickhouse/queries/top_n__v2.liquid b/etc/clickhouse/queries/top_n__v2.liquid new file mode 100755 index 0000000..111730a --- /dev/null +++ b/etc/clickhouse/queries/top_n__v2.liquid @@ -0,0 +1,29 @@ +SELECT {{name_column}} as Name, + {% if value_column == 'UniqueSessions' %} + uniqExact(session_id) as Value + {% else %} + count() as Value + {% endif %} +FROM events +PREWHERE app_id = '{{app_id}}' +WHERE 1 = 1 +{% if date_from and date_to %} +AND timestamp BETWEEN '{{date_from}}' AND '{{date_to}}' +{% endif %} +{% if country_code %} +AND country_code = '{{country_code}}' +{% endif %} +{% if event_name %} +AND event_name = '{{event_name}}' +{% endif %} +{% if os_name %} +AND os_name = '{{os_name}}' +{% endif %} +{% if device_model %} +AND device_model = '{{device_model}}' +{% endif %} +{% if app_version %} +AND app_version = '{{app_version}}' +{% endif %} +GROUP BY Name +ORDER BY Value DESC \ No newline at end of file diff --git a/etc/clickhouse/queries/top_props__v1.liquid b/etc/clickhouse/queries/top_props__v1.liquid new file mode 100755 index 0000000..192b18d --- /dev/null +++ b/etc/clickhouse/queries/top_props__v1.liquid @@ -0,0 +1,34 @@ +SELECT string_arr.1 AS StringKey, + string_arr.2 AS StringValue, + numeric_arr.1 AS NumericKey, + count() AS Events, + median(numeric_arr.2) AS Median, + min(numeric_arr.2) AS Min, + max(numeric_arr.2) AS Max, + sum(numeric_arr.2) AS Sum +FROM ( + SELECT * FROM ( + SELECT JSONExtractKeysAndValues(string_props, 'String') AS string_arr, + JSONExtractKeysAndValues(numeric_props, 'Float') AS numeric_arr + FROM events + PREWHERE app_id = '{{app_id}}' + WHERE 1 = 1 + {% if date_from and date_to %} + AND timestamp BETWEEN '{{date_from}}' AND '{{date_to}}' + {% endif %} + {% if country_code %} + AND country_code = '{{country_code}}' + {% endif %} + {% if event_name %} + AND event_name = '{{event_name}}' + {% endif %} + {% if os_name %} + AND os_name = '{{os_name}}' + {% endif %} + {% if app_version %} + AND app_version = '{{app_version}}' + {% endif %} + ) LEFT ARRAY JOIN string_arr +) LEFT ARRAY JOIN numeric_arr +GROUP BY StringKey, StringValue, NumericKey +ORDER BY StringKey, Events DESC \ No newline at end of file diff --git a/etc/clickhouse/queries/top_props__v2.liquid b/etc/clickhouse/queries/top_props__v2.liquid new file mode 100755 index 0000000..4c33eda --- /dev/null +++ b/etc/clickhouse/queries/top_props__v2.liquid @@ -0,0 +1,37 @@ +SELECT string_arr.1 AS StringKey, + string_arr.2 AS StringValue, + numeric_arr.1 AS NumericKey, + count() AS Events, + median(numeric_arr.2) AS Median, + min(numeric_arr.2) AS Min, + max(numeric_arr.2) AS Max, + sum(numeric_arr.2) AS Sum +FROM ( + SELECT * FROM ( + SELECT JSONExtractKeysAndValues(string_props, 'String') AS string_arr, + JSONExtractKeysAndValues(numeric_props, 'Float') AS numeric_arr + FROM events + PREWHERE app_id = '{{app_id}}' + WHERE 1 = 1 + {% if date_from and date_to %} + AND timestamp BETWEEN '{{date_from}}' AND '{{date_to}}' + {% endif %} + {% if country_code %} + AND country_code = '{{country_code}}' + {% endif %} + {% if event_name %} + AND event_name = '{{event_name}}' + {% endif %} + {% if os_name %} + AND os_name = '{{os_name}}' + {% endif %} + {% if device_model %} + AND device_model = '{{device_model}}' + {% endif %} + {% if app_version %} + AND app_version = '{{app_version}}' + {% endif %} + ) LEFT ARRAY JOIN string_arr +) LEFT ARRAY JOIN numeric_arr +GROUP BY StringKey, StringValue, NumericKey +ORDER BY StringKey, Events DESC \ No newline at end of file diff --git a/etc/geoip/GeoLite2-City.mmdb b/etc/geoip/GeoLite2-City.mmdb new file mode 100755 index 0000000..9d4c813 Binary files /dev/null and b/etc/geoip/GeoLite2-City.mmdb differ diff --git a/etc/geoip/coordinates.json b/etc/geoip/coordinates.json new file mode 100755 index 0000000..ec6300e --- /dev/null +++ b/etc/geoip/coordinates.json @@ -0,0 +1,14886 @@ +{ + "IR": { + "Māzandarān": { + "lat": 36.3993691, + "lng": 52.1912307 + }, + "North Khorasan": { + "lat": 37.3744224, + "lng": 57.2818625 + }, + "Qazvin Province": { + "lat": 36.0881317, + "lng": 49.8547266 + }, + "Khuzestan": { + "lat": 31.4360149, + "lng": 49.041312 + }, + "West Azerbaijan Province": { + "lat": 37.7595018, + "lng": 45 + }, + "Kermanshah Province": { + "lat": 34.4576233, + "lng": 46.670534 + }, + "Gilan Province": { + "lat": 37.1171617, + "lng": 49.5279996 + }, + "East Azerbaijan Province": { + "lat": 38.4280669, + "lng": 45.9071112 + }, + "Hamadan Province": { + "lat": 34.9736585, + "lng": 48.558749 + }, + "Golestan": { + "lat": 37.0230398, + "lng": 55.5898826 + }, + "Markazi": { + "lat": 34.6961791, + "lng": 49.6911374 + }, + "Tehran": { + "lat": 35.7218583, + "lng": 51.3346954 + }, + "Kerman": { + "lat": 30.2839379, + "lng": 57.0833628 + }, + "Kohgiluyeh and Boyer-Ahmad Province": { + "lat": 30.724586, + "lng": 50.8456323 + }, + "Lorestan Province": { + "lat": 33.5818394, + "lng": 48.3988186 + }, + "Zanjan": { + "lat": 36.6830045, + "lng": 48.5087209 + }, + "Yazd Province": { + "lat": 32.1006387, + "lng": 54.4342138 + }, + "Razavi Khorasan": { + "lat": 35.1020253, + "lng": 59.1041758 + }, + "Alborz Province": { + "lat": 35.8898715, + "lng": 50.8456323 + }, + "Fars": { + "lat": 29.1043813, + "lng": 53.045893 + }, + "Hormozgan": { + "lat": 27.4150286, + "lng": 56.741207 + }, + "Chaharmahal and Bakhtiari Province": { + "lat": 32.1461389, + "lng": 50.5135589 + }, + "Bushehr Province": { + "lat": 28.7620739, + "lng": 51.5150077 + }, + "Semnan Province": { + "lat": 35.2255585, + "lng": 54.4342138 + }, + "Kurdistan Province": { + "lat": 35.382438, + "lng": 47.1362125 + }, + "Ardabil Province": { + "lat": 38.3180808, + "lng": 48.199974 + }, + "Qom Province": { + "lat": 34.6976594, + "lng": 50.8456323 + }, + "Isfahan": { + "lat": 32.6538966, + "lng": 51.66596560000001 + }, + "Ilam Province": { + "lat": 33.29576180000001, + "lng": 46.670534 + }, + "South Khorasan Province": { + "lat": 32.5175643, + "lng": 59.1041758 + }, + "Sistan and Baluchestan": { + "lat": 27.5299906, + "lng": 60.58206759999999 + }, + "": { + "lat": 32.427908, + "lng": 53.688046 + } + }, + "CY": { + "Limassol District": { + "lat": 34.6786322, + "lng": 33.0413055 + }, + "Ammochostos": { + "lat": 35.1149116, + "lng": 33.919245 + }, + "Nicosia": { + "lat": 35.1855659, + "lng": 33.38227639999999 + }, + "Larnaka": { + "lat": 34.9182222, + "lng": 33.6200625 + }, + "Pafos": { + "lat": 34.77539489999999, + "lng": 32.4217786 + }, + "Keryneia": { + "lat": 35.299194, + "lng": 33.2363246 + }, + "": { + "lat": 35.126413, + "lng": 33.429859 + } + }, + "SO": { + "Bakool": { + "lat": 4.3657221, + "lng": 44.0960311 + }, + "Banaadir": { + "lat": 2.1065414, + "lng": 45.3933422 + }, + "Lower Shabeelle": { + "lat": 1.8669821, + "lng": 44.5501935 + }, + "Woqooyi Galbeed": { + "lat": 9.542373999999999, + "lng": 44.0960311 + }, + "Middle Juba": { + "lat": 1.5884675, + "lng": 42.2362435 + }, + "Nugaal": { + "lat": 8.1777704, + "lng": 48.8799972 + }, + "Gedo": { + "lat": 2.5584269, + "lng": 41.6011814 + }, + "Mudug": { + "lat": 6.6307569, + "lng": 48.3988186 + }, + "Togdheer": { + "lat": 9.4460587, + "lng": 45.29938620000001 + }, + "Bari": { + "lat": 10.2161213, + "lng": 50.3481835 + }, + "Awdal": { + "lat": 10.6334285, + "lng": 43.329466 + }, + "Hiiraan": { + "lat": 4.321015, + "lng": 45.29938620000001 + }, + "Bay": { + "lat": 2.4825193, + "lng": 43.483738 + }, + "Sool": { + "lat": 8.722155599999999, + "lng": 47.7637565 + }, + "": { + "lat": 5.152149, + "lng": 46.199616 + } + }, + "YE": { + "Abyan Governorate": { + "lat": 13.6343413, + "lng": 46.0563212 + }, + "Sanaa Governorate": { + "lat": 15.2254869, + "lng": 44.5501935 + }, + "Muhafazat Hadramaout": { + "lat": 15.552727, + "lng": 48.516388 + }, + "Amanat Alasimah": { + "lat": 15.469397, + "lng": 44.2289441 + }, + "Al Bayda": { + "lat": 13.9889146, + "lng": 45.5771002 + }, + "Raymah": { + "lat": 14.883333, + "lng": 43.55 + }, + "Laḩij": { + "lat": 13.0578415, + "lng": 44.8832833 + }, + "Ibb Governorate": { + "lat": 14.1415717, + "lng": 44.2479015 + }, + "Ḩajjah": { + "lat": 15.7030403, + "lng": 43.6039301 + }, + "Soqatra": { + "lat": 12.4634205, + "lng": 53.8237385 + }, + "Dhamār": { + "lat": 14.5455447, + "lng": 44.4087348 + }, + "Omran": { + "lat": 15.6349352, + "lng": 43.89597 + }, + "Al Jawf": { + "lat": 16.7901819, + "lng": 45.29938620000001 + }, + "Shabwah": { + "lat": 14.7546303, + "lng": 46.516262 + }, + "Al Hudaydah": { + "lat": 14.7909118, + "lng": 42.9708838 + }, + "Ta‘izz": { + "lat": 13.5775886, + "lng": 44.0177989 + }, + "Al Mahwit Governorate": { + "lat": 15.3963229, + "lng": 43.5606946 + }, + "Al Mahrah Governorate": { + "lat": 16.5238423, + "lng": 51.6834275 + }, + "Aḑ Ḑāli‘": { + "lat": 13.7032564, + "lng": 44.7334536 + }, + "Aden": { + "lat": 12.7854969, + "lng": 45.0186548 + }, + "Ma’rib": { + "lat": 15.515888, + "lng": 45.4498065 + }, + "Şa‘dah": { + "lat": 16.9509413, + "lng": 43.7477743 + }, + "": { + "lat": 15.552727, + "lng": 48.516388 + } + }, + "LY": { + "Al Butnan": { + "lat": 29.7579854, + "lng": 23.7632828 + }, + "Al Jabal al Akhdar": { + "lat": 32.4032332, + "lng": 21.6660725 + }, + "Darnah": { + "lat": 32.755613, + "lng": 22.6377432 + }, + "Sha'biyat Banghazi": { + "lat": 32.1194242, + "lng": 20.0867909 + }, + "Al Kufrah": { + "lat": 23.3112389, + "lng": 21.8568586 + }, + "Al Marj": { + "lat": 32.4981803, + "lng": 20.8369932 + }, + "Al Wahat": { + "lat": 29.0466808, + "lng": 21.8568586 + }, + "An Nuqat al Khams": { + "lat": 32.6914909, + "lng": 11.8891721 + }, + "Sha'biyat Misratah": { + "lat": 31.347767, + "lng": 14.4723301 + }, + "Al Jufrah": { + "lat": 27.9835135, + "lng": 16.912251 + }, + "Tripoli": { + "lat": 32.8872094, + "lng": 13.1913383 + }, + "Surt": { + "lat": 31.189689, + "lng": 16.5701927 + }, + "Az Zawiyah": { + "lat": 32.7630282, + "lng": 12.7364962 + }, + "Sabha": { + "lat": 27.0365406, + "lng": 14.4290236 + }, + "Nalut": { + "lat": 31.8742348, + "lng": 10.9750484 + }, + "Murzuq": { + "lat": 25.9182262, + "lng": 13.9260001 + }, + "Jabal al Gharbi": { + "lat": 30.26380319999999, + "lng": 12.8054753 + }, + "Ghat": { + "lat": 24.9640371, + "lng": 10.1759285 + }, + "Wadi al Hayat": { + "lat": 26.4225926, + "lng": 12.6216211 + }, + "Al Marqab": { + "lat": 32.4599677, + "lng": 14.1001326 + }, + "": { + "lat": 26.3351, + "lng": 17.228331 + } + }, + "IQ": { + "Basra": { + "lat": 30.5257657, + "lng": 47.77379699999999 + }, + "Duhok": { + "lat": 36.8632107, + "lng": 42.9884805 + }, + "Salah ad Din": { + "lat": 34.5337527, + "lng": 43.483738 + }, + "Sulaymaniyah": { + "lat": 35.5557603, + "lng": 45.4351181 + }, + "Maysan": { + "lat": 31.83790119999999, + "lng": 47.1420675 + }, + "Kirkuk": { + "lat": 35.4666329, + "lng": 44.3798895 + }, + "Muhafazat Karbala'": { + "lat": 32.4045493, + "lng": 43.8673222 + }, + "Erbil": { + "lat": 36.190073, + "lng": 43.9930303 + }, + "Diyālá": { + "lat": 33.7733487, + "lng": 45.1494505 + }, + "Baghdad": { + "lat": 33.315241, + "lng": 44.3660671 + }, + "Muhafazat Wasit": { + "lat": 32.6024094, + "lng": 45.7520985 + }, + "Al Anbar": { + "lat": 32.5597614, + "lng": 41.9196471 + }, + "Dhi Qar": { + "lat": 31.1042292, + "lng": 46.3624686 + }, + "An Najaf": { + "lat": 32.0106646, + "lng": 44.3265272 + }, + "Nineveh": { + "lat": 36.229574, + "lng": 42.2362435 + }, + "Muhafazat Babil": { + "lat": 32.468191, + "lng": 44.5501935 + }, + "Muhafazat al Qadisiyah": { + "lat": 32.043691, + "lng": 45.1494505 + }, + "": { + "lat": 33.223191, + "lng": 43.679291 + } + }, + "SA": { + "Medina Region": { + "lat": 24.8403977, + "lng": 39.3206241 + }, + "Al-Qassim Region": { + "lat": 26.207826, + "lng": 43.483738 + }, + "Riyadh Region": { + "lat": 22.7554385, + "lng": 46.2091547 + }, + "Tabuk Region": { + "lat": 28.2492372, + "lng": 36.6093926 + }, + "Eastern Province": { + "lat": 23.5681347, + "lng": 50.6793759 + }, + "'Asir Region": { + "lat": 19.0969062, + "lng": 42.8637875 + }, + "Al Jawf Region": { + "lat": 29.887356, + "lng": 39.3206241 + }, + "Jazan Region": { + "lat": 17.1738176, + "lng": 42.7076107 + }, + "Northern Borders Region": { + "lat": 30.0799162, + "lng": 42.8637875 + }, + "Mecca Region": { + "lat": 21.5235584, + "lng": 41.9196471 + }, + "Najran Region": { + "lat": 18.3514664, + "lng": 45.6007108 + }, + "Ha'il Region": { + "lat": 27.7076143, + "lng": 41.9196471 + }, + "Al Bahah Region": { + "lat": 20.2722739, + "lng": 41.441251 + }, + "": { + "lat": 23.885942, + "lng": 45.079162 + } + }, + "AO": { + "Lunda Sul": { + "lat": -10.2866578, + "lng": 20.7122465 + }, + "Luanda Norte": { + "lat": -8.352502200000002, + "lng": 19.1880047 + }, + "Moxico": { + "lat": -13.4293579, + "lng": 20.3308814 + }, + "Luanda Province": { + "lat": -9.035088, + "lng": 13.2663479 + }, + "Uíge": { + "lat": -7.6102105, + "lng": 15.0615865 + }, + "Zaire": { + "lat": -6.573345799999999, + "lng": 13.1740348 + }, + "Malanje Province": { + "lat": -9.8251183, + "lng": 16.912251 + }, + "Bengo Province": { + "lat": -9.104225699999999, + "lng": 13.7289167 + }, + "Cuanza Norte Province": { + "lat": -9.2398513, + "lng": 14.6587821 + }, + "Kwanza Sul": { + "lat": -10.595191, + "lng": 15.4068079 + }, + "Huíla": { + "lat": -15.0507288, + "lng": 13.5451374 + }, + "Cunene Province": { + "lat": -16.2802221, + "lng": 16.1580937 + }, + "Namibe Province": { + "lat": -15.7979942, + "lng": 12.4380581 + }, + "Cuando Cobango": { + "lat": -16.4180824, + "lng": 18.8076195 + }, + "Benguela": { + "lat": -12.5905158, + "lng": 13.416501 + }, + "Bíe": { + "lat": -12.5727907, + "lng": 17.668887 + }, + "Huambo": { + "lat": -12.7739761, + "lng": 15.7468535 + }, + "": { + "lat": -11.202692, + "lng": 17.873887 + } + }, + "AZ": { + "Nakhichevan": { + "lat": 39.3256814, + "lng": 45.4912647 + }, + "Lankaran Rayon": { + "lat": 38.6862484, + "lng": 48.79128679999999 + }, + "Astara": { + "lat": 38.4687834, + "lng": 48.8728029 + }, + "Zaqatala Rayon": { + "lat": 41.5906889, + "lng": 46.7240373 + }, + "Yevlax City": { + "lat": 40.6196638, + "lng": 47.1500324 + }, + "Ujar Rayon": { + "lat": 40.4250377, + "lng": 47.76990929999999 + }, + "Sumqayit City": { + "lat": 40.5854765, + "lng": 49.6317411 + }, + "Shamkir Rayon": { + "lat": 40.88123969999999, + "lng": 46.0179009 + }, + "Qusar Rayon": { + "lat": 41.4206965, + "lng": 48.1766522 + }, + "Qakh Rayon": { + "lat": 41.3756182, + "lng": 46.801388 + }, + "Qabala Rayon": { + "lat": 40.9253925, + "lng": 47.8016106 + }, + "Mingacevir City": { + "lat": 40.7702563, + "lng": 47.0496015 + }, + "Gobustan Rayon": { + "lat": 40.5446697, + "lng": 48.9550509 + }, + "Sabirabad Rayon": { + "lat": 39.9384752, + "lng": 48.6520675 + }, + "Absheron Rayon": { + "lat": 40.3629693, + "lng": 49.2736815 + }, + "Baku City": { + "lat": 40.40926169999999, + "lng": 49.8670924 + }, + "Khachmaz Rayon": { + "lat": 41.481364, + "lng": 48.7825102 + }, + "Ismayilli Rayon": { + "lat": 40.7429936, + "lng": 48.2125556 + }, + "Aghjabadi Rayon": { + "lat": 40.0529597, + "lng": 47.4553529 + }, + "Ganja City": { + "lat": 40.6878581, + "lng": 46.3723313 + }, + "Shamakhi Rayon": { + "lat": 40.64272680000001, + "lng": 48.6267413 + }, + "Barda": { + "lat": 40.3706555, + "lng": 47.1378909 + }, + "": { + "lat": 40.143105, + "lng": 47.576927 + } + }, + "TZ": { + "Zanzibar Urban/West": { + "lat": -6.229813600000001, + "lng": 39.2583293 + }, + "Pemba North": { + "lat": -5.031935199999999, + "lng": 39.7755571 + }, + "Geita": { + "lat": -2.8850378, + "lng": 32.2313539 + }, + "Arusha": { + "lat": -3.3869254, + "lng": 36.6829927 + }, + "Tanga": { + "lat": -5.0888751, + "lng": 39.1023228 + }, + "Tabora": { + "lat": -5.0424945, + "lng": 32.8197329 + }, + "Rukwa": { + "lat": -8.0109444, + "lng": 31.4456179 + }, + "Simiyu": { + "lat": -2.8308738, + "lng": 34.1531947 + }, + "Shinyanga": { + "lat": -3.6809961, + "lng": 33.4271403 + }, + "Dar es Salaam Region": { + "lat": -6.904236500000001, + "lng": 39.1959729 + }, + "Njombe": { + "lat": -9.3731511, + "lng": 34.8005067 + }, + "Mwanza": { + "lat": -2.5164305, + "lng": 32.9174517 + }, + "Mara": { + "lat": -1.7753538, + "lng": 34.1531947 + }, + "Katavi": { + "lat": -6.3677125, + "lng": 31.2626366 + }, + "Kilimanjaro": { + "lat": -3.0674247, + "lng": 37.35562729999999 + }, + "Morogoro": { + "lat": -6.8277556, + "lng": 37.6591144 + }, + "Mbeya": { + "lat": -8.9094014, + "lng": 33.4607744 + }, + "Iringa": { + "lat": -7.768059, + "lng": 35.6860723 + }, + "Kigoma": { + "lat": -4.8824092, + "lng": 29.6615055 + }, + "Pwani": { + "lat": -7.323771400000001, + "lng": 38.8205454 + }, + "Zanzibar North": { + "lat": -5.9395093, + "lng": 39.2791011 + }, + "Dodoma": { + "lat": -6.1811245, + "lng": 35.746877 + }, + "Pemba South": { + "lat": -5.3146961, + "lng": 39.7549511 + }, + "Kagera": { + "lat": -1.3001115, + "lng": 31.2626366 + }, + "Manyara": { + "lat": -4.3150058, + "lng": 36.954107 + }, + "Ruvuma": { + "lat": -10.6878717, + "lng": 36.2630846 + }, + "Mtwara": { + "lat": -10.3112236, + "lng": 40.1759806 + }, + "Lindi": { + "lat": -9.987607599999999, + "lng": 39.6982485 + }, + "": { + "lat": -6.369028, + "lng": 34.888822 + } + }, + "TM": { + "Balkan": { + "lat": 40.9468884, + "lng": 54.4952432 + }, + "Ashgabat": { + "lat": 37.9600766, + "lng": 58.32606289999999 + }, + "Ahal": { + "lat": 38.6399398, + "lng": 59.4720904 + }, + "Mary": { + "lat": 37.6092461, + "lng": 61.86432519999999 + }, + "Lebap": { + "lat": 38.12724619999999, + "lng": 64.7162415 + }, + "": { + "lat": 38.969719, + "lng": 59.556278 + } + }, + "IL": { + "Northern District": { + "lat": 32.8972246, + "lng": 35.3027226 + }, + "Jerusalem": { + "lat": 31.768319, + "lng": 35.21371 + }, + "Central District": { + "lat": 31.9521108, + "lng": 34.906551 + }, + "Southern District": { + "lat": 30.829562, + "lng": 35.0388164 + }, + "Haifa": { + "lat": 32.7940463, + "lng": 34.989571 + }, + "Tel Aviv": { + "lat": 32.0852999, + "lng": 34.78176759999999 + }, + "": { + "lat": 31.046051, + "lng": 34.851612 + } + }, + "SY": { + "Idlib Governorate": { + "lat": 35.8268798, + "lng": 36.6957216 + }, + "Homs Governorate": { + "lat": 34.2567123, + "lng": 38.3165725 + }, + "Aleppo Governorate": { + "lat": 36.2262393, + "lng": 37.4681396 + }, + "Damascus Governorate": { + "lat": 33.5138073, + "lng": 36.2765279 + }, + "Latakia Governorate": { + "lat": 35.6129791, + "lng": 36.0023225 + }, + "As-Suwayda Governorate": { + "lat": 32.7989156, + "lng": 36.7819505 + }, + "": { + "lat": 34.802075, + "lng": 38.996815 + } + }, + "AM": { + "Vayots Dzor": { + "lat": 39.8172259, + "lng": 45.6727334 + }, + "Ararat": { + "lat": 39.8516615, + "lng": 44.6953813 + }, + "Syunik": { + "lat": 39.2028881, + "lng": 46.4798169 + }, + "Yerevan": { + "lat": 40.1872023, + "lng": 44.515209 + }, + "Armavir": { + "lat": 40.1555525, + "lng": 44.0388482 + }, + "Kotayk": { + "lat": 40.2715286, + "lng": 44.63338299999999 + }, + "Gegharkunik": { + "lat": 40.2500421, + "lng": 45.1463314 + }, + "Tavush": { + "lat": 40.8791442, + "lng": 45.1470572 + }, + "Lori": { + "lat": 40.96984519999999, + "lng": 44.4900138 + }, + "Shirak": { + "lat": 40.8384463, + "lng": 43.91423959999999 + }, + "Aragatsotn": { + "lat": 40.2591212, + "lng": 44.1793418 + }, + "": { + "lat": 40.069099, + "lng": 45.038189 + } + }, + "ZM": { + "Luapula Province": { + "lat": -11.564831, + "lng": 29.0459927 + }, + "Western Province": { + "lat": -15.9454906, + "lng": 23.3823545 + }, + "North-Western Province": { + "lat": -13.0050258, + "lng": 24.9042208 + }, + "Northern Province": { + "lat": -9.7670177, + "lng": 30.8958242 + }, + "Copperbelt": { + "lat": -13.0570073, + "lng": 27.5495846 + }, + "Muchinga": { + "lat": -11.956818, + "lng": 31.2626366 + }, + "Southern Province": { + "lat": -16.9620634, + "lng": 26.419389 + }, + "Lusaka Province": { + "lat": -15.3657129, + "lng": 29.2320784 + }, + "Eastern Province": { + "lat": -13.8056187, + "lng": 31.99280779999999 + }, + "Central Province": { + "lat": -14.3112263, + "lng": 28.299435 + }, + "": { + "lat": -13.133897, + "lng": 27.849332 + } + }, + "KE": { + "Wajir": { + "lat": 1.7488388, + "lng": 40.058633 + }, + "Kakamega": { + "lat": 0.2827307, + "lng": 34.7518631 + }, + "Nairobi": { + "lat": -1.2920659, + "lng": 36.8219462 + }, + "Kiambu": { + "lat": -1.1748105, + "lng": 36.8304102 + }, + "Nyeri": { + "lat": -0.437099, + "lng": 36.9580104 + }, + "Murang'A": { + "lat": -0.7236857999999999, + "lng": 37.1606968 + }, + "Siaya": { + "lat": 0.0626293, + "lng": 34.2878084 + }, + "Kajiado": { + "lat": -1.8420731, + "lng": 36.7918599 + }, + "Narok": { + "lat": -1.0875428, + "lng": 35.87706420000001 + }, + "Laikipia": { + "lat": 0.3606063, + "lng": 36.7819505 + }, + "Nandi": { + "lat": 0.1835867, + "lng": 35.1268781 + }, + "Nakuru": { + "lat": -0.3030988, + "lng": 36.080026 + }, + "Machakos": { + "lat": -1.5176837, + "lng": 37.2634146 + }, + "Kilifi": { + "lat": -3.5106508, + "lng": 39.9093269 + }, + "Mombasa": { + "lat": -4.0434771, + "lng": 39.6682065 + }, + "Marsabit": { + "lat": 2.3354966, + "lng": 37.9943453 + }, + "Migori": { + "lat": -1.0706976, + "lng": 34.4752715 + }, + "Meru": { + "lat": 0.0514721, + "lng": 37.6456042 + }, + "Vihiga": { + "lat": 0.03835939999999999, + "lng": 34.6967094 + }, + "Samburu": { + "lat": 1.2154506, + "lng": 36.954107 + }, + "Mandera District": { + "lat": 3.5737991, + "lng": 40.958688 + }, + "Turkana": { + "lat": 3.3122477, + "lng": 35.5657862 + }, + "Kericho": { + "lat": -0.3688967, + "lng": 35.286286 + }, + "Lamu": { + "lat": -2.2695575, + "lng": 40.9006408 + }, + "Kwale": { + "lat": -4.1816115, + "lng": 39.4605612 + }, + "Kitui": { + "lat": -1.3750813, + "lng": 37.9952144 + }, + "Trans Nzoia": { + "lat": 1.0566667, + "lng": 34.9506625 + }, + "Kisumu": { + "lat": -0.0917016, + "lng": 34.7679568 + }, + "Kisii": { + "lat": -0.677334, + "lng": 34.779603 + }, + "Kirinyaga": { + "lat": -0.6590564999999999, + "lng": 37.3827234 + }, + "Nyamira": { + "lat": -0.5669405, + "lng": 34.9341234 + }, + "Marakwet District": { + "lat": 1.0498237, + "lng": 35.4781926 + }, + "West Pokot District": { + "lat": 1.6210076, + "lng": 35.3905046 + }, + "Baringo": { + "lat": 0.8554988, + "lng": 36.0893406 + }, + "Isiolo": { + "lat": 0.355636, + "lng": 37.5833061 + }, + "Homa Bay": { + "lat": -0.5350427, + "lng": 34.4530968 + }, + "Tana River District": { + "lat": -1.6518468, + "lng": 39.6518165 + }, + "Nyandarua": { + "lat": -0.1803855, + "lng": 36.5229641 + }, + "Garissa": { + "lat": -0.4532293, + "lng": 39.6460988 + }, + "Embu": { + "lat": -0.5388381, + "lng": 37.4596409 + }, + "Uasin Gishu": { + "lat": 0.5527638000000001, + "lng": 35.3027226 + }, + "Tharaka - Nithi": { + "lat": -0.2964851, + "lng": 37.7237678 + }, + "Bungoma": { + "lat": 0.5695252, + "lng": 34.5583766 + }, + "Bomet": { + "lat": -0.7777854, + "lng": 35.3356518 + }, + "": { + "lat": -0.023559, + "lng": 37.906193 + } + }, + "RW": { + "Northern Province": { + "lat": -1.656166, + "lng": 29.8815203 + }, + "Kigali": { + "lat": -1.9440727, + "lng": 30.0618851 + }, + "Western Province": { + "lat": -2.07649, + "lng": 29.3250347 + }, + "": { + "lat": -1.940278, + "lng": 29.873888 + } + }, + "CD": { + "Tshopo": { + "lat": 0.5455462, + "lng": 24.9042208 + }, + "Sud-Ubangi": { + "lat": 3.2299942, + "lng": 19.1880047 + }, + "Haut-Lomami": { + "lat": -7.705275199999999, + "lng": 24.9042208 + }, + "Lomami": { + "lat": -3.984212297447589, + "lng": 24.99751772303499 + }, + "Kasaï-Oriental": { + "lat": -6.033623, + "lng": 23.5728501 + }, + "Mongala": { + "lat": 1.9962324, + "lng": 21.4752851 + }, + "Kasai": { + "lat": -5.0471979, + "lng": 20.7122465 + }, + "Sankuru": { + "lat": -2.8437453, + "lng": 23.3823545 + }, + "Maniema": { + "lat": -3.0730929, + "lng": 26.0413889 + }, + "Tanganyika": { + "lat": -6.274011799999999, + "lng": 27.9249002 + }, + "Haut-Uele": { + "lat": 3.5845154, + "lng": 28.299435 + }, + "Nord Kivu": { + "lat": -0.7917729, + "lng": 29.0459927 + }, + "Nord-Ubangi": { + "lat": 3.8073246, + "lng": 20.7122465 + }, + "Ituri": { + "lat": 1.8754518, + "lng": 29.0459927 + }, + "Kasai-Central": { + "lat": -6.2514921, + "lng": 22.2384017 + }, + "South Kivu Province": { + "lat": -3.011658, + "lng": 28.299435 + }, + "Tshuapa": { + "lat": -0.5582387999999999, + "lng": 21.8568586 + }, + "Haut-Katanga": { + "lat": -11.0646485, + "lng": 27.5495846 + }, + "Kwango": { + "lat": -6.4337409, + "lng": 17.668887 + }, + "Kinshasa City": { + "lat": -4.3032527, + "lng": 15.310528 + }, + "Kwilu": { + "lat": -4.4863479, + "lng": 18.4276047 + }, + "Mai-Ndombe": { + "lat": -2.6357434, + "lng": 18.4276047 + }, + "Bas-Congo": { + "lat": -5.2365685, + "lng": 13.914399 + }, + "": { + "lat": -4.038333, + "lng": 21.758664 + } + }, + "DJ": { + "Tadjourah": { + "lat": 11.7873808, + "lng": 42.8810929 + }, + "Obock": { + "lat": 11.9647465, + "lng": 43.2884071 + }, + "Djibouti": { + "lat": 11.825138, + "lng": 42.590275 + }, + "Dikhil": { + "lat": 11.1054336, + "lng": 42.3704744 + }, + "Ali Sabieh Region": { + "lat": 11.1928973, + "lng": 42.941698 + }, + "Arta Region": { + "lat": 11.418236, + "lng": 42.4724578 + }, + "": { + "lat": 11.825138, + "lng": 42.590275 + } + }, + "UG": { + "Central Region": { + "lat": 0.2540775, + "lng": 31.99280779999999 + }, + "Western Region": { + "lat": 0.2580521, + "lng": 30.5279096 + }, + "Eastern Region": { + "lat": 1.2692186, + "lng": 33.438353 + }, + "Northern Region": { + "lat": 2.8780034, + "lng": 32.7181375 + }, + "": { + "lat": 1.373333, + "lng": 32.290275 + } + }, + "CF": { + "Haut-Mbomou": { + "lat": 6.2537134, + "lng": 25.4733554 + }, + "Mbomou": { + "lat": 5.556837000000001, + "lng": 23.7632828 + }, + "Vakaga": { + "lat": 9.5113296, + "lng": 22.2384017 + }, + "Haute-Kotto": { + "lat": 7.7964379, + "lng": 23.3823545 + }, + "Bamingui-Bangoran": { + "lat": 8.2733455, + "lng": 20.7122465 + }, + "Basse-Kotto": { + "lat": 4.8719319, + "lng": 21.2845025 + }, + "Ouaka": { + "lat": 6.3168216, + "lng": 20.7122465 + }, + "Ouham-Pendé": { + "lat": 6.6562516, + "lng": 15.9699878 + }, + "Sangha-Mbaéré": { + "lat": 3.4433928, + "lng": 15.9699878 + }, + "Lobaye": { + "lat": 4.038521, + "lng": 17.4795173 + }, + "Nana-Grébizi": { + "lat": 7.184860700000001, + "lng": 19.3783206 + }, + "Ouham": { + "lat": 7.090910999999999, + "lng": 17.668887 + }, + "Kémo": { + "lat": 5.8867794, + "lng": 19.3783206 + }, + "Ombella-M'Poko": { + "lat": 5.1188825, + "lng": 18.4276047 + }, + "Mambéré-Kadéï": { + "lat": 4.541792, + "lng": 16.1580937 + }, + "Nana-Mambéré": { + "lat": 5.8514026, + "lng": 15.4068079 + }, + "Bangui": { + "lat": 4.3946735, + "lng": 18.5581899 + }, + "": { + "lat": 6.611111, + "lng": 20.939444 + } + }, + "SC": { + "English River": { + "lat": -4.6149553, + "lng": 55.4540841 + }, + "Takamaka": { + "lat": -4.7843688, + "lng": 55.5081012 + }, + "Port Glaud": { + "lat": -4.6488523, + "lng": 55.41947529999999 + }, + "Plaisance": { + "lat": -4.6489918, + "lng": 55.4610075 + }, + "Glacis": { + "lat": -4.5719729, + "lng": 55.4416234 + }, + "Cascade": { + "lat": -4.670554099999999, + "lng": 55.49701810000001 + }, + "Beau Vallon": { + "lat": -4.6210967, + "lng": 55.4277802 + }, + "Anse Royale": { + "lat": -4.740798799999999, + "lng": 55.5081012 + }, + "Au Cap": { + "lat": -4.7059723, + "lng": 55.5081012 + }, + "Grand Anse Praslin": { + "lat": -4.3190725, + "lng": 55.69395129999999 + }, + "Anse-aux-Pins": { + "lat": -4.6900443, + "lng": 55.5150289 + }, + "Pointe Larue": { + "lat": -4.680489, + "lng": 55.51918569999999 + }, + "Baie Lazare": { + "lat": -4.7482525, + "lng": 55.4859363 + }, + "La Digue": { + "lat": -4.3590972, + "lng": 55.8412424 + }, + "Mont Fleuri": { + "lat": -4.6356543, + "lng": 55.4554688 + }, + "Anse Etoile": { + "lat": -4.5962834, + "lng": 55.4499303 + }, + "Baie Sainte Anne": { + "lat": -4.3496065, + "lng": 55.76200859999999 + }, + "Bel Air": { + "lat": -4.6440325, + "lng": 55.4499303 + }, + "Mont Buxton": { + "lat": -4.6166667, + "lng": 55.4457768 + }, + "Les Mamelles": { + "lat": -4.6539526, + "lng": 55.4720861 + }, + "": { + "lat": -4.679574, + "lng": 55.491977 + } + }, + "TD": { + "Sila": { + "lat": 12.13074, + "lng": 21.2845025 + }, + "Ennedi-Ouest": { + "lat": 19.1593285, + "lng": 20.3308814 + }, + "Wadi Fira Region": { + "lat": 15.0892416, + "lng": 21.4752851 + }, + "Salamat Region": { + "lat": 10.9691601, + "lng": 20.7122465 + }, + "Ouadaï": { + "lat": 13.5226612, + "lng": 21.2845025 + }, + "Chari-Baguirmi Region": { + "lat": 11.2564207, + "lng": 16.1580937 + }, + "Mayo-Kebbi Ouest": { + "lat": 9.4046039, + "lng": 14.8454619 + }, + "Moyen-Chari Region": { + "lat": 9.563214799999999, + "lng": 18.6175626 + }, + "Barh el Gazel": { + "lat": 14.7702266, + "lng": 16.912251 + }, + "Tandjilé": { + "lat": 9.5, + "lng": 16.5 + }, + "Kanem Region": { + "lat": 14.8781262, + "lng": 15.4068079 + }, + "N’Djaména": { + "lat": 12.1348457, + "lng": 15.0557415 + }, + "Logone Occidental Region": { + "lat": 8.6862748, + "lng": 15.5943388 + }, + "Guéra": { + "lat": 11.818682, + "lng": 18.4276047 + }, + "Mandoul": { + "lat": 8.603091, + "lng": 17.4795173 + }, + "Hadjer-Lamis": { + "lat": 12.4577273, + "lng": 16.7234639 + }, + "Borkou Region": { + "lat": 18.0137973, + "lng": 17.2902839 + }, + "Mayo-Kebbi Est": { + "lat": 10.4113014, + "lng": 15.5943388 + }, + "Lac Region": { + "lat": 13.3090183, + "lng": 14.4723301 + }, + "Logone Oriental Region": { + "lat": 8.3149949, + "lng": 16.3463791 + }, + "Batha Region": { + "lat": 13.9371775, + "lng": 18.4276047 + }, + "Tibesti Region": { + "lat": 20.9566791, + "lng": 17.2902839 + }, + "Ennedi-Est": { + "lat": 17.7620703, + "lng": 23.0011989 + }, + "": { + "lat": 15.454166, + "lng": 18.732207 + } + }, + "JO": { + "Ma’an": { + "lat": 30.1926637, + "lng": 35.7249409 + }, + "Jerash": { + "lat": 32.2746515, + "lng": 35.8960765 + }, + "Amman Governorate": { + "lat": 31.9453633, + "lng": 35.9283895 + }, + "Madaba": { + "lat": 31.7193405, + "lng": 35.7932432 + }, + "Irbid": { + "lat": 32.5568095, + "lng": 35.846887 + }, + "Ajloun": { + "lat": 32.3325599, + "lng": 35.751742 + }, + "Tafielah": { + "lat": 30.8337059, + "lng": 35.6160558 + }, + "Zarqa": { + "lat": 32.0608187, + "lng": 36.0941795 + }, + "Balqa": { + "lat": 32.0366806, + "lng": 35.728848 + }, + "Karak": { + "lat": 31.1853497, + "lng": 35.7047733 + }, + "Mafraq": { + "lat": 32.341673, + "lng": 36.2020028 + }, + "Aqaba": { + "lat": 29.5320522, + "lng": 35.0063209 + }, + "": { + "lat": 30.585164, + "lng": 36.238414 + } + }, + "GR": { + "Ionian Islands": { + "lat": 37.96948986153249, + "lng": 21.38023715882525 + }, + "West Greece": { + "lat": 38.5115496, + "lng": 21.5706786 + }, + "Peloponnese": { + "lat": 37.349722, + "lng": 22.352222 + }, + "Thessaly": { + "lat": 39.6102887, + "lng": 22.047637 + }, + "South Aegean": { + "lat": 37.0855302, + "lng": 25.1489215 + }, + "Attica": { + "lat": 38.0457568, + "lng": 23.8584737 + }, + "Epirus": { + "lat": 39.5706413, + "lng": 20.7642843 + }, + "Central Greece": { + "lat": 38.6043984, + "lng": 22.7152131 + }, + "Crete": { + "lat": 35.240117, + "lng": 24.8092691 + }, + "Central Macedonia": { + "lat": 40.621173, + "lng": 23.1918021 + }, + "North Aegean": { + "lat": 39.074208, + "lng": 21.824312 + }, + "West Macedonia": { + "lat": 40.3004058, + "lng": 21.7903559 + }, + "East Macedonia and Thrace": { + "lat": 41.1295126, + "lng": 24.8877191 + }, + "Mount Athos": { + "lat": 40.2644928, + "lng": 24.2152731 + }, + "": { + "lat": 39.074208, + "lng": 21.824312 + } + }, + "LB": { + "Mohafazat Beqaa": { + "lat": 33.8462662, + "lng": 35.9019489 + }, + "Mohafazat Liban-Nord": { + "lat": 34.4380625, + "lng": 35.8308233 + }, + "Mohafazat Mont-Liban": { + "lat": 33.8100858, + "lng": 35.5973139 + }, + "South Governorate": { + "lat": 33.2721479, + "lng": 35.2032778 + }, + "Mohafazat Nabatiye": { + "lat": 33.3771693, + "lng": 35.4838293 + }, + "Mohafazat Aakkar": { + "lat": 34.5328763, + "lng": 36.1328132 + }, + "Beyrouth": { + "lat": 33.8937913, + "lng": 35.5017767 + }, + "Mohafazat Baalbek-Hermel": { + "lat": 34.2658556, + "lng": 36.3498097 + }, + "": { + "lat": 33.854721, + "lng": 35.862285 + } + }, + "PS": { + "Rafah Governorate": { + "lat": 31.29677999999999, + "lng": 34.243482 + }, + "Deir al-Balah Governorate": { + "lat": 31.4216346, + "lng": 34.3865392 + }, + "Khan Yunis Governorate": { + "lat": 31.3546763, + "lng": 34.3088255 + }, + "North Gaza Governorate": { + "lat": 31.5417408, + "lng": 34.519604 + }, + "Gaza Governorate": { + "lat": 31.3546763, + "lng": 34.3088255 + }, + "Tulkarm Governorate": { + "lat": 32.3075097, + "lng": 35.1048713 + }, + "Tubas Governorate": { + "lat": 31.952162, + "lng": 35.233154 + }, + "Salfit Governorate": { + "lat": 32.1087714, + "lng": 35.1048713 + }, + "Ramallah and al-Bireh Governorate": { + "lat": 31.9485955, + "lng": 35.1708741 + }, + "Qalqilya Governorate": { + "lat": 32.1764026, + "lng": 35.0167866 + }, + "Nablus Governorate": { + "lat": 32.2226678, + "lng": 35.2621461 + }, + "Jenin Governorate": { + "lat": 32.397317, + "lng": 35.2587964 + }, + "Hebron": { + "lat": 31.532569, + "lng": 35.09982600000001 + }, + "Bethlehem Governorate": { + "lat": 31.952162, + "lng": 35.233154 + }, + "Jericho Governorate": { + "lat": 31.8611058, + "lng": 35.4617583 + }, + "Quds Governorate": { + "lat": 31.952162, + "lng": 35.233154 + }, + "": { + "lat": 31.952162, + "lng": 35.233154 + } + }, + "KW": { + "Muhafazat al Jahra'": { + "lat": 29.9931831, + "lng": 47.76347310000001 + }, + "Al Asimah": { + "lat": 29.3342457, + "lng": 47.9812152 + }, + "Hawalli": { + "lat": 29.33778819999999, + "lng": 48.0234708 + }, + "Al Aḩmadī": { + "lat": 29.083461, + "lng": 48.07347619999999 + }, + "Mubārak al Kabīr": { + "lat": 29.1886698, + "lng": 48.0806909 + }, + "Al Farwaniyah": { + "lat": 29.281596, + "lng": 47.96025179999999 + }, + "": { + "lat": 29.31166, + "lng": 47.481766 + } + }, + "OM": { + "Muscat": { + "lat": 23.5880307, + "lng": 58.3828717 + }, + "Southeastern Governorate": { + "lat": 22.0158249, + "lng": 59.3251922 + }, + "Al Batinah North Governorate": { + "lat": 24.3419846, + "lng": 56.7298904 + }, + "Ad Dakhiliyah": { + "lat": 22.8588758, + "lng": 57.5394356 + }, + "Al Wusta Governorate": { + "lat": 19.9571078, + "lng": 56.2756846 + }, + "Northeastern Governorate": { + "lat": 22.7141196, + "lng": 58.5308064 + }, + "Dhofar": { + "lat": 17.0322121, + "lng": 54.1425214 + }, + "Musandam Governorate": { + "lat": 26.1986144, + "lng": 56.2460949 + }, + "Ad Dhahirah": { + "lat": 23.2161674, + "lng": 56.4907444 + }, + "Al Batinah South": { + "lat": 23.4314903, + "lng": 57.4239796 + }, + "Al Buraimi": { + "lat": 24.2815207, + "lng": 55.8245558 + }, + "": { + "lat": 21.512583, + "lng": 55.923255 + } + }, + "QA": { + "Baladiyat Umm Salal": { + "lat": 25.4600894, + "lng": 51.3260472 + }, + "Al Wakrah": { + "lat": 25.1659314, + "lng": 51.5975524 + }, + "Al-Shahaniya": { + "lat": 25.4106386, + "lng": 51.1846025 + }, + "Baladiyat ash Shamal": { + "lat": 26.1182743, + "lng": 51.2157265 + }, + "Baladiyat ar Rayyan": { + "lat": 24.9169514, + "lng": 51.0540674 + }, + "Al Khor": { + "lat": 25.6804078, + "lng": 51.4968502 + }, + "Baladiyat ad Dawhah": { + "lat": 25.2854473, + "lng": 51.53103979999999 + }, + "": { + "lat": 25.354826, + "lng": 51.183884 + } + }, + "BH": { + "Manama": { + "lat": 26.2235305, + "lng": 50.5875935 + }, + "Northern": { + "lat": 26.1882301, + "lng": 50.4928628 + }, + "Muharraq": { + "lat": 26.2534919, + "lng": 50.60825579999999 + }, + "Southern Governorate": { + "lat": 26.0303854, + "lng": 50.5549719 + }, + "": { + "lat": 25.930414, + "lng": 50.637772 + } + }, + "AE": { + "Imarat Umm al Qaywayn": { + "lat": 25.5256168, + "lng": 55.5403105 + }, + "Imarat Ra's al Khaymah": { + "lat": 25.6741343, + "lng": 55.9804173 + }, + "Sharjah": { + "lat": 25.3561698, + "lng": 55.427211 + }, + "Dubai": { + "lat": 25.2048493, + "lng": 55.2707828 + }, + "Abu Dhabi": { + "lat": 24.453884, + "lng": 54.3773438 + }, + "Fujairah": { + "lat": 25.1288099, + "lng": 56.3264849 + }, + "Ajman": { + "lat": 25.4052165, + "lng": 55.5136433 + }, + "": { + "lat": 23.424076, + "lng": 53.847818 + } + }, + "TR": { + "Siirt": { + "lat": 37.927404, + "lng": 41.94197800000001 + }, + "Adana": { + "lat": 36.9914194, + "lng": 35.3308285 + }, + "İzmir Province": { + "lat": 38.3591693, + "lng": 27.2676116 + }, + "Hakkâri": { + "lat": 37.577427, + "lng": 43.736782 + }, + "Antalya": { + "lat": 36.8968908, + "lng": 30.7133233 + }, + "Yozgat": { + "lat": 39.821049, + "lng": 34.808573 + }, + "Şanlıurfa": { + "lat": 37.1674039, + "lng": 38.7955149 + }, + "Hatay": { + "lat": 36.202585, + "lng": 36.1604041 + }, + "Malatya": { + "lat": 38.3553627, + "lng": 38.33352470000001 + }, + "Ankara": { + "lat": 39.9333635, + "lng": 32.8597419 + }, + "Aksaray": { + "lat": 38.368626, + "lng": 34.0297 + }, + "Aydın": { + "lat": 37.8380162, + "lng": 27.8455601 + }, + "Konya": { + "lat": 37.8746429, + "lng": 32.4931554 + }, + "Sivas": { + "lat": 39.750545, + "lng": 37.0150217 + }, + "Diyarbakır Province": { + "lat": 38.1066372, + "lng": 40.5426896 + }, + "Van": { + "lat": 38.5012085, + "lng": 43.3729793 + }, + "Uşak": { + "lat": 38.6742286, + "lng": 29.4058825 + }, + "Niğde Province": { + "lat": 38.0993086, + "lng": 34.6856509 + }, + "Şırnak": { + "lat": 37.518974, + "lng": 42.453714 + }, + "Muğla": { + "lat": 37.215374, + "lng": 28.363394 + }, + "Ağrı": { + "lat": 39.719074, + "lng": 43.050591 + }, + "Manisa": { + "lat": 38.6140337, + "lng": 27.4295624 + }, + "Tunceli": { + "lat": 39.10617, + "lng": 39.548259 + }, + "Bingöl": { + "lat": 38.885464, + "lng": 40.496625 + }, + "Bitlis": { + "lat": 38.400569, + "lng": 42.109502 + }, + "Mersin": { + "lat": 36.8121041, + "lng": 34.6414811 + }, + "Erzincan": { + "lat": 39.746797, + "lng": 39.491124 + }, + "Kayseri": { + "lat": 38.720489, + "lng": 35.48259700000001 + }, + "Balıkesir": { + "lat": 39.65329759999999, + "lng": 27.8903423 + }, + "Elazığ": { + "lat": 38.674816, + "lng": 39.22251500000001 + }, + "Afyonkarahisar Province": { + "lat": 38.7391099, + "lng": 30.7120023 + }, + "Kütahya": { + "lat": 39.4199547, + "lng": 29.985732 + }, + "Eskişehir": { + "lat": 39.7667061, + "lng": 30.52563109999999 + }, + "Mardin": { + "lat": 37.312903, + "lng": 40.733951 + }, + "Kahramanmaraş": { + "lat": 37.5752755, + "lng": 36.9228223 + }, + "Denizli": { + "lat": 37.7830159, + "lng": 29.0963328 + }, + "Batman": { + "lat": 37.8895167, + "lng": 41.1292832 + }, + "Adıyaman Province": { + "lat": 37.9078291, + "lng": 38.48499229999999 + }, + "Isparta": { + "lat": 37.7626487, + "lng": 30.55370499999999 + }, + "Osmaniye": { + "lat": 37.0746279, + "lng": 36.2464002 + }, + "Kilis": { + "lat": 36.716477, + "lng": 37.114661 + }, + "Gaziantep": { + "lat": 37.065953, + "lng": 37.37811 + }, + "Nevşehir Province": { + "lat": 38.6939399, + "lng": 34.6856509 + }, + "Muş": { + "lat": 38.734561, + "lng": 41.491038 + }, + "Kırşehir": { + "lat": 39.146078, + "lng": 34.1594989 + }, + "Kırıkkale": { + "lat": 39.8397835, + "lng": 33.5088782 + }, + "Burdur": { + "lat": 37.718336, + "lng": 30.282333 + }, + "Karaman": { + "lat": 37.181009, + "lng": 33.22224300000001 + }, + "Iğdır": { + "lat": 39.92005959999999, + "lng": 44.0436151 + }, + "Erzurum": { + "lat": 39.9054993, + "lng": 41.2658236 + }, + "Bilecik": { + "lat": 40.142573, + "lng": 29.97933 + }, + "Zonguldak": { + "lat": 41.45352099999999, + "lng": 31.78938 + }, + "Kocaeli": { + "lat": 40.7654408, + "lng": 29.9408089 + }, + "Istanbul": { + "lat": 41.0082376, + "lng": 28.9783589 + }, + "Artvin": { + "lat": 41.180937, + "lng": 41.820819 + }, + "Trabzon": { + "lat": 41.0026969, + "lng": 39.7167633 + }, + "Giresun": { + "lat": 40.91753200000001, + "lng": 38.392653 + }, + "Bursa Province": { + "lat": 40.0655459, + "lng": 29.2320784 + }, + "Karabük Province": { + "lat": 41.187489, + "lng": 32.7417419 + }, + "Gümüşhane Province": { + "lat": 40.28036729999999, + "lng": 39.3143253 + }, + "Yalova": { + "lat": 40.654895, + "lng": 29.284186 + }, + "Kırklareli": { + "lat": 41.735472, + "lng": 27.224369 + }, + "Edirne": { + "lat": 41.67712969999999, + "lng": 26.5557145 + }, + "Ordu": { + "lat": 40.986166, + "lng": 37.879721 + }, + "Bartın": { + "lat": 41.637602, + "lng": 32.333811 + }, + "Sinop": { + "lat": 42.02797400000001, + "lng": 35.151725 + }, + "Kastamonu": { + "lat": 41.376625, + "lng": 33.776497 + }, + "Kars Province": { + "lat": 40.2807636, + "lng": 42.9919527 + }, + "Tokat": { + "lat": 40.3234643, + "lng": 36.5521928 + }, + "Samsun": { + "lat": 41.2797031, + "lng": 36.3360667 + }, + "Tekirdağ": { + "lat": 40.9780919, + "lng": 27.511674 + }, + "Çorum": { + "lat": 40.54992560000001, + "lng": 34.9537242 + }, + "Amasya": { + "lat": 40.656455, + "lng": 35.837347 + }, + "Sakarya": { + "lat": 40.7730743, + "lng": 30.3948169 + }, + "Bolu": { + "lat": 40.732541, + "lng": 31.608209 + }, + "Rize Province": { + "lat": 40.95814970000001, + "lng": 40.9226985 + }, + "Çankırı": { + "lat": 40.600207, + "lng": 33.616223 + }, + "Canakkale": { + "lat": 40.14671999999999, + "lng": 26.408587 + }, + "Duezce": { + "lat": 40.83872, + "lng": 31.162609 + }, + "Bayburt Province": { + "lat": 40.3317555, + "lng": 40.1437863 + }, + "Ardahan": { + "lat": 41.11295, + "lng": 42.70227999999999 + }, + "": { + "lat": 38.963745, + "lng": 35.243322 + } + }, + "ET": { + "Somali": { + "lat": 6.6612293, + "lng": 43.7908453 + }, + "Oromiya": { + "lat": 7.546037699999999, + "lng": 40.6346851 + }, + "Amhara": { + "lat": 11.3494247, + "lng": 37.9784585 + }, + "Harari Region": { + "lat": 9.314866, + "lng": 42.1967716 + }, + "Gambela": { + "lat": 8.2505817, + "lng": 34.5877629 + }, + "Afar Region": { + "lat": 12.1056239, + "lng": 40.6346851 + }, + "Dire Dawa": { + "lat": 9.6048717, + "lng": 41.85850809999999 + }, + "Southern Nations, Nationalities, and People's Region": { + "lat": 6.5156911, + "lng": 36.954107 + }, + "Bīnshangul Gumuz": { + "lat": 10.7802889, + "lng": 35.5657862 + }, + "Tigray": { + "lat": 14.0323336, + "lng": 38.3165725 + }, + "Addis Ababa": { + "lat": 9.0191936, + "lng": 38.7524635 + }, + "": { + "lat": 9.145, + "lng": 40.489673 + } + }, + "ER": { + "Northern Red Sea": { + "lat": 16.2583997, + "lng": 38.8205454 + }, + "Anseba Region": { + "lat": 16.4745531, + "lng": 37.8087693 + }, + "Southern Red Sea Region": { + "lat": 13.875153, + "lng": 41.441251 + }, + "Gash-Barka Region": { + "lat": 15.4068825, + "lng": 37.6386622 + }, + "Maekel Region": { + "lat": 15.3852689, + "lng": 38.904164 + }, + "Debub Region": { + "lat": 14.9478692, + "lng": 39.1543677 + }, + "": { + "lat": 15.179384, + "lng": 39.782334 + } + }, + "EG": { + "Gharbia": { + "lat": 30.8753556, + "lng": 31.03351 + }, + "New Valley": { + "lat": 24.5455638, + "lng": 27.1735316 + }, + "Qalyubia": { + "lat": 30.3292368, + "lng": 31.2168466 + }, + "Dakahlia": { + "lat": 31.1656044, + "lng": 31.4913182 + }, + "Monufia": { + "lat": 30.5972455, + "lng": 30.9876321 + }, + "Beni Suweif": { + "lat": 28.8938837, + "lng": 31.4456179 + }, + "Sohag": { + "lat": 26.5590737, + "lng": 31.6956705 + }, + "Matruh": { + "lat": 31.3543445, + "lng": 27.2373159 + }, + "Cairo Governorate": { + "lat": 29.9537564, + "lng": 31.5370003 + }, + "South Sinai": { + "lat": 29.3101828, + "lng": 34.1531947 + }, + "Beheira": { + "lat": 30.8480986, + "lng": 30.3435506 + }, + "North Sinai": { + "lat": 30.282365, + "lng": 33.617577 + }, + "Qena": { + "lat": 26.155061, + "lng": 32.7160121 + }, + "Kafr el-Sheikh": { + "lat": 31.11065929999999, + "lng": 30.9387799 + }, + "Sharqia": { + "lat": 30.7326622, + "lng": 31.7195459 + }, + "Minya": { + "lat": 28.0870967, + "lng": 30.7618397 + }, + "Asyut": { + "lat": 27.1783117, + "lng": 31.1859257 + }, + "Giza": { + "lat": 30.0130557, + "lng": 31.2088526 + }, + "Faiyum": { + "lat": 29.3084021, + "lng": 30.8428497 + }, + "Damietta Governorate": { + "lat": 31.3625799, + "lng": 31.6739371 + }, + "Aswan": { + "lat": 24.088938, + "lng": 32.8998293 + }, + "Port Said": { + "lat": 31.2652893, + "lng": 32.3018661 + }, + "Suez": { + "lat": 29.9668343, + "lng": 32.5498069 + }, + "Alexandria": { + "lat": 31.2000924, + "lng": 29.9187387 + }, + "Luxor": { + "lat": 25.6872431, + "lng": 32.6396357 + }, + "Ismailia Governorate": { + "lat": 30.5830934, + "lng": 32.2653887 + }, + "Red Sea": { + "lat": 24.6826316, + "lng": 34.1531947 + }, + "": { + "lat": 26.820553, + "lng": 30.802498 + } + }, + "AL": { + "Vlorë County": { + "lat": 40.150096, + "lng": 19.8067916 + }, + "Gjirokastër County": { + "lat": 40.1910995, + "lng": 20.2355647 + }, + "Korçë County": { + "lat": 40.590567, + "lng": 20.6168921 + }, + "Dibër County": { + "lat": 41.5888163, + "lng": 20.2355647 + }, + "Elbasan County": { + "lat": 41.1266672, + "lng": 20.2355647 + }, + "Kukës County": { + "lat": 42.0120271, + "lng": 20.4262088 + }, + "Tirana": { + "lat": 41.3275459, + "lng": 19.8186982 + }, + "Berat County": { + "lat": 40.6953012, + "lng": 20.0449662 + }, + "Shkodër County": { + "lat": 42.150371, + "lng": 19.6639309 + }, + "Lezhë County": { + "lat": 41.7813759, + "lng": 19.8067916 + }, + "Fier County": { + "lat": 40.727504, + "lng": 19.5627596 + }, + "Durrës County": { + "lat": 41.50809719999999, + "lng": 19.6163185 + }, + "": { + "lat": 41.153332, + "lng": 20.168331 + } + }, + "SD": { + "Northern": { + "lat": 18.4448963, + "lng": 30.1589303 + }, + "Al Jazīrah": { + "lat": 14.4275191, + "lng": 33.258793 + }, + "Northern Darfur": { + "lat": 15.7661969, + "lng": 24.9042208 + }, + "Khartoum": { + "lat": 15.5006544, + "lng": 32.5598994 + }, + "Sinnār": { + "lat": 13.567469, + "lng": 33.5672045 + }, + "White Nile": { + "lat": 13.2403881, + "lng": 32.5372741 + }, + "Southern Darfur": { + "lat": 11.3464512, + "lng": 24.524264 + }, + "Kassala": { + "lat": 15.4581332, + "lng": 36.4039629 + }, + "Southern Kordofan": { + "lat": 11.0365439, + "lng": 30.8958242 + }, + "Red Sea": { + "lat": 19.4556063, + "lng": 35.2148469 + }, + "Central Darfur": { + "lat": 12.3135376, + "lng": 23.1918021 + }, + "Eastern Darfur": { + "lat": 11.1376634, + "lng": 26.7967849 + }, + "Blue Nile": { + "lat": 11.5860078, + "lng": 34.1531947 + }, + "North Kordofan": { + "lat": 15.170596, + "lng": 29.4179324 + }, + "West Kordofan State": { + "lat": 11.6452819, + "lng": 28.299435 + }, + "Al Qaḑārif": { + "lat": 14.024307, + "lng": 35.3685679 + }, + "Western Darfur": { + "lat": 12.8463561, + "lng": 23.0011989 + }, + "River Nile": { + "lat": 17.1142529, + "lng": 33.7964613 + }, + "": { + "lat": 12.862807, + "lng": 30.217636 + } + }, + "SS": { + "Central Equatoria": { + "lat": 4.6144063, + "lng": 31.2626366 + }, + "": { + "lat": 7.0770786, + "lng": 30.2045784 + } + }, + "BI": { + "Makamba Province": { + "lat": -4.3257062, + "lng": 29.6962677 + }, + "Bururi Province": { + "lat": -3.8594819, + "lng": 29.6499162 + }, + "Rumonge": { + "lat": -3.9754049, + "lng": 29.4388014 + }, + "Mwaro": { + "lat": -3.4268749, + "lng": 29.6393546 + }, + "Bujumbura Rural Province": { + "lat": -3.512215, + "lng": 29.3714909 + }, + "Bujumbura Mairie Province": { + "lat": -3.3884141, + "lng": 29.3482646 + }, + "Muramvya Province": { + "lat": -3.2898398, + "lng": 29.6499162 + }, + "Gitega Province": { + "lat": -3.4929051, + "lng": 29.9277947 + }, + "Ruyigi Province": { + "lat": -3.446207, + "lng": 30.2512728 + }, + "Cankuzo Province": { + "lat": -3.1527788, + "lng": 30.6199895 + }, + "Bubanza Province": { + "lat": -3.1572403, + "lng": 29.3714909 + }, + "Cibitoke Province": { + "lat": -2.8102897, + "lng": 29.1855785 + }, + "Ngozi Province": { + "lat": -2.8958243, + "lng": 29.8815203 + }, + "Kayanza Province": { + "lat": -3.0077981, + "lng": 29.6499162 + }, + "Muyinga Province": { + "lat": -2.7793511, + "lng": 30.29741989999999 + }, + "Kirundo Province": { + "lat": -2.5762882, + "lng": 30.112735 + }, + "Rutana Province": { + "lat": -3.879152299999999, + "lng": 30.0665236 + }, + "": { + "lat": -3.373056, + "lng": 29.918886 + } + }, + "RU": { + "Tver Oblast": { + "lat": 57.0021654, + "lng": 33.9853142 + }, + "Orenburg Oblast": { + "lat": 51.76340260000001, + "lng": 54.6188188 + }, + "Vladimir Oblast": { + "lat": 56.1553465, + "lng": 40.5926685 + }, + "Moscow Oblast": { + "lat": 55.340396, + "lng": 38.2917651 + }, + "Chelyabinsk Oblast": { + "lat": 54.43194219999999, + "lng": 60.87889629999999 + }, + "Rostov Oblast": { + "lat": 47.6853247, + "lng": 41.8258952 + }, + "Volgograd Oblast": { + "lat": 49.7604522, + "lng": 45 + }, + "Kaluga Oblast": { + "lat": 54.3872666, + "lng": 35.1889094 + }, + "Samara Oblast": { + "lat": 53.41838389999999, + "lng": 50.4725528 + }, + "Kursk Oblast": { + "lat": 51.76340260000001, + "lng": 35.3811812 + }, + "Stavropol Kray": { + "lat": 44.6680993, + "lng": 43.520214 + }, + "Kaliningrad Oblast": { + "lat": 54.8235292, + "lng": 21.4816163 + }, + "Moscow": { + "lat": 55.755826, + "lng": 37.6173 + }, + "St.-Petersburg": { + "lat": 59.9310584, + "lng": 30.3609097 + }, + "Tatarstan Republic": { + "lat": 55.1802364, + "lng": 50.7263945 + }, + "Tambov Oblast": { + "lat": 52.6416589, + "lng": 41.4216451 + }, + "Nizhny Novgorod Oblast": { + "lat": 55.7995159, + "lng": 44.0296769 + }, + "Krasnodar Krai": { + "lat": 45.6415289, + "lng": 39.7055977 + }, + "Lipetsk Oblast": { + "lat": 52.5264702, + "lng": 39.2032269 + }, + "Karelia": { + "lat": 63.15587019999999, + "lng": 32.9905552 + }, + "Komi": { + "lat": 63.8630539, + "lng": 54.8312689 + }, + "Perm Krai": { + "lat": 58.8231929, + "lng": 56.5872481 + }, + "Mariy-El Republic": { + "lat": 56.438457, + "lng": 47.9607757 + }, + "Tula Oblast": { + "lat": 54.163768, + "lng": 37.5649507 + }, + "Smolensk Oblast": { + "lat": 54.9882994, + "lng": 32.6677378 + }, + "Kirov Oblast": { + "lat": 58.4198529, + "lng": 50.2097248 + }, + "Yaroslavl Oblast": { + "lat": 57.8991523, + "lng": 38.8388633 + }, + "Chuvashia": { + "lat": 55.55959920000001, + "lng": 46.9283536 + }, + "Bashkortostan Republic": { + "lat": 54.2312172, + "lng": 56.1645257 + }, + "Leningrad Oblast": { + "lat": 60.0793208, + "lng": 31.8926644 + }, + "Adygeya Republic": { + "lat": 44.8229155, + "lng": 40.1754463 + }, + "Vologda Oblast": { + "lat": 59.8706711, + "lng": 40.6555411 + }, + "Udmurtiya Republic": { + "lat": 57.0670218, + "lng": 53.0277948 + }, + "Voronezh Oblast": { + "lat": 50.8589713, + "lng": 39.8644375 + }, + "Saratov Oblast": { + "lat": 51.83692629999999, + "lng": 46.7539397 + }, + "Belgorod Oblast": { + "lat": 50.71069259999999, + "lng": 37.7533377 + }, + "Kostroma Oblast": { + "lat": 58.5501069, + "lng": 43.9541103 + }, + "North Ossetia–Alania": { + "lat": 43.0451302, + "lng": 44.2870972 + }, + "Oryol oblast": { + "lat": 52.7856414, + "lng": 36.9242344 + }, + "Arkhangelskaya": { + "lat": 63.2852803, + "lng": 42.5884191 + }, + "Pskov Oblast": { + "lat": 56.7708599, + "lng": 29.094009 + }, + "Ryazan Oblast": { + "lat": 54.3875964, + "lng": 41.259566 + }, + "Bryansk Oblast": { + "lat": 53.0408599, + "lng": 33.2690899 + }, + "Ulyanovsk Oblast": { + "lat": 53.9793357, + "lng": 47.7762426 + }, + "Kabardino-Balkariya Republic": { + "lat": 43.3932469, + "lng": 43.5628498 + }, + "Ivanovo Oblast": { + "lat": 57.1056854, + "lng": 41.4830084 + }, + "Sverdlovsk Oblast": { + "lat": 59.007735, + "lng": 61.9316226 + }, + "Murmansk": { + "lat": 68.97331129999999, + "lng": 33.0855848 + }, + "Penza Oblast": { + "lat": 53.1412105, + "lng": 44.0940048 + }, + "Mordoviya Republic": { + "lat": 54.2369441, + "lng": 44.0683969 + }, + "Chechnya": { + "lat": 43.4023301, + "lng": 45.7187468 + }, + "Novgorod Oblast": { + "lat": 58.2427552, + "lng": 32.5665191 + }, + "Dagestan": { + "lat": 42.14318859999999, + "lng": 47.0949799 + }, + "Astrakhan Oblast": { + "lat": 46.1321166, + "lng": 48.0610115 + }, + "Ingushetiya Republic": { + "lat": 43.4051698, + "lng": 44.82029989999999 + }, + "Nenets": { + "lat": 67.6078337, + "lng": 57.63383309999999 + }, + "Kalmykiya Republic": { + "lat": 46.18671759999999, + "lng": 45 + }, + "Karachayevo-Cherkesiya Republic": { + "lat": 43.8845143, + "lng": 41.730394 + }, + "Kurgan Oblast": { + "lat": 55.4481548, + "lng": 65.11809749999999 + }, + "Tyumen Oblast": { + "lat": 56.9634387, + "lng": 66.948278 + }, + "Altai Krai": { + "lat": 51.7936298, + "lng": 82.6758596 + }, + "Krasnoyarsk Krai": { + "lat": 64.24797579999999, + "lng": 95.11041759999999 + }, + "Kemerovo Oblast": { + "lat": 54.7574648, + "lng": 87.4055288 + }, + "Yamalo-Nenets": { + "lat": 66.0653057, + "lng": 76.9345194 + }, + "Khakasiya Republic": { + "lat": 53.0452281, + "lng": 90.3982145 + }, + "Khanty-Mansia": { + "lat": 62.2287062, + "lng": 70.6410058 + }, + "Irkutsk Oblast": { + "lat": 56.13214199999999, + "lng": 103.948625 + }, + "Altai": { + "lat": 50.6181924, + "lng": 86.2199308 + }, + "Tomsk Oblast": { + "lat": 58.8969882, + "lng": 82.67654999999999 + }, + "Republic of Tyva": { + "lat": 51.8872669, + "lng": 95.62601719999999 + }, + "Omsk Oblast": { + "lat": 55.0554669, + "lng": 73.3167343 + }, + "Novosibirsk Oblast": { + "lat": 55.4467133, + "lng": 80.1043924 + }, + "Transbaikal Territory": { + "lat": 52.0471848, + "lng": 113.5176751 + }, + "Amur Oblast": { + "lat": 54.60350649999999, + "lng": 127.4801721 + }, + "Buryatiya Republic": { + "lat": 54.83311459999999, + "lng": 112.4060529 + }, + "Sakha": { + "lat": 66.7613451, + "lng": 124.1237531 + }, + "Primorye": { + "lat": 43.09164579999999, + "lng": 131.5873057 + }, + "Khabarovsk": { + "lat": 48.4814433, + "lng": 135.0720667 + }, + "Yevrey (Jewish) Autonomous Oblast": { + "lat": 48.4808147, + "lng": 131.7657367 + }, + "Kamchatka": { + "lat": 61.43439809999999, + "lng": 166.7884131 + }, + "Sakhalin Oblast": { + "lat": 49.9807847, + "lng": 143.3738129 + }, + "Magadan Oblast": { + "lat": 62.66434169999999, + "lng": 153.9149909 + }, + "Chukotka": { + "lat": 65.6298355, + "lng": 171.6952159 + }, + "": { + "lat": 61.52401, + "lng": 105.318756 + } + }, + "LV": { + "Valmiera": { + "lat": 57.53125290000001, + "lng": 25.4083677 + }, + "Saulkrasti Municipality": { + "lat": 57.2191743, + "lng": 24.5183794 + }, + "Ludza Municipality": { + "lat": 56.5446597, + "lng": 27.8386317 + }, + "Jelgava Municipality": { + "lat": 56.5032408, + "lng": 23.6926539 + }, + "Ropaži Municipality": { + "lat": 57.0034394, + "lng": 24.5155446 + }, + "Balvi Municipality": { + "lat": 57.0736222, + "lng": 27.4647376 + }, + "Jēkabpils Municipality": { + "lat": 56.2737577, + "lng": 25.8919513 + }, + "Limbaži Municipality": { + "lat": 57.7047514, + "lng": 24.6203033 + }, + "Bauska Municipality": { + "lat": 56.4152375, + "lng": 24.3533501 + }, + "Ventspils": { + "lat": 57.4088355, + "lng": 21.620621 + }, + "Varakļāni Municipality": { + "lat": 56.6100296, + "lng": 26.6391527 + }, + "Sigulda Municipality": { + "lat": 57.1327819, + "lng": 24.8162797 + }, + "Valka": { + "lat": 57.77526940000001, + "lng": 26.0211962 + }, + "Talsi Municipality": { + "lat": 57.3415208, + "lng": 22.5713125 + }, + "South Kurzeme Municipality": { + "lat": 56.57919949999999, + "lng": 21.5692299 + }, + "Tukums Municipality": { + "lat": 56.8555629, + "lng": 23.1166094 + }, + "Ogre": { + "lat": 56.8138328, + "lng": 24.6060417 + }, + "Cēsis Municipality": { + "lat": 57.1458187, + "lng": 25.4324729 + }, + "Smiltene Municipality": { + "lat": 57.39835829999999, + "lng": 26.0495451 + }, + "Kuldīga Municipality": { + "lat": 56.9052094, + "lng": 21.8522859 + }, + "Aizkraukle Municipality": { + "lat": 56.5283659, + "lng": 25.3921443 + }, + "Salaspils Municipality": { + "lat": 56.877881, + "lng": 24.3695603 + }, + "Saldus Municipality": { + "lat": 56.58679, + "lng": 22.5423817 + }, + "Riga": { + "lat": 56.9676941, + "lng": 24.1056221 + }, + "Rezekne": { + "lat": 56.5099129, + "lng": 27.3328284 + }, + "Ķekava": { + "lat": 56.8226783, + "lng": 24.2271889 + }, + "Preili Municipality": { + "lat": 56.3257394, + "lng": 26.7189095 + }, + "Mārupe": { + "lat": 56.9021321, + "lng": 24.0401848 + }, + "Ventspils Municipality": { + "lat": 57.2833682, + "lng": 21.8587558 + }, + "Madona Municipality": { + "lat": 56.8030323, + "lng": 26.1097605 + }, + "Līvāni": { + "lat": 56.3538262, + "lng": 26.1761923 + }, + "Liepaja": { + "lat": 56.5384577, + "lng": 21.0538204 + }, + "Augsdaugava Municipality": { + "lat": 55.9947059, + "lng": 26.2764871 + }, + "Dobele Municipality": { + "lat": 56.5333505, + "lng": 23.0638172 + }, + "Krāslava Municipality": { + "lat": 55.95655069999999, + "lng": 27.261882 + }, + "Ādaži": { + "lat": 57.07710789999999, + "lng": 24.323533 + }, + "Jurmala": { + "lat": 56.9684476, + "lng": 23.7737546 + }, + "Jelgava": { + "lat": 56.6511478, + "lng": 23.7196411 + }, + "Olaine": { + "lat": 56.7871834, + "lng": 23.9422997 + }, + "Gulbene Municipality": { + "lat": 57.2155645, + "lng": 26.6452955 + }, + "Daugavpils": { + "lat": 55.88261259999999, + "lng": 26.5464985 + }, + "Rēzekne Municipality": { + "lat": 56.3273638, + "lng": 27.3284332 + }, + "Alūksne Municipality": { + "lat": 57.3830775, + "lng": 27.1889735 + }, + "": { + "lat": 56.879635, + "lng": 24.603189 + } + }, + "EE": { + "Pärnumaa": { + "lat": 58.3916898, + "lng": 24.4952895 + }, + "Võrumaa": { + "lat": 57.84250170000001, + "lng": 27.0059657 + }, + "Ida-Virumaa": { + "lat": 59.25926629999999, + "lng": 27.4136535 + }, + "Viljandimaa": { + "lat": 58.363476, + "lng": 25.5859691 + }, + "Harjumaa": { + "lat": 59.33342390000001, + "lng": 25.2466973 + }, + "Lääne-Virumaa": { + "lat": 59.30188159999999, + "lng": 26.3280312 + }, + "Valgamaa": { + "lat": 57.78145679999999, + "lng": 26.0550403 + }, + "Tartu": { + "lat": 58.37798299999999, + "lng": 26.7290383 + }, + "Järvamaa": { + "lat": 58.8866713, + "lng": 25.5000625 + }, + "Lääne": { + "lat": 58.9722742, + "lng": 23.8740834 + }, + "Jõgevamaa": { + "lat": 58.7425969, + "lng": 26.3877205 + }, + "Raplamaa": { + "lat": 59.0000543, + "lng": 24.8048628 + }, + "Põlvamaa": { + "lat": 58.067018, + "lng": 27.0738741 + }, + "Saare": { + "lat": 57.9229168, + "lng": 22.0450493 + }, + "Hiiumaa": { + "lat": 58.9239553, + "lng": 22.5919468 + }, + "": { + "lat": 58.595272, + "lng": 25.013607 + } + }, + "LT": { + "Vilnius": { + "lat": 54.6871555, + "lng": 25.2796514 + }, + "Kaunas": { + "lat": 54.8985207, + "lng": 23.9035965 + }, + "Utena": { + "lat": 55.5000404, + "lng": 25.609385 + }, + "Siauliai": { + "lat": 55.9349085, + "lng": 23.3136823 + }, + "Klaipėda County": { + "lat": 55.6686983, + "lng": 21.4241373 + }, + "Marijampolė County": { + "lat": 54.78199710000001, + "lng": 23.1341364 + }, + "Telsiai": { + "lat": 55.9833874, + "lng": 22.2503539 + }, + "Alytus": { + "lat": 54.3960558, + "lng": 24.0460597 + }, + "Panevėžys": { + "lat": 55.7347915, + "lng": 24.3574773 + }, + "Tauragė County": { + "lat": 55.3072586, + "lng": 22.357294 + }, + "": { + "lat": 55.169438, + "lng": 23.881275 + } + }, + "UZ": { + "Karakalpakstan": { + "lat": 43.8041334, + "lng": 59.4457988 + }, + "Samarqand Region": { + "lat": 39.920791, + "lng": 66.4271499 + }, + "Qashqadaryo": { + "lat": 38.9271163, + "lng": 65.7539311 + }, + "Surxondaryo Region": { + "lat": 37.9409005, + "lng": 67.57085359999999 + }, + "Bukhara": { + "lat": 39.7680827, + "lng": 64.4555769 + }, + "Tashkent": { + "lat": 41.2994958, + "lng": 69.2400734 + }, + "Tashkent Region": { + "lat": 41.2213234, + "lng": 69.8597406 + }, + "Xorazm Region": { + "lat": 41.3565336, + "lng": 60.8566686 + }, + "Sirdaryo Region": { + "lat": 40.3863808, + "lng": 68.7154975 + }, + "Namangan": { + "lat": 41.0057729, + "lng": 71.6436028 + }, + "Navoiy Region": { + "lat": 42.6988575, + "lng": 64.6337685 + }, + "Jizzakh Region": { + "lat": 40.4706415, + "lng": 67.57085359999999 + }, + "Fergana": { + "lat": 40.37338020000001, + "lng": 71.7978333 + }, + "Andijan Region": { + "lat": 40.7685941, + "lng": 72.236379 + }, + "": { + "lat": 41.377491, + "lng": 64.585262 + } + }, + "SE": { + "Norrbotten County": { + "lat": 66.8309216, + "lng": 20.3991966 + }, + "Västerbotten County": { + "lat": 65.3337311, + "lng": 16.5161695 + }, + "Uppsala County": { + "lat": 60.2724233, + "lng": 18.1396124 + }, + "Jämtland County": { + "lat": 63.1711922, + "lng": 14.95918 + }, + "Västra Götaland County": { + "lat": 58.15991589999999, + "lng": 12.1360549 + }, + "Skåne County": { + "lat": 55.99025719999999, + "lng": 13.5957692 + }, + "Jönköping": { + "lat": 57.78261370000001, + "lng": 14.1617876 + }, + "Örebro County": { + "lat": 59.535036, + "lng": 15.0065731 + }, + "Östergötland County": { + "lat": 58.3453635, + "lng": 15.5197843 + }, + "Kronoberg County": { + "lat": 56.89064940000001, + "lng": 14.5084523 + }, + "Gotland County": { + "lat": 57.531291, + "lng": 18.6901396 + }, + "Kalmar": { + "lat": 56.6634447, + "lng": 16.356779 + }, + "Västmanland County": { + "lat": 59.6713879, + "lng": 16.2158954 + }, + "Södermanland County": { + "lat": 59.03363489999999, + "lng": 16.7518899 + }, + "Halland County": { + "lat": 56.7957699, + "lng": 12.893493 + }, + "Gävleborg County": { + "lat": 61.51322, + "lng": 15.53581 + }, + "Stockholm County": { + "lat": 59.4069048, + "lng": 18.8230665 + }, + "Dalarna County": { + "lat": 61.0917012, + "lng": 14.6663653 + }, + "Värmland County": { + "lat": 59.5807897, + "lng": 12.8472974 + }, + "Västernorrland County": { + "lat": 62.98552900000001, + "lng": 18.1258604 + }, + "Blekinge County": { + "lat": 56.143109, + "lng": 15.3666703 + }, + "": { + "lat": 60.128161, + "lng": 18.643501 + } + }, + "KZ": { + "Mangistauskaya Oblast'": { + "lat": 44.590802, + "lng": 53.84995079999999 + }, + "Atyrau Oblysy": { + "lat": 47.9053152, + "lng": 51.3780767 + }, + "West Kazakhstan": { + "lat": 49.56797270000001, + "lng": 50.8066617 + }, + "Aktyubinskaya Oblast'": { + "lat": 48.7797078, + "lng": 57.9974377 + }, + "Karaganda": { + "lat": 49.8046835, + "lng": 73.1093826 + }, + "Almaty": { + "lat": 43.2379761, + "lng": 76.8828618 + }, + "East Kazakhstan": { + "lat": 48.9131152, + "lng": 84.5574499 + }, + "Ulytau Region": { + "lat": 47.2847476, + "lng": 68.3533295 + }, + "Abai Region": { + "lat": 49.2237489, + "lng": 79.61572869999999 + }, + "Zhambyl Oblysy": { + "lat": 44.2220308, + "lng": 72.36579669999999 + }, + "North Kazakhstan": { + "lat": 54.1622066, + "lng": 69.9387071 + }, + "Aqmola Oblysy": { + "lat": 51.916532, + "lng": 69.4110493 + }, + "Jetisu Region": { + "lat": 45.0119227, + "lng": 78.4229392 + }, + "South Kazakhstan": { + "lat": 42.2663378, + "lng": 68.14314139999999 + }, + "Shymkent": { + "lat": 42.32053399999999, + "lng": 69.58763019999999 + }, + "Qostanay Oblysy": { + "lat": 51.5077096, + "lng": 64.04790729999999 + }, + "Qyzylorda Oblysy": { + "lat": 45.4114713, + "lng": 62.9526298 + }, + "Almaty Oblysy": { + "lat": 43.9368069, + "lng": 76.8259652 + }, + "Pavlodar Region": { + "lat": 52.65085440000001, + "lng": 76.7773224 + }, + "Astana": { + "lat": 51.1655126, + "lng": 71.4272222 + }, + "": { + "lat": 48.019573, + "lng": 66.923684 + } + }, + "GE": { + "Samegrelo and Zemo Svaneti": { + "lat": 42.7352247, + "lng": 42.1689362 + }, + "Mtskheta-Mtianeti": { + "lat": 42.16821849999999, + "lng": 44.6506057 + }, + "Shida Kartli": { + "lat": 42.0756944, + "lng": 43.9540462 + }, + "Kakheti": { + "lat": 41.6481602, + "lng": 45.6905554 + }, + "K'alak'i T'bilisi": { + "lat": 41.6938026, + "lng": 44.80151679999999 + }, + "Abkhazia": { + "lat": 42.9737816, + "lng": 41.4421799 + }, + "Imereti": { + "lat": 42.230108, + "lng": 42.9008665 + }, + "Kvemo Kartli": { + "lat": 41.4791833, + "lng": 44.65604510000001 + }, + "Guria": { + "lat": 41.9442736, + "lng": 42.0458091 + }, + "Racha-Lechkhumi and Kvemo Svaneti": { + "lat": 42.67188729999999, + "lng": 43.0562836 + }, + "Achara": { + "lat": 41.6005626, + "lng": 42.0688383 + }, + "Samtskhe-Javakheti": { + "lat": 41.5479296, + "lng": 43.2776399 + }, + "": { + "lat": 42.315407, + "lng": 43.356892 + } + }, + "UA": { + "Sumy": { + "lat": 50.9077, + "lng": 34.7981 + }, + "Donetsk": { + "lat": 48.015883, + "lng": 37.80285 + }, + "Lviv": { + "lat": 49.839683, + "lng": 24.029717 + }, + "Cherkasy Oblast": { + "lat": 49.444433, + "lng": 32.059767 + }, + "Luhansk": { + "lat": 48.574041, + "lng": 39.307815 + }, + "Kharkiv": { + "lat": 49.9935, + "lng": 36.230383 + }, + "Kirovohrad Oblast": { + "lat": 48.50793300000001, + "lng": 32.262317 + }, + "Zhytomyr": { + "lat": 50.25465, + "lng": 28.658667 + }, + "Vinnytsia": { + "lat": 49.233083, + "lng": 28.468217 + }, + "Khmelnytskyi Oblast": { + "lat": 49.422983, + "lng": 26.987133 + }, + "Dnipropetrovsk Oblast": { + "lat": 48.464717, + "lng": 35.046183 + }, + "Kyiv Oblast": { + "lat": 50.0529506, + "lng": 30.7667133 + }, + "Poltava Oblast": { + "lat": 49.58826699999999, + "lng": 34.551417 + }, + "Rivne": { + "lat": 50.6199, + "lng": 26.251617 + }, + "Ternopil Oblast": { + "lat": 49.553517, + "lng": 25.594767 + }, + "Zakarpattia Oblast": { + "lat": 48.6208, + "lng": 22.287883 + }, + "Volyn": { + "lat": 50.74723299999999, + "lng": 25.325383 + }, + "Chernivtsi": { + "lat": 48.2920787, + "lng": 25.9358367 + }, + "Zaporizhzhia": { + "lat": 47.8388, + "lng": 35.139567 + }, + "Ivano-Frankivsk Oblast": { + "lat": 48.922633, + "lng": 24.711117 + }, + "Odessa": { + "lat": 46.482526, + "lng": 30.7233095 + }, + "Crimea": { + "lat": 48.379433, + "lng": 31.16558 + }, + "Mykolaiv": { + "lat": 46.975033, + "lng": 31.994583 + }, + "Chernihiv": { + "lat": 51.4982, + "lng": 31.28935 + }, + "Kherson Oblast": { + "lat": 46.635417, + "lng": 32.616867 + }, + "Sebastopol City": { + "lat": 44.61665, + "lng": 33.525367 + }, + "Kyiv City": { + "lat": 50.4501, + "lng": 30.5234 + }, + "": { + "lat": 48.379433, + "lng": 31.16558 + } + }, + "MD": { + "Raionul Edineţ": { + "lat": 48.1678991, + "lng": 27.2936143 + }, + "Gagauzia": { + "lat": 46.0979435, + "lng": 28.6384645 + }, + "Nisporeni": { + "lat": 47.07513489999999, + "lng": 28.1768155 + }, + "Anenii Noi": { + "lat": 46.8795663, + "lng": 29.2312175 + }, + "Rîşcani": { + "lat": 47.9382017, + "lng": 27.5614647 + }, + "Chișinău Municipality": { + "lat": 47.0104529, + "lng": 28.8638103 + }, + "Ungheni": { + "lat": 47.2305767, + "lng": 27.7892661 + }, + "Taraclia": { + "lat": 45.898651, + "lng": 28.6671645 + }, + "Transnistria": { + "lat": 47.411631, + "lng": 28.369885 + }, + "Teleneşti": { + "lat": 47.4983962, + "lng": 28.3676019 + }, + "Cahul": { + "lat": 45.8939404, + "lng": 28.1890275 + }, + "Cantemir": { + "lat": 46.2771742, + "lng": 28.2009653 + }, + "Raionul Stefan Voda": { + "lat": 46.5540488, + "lng": 29.702242 + }, + "Briceni": { + "lat": 48.3632022, + "lng": 27.0750398 + }, + "Orhei": { + "lat": 47.38604, + "lng": 28.8303082 + }, + "Glodeni": { + "lat": 47.7790156, + "lng": 27.5168009 + }, + "Strășeni": { + "lat": 47.1450267, + "lng": 28.6136736 + }, + "Raionul Soroca": { + "lat": 48.1549743, + "lng": 28.2870783 + }, + "Şoldăneşti": { + "lat": 47.81473889999999, + "lng": 28.7889586 + }, + "Hînceşti": { + "lat": 46.8281147, + "lng": 28.5850889 + }, + "Raionul Causeni": { + "lat": 46.6554715, + "lng": 29.4091223 + }, + "Rezina": { + "lat": 47.7486542, + "lng": 28.9612499 + }, + "Făleşti": { + "lat": 47.5647725, + "lng": 27.7265593 + }, + "Sîngerei": { + "lat": 47.6389134, + "lng": 28.1371816 + }, + "Raionul Dubasari": { + "lat": 47.34481599999999, + "lng": 29.0765341 + }, + "Raionul Ocniţa": { + "lat": 48.3687818, + "lng": 27.4943835 + }, + "Criuleni": { + "lat": 47.2136114, + "lng": 29.1557519 + }, + "Floreşti": { + "lat": 47.8909785, + "lng": 28.298958 + }, + "Leova": { + "lat": 46.4780356, + "lng": 28.2539462 + }, + "Ialoveni": { + "lat": 46.9462689, + "lng": 28.7798944 + }, + "Drochia": { + "lat": 48.0318119, + "lng": 27.8182766 + }, + "Raionul Calarasi": { + "lat": 47.286946, + "lng": 28.274531 + }, + "Cimişlia": { + "lat": 46.5250851, + "lng": 28.7721835 + }, + "Bender Municipality": { + "lat": 46.84633609999999, + "lng": 29.4534416 + }, + "Basarabeasca": { + "lat": 46.3304439, + "lng": 28.9758649 + }, + "Municipiul Balti": { + "lat": 47.7778898, + "lng": 27.9087841 + }, + "": { + "lat": 47.411631, + "lng": 28.369885 + } + }, + "BY": { + "Minsk": { + "lat": 53.9006011, + "lng": 27.558972 + }, + "Homyel’ Voblasc’": { + "lat": 52.4313388, + "lng": 30.99367 + }, + "Brest": { + "lat": 52.0996507, + "lng": 23.7636662 + }, + "Vitebsk": { + "lat": 55.1926809, + "lng": 30.206359 + }, + "Grodnenskaya": { + "lat": 53.6599945, + "lng": 25.344857 + }, + "Mogilev": { + "lat": 53.8980663, + "lng": 30.3325337 + }, + "Minsk City": { + "lat": 53.9006011, + "lng": 27.558972 + }, + "": { + "lat": 53.709807, + "lng": 27.953389 + } + }, + "FI": { + "Ostrobothnia": { + "lat": 63.28620240000001, + "lng": 21.4765525 + }, + "Pirkanmaa": { + "lat": 61.6986918, + "lng": 23.7895598 + }, + "North Ostrobothnia": { + "lat": 64.7151463, + "lng": 26.018763 + }, + "Satakunta": { + "lat": 61.59327580000001, + "lng": 22.148308 + }, + "Southwest Finland": { + "lat": 60.36279139999999, + "lng": 22.4439369 + }, + "North Karelia": { + "lat": 62.8062078, + "lng": 30.1553887 + }, + "Kainuu": { + "lat": 64.3736564, + "lng": 28.7437475 + }, + "Kymenlaakso": { + "lat": 60.59772, + "lng": 27.2591176 + }, + "Paijat-Hame Region": { + "lat": 61.3230041, + "lng": 25.7322496 + }, + "Central Finland": { + "lat": 62.5666743, + "lng": 25.5549445 + }, + "Uusimaa": { + "lat": 60.21872, + "lng": 25.2716209 + }, + "North Savo": { + "lat": 62.94236739999999, + "lng": 27.7043601 + }, + "South Karelia Region": { + "lat": 61.2033481, + "lng": 28.6226138 + }, + "South Ostrobothnia": { + "lat": 62.9433099, + "lng": 23.5285266 + }, + "Kanta-Häme": { + "lat": 60.90701499999999, + "lng": 24.3005498 + }, + "Lapland": { + "lat": 67.9222304, + "lng": 26.5046438 + }, + "Southern Savonia": { + "lat": 61.6945148, + "lng": 27.8005015 + }, + "Central Ostrobothnia": { + "lat": 63.5621735, + "lng": 24.001363 + }, + "": { + "lat": 61.92411, + "lng": 25.748151 + } + }, + "RO": { + "Caras-Severin": { + "lat": 45.1139646, + "lng": 22.0740993 + }, + "Alba": { + "lat": 46.1558924, + "lng": 23.5556121 + }, + "Teleorman": { + "lat": 44.0160491, + "lng": 25.2986628 + }, + "Buzau": { + "lat": 45.1371109, + "lng": 26.8171122 + }, + "Hunedoara": { + "lat": 45.7678128, + "lng": 22.9072331 + }, + "Brasov": { + "lat": 45.6426802, + "lng": 25.5887252 + }, + "Neamt": { + "lat": 46.9758685, + "lng": 26.3818764 + }, + "Braila": { + "lat": 45.2652463, + "lng": 27.9594714 + }, + "Salaj": { + "lat": 47.2090813, + "lng": 23.2121901 + }, + "Sibiu": { + "lat": 45.80347889999999, + "lng": 24.1449997 + }, + "Arges": { + "lat": 45.0722527, + "lng": 24.8142726 + }, + "Mures": { + "lat": 46.5569904, + "lng": 24.6723215 + }, + "Mehedinti": { + "lat": 44.5515053, + "lng": 22.9044157 + }, + "Botosani": { + "lat": 47.7406537, + "lng": 26.6658127 + }, + "Ilfov": { + "lat": 44.535548, + "lng": 26.2324886 + }, + "Arad": { + "lat": 46.1865606, + "lng": 21.3122677 + }, + "Suceava": { + "lat": 47.6634521, + "lng": 26.2732302 + }, + "Iasi": { + "lat": 47.1584549, + "lng": 27.6014418 + }, + "Harghita": { + "lat": 46.4928507, + "lng": 25.6456696 + }, + "Dambovita": { + "lat": 44.9289893, + "lng": 25.425385 + }, + "Maramureş": { + "lat": 47.6737598, + "lng": 23.7456285 + }, + "Dolj": { + "lat": 44.1623022, + "lng": 23.6325054 + }, + "Galati": { + "lat": 45.4353208, + "lng": 28.0079945 + }, + "Vrancea": { + "lat": 45.81348759999999, + "lng": 27.0657531 + }, + "Vaslui": { + "lat": 46.6406915, + "lng": 27.7276468 + }, + "Calarasi": { + "lat": 44.2085144, + "lng": 27.3137439 + }, + "Timis": { + "lat": 45.7488716, + "lng": 21.2086793 + }, + "Constanta": { + "lat": 44.1759147, + "lng": 28.6519359 + }, + "Prahova": { + "lat": 45.08919059999999, + "lng": 26.0829312 + }, + "Bistrita-Nasaud": { + "lat": 47.2486107, + "lng": 24.5322814 + }, + "Bihor": { + "lat": 47.01575159999999, + "lng": 22.172266 + }, + "Bacau": { + "lat": 46.5670437, + "lng": 26.9145748 + }, + "Giurgiu": { + "lat": 43.9037076, + "lng": 25.9699265 + }, + "Ialomita": { + "lat": 44.603133, + "lng": 27.3789914 + }, + "Covasna": { + "lat": 45.84727669999999, + "lng": 26.1783452 + }, + "Cluj": { + "lat": 46.7712101, + "lng": 23.6236353 + }, + "Tulcea": { + "lat": 45.1716165, + "lng": 28.7914439 + }, + "Gorj": { + "lat": 44.94855949999999, + "lng": 23.2427079 + }, + "Olt": { + "lat": 44.200797, + "lng": 24.5022981 + }, + "Satu Mare": { + "lat": 47.8016702, + "lng": 22.8575926 + }, + "Valcea": { + "lat": 45.30095439999999, + "lng": 24.1590342 + }, + "Bucuresti": { + "lat": 44.4267674, + "lng": 26.1025384 + }, + "": { + "lat": 45.943161, + "lng": 24.96676 + } + }, + "HU": { + "Hajdú-Bihar": { + "lat": 47.4688355, + "lng": 21.5453228 + }, + "Bekes County": { + "lat": 46.67048990000001, + "lng": 21.0434996 + }, + "Szabolcs-Szatmár-Bereg": { + "lat": 48.0394954, + "lng": 22.00333 + }, + "Jász-Nagykun-Szolnok": { + "lat": 47.2555579, + "lng": 20.5232456 + }, + "Heves megye": { + "lat": 47.8057617, + "lng": 20.2038559 + }, + "Csongrad megye": { + "lat": 46.416705, + "lng": 20.2566161 + }, + "Borsod-Abaúj-Zemplén": { + "lat": 48.2939401, + "lng": 20.6934113 + }, + "Bács-Kiskun": { + "lat": 46.5661437, + "lng": 19.4272464 + }, + "Pest megye": { + "lat": 47.44800009999999, + "lng": 19.4618128 + }, + "Budapest": { + "lat": 47.497912, + "lng": 19.040235 + }, + "Somogy megye": { + "lat": 46.554859, + "lng": 17.5866732 + }, + "Veszprem megye": { + "lat": 47.0930974, + "lng": 17.9100763 + }, + "Fejér": { + "lat": 47.1217932, + "lng": 18.5294815 + }, + "Zala": { + "lat": 46.73844039999999, + "lng": 16.9152252 + }, + "Győr-Moson-Sopron": { + "lat": 47.6509285, + "lng": 17.2505883 + }, + "Komárom-Esztergom": { + "lat": 47.7390852, + "lng": 18.1267006 + }, + "Vas": { + "lat": 47.09291109999999, + "lng": 16.6812183 + }, + "Baranya": { + "lat": 46.0484585, + "lng": 18.2719173 + }, + "Nograd megye": { + "lat": 47.9218427, + "lng": 19.5586447 + }, + "Tolna megye": { + "lat": 46.4762754, + "lng": 18.5570627 + }, + "": { + "lat": 47.162494, + "lng": 19.503304 + } + }, + "SK": { + "Presov": { + "lat": 49.0018324, + "lng": 21.2393119 + }, + "Kosice": { + "lat": 48.7163857, + "lng": 21.2610746 + }, + "Banska Bystrica": { + "lat": 48.736277, + "lng": 19.1461917 + }, + "Zilina": { + "lat": 49.21944980000001, + "lng": 18.7408001 + }, + "Trencin": { + "lat": 48.884936, + "lng": 18.0335209 + }, + "Nitra": { + "lat": 48.3061414, + "lng": 18.076376 + }, + "Trnava": { + "lat": 48.3943898, + "lng": 17.7216205 + }, + "Bratislava": { + "lat": 48.1485965, + "lng": 17.1077477 + }, + "": { + "lat": 48.669026, + "lng": 19.699024 + } + }, + "BG": { + "Varna": { + "lat": 43.2140504, + "lng": 27.9147333 + }, + "Dobrich": { + "lat": 43.57259, + "lng": 27.8272606 + }, + "Kardzhali": { + "lat": 41.6338439, + "lng": 25.3777119 + }, + "Smolyan": { + "lat": 41.5774233, + "lng": 24.7011138 + }, + "Sofia": { + "lat": 42.6977082, + "lng": 23.3218675 + }, + "Plovdiv": { + "lat": 42.1354079, + "lng": 24.7452904 + }, + "Veliko Tarnovo": { + "lat": 43.0756739, + "lng": 25.6171514 + }, + "Blagoevgrad": { + "lat": 42.0208569, + "lng": 23.0943385 + }, + "Kyustendil": { + "lat": 42.2868817, + "lng": 22.6939308 + }, + "Vratsa": { + "lat": 43.2102375, + "lng": 23.5528803 + }, + "Burgas": { + "lat": 42.50479259999999, + "lng": 27.4626361 + }, + "Silistra": { + "lat": 44.1147271, + "lng": 27.2671901 + }, + "Razgrad": { + "lat": 43.53367189999999, + "lng": 26.5411164 + }, + "Pazardzhik": { + "lat": 42.1927654, + "lng": 24.3335662 + }, + "Pleven": { + "lat": 43.4170423, + "lng": 24.6066847 + }, + "Yambol": { + "lat": 42.48419990000001, + "lng": 26.5035023 + }, + "Sliven": { + "lat": 42.6816536, + "lng": 26.3228685 + }, + "Lovech": { + "lat": 43.1369534, + "lng": 24.7141906 + }, + "Montana": { + "lat": 43.4085161, + "lng": 23.2257292 + }, + "Sofia-Capital": { + "lat": 42.7570109, + "lng": 23.4504683 + }, + "Vidin": { + "lat": 43.996159, + "lng": 22.8679302 + }, + "Ruse": { + "lat": 43.83557130000001, + "lng": 25.9656554 + }, + "Haskovo": { + "lat": 41.9344366, + "lng": 25.5554462 + }, + "Stara Zagora": { + "lat": 42.4257769, + "lng": 25.6344644 + }, + "Targovishte": { + "lat": 43.2493556, + "lng": 26.5727357 + }, + "Gabrovo": { + "lat": 42.8742212, + "lng": 25.3186837 + }, + "Pernik": { + "lat": 42.6051862, + "lng": 23.0378368 + }, + "Shumen": { + "lat": 43.2712398, + "lng": 26.9361286 + }, + "": { + "lat": 42.733883, + "lng": 25.48583 + } + }, + "PL": { + "Lublin": { + "lat": 51.2464536, + "lng": 22.5684463 + }, + "Mazovia": { + "lat": 52.5373228, + "lng": 21.0768288 + }, + "Subcarpathia": { + "lat": 50.0574749, + "lng": 22.0895691 + }, + "Świętokrzyskie": { + "lat": 50.6261041, + "lng": 20.9406279 + }, + "Lesser Poland": { + "lat": 49.72253060000001, + "lng": 20.2503359 + }, + "Warmia-Masuria": { + "lat": 53.8671117, + "lng": 20.702786 + }, + "Podlasie": { + "lat": 53.0697159, + "lng": 22.9674639 + }, + "Łódź Voivodeship": { + "lat": 51.4634771, + "lng": 19.1726974 + }, + "Silesia": { + "lat": 50.5716595, + "lng": 19.3219768 + }, + "Opole Voivodeship": { + "lat": 50.8003761, + "lng": 17.937989 + }, + "West Pomerania": { + "lat": 53.46578909999999, + "lng": 15.1822581 + }, + "Lubusz": { + "lat": 52.2274612, + "lng": 15.2559103 + }, + "Pomerania": { + "lat": 54.2944252, + "lng": 18.1531164 + }, + "Lower Silesia": { + "lat": 51.13398609999999, + "lng": 16.8841961 + }, + "Kujawsko-Pomorskie": { + "lat": 53.1648363, + "lng": 18.4834224 + }, + "Greater Poland": { + "lat": 52.279986, + "lng": 17.3522939 + }, + "": { + "lat": 51.919438, + "lng": 19.145136 + } + }, + "NO": { + "Troms og Finnmark": { + "lat": 70.0718208, + "lng": 23.2171664 + }, + "Nordland": { + "lat": 68.6670601, + "lng": 14.946742 + }, + "Vestland": { + "lat": 60.8138379, + "lng": 6.1410712 + }, + "Viken": { + "lat": 60.0389059, + "lng": 9.725371899999999 + }, + "Møre og Romsdal": { + "lat": 62.0743203, + "lng": 6.2108624 + }, + "Rogaland": { + "lat": 59.1489544, + "lng": 6.0143431 + }, + "Innlandet": { + "lat": 61.3368595, + "lng": 9.946462799999999 + }, + "Trøndelag": { + "lat": 63.4305149, + "lng": 10.3950528 + }, + "Agder": { + "lat": 58.50320840000001, + "lng": 7.757197499999999 + }, + "Vestfold og Telemark": { + "lat": 59.35421950000001, + "lng": 8.8817981 + }, + "Oslo County": { + "lat": 59.9138688, + "lng": 10.7522454 + }, + "": { + "lat": 60.472024, + "lng": 8.468946 + } + }, + "MK": { + "Zrnovci": { + "lat": 41.854056, + "lng": 22.4438316 + }, + "Zhelino": { + "lat": 41.98034190000001, + "lng": 21.0609438 + }, + "Vinica": { + "lat": 41.883306, + "lng": 22.5081193 + }, + "Vevchani": { + "lat": 41.24075430000001, + "lng": 20.5915649 + }, + "Vasilevo": { + "lat": 41.4741699, + "lng": 22.6422128 + }, + "Valandovo": { + "lat": 41.3169713, + "lng": 22.5617585 + }, + "Veles": { + "lat": 41.7164563, + "lng": 21.7722966 + }, + "Tetovo": { + "lat": 42.0069115, + "lng": 20.9715269 + }, + "Tearce": { + "lat": 42.0777511, + "lng": 21.0534923 + }, + "Sveti Nikole": { + "lat": 41.8656094, + "lng": 21.9373445 + }, + "Strumica": { + "lat": 41.4378004, + "lng": 22.6427428 + }, + "Struga": { + "lat": 41.1778353, + "lng": 20.6783258 + }, + "Shtip": { + "lat": 41.746429, + "lng": 22.199654 + }, + "Petrovec": { + "lat": 41.9401891, + "lng": 21.6094324 + }, + "Debarca": { + "lat": 41.3584077, + "lng": 20.8552919 + }, + "Mavrovo and Rostuša": { + "lat": 41.6243153, + "lng": 20.6645683 + }, + "Rosoman": { + "lat": 41.5175986, + "lng": 21.9433064 + }, + "Resen": { + "lat": 41.0902853, + "lng": 21.0132542 + }, + "Rankovce": { + "lat": 42.1695387, + "lng": 22.116195 + }, + "Radovish": { + "lat": 41.6395303, + "lng": 22.4678884 + }, + "Probishtip": { + "lat": 41.9948176, + "lng": 22.1877315 + }, + "Prilep": { + "lat": 41.3440827, + "lng": 21.5527922 + }, + "Plasnica": { + "lat": 41.471841, + "lng": 21.1220478 + }, + "Ohrid": { + "lat": 41.1230977, + "lng": 20.8016481 + }, + "Novo Selo": { + "lat": 41.4117903, + "lng": 22.87907 + }, + "Pehchevo": { + "lat": 41.7621467, + "lng": 22.8865173 + }, + "Negotino": { + "lat": 41.48292199999999, + "lng": 22.092349 + }, + "Demir Hisar": { + "lat": 41.22136039999999, + "lng": 21.2025287 + }, + "Mogila": { + "lat": 41.15723939999999, + "lng": 21.4037407 + }, + "Vrapchishte": { + "lat": 41.83841779999999, + "lng": 20.8858003 + }, + "Opstina Centar": { + "lat": 41.9953628, + "lng": 21.4246078 + }, + "Kumanovo": { + "lat": 42.0732613, + "lng": 21.7853143 + }, + "Kriva Palanka": { + "lat": 42.2058454, + "lng": 22.3307965 + }, + "Kratovo": { + "lat": 42.0800379, + "lng": 22.1802799 + }, + "Saraj": { + "lat": 41.9994052, + "lng": 21.324745 + }, + "Kochani": { + "lat": 41.91680480000001, + "lng": 22.4082849 + }, + "Kichevo": { + "lat": 41.5129112, + "lng": 20.9525065 + }, + "Kavadarci": { + "lat": 41.4329364, + "lng": 22.0088861 + }, + "Karbinci": { + "lat": 41.8180159, + "lng": 22.2324758 + }, + "Makedonska Kamenica": { + "lat": 42.0214425, + "lng": 22.5870873 + }, + "Jegunovce": { + "lat": 42.07407200000001, + "lng": 21.1220478 + }, + "Gradsko": { + "lat": 41.5750625, + "lng": 21.9494531 + }, + "Gostivar": { + "lat": 41.80255409999999, + "lng": 20.9089378 + }, + "Karposh": { + "lat": 42.0029617, + "lng": 21.3977787 + }, + "Centar Zhupa": { + "lat": 41.47852779999999, + "lng": 20.5602793 + }, + "Gevgelija": { + "lat": 41.1451892, + "lng": 22.4997467 + }, + "Konche": { + "lat": 41.5171011, + "lng": 22.3814624 + }, + "Lozovo": { + "lat": 41.7818139, + "lng": 21.9000827 + }, + "Gjorche Petrov": { + "lat": 42.0069781, + "lng": 21.3649879 + }, + "Dolneni": { + "lat": 41.42701539999999, + "lng": 21.4529274 + }, + "Demir Kapija": { + "lat": 41.4088425, + "lng": 22.2436177 + }, + "Zelenikovo": { + "lat": 41.8831336, + "lng": 21.5893102 + }, + "Debar": { + "lat": 41.51975729999999, + "lng": 20.5289 + }, + "Makedonski Brod": { + "lat": 41.5133088, + "lng": 21.2174329 + }, + "Bogovinje": { + "lat": 41.9236371, + "lng": 20.9163887 + }, + "Bogdanci": { + "lat": 41.203138, + "lng": 22.5754421 + }, + "Bitola": { + "lat": 41.0296773, + "lng": 21.3292164 + }, + "Berovo": { + "lat": 41.7060632, + "lng": 22.8552379 + }, + "Studenichani": { + "lat": 41.9225639, + "lng": 21.5363965 + }, + "Butel": { + "lat": 42.0297408, + "lng": 21.4424938 + }, + "Chucher Sandevo": { + "lat": 42.0974773, + "lng": 21.3877179 + }, + "Kisela Voda": { + "lat": 41.9747129, + "lng": 21.4454748 + }, + "Arachinovo": { + "lat": 42.02538, + "lng": 21.5639736 + }, + "Aerodrom": { + "lat": 41.9795248, + "lng": 21.469323 + }, + "": { + "lat": 41.608635, + "lng": 21.745275 + } + }, + "RS": { + "Vojvodina": { + "lat": 45.2608651, + "lng": 19.8319339 + }, + "Belgrade": { + "lat": 44.8125449, + "lng": 20.46123 + }, + "Zajecar": { + "lat": 43.9018089, + "lng": 22.27015 + }, + "Raska": { + "lat": 43.2863993, + "lng": 20.6150936 + }, + "Pcinja": { + "lat": 42.5836362, + "lng": 22.1430215 + }, + "Jablanica": { + "lat": 43.4530694, + "lng": 21.2688456 + }, + "Branicevo": { + "lat": 44.7046041, + "lng": 21.5371328 + }, + "Podunavlje": { + "lat": 44.4729156, + "lng": 20.9901426 + }, + "Kolubara": { + "lat": 44.3509811, + "lng": 20.0004305 + }, + "Rasina": { + "lat": 43.5263525, + "lng": 21.1588179 + }, + "Morava": { + "lat": 44.20574536720186, + "lng": 21.22756176735834 + }, + "Sumadija": { + "lat": 44.20506779999999, + "lng": 20.7856565 + }, + "Nisava": { + "lat": 43.3738902, + "lng": 21.9322331 + }, + "Pomoravlje": { + "lat": 43.95913789999999, + "lng": 21.271353 + }, + "Toplica": { + "lat": 43.1906592, + "lng": 21.3407762 + }, + "Zlatibor": { + "lat": 43.7304492, + "lng": 19.6982165 + }, + "Bor": { + "lat": 44.0698918, + "lng": 22.0985086 + }, + "Pirot": { + "lat": 43.1570535, + "lng": 22.5839865 + }, + "Macva": { + "lat": 44.5925314, + "lng": 19.5082246 + }, + "": { + "lat": 44.016521, + "lng": 21.005859 + } + }, + "ME": { + "Rožaje Municipality": { + "lat": 42.8487304, + "lng": 20.1879106 + }, + "Opstina Zabljak": { + "lat": 43.09381399999999, + "lng": 19.2355764 + }, + "Ulcinj": { + "lat": 41.9310884, + "lng": 19.2147632 + }, + "Tuzi": { + "lat": 42.36894239999999, + "lng": 19.3128909 + }, + "Tivat": { + "lat": 42.4319094, + "lng": 18.6986213 + }, + "Danilovgrad": { + "lat": 42.5537532, + "lng": 19.1077389 + }, + "Opstina Savnik": { + "lat": 42.9603756, + "lng": 19.140438 + }, + "Kotor": { + "lat": 42.424662, + "lng": 18.771234 + }, + "Podgorica": { + "lat": 42.4304196, + "lng": 19.2593642 + }, + "Opstina Pluzine": { + "lat": 43.1593384, + "lng": 18.8551484 + }, + "Pljevlja": { + "lat": 43.3582371, + "lng": 19.3512591 + }, + "Opstina Plav": { + "lat": 42.60598419999999, + "lng": 19.9735048 + }, + "Budva": { + "lat": 42.2911489, + "lng": 18.840295 + }, + "Opstina Niksic": { + "lat": 42.7997184, + "lng": 18.7600963 + }, + "Mojkovac": { + "lat": 42.9600774, + "lng": 19.5835874 + }, + "Opstina Kolasin": { + "lat": 42.7601916, + "lng": 19.4259114 + }, + "Berane": { + "lat": 42.83793439999999, + "lng": 19.8603732 + }, + "Herceg Novi": { + "lat": 42.4572478, + "lng": 18.5314753 + }, + "Gusinje": { + "lat": 42.5619739, + "lng": 19.8320935 + }, + "Cetinje": { + "lat": 42.3930959, + "lng": 18.9115964 + }, + "Bijelo Polje": { + "lat": 43.0369422, + "lng": 19.7561911 + }, + "Bar": { + "lat": 42.0912106, + "lng": 19.089904 + }, + "Petnjica": { + "lat": 42.9069016, + "lng": 19.9586179 + }, + "": { + "lat": 42.708678, + "lng": 19.37439 + } + }, + "NA": { + "Zambezi Region": { + "lat": -17.8193419, + "lng": 23.9536466 + }, + "Khomas": { + "lat": -22.6377854, + "lng": 17.1011931 + }, + "Oshikoto": { + "lat": -18.4152575, + "lng": 16.912251 + }, + "Erongo": { + "lat": -22.2565682, + "lng": 15.4068079 + }, + "Kavango East": { + "lat": -18.271048, + "lng": 18.4276047 + }, + "Kunene": { + "lat": -19.4086317, + "lng": 13.914399 + }, + "Otjozondjupa": { + "lat": -20.5486916, + "lng": 17.668887 + }, + "Oshana": { + "lat": -18.4305064, + "lng": 15.6881788 + }, + "Karas": { + "lat": -26.8429645, + "lng": 17.2902839 + }, + "Omusati": { + "lat": -18.4070294, + "lng": 14.8454619 + }, + "Kavango West": { + "lat": -18.271048, + "lng": 18.4276047 + }, + "Hardap": { + "lat": -24.533333, + "lng": 17.933333 + }, + "Ohangwena": { + "lat": -17.5979291, + "lng": 16.8178377 + }, + "": { + "lat": -22.95764, + "lng": 18.49041 + } + }, + "ZW": { + "Matabeleland North": { + "lat": -18.5331566, + "lng": 27.5495846 + }, + "Manicaland": { + "lat": -18.9216386, + "lng": 32.174605 + }, + "Mashonaland East Province": { + "lat": -18.5871642, + "lng": 31.2626366 + }, + "Masvingo Province": { + "lat": -20.6241509, + "lng": 31.2626366 + }, + "Midlands Province": { + "lat": -19.0552009, + "lng": 29.6035495 + }, + "Mashonaland West": { + "lat": -17.4851029, + "lng": 29.7889248 + }, + "Harare": { + "lat": -17.8216288, + "lng": 31.0492259 + }, + "Matabeleland South Province": { + "lat": -21.052337, + "lng": 29.0459927 + }, + "Bulawayo": { + "lat": -20.1457125, + "lng": 28.5873388 + }, + "": { + "lat": -19.015438, + "lng": 29.154857 + } + }, + "KM": { + "Mohéli": { + "lat": -12.3377376, + "lng": 43.7334089 + }, + "Ndzuwani": { + "lat": -12.2138145, + "lng": 44.4370606 + }, + "Grande Comore": { + "lat": -11.7167338, + "lng": 43.3680788 + }, + "": { + "lat": -11.875001, + "lng": 43.872219 + } + }, + "MW": { + "Central Region": { + "lat": -13.4830679, + "lng": 34.1531947 + }, + "Northern Region": { + "lat": -10.9625469, + "lng": 34.1531947 + }, + "Southern Region": { + "lat": -15.4201189, + "lng": 35.0388164 + }, + "": { + "lat": -13.254308, + "lng": 34.301525 + } + }, + "LS": { + "Berea": { + "lat": -29.2068943, + "lng": 27.8780275 + }, + "Quthing": { + "lat": -30.4015687, + "lng": 27.7080133 + }, + "Qacha's Nek": { + "lat": -30.1114565, + "lng": 28.678979 + }, + "Leribe": { + "lat": -28.8638065, + "lng": 28.0478826 + }, + "Mokhotlong": { + "lat": -29.2875557, + "lng": 29.06053889999999 + }, + "Mohale's Hoek District": { + "lat": -30.0812662, + "lng": 27.784246 + }, + "Maseru": { + "lat": -29.3150767, + "lng": 27.4869229 + }, + "Mafeteng District": { + "lat": -29.7606683, + "lng": 27.3146351 + }, + "Butha-Buthe": { + "lat": -28.7653754, + "lng": 28.2468148 + }, + "Thaba-Tseka": { + "lat": -29.5238975, + "lng": 28.6089752 + }, + "": { + "lat": -29.609988, + "lng": 28.233608 + } + }, + "BW": { + "Kgalagadi District": { + "lat": -24.7550285, + "lng": 21.8568586 + }, + "South-East": { + "lat": -24.9630286, + "lng": 25.7811869 + }, + "Kweneng District": { + "lat": -23.8367249, + "lng": 25.2837585 + }, + "Central District": { + "lat": -21.5831595, + "lng": 26.0413889 + }, + "Selibe Phikwe": { + "lat": -21.9760747, + "lng": 27.837023 + }, + "Kgatleng District": { + "lat": -24.1970445, + "lng": 26.2304616 + }, + "North-West": { + "lat": -19.9070787, + "lng": 23.0011989 + }, + "Lobatse": { + "lat": -25.2076148, + "lng": 25.6805868 + }, + "Chobe District": { + "lat": -18.4256281, + "lng": 24.7142918 + }, + "Ngwaketsi": { + "lat": -25.0405342, + "lng": 25.2363408 + }, + "Gaborone": { + "lat": -24.6282079, + "lng": 25.9231471 + }, + "City of Francistown": { + "lat": -21.1661005, + "lng": 27.5143603 + }, + "Ghanzi District": { + "lat": -21.8652314, + "lng": 21.8568586 + }, + "Sowa Town": { + "lat": -20.5653345, + "lng": 26.2235167 + }, + "Jwaneng": { + "lat": -24.6059582, + "lng": 24.7202287 + }, + "": { + "lat": -22.328474, + "lng": 24.684866 + } + }, + "MU": { + "Grand Port District": { + "lat": -20.3851546, + "lng": 57.6665742 + }, + "Moka District": { + "lat": -20.2399782, + "lng": 57.57592599999999 + }, + "Plaines Wilhems District": { + "lat": -20.3054872, + "lng": 57.48535609999999 + }, + "Flacq District": { + "lat": -20.2257836, + "lng": 57.7119274 + }, + "Pamplemousses District": { + "lat": -20.1094972, + "lng": 57.5815892 + }, + "Riviere du Rempart District": { + "lat": -20.0560983, + "lng": 57.65523890000001 + }, + "Black River District": { + "lat": -20.3708492, + "lng": 57.39486489999999 + }, + "Savanne District": { + "lat": -20.473953, + "lng": 57.48535609999999 + }, + "Agalega Islands": { + "lat": -10.4681988, + "lng": 56.690672 + }, + "Port Louis District": { + "lat": -20.1608912, + "lng": 57.5012222 + }, + "Rodrigues": { + "lat": -19.7245385, + "lng": 63.4272185 + }, + "Cargados Carajos": { + "lat": -16.583333, + "lng": 59.61666699999999 + }, + "": { + "lat": -20.348404, + "lng": 57.552152 + } + }, + "SZ": { + "Lubombo District": { + "lat": -26.7851773, + "lng": 31.8107079 + }, + "Shiselweni District": { + "lat": -26.9827577, + "lng": 31.3541631 + }, + "Hhohho": { + "lat": -26.1365662, + "lng": 31.3541631 + }, + "Manzini": { + "lat": -26.5081999, + "lng": 31.3713164 + }, + "": { + "lat": -26.522503, + "lng": 31.465866 + } + }, + "ZA": { + "Gauteng": { + "lat": -26.2707593, + "lng": 28.1122679 + }, + "North West": { + "lat": -26.6638599, + "lng": 25.2837585 + }, + "Limpopo": { + "lat": -23.4012946, + "lng": 29.4179324 + }, + "Orange Free State": { + "lat": -28.4541105, + "lng": 26.7967849 + }, + "Mpumalanga": { + "lat": -25.565336, + "lng": 30.5279096 + }, + "KwaZulu-Natal": { + "lat": -28.5305539, + "lng": 30.8958242 + }, + "Eastern Cape": { + "lat": -32.2968402, + "lng": 26.419389 + }, + "Northern Cape": { + "lat": -29.0466808, + "lng": 21.8568586 + }, + "Western Cape": { + "lat": -33.2277918, + "lng": 21.8568586 + }, + "": { + "lat": -30.559482, + "lng": 22.937506 + } + }, + "MZ": { + "Gaza Province": { + "lat": -23.0221928, + "lng": 32.7181375 + }, + "Inhambane Province": { + "lat": -22.6830732, + "lng": 34.1531947 + }, + "Provincia de Zambezia": { + "lat": -16.5638987, + "lng": 36.6093926 + }, + "Manica Province": { + "lat": -18.1747162, + "lng": 33.438353 + }, + "Tete": { + "lat": -16.1328104, + "lng": 33.6063855 + }, + "Cabo Delgado Province": { + "lat": -12.3335474, + "lng": 39.3206241 + }, + "Nampula": { + "lat": -15.1266347, + "lng": 39.2687161 + }, + "Maputo Province": { + "lat": -25.593649, + "lng": 32.5372741 + }, + "Cidade de Maputo": { + "lat": -25.969248, + "lng": 32.5731746 + }, + "Sofala Province": { + "lat": -19.2039073, + "lng": 34.8624166 + }, + "": { + "lat": -18.665695, + "lng": 35.529562 + } + }, + "TH": { + "Prachuap Khiri Khan": { + "lat": 11.7938389, + "lng": 99.7957564 + }, + "Phetchaburi": { + "lat": 12.9035085, + "lng": 99.634135 + }, + "Surat Thani": { + "lat": 9.134194899999999, + "lng": 99.3334198 + }, + "Chiang Rai": { + "lat": 19.9104798, + "lng": 99.840576 + }, + "Trang": { + "lat": 7.564483299999999, + "lng": 99.6239334 + }, + "Nakhon Si Thammarat": { + "lat": 8.432483099999999, + "lng": 99.9599033 + }, + "Lampang": { + "lat": 18.2855395, + "lng": 99.5127895 + }, + "Chumphon": { + "lat": 10.5780131, + "lng": 99.1013498 + }, + "Phuket": { + "lat": 7.8804479, + "lng": 98.3922504 + }, + "Phang Nga": { + "lat": 8.6557757, + "lng": 98.3964938 + }, + "Tak": { + "lat": 16.9021534, + "lng": 99.0128926 + }, + "Sukhothai": { + "lat": 17.0076429, + "lng": 99.8264435 + }, + "Chiang Mai": { + "lat": 18.7883439, + "lng": 98.98530079999999 + }, + "Ratchaburi": { + "lat": 13.4509884, + "lng": 99.634135 + }, + "Ranong": { + "lat": 9.9658401, + "lng": 98.6408184 + }, + "Kamphaeng Phet": { + "lat": 16.4808481, + "lng": 99.5491144 + }, + "Phayao": { + "lat": 19.1709666, + "lng": 99.9067377 + }, + "Mae Hong Son": { + "lat": 18.7370307, + "lng": 97.87216 + }, + "Uthai Thani": { + "lat": 15.2998546, + "lng": 99.456155 + }, + "Lamphun": { + "lat": 18.1253143, + "lng": 98.92453429999999 + }, + "Phrae": { + "lat": 18.2509588, + "lng": 100.1703257 + }, + "Satun": { + "lat": 6.6120518, + "lng": 100.0723349 + }, + "Krabi": { + "lat": 8.0854803, + "lng": 98.9062856 + }, + "Kanchanaburi": { + "lat": 14.1011393, + "lng": 99.4179431 + }, + "Suphanburi": { + "lat": 14.5379162, + "lng": 99.99122539999999 + }, + "Nakhon Sawan": { + "lat": 15.6987382, + "lng": 100.11996 + }, + "Chai Nat": { + "lat": 15.1614193, + "lng": 100.1087187 + }, + "Nakhon Pathom": { + "lat": 13.8140293, + "lng": 100.0372929 + }, + "Samut Songkhram": { + "lat": 13.3931212, + "lng": 99.9465077 + }, + "Lopburi": { + "lat": 15.0499572, + "lng": 100.8903099 + }, + "Pathum Thani": { + "lat": 14.0033913, + "lng": 100.6647101 + }, + "Ubon Ratchathani": { + "lat": 15.2448453, + "lng": 104.8472995 + }, + "Nong Khai": { + "lat": 17.7696871, + "lng": 102.7594847 + }, + "Udon Thani": { + "lat": 17.3646969, + "lng": 102.8158924 + }, + "Nong Bua Lam Phu": { + "lat": 17.1739533, + "lng": 102.4495221 + }, + "Narathiwat": { + "lat": 6.190087999999999, + "lng": 101.7979613 + }, + "Yasothon": { + "lat": 15.795426, + "lng": 104.1418709 + }, + "Pattani": { + "lat": 6.869516, + "lng": 101.2504562 + }, + "Bangkok": { + "lat": 13.7563309, + "lng": 100.5017651 + }, + "Kalasin": { + "lat": 16.438508, + "lng": 103.5060994 + }, + "Yala": { + "lat": 6.5411018, + "lng": 101.2804075 + }, + "Saraburi": { + "lat": 14.5270426, + "lng": 100.9130244 + }, + "Phetchabun": { + "lat": 16.4243306, + "lng": 101.1617356 + }, + "Nan": { + "lat": 18.7838757, + "lng": 100.7789631 + }, + "Sa Kaeo": { + "lat": 13.8221849, + "lng": 102.0660435 + }, + "Phitsanulok": { + "lat": 16.8211238, + "lng": 100.2658516 + }, + "Phra Nakhon Si Ayutthaya": { + "lat": 14.3692325, + "lng": 100.5876634 + }, + "Si Sa Ket": { + "lat": 14.7579424, + "lng": 104.4723301 + }, + "Rayong": { + "lat": 12.6813957, + "lng": 101.2816261 + }, + "Uttaradit": { + "lat": 17.6698151, + "lng": 100.5296115 + }, + "Trat": { + "lat": 12.4202239, + "lng": 102.5298028 + }, + "Songkhla": { + "lat": 7.189765899999998, + "lng": 100.5953813 + }, + "Roi Et": { + "lat": 15.9032933, + "lng": 103.7289167 + }, + "Surin": { + "lat": 15.1696104, + "lng": 103.7289167 + }, + "Nakhon Phanom": { + "lat": 17.4115756, + "lng": 104.5655273 + }, + "Chanthaburi": { + "lat": 12.6112485, + "lng": 102.1037806 + }, + "Phichit": { + "lat": 16.1872019, + "lng": 100.3497895 + }, + "Nakhon Ratchasima": { + "lat": 14.9738493, + "lng": 102.083652 + }, + "Chon Buri": { + "lat": 13.2017461, + "lng": 101.2523792 + }, + "Sing Buri": { + "lat": 14.9148841, + "lng": 100.3273368 + }, + "Prachin Buri": { + "lat": 14.0965588, + "lng": 101.6157773 + }, + "Sakon Nakhon": { + "lat": 17.1664211, + "lng": 104.1486055 + }, + "Buriram": { + "lat": 14.9951003, + "lng": 103.1115915 + }, + "Chachoengsao": { + "lat": 13.5490952, + "lng": 101.6157773 + }, + "Samut Sakhon": { + "lat": 13.5497754, + "lng": 100.2740821 + }, + "Samut Prakan": { + "lat": 13.5954062, + "lng": 100.6072401 + }, + "Nonthaburi": { + "lat": 13.8591084, + "lng": 100.5216508 + }, + "Khon Kaen": { + "lat": 16.4321938, + "lng": 102.8236214 + }, + "Loei": { + "lat": 17.4220407, + "lng": 101.6157773 + }, + "Maha Sarakham": { + "lat": 16.1872403, + "lng": 103.3044979 + }, + "Phatthalung": { + "lat": 7.582847199999999, + "lng": 99.99122539999999 + }, + "Amnat Charoen": { + "lat": 15.9265678, + "lng": 104.7520939 + }, + "Bueng Kan": { + "lat": 18.326944, + "lng": 103.6883765 + }, + "Nakhon Nayok": { + "lat": 14.2038387, + "lng": 101.2268773 + }, + "Mukdahan": { + "lat": 16.5435914, + "lng": 104.7024121 + }, + "Chaiyaphum": { + "lat": 16.013875, + "lng": 101.8891721 + }, + "Ang Thong": { + "lat": 14.6583662, + "lng": 100.3947116 + }, + "": { + "lat": 15.870032, + "lng": 100.992541 + } + }, + "AF": { + "Helmand": { + "lat": 31.3636474, + "lng": 63.95861110000001 + }, + "Balkh": { + "lat": 36.7550603, + "lng": 66.8975372 + }, + "Kandahar": { + "lat": 31.628871, + "lng": 65.7371749 + }, + "Kabul": { + "lat": 34.5553494, + "lng": 69.207486 + }, + "Nangarhar": { + "lat": 34.1718313, + "lng": 70.6216794 + }, + "Herat": { + "lat": 34.352865, + "lng": 62.20402869999999 + }, + "Ghazni": { + "lat": 33.5450587, + "lng": 68.4173972 + }, + "Parwan": { + "lat": 34.9630977, + "lng": 68.81088489999999 + }, + "Baghlan": { + "lat": 36.17890260000001, + "lng": 68.74530639999999 + }, + "Khowst": { + "lat": 33.3338472, + "lng": 69.9371673 + }, + "": { + "lat": 33.93911, + "lng": 67.709953 + } + }, + "PK": { + "Sindh": { + "lat": 25.8943018, + "lng": 68.52471489999999 + }, + "Islamabad": { + "lat": 33.6844202, + "lng": 73.04788479999999 + }, + "Balochistan": { + "lat": 28.4907332, + "lng": 65.0957792 + }, + "Punjab": { + "lat": 31.1704063, + "lng": 72.7097161 + }, + "Khyber Pakhtunkhwa": { + "lat": 34.9526205, + "lng": 72.331113 + }, + "Azad Jammu and Kashmir": { + "lat": 33.9259055, + "lng": 73.7810334 + }, + "Gilgit-Baltistan": { + "lat": 35.80256670000001, + "lng": 74.9831808 + }, + "": { + "lat": 30.375321, + "lng": 69.345116 + } + }, + "BD": { + "Rajshahi Division": { + "lat": 24.7105776, + "lng": 88.94138650000001 + }, + "Rangpur Division": { + "lat": 25.8483388, + "lng": 88.94138650000001 + }, + "Dhaka Division": { + "lat": 23.9535742, + "lng": 90.14949879999999 + }, + "Chittagong": { + "lat": 22.3752075, + "lng": 91.8348606 + }, + "Sylhet Division": { + "lat": 24.7049811, + "lng": 91.67606909999999 + }, + "Mymensingh Division": { + "lat": 24.71362, + "lng": 90.4502368 + }, + "Khulna Division": { + "lat": 22.8087816, + "lng": 89.24671909999999 + }, + "Barisal Division": { + "lat": 22.3811131, + "lng": 90.3371889 + }, + "": { + "lat": 23.684994, + "lng": 90.356331 + } + }, + "ID": { + "North Sumatra": { + "lat": 2.1153547, + "lng": 99.54509739999999 + }, + "Aceh": { + "lat": 4.695135, + "lng": 96.7493993 + }, + "Yogyakarta": { + "lat": -7.7955798, + "lng": 110.3694896 + }, + "Central Java": { + "lat": -6.879704100000001, + "lng": 109.1255917 + }, + "East Java": { + "lat": -7.5360639, + "lng": 112.2384017 + }, + "South Sulawesi": { + "lat": -3.6687994, + "lng": 119.9740534 + }, + "Southeast Sulawesi": { + "lat": -4.144909999999999, + "lng": 122.174605 + }, + "West Java": { + "lat": -7.090910999999999, + "lng": 107.668887 + }, + "Bali": { + "lat": -8.4095178, + "lng": 115.188916 + }, + "North Sulawesi": { + "lat": 0.6246932, + "lng": 123.9750018 + }, + "Central Papua": { + "lat": -9.1360187, + "lng": 147.4627259 + }, + "North Maluku": { + "lat": 1.5709993, + "lng": 127.8087693 + }, + "Lampung": { + "lat": -4.5585849, + "lng": 105.4068079 + }, + "East Kalimantan": { + "lat": 0.5386586, + "lng": 116.419389 + }, + "Riau": { + "lat": 0.2933469, + "lng": 101.7068294 + }, + "Jakarta": { + "lat": -6.2087634, + "lng": 106.845599 + }, + "North Kalimantan": { + "lat": 3.0730929, + "lng": 116.0413889 + }, + "Riau Islands": { + "lat": 3.9456514, + "lng": 108.1428669 + }, + "South Kalimantan": { + "lat": -3.0926415, + "lng": 115.2837585 + }, + "Banten": { + "lat": -6.4058172, + "lng": 106.0640179 + }, + "Jambi": { + "lat": -1.6101229, + "lng": 103.6131203 + }, + "Bangka–Belitung Islands": { + "lat": -2.7410513, + "lng": 106.4405872 + }, + "West Nusa Tenggara": { + "lat": -8.6529334, + "lng": 117.3616476 + }, + "South Sumatra": { + "lat": -3.3194374, + "lng": 103.914399 + }, + "West Sumatra": { + "lat": -0.7399397, + "lng": 100.8000051 + }, + "South Papua": { + "lat": -4.269928, + "lng": 138.0803529 + }, + "West Kalimantan": { + "lat": -0.2787808, + "lng": 111.4752851 + }, + "Central Kalimantan": { + "lat": -1.6814878, + "lng": 113.3823545 + }, + "West Sulawesi": { + "lat": -2.8441371, + "lng": 119.2320784 + }, + "Maluku": { + "lat": -3.2384616, + "lng": 130.1452734 + }, + "Central Sulawesi": { + "lat": -1.4300254, + "lng": 121.4456179 + }, + "West Papua": { + "lat": -1.3361154, + "lng": 133.1747162 + }, + "East Nusa Tenggara": { + "lat": -8.657381899999999, + "lng": 121.0793705 + }, + "Papua": { + "lat": -4.269928, + "lng": 138.0803529 + }, + "Gorontalo": { + "lat": 0.5435441999999999, + "lng": 123.0567693 + }, + "Bengkulu": { + "lat": -3.792845099999999, + "lng": 102.2607641 + }, + "": { + "lat": -0.789275, + "lng": 113.921327 + } + }, + "TJ": { + "Viloyati Khatlon": { + "lat": 37.9113562, + "lng": 69.097023 + }, + "Viloyati Sughd": { + "lat": 39.5155326, + "lng": 69.097023 + }, + "Republican Subordination": { + "lat": 39.0857902, + "lng": 70.2408325 + }, + "Gorno-Badakhshan": { + "lat": 38.412732, + "lng": 73.087749 + }, + "Dushanbe": { + "lat": 38.5597722, + "lng": 68.7870384 + }, + "": { + "lat": 38.861034, + "lng": 71.276093 + } + }, + "MY": { + "Kedah": { + "lat": 6.0498656, + "lng": 100.5296115 + }, + "Penang": { + "lat": 5.414130699999999, + "lng": 100.3287506 + }, + "Pahang": { + "lat": 3.9743406, + "lng": 102.4380581 + }, + "Perak": { + "lat": 4.807294, + "lng": 100.8000051 + }, + "Selangor": { + "lat": 3.509247, + "lng": 101.5248055 + }, + "Johor": { + "lat": 1.9343998, + "lng": 103.3587288 + }, + "Terengganu": { + "lat": 5.093634199999999, + "lng": 102.989615 + }, + "Sarawak": { + "lat": 2.5574285, + "lng": 113.0011989 + }, + "Sabah": { + "lat": 5.4204043, + "lng": 116.7967849 + }, + "Kelantan": { + "lat": 5.115146, + "lng": 101.8891721 + }, + "Kuala Lumpur": { + "lat": 3.1319197, + "lng": 101.6840589 + }, + "Labuan": { + "lat": 5.2831456, + "lng": 115.230825 + }, + "Melaka": { + "lat": 2.189594, + "lng": 102.2500868 + }, + "Negeri Sembilan": { + "lat": 2.8707497, + "lng": 102.2547919 + }, + "Perlis": { + "lat": 6.5170189, + "lng": 100.2151578 + }, + "Putrajaya": { + "lat": 2.926361, + "lng": 101.696445 + }, + "": { + "lat": 4.210484, + "lng": 101.975766 + } + }, + "LK": { + "Western Province": { + "lat": 6.901608599999999, + "lng": 80.0087746 + }, + "Southern Province": { + "lat": 6.237375, + "lng": 80.54384499999999 + }, + "North Western Province": { + "lat": 7.758409100000001, + "lng": 80.1875065 + }, + "Province of Uva": { + "lat": 6.8427612, + "lng": 81.3399414 + }, + "Sabaragamuwa Province": { + "lat": 6.7395941, + "lng": 80.365865 + }, + "Northern Province": { + "lat": 8.8855027, + "lng": 80.2767327 + }, + "Central Province": { + "lat": 7.256499600000001, + "lng": 80.7214417 + }, + "North Central Province": { + "lat": 8.1995638, + "lng": 80.6326916 + }, + "Eastern Province": { + "lat": 7.7853051, + "lng": 81.42789839999999 + }, + "": { + "lat": 7.873054, + "lng": 80.771797 + } + }, + "BT": { + "Trongsa Dzongkhag": { + "lat": 27.2666292, + "lng": 90.52578229999999 + }, + "Thimphu District": { + "lat": 27.471586, + "lng": 89.6386108 + }, + "Trashi Yangste": { + "lat": 27.8236356, + "lng": 91.44346900000001 + }, + "Trashigang District": { + "lat": 27.2566795, + "lng": 91.7538817 + }, + "Dagana": { + "lat": 27.0322861, + "lng": 89.8879304 + }, + "Zhemgang District": { + "lat": 27.076975, + "lng": 90.8294002 + }, + "Sarpang District": { + "lat": 26.9373041, + "lng": 90.4879916 + }, + "Punakha Dzongkhag": { + "lat": 27.5920869, + "lng": 89.87974589999999 + }, + "Paro": { + "lat": 27.428684, + "lng": 89.41636539999999 + }, + "Mongar": { + "lat": 27.2753739, + "lng": 91.23977750000002 + }, + "Lhuntse": { + "lat": 27.6649208, + "lng": 91.1761004 + }, + "Haa": { + "lat": 27.386227, + "lng": 89.27760169999999 + }, + "Gasa": { + "lat": 27.8982746, + "lng": 89.7309534 + }, + "Chukha": { + "lat": 27.0522919, + "lng": 89.5756987 + }, + "Bumthang District": { + "lat": 27.641839, + "lng": 90.6773046 + }, + "Samtse District": { + "lat": 27.0291832, + "lng": 89.0561532 + }, + "Wangdue Phodrang Dzongkhag": { + "lat": 27.4526046, + "lng": 90.0674928 + }, + "Samdrup Jongkhar": { + "lat": 26.8035682, + "lng": 91.5039207 + }, + "": { + "lat": 27.514162, + "lng": 90.433601 + } + }, + "IN": { + "Maharashtra": { + "lat": 19.7514798, + "lng": 75.7138884 + }, + "Karnataka": { + "lat": 15.3172775, + "lng": 75.7138884 + }, + "Telangana": { + "lat": 18.1124372, + "lng": 79.01929969999999 + }, + "Haryana": { + "lat": 29.0587757, + "lng": 76.085601 + }, + "Nagaland": { + "lat": 26.1584354, + "lng": 94.5624426 + }, + "West Bengal": { + "lat": 22.9867569, + "lng": 87.8549755 + }, + "Tamil Nadu": { + "lat": 11.1271225, + "lng": 78.6568942 + }, + "Gujarat": { + "lat": 22.6708317, + "lng": 71.5723953 + }, + "Kerala": { + "lat": 10.1631526, + "lng": 76.64127119999999 + }, + "Andhra Pradesh": { + "lat": 15.9128998, + "lng": 79.7399875 + }, + "Manipur": { + "lat": 24.6637173, + "lng": 93.90626879999999 + }, + "Madhya Pradesh": { + "lat": 22.9734229, + "lng": 78.6568942 + }, + "Goa": { + "lat": 15.2993265, + "lng": 74.12399599999999 + }, + "Uttar Pradesh": { + "lat": 27.5705886, + "lng": 80.0981869 + }, + "Odisha": { + "lat": 20.2375561, + "lng": 84.2700179 + }, + "Uttarakhand": { + "lat": 30.066753, + "lng": 79.01929969999999 + }, + "Chhattisgarh": { + "lat": 21.2786567, + "lng": 81.8661442 + }, + "Himachal Pradesh": { + "lat": 32.1024076, + "lng": 77.5619419 + }, + "Assam": { + "lat": 26.2006043, + "lng": 92.9375739 + }, + "Rajasthan": { + "lat": 27.0238036, + "lng": 74.21793260000001 + }, + "Tripura": { + "lat": 23.5638643, + "lng": 91.67606909999999 + }, + "Meghalaya": { + "lat": 25.4670308, + "lng": 91.366216 + }, + "Jharkhand": { + "lat": 23.6913486, + "lng": 85.2722472 + }, + "Punjab": { + "lat": 31.1471305, + "lng": 75.34121789999999 + }, + "Bihar": { + "lat": 25.9644427, + "lng": 85.2722472 + }, + "Jammu and Kashmir": { + "lat": 33.277839, + "lng": 75.34121789999999 + }, + "Sikkim": { + "lat": 27.3516407, + "lng": 88.32393090000001 + }, + "Dadra and Nagar Haveli and Daman and Diu": { + "lat": 20.1808672, + "lng": 73.0169135 + }, + "Andaman and Nicobar": { + "lat": 10.7448873, + "lng": 92.49999179999999 + }, + "Mizoram": { + "lat": 23.164543, + "lng": 92.9375739 + }, + "Union Territory of Puducherry": { + "lat": 11.941685, + "lng": 79.8083177 + }, + "National Capital Territory of Delhi": { + "lat": 28.7040592, + "lng": 77.10249019999999 + }, + "Ladakh": { + "lat": 34.2268475, + "lng": 77.5619419 + }, + "Lakshadweep": { + "lat": 9.985089192440064, + "lng": 72.93247324992308 + }, + "Arunachal Pradesh": { + "lat": 28.2179994, + "lng": 94.7277528 + }, + "Chandigarh": { + "lat": 30.7333148, + "lng": 76.7794179 + }, + "": { + "lat": 20.593684, + "lng": 78.96288 + } + }, + "CN": { + "Tibet": { + "lat": 29.6472399, + "lng": 91.11744999999999 + }, + "Gansu": { + "lat": 36.0594199, + "lng": 103.82634 + }, + "Xinjiang Uyghur Autonomous Region": { + "lat": 43.7934299, + "lng": 87.6271 + }, + "Qinghai": { + "lat": 36.6208699, + "lng": 101.7801099 + }, + "Yunnan": { + "lat": 25.0452999, + "lng": 102.7097299 + }, + "Guizhou": { + "lat": 26.5981999, + "lng": 106.70722 + }, + "Anhui": { + "lat": 31.8615699, + "lng": 117.28565 + }, + "Shandong": { + "lat": 36.6682599, + "lng": 117.02076 + }, + "Sichuan": { + "lat": 30.6508899, + "lng": 104.07572 + }, + "Hunan": { + "lat": 28.1142216, + "lng": 112.9833341 + }, + "Henan": { + "lat": 34.7657099, + "lng": 113.75322 + }, + "Zhejiang": { + "lat": 30.2655499, + "lng": 120.1536 + }, + "Liaoning": { + "lat": 41.8357099, + "lng": 123.42925 + }, + "Ningxia Hui Autonomous Region": { + "lat": 38.4711699, + "lng": 106.25867 + }, + "Jiangsu": { + "lat": 32.0607099, + "lng": 118.76295 + }, + "Hebei": { + "lat": 38.0359899, + "lng": 114.46979 + }, + "Shanxi": { + "lat": 37.8734299, + "lng": 112.56272 + }, + "Guangdong": { + "lat": 23.1317099, + "lng": 113.26627 + }, + "Fujian": { + "lat": 26.0998199, + "lng": 119.2965899 + }, + "Hubei": { + "lat": 30.5453899, + "lng": 114.34234 + }, + "Guangxi": { + "lat": 24.3255257, + "lng": 109.4154583 + }, + "Jiangxi": { + "lat": 28.6741699, + "lng": 115.91004 + }, + "Hainan": { + "lat": 20.0199699, + "lng": 110.34863 + }, + "Tianjin": { + "lat": 39.0850999, + "lng": 117.19937 + }, + "Shaanxi": { + "lat": 34.2648599, + "lng": 108.95424 + }, + "Shanghai": { + "lat": 31.230416, + "lng": 121.473701 + }, + "Chongqing": { + "lat": 29.5656843, + "lng": 106.5511838 + }, + "Beijing": { + "lat": 39.904211, + "lng": 116.407395 + }, + "Heilongjiang": { + "lat": 45.7420799, + "lng": 126.66285 + }, + "Jilin": { + "lat": 43.8378399, + "lng": 126.54944 + }, + "Inner Mongolia Autonomous Region": { + "lat": 40.8173299, + "lng": 111.76522 + }, + "": { + "lat": 35.86166, + "lng": 104.195397 + } + }, + "MV": { + "Addu Atoll": { + "lat": -0.6300994999999999, + "lng": 73.1585626 + }, + "Kaafu Atoll": { + "lat": 4.4558979, + "lng": 73.55941279999999 + }, + "Haa Dhaalu Atholhu": { + "lat": 6.578271699999999, + "lng": 72.94605659999999 + }, + "Southern Ari Atoll": { + "lat": 3.6543302, + "lng": 72.8042797 + }, + "Baa Atholhu": { + "lat": 5.2396042, + "lng": 72.99329689999999 + }, + "Dhaalu Atholhu": { + "lat": 2.8468502, + "lng": 72.94605659999999 + }, + "Faafu Atholhu": { + "lat": 3.2309409, + "lng": 72.94605659999999 + }, + "Gnyaviyani Atoll": { + "lat": -0.3006425, + "lng": 73.42391429999999 + }, + "Haa Alifu Atholhu": { + "lat": 6.9903488, + "lng": 72.94605659999999 + }, + "Faadhippolhu Atoll": { + "lat": 5.3747021, + "lng": 73.5122928 + }, + "Meemu Atholhu": { + "lat": 3.0090345, + "lng": 73.5122928 + }, + "Shaviyani Atholhu": { + "lat": 6.17511, + "lng": 73.13496049999999 + }, + "Thaa Atholhu": { + "lat": 2.4311161, + "lng": 73.1821623 + }, + "Vaavu Atholhu": { + "lat": 3.3955438, + "lng": 73.5122928 + }, + "Male": { + "lat": 4.1754959, + "lng": 73.5093474 + }, + "Laamu Atholhu": { + "lat": 1.9430737, + "lng": 73.4180211 + }, + "Northern Ari Atoll": { + "lat": 4.085, + "lng": 72.8515479 + }, + "": { + "lat": 3.202778, + "lng": 73.22068 + } + }, + "NP": { + "Province 1": { + "lat": 27.3372148, + "lng": 87.38107269999999 + }, + "Sudurpashchim Pradesh": { + "lat": 29.2987871, + "lng": 80.9871074 + }, + "Lumbini Province": { + "lat": 27.9207402, + "lng": 82.7347142 + }, + "Gandaki Pradesh": { + "lat": 28.3590669, + "lng": 84.1012861 + }, + "Karnali Pradesh": { + "lat": 29.3862555, + "lng": 82.38857829999999 + }, + "Bagmati Province": { + "lat": 27.6624958, + "lng": 85.4375574 + }, + "Province 2": { + "lat": 27.0135298, + "lng": 85.684578 + }, + "": { + "lat": 28.394857, + "lng": 84.124008 + } + }, + "MM": { + "Magway Region": { + "lat": 19.8871386, + "lng": 94.7277528 + }, + "Bago Region": { + "lat": 18.3312802, + "lng": 96.0679194 + }, + "Tanintharyi Region": { + "lat": 12.4706876, + "lng": 99.0128926 + }, + "Shan State": { + "lat": 22.0361985, + "lng": 98.1338558 + }, + "Yangon": { + "lat": 16.840939, + "lng": 96.173526 + }, + "Mandalay Region": { + "lat": 21.5619058, + "lng": 95.89871389999999 + }, + "Kachin State": { + "lat": 25.850904, + "lng": 97.4381355 + }, + "Mon State": { + "lat": 16.3003133, + "lng": 97.69822719999999 + }, + "Sagaing Region": { + "lat": 24.428381, + "lng": 95.3939551 + }, + "Kayah State": { + "lat": 19.2342061, + "lng": 97.26528580000002 + }, + "Kayin State": { + "lat": 16.9459346, + "lng": 97.9592863 + }, + "Chin State": { + "lat": 22.0086978, + "lng": 93.5812692 + }, + "Ayeyarwady Region": { + "lat": 17.0342125, + "lng": 95.22666749999999 + }, + "Nay Pyi Taw": { + "lat": 19.7633057, + "lng": 96.07851040000001 + }, + "": { + "lat": 21.913965, + "lng": 95.956223 + } + }, + "MN": { + "Dzavhan Aymag": { + "lat": 48.2388147, + "lng": 96.07030189999999 + }, + "Uvs Province": { + "lat": 49.64497069999999, + "lng": 93.27365759999999 + }, + "Govi-Altai Province": { + "lat": 45.4511227, + "lng": 95.8505766 + }, + "Bayan-OElgiy Aymag": { + "lat": 48.3983254, + "lng": 89.66259149999999 + }, + "Hovd": { + "lat": 47.9795218, + "lng": 91.634756 + }, + "Ulaanbaatar Hot": { + "lat": 47.88639879999999, + "lng": 106.9057439 + }, + "Bulgan": { + "lat": 48.8231572, + "lng": 103.5218199 + }, + "Arkhangai Province": { + "lat": 47.8971101, + "lng": 100.7240165 + }, + "Middle Govĭ": { + "lat": 45.5822786, + "lng": 106.7644209 + }, + "Ömnögovĭ": { + "lat": 43.500024, + "lng": 104.2861116 + }, + "Suehbaatar Aymag": { + "lat": 46.5653163, + "lng": 113.5380836 + }, + "Hentiy Aymag": { + "lat": 47.6081209, + "lng": 109.9372856 + }, + "East Gobi Aymag": { + "lat": 43.9653889, + "lng": 109.1773459 + }, + "Khövsgöl Province": { + "lat": 50.2204484, + "lng": 100.3213768 + }, + "Orhon Aymag": { + "lat": 49.0366226, + "lng": 104.3749853 + }, + "South Khangay": { + "lat": 45.84891589999999, + "lng": 102.3244613 + }, + "Central Aymag": { + "lat": 47.2124056, + "lng": 106.41541 + }, + "Govi-Sumber": { + "lat": 46.4762754, + "lng": 108.5570627 + }, + "East Aimak": { + "lat": 48.2227684, + "lng": 115.0389646 + }, + "Bayanhongor Aymag": { + "lat": 46.177215, + "lng": 100.7118653 + }, + "Selenge Aymag": { + "lat": 49.0183549, + "lng": 106.3334287 + }, + "": { + "lat": 46.862496, + "lng": 103.846656 + } + }, + "KG": { + "Issyk-Kul": { + "lat": 42.3882639, + "lng": 77.2864879 + }, + "Chuyskaya Oblast'": { + "lat": 42.5655, + "lng": 74.4056612 + }, + "Jalal-Abad oblast": { + "lat": 41.106808, + "lng": 72.8988069 + }, + "Talas": { + "lat": 42.5317628, + "lng": 72.2304571 + }, + "Osh Oblasty": { + "lat": 39.8407366, + "lng": 72.8988069 + }, + "Batken": { + "lat": 40.054846, + "lng": 70.82091439999999 + }, + "Gorod Bishkek": { + "lat": 42.8746212, + "lng": 74.5697617 + }, + "": { + "lat": 41.20438, + "lng": 74.766098 + } + }, + "PW": { + "State of Koror": { + "lat": 7.3375646, + "lng": 134.4889469 + }, + "State of Aimeliik": { + "lat": 7.445585899999998, + "lng": 134.5030878 + }, + "State of Airai": { + "lat": 7.396611799999999, + "lng": 134.5690225 + }, + "State of Angaur": { + "lat": 6.909223, + "lng": 134.1387934 + }, + "State of Melekeok": { + "lat": 7.515028599999999, + "lng": 134.5972518 + }, + "State of Ngeremlengui": { + "lat": 7.5198397, + "lng": 134.5596089 + }, + "State of Ngatpang": { + "lat": 7.4710994, + "lng": 134.5266466 + }, + "State of Ngarchelong": { + "lat": 7.7105469, + "lng": 134.6301646 + }, + "State of Sonsorol": { + "lat": 5.3268119, + "lng": 132.2239117 + }, + "State of Kayangel": { + "lat": 8.07, + "lng": 134.702778 + }, + "State of Peleliu": { + "lat": 7.002290599999999, + "lng": 134.2431628 + }, + "State of Ngardmau": { + "lat": 7.585048599999999, + "lng": 134.5596089 + }, + "": { + "lat": 7.51498, + "lng": 134.58252 + } + }, + "VN": { + "Tinh Nghe An": { + "lat": 19.2342489, + "lng": 104.9200365 + }, + "Tinh Soc Trang": { + "lat": 9.6003688, + "lng": 105.9599539 + }, + "Tinh Tra Vinh": { + "lat": 9.812740999999999, + "lng": 106.2992912 + }, + "Tinh Vinh Long": { + "lat": 10.0861281, + "lng": 106.0169971 + }, + "Hanoi": { + "lat": 21.0277644, + "lng": 105.8341598 + }, + "Tinh Ninh Binh": { + "lat": 20.2129969, + "lng": 105.92299 + }, + "Tinh Yen Bai": { + "lat": 21.6837923, + "lng": 104.4551361 + }, + "Tinh GJong Nai": { + "lat": 11.0686305, + "lng": 107.1675976 + }, + "Ho Chi Minh": { + "lat": 10.8230989, + "lng": 106.6296638 + }, + "Tinh Ba Ria-Vung Tau": { + "lat": 10.5417397, + "lng": 107.2429976 + }, + "Tinh Phu Tho": { + "lat": 21.268443, + "lng": 105.2045573 + }, + "Hậu Giang": { + "lat": 9.757897999999999, + "lng": 105.6412527 + }, + "Tinh Vinh Phuc": { + "lat": 21.3608805, + "lng": 105.5474373 + }, + "Tinh Binh GJinh": { + "lat": 15.193231, + "lng": 108.7185184 + }, + "Tinh Khanh Hoa": { + "lat": 12.2585098, + "lng": 109.0526076 + }, + "Tinh Quang Ninh": { + "lat": 21.006382, + "lng": 107.2925144 + }, + "Tinh Phu Yen": { + "lat": 13.0881861, + "lng": 109.0928764 + }, + "Tinh Tuyen Quang": { + "lat": 22.1726708, + "lng": 105.3131185 + }, + "Tiền Giang": { + "lat": 10.4493324, + "lng": 106.3420504 + }, + "Haiphong": { + "lat": 20.8449115, + "lng": 106.6880841 + }, + "Tinh Binh Duong": { + "lat": 11.3254024, + "lng": 106.477017 + }, + "Tinh Quang Nam": { + "lat": 15.5393538, + "lng": 108.019102 + }, + "Tinh Thanh Hoa": { + "lat": 20.1291279, + "lng": 105.3131185 + }, + "Can Tho": { + "lat": 10.0451618, + "lng": 105.7468535 + }, + "Tinh Lai Chau": { + "lat": 22.3686613, + "lng": 103.3119085 + }, + "Tinh Thai Nguyen": { + "lat": 21.5613771, + "lng": 105.876004 + }, + "Tinh Thai Binh": { + "lat": 20.5386936, + "lng": 106.3934777 + }, + "Tây Ninh Province": { + "lat": 11.3494766, + "lng": 106.0640179 + }, + "Long An": { + "lat": 10.5607168, + "lng": 106.6497623 + }, + "An Giang": { + "lat": 10.5215836, + "lng": 105.1258955 + }, + "Tinh Son La": { + "lat": 21.1022284, + "lng": 103.7289167 + }, + "Tinh Lao Cai": { + "lat": 22.3380865, + "lng": 104.1487055 + }, + "Tinh GJong Thap": { + "lat": 14.058324, + "lng": 108.277199 + }, + "Tinh Kien Giang": { + "lat": 9.8249587, + "lng": 105.1258955 + }, + "Tinh Quang Tri": { + "lat": 16.7943472, + "lng": 106.963409 + }, + "Tinh Quang Binh": { + "lat": 17.6102715, + "lng": 106.3487474 + }, + "Quảng Ngãi Province": { + "lat": 15.0759838, + "lng": 108.7125791 + }, + "Gia Lai": { + "lat": 13.8078943, + "lng": 108.109375 + }, + "Tinh Bac Ninh": { + "lat": 21.121444, + "lng": 106.1110501 + }, + "Tinh Hoa Binh": { + "lat": 20.6861265, + "lng": 105.3131185 + }, + "Tinh Ha Nam": { + "lat": 20.5835196, + "lng": 105.92299 + }, + "Tinh Thua Thien-Hue": { + "lat": 16.467397, + "lng": 107.5905326 + }, + "Tinh Binh Thuan": { + "lat": 11.0903703, + "lng": 108.0720781 + }, + "Tinh Ninh Thuan": { + "lat": 11.6738767, + "lng": 108.8629572 + }, + "Tinh Nam GJinh": { + "lat": 14.058324, + "lng": 108.277199 + }, + "Tinh Bac Giang": { + "lat": 21.3014947, + "lng": 106.6291304 + }, + "Tinh Lang Son": { + "lat": 21.8563705, + "lng": 106.6291304 + }, + "Tinh Lam GJong": { + "lat": 14.058324, + "lng": 108.277199 + }, + "Tinh Dien Bien": { + "lat": 21.8042309, + "lng": 103.1076525 + }, + "Kon Tum": { + "lat": 14.3497403, + "lng": 108.0004606 + }, + "Tinh Hung Yen": { + "lat": 20.8525711, + "lng": 106.0169971 + }, + "Tinh Ha Tinh": { + "lat": 18.2943776, + "lng": 105.6745247 + }, + "Tinh Hai Duong": { + "lat": 20.9385958, + "lng": 106.3206861 + }, + "Tinh Ha Giang": { + "lat": 22.7662056, + "lng": 104.9388853 + }, + "Dak Nong": { + "lat": 12.2646476, + "lng": 107.609806 + }, + "Tinh Binh Phuoc": { + "lat": 11.7511894, + "lng": 106.7234639 + }, + "Da Nang": { + "lat": 16.0544563, + "lng": 108.0717219 + }, + "Đắk Lắk": { + "lat": 12.7100116, + "lng": 108.2377519 + }, + "Tinh Ben Tre": { + "lat": 10.1081553, + "lng": 106.4405872 + }, + "Tinh Bac Lieu": { + "lat": 9.251555500000002, + "lng": 105.5136472 + }, + "Tinh Cao Bang": { + "lat": 22.635689, + "lng": 106.2522143 + }, + "Tinh Ca Mau": { + "lat": 8.962409899999999, + "lng": 105.1258955 + }, + "Tinh Bac Kan": { + "lat": 22.3032923, + "lng": 105.876004 + }, + "": { + "lat": 14.058324, + "lng": 108.277199 + } + }, + "TL": { + "Viqueque": { + "lat": -8.8597918, + "lng": 126.3633516 + }, + "Cova Lima": { + "lat": -9.2650375, + "lng": 125.2587964 + }, + "Manufahi": { + "lat": -9.0145495, + "lng": 125.8279959 + }, + "Oecusse": { + "lat": -9.2970576, + "lng": 124.2866091 + }, + "Manatuto": { + "lat": -8.5155608, + "lng": 126.0159255 + }, + "Bobonaro": { + "lat": -9.0320375, + "lng": 125.3233049 + }, + "Liquiçá": { + "lat": -8.5891214, + "lng": 125.3411388 + }, + "Ainaro": { + "lat": -8.996518199999999, + "lng": 125.5083136 + }, + "Dili": { + "lat": -8.5568557, + "lng": 125.5603143 + }, + "Baucau": { + "lat": -8.4731006, + "lng": 126.4553727 + }, + "Aileu": { + "lat": -8.722408399999999, + "lng": 125.5712576 + }, + "Lautém": { + "lat": -8.5531846, + "lng": 126.9970831 + }, + "Ermera": { + "lat": -8.752480199999999, + "lng": 125.3987294 + }, + "": { + "lat": -8.874217, + "lng": 125.727539 + } + }, + "LA": { + "Vientiane Prefecture": { + "lat": 18.110541, + "lng": 102.5298028 + }, + "Houaphan": { + "lat": 20.3254175, + "lng": 104.1001326 + }, + "Khoueng Savannakhet": { + "lat": 16.5065381, + "lng": 105.5943388 + }, + "Salavan": { + "lat": 15.7165658, + "lng": 106.4140868 + }, + "Champasak": { + "lat": 14.9030362, + "lng": 105.8642593 + }, + "Xaignabouli": { + "lat": 19.1945799, + "lng": 101.483498 + }, + "Vientiane Province": { + "lat": 18.9686447, + "lng": 102.2547919 + }, + "Bolikhamsai": { + "lat": 18.4362924, + "lng": 104.4723301 + }, + "Khammouan": { + "lat": 17.6384066, + "lng": 105.2194808 + }, + "Louangnamtha": { + "lat": 20.9709135, + "lng": 101.4112049 + }, + "Xiangkhouang": { + "lat": 19.6375088, + "lng": 103.3587288 + }, + "Xaisomboun": { + "lat": 18.9181183, + "lng": 102.989615 + }, + "Khoueng Phongsali": { + "lat": 21.5919377, + "lng": 102.2547919 + }, + "Khoueng Oudomxai": { + "lat": 20.4921929, + "lng": 101.8891721 + }, + "Attapu": { + "lat": 14.8193696, + "lng": 106.8207875 + }, + "Khoueng Xekong": { + "lat": 15.5767446, + "lng": 107.0067031 + }, + "": { + "lat": 19.85627, + "lng": 102.495496 + } + }, + "TW": { + "New Taipei": { + "lat": 25.0329694, + "lng": 121.5654177 + }, + "Yunlin": { + "lat": 23.7092033, + "lng": 120.4313373 + }, + "Tainan": { + "lat": 22.9997281, + "lng": 120.2270277 + }, + "Changhua": { + "lat": 24.0716583, + "lng": 120.5624474 + }, + "Nantou": { + "lat": 23.9179593, + "lng": 120.6775164 + }, + "Yilan": { + "lat": 24.7021073, + "lng": 121.7377502 + }, + "Kaohsiung": { + "lat": 22.6272784, + "lng": 120.3014353 + }, + "Taoyuan": { + "lat": 24.9936281, + "lng": 121.3009798 + }, + "Taichung City": { + "lat": 24.1477358, + "lng": 120.6736482 + }, + "Hualien": { + "lat": 23.9871589, + "lng": 121.6015714 + }, + "Pingtung": { + "lat": 22.6710655, + "lng": 120.4892658 + }, + "Miaoli": { + "lat": 24.560159, + "lng": 120.8214265 + }, + "Chiayi County": { + "lat": 23.4518428, + "lng": 120.2554615 + }, + "Hsinchu County": { + "lat": 24.8387226, + "lng": 121.0177246 + }, + "Taitung": { + "lat": 22.7613207, + "lng": 121.1438152 + }, + "Taipei City": { + "lat": 25.0329636, + "lng": 121.5654268 + }, + "Penghu County": { + "lat": 23.5711899, + "lng": 119.5793157 + }, + "Lienchiang": { + "lat": 26.160243, + "lng": 119.9516652 + }, + "Hsinchu": { + "lat": 24.8138287, + "lng": 120.9674798 + }, + "Kinmen County": { + "lat": 24.34877912595629, + "lng": 118.3285644254523 + }, + "Keelung": { + "lat": 25.1276033, + "lng": 121.7391833 + }, + "Chiayi": { + "lat": 23.4800751, + "lng": 120.4491113 + }, + "": { + "lat": 23.69781, + "lng": 120.960515 + } + }, + "PH": { + "Western Visayas": { + "lat": 11.0049836, + "lng": 122.5372741 + }, + "Central Luzon": { + "lat": 15.4827722, + "lng": 120.7120023 + }, + "Zamboanga Peninsula": { + "lat": 8.154077, + "lng": 123.258793 + }, + "Autonomous Region in Muslim Mindanao": { + "lat": 6.956831299999999, + "lng": 124.2421597 + }, + "Bicol": { + "lat": 13.4209885, + "lng": 123.4136736 + }, + "Ilocos": { + "lat": 16.0832144, + "lng": 120.6199895 + }, + "Mimaropa": { + "lat": 9.843206499999999, + "lng": 118.7364783 + }, + "Calabarzon": { + "lat": 14.1007803, + "lng": 121.0793705 + }, + "Metro Manila": { + "lat": 14.6090537, + "lng": 121.0222565 + }, + "Central Visayas": { + "lat": 9.816875, + "lng": 124.0641419 + }, + "Northern Mindanao": { + "lat": 8.020163499999999, + "lng": 124.6856509 + }, + "Cagayan Valley": { + "lat": 16.9753758, + "lng": 121.8107079 + }, + "Soccsksargen": { + "lat": 6.2706918, + "lng": 124.6856509 + }, + "Cordillera": { + "lat": 17.3512542, + "lng": 121.1718851 + }, + "Eastern Visayas": { + "lat": 12.2445533, + "lng": 125.0388164 + }, + "Caraga": { + "lat": 8.801456199999999, + "lng": 125.7406882 + }, + "Davao": { + "lat": 7.190708, + "lng": 125.455341 + }, + "": { + "lat": 12.879721, + "lng": 121.774017 + } + }, + "HK": { + "Islands District": { + "lat": 22.2627924, + "lng": 113.9655419 + }, + "Yuen Long District": { + "lat": 22.4445484, + "lng": 114.0222095 + }, + "Yau Tsim Mong": { + "lat": 22.3116028, + "lng": 114.1706884 + }, + "Wong Tai Sin": { + "lat": 22.3548115, + "lng": 114.1974398 + }, + "Southern": { + "lat": 22.2432164, + "lng": 114.1974398 + }, + "Wan Chai": { + "lat": 22.276022, + "lng": 114.1751471 + }, + "Eastern": { + "lat": 22.2733889, + "lng": 114.2360778 + }, + "Tuen Mun": { + "lat": 22.3908295, + "lng": 113.9725126 + }, + "Sai Kung District": { + "lat": 22.3833893, + "lng": 114.270976 + }, + "Kwai Tsing": { + "lat": 22.3549077, + "lng": 114.1260991 + }, + "Tsuen Wan District": { + "lat": 22.3713227, + "lng": 114.1141601 + }, + "Kowloon City": { + "lat": 22.3232097, + "lng": 114.1855505 + }, + "Tai Po District": { + "lat": 22.4423282, + "lng": 114.165521 + }, + "Sha Tin": { + "lat": 22.3771304, + "lng": 114.1974398 + }, + "North": { + "lat": 22.5009081, + "lng": 114.1558258 + }, + "Central and Western District": { + "lat": 22.2730219, + "lng": 114.1498806 + }, + "Sham Shui Po": { + "lat": 22.3285899, + "lng": 114.1602846 + }, + "Kwun Tong": { + "lat": 22.315698, + "lng": 114.2331057 + }, + "": { + "lat": 22.396428, + "lng": 114.109497 + } + }, + "BN": { + "Tutong": { + "lat": 4.8140524, + "lng": 114.6608562 + }, + "Belait": { + "lat": 4.3750749, + "lng": 114.6192899 + }, + "Brunei-Muara District": { + "lat": 4.9311206, + "lng": 114.9516869 + }, + "Temburong": { + "lat": 4.6204128, + "lng": 115.141484 + }, + "": { + "lat": 4.535277, + "lng": 114.727669 + } + }, + "KH": { + "Phnom Penh": { + "lat": 11.5563738, + "lng": 104.9282099 + }, + "Takeo": { + "lat": 10.9877057, + "lng": 104.7871004 + }, + "Svay Rieng": { + "lat": 11.0877866, + "lng": 105.800951 + }, + "Stung Treng": { + "lat": 13.5069103, + "lng": 105.9676931 + }, + "Siem Reap": { + "lat": 13.3632533, + "lng": 103.856403 + }, + "Mondolkiri": { + "lat": 12.7879427, + "lng": 107.1011931 + }, + "Prey Veng": { + "lat": 11.485114, + "lng": 105.328098 + }, + "Pursat": { + "lat": 12.4651824, + "lng": 103.8911999 + }, + "Kampong Speu": { + "lat": 11.4650455, + "lng": 104.5072722 + }, + "Otar Meanchey": { + "lat": 14.1609738, + "lng": 103.8216261 + }, + "Kampong Chhnang": { + "lat": 12.2613926, + "lng": 104.6762738 + }, + "Tboung Khmum": { + "lat": 11.8891023, + "lng": 105.876004 + }, + "Kandal": { + "lat": 11.2237383, + "lng": 105.1258955 + }, + "Banteay Meanchey": { + "lat": 13.7531914, + "lng": 102.989615 + }, + "Kampong Thom": { + "lat": 12.70787, + "lng": 104.8736858 + }, + "Preah Vihear": { + "lat": 14.0085797, + "lng": 104.8454619 + }, + "Pailin": { + "lat": 12.9092962, + "lng": 102.6675575 + }, + "Kep": { + "lat": 10.543246, + "lng": 104.319142 + }, + "Koh Kong": { + "lat": 11.6153795, + "lng": 102.9836882 + }, + "Kratie": { + "lat": 12.4896877, + "lng": 106.0287512 + }, + "Preah Sihanouk": { + "lat": 10.6267867, + "lng": 103.5115545 + }, + "Kampong Cham": { + "lat": 11.9924294, + "lng": 105.4645408 + }, + "Battambang": { + "lat": 13.09573, + "lng": 103.2022055 + }, + "": { + "lat": 12.565679, + "lng": 104.990963 + } + }, + "KR": { + "Gyeongsangbuk-do": { + "lat": 36.3436011, + "lng": 128.7401566 + }, + "Daejeon": { + "lat": 36.3398175, + "lng": 127.3940486 + }, + "Gangwon-do": { + "lat": 37.724962, + "lng": 128.3009629 + }, + "Seoul": { + "lat": 37.5518911, + "lng": 126.9917937 + }, + "Jeollanam-do": { + "lat": 34.94020010000001, + "lng": 126.9565003 + }, + "Gyeonggi-do": { + "lat": 37.5289145, + "lng": 127.1727772 + }, + "North Chungcheong": { + "lat": 36.7378449, + "lng": 127.8305242 + }, + "Gyeongsangnam-do": { + "lat": 35.36956300000001, + "lng": 128.2570135 + }, + "Busan": { + "lat": 35.2100142, + "lng": 129.0688702 + }, + "Chungcheongnam-do": { + "lat": 36.5296003, + "lng": 126.8590621 + }, + "Jeollabuk-do": { + "lat": 35.7197198, + "lng": 127.1243977 + }, + "Gwangju": { + "lat": 35.1557358, + "lng": 126.8354271 + }, + "Ulsan": { + "lat": 35.5537228, + "lng": 129.2380554 + }, + "Daegu": { + "lat": 35.8294374, + "lng": 128.5655119 + }, + "Jeju-do": { + "lat": 33.3846216, + "lng": 126.5534925 + }, + "Incheon": { + "lat": 37.4562557, + "lng": 126.7052062 + }, + "Sejong-si": { + "lat": 36.5606976, + "lng": 127.2587334 + }, + "": { + "lat": 35.907757, + "lng": 127.766922 + } + }, + "JP": { + "Wakayama": { + "lat": 34.2303678, + "lng": 135.1707405 + }, + "Ishikawa": { + "lat": 36.3260317, + "lng": 136.5289653 + }, + "Kanagawa": { + "lat": 35.4827827, + "lng": 139.6146102 + }, + "Hyōgo": { + "lat": 34.8579518, + "lng": 134.5453787 + }, + "Shiga": { + "lat": 35.3292014, + "lng": 136.0563212 + }, + "Kagawa": { + "lat": 34.2225915, + "lng": 134.0199152 + }, + "Shizuoka": { + "lat": 34.9755668, + "lng": 138.3826773 + }, + "Tottori": { + "lat": 35.5011082, + "lng": 134.2351011 + }, + "Gifu": { + "lat": 35.42342259999999, + "lng": 136.7606217 + }, + "Yamagata": { + "lat": 38.2554153, + "lng": 140.3396175 + }, + "Yamaguchi": { + "lat": 34.1782945, + "lng": 131.4738432 + }, + "Fukuoka": { + "lat": 33.5901838, + "lng": 130.4016888 + }, + "Ibaraki": { + "lat": 36.2193571, + "lng": 140.1832516 + }, + "Tokyo": { + "lat": 35.6761919, + "lng": 139.6503106 + }, + "Nara": { + "lat": 34.685109, + "lng": 135.8048019 + }, + "Saitama": { + "lat": 35.8616486, + "lng": 139.6454782 + }, + "Niigata": { + "lat": 37.9161244, + "lng": 139.0363708 + }, + "Nagano": { + "lat": 36.6485258, + "lng": 138.1950371 + }, + "Yamanashi": { + "lat": 35.6933453, + "lng": 138.6873168 + }, + "Okinawa": { + "lat": 26.3343533, + "lng": 127.8056058 + }, + "Okayama": { + "lat": 34.6555312, + "lng": 133.919795 + }, + "Gunma": { + "lat": 36.5605388, + "lng": 138.8799972 + }, + "Mie": { + "lat": 33.8143901, + "lng": 136.0487047 + }, + "Oita": { + "lat": 33.2396084, + "lng": 131.6095148 + }, + "Chiba": { + "lat": 35.6074041, + "lng": 140.1065366 + }, + "Aichi": { + "lat": 35.0182505, + "lng": 137.2923893 + }, + "Kyoto": { + "lat": 35.011564, + "lng": 135.7681489 + }, + "Kumamoto": { + "lat": 32.8032164, + "lng": 130.7079369 + }, + "Shimane": { + "lat": 35.1244094, + "lng": 132.6293446 + }, + "Ōsaka": { + "lat": 34.6937249, + "lng": 135.5022535 + }, + "Kagoshima": { + "lat": 31.5968539, + "lng": 130.5571392 + }, + "Tochigi": { + "lat": 36.3823772, + "lng": 139.7341396 + }, + "Fukui": { + "lat": 36.0641386, + "lng": 136.219623 + }, + "Tokushima": { + "lat": 34.0703652, + "lng": 134.5549537 + }, + "Nagasaki": { + "lat": 32.7503334, + "lng": 129.8778888 + }, + "Hiroshima": { + "lat": 34.3852894, + "lng": 132.4553055 + }, + "Kochi": { + "lat": 33.5588821, + "lng": 133.5312383 + }, + "Toyama": { + "lat": 36.6958223, + "lng": 137.2137211 + }, + "Miyazaki": { + "lat": 31.9077285, + "lng": 131.4202196 + }, + "Saga": { + "lat": 33.2631179, + "lng": 130.3009057 + }, + "Akita": { + "lat": 39.7199668, + "lng": 140.1034795 + }, + "Fukushima": { + "lat": 37.7607991, + "lng": 140.4747856 + }, + "Ehime": { + "lat": 33.6025306, + "lng": 132.7857583 + }, + "Miyagi": { + "lat": 38.630612, + "lng": 141.1193048 + }, + "Iwate": { + "lat": 39.9726552, + "lng": 141.2124895 + }, + "Hokkaido": { + "lat": 43.2203266, + "lng": 142.8634737 + }, + "Aomori": { + "lat": 40.8222197, + "lng": 140.7473524 + }, + "": { + "lat": 36.204824, + "lng": 138.252924 + } + }, + "KP": { + "Pyongyang": { + "lat": 39.0737987, + "lng": 125.8197642 + }, + "Chagang-do": { + "lat": 40.7202809, + "lng": 126.5621137 + }, + "": { + "lat": 40.339852, + "lng": 127.510093 + } + }, + "AU": { + "South Australia": { + "lat": -30.0002315, + "lng": 136.2091547 + }, + "Western Australia": { + "lat": -27.6728168, + "lng": 121.6283098 + }, + "Northern Territory": { + "lat": -19.4914108, + "lng": 132.5509603 + }, + "Queensland": { + "lat": -22.575197, + "lng": 144.0847926 + }, + "New South Wales": { + "lat": -31.2532183, + "lng": 146.921099 + }, + "Victoria": { + "lat": -36.9847807, + "lng": 143.3906074 + }, + "Tasmania": { + "lat": -42.0409059, + "lng": 146.8087322 + }, + "Australian Capital Territory": { + "lat": -35.4734679, + "lng": 149.0123679 + }, + "": { + "lat": -25.274398, + "lng": 133.775136 + } + }, + "FM": { + "State of Chuuk": { + "lat": 7.134462467372186, + "lng": 151.5074133217651 + }, + "State of Kosrae": { + "lat": 7.425554, + "lng": 150.550812 + }, + "State of Pohnpei": { + "lat": 6.8518697, + "lng": 158.2146857 + }, + "State of Yap": { + "lat": 9.5556503, + "lng": 138.1399232 + }, + "": { + "lat": 7.425554, + "lng": 150.550812 + } + }, + "PG": { + "East Sepik Province": { + "lat": -4.3150058, + "lng": 143.045893 + }, + "Enga Province": { + "lat": -5.3005849, + "lng": 143.5635637 + }, + "West Sepik Province": { + "lat": -3.7126179, + "lng": 141.6834275 + }, + "East New Britain Province": { + "lat": -4.612894300000001, + "lng": 151.8877321 + }, + "National Capital": { + "lat": -9.4377025, + "lng": 147.2141209 + }, + "Northern Province": { + "lat": -8.8988063, + "lng": 148.1892921 + }, + "Bougainville": { + "lat": -6.053602, + "lng": 155.1907309 + }, + "Gulf Province": { + "lat": -7.1445402, + "lng": 143.7369154 + }, + "Western Highlands Province": { + "lat": -5.8310333, + "lng": 144.1720041 + }, + "New Ireland": { + "lat": -4.2853256, + "lng": 152.9205918 + }, + "Southern Highlands Province": { + "lat": -6.4179083, + "lng": 143.5635637 + }, + "Madang Province": { + "lat": -4.984973300000001, + "lng": 145.1375834 + }, + "Manus Province": { + "lat": -2.0941169, + "lng": 146.8760951 + }, + "Morobe Province": { + "lat": -6.801373700000001, + "lng": 146.561647 + }, + "Chimbu Province": { + "lat": -6.308768199999999, + "lng": 144.8731219 + }, + "Central Province": { + "lat": -9.1360187, + "lng": 147.4627259 + }, + "Hela": { + "lat": -5.5029613, + "lng": 142.5318604 + }, + "West New Britain Province": { + "lat": -5.704743199999999, + "lng": 150.0259466 + }, + "Eastern Highlands Province": { + "lat": -6.5861674, + "lng": 145.6689636 + }, + "Western Province": { + "lat": -7.8344739, + "lng": 142.0215415 + }, + "Milne Bay Province": { + "lat": -9.5221451, + "lng": 150.6749653 + }, + "": { + "lat": -6.314993, + "lng": 143.95555 + } + }, + "SB": { + "Central Province": { + "lat": -9.0516057, + "lng": 160.1693949 + }, + "Western Province": { + "lat": -8.128037299999999, + "lng": 157.4278119 + }, + "Guadalcanal Province": { + "lat": -9.577328399999999, + "lng": 160.1455805 + }, + "Honiara": { + "lat": -9.4456381, + "lng": 159.9728999 + }, + "Isabel Province": { + "lat": -8.0592353, + "lng": 159.1447081 + }, + "Malaita Province": { + "lat": -8.9446168, + "lng": 160.9071236 + }, + "Rennell and Bellona": { + "lat": -11.6598657, + "lng": 160.2170209 + }, + "Temotu Province": { + "lat": -10.7309282, + "lng": 166.0623979 + }, + "Makira-Ulawa Province": { + "lat": -10.5737447, + "lng": 161.8096941 + }, + "Choiseul": { + "lat": -7.0501494, + "lng": 156.9511459 + }, + "": { + "lat": -9.64571, + "lng": 160.156194 + } + }, + "KI": { + "Gilbert Islands": { + "lat": 0, + "lng": 174 + }, + "Line Islands": { + "lat": -3.37344501173228, + "lng": -155.300819350443 + }, + "Phoenix Islands": { + "lat": -3.730110963939701, + "lng": -172.6253624186994 + }, + "": { + "lat": -3.370417, + "lng": -168.734039 + } + }, + "TV": { + "Nui": { + "lat": -7.238876800000001, + "lng": 177.1485232 + }, + "Funafuti": { + "lat": -8.5211471, + "lng": 179.1961926 + }, + "Vaitupu": { + "lat": -7.476732699999999, + "lng": 178.6747675 + }, + "Nukulaelae": { + "lat": -9.381110999999999, + "lng": 179.852222 + }, + "Nanumea": { + "lat": -5.6613889, + "lng": 176.1036111 + }, + "Nanumanga": { + "lat": -6.2858019, + "lng": 176.319928 + }, + "Niutao": { + "lat": -6.106425799999999, + "lng": 177.3438429 + }, + "Nukufetau": { + "lat": -8, + "lng": 178.5 + }, + "": { + "lat": -7.109535, + "lng": 177.64933 + } + }, + "NR": { + "Aiwo": { + "lat": -0.5340012, + "lng": 166.9138873 + }, + "Uaboe": { + "lat": -0.5202222, + "lng": 166.9311761 + }, + "Anetan": { + "lat": -0.5064343, + "lng": 166.9427006 + }, + "Baiti": { + "lat": -0.5133158, + "lng": 166.9311761 + }, + "Ijuw": { + "lat": -0.5202646000000001, + "lng": 166.9513432 + }, + "Ewa": { + "lat": -0.5087241, + "lng": 166.9369384 + }, + "Anibare": { + "lat": -0.532915, + "lng": 166.944141 + }, + "Anabar": { + "lat": -0.5133517, + "lng": 166.9484624 + }, + "Yaren": { + "lat": -0.5466856999999999, + "lng": 166.9210913 + }, + "Buada": { + "lat": -0.5328777, + "lng": 166.9268541 + }, + "Boe": { + "lat": -0.5414945, + "lng": 166.9174893 + }, + "Meneng": { + "lat": -0.546724, + "lng": 166.938379 + }, + "Nibok": { + "lat": -0.5225124, + "lng": 166.9254134 + }, + "Denigomodu": { + "lat": -0.5247963999999999, + "lng": 166.9167689 + }, + "": { + "lat": -0.522778, + "lng": 166.931503 + } + }, + "MH": { + "Majuro Atoll": { + "lat": 7.066667, + "lng": 171.266667 + }, + "Kwajalein Atoll": { + "lat": 8.716667, + "lng": 167.733333 + }, + "Wotho Atoll": { + "lat": 10.112915, + "lng": 165.9695465 + }, + "": { + "lat": 7.131474, + "lng": 171.184478 + } + }, + "VU": { + "Torba": { + "lat": -14.28096, + "lng": 167.5160788 + }, + "Shefa Province": { + "lat": -17.6523412, + "lng": 168.3387066 + }, + "Malampa Province": { + "lat": -16.3031816, + "lng": 167.5160788 + }, + "Sanma Province": { + "lat": -15.3003549, + "lng": 166.9182097 + }, + "Penama Province": { + "lat": -15.3335375, + "lng": 167.9053182 + }, + "Tafea Province": { + "lat": -18.8175345, + "lng": 169.0645056 + }, + "": { + "lat": -15.376706, + "lng": 166.959158 + } + }, + "NC": { + "Loyalty Islands": { + "lat": -21.0454468, + "lng": 167.3324425 + }, + "North Province": { + "lat": -20.8888373, + "lng": 164.8741045 + }, + "South Province": { + "lat": -21.7482006, + "lng": 166.1783739 + }, + "": { + "lat": -20.904305, + "lng": 165.618042 + } + }, + "NZ": { + "Auckland": { + "lat": -36.85088270000001, + "lng": 174.7644881 + }, + "Southland": { + "lat": -45.84891589999999, + "lng": 167.6755387 + }, + "Canterbury": { + "lat": -43.7542275, + "lng": 171.1637245 + }, + "Marlborough": { + "lat": -41.5916883, + "lng": 173.7624053 + }, + "Otago": { + "lat": -45.47906709999999, + "lng": 170.1547567 + }, + "Waikato": { + "lat": -37.6190862, + "lng": 175.023346 + }, + "Gisborne": { + "lat": -38.6640913, + "lng": 178.0227931 + }, + "Bay of Plenty": { + "lat": -37.4233917, + "lng": 176.7416374 + }, + "Wellington": { + "lat": -41.2923814, + "lng": 174.7787463 + }, + "Taranaki": { + "lat": -39.3538149, + "lng": 174.4382721 + }, + "Manawatu-Wanganui": { + "lat": -39.9330715, + "lng": 175.0286083 + }, + "Tasman": { + "lat": -41.4571184, + "lng": 172.820974 + }, + "Northland": { + "lat": -35.4136172, + "lng": 173.9320806 + }, + "Hawke's Bay": { + "lat": -39.6016597, + "lng": 176.5804473 + }, + "West Coast": { + "lat": -42.6919232, + "lng": 171.3399414 + }, + "Nelson": { + "lat": -41.2985321, + "lng": 173.2443635 + }, + "Chatham Islands": { + "lat": -43.9271098, + "lng": -176.4592091 + }, + "": { + "lat": -40.900557, + "lng": 174.885971 + } + }, + "FJ": { + "Central": { + "lat": -17.9453123, + "lng": 178.2461183 + }, + "Western": { + "lat": -17.7510845, + "lng": 177.7763333 + }, + "Northern": { + "lat": -16.6268225, + "lng": 179.0179332 + }, + "Rotuma": { + "lat": -12.5025069, + "lng": 177.0724164 + }, + "": { + "lat": -16.578193, + "lng": 179.414413 + } + }, + "CM": { + "Centre": { + "lat": 4.6298411, + "lng": 11.7068294 + }, + "North-West Region": { + "lat": 6.470373899999999, + "lng": 10.439656 + }, + "Adamaoua Region": { + "lat": 6.9181954, + "lng": 12.8054753 + }, + "Littoral": { + "lat": 4.1682138, + "lng": 10.0807298 + }, + "Far North Region": { + "lat": 10.6315589, + "lng": 14.6587821 + }, + "South": { + "lat": 2.7202832, + "lng": 11.7068294 + }, + "South-West Region": { + "lat": 5.1573493, + "lng": 9.367308399999999 + }, + "West Region": { + "lat": 5.4638158, + "lng": 10.8000051 + }, + "": { + "lat": 7.369722, + "lng": 12.354722 + } + }, + "SN": { + "Ziguinchor": { + "lat": 12.5641479, + "lng": -16.2639825 + }, + "Region de Thies": { + "lat": 14.7910052, + "lng": -16.9358604 + }, + "Region de Sedhiou": { + "lat": 12.8836881, + "lng": -15.5943388 + }, + "Fatick": { + "lat": 14.3390167, + "lng": -16.4111425 + }, + "Matam": { + "lat": 15.6600225, + "lng": -13.2576906 + }, + "Diourbel": { + "lat": 14.6561238, + "lng": -16.2345633 + }, + "Saint-Louis": { + "lat": 16.0326307, + "lng": -16.4818167 + }, + "Dakar": { + "lat": 14.716677, + "lng": -17.4676861 + }, + "Region de Kedougou": { + "lat": 12.5604607, + "lng": -12.1747077 + }, + "Kaolack": { + "lat": 14.1652083, + "lng": -16.0757749 + }, + "Louga": { + "lat": 15.6141768, + "lng": -16.22868 + }, + "Kolda": { + "lat": 12.9001718, + "lng": -14.9425368 + }, + "Tambacounda": { + "lat": 13.7725888, + "lng": -13.6710059 + }, + "Region de Kaffrine": { + "lat": 14.3466477, + "lng": -15.2194808 + }, + "": { + "lat": 14.497401, + "lng": -14.452362 + } + }, + "CG": { + "Lékoumou": { + "lat": -3.170382, + "lng": 13.3587288 + }, + "Pointe-Noire": { + "lat": -4.7691623, + "lng": 11.866362 + }, + "Sangha": { + "lat": 1.4662328, + "lng": 15.4068079 + }, + "Cuvette": { + "lat": -0.2877446, + "lng": 16.1580937 + }, + "Niari": { + "lat": -3.18427, + "lng": 12.2547919 + }, + "Brazzaville": { + "lat": -4.2744405, + "lng": 15.2812803 + }, + "Likouala": { + "lat": 2.043924000000001, + "lng": 17.668887 + }, + "Kouilou": { + "lat": -4.1428413, + "lng": 11.8891721 + }, + "Cuvette-Ouest": { + "lat": 0.144755, + "lng": 14.4723301 + }, + "Plateaux": { + "lat": -2.0680088, + "lng": 15.4068079 + }, + "Pool": { + "lat": -3.7762628, + "lng": 14.8454619 + }, + "": { + "lat": -0.228021, + "lng": 15.827659 + } + }, + "PT": { + "Santarém": { + "lat": 39.2848804, + "lng": -8.704075 + }, + "Beja": { + "lat": 38.0153039, + "lng": -7.8627308 + }, + "Évora": { + "lat": 38.571431, + "lng": -7.913501999999999 + }, + "Lisbon": { + "lat": 38.7222524, + "lng": -9.1393366 + }, + "Faro": { + "lat": 37.0193548, + "lng": -7.9304397 + }, + "Setúbal": { + "lat": 38.5260437, + "lng": -8.8909328 + }, + "Bragança": { + "lat": 41.8061131, + "lng": -6.756737999999999 + }, + "Castelo Branco": { + "lat": 39.8197117, + "lng": -7.4964662 + }, + "Leiria": { + "lat": 39.74953310000001, + "lng": -8.807682999999999 + }, + "Portalegre": { + "lat": 39.2967086, + "lng": -7.4284755 + }, + "Madeira": { + "lat": 32.76070740000001, + "lng": -16.9594723 + }, + "Viseu": { + "lat": 40.6588424, + "lng": -7.914756 + }, + "Braga": { + "lat": 41.5454486, + "lng": -8.426506999999999 + }, + "Porto": { + "lat": 41.1579438, + "lng": -8.629105299999999 + }, + "Vila Real": { + "lat": 41.3010351, + "lng": -7.7422354 + }, + "Guarda": { + "lat": 40.5308408, + "lng": -7.2221421 + }, + "Viana do Castelo": { + "lat": 41.6918275, + "lng": -8.8344101 + }, + "Coimbra": { + "lat": 40.2033145, + "lng": -8.4102573 + }, + "Aveiro": { + "lat": 40.6405055, + "lng": -8.6537539 + }, + "Azores": { + "lat": 37.7412488, + "lng": -25.6755944 + }, + "": { + "lat": 39.399872, + "lng": -8.224454 + } + }, + "LR": { + "Grand Gedeh County": { + "lat": 5.9222078, + "lng": -8.2212979 + }, + "Grand Kru County": { + "lat": 4.7613862, + "lng": -8.2212979 + }, + "Lofa County": { + "lat": 8.191118399999999, + "lng": -9.7232673 + }, + "Bomi County": { + "lat": 6.7171372, + "lng": -10.7097867 + }, + "Gbarpolu County": { + "lat": 7.495263700000001, + "lng": -10.0807298 + }, + "Nimba County": { + "lat": 6.8427612, + "lng": -8.6600586 + }, + "River Cess County": { + "lat": 5.8205993, + "lng": -9.367308399999999 + }, + "Montserrado County": { + "lat": 6.509587199999999, + "lng": -10.5746224 + }, + "Margibi County": { + "lat": 6.5151875, + "lng": -10.3048897 + }, + "Maryland County": { + "lat": 4.6033516, + "lng": -7.6982272 + }, + "Sinoe County": { + "lat": 5.49871, + "lng": -8.6600586 + }, + "Bong County": { + "lat": 6.908980300000001, + "lng": -9.634134999999999 + }, + "Grand Bassa County": { + "lat": 6.2308452, + "lng": -9.8124935 + }, + "": { + "lat": 6.428055, + "lng": -9.429499 + } + }, + "CI": { + "Yamoussoukro Autonomous District": { + "lat": 6.827622799999999, + "lng": -5.2893433 + }, + "Lacs": { + "lat": 6.899194400000001, + "lng": -4.5624426 + }, + "Abidjan": { + "lat": 5.3599517, + "lng": -4.0082563 + }, + "Zanzan": { + "lat": 8.8207904, + "lng": -3.4195527 + }, + "Bas-Sassandra": { + "lat": 5.5253377, + "lng": -6.5783387 + }, + "Goh-Djiboua": { + "lat": 5.871139299999999, + "lng": -5.5617279 + }, + "Denguele": { + "lat": 9.646674299999999, + "lng": -7.265285799999999 + }, + "Woroba": { + "lat": 8.2548962, + "lng": -6.5783387 + }, + "Montagnes": { + "lat": 7.376237300000001, + "lng": -7.4381355 + }, + "Savanes": { + "lat": 9.6783614, + "lng": -5.5617279 + }, + "Comoe": { + "lat": 5.974122899999999, + "lng": -3.1779659 + }, + "Sassandra-Marahoue": { + "lat": 7.222729999999999, + "lng": -6.2375947 + }, + "Lagunes": { + "lat": 5.882733399999999, + "lng": -4.2333355 + }, + "Vallee du Bandama": { + "lat": 8.278978, + "lng": -4.893562699999999 + }, + "": { + "lat": 7.539989, + "lng": -5.54708 + } + }, + "GH": { + "Northern Region": { + "lat": 9.367277099999999, + "lng": -0.1494988 + }, + "Upper West Region": { + "lat": 10.2529757, + "lng": -2.1450245 + }, + "Greater Accra Region": { + "lat": 5.8142836, + "lng": 0.0746767 + }, + "Ashanti Region": { + "lat": 6.7470436, + "lng": -1.5208624 + }, + "Western Region": { + "lat": 5.5572659, + "lng": -2.302446 + }, + "Bono": { + "lat": 7.946527, + "lng": -1.023194 + }, + "Eastern Region": { + "lat": 6.5781373, + "lng": -0.4502368 + }, + "Central Region": { + "lat": 5.644375399999999, + "lng": -1.2891036 + }, + "Western North": { + "lat": 5.5572659, + "lng": -2.302446 + }, + "Upper East Region": { + "lat": 10.7982051, + "lng": -1.0586135 + }, + "North East": { + "lat": 7.946527, + "lng": -1.023194 + }, + "Ahafo": { + "lat": 7.9559247, + "lng": -1.6760691 + }, + "Bono East": { + "lat": 6.5781373, + "lng": -0.4502368 + }, + "Volta Region": { + "lat": 6.5781373, + "lng": 0.4502368 + }, + "Oti": { + "lat": 9.172306030466055, + "lng": 0.2624567475647809 + }, + "Savannah": { + "lat": 7.946527, + "lng": -1.023194 + }, + "": { + "lat": 7.946527, + "lng": -1.023194 + } + }, + "GQ": { + "Annobon": { + "lat": -1.4268782, + "lng": 5.6352801 + }, + "Wele-Nzas": { + "lat": 1.4166162, + "lng": 11.0711758 + }, + "Kié-Ntem": { + "lat": 2.028093, + "lng": 11.0711758 + }, + "Centro Sur": { + "lat": 1.3436084, + "lng": 10.439656 + }, + "Bioko Norte": { + "lat": 3.6595072, + "lng": 8.7921836 + }, + "Bioko Sur": { + "lat": 3.4209785, + "lng": 8.6160674 + }, + "Litoral": { + "lat": 1.5750244, + "lng": 9.8124935 + }, + "Djibloho": { + "lat": 1.616835, + "lng": 10.82757 + }, + "": { + "lat": 1.650801, + "lng": 10.267895 + } + }, + "NG": { + "Kaduna State": { + "lat": 10.3764006, + "lng": 7.7094537 + }, + "Adamawa": { + "lat": 9.3264751, + "lng": 12.3983853 + }, + "Lagos": { + "lat": 6.5243793, + "lng": 3.3792057 + }, + "Taraba State": { + "lat": 7.9993616, + "lng": 10.7739863 + }, + "Delta": { + "lat": 5.704030800000001, + "lng": 5.9339181 + }, + "Nasarawa State": { + "lat": 8.4997908, + "lng": 8.1996937 + }, + "Akwa Ibom State": { + "lat": 4.9057371, + "lng": 7.853667499999999 + }, + "Abia State": { + "lat": 5.4527354, + "lng": 7.5248414 + }, + "Borno State": { + "lat": 11.8846356, + "lng": 13.1519665 + }, + "Yobe State": { + "lat": 12.293876, + "lng": 11.4390411 + }, + "Sokoto State": { + "lat": 13.0533143, + "lng": 5.3222722 + }, + "Ogun State": { + "lat": 6.997974699999999, + "lng": 3.4737378 + }, + "Rivers State": { + "lat": 4.8396414, + "lng": 6.911237799999999 + }, + "Gombe State": { + "lat": 10.3637795, + "lng": 11.1927587 + }, + "Oyo State": { + "lat": 8.157380900000001, + "lng": 3.6146534 + }, + "Imo State": { + "lat": 5.5720122, + "lng": 7.0588219 + }, + "Osun State": { + "lat": 7.562896399999999, + "lng": 4.5199593 + }, + "Kwara State": { + "lat": 8.9668961, + "lng": 4.3874051 + }, + "Anambra": { + "lat": 6.220899699999999, + "lng": 6.9369559 + }, + "Ondo State": { + "lat": 6.9148682, + "lng": 5.1478144 + }, + "Kogi State": { + "lat": 7.7337325, + "lng": 6.6905836 + }, + "Enugu State": { + "lat": 6.536353, + "lng": 7.435619399999999 + }, + "Cross River State": { + "lat": 5.8701724, + "lng": 8.5988014 + }, + "FCT": { + "lat": 8.8940691, + "lng": 7.1860402 + }, + "Niger State": { + "lat": 9.930922400000002, + "lng": 5.598320999999999 + }, + "Plateau State": { + "lat": 9.2182093, + "lng": 9.5179488 + }, + "Katsina State": { + "lat": 12.3796707, + "lng": 7.6305748 + }, + "Kano State": { + "lat": 11.7470698, + "lng": 8.5247107 + }, + "Kebbi": { + "lat": 11.4942003, + "lng": 4.2333355 + }, + "Ebonyi State": { + "lat": 6.2649232, + "lng": 8.0137302 + }, + "Edo": { + "lat": 6.6341831, + "lng": 5.9304056 + }, + "Bauchi": { + "lat": 10.3059966, + "lng": 9.840395899999999 + }, + "Zamfara State": { + "lat": 12.1221805, + "lng": 6.2235819 + }, + "Ekiti State": { + "lat": 7.718986200000001, + "lng": 5.310950500000001 + }, + "Benue State": { + "lat": 7.3369024, + "lng": 8.7403687 + }, + "Bayelsa State": { + "lat": 4.7719071, + "lng": 6.0698526 + }, + "": { + "lat": 9.081999, + "lng": 8.675277 + } + }, + "BF": { + "Plateau-Central": { + "lat": 12.2537648, + "lng": -0.7532808999999999 + }, + "Centre-Est": { + "lat": 11.5247674, + "lng": -0.1494988 + }, + "Nord": { + "lat": 13.718252, + "lng": -2.302446 + }, + "Centre": { + "lat": 12.3425897, + "lng": -1.443469 + }, + "Centre-Sud": { + "lat": 11.5228911, + "lng": -1.0586135 + }, + "Boucle du Mouhoun": { + "lat": 13.1299154, + "lng": -3.3693702 + }, + "Centre-Nord": { + "lat": 13.1724464, + "lng": -0.9056623 + }, + "Sahel": { + "lat": 12.238333, + "lng": -1.561593 + }, + "Sud-Ouest": { + "lat": 10.4231493, + "lng": -3.2583626 + }, + "Est": { + "lat": 12.4365526, + "lng": 0.9056623 + }, + "Hauts-Bassins": { + "lat": 11.4942003, + "lng": -4.2333355 + }, + "Cascades Region": { + "lat": 10.4072992, + "lng": -4.5624426 + }, + "": { + "lat": 12.238333, + "lng": -1.561593 + } + }, + "TG": { + "Centrale": { + "lat": 8.6586029, + "lng": 1.0586135 + }, + "Savanes": { + "lat": 10.5291781, + "lng": 0.5257822999999999 + }, + "Maritime": { + "lat": 6.4913504, + "lng": 1.2891036 + }, + "Plateaux": { + "lat": 7.6101378, + "lng": 1.0586135 + }, + "Kara": { + "lat": 9.5486112, + "lng": 1.1977158 + }, + "": { + "lat": 8.619543, + "lng": 0.824782 + } + }, + "GW": { + "Biombo": { + "lat": 11.8529061, + "lng": -15.7351171 + }, + "Gabu": { + "lat": 12.2836137, + "lng": -14.2251939 + }, + "Cacheu Region": { + "lat": 12.0551416, + "lng": -16.0640179 + }, + "Bafata": { + "lat": 12.1720266, + "lng": -14.6568929 + }, + "Bolama": { + "lat": 11.5770712, + "lng": -15.4800382 + }, + "Quinara": { + "lat": 11.7904668, + "lng": -15.2662931 + }, + "Oio Region": { + "lat": 12.2760709, + "lng": -15.3131185 + }, + "Bissau": { + "lat": 11.803749, + "lng": -15.180413 + }, + "": { + "lat": 11.803749, + "lng": -15.180413 + } + }, + "MR": { + "Tagant": { + "lat": 18.5467527, + "lng": -9.9018131 + }, + "Hodh El Gharbi": { + "lat": 16.6912149, + "lng": -9.5450974 + }, + "Trarza": { + "lat": 17.8664964, + "lng": -14.6587821 + }, + "Hodh Ech Chargi": { + "lat": 18.6737026, + "lng": -7.092877 + }, + "Gorgol": { + "lat": 15.949622, + "lng": -12.989615 + }, + "Assaba": { + "lat": 16.579953, + "lng": -11.7068294 + }, + "Tiris Zemmour": { + "lat": 24.5773764, + "lng": -9.9018131 + }, + "Guidimaka": { + "lat": 15.2557331, + "lng": -12.2547919 + }, + "Adrar": { + "lat": 21.6398783, + "lng": -8.4842465 + }, + "Brakna": { + "lat": 17.4047323, + "lng": -13.3587288 + }, + "Inchiri": { + "lat": 19.5195792, + "lng": -14.8454619 + }, + "Nouakchott Sud": { + "lat": 18.0130087, + "lng": -15.993491 + }, + "Nouakchott Ouest": { + "lat": 18.1050859, + "lng": -15.993491 + }, + "": { + "lat": 21.00789, + "lng": -10.940835 + } + }, + "BJ": { + "Collines Department": { + "lat": 8.3022297, + "lng": 2.302446 + }, + "Atakora Department": { + "lat": 10.7954931, + "lng": 1.6760691 + }, + "Ouémé": { + "lat": 6.6148152, + "lng": 2.4999918 + }, + "Borgou Department": { + "lat": 9.5340864, + "lng": 2.7779813 + }, + "Atlantique Department": { + "lat": 6.6588391, + "lng": 2.2236667 + }, + "Alibori": { + "lat": 10.9681093, + "lng": 2.7779813 + }, + "Mono": { + "lat": 6.6607182, + "lng": 1.7538817 + }, + "Kouffo Department": { + "lat": 7.003589400000001, + "lng": 1.7538817 + }, + "Donga": { + "lat": 9.7191867, + "lng": 1.6760691 + }, + "Littoral": { + "lat": 6.3806973, + "lng": 2.4406387 + }, + "Zou Department": { + "lat": 7.346926799999999, + "lng": 2.0665197 + }, + "": { + "lat": 9.30769, + "lng": 2.315834 + } + }, + "GA": { + "Ogooué-Maritime": { + "lat": -1.3465975, + "lng": 9.7232673 + }, + "Woleu-Ntem": { + "lat": 1.4892408, + "lng": 11.7068294 + }, + "Moyen-Ogooué": { + "lat": -0.442784, + "lng": 10.439656 + }, + "Nyanga": { + "lat": -2.8821033, + "lng": 11.1617356 + }, + "Ngouni": { + "lat": -1.4930303, + "lng": 10.9807003 + }, + "Ogooué-Ivindo": { + "lat": 0.8818311, + "lng": 13.1740348 + }, + "Estuaire": { + "lat": 0.4432864, + "lng": 10.0807298 + }, + "Ogooué-Lolo": { + "lat": -0.8844093, + "lng": 12.4380581 + }, + "Haut-Ogooué": { + "lat": -1.4762544, + "lng": 13.914399 + }, + "": { + "lat": -0.803689, + "lng": 11.609444 + } + }, + "SL": { + "Western Area": { + "lat": 8.3114983, + "lng": -13.035694 + }, + "North West": { + "lat": 8.7985396, + "lng": -12.6216211 + }, + "Northern Province": { + "lat": 9.1816965, + "lng": -11.5248055 + }, + "Eastern Province": { + "lat": 7.8106095, + "lng": -11.1617356 + }, + "Southern Province": { + "lat": 7.790662499999999, + "lng": -11.8891721 + }, + "": { + "lat": 8.460555, + "lng": -11.779889 + } + }, + "ST": { + "Agua Grande": { + "lat": 0.3395496, + "lng": 6.7119409 + }, + "Principe": { + "lat": 1.6139012, + "lng": 7.405702400000001 + }, + "Lemba": { + "lat": 0.2416603, + "lng": 6.5143087 + }, + "": { + "lat": 0.18636, + "lng": 6.613081 + } + }, + "SH": { + "Ascension": { + "lat": -7.9438072, + "lng": -14.3741107 + }, + "Tristan da Cunha": { + "lat": -37.1052489, + "lng": -12.2776838 + }, + "Saint Helena": { + "lat": -15.9650104, + "lng": -5.7089241 + }, + "": { + "lat": -24.143474, + "lng": -10.030696 + } + }, + "GM": { + "West Coast": { + "lat": 13.2229202, + "lng": -16.5819789 + }, + "Lower River Division": { + "lat": 13.3553306, + "lng": -15.92299 + }, + "Banjul": { + "lat": 13.454375, + "lng": -16.5753186 + }, + "Central River": { + "lat": 13.5994469, + "lng": -14.8921668 + }, + "North Bank": { + "lat": 13.5285436, + "lng": -16.0169971 + }, + "": { + "lat": 13.443182, + "lng": -15.310139 + } + }, + "GN": { + "Mamou Region": { + "lat": 10.5736024, + "lng": -11.8891721 + }, + "Labe Region": { + "lat": 11.641159, + "lng": -11.8891721 + }, + "Conakry Region": { + "lat": 9.5090945, + "lng": -13.7119312 + }, + "Boke Region": { + "lat": 11.1864672, + "lng": -14.1001326 + }, + "Faranah": { + "lat": 10.2587018, + "lng": -10.8000051 + }, + "Kindia": { + "lat": 10.0406725, + "lng": -12.8629885 + }, + "Kankan Region": { + "lat": 10.120923, + "lng": -9.5450974 + }, + "": { + "lat": 9.945587, + "lng": -9.696645 + } + }, + "NE": { + "Tahoua": { + "lat": 14.8904575, + "lng": 5.2579968 + }, + "Zinder": { + "lat": 13.8018124, + "lng": 8.98527 + }, + "Tillaberi Region": { + "lat": 14.6489525, + "lng": 2.1450245 + }, + "Maradi": { + "lat": 13.5009779, + "lng": 7.103639599999999 + }, + "Niamey": { + "lat": 13.5115963, + "lng": 2.1253854 + }, + "Dosso Region": { + "lat": 13.1513947, + "lng": 3.4195527 + }, + "Diffa": { + "lat": 13.3132472, + "lng": 12.6158803 + }, + "Agadez": { + "lat": 16.9741689, + "lng": 7.986535 + }, + "": { + "lat": 17.607789, + "lng": 8.081666 + } + }, + "ML": { + "Tombouctou": { + "lat": 16.7665887, + "lng": -3.0025615 + }, + "Gao": { + "lat": 16.2639807, + "lng": -0.0279867 + }, + "Taoudénit": { + "lat": 22.6764132, + "lng": -3.9789143 + }, + "Sikasso": { + "lat": 11.3223834, + "lng": -5.6983979 + }, + "Ségou": { + "lat": 13.4316597, + "lng": -6.2482149 + }, + "Mopti": { + "lat": 14.490149, + "lng": -4.1924713 + }, + "Koulikoro": { + "lat": 12.8662046, + "lng": -7.5599321 + }, + "Kidal": { + "lat": 18.4467469, + "lng": 1.408939 + }, + "Kayes": { + "lat": 14.4393538, + "lng": -11.4467369 + }, + "Bamako Region": { + "lat": 12.6392316, + "lng": -8.0028892 + }, + "": { + "lat": 17.570692, + "lng": -3.996166 + } + }, + "MA": { + "Laayoune-Sakia El Hamra": { + "lat": 27.8683194, + "lng": -11.9804613 + }, + "Dakhla-Oued Ed-Dahab": { + "lat": 31.791702, + "lng": -7.092619999999999 + }, + "Draa-Tafilalet": { + "lat": 31.1499538, + "lng": -5.393955099999999 + }, + "Marrakesh-Safi": { + "lat": 31.7330833, + "lng": -8.1338558 + }, + "Souss-Massa": { + "lat": 30.2750611, + "lng": -8.1338558 + }, + "Casablanca-Settat": { + "lat": 33.0266712, + "lng": -7.6114217 + }, + "Beni Mellal-Khenifra": { + "lat": 32.5719184, + "lng": -6.0679194 + }, + "Rabat-Sale-Kenitra": { + "lat": 34.1727659, + "lng": -6.2375947 + }, + "Tanger-Tetouan-Al Hoceima": { + "lat": 35.2629558, + "lng": -5.5617279 + }, + "Fes-Meknes": { + "lat": 34.062529, + "lng": -4.7277528 + }, + "Oriental": { + "lat": 33.4198879, + "lng": -2.1450245 + }, + "Guelmim-Oued Noun": { + "lat": 28.4844281, + "lng": -10.0807298 + }, + "": { + "lat": 31.791702, + "lng": -7.09262 + } + }, + "TN": { + "Zaghouan Governorate": { + "lat": 36.2771237, + "lng": 9.9912254 + }, + "Jendouba Governorate": { + "lat": 36.7181862, + "lng": 8.748116699999999 + }, + "Béja Governorate": { + "lat": 36.8625526, + "lng": 9.1013498 + }, + "Manouba": { + "lat": 36.8093284, + "lng": 10.0863269 + }, + "Gafsa": { + "lat": 34.4311398, + "lng": 8.7756556 + }, + "Tunis Governorate": { + "lat": 36.8336172, + "lng": 10.2375823 + }, + "Tozeur Governorate": { + "lat": 33.9789491, + "lng": 8.0465185 + }, + "Tataouine": { + "lat": 32.9210902, + "lng": 10.4508956 + }, + "Kasserine Governorate": { + "lat": 35.0809148, + "lng": 8.6600586 + }, + "Nabeul Governorate": { + "lat": 36.7278251, + "lng": 10.7097867 + }, + "Kef Governorate": { + "lat": 36.1230512, + "lng": 8.6600586 + }, + "Monastir Governorate": { + "lat": 35.564091, + "lng": 10.7548851 + }, + "Sousse Governorate": { + "lat": 35.9022267, + "lng": 10.3497895 + }, + "Ariana Governorate": { + "lat": 36.907169, + "lng": 10.1255164 + }, + "Siliana Governorate": { + "lat": 35.990294, + "lng": 9.2785583 + }, + "Mahdia Governorate": { + "lat": 35.3830893, + "lng": 10.3497895 + }, + "Sidi Bouzid Governorate": { + "lat": 35.0000369, + "lng": 9.7232673 + }, + "Sfax Governorate": { + "lat": 34.685798, + "lng": 10.3497895 + }, + "Ben Arous Governorate": { + "lat": 36.6435606, + "lng": 10.2151578 + }, + "Kebili Governorate": { + "lat": 33.1245286, + "lng": 8.8362755 + }, + "Medenine Governorate": { + "lat": 33.2280565, + "lng": 10.8903099 + }, + "Gabès Governorate": { + "lat": 33.9459648, + "lng": 9.7232673 + }, + "Bizerte Governorate": { + "lat": 37.1609397, + "lng": 9.634134999999999 + }, + "Kairouan": { + "lat": 35.6711663, + "lng": 10.1005469 + }, + "": { + "lat": 33.886917, + "lng": 9.537499 + } + }, + "DZ": { + "Boumerdes": { + "lat": 36.7590552, + "lng": 3.4731462 + }, + "Constantine": { + "lat": 36.3570052, + "lng": 6.639028199999999 + }, + "Biskra": { + "lat": 34.8536654, + "lng": 5.727726199999999 + }, + "Algiers": { + "lat": 36.753768, + "lng": 3.0587561 + }, + "Djelfa": { + "lat": 34.6703956, + "lng": 3.2503761 + }, + "Illizi": { + "lat": 26.5081447, + "lng": 8.483017499999999 + }, + "Tizi Ouzou": { + "lat": 36.713548, + "lng": 4.0473075 + }, + "El Tarf": { + "lat": 36.7667584, + "lng": 8.3167895 + }, + "Touggourt": { + "lat": 33.1049642, + "lng": 6.0662834 + }, + "Jijel": { + "lat": 36.8210144, + "lng": 5.7634126 + }, + "Tlemcen": { + "lat": 34.8828864, + "lng": -1.3166815 + }, + "Tissemsilt": { + "lat": 35.6053781, + "lng": 1.813098 + }, + "Tipaza": { + "lat": 36.5906719, + "lng": 2.4433723 + }, + "Tindouf": { + "lat": 27.677069, + "lng": -8.128375000000002 + }, + "Timimoun": { + "lat": 29.2616911, + "lng": 0.2415964 + }, + "Mascara": { + "lat": 35.4020988, + "lng": 0.1400257 + }, + "Tiaret": { + "lat": 35.3708689, + "lng": 1.3217852 + }, + "Chlef": { + "lat": 36.1581301, + "lng": 1.3371874 + }, + "Mila": { + "lat": 36.4519049, + "lng": 6.2584338 + }, + "Tébessa": { + "lat": 35.4010797, + "lng": 8.1172958 + }, + "Batna": { + "lat": 35.5446077, + "lng": 6.1596945 + }, + "Béjaïa": { + "lat": 36.7514502, + "lng": 5.0557713 + }, + "Tamanrasset": { + "lat": 22.7942358, + "lng": 5.5361426 + }, + "Skikda": { + "lat": 36.8620804, + "lng": 6.9054888 + }, + "Medea": { + "lat": 36.2636153, + "lng": 2.7586773 + }, + "Bouira": { + "lat": 36.3737098, + "lng": 3.8980159 + }, + "Blida": { + "lat": 36.4735715, + "lng": 2.8323153 + }, + "Souk Ahras": { + "lat": 36.2799517, + "lng": 7.9382788 + }, + "Relizane": { + "lat": 35.7450263, + "lng": 0.5578837 + }, + "Oran": { + "lat": 35.6987388, + "lng": -0.6349319 + }, + "Aïn Témouchent": { + "lat": 35.3038931, + "lng": -1.1391863 + }, + "Sidi Bel Abbès": { + "lat": 35.2105876, + "lng": -0.629983 + }, + "M'Sila": { + "lat": 35.7186646, + "lng": 4.523342299999999 + }, + "Sétif": { + "lat": 36.1897593, + "lng": 5.4107984 + }, + "Saida": { + "lat": 34.8381764, + "lng": 0.1481254 + }, + "El Oued": { + "lat": 33.367811, + "lng": 6.8516511 + }, + "Adrar": { + "lat": 27.8808838, + "lng": -0.28943 + }, + "Bordj Bou Arréridj": { + "lat": 36.0739925, + "lng": 4.7630271 + }, + "Oum el Bouaghi": { + "lat": 35.8688789, + "lng": 7.110826599999999 + }, + "Ouled Djellal": { + "lat": 34.4303855, + "lng": 5.0611761 + }, + "Guelma": { + "lat": 36.4627444, + "lng": 7.4330833 + }, + "Ouargla": { + "lat": 31.9527411, + "lng": 5.3335348 + }, + "Naama": { + "lat": 33.2667317, + "lng": -0.3128659 + }, + "Mostaganem": { + "lat": 35.9311454, + "lng": 0.09094139999999999 + }, + "Beni Abbes": { + "lat": 30.1312217, + "lng": -2.1662258 + }, + "Aïn Defla": { + "lat": 36.2552144, + "lng": 1.9562997 + }, + "Laghouat": { + "lat": 33.8079718, + "lng": 2.8628592 + }, + "Khenchela": { + "lat": 35.4269404, + "lng": 7.1460155 + }, + "Béchar": { + "lat": 31.6238098, + "lng": -2.2162443 + }, + "Djanet": { + "lat": 24.554151, + "lng": 9.485429 + }, + "In Salah": { + "lat": 27.1977907, + "lng": 2.4818223 + }, + "Ghardaia": { + "lat": 32.4943741, + "lng": 3.64446 + }, + "El Bayadh": { + "lat": 33.6854149, + "lng": 1.0303543 + }, + "In Guezzam": { + "lat": 19.5732736, + "lng": 5.7693343 + }, + "El Mghair": { + "lat": 33.950285, + "lng": 5.9244238 + }, + "Annaba": { + "lat": 36.897375, + "lng": 7.7500122 + }, + "El Menia": { + "lat": 30.5833161, + "lng": 2.8836701 + }, + "Bordj Badji Mokhtar": { + "lat": 21.32551, + "lng": 0.9524803 + }, + "": { + "lat": 28.033886, + "lng": 1.659626 + } + }, + "ES": { + "Andalusia": { + "lat": 37.5442706, + "lng": -4.7277528 + }, + "Extremadura": { + "lat": 39.4937392, + "lng": -6.0679194 + }, + "Murcia": { + "lat": 37.9922399, + "lng": -1.1306544 + }, + "Castille-La Mancha": { + "lat": 39.2795607, + "lng": -3.097702 + }, + "Valencia": { + "lat": 39.4699075, + "lng": -0.3762881 + }, + "Canary Islands": { + "lat": 28.2915637, + "lng": -16.6291304 + }, + "Balearic Islands": { + "lat": 39.35877591790004, + "lng": 2.735632782011336 + }, + "Melilla": { + "lat": 35.2922775, + "lng": -2.9380973 + }, + "Basque Country": { + "lat": 42.9896248, + "lng": -2.6189273 + }, + "Aragon": { + "lat": 41.5976275, + "lng": -0.9056623 + }, + "Galicia": { + "lat": 42.5750554, + "lng": -8.1338558 + }, + "Madrid": { + "lat": 40.4167754, + "lng": -3.7037902 + }, + "Castille and León": { + "lat": 41.83568210000001, + "lng": -4.397635699999999 + }, + "Cantabria": { + "lat": 43.1828396, + "lng": -3.9878427 + }, + "Catalonia": { + "lat": 41.5911589, + "lng": 1.5208624 + }, + "Principality of Asturias": { + "lat": 43.3613953, + "lng": -5.8593267 + }, + "Navarre": { + "lat": 42.6953909, + "lng": -1.6760691 + }, + "La Rioja": { + "lat": 42.2870733, + "lng": -2.539603 + }, + "Ceuta": { + "lat": 35.8893874, + "lng": -5.3213455 + }, + "": { + "lat": 40.463667, + "lng": -3.74922 + } + }, + "IT": { + "Calabria": { + "lat": 39.3087714, + "lng": 16.3463791 + }, + "Sicily": { + "lat": 37.5999938, + "lng": 14.0153557 + }, + "Sardinia": { + "lat": 40.1208752, + "lng": 9.012892599999999 + }, + "Basilicate": { + "lat": 40.6430766, + "lng": 15.9699878 + }, + "Apulia": { + "lat": 40.7928393, + "lng": 17.1011931 + }, + "Campania": { + "lat": 41.10994729999999, + "lng": 14.8475139 + }, + "Veneto": { + "lat": 45.4414662, + "lng": 12.3152595 + }, + "Emilia-Romagna": { + "lat": 44.5967607, + "lng": 11.2186396 + }, + "Friuli Venezia Giulia": { + "lat": 46.2259177, + "lng": 13.1033646 + }, + "Lombardy": { + "lat": 45.47906709999999, + "lng": 9.8452433 + }, + "Liguria": { + "lat": 44.3167917, + "lng": 8.3964938 + }, + "Trentino-Alto Adige": { + "lat": 46.4336662, + "lng": 11.1693296 + }, + "Lazio": { + "lat": 41.6552418, + "lng": 12.989615 + }, + "Piedmont": { + "lat": 45.0522366, + "lng": 7.5153885 + }, + "Tuscany": { + "lat": 43.7710513, + "lng": 11.2486208 + }, + "Abruzzo": { + "lat": 42.1920119, + "lng": 13.7289167 + }, + "The Marches": { + "lat": 43.5058744, + "lng": 12.989615 + }, + "Molise": { + "lat": 41.67388649999999, + "lng": 14.7520939 + }, + "Umbria": { + "lat": 42.938004, + "lng": 12.6216211 + }, + "Aosta Valley": { + "lat": 45.7388878, + "lng": 7.426186599999999 + }, + "": { + "lat": 41.87194, + "lng": 12.56738 + } + }, + "MT": { + "Marsaxlokk": { + "lat": 35.8422063, + "lng": 14.5427735 + }, + "Iz-Zurrieq": { + "lat": 35.8302346, + "lng": 14.4751779 + }, + "Iz-Zejtun": { + "lat": 35.8557905, + "lng": 14.5332143 + }, + "Iz-Zebbug": { + "lat": 36.0671351, + "lng": 14.234817 + }, + "Haz-Zabbar": { + "lat": 35.8771148, + "lng": 14.540596 + }, + "Ix-Xghajra": { + "lat": 35.8865072, + "lng": 14.5467404 + }, + "Ix-Xewkija": { + "lat": 36.0321998, + "lng": 14.2608044 + }, + "Ix-Xaghra": { + "lat": 36.0500932, + "lng": 14.2643301 + }, + "Valletta": { + "lat": 35.8992375, + "lng": 14.5140996 + }, + "Tarxien": { + "lat": 35.8668509, + "lng": 14.5124736 + }, + "Ta' Xbiex": { + "lat": 35.898976, + "lng": 14.4949271 + }, + "Tas-Sliema": { + "lat": 35.9123988, + "lng": 14.5017677 + }, + "Is-Siggiewi": { + "lat": 35.8537819, + "lng": 14.4385572 + }, + "Saint Venera": { + "lat": 35.888949, + "lng": 14.4780874 + }, + "Saint Lucia": { + "lat": 35.8628915, + "lng": 14.5055214 + }, + "Saint Paul’s Bay": { + "lat": 35.9483408, + "lng": 14.4109413 + }, + "Sannat": { + "lat": 36.0237371, + "lng": 14.2457123 + }, + "Saint Lawrence": { + "lat": 36.05406, + "lng": 14.2026799 + }, + "Saint Julian": { + "lat": 35.9214241, + "lng": 14.4905868 + }, + "Safi": { + "lat": 35.833088, + "lng": 14.4849813 + }, + "Paola": { + "lat": 35.8729954, + "lng": 14.507489 + }, + "Victoria": { + "lat": 36.0447227, + "lng": 14.2409977 + }, + "Il-Qrendi": { + "lat": 35.8342549, + "lng": 14.4577862 + }, + "Qormi": { + "lat": 35.8785358, + "lng": 14.4705233 + }, + "Il-Qala": { + "lat": 36.0347623, + "lng": 14.3095825 + }, + "Tal-Pieta": { + "lat": 35.8927838, + "lng": 14.4934702 + }, + "In-Naxxar": { + "lat": 35.9145712, + "lng": 14.4442551 + }, + "In-Nadur": { + "lat": 36.0380524, + "lng": 14.2929803 + }, + "Il-Munxar": { + "lat": 36.0315459, + "lng": 14.2359164 + }, + "L-Imsida": { + "lat": 35.8993715, + "lng": 14.4845673 + }, + "L-Imqabba": { + "lat": 35.842057, + "lng": 14.4651729 + }, + "Il-Mosta": { + "lat": 35.9094431, + "lng": 14.4258739 + }, + "L-Imgarr": { + "lat": 35.9198177, + "lng": 14.3658099 + }, + "Il-Mellieha": { + "lat": 35.9565528, + "lng": 14.363558 + }, + "L-Imdina": { + "lat": 35.8863691, + "lng": 14.4031146 + }, + "Marsaskala": { + "lat": 35.86281779999999, + "lng": 14.5697064 + }, + "Il-Marsa": { + "lat": 35.8831345, + "lng": 14.4947288 + }, + "Is-Swieqi": { + "lat": 35.921781, + "lng": 14.4788318 + }, + "Luqa": { + "lat": 35.8598629, + "lng": 14.4888722 + }, + "Senglea": { + "lat": 35.8878874, + "lng": 14.5167449 + }, + "L-Iklin": { + "lat": 35.9060466, + "lng": 14.4528477 + }, + "Lija": { + "lat": 35.9018584, + "lng": 14.4480114 + }, + "Ta' Kercem": { + "lat": 36.0416864, + "lng": 14.2270714 + }, + "Il-Kalkara": { + "lat": 35.8890117, + "lng": 14.5309693 + }, + "Il-Gzira": { + "lat": 35.9049436, + "lng": 14.4936571 + }, + "Floriana": { + "lat": 35.8922208, + "lng": 14.5036155 + }, + "Il-Birgu": { + "lat": 35.8880695, + "lng": 14.5220972 + }, + "Il-Hamrun": { + "lat": 35.8866813, + "lng": 14.4875714 + }, + "Il-Gudja": { + "lat": 35.848248, + "lng": 14.5027222 + }, + "Hal Ghaxaq": { + "lat": 35.847411, + "lng": 14.5147543 + }, + "L-Ghasri": { + "lat": 36.0633571, + "lng": 14.2218064 + }, + "Hal Gharghur": { + "lat": 35.9237367, + "lng": 14.453202 + }, + "L-Gharb": { + "lat": 36.0598645, + "lng": 14.2090723 + }, + "Ghajnsielem": { + "lat": 36.0257218, + "lng": 14.28641 + }, + "Il-Fgura": { + "lat": 35.8723777, + "lng": 14.5214089 + }, + "Dingli": { + "lat": 35.8599678, + "lng": 14.3796965 + }, + "Bormla": { + "lat": 35.882448, + "lng": 14.522503 + }, + "Birzebbuga": { + "lat": 35.8265339, + "lng": 14.5278428 + }, + "Birkirkara": { + "lat": 35.8961327, + "lng": 14.4644929 + }, + "Balzan": { + "lat": 35.8983624, + "lng": 14.4521396 + }, + "Attard": { + "lat": 35.893269, + "lng": 14.4368125 + }, + "Saint John": { + "lat": 35.9079226, + "lng": 14.478277 + }, + "Il-Fontana": { + "lat": 36.0388917, + "lng": 14.2369197 + }, + "Pembroke": { + "lat": 35.9261416, + "lng": 14.4803358 + }, + "": { + "lat": 35.937496, + "lng": 14.375416 + } + }, + "AT": { + "Lower Austria": { + "lat": 48.10807699999999, + "lng": 15.8049558 + }, + "Upper Austria": { + "lat": 48.025854, + "lng": 13.9723665 + }, + "Salzburg": { + "lat": 47.80949, + "lng": 13.05501 + }, + "Styria": { + "lat": 47.3593442, + "lng": 14.4699827 + }, + "Carinthia": { + "lat": 46.722203, + "lng": 14.1805881 + }, + "Burgenland": { + "lat": 47.15371649999999, + "lng": 16.2688797 + }, + "Tyrol": { + "lat": 47.2537414, + "lng": 11.601487 + }, + "Vorarlberg": { + "lat": 47.2497427, + "lng": 9.9797373 + }, + "Vienna": { + "lat": 48.2081743, + "lng": 16.3738189 + }, + "": { + "lat": 47.516231, + "lng": 14.550072 + } + }, + "DK": { + "North Denmark": { + "lat": 56.8307416, + "lng": 9.4930528 + }, + "Zealand": { + "lat": 55.4632518, + "lng": 11.7214979 + }, + "South Denmark": { + "lat": 55.3307714, + "lng": 9.0924903 + }, + "Capital Region": { + "lat": 55.8565124, + "lng": 12.3011513 + }, + "Central Jutland": { + "lat": 56.302139, + "lng": 9.3027769 + }, + "": { + "lat": 56.26392, + "lng": 9.501785 + } + }, + "IS": { + "East": { + "lat": 64.8329974, + "lng": -15.7433981 + }, + "South": { + "lat": 64.2661426, + "lng": -18.8158138 + }, + "Northwest": { + "lat": 65.19625049999999, + "lng": -19.2425804 + }, + "Northeast": { + "lat": 65.4713712, + "lng": -17.0280279 + }, + "Southern Peninsula": { + "lat": 63.91548030000001, + "lng": -22.3649667 + }, + "Westfjords": { + "lat": 65.91961500000001, + "lng": -21.8811763 + }, + "West": { + "lat": 64.963051, + "lng": -19.020835 + }, + "Capital Region": { + "lat": 64.1846369, + "lng": -21.6131424 + }, + "": { + "lat": 64.963051, + "lng": -19.020835 + } + }, + "GB": { + "Wales": { + "lat": 52.1306607, + "lng": -3.7837117 + }, + "England": { + "lat": 52.3555177, + "lng": -1.1743197 + }, + "Scotland": { + "lat": 56.49067119999999, + "lng": -4.2026458 + }, + "Northern Ireland": { + "lat": 54.7877149, + "lng": -6.4923145 + }, + "": { + "lat": 55.378051, + "lng": -3.435973 + } + }, + "IE": { + "Ulster": { + "lat": 54.7616555, + "lng": -6.9612273 + }, + "Connacht": { + "lat": 53.83762429999999, + "lng": -8.9584481 + }, + "Munster": { + "lat": 52.2217725, + "lng": -8.556663799999999 + }, + "Leinster": { + "lat": 53.3271538, + "lng": -7.514084100000001 + }, + "": { + "lat": 53.41291, + "lng": -8.24389 + } + }, + "CH": { + "Basel-Landschaft": { + "lat": 47.44181220000001, + "lng": 7.7644002 + }, + "Bern": { + "lat": 46.9479739, + "lng": 7.4474468 + }, + "Saint Gallen": { + "lat": 47.4244818, + "lng": 9.3767173 + }, + "Aargau": { + "lat": 47.3876664, + "lng": 8.2554295 + }, + "Zurich": { + "lat": 47.3768866, + "lng": 8.541694 + }, + "Grisons": { + "lat": 46.65698709999999, + "lng": 9.578025700000001 + }, + "Fribourg": { + "lat": 46.8064773, + "lng": 7.161971899999999 + }, + "Zug": { + "lat": 47.1661672, + "lng": 8.5154946 + }, + "Solothurn": { + "lat": 47.2088348, + "lng": 7.532291 + }, + "Valais": { + "lat": 46.1904614, + "lng": 7.5449226 + }, + "Vaud": { + "lat": 46.5613135, + "lng": 6.536765 + }, + "Schwyz": { + "lat": 47.0207138, + "lng": 8.652988400000002 + }, + "Lucerne": { + "lat": 47.05016819999999, + "lng": 8.3093072 + }, + "Nidwalden": { + "lat": 46.9267016, + "lng": 8.3849982 + }, + "Schaffhausen": { + "lat": 47.6958897, + "lng": 8.6380488 + }, + "Thurgau": { + "lat": 47.5960787, + "lng": 9.1523232 + }, + "Appenzell Ausserrhoden": { + "lat": 47.366481, + "lng": 9.3000916 + }, + "Ticino": { + "lat": 46.331734, + "lng": 8.800452900000002 + }, + "Jura": { + "lat": 47.3444474, + "lng": 7.143060800000001 + }, + "Geneva": { + "lat": 46.2043907, + "lng": 6.1431577 + }, + "Uri": { + "lat": 46.7738629, + "lng": 8.602515300000002 + }, + "Neuchâtel": { + "lat": 46.9899874, + "lng": 6.9292732 + }, + "Obwalden": { + "lat": 46.877858, + "lng": 8.251249 + }, + "Glarus": { + "lat": 47.0411232, + "lng": 9.0679 + }, + "Appenzell Innerrhoden": { + "lat": 47.3161925, + "lng": 9.4316573 + }, + "Basel-City": { + "lat": 47.5619253, + "lng": 7.592767999999999 + }, + "": { + "lat": 46.818188, + "lng": 8.227512 + } + }, + "SJ": { + "Svalbard": { + "lat": 71.031818, + "lng": -8.2920347 + }, + "Jan Mayen": { + "lat": 71.031818, + "lng": -8.2920347 + }, + "": { + "lat": 77.553604, + "lng": 23.670272 + } + }, + "NL": { + "Overijssel": { + "lat": 52.4387814, + "lng": 6.5016411 + }, + "Gelderland": { + "lat": 52.045155, + "lng": 5.871823399999999 + }, + "Drenthe": { + "lat": 52.9476012, + "lng": 6.623058599999999 + }, + "South Holland": { + "lat": 52.0207975, + "lng": 4.4937836 + }, + "North Holland": { + "lat": 52.5205869, + "lng": 4.788474 + }, + "Friesland": { + "lat": 53.1641642, + "lng": 5.7817542 + }, + "North Brabant": { + "lat": 51.4826537, + "lng": 5.2321687 + }, + "Groningen": { + "lat": 53.2193835, + "lng": 6.566501799999999 + }, + "Zeeland": { + "lat": 51.4940309, + "lng": 3.8496815 + }, + "Utrecht": { + "lat": 52.09073739999999, + "lng": 5.1214201 + }, + "Flevoland": { + "lat": 52.5279781, + "lng": 5.595350799999999 + }, + "Limburg": { + "lat": 51.4427238, + "lng": 6.0608726 + }, + "": { + "lat": 52.132633, + "lng": 5.291266 + } + }, + "BE": { + "Flanders": { + "lat": 51.0950244, + "lng": 4.4477809 + }, + "Wallonia": { + "lat": 50.400501, + "lng": 5.1335125 + }, + "Brussels Capital": { + "lat": 50.8476424, + "lng": 4.3571696 + }, + "": { + "lat": 50.503887, + "lng": 4.469936 + } + }, + "DE": { + "Saxony": { + "lat": 51.1045407, + "lng": 13.2017384 + }, + "Hesse": { + "lat": 50.6520515, + "lng": 9.162437599999999 + }, + "Bavaria": { + "lat": 48.7904472, + "lng": 11.4978895 + }, + "Baden-Wurttemberg": { + "lat": 48.6616037, + "lng": 9.3501336 + }, + "Rheinland-Pfalz": { + "lat": 50.118346, + "lng": 7.3089527 + }, + "North Rhine-Westphalia": { + "lat": 51.43323669999999, + "lng": 7.661593799999999 + }, + "Brandenburg": { + "lat": 52.4125287, + "lng": 12.5316444 + }, + "Saxony-Anhalt": { + "lat": 51.9502649, + "lng": 11.6922735 + }, + "Mecklenburg-Vorpommern": { + "lat": 53.6126505, + "lng": 12.4295953 + }, + "Lower Saxony": { + "lat": 52.6367036, + "lng": 9.8450765 + }, + "Thuringia": { + "lat": 51.0109892, + "lng": 10.845346 + }, + "Land Berlin": { + "lat": 52.52000659999999, + "lng": 13.404954 + }, + "Schleswig-Holstein": { + "lat": 54.21936720000001, + "lng": 9.696116700000001 + }, + "Saarland": { + "lat": 49.3964234, + "lng": 7.0229607 + }, + "Bremen": { + "lat": 53.07929619999999, + "lng": 8.8016937 + }, + "Hamburg": { + "lat": 53.5488282, + "lng": 9.987170299999999 + }, + "": { + "lat": 51.165691, + "lng": 10.451526 + } + }, + "LU": { + "Wiltz": { + "lat": 49.96622, + "lng": 5.932430600000001 + }, + "Clervaux": { + "lat": 50.0534803, + "lng": 6.0291558 + }, + "Grevenmacher": { + "lat": 49.680841, + "lng": 6.440759300000001 + }, + "Luxembourg": { + "lat": 49.815273, + "lng": 6.129582999999999 + }, + "Vianden": { + "lat": 49.93397479999999, + "lng": 6.2076994 + }, + "Esch-sur-Alzette": { + "lat": 49.5024342, + "lng": 5.9722212 + }, + "Capellen": { + "lat": 49.646245, + "lng": 5.990757599999999 + }, + "Diekirch": { + "lat": 49.8671784, + "lng": 6.159563299999999 + }, + "Remich": { + "lat": 49.54501699999999, + "lng": 6.367422200000001 + }, + "Echternach": { + "lat": 49.811342, + "lng": 6.4175245 + }, + "Mersch": { + "lat": 49.7481135, + "lng": 6.103932899999999 + }, + "Redange": { + "lat": 49.7640865, + "lng": 5.888528099999999 + }, + "": { + "lat": 49.815273, + "lng": 6.129583 + } + }, + "FR": { + "Nouvelle-Aquitaine": { + "lat": 45.5990651, + "lng": 0.6142169 + }, + "Hauts-de-France": { + "lat": 49.66361269999999, + "lng": 2.5280732 + }, + "Grand Est": { + "lat": 48.9131152, + "lng": 5.4425501 + }, + "Corsica": { + "lat": 42.0396042, + "lng": 9.012892599999999 + }, + "Centre-Val de Loire": { + "lat": 47.7515686, + "lng": 1.6750631 + }, + "Auvergne-Rhone-Alpes": { + "lat": 45.5126545, + "lng": 4.4904519 + }, + "Pays de la Loire": { + "lat": 47.7632836, + "lng": -0.3299687 + }, + "Brittany": { + "lat": 48.2020471, + "lng": -2.9326435 + }, + "Normandy": { + "lat": 48.87987039999999, + "lng": 0.1712529 + }, + "Île-de-France": { + "lat": 48.8499198, + "lng": 2.6370411 + }, + "Bourgogne-Franche-Comte": { + "lat": 47.02321569999999, + "lng": 5.0922632 + }, + "Provence-Alpes-Côte d'Azur": { + "lat": 43.9351691, + "lng": 6.0679194 + }, + "Occitanie": { + "lat": 43.4636856, + "lng": 2.1450245 + }, + "": { + "lat": 46.227638, + "lng": 2.213749 + } + }, + "AD": { + "Sant Julià de Loria": { + "lat": 42.4657861, + "lng": 1.4921277 + }, + "Andorra la Vella": { + "lat": 42.50631740000001, + "lng": 1.5218355 + }, + "Encamp": { + "lat": 42.5194138, + "lng": 1.6566377 + }, + "Ordino": { + "lat": 42.5994433, + "lng": 1.5402327 + }, + "Escaldes-Engordany": { + "lat": 42.5100804, + "lng": 1.5387862 + }, + "La Massana": { + "lat": 42.545625, + "lng": 1.5147392 + }, + "Canillo": { + "lat": 42.5666535, + "lng": 1.5994581 + }, + "": { + "lat": 42.546245, + "lng": 1.601554 + } + }, + "LI": { + "Vaduz": { + "lat": 47.1410303, + "lng": 9.5209277 + }, + "Triesenberg": { + "lat": 47.1224511, + "lng": 9.5701985 + }, + "Triesen": { + "lat": 47.1097988, + "lng": 9.5248296 + }, + "Schellenberg": { + "lat": 47.230966, + "lng": 9.546784299999999 + }, + "Mauren": { + "lat": 47.2189285, + "lng": 9.541735 + }, + "Schaan": { + "lat": 47.1667317, + "lng": 9.5112002 + }, + "Ruggell": { + "lat": 47.24167, + "lng": 9.5253371 + }, + "Planken": { + "lat": 47.1797118, + "lng": 9.5618123 + }, + "Gamprin": { + "lat": 47.213249, + "lng": 9.5025195 + }, + "Eschen": { + "lat": 47.2050219, + "lng": 9.5240771 + }, + "Balzers": { + "lat": 47.0655826, + "lng": 9.507452599999999 + }, + "": { + "lat": 47.166, + "lng": 9.555373 + } + }, + "CZ": { + "Kralovehradecky kraj": { + "lat": 50.3512484, + "lng": 15.7976459 + }, + "Central Bohemia": { + "lat": 49.81749199999999, + "lng": 15.472962 + }, + "Olomoucky kraj": { + "lat": 49.65865489999999, + "lng": 17.0811406 + }, + "Zlín": { + "lat": 49.2244365, + "lng": 17.6627635 + }, + "South Moravian": { + "lat": 48.9544528, + "lng": 16.7676899 + }, + "Karlovarsky kraj": { + "lat": 50.1435, + "lng": 12.7501899 + }, + "Jihocesky kraj": { + "lat": 48.9457789, + "lng": 14.4416055 + }, + "Ustecky kraj": { + "lat": 50.6119037, + "lng": 13.7870086 + }, + "Kraj Vysocina": { + "lat": 49.44900519999999, + "lng": 15.6405934 + }, + "Plzeň Region": { + "lat": 49.4134812, + "lng": 13.3157246 + }, + "Moravskoslezsky kraj": { + "lat": 49.7305327, + "lng": 18.2332637 + }, + "Liberecky kraj": { + "lat": 50.76627999999999, + "lng": 15.0543387 + }, + "Prague": { + "lat": 50.0755381, + "lng": 14.4378005 + }, + "Pardubicky kraj": { + "lat": 49.9444479, + "lng": 16.2856916 + }, + "": { + "lat": 49.817492, + "lng": 15.472962 + } + }, + "SM": { + "Serravalle": { + "lat": 43.9690367, + "lng": 12.4774099 + }, + "Castello di San Marino Citta": { + "lat": 43.9355907, + "lng": 12.4472806 + }, + "Chiesanuova": { + "lat": 43.91429050000001, + "lng": 12.4208642 + }, + "Castello di Montegiardino": { + "lat": 43.9052999, + "lng": 12.4810542 + }, + "Castello di Faetano": { + "lat": 43.9348967, + "lng": 12.4896554 + }, + "Castello di Borgo Maggiore": { + "lat": 43.9574882, + "lng": 12.4552546 + }, + "Castello di Acquaviva": { + "lat": 43.945153, + "lng": 12.4179988 + }, + "": { + "lat": 43.94236, + "lng": 12.457777 + } + }, + "HR": { + "Split-Dalmatia": { + "lat": 43.5240328, + "lng": 16.8178377 + }, + "Vukovar-Sirmium": { + "lat": 45.3452377, + "lng": 19.0010204 + }, + "Dubrovnik-Neretva": { + "lat": 43.0766588, + "lng": 17.5268471 + }, + "Istria": { + "lat": 45.2745018, + "lng": 13.8901858 + }, + "County of Krapina-Zagorje": { + "lat": 46.10133930000001, + "lng": 15.8809693 + }, + "County of Zagreb": { + "lat": 45.8706612, + "lng": 16.395491 + }, + "Karlovac": { + "lat": 45.4928973, + "lng": 15.5552683 + }, + "City of Zagreb": { + "lat": 45.8150108, + "lng": 15.981919 + }, + "County of Zadar": { + "lat": 44.1318273, + "lng": 15.4556962 + }, + "County of Koprivnica-Križevci": { + "lat": 46.1568919, + "lng": 16.8390826 + }, + "County of Sisak-Moslavina": { + "lat": 45.4850767, + "lng": 16.3731156 + }, + "County of Primorje-Gorski Kotar": { + "lat": 45.31739959999999, + "lng": 14.8167466 + }, + "County of Međimurje": { + "lat": 46.3766644, + "lng": 16.4213298 + }, + "Šibenik-Knin": { + "lat": 43.9281485, + "lng": 16.1037694 + }, + "County of Virovitica-Podravina": { + "lat": 45.6557985, + "lng": 17.7932472 + }, + "County of Osijek-Baranja": { + "lat": 45.5576428, + "lng": 18.3942141 + }, + "County of Varaždin": { + "lat": 46.23174729999999, + "lng": 16.3360558 + }, + "Bjelovar-Bilogora": { + "lat": 45.7809992, + "lng": 16.9936575 + }, + "County of Požega-Slavonia": { + "lat": 45.3417868, + "lng": 17.8114359 + }, + "County of Lika-Senj": { + "lat": 44.6192218, + "lng": 15.4701608 + }, + "Brod-Posavina": { + "lat": 45.2637951, + "lng": 17.3264562 + }, + "": { + "lat": 45.1, + "lng": 15.2 + } + }, + "BA": { + "Republika Srpska": { + "lat": 44.7280186, + "lng": 17.3148136 + }, + "Federation of B&H": { + "lat": 43.8562586, + "lng": 18.4130763 + }, + "Brčko": { + "lat": 44.8726563, + "lng": 18.8106276 + }, + "": { + "lat": 43.915886, + "lng": 17.679076 + } + }, + "SI": { + "Obcina Zuzemberk": { + "lat": 45.830669, + "lng": 14.9298228 + }, + "Obcina Zirovnica": { + "lat": 46.4044239, + "lng": 14.1375549 + }, + "Obcina Ziri": { + "lat": 46.0513094, + "lng": 14.111887 + }, + "Obcina Lasko": { + "lat": 46.1542793, + "lng": 15.2359978 + }, + "Videm": { + "lat": 45.8490426, + "lng": 14.6947037 + }, + "Jezersko": { + "lat": 46.3942794, + "lng": 14.4985559 + }, + "Gorje": { + "lat": 46.3802458, + "lng": 14.0685339 + }, + "Sveta Trojica v Slovenskih Goricah": { + "lat": 46.5771983, + "lng": 15.8789679 + }, + "Slovenska Bistrica": { + "lat": 46.3919813, + "lng": 15.5727868 + }, + "Kungota": { + "lat": 46.6418793, + "lng": 15.6036288 + }, + "Kranj": { + "lat": 46.2428344, + "lng": 14.3555417 + }, + "Obcina Zetale": { + "lat": 46.2784002, + "lng": 15.8122225 + }, + "Obcina Zelezniki": { + "lat": 46.22544, + "lng": 14.1692478 + }, + "Krsko": { + "lat": 45.95899780000001, + "lng": 15.4921312 + }, + "Obcina Zavrc": { + "lat": 46.3856393, + "lng": 16.0470768 + }, + "Obcina Zalec": { + "lat": 46.251984, + "lng": 15.1650282 + }, + "Obcina Ivancna Gorica": { + "lat": 45.9395841, + "lng": 14.8047626 + }, + "Zagorje ob Savi": { + "lat": 46.1345186, + "lng": 14.9945975 + }, + "Vuzenica": { + "lat": 46.5980836, + "lng": 15.1657237 + }, + "Horjul": { + "lat": 46.02253779999999, + "lng": 14.2986269 + }, + "Vrhnika": { + "lat": 45.966583, + "lng": 14.2973873 + }, + "Obcina Divaca": { + "lat": 45.6806069, + "lng": 13.9720312 + }, + "Vransko": { + "lat": 46.2444682, + "lng": 14.9511982 + }, + "Obcina Rence-Vogrsko": { + "lat": 45.8954617, + "lng": 13.6785673 + }, + "Vojnik": { + "lat": 46.2920581, + "lng": 15.302058 + }, + "Vodice": { + "lat": 46.1896643, + "lng": 14.493854 + }, + "Obcina Ajdovscina": { + "lat": 45.8870776, + "lng": 13.9042818 + }, + "Obcina Sveti Andraz v Slovenskih Goricah": { + "lat": 46.5189747, + "lng": 15.9498262 + }, + "Vitanje": { + "lat": 46.3815074, + "lng": 15.2950333 + }, + "Vipava": { + "lat": 45.8455744, + "lng": 13.9625431 + }, + "Obcina Smarjeske Toplice": { + "lat": 45.8849392, + "lng": 15.2499203 + }, + "Obcina Verzej": { + "lat": 46.5832778, + "lng": 16.1640173 + }, + "Obcina Sentilj": { + "lat": 46.6862839, + "lng": 15.7103566 + }, + "Trebnje": { + "lat": 45.9081708, + "lng": 15.0125985 + }, + "Obcina Velike Lasce": { + "lat": 45.8336591, + "lng": 14.6362363 + }, + "Velika Polana": { + "lat": 46.5731715, + "lng": 16.3444126 + }, + "Obcina Ormoz": { + "lat": 46.4353333, + "lng": 16.154374 + }, + "Grosuplje": { + "lat": 45.9557645, + "lng": 14.658899 + }, + "Mestna Obcina Novo mesto": { + "lat": 45.7869649, + "lng": 15.1992167 + }, + "Obcina Turnisce": { + "lat": 46.625456, + "lng": 16.3144961 + }, + "Sevnica": { + "lat": 46.0129508, + "lng": 15.2979868 + }, + "Trzin": { + "lat": 46.1314634, + "lng": 14.56125 + }, + "Obcina Trzic": { + "lat": 46.3593514, + "lng": 14.3006623 + }, + "Obcina Trnovska vas": { + "lat": 46.5201168, + "lng": 15.8866431 + }, + "Sentjur": { + "lat": 46.2157774, + "lng": 15.3945807 + }, + "Mokronog-Trebelno": { + "lat": 45.9088529, + "lng": 15.1596736 + }, + "Trbovlje": { + "lat": 46.1503563, + "lng": 15.0453138 + }, + "Dravograd": { + "lat": 46.589219, + "lng": 15.0246021 + }, + "Obcina Sveti Tomaz": { + "lat": 46.4835846, + "lng": 16.0801157 + }, + "Obcina Tolmin": { + "lat": 46.1857188, + "lng": 13.7319838 + }, + "Velenje": { + "lat": 46.3622743, + "lng": 15.1106582 + }, + "Celje": { + "lat": 46.23974949999999, + "lng": 15.2677063 + }, + "Tabor": { + "lat": 46.2107921, + "lng": 15.0174249 + }, + "Sveta Ana": { + "lat": 46.6483424, + "lng": 15.8425218 + }, + "Postojna": { + "lat": 45.7749704, + "lng": 14.2133245 + }, + "Obcina Zrece": { + "lat": 46.3696591, + "lng": 15.3918526 + }, + "Obcina Store": { + "lat": 46.2205619, + "lng": 15.3154297 + }, + "Obcina Starse": { + "lat": 46.4637374, + "lng": 15.7482186 + }, + "Obcina Loska Dolina": { + "lat": 45.6477908, + "lng": 14.4973147 + }, + "Obcina Crnomelj": { + "lat": 45.5361225, + "lng": 15.1944143 + }, + "Obcina Kocevje": { + "lat": 45.6409009, + "lng": 14.8633128 + }, + "Komen": { + "lat": 45.81752350000001, + "lng": 13.7482711 + }, + "Kamnik": { + "lat": 46.2221964, + "lng": 14.6072968 + }, + "Obcina Bovec": { + "lat": 46.3380495, + "lng": 13.5524174 + }, + "Obcina Sredisce ob Dravi": { + "lat": 46.4061386, + "lng": 16.2536567 + }, + "Gornja Radgona": { + "lat": 46.6767099, + "lng": 15.9910846 + }, + "Duplek": { + "lat": 46.5010016, + "lng": 15.7546307 + }, + "Obcina Domzale": { + "lat": 46.1438269, + "lng": 14.6375279 + }, + "Lenart": { + "lat": 46.5834424, + "lng": 15.8262124 + }, + "Idrija": { + "lat": 46.00294539999999, + "lng": 14.0278459 + }, + "Hajdina": { + "lat": 46.4185014, + "lng": 15.8244722 + }, + "Obcina Sostanj": { + "lat": 46.3782836, + "lng": 15.0461378 + }, + "Obcina Solcava": { + "lat": 46.4023526, + "lng": 14.6802304 + }, + "Obcina Sodrazica": { + "lat": 45.762125, + "lng": 14.6362158 + }, + "Medvode": { + "lat": 46.1413926, + "lng": 14.411347 + }, + "Slovenj Gradec": { + "lat": 46.5075851, + "lng": 15.0768162 + }, + "Obcina Smartno pri Litiji": { + "lat": 46.0457437, + "lng": 14.8410058 + }, + "Obcina Smartno ob Paki": { + "lat": 46.3416137, + "lng": 15.0277226 + }, + "Nazarje": { + "lat": 46.3179494, + "lng": 14.9470385 + }, + "Obcina Sezana": { + "lat": 45.7275109, + "lng": 13.8661931 + }, + "Obcina Smarje pri Jelsah": { + "lat": 46.2287025, + "lng": 15.5190353 + }, + "Slovenske Konjice": { + "lat": 46.3369191, + "lng": 15.4214708 + }, + "Obcina Hoce-Slivnica": { + "lat": 46.477858, + "lng": 15.6476005 + }, + "Ig": { + "lat": 45.95888679999999, + "lng": 14.5270528 + }, + "Obcina Skofljica": { + "lat": 45.9840962, + "lng": 14.5746626 + }, + "Škofja Loka": { + "lat": 46.1382331, + "lng": 14.148966 + }, + "Koper": { + "lat": 45.54805899999999, + "lng": 13.7301877 + }, + "Obcina Sentrupert": { + "lat": 45.9769612, + "lng": 15.0909889 + }, + "Obcina Sentjernej": { + "lat": 45.843413, + "lng": 15.3378312 + }, + "Obcina Sencur": { + "lat": 46.2433699, + "lng": 14.4192223 + }, + "Obcina Sempeter-Vrtojba": { + "lat": 45.9290095, + "lng": 13.6415594 + }, + "Nova Gorica": { + "lat": 45.9549755, + "lng": 13.6493044 + }, + "Obcina Semic": { + "lat": 45.6520534, + "lng": 15.1820701 + }, + "Piran": { + "lat": 45.528319, + "lng": 13.5682895 + }, + "Obcina Salovci": { + "lat": 46.8533568, + "lng": 16.2591791 + }, + "Obcina Ruse": { + "lat": 46.5206265, + "lng": 15.4817869 + }, + "Rogatec": { + "lat": 46.2258891, + "lng": 15.7000313 + }, + "Obcina Rogasovci": { + "lat": 46.8083883, + "lng": 16.0339763 + }, + "Obcina Rogaska Slatina": { + "lat": 46.2453973, + "lng": 15.6265014 + }, + "Obcina Kanal ob Soci": { + "lat": 46.067353, + "lng": 13.620335 + }, + "Ribnica na Pohorju": { + "lat": 46.5356403, + "lng": 15.2675136 + }, + "Ribnica": { + "lat": 45.7400303, + "lng": 14.7265782 + }, + "Pivka": { + "lat": 45.6825301, + "lng": 14.1960582 + }, + "Obcina Recica ob Savinji": { + "lat": 46.323379, + "lng": 14.922367 + }, + "Obcina Ravne na Koroskem": { + "lat": 46.5448323, + "lng": 14.9660445 + }, + "Cerknica": { + "lat": 45.7955099, + "lng": 14.3621843 + }, + "Obcina Luce": { + "lat": 46.35487209999999, + "lng": 14.7458015 + }, + "Radovljica": { + "lat": 46.3437361, + "lng": 14.1740042 + }, + "Radlje ob Dravi": { + "lat": 46.614358, + "lng": 15.2255472 + }, + "Radenci": { + "lat": 46.6439196, + "lng": 16.0402676 + }, + "Obcina Radece": { + "lat": 46.0666954, + "lng": 15.1820438 + }, + "Obcina Race-Fram": { + "lat": 46.4542083, + "lng": 15.6329467 + }, + "Puconci": { + "lat": 46.7046395, + "lng": 16.1572755 + }, + "Obcina Majsperk": { + "lat": 46.3503019, + "lng": 15.7340595 + }, + "Ptuj": { + "lat": 46.4199535, + "lng": 15.8696884 + }, + "Obcina Podcetrtek": { + "lat": 46.1739542, + "lng": 15.6013816 + }, + "Prevalje": { + "lat": 46.54687879999999, + "lng": 14.9197479 + }, + "Brezovica": { + "lat": 45.9559351, + "lng": 14.4349952 + }, + "Ilirska Bistrica": { + "lat": 45.570099, + "lng": 14.2418616 + }, + "Preddvor": { + "lat": 46.3017155, + "lng": 14.4216753 + }, + "Prebold": { + "lat": 46.2365972, + "lng": 15.091648 + }, + "Polzela": { + "lat": 46.280897, + "lng": 15.0737321 + }, + "Litija": { + "lat": 46.05923050000001, + "lng": 14.8266015 + }, + "Obcina Poljcane": { + "lat": 46.3141609, + "lng": 15.5784672 + }, + "Gorenja Vas-Poljane": { + "lat": 46.1116582, + "lng": 14.1149347 + }, + "Dobrova-Polhov Gradec": { + "lat": 46.0648896, + "lng": 14.3168195 + }, + "Podvelka": { + "lat": 46.5903517, + "lng": 15.3310343 + }, + "Kozje": { + "lat": 46.0733211, + "lng": 15.5596719 + }, + "Naklo": { + "lat": 46.2718663, + "lng": 14.3156932 + }, + "Podlehnik": { + "lat": 46.3358974, + "lng": 15.8787245 + }, + "Jesenice": { + "lat": 46.4367047, + "lng": 14.0526057 + }, + "Pesnica": { + "lat": 46.6373877, + "lng": 15.5543464 + }, + "Muta": { + "lat": 46.62720059999999, + "lng": 15.1348663 + }, + "Osilnica": { + "lat": 45.5291637, + "lng": 14.6984205 + }, + "Oplotnica": { + "lat": 46.387163, + "lng": 15.4458131 + }, + "Odranci": { + "lat": 46.5847279, + "lng": 16.2762351 + }, + "Murska Sobota": { + "lat": 46.6581381, + "lng": 16.1610293 + }, + "Mozirje": { + "lat": 46.3392772, + "lng": 14.9599807 + }, + "Moravske Toplice": { + "lat": 46.6856932, + "lng": 16.2224582 + }, + "Obcina Moravce": { + "lat": 46.1357218, + "lng": 14.7444352 + }, + "Kranjska Gora": { + "lat": 46.4845293, + "lng": 13.7857145 + }, + "Mislinja": { + "lat": 46.4439734, + "lng": 15.1985947 + }, + "Obcina Mirna Pec": { + "lat": 45.8481574, + "lng": 15.087945 + }, + "Mirna": { + "lat": 45.97311560728623, + "lng": 15.19467448424267 + }, + "Miren-Kostanjevica": { + "lat": 45.84360290000001, + "lng": 13.6276647 + }, + "Obcina Miklavz na Dravskem Polju": { + "lat": 46.4836246, + "lng": 15.7119067 + }, + "Obcina Mezica": { + "lat": 46.5214145, + "lng": 14.8523952 + }, + "Metlika": { + "lat": 45.6473994, + "lng": 15.3176752 + }, + "Obcina Menges": { + "lat": 46.16591220000001, + "lng": 14.5719694 + }, + "Hrpelje-Kozina": { + "lat": 45.60911919999999, + "lng": 13.9379148 + }, + "Maribor": { + "lat": 46.5546503, + "lng": 15.6458812 + }, + "Makole": { + "lat": 46.3173644, + "lng": 15.6670441 + }, + "Lukovica": { + "lat": 46.1696293, + "lng": 14.6907259 + }, + "Lovrenc na Pohorju": { + "lat": 46.54027749999999, + "lng": 15.3884817 + }, + "Obcina Loski Potok": { + "lat": 45.6909637, + "lng": 14.5985971 + }, + "Log–Dragomer": { + "lat": 46.0178747, + "lng": 14.3687767 + }, + "Ljutomer": { + "lat": 46.5193583, + "lng": 16.197814 + }, + "Ljubljana": { + "lat": 46.0569465, + "lng": 14.5057515 + }, + "Lendava": { + "lat": 46.5644783, + "lng": 16.453063 + }, + "Kuzma": { + "lat": 46.8374998, + "lng": 16.0993411 + }, + "Obcina Krizevci": { + "lat": 46.5701821, + "lng": 16.1092653 + }, + "Kostanjevica na Krki": { + "lat": 45.8449724, + "lng": 15.4217164 + }, + "Kobilje": { + "lat": 46.68518, + "lng": 16.3936719 + }, + "Obcina Kobarid": { + "lat": 46.2476549, + "lng": 13.5791749 + }, + "Obcina Kidricevo": { + "lat": 46.4050135, + "lng": 15.7947234 + }, + "Obcina Jursinci": { + "lat": 46.4850899, + "lng": 15.9714564 + }, + "Sveti Jurij v Slovenskih Goricah": { + "lat": 46.6170791, + "lng": 15.7804677 + }, + "Obcina Sveti Jurij ob Scavnici": { + "lat": 46.5687452, + "lng": 16.0222528 + }, + "Izola": { + "lat": 45.5374048, + "lng": 13.6600802 + }, + "Hrastnik": { + "lat": 46.1417288, + "lng": 15.0844894 + }, + "Logatec": { + "lat": 45.917611, + "lng": 14.2351451 + }, + "Hodos": { + "lat": 46.8314134, + "lng": 16.321068 + }, + "Grad": { + "lat": 46.808732, + "lng": 16.109206 + }, + "Gornji Petrovci": { + "lat": 46.8047535, + "lng": 16.2180722 + }, + "Gornji Grad": { + "lat": 46.2961712, + "lng": 14.8062347 + }, + "Obcina Gorisnica": { + "lat": 46.4120271, + "lng": 16.0133089 + }, + "Obcina Straza": { + "lat": 45.7768428, + "lng": 15.0948694 + }, + "Obcina Braslovce": { + "lat": 46.2836192, + "lng": 15.0418321 + }, + "Obcina Brezice": { + "lat": 45.9041096, + "lng": 15.5943639 + }, + "Obcina Tisina": { + "lat": 46.6541884, + "lng": 16.0754781 + }, + "Dol pri Ljubljani": { + "lat": 46.0884386, + "lng": 14.6424792 + }, + "Dolenjske Toplice": { + "lat": 45.7558584, + "lng": 15.0592333 + }, + "Brda": { + "lat": 45.9975652, + "lng": 13.5270474 + }, + "Dobrovnik": { + "lat": 46.6516758, + "lng": 16.3429113 + }, + "Dobrna": { + "lat": 46.3356141, + "lng": 15.2259732 + }, + "Dobje": { + "lat": 46.1370037, + "lng": 15.394129 + }, + "Destrnik": { + "lat": 46.4922368, + "lng": 15.8777956 + }, + "Obcina Crna na Koroskem": { + "lat": 46.4704529, + "lng": 14.8499998 + }, + "Obcina Crensovci": { + "lat": 46.5688644, + "lng": 16.2941962 + }, + "Cerkvenjak": { + "lat": 46.5670711, + "lng": 15.9429753 + }, + "Cerklje na Gorenjskem": { + "lat": 46.2531292, + "lng": 14.4868829 + }, + "Cankova": { + "lat": 46.71823699999999, + "lng": 16.0197222 + }, + "Borovnica": { + "lat": 45.9193034, + "lng": 14.3640682 + }, + "Bohinj": { + "lat": 46.2715959, + "lng": 13.9563992 + }, + "Obcina Bled": { + "lat": 46.3511373, + "lng": 14.0859017 + }, + "Bistrica ob Sotli": { + "lat": 46.0565579, + "lng": 15.6625947 + }, + "Benedikt": { + "lat": 46.6075732, + "lng": 15.8896942 + }, + "Beltinci": { + "lat": 46.60791529999999, + "lng": 16.2365127 + }, + "Obcina Apace": { + "lat": 46.6974679, + "lng": 15.9102534 + }, + "Obcina Razkrizje": { + "lat": 46.5226339, + "lng": 16.2668638 + }, + "Cerkno": { + "lat": 46.128954, + "lng": 13.9891931 + }, + "Dobrepolje": { + "lat": 45.8524951, + "lng": 14.7083109 + }, + "Ankaran": { + "lat": 45.57914359999999, + "lng": 13.7362855 + }, + "Selnica ob Dravi": { + "lat": 46.5513918, + "lng": 15.492941 + }, + "Cirkulane": { + "lat": 46.3443632, + "lng": 15.9951112 + }, + "Komenda": { + "lat": 46.2052815, + "lng": 14.5391653 + }, + "Kostel": { + "lat": 45.5083472, + "lng": 14.9087946 + }, + "Obcina Skocjan": { + "lat": 45.9072257, + "lng": 15.292258 + }, + "": { + "lat": 46.151241, + "lng": 14.995463 + } + }, + "BB": { + "Christ Church": { + "lat": 13.0728209, + "lng": -59.5261392 + }, + "Saint Andrew": { + "lat": 13.2462565, + "lng": -59.5651284 + }, + "Saint James": { + "lat": 13.1842181, + "lng": -59.6304714 + }, + "Saint John": { + "lat": 13.1824334, + "lng": -59.5033496 + }, + "Saint Philip": { + "lat": 13.1256336, + "lng": -59.456055 + }, + "Saint Peter": { + "lat": 13.2600459, + "lng": -59.621862 + }, + "Saint Michael": { + "lat": 13.1132219, + "lng": -59.59880889999999 + }, + "Saint George": { + "lat": 13.1401378, + "lng": -59.54611249999999 + }, + "Saint Thomas": { + "lat": 13.1740547, + "lng": -59.5827556 + }, + "Saint Joseph": { + "lat": 13.2005084, + "lng": -59.53932349999999 + }, + "": { + "lat": 13.193887, + "lng": -59.543198 + } + }, + "CV": { + "Brava": { + "lat": 14.8444438, + "lng": -24.7024179 + }, + "Maio": { + "lat": 15.2003098, + "lng": -23.1679793 + }, + "Ribeira Brava": { + "lat": 16.616129, + "lng": -24.2969999 + }, + "Tarrafal": { + "lat": 15.2760578, + "lng": -23.7484077 + }, + "Ribeira Grande": { + "lat": 17.1820718, + "lng": -25.0658748 + }, + "Tarrafal de São Nicolau": { + "lat": 16.5636498, + "lng": -24.354942 + }, + "São Filipe": { + "lat": 14.8951679, + "lng": -24.4945636 + }, + "São Domingos": { + "lat": 15.0286165, + "lng": -23.563922 + }, + "Sal": { + "lat": 16.7266152, + "lng": -22.9297109 + }, + "Santa Cruz": { + "lat": 15.1027292, + "lng": -23.5609459 + }, + "Boa Vista": { + "lat": 16.0950108, + "lng": -22.8078335 + }, + "Praia": { + "lat": 14.93305, + "lng": -23.5133267 + }, + "Porto Novo": { + "lat": 17.0215176, + "lng": -25.0673575 + }, + "Mosteiros": { + "lat": 15.0311753, + "lng": -24.3252291 + }, + "Paul": { + "lat": 17.1005359, + "lng": -24.9872821 + }, + "São Salvador do Mundo": { + "lat": 15.088764, + "lng": -23.6383187 + }, + "São Lourenço dos Órgãos": { + "lat": 15.0537841, + "lng": -23.6085612 + }, + "São Vicente": { + "lat": 16.8341271, + "lng": -24.9279547 + }, + "São Miguel": { + "lat": 15.1919609, + "lng": -23.6442701 + }, + "Santa Catarina do Fogo": { + "lat": 14.9309104, + "lng": -24.3222577 + }, + "Ribeira Grande de Santiago": { + "lat": 14.9830298, + "lng": -23.6561725 + }, + "Santa Catarina": { + "lat": 15.0992532, + "lng": -23.6918783 + }, + "": { + "lat": 16.002082, + "lng": -24.013197 + } + }, + "GY": { + "Essequibo Islands-West Demerara Region": { + "lat": 6.5720132, + "lng": -58.4629997 + }, + "East Berbice-Corentyne Region": { + "lat": 2.7477922, + "lng": -57.4627259 + }, + "Mahaica-Berbice Region": { + "lat": 6.238496, + "lng": -57.9162555 + }, + "Pomeroon-Supenaam Region": { + "lat": 7.1294166, + "lng": -58.9206295 + }, + "Potaro-Siparuni Region": { + "lat": 4.7855853, + "lng": -59.28799770000001 + }, + "Demerara-Mahaica Region": { + "lat": 6.546425999999999, + "lng": -58.0982046 + }, + "Barima-Waini Region": { + "lat": 7.488241899999999, + "lng": -59.6564494 + }, + "Upper Demerara-Berbice Region": { + "lat": 5.3064879, + "lng": -58.18929209999999 + }, + "Upper Takutu-Upper Essequibo Region": { + "lat": 2.9239595, + "lng": -58.73736339999999 + }, + "Cuyuni-Mazaruni Region": { + "lat": 6.4642141, + "lng": -60.21107519999999 + }, + "": { + "lat": 4.860416, + "lng": -58.93018 + } + }, + "SR": { + "Distrikt Wanica": { + "lat": 5.7323762, + "lng": -55.2701235 + }, + "Distrikt Nickerie": { + "lat": 5.5855469, + "lng": -56.83111169999999 + }, + "Distrikt Coronie": { + "lat": 5.6943271, + "lng": -56.2929381 + }, + "Distrikt Para": { + "lat": 5.481731799999999, + "lng": -55.2259207 + }, + "Distrikt Paramaribo": { + "lat": 5.848920499999999, + "lng": -55.1596592 + }, + "Distrikt Commewijne": { + "lat": 5.740211, + "lng": -54.8731219 + }, + "Distrikt Marowijne": { + "lat": 5.6268128, + "lng": -54.25931180000001 + }, + "Distrikt Saramacca": { + "lat": 5.7240813, + "lng": -55.6689636 + }, + "Distrikt Brokopondo": { + "lat": 4.7710247, + "lng": -55.0493375 + }, + "Distrikt Sipaliwini": { + "lat": 3.6567382, + "lng": -56.2035387 + }, + "": { + "lat": 3.919305, + "lng": -56.027783 + } + }, + "BR": { + "Paraíba": { + "lat": -7.239960900000001, + "lng": -36.7819505 + }, + "Piaui": { + "lat": -8.322948799999999, + "lng": -43.1747162 + }, + "Pernambuco": { + "lat": -8.8137173, + "lng": -36.954107 + }, + "Tocantins": { + "lat": -11.4098737, + "lng": -48.71914229999999 + }, + "Maranhao": { + "lat": -5.080419, + "lng": -45.6007108 + }, + "Para": { + "lat": -6.207102, + "lng": -52.70279559999999 + }, + "Amapa": { + "lat": 1.4441146, + "lng": -52.0215415 + }, + "Ceara": { + "lat": -5.4983977, + "lng": -39.3206241 + }, + "Alagoas": { + "lat": -9.5713058, + "lng": -36.7819505 + }, + "Amazonas": { + "lat": -3.4168427, + "lng": -65.8560646 + }, + "Rio Grande do Norte": { + "lat": -5.402580299999999, + "lng": -36.954107 + }, + "Bahia": { + "lat": -11.4098737, + "lng": -41.2808577 + }, + "Sergipe": { + "lat": -10.6738878, + "lng": -37.4681396 + }, + "Roraima": { + "lat": 1.5957682, + "lng": -60.58206759999999 + }, + "Santa Catarina": { + "lat": -26.928572, + "lng": -49.36531489999999 + }, + "Sao Paulo": { + "lat": -23.5557714, + "lng": -46.6395571 + }, + "Parana": { + "lat": -25.2520888, + "lng": -52.0215415 + }, + "Rio de Janeiro": { + "lat": -22.9068467, + "lng": -43.1728965 + }, + "Minas Gerais": { + "lat": -17.930178, + "lng": -43.7908453 + }, + "Espirito Santo": { + "lat": -19.1834229, + "lng": -40.3088626 + }, + "Rio Grande do Sul": { + "lat": -29.3646459, + "lng": -51.6657692 + }, + "Goias": { + "lat": -15.7050424, + "lng": -49.36531489999999 + }, + "Mato Grosso": { + "lat": -12.6818712, + "lng": -56.921099 + }, + "Mato Grosso do Sul": { + "lat": -20.7722295, + "lng": -54.7851531 + }, + "Federal District": { + "lat": -15.826691, + "lng": -47.92182039999999 + }, + "Acre": { + "lat": -9.0237964, + "lng": -70.81199529999999 + }, + "Rondonia": { + "lat": -11.5057341, + "lng": -63.580611 + }, + "": { + "lat": -14.235004, + "lng": -51.92528 + } + }, + "GL": { + "Avannaata": { + "lat": 75.9645208, + "lng": -53.3651186 + }, + "Qeqqata": { + "lat": 66.1763879, + "lng": -48.9906533 + }, + "Qeqertalik": { + "lat": 68.2055932, + "lng": -46.4348782 + }, + "Kujalleq": { + "lat": 61.3850674, + "lng": -44.4319034 + }, + "Sermersooq": { + "lat": 68.09179809999999, + "lng": -39.2845123 + }, + "": { + "lat": 71.706936, + "lng": -42.604303 + } + }, + "PM": { + "Commune de Saint-Pierre": { + "lat": 46.7809057, + "lng": -56.1720053 + }, + "Miquelon-Langlade": { + "lat": 46.8454114, + "lng": -56.30752810000001 + }, + "": { + "lat": 46.941936, + "lng": -56.27111 + } + }, + "AR": { + "Buenos Aires": { + "lat": -34.6036844, + "lng": -58.3815591 + }, + "Misiones": { + "lat": -26.9377146, + "lng": -54.4342138 + }, + "Buenos Aires F.D.": { + "lat": -34.6036844, + "lng": -58.3815591 + }, + "Entre Rios": { + "lat": -32.5175643, + "lng": -59.1041758 + }, + "Santa Fe": { + "lat": -31.6106578, + "lng": -60.697294 + }, + "Formosa": { + "lat": -26.1857768, + "lng": -58.1755669 + }, + "Corrientes": { + "lat": -27.4692131, + "lng": -58.8306349 + }, + "Chaco": { + "lat": -26.5857656, + "lng": -60.9540073 + }, + "Neuquen": { + "lat": -38.9516784, + "lng": -68.0591888 + }, + "Jujuy": { + "lat": -24.1857864, + "lng": -65.2994767 + }, + "Tucuman": { + "lat": -26.8082848, + "lng": -65.2175903 + }, + "La Pampa": { + "lat": -37.8956594, + "lng": -65.0957792 + }, + "Cordoba": { + "lat": -31.42008329999999, + "lng": -64.1887761 + }, + "Mendoza": { + "lat": -32.8894587, + "lng": -68.8458386 + }, + "La Rioja": { + "lat": -29.4134538, + "lng": -66.8564579 + }, + "Santiago del Estero": { + "lat": -27.7833574, + "lng": -64.264167 + }, + "Rio Negro": { + "lat": -40.00988417743898, + "lng": -65.39272061835581 + }, + "San Juan": { + "lat": -31.5351074, + "lng": -68.5385941 + }, + "Santa Cruz": { + "lat": -48.7736825, + "lng": -69.1917167 + }, + "Salta": { + "lat": -24.7821269, + "lng": -65.4231976 + }, + "Tierra del Fuego": { + "lat": -54.3083548, + "lng": -67.7451565 + }, + "Chubut": { + "lat": -43.6846192, + "lng": -69.2745537 + }, + "Catamarca": { + "lat": -28.469581, + "lng": -65.7795441 + }, + "San Luis": { + "lat": -33.3017267, + "lng": -66.3377522 + }, + "": { + "lat": -38.416097, + "lng": -63.616672 + } + }, + "PY": { + "Departamento de Caazapa": { + "lat": -26.2260696, + "lng": -56.0249982 + }, + "Departamento Central": { + "lat": -25.5628115, + "lng": -57.5079912 + }, + "Departamento de Canindeyu": { + "lat": -24.1378735, + "lng": -55.6689636 + }, + "Departamento del Guaira": { + "lat": -25.8810932, + "lng": -56.2929381 + }, + "Departamento de Neembucu": { + "lat": -27.0299114, + "lng": -57.825395 + }, + "Departamento de Presidente Hayes": { + "lat": -23.3512605, + "lng": -58.73736339999999 + }, + "Departamento del Amambay": { + "lat": -22.5590272, + "lng": -56.0249982 + }, + "Departamento de Misiones": { + "lat": -26.8433512, + "lng": -57.10131879999999 + }, + "Departamento del Alto Parana": { + "lat": -25.6075546, + "lng": -54.9611836 + }, + "Departamento de San Pedro": { + "lat": -24.1948668, + "lng": -56.56164700000001 + }, + "Departamento de la Cordillera": { + "lat": -25.2289491, + "lng": -57.0111681 + }, + "Departamento de Paraguari": { + "lat": -26.174027, + "lng": -57.10131879999999 + }, + "Departamento de Itapua": { + "lat": -26.7923623, + "lng": -55.6689636 + }, + "Departamento de Boqueron": { + "lat": -21.7449254, + "lng": -60.9540073 + }, + "Departamento de Caaguazu": { + "lat": -25.2281911, + "lng": -56.0249982 + }, + "Departamento de Alto Paraguay": { + "lat": -20.0852508, + "lng": -59.4720904 + }, + "Departamento de Concepcion": { + "lat": -22.811926, + "lng": -57.10131879999999 + }, + "Asuncion": { + "lat": -25.2637399, + "lng": -57.57592599999999 + }, + "": { + "lat": -23.442503, + "lng": -58.443832 + } + }, + "UY": { + "Durazno Department": { + "lat": -33.0232454, + "lng": -56.0284644 + }, + "Rivera Department": { + "lat": -31.4817421, + "lng": -55.2435759 + }, + "Treinta y Tres Department": { + "lat": -33.0685086, + "lng": -54.2858627 + }, + "Florida": { + "lat": -34.0944617, + "lng": -56.2185912 + }, + "Cerro Largo": { + "lat": -32.4411032, + "lng": -54.35217530000001 + }, + "Montevideo Department": { + "lat": -34.8181587, + "lng": -56.2138256 + }, + "Flores Department": { + "lat": -33.5733753, + "lng": -56.8945028 + }, + "Canelones": { + "lat": -34.5246844, + "lng": -56.28121040000001 + }, + "Colonia": { + "lat": -34.1294678, + "lng": -57.66051840000001 + }, + "Tacuarembó Department": { + "lat": -32.1082207, + "lng": -55.77085779999999 + }, + "Soriano": { + "lat": -33.5102792, + "lng": -57.7498103 + }, + "Lavalleja": { + "lat": -33.9226175, + "lng": -54.9765794 + }, + "San José Department": { + "lat": -34.3086518, + "lng": -56.72563659999999 + }, + "Maldonado": { + "lat": -34.9027462, + "lng": -54.9491154 + }, + "Salto Department": { + "lat": -31.3275473, + "lng": -57.0174129 + }, + "Rocha Department": { + "lat": -33.9690081, + "lng": -54.021485 + }, + "Paysandú Department": { + "lat": -32.0667366, + "lng": -57.3364789 + }, + "Río Negro Department": { + "lat": -32.7676356, + "lng": -57.4295207 + }, + "Artigas": { + "lat": -30.4043287, + "lng": -56.4692371 + }, + "": { + "lat": -32.522779, + "lng": -55.765835 + } + }, + "VE": { + "Nueva Esparta": { + "lat": 10.9970723, + "lng": -63.91132959999999 + }, + "Distrito Federal": { + "lat": 10.5004352, + "lng": -66.9511459 + }, + "Carabobo": { + "lat": 10.1176433, + "lng": -68.0477509 + }, + "Anzoátegui": { + "lat": 8.5913073, + "lng": -63.95861110000001 + }, + "Barinas": { + "lat": 8.6231498, + "lng": -70.2371045 + }, + "Zulia": { + "lat": 10.2910237, + "lng": -72.1416132 + }, + "Yaracuy": { + "lat": 10.339389, + "lng": -68.81088489999999 + }, + "Aragua": { + "lat": 10.0635758, + "lng": -67.2847875 + }, + "Portuguesa": { + "lat": 9.094399899999999, + "lng": -69.097023 + }, + "Guárico": { + "lat": 8.7489309, + "lng": -66.2367172 + }, + "Bolívar": { + "lat": 6.3585216, + "lng": -63.580611 + }, + "Estado Trujillo": { + "lat": 9.4302528, + "lng": -70.52649339999999 + }, + "Delta Amacuro": { + "lat": 8.8499307, + "lng": -61.14031960000001 + }, + "Falcón": { + "lat": 11.1810674, + "lng": -69.8597406 + }, + "Cojedes": { + "lat": 9.3816682, + "lng": -68.3339275 + }, + "Táchira": { + "lat": 7.9137001, + "lng": -72.1416132 + }, + "Lara": { + "lat": 10.1537842, + "lng": -69.8597406 + }, + "Sucre": { + "lat": 10.4055886, + "lng": -63.297505 + }, + "Miranda": { + "lat": 10.2509303, + "lng": -66.4271499 + }, + "Amazonas": { + "lat": 2.8101413, + "lng": -65.0957792 + }, + "Monagas": { + "lat": 9.3241652, + "lng": -63.0147578 + }, + "Mérida": { + "lat": 8.5698244, + "lng": -71.1804988 + }, + "Vargas": { + "lat": 10.5890015, + "lng": -66.7367345 + }, + "Dependencias Federales": { + "lat": 10.9377053, + "lng": -65.35695729999999 + }, + "Apure": { + "lat": 6.926948299999999, + "lng": -68.52471489999999 + }, + "": { + "lat": 6.42375, + "lng": -66.58973 + } + }, + "MX": { + "Tamaulipas": { + "lat": 24.26694, + "lng": -98.8362755 + }, + "San Luis Potosí": { + "lat": 22.1564699, + "lng": -100.9855409 + }, + "México": { + "lat": 23.634501, + "lng": -102.552784 + }, + "Guerrero": { + "lat": 17.4391926, + "lng": -99.54509739999999 + }, + "Veracruz": { + "lat": 19.173773, + "lng": -96.1342241 + }, + "Hidalgo": { + "lat": 20.0910963, + "lng": -98.7623874 + }, + "Puebla": { + "lat": 19.0414398, + "lng": -98.2062727 + }, + "Morelos": { + "lat": 18.6813049, + "lng": -99.1013498 + }, + "Oaxaca": { + "lat": 17.0731842, + "lng": -96.7265889 + }, + "Yucatán": { + "lat": 20.7098786, + "lng": -89.0943377 + }, + "Chiapas": { + "lat": 16.7569318, + "lng": -93.12923529999999 + }, + "Mexico City": { + "lat": 19.4326077, + "lng": -99.133208 + }, + "Tlaxcala": { + "lat": 19.318154, + "lng": -98.2374954 + }, + "Tabasco": { + "lat": 17.8409173, + "lng": -92.6189273 + }, + "Quintana Roo": { + "lat": 19.1817393, + "lng": -88.4791376 + }, + "Querétaro": { + "lat": 20.5887932, + "lng": -100.3898881 + }, + "Campeche": { + "lat": 19.8301251, + "lng": -90.5349087 + }, + "Nuevo León": { + "lat": 25.592172, + "lng": -99.99619469999999 + }, + "Sonora": { + "lat": 29.2972247, + "lng": -110.3308814 + }, + "Zacatecas": { + "lat": 22.7708555, + "lng": -102.5832426 + }, + "Chihuahua": { + "lat": 28.6333754, + "lng": -106.0697945 + }, + "Sinaloa": { + "lat": 25.8226854, + "lng": -108.2216704 + }, + "Jalisco": { + "lat": 20.6595382, + "lng": -103.3494376 + }, + "Durango": { + "lat": 24.0277202, + "lng": -104.6531759 + }, + "Baja California": { + "lat": 30.8406338, + "lng": -115.2837585 + }, + "Michoacán": { + "lat": 19.5665192, + "lng": -101.7068294 + }, + "Nayarit": { + "lat": 21.7513844, + "lng": -104.8454619 + }, + "Guanajuato": { + "lat": 20.9170187, + "lng": -101.1617356 + }, + "Coahuila": { + "lat": 27.058676, + "lng": -101.7068294 + }, + "Colima": { + "lat": 19.2452342, + "lng": -103.7240868 + }, + "Baja California Sur": { + "lat": 26.0444446, + "lng": -111.6660725 + }, + "Aguascalientes": { + "lat": 21.8852562, + "lng": -102.2915677 + }, + "": { + "lat": 23.634501, + "lng": -102.552784 + } + }, + "JM": { + "Saint Thomas": { + "lat": 17.9700261, + "lng": -76.4331698 + }, + "Trelawny": { + "lat": 18.3526143, + "lng": -77.6077865 + }, + "Saint Andrew": { + "lat": 18.0391345, + "lng": -76.7567368 + }, + "Saint Elizabeth": { + "lat": 18.0788461, + "lng": -77.69941969999999 + }, + "Parish of Saint Ann": { + "lat": 18.3281428, + "lng": -77.2405153 + }, + "Saint Catherine": { + "lat": 18.0364134, + "lng": -77.0564464 + }, + "Clarendon": { + "lat": 17.9557183, + "lng": -77.2405153 + }, + "Saint Mary": { + "lat": 18.3092711, + "lng": -76.964306 + }, + "Portland": { + "lat": 18.0844274, + "lng": -76.4100267 + }, + "Westmoreland": { + "lat": 18.2944378, + "lng": -78.1564432 + }, + "Saint James": { + "lat": 18.3923184, + "lng": -77.85959629999999 + }, + "Manchester": { + "lat": 18.0669654, + "lng": -77.5160788 + }, + "Hanover": { + "lat": 18.4097707, + "lng": -78.13363799999999 + }, + "Kingston": { + "lat": 18.0178743, + "lng": -76.8099041 + }, + "": { + "lat": 18.109581, + "lng": -77.297508 + } + }, + "DO": { + "Provincia de Monte Plata": { + "lat": 18.8080878, + "lng": -69.7869146 + }, + "Provincia de Hermanas Mirabal": { + "lat": 19.3747559, + "lng": -70.35132349999999 + }, + "Provincia de Baoruco": { + "lat": 18.4879898, + "lng": -71.4182249 + }, + "Provincia de Santiago": { + "lat": 19.45409, + "lng": -70.6922025 + }, + "Provincia de San Cristobal": { + "lat": 18.4615358, + "lng": -70.1217652 + }, + "Provincia Espaillat": { + "lat": 19.6277658, + "lng": -70.2786775 + }, + "Provincia de La Altagracia": { + "lat": 18.5850236, + "lng": -68.62010719999999 + }, + "Provincia de Dajabon": { + "lat": 19.4196997, + "lng": -71.6144554 + }, + "Puerto Plata": { + "lat": 19.7807686, + "lng": -70.6871091 + }, + "Nacional": { + "lat": 18.735693, + "lng": -70.162651 + }, + "Provincia de El Seibo": { + "lat": 18.7658496, + "lng": -69.040668 + }, + "Provincia de Barahona": { + "lat": 18.2139066, + "lng": -71.1043759 + }, + "Samaná": { + "lat": 19.2030757, + "lng": -69.3387664 + }, + "Provincia de San Pedro de Macoris": { + "lat": 18.4685065, + "lng": -69.2973372 + }, + "Provincia de San Juan": { + "lat": 18.8073919, + "lng": -71.2293466 + }, + "Provincia de San Jose de Ocoa": { + "lat": 18.5465792, + "lng": -70.50774899999999 + }, + "Provincia de Santo Domingo": { + "lat": 18.5104253, + "lng": -69.8404054 + }, + "Provincia Duarte": { + "lat": 19.2090823, + "lng": -70.02700039999999 + }, + "Provincia de Monte Cristi": { + "lat": 19.7396899, + "lng": -71.44339839999999 + }, + "Provincia de Santiago Rodriguez": { + "lat": 19.4786174, + "lng": -71.3398271 + }, + "Provincia Maria Trinidad Sanchez": { + "lat": 19.3734597, + "lng": -69.85144389999999 + }, + "Provincia de Elias Pina": { + "lat": 19.0524685, + "lng": -71.6198686 + }, + "Provincia de Pedernales": { + "lat": 17.8537626, + "lng": -71.3303209 + }, + "Provincia de Azua": { + "lat": 18.4552709, + "lng": -70.7380928 + }, + "Provincia de Valverde": { + "lat": 19.5881221, + "lng": -70.98033099999999 + }, + "Provincia de Monsenor Nouel": { + "lat": 18.9215234, + "lng": -70.3836815 + }, + "Provincia de Peravia": { + "lat": 18.2786594, + "lng": -70.33358869999999 + }, + "Provincia de La Vega": { + "lat": 19.2211554, + "lng": -70.5288753 + }, + "Provincia de Independencia": { + "lat": 18.3785651, + "lng": -71.5232874 + }, + "Provincia de La Romana": { + "lat": 18.4310271, + "lng": -68.98373730000002 + }, + "Provincia Sanchez Ramirez": { + "lat": 19.052706, + "lng": -70.1492264 + }, + "Provincia de Hato Mayor": { + "lat": 18.7635799, + "lng": -69.2557637 + }, + "": { + "lat": 18.735693, + "lng": -70.162651 + } + }, + "BQ": { + "Saba": { + "lat": 17.6354642, + "lng": -63.2326763 + }, + "Sint Eustatius": { + "lat": 17.4890306, + "lng": -62.973555 + }, + "Bonaire": { + "lat": 12.2018902, + "lng": -68.26238219999999 + }, + "": { + "lat": 14.8583535, + "lng": -67.152135 + } + }, + "CU": { + "Matanzas Province": { + "lat": 22.5767123, + "lng": -81.3399414 + }, + "Artemisa": { + "lat": 22.8159074, + "lng": -82.7589859 + }, + "Provincia de Santiago de Cuba": { + "lat": 20.2246196, + "lng": -75.8069082 + }, + "Havana": { + "lat": 23.1135925, + "lng": -82.3665956 + }, + "Las Tunas": { + "lat": 20.957938, + "lng": -76.9527836 + }, + "Provincia de Pinar del Rio": { + "lat": 22.4076256, + "lng": -83.8473015 + }, + "Holguín Province": { + "lat": 20.7992817, + "lng": -75.9927652 + }, + "Guantánamo Province": { + "lat": 20.1455917, + "lng": -74.8741045 + }, + "Cienfuegos Province": { + "lat": 22.2587327, + "lng": -80.7214417 + }, + "Provincia de Camagueey": { + "lat": 21.2167247, + "lng": -77.7452081 + }, + "Granma Province": { + "lat": 20.3844902, + "lng": -76.64127119999999 + }, + "Ciego de Ávila Province": { + "lat": 21.9329515, + "lng": -78.5660852 + }, + "": { + "lat": 21.521757, + "lng": -77.781167 + } + }, + "BS": { + "West Grand Bahama District": { + "lat": 26.659447, + "lng": -78.52065 + }, + "North Eleuthera": { + "lat": 25.4647517, + "lng": -76.675922 + }, + "South Eleuthera": { + "lat": 24.7708562, + "lng": -76.2131474 + }, + "East Grand Bahama District": { + "lat": 26.6582823, + "lng": -78.2248291 + }, + "Spanish Wells District": { + "lat": 25.5461164, + "lng": -76.7711626 + }, + "New Providence District": { + "lat": 25.0443256, + "lng": -77.3503559 + }, + "Inagua": { + "lat": 21.0656066, + "lng": -73.323708 + }, + "Central Abaco District": { + "lat": 26.3555029, + "lng": -77.1485163 + }, + "City of Freeport District": { + "lat": 26.5333159, + "lng": -78.6429019 + }, + "Harbour Island": { + "lat": 25.50011, + "lng": -76.6340511 + }, + "North Abaco District": { + "lat": 26.7871697, + "lng": -77.4357739 + }, + "San Salvador District": { + "lat": 24.0775546, + "lng": -74.4760088 + }, + "Long Island": { + "lat": 23.1764239, + "lng": -75.0961549 + }, + "Berry Islands District": { + "lat": 25.7236234, + "lng": -77.8310104 + }, + "Central Andros District": { + "lat": 24.4688482, + "lng": -77.973865 + }, + "Black Point District": { + "lat": 24.0861912, + "lng": -76.3955603 + }, + "North Andros District": { + "lat": 24.7063805, + "lng": -78.0195387 + }, + "Grand Cay District": { + "lat": 27.2162615, + "lng": -78.3230559 + }, + "": { + "lat": 25.03428, + "lng": -77.39628 + } + }, + "BM": { + "Hamilton": { + "lat": 32.2950716, + "lng": -64.7842417 + }, + "Southampton Parish": { + "lat": 32.2540095, + "lng": -64.8259058 + }, + "Sandys Parish": { + "lat": 32.2999528, + "lng": -64.8674103 + }, + "Saint George": { + "lat": 32.3811588, + "lng": -64.6821494 + }, + "Pembroke Parish": { + "lat": 32.3007672, + "lng": -64.796263 + }, + "Paget": { + "lat": 32.281074, + "lng": -64.7784787 + }, + "Saint George's Parish": { + "lat": 32.3668615, + "lng": -64.68364609999999 + }, + "Hamilton city": { + "lat": 32.2950716, + "lng": -64.7842417 + }, + "Devonshire Parish": { + "lat": 32.3038062, + "lng": -64.7606954 + }, + "Smith's Parish": { + "lat": 32.3133966, + "lng": -64.7310588 + }, + "": { + "lat": 32.321384, + "lng": -64.75737 + } + }, + "TT": { + "Port of Spain": { + "lat": 10.6603196, + "lng": -61.5085625 + }, + "Princes Town": { + "lat": 10.2659505, + "lng": -61.37643809999999 + }, + "Tunapuna/Piarco": { + "lat": 10.6859096, + "lng": -61.30352480000001 + }, + "Sangre Grande": { + "lat": 10.5852939, + "lng": -61.1315813 + }, + "Tobago": { + "lat": 11.2336911, + "lng": -60.69889089999999 + }, + "Siparia": { + "lat": 10.0910494, + "lng": -61.5252827 + }, + "San Juan/Laventille": { + "lat": 10.6908578, + "lng": -61.4552213 + }, + "San Fernando": { + "lat": 10.2819954, + "lng": -61.4668769 + }, + "Mayaro": { + "lat": 10.2803437, + "lng": -61.0296696 + }, + "Couva-Tabaquite-Talparo": { + "lat": 10.4297145, + "lng": -61.373521 + }, + "Point Fortin": { + "lat": 10.1702737, + "lng": -61.67133860000001 + }, + "Diego Martin": { + "lat": 10.7362286, + "lng": -61.5544836 + }, + "Penal/Debe": { + "lat": 10.1337402, + "lng": -61.44354740000001 + }, + "Chaguanas": { + "lat": 10.5168387, + "lng": -61.4114482 + }, + "Borough of Arima": { + "lat": 10.6273078, + "lng": -61.2743688 + }, + "": { + "lat": 10.691803, + "lng": -61.222503 + } + }, + "KN": { + "Trinity Palmetto Point": { + "lat": 17.3063519, + "lng": -62.7617837 + }, + "Saint Anne Sandy Point": { + "lat": 17.3725333, + "lng": -62.8441133 + }, + "Middle Island": { + "lat": 17.3348813, + "lng": -62.80882510000001 + }, + "Christ Church Nichola Town": { + "lat": 17.3604812, + "lng": -62.7617837 + }, + "Saint Paul Capesterre": { + "lat": 17.4016683, + "lng": -62.82573319999999 + }, + "Saint James Windward": { + "lat": 17.1769633, + "lng": -62.57960259999999 + }, + "Saint Peter Basseterre": { + "lat": 17.3102911, + "lng": -62.7147533 + }, + "Saint George Gingerland": { + "lat": 17.1257759, + "lng": -62.5619811 + }, + "Saint Thomas Lowland": { + "lat": 17.1650513, + "lng": -62.6089753 + }, + "Saint Paul Charlestown": { + "lat": 17.1346297, + "lng": -62.6133816 + }, + "Saint Mary Cayon": { + "lat": 17.3462071, + "lng": -62.73826709999999 + }, + "Saint George Basseterre": { + "lat": 17.2350612, + "lng": -62.6442284 + }, + "": { + "lat": 17.357822, + "lng": -62.782998 + } + }, + "DM": { + "Saint Joseph": { + "lat": 15.406483, + "lng": -61.4216609 + }, + "Saint George": { + "lat": 15.3087847, + "lng": -61.33851909999999 + }, + "Saint David": { + "lat": 15.4081998, + "lng": -61.29186180000001 + }, + "Saint John": { + "lat": 15.5763757, + "lng": -61.4318742 + }, + "Saint Luke": { + "lat": 15.254587, + "lng": -61.36768679999999 + }, + "Saint Andrew": { + "lat": 15.5542442, + "lng": -61.3501856 + }, + "Saint Peter": { + "lat": 15.5098288, + "lng": -61.4493842 + }, + "Saint Patrick": { + "lat": 15.2811117, + "lng": -61.29186180000001 + }, + "": { + "lat": 15.414999, + "lng": -61.370976 + } + }, + "AG": { + "Parish of Saint John": { + "lat": 17.1086478, + "lng": -61.82922060000001 + }, + "Parish of Saint George": { + "lat": 17.1078489, + "lng": -61.7882743 + }, + "Parish of Saint Mary": { + "lat": 17.0512064, + "lng": -61.87602829999999 + }, + "Parish of Saint Paul": { + "lat": 17.0371588, + "lng": -61.7824256 + }, + "Parish of Saint Philip": { + "lat": 17.0480636, + "lng": -61.71225699999999 + }, + "Barbuda": { + "lat": 17.6266242, + "lng": -61.77130279999999 + }, + "Parish of Saint Peter": { + "lat": 17.0980665, + "lng": -61.75903289999999 + }, + "": { + "lat": 17.060816, + "lng": -61.796428 + } + }, + "LC": { + "Vieux-Fort": { + "lat": 13.720608, + "lng": -60.94964329999999 + }, + "Micoud": { + "lat": 13.8211871, + "lng": -60.90019339999999 + }, + "Castries": { + "lat": 14.0110158, + "lng": -60.98972389999999 + }, + "Laborie": { + "lat": 13.7522783, + "lng": -60.9932889 + }, + "Gros-Islet": { + "lat": 14.0843578, + "lng": -60.9452794 + }, + "Choiseul": { + "lat": 13.7750154, + "lng": -61.04859099999999 + }, + "Canaries": { + "lat": 13.9047039, + "lng": -61.0667869 + }, + "": { + "lat": 13.909444, + "lng": -60.978893 + } + }, + "VC": { + "Parish of Saint George": { + "lat": 13.1698266, + "lng": -61.1985879 + }, + "Parish of Saint Patrick": { + "lat": 13.2379389, + "lng": -61.24521799999999 + }, + "Parish of Saint Andrew": { + "lat": 13.2008061, + "lng": -61.2393885 + }, + "Grenadines": { + "lat": 13.0122965, + "lng": -61.2277301 + }, + "Parish of Charlotte": { + "lat": 13.2175451, + "lng": -61.1636244 + }, + "Parish of Saint David": { + "lat": 13.3154926, + "lng": -61.1985879 + }, + "": { + "lat": 12.984305, + "lng": -61.287228 + } + }, + "GD": { + "Saint Mark": { + "lat": 12.19023, + "lng": -61.6888738 + }, + "Saint George": { + "lat": 12.0560975, + "lng": -61.7487996 + }, + "Saint Patrick": { + "lat": 12.2056921, + "lng": -61.64211719999999 + }, + "Saint David": { + "lat": 12.0456435, + "lng": -61.6888738 + }, + "Carriacou and Petite Martinique": { + "lat": 12.4785888, + "lng": -61.4493842 + }, + "Saint Andrew": { + "lat": 12.1230598, + "lng": -61.64211719999999 + }, + "Saint John": { + "lat": 12.130824, + "lng": -61.71225699999999 + }, + "": { + "lat": 12.262776, + "lng": -61.604171 + } + }, + "BZ": { + "Orange Walk District": { + "lat": 17.760353, + "lng": -88.86469799999999 + }, + "Cayo District": { + "lat": 17.0984445, + "lng": -88.94138650000001 + }, + "Belize District": { + "lat": 17.5677679, + "lng": -88.4016041 + }, + "Toledo District": { + "lat": 16.2491923, + "lng": -88.86469799999999 + }, + "Stann Creek District": { + "lat": 16.8116631, + "lng": -88.4016041 + }, + "Corozal District": { + "lat": 18.1349238, + "lng": -88.24611829999999 + }, + "": { + "lat": 17.189877, + "lng": -88.49765 + } + }, + "SV": { + "Departamento de La Libertad": { + "lat": 13.6817661, + "lng": -89.3606298 + }, + "Departamento de La Paz": { + "lat": 13.495364, + "lng": -88.9796776 + }, + "Departamento de Usulutan": { + "lat": 13.4470634, + "lng": -88.55653099999999 + }, + "Departamento de Chalatenango": { + "lat": 14.1916648, + "lng": -89.1705998 + }, + "Departamento de San Salvador": { + "lat": 13.7739997, + "lng": -89.20867729999999 + }, + "Departamento de Sonsonate": { + "lat": 13.682358, + "lng": -89.6628111 + }, + "Departamento de San Vicente": { + "lat": 13.5868561, + "lng": -88.7493998 + }, + "Departamento de La Union": { + "lat": 13.4886443, + "lng": -87.89424509999999 + }, + "Departamento de Santa Ana": { + "lat": 14.1461121, + "lng": -89.5120084 + }, + "Departamento de San Miguel": { + "lat": 13.4792922, + "lng": -88.1779182 + }, + "Departamento de Cuscatlan": { + "lat": 13.8661957, + "lng": -89.0561532 + }, + "Departamento de Ahuachapan": { + "lat": 13.8216148, + "lng": -89.9253233 + }, + "Departamento de Morazan": { + "lat": 13.7682, + "lng": -88.1291387 + }, + "": { + "lat": 13.794185, + "lng": -88.89653 + } + }, + "GT": { + "Zacapa": { + "lat": 14.9676218, + "lng": -89.5356094 + }, + "Guatemala": { + "lat": 15.783471, + "lng": -90.23075899999999 + }, + "Totonicapán": { + "lat": 14.9173402, + "lng": -91.36139229999999 + }, + "Sololá": { + "lat": 14.7666085, + "lng": -91.17850159999999 + }, + "Santa Rosa Department": { + "lat": 14.1928003, + "lng": -90.37483540000001 + }, + "San Marcos": { + "lat": 14.9609782, + "lng": -91.80745859999999 + }, + "Sacatepéquez": { + "lat": 14.5178379, + "lng": -90.7152749 + }, + "Petén": { + "lat": 16.912033, + "lng": -90.2995785 + }, + "Baja Verapaz": { + "lat": 15.1255867, + "lng": -90.37483540000001 + }, + "Quiché": { + "lat": 15.4983808, + "lng": -90.9820668 + }, + "Quetzaltenango": { + "lat": 14.8446068, + "lng": -91.5231866 + }, + "Izabal Department": { + "lat": 15.4036847, + "lng": -89.1384441 + }, + "Alta Verapaz": { + "lat": 15.5942883, + "lng": -90.14949879999999 + }, + "Departamento de Escuintla": { + "lat": 14.1910912, + "lng": -90.9820668 + }, + "Suchitepeque": { + "lat": 14.4215982, + "lng": -91.4048249 + }, + "Departamento de Jutiapa": { + "lat": 14.1930802, + "lng": -89.9253233 + }, + "Jalapa": { + "lat": 14.6325483, + "lng": -89.9930052 + }, + "Chiquimula": { + "lat": 14.7924897, + "lng": -89.5450458 + }, + "Departamento de Huehuetenango": { + "lat": 15.5879914, + "lng": -91.67606909999999 + }, + "El Progreso": { + "lat": 14.9388732, + "lng": -90.0746767 + }, + "Chimaltenango": { + "lat": 14.6589734, + "lng": -90.8245242 + }, + "": { + "lat": 15.783471, + "lng": -90.230759 + } + }, + "HN": { + "Cortés Department": { + "lat": 15.4923508, + "lng": -88.0900762 + }, + "Yoro Department": { + "lat": 15.2949679, + "lng": -87.14228949999999 + }, + "Lempira Department": { + "lat": 14.1887698, + "lng": -88.55653099999999 + }, + "Santa Bárbara Department": { + "lat": 15.1202795, + "lng": -88.4016041 + }, + "Bay Islands": { + "lat": 16.4826614, + "lng": -85.8793252 + }, + "Colón Department": { + "lat": 15.6425965, + "lng": -85.52002399999999 + }, + "Atlántida Department": { + "lat": 15.6696283, + "lng": -87.14228949999999 + }, + "Francisco Morazán Department": { + "lat": 14.0650449, + "lng": -87.17150389999999 + }, + "Comayagua Department": { + "lat": 14.5534828, + "lng": -87.6186379 + }, + "Copán Department": { + "lat": 14.9360838, + "lng": -88.86469799999999 + }, + "La Paz Department": { + "lat": 13.9984833, + "lng": -87.9334803 + }, + "Choluteca Department": { + "lat": 13.3011523, + "lng": -87.1928293 + }, + "Valle Department": { + "lat": 13.5372374, + "lng": -87.4851655 + }, + "Olancho Department": { + "lat": 14.8067406, + "lng": -85.76666449999999 + }, + "Gracias a Dios Department": { + "lat": 15.341806, + "lng": -84.6060449 + }, + "Ocotepeque Department": { + "lat": 14.5170347, + "lng": -89.0561532 + }, + "Intibucá Department": { + "lat": 14.372734, + "lng": -88.24611829999999 + }, + "El Paraíso Department": { + "lat": 13.944485, + "lng": -86.85283659999999 + }, + "": { + "lat": 15.199999, + "lng": -86.241905 + } + }, + "NI": { + "Managua Department": { + "lat": 12.1853677, + "lng": -86.3782198 + }, + "Madriz Department": { + "lat": 13.4714521, + "lng": -86.3782198 + }, + "Departamento de Chinandega": { + "lat": 12.6982149, + "lng": -87.14228949999999 + }, + "Matagalpa Department": { + "lat": 12.9498436, + "lng": -85.4375574 + }, + "Departamento de Rivas": { + "lat": 11.3599685, + "lng": -85.8895551 + }, + "Masaya Department": { + "lat": 12.0896453, + "lng": -86.05296039999999 + }, + "León Department": { + "lat": 12.690563, + "lng": -86.49965460000001 + }, + "Chontales Department": { + "lat": 12.1216944, + "lng": -85.1894045 + }, + "Jinotega Department": { + "lat": 13.7810091, + "lng": -85.52002399999999 + }, + "Granada Department": { + "lat": 11.8151151, + "lng": -85.971322 + }, + "Estelí Department": { + "lat": 13.2424713, + "lng": -86.49965460000001 + }, + "Boaco Department": { + "lat": 12.492724, + "lng": -85.52002399999999 + }, + "": { + "lat": 12.865416, + "lng": -85.207229 + } + }, + "CR": { + "Alajuela Province": { + "lat": 10.391583, + "lng": -84.4382721 + }, + "Provincia de San Jose": { + "lat": 9.9280694, + "lng": -84.0907246 + }, + "Puntarenas Province": { + "lat": 9.2169531, + "lng": -83.33618799999999 + }, + "Heredia Province": { + "lat": 10.473523, + "lng": -84.01674229999999 + }, + "Cartago Province": { + "lat": 9.7539596, + "lng": -83.67739279999999 + }, + "Guanacaste Province": { + "lat": 10.4072992, + "lng": -85.4375574 + }, + "Limón Province": { + "lat": 10.1064393, + "lng": -83.5070203 + }, + "": { + "lat": 9.748917, + "lng": -83.753428 + } + }, + "EC": { + "Zamora Chinchipe": { + "lat": -4.0655892, + "lng": -78.9503525 + }, + "Provincia de El Oro": { + "lat": -3.2592413, + "lng": -79.9583541 + }, + "Provincia de Loja": { + "lat": -4.1635066, + "lng": -79.560344 + }, + "Provincia del Guayas": { + "lat": -1.9574839, + "lng": -79.9192702 + }, + "Provincia de Los Rios": { + "lat": -1.0230607, + "lng": -79.4608897 + }, + "Provincia de Imbabura": { + "lat": 0.3499768, + "lng": -78.12601289999999 + }, + "Tungurahua": { + "lat": -1.2635284, + "lng": -78.5660852 + }, + "Provincia de Pichincha": { + "lat": -0.1464847, + "lng": -78.4751945 + }, + "Provincia del Carchi": { + "lat": 0.5026912, + "lng": -77.9042521 + }, + "Provincia de Manabi": { + "lat": -1.0543434, + "lng": -80.45264399999999 + }, + "Provincia de Napo": { + "lat": -0.9955963999999998, + "lng": -77.8129684 + }, + "Canar": { + "lat": -2.555695, + "lng": -78.9344814 + }, + "Morona Santiago": { + "lat": -2.3051062, + "lng": -78.11468660000001 + }, + "Provincia del Azuay": { + "lat": -2.8943068, + "lng": -78.9968344 + }, + "Provincia de Sucumbios": { + "lat": 0.08892309999999999, + "lng": -76.8897557 + }, + "Pastaza": { + "lat": -1.4882265, + "lng": -78.00310569999999 + }, + "Provincia de Cotopaxi": { + "lat": -0.8384206, + "lng": -78.6662678 + }, + "Provincia de Santo Domingo de los Tsachilas": { + "lat": -0.2521882, + "lng": -79.1879383 + }, + "Provincia de Santa Elena": { + "lat": -2.2267105, + "lng": -80.859499 + }, + "Provincia de Esmeraldas": { + "lat": 0.3217414, + "lng": -79.468107 + }, + "Provincia de Bolivar": { + "lat": -1.7095828, + "lng": -79.0450429 + }, + "Chimborazo": { + "lat": -1.4693018, + "lng": -78.8169396 + }, + "Provincia de Galapagos": { + "lat": -0.9537690999999999, + "lng": -90.9656019 + }, + "Orellana": { + "lat": -0.4545163, + "lng": -76.9950286 + }, + "": { + "lat": -1.831239, + "lng": -78.183406 + } + }, + "CO": { + "Cundinamarca": { + "lat": 5.026002999999999, + "lng": -74.0300122 + }, + "Departamento del Valle del Cauca": { + "lat": 3.8008893, + "lng": -76.64127119999999 + }, + "Departamento del Huila": { + "lat": 2.5359349, + "lng": -75.52766989999999 + }, + "Departamento de Santander": { + "lat": 6.6437076, + "lng": -73.65362089999999 + }, + "Casanare Department": { + "lat": 5.7589269, + "lng": -71.5723953 + }, + "Antioquia": { + "lat": 6.2476598, + "lng": -75.565816 + }, + "Caldas Department": { + "lat": 5.29826, + "lng": -75.2479061 + }, + "Departamento del Meta": { + "lat": 3.2719904, + "lng": -73.087749 + }, + "Departamento del Cauca": { + "lat": 2.20849, + "lng": -77.8288199 + }, + "Norte de Santander Department": { + "lat": 7.9462831, + "lng": -72.8988069 + }, + "La Guajira Department": { + "lat": 11.3547743, + "lng": -72.5204827 + }, + "Atlántico": { + "lat": 10.6966159, + "lng": -74.8741045 + }, + "Departamento de Boyaca": { + "lat": 5.454511, + "lng": -73.362003 + }, + "Departamento del Putumayo": { + "lat": 0.4359506, + "lng": -75.52766989999999 + }, + "Departamento del Magdalena": { + "lat": 10.4113014, + "lng": -74.4056612 + }, + "Departamento de Sucre": { + "lat": 8.813977, + "lng": -74.723283 + }, + "Departamento del Cesar": { + "lat": 9.3372948, + "lng": -73.65362089999999 + }, + "Bogota D.C.": { + "lat": 4.710988599999999, + "lng": -74.072092 + }, + "Amazonas": { + "lat": -1.4429123, + "lng": -71.5723953 + }, + "Departamento de Bolivar": { + "lat": 8.6704382, + "lng": -74.0300122 + }, + "Departamento de Narino": { + "lat": 1.289151, + "lng": -77.35794000000001 + }, + "Departamento de Cordoba": { + "lat": 8.7509716, + "lng": -75.8785438 + }, + "Departamento de Arauca": { + "lat": 6.547306, + "lng": -71.0022311 + }, + "Departamento del Choco": { + "lat": 5.6914548, + "lng": -76.65897939999999 + }, + "Departamento de Risaralda": { + "lat": 5.315847499999999, + "lng": -75.9927652 + }, + "Departamento de Tolima": { + "lat": 4.092516799999999, + "lng": -75.1545381 + }, + "Departamento del Caqueta": { + "lat": 0.869892, + "lng": -73.8419063 + }, + "Guainía Department": { + "lat": 2.585393, + "lng": -68.52471489999999 + }, + "Departamento del Vichada": { + "lat": 4.4234452, + "lng": -69.2877535 + }, + "Quindio Department": { + "lat": 4.4610191, + "lng": -75.667356 + }, + "Departamento del Vaupes": { + "lat": 0.8553561, + "lng": -70.81199529999999 + }, + "Departamento del Guaviare": { + "lat": 2.043924000000001, + "lng": -72.331113 + }, + "": { + "lat": 4.570868, + "lng": -74.297333 + } + }, + "PE": { + "Loreto": { + "lat": -4.232472899999999, + "lng": -74.21793260000001 + }, + "Ancash": { + "lat": -9.3250497, + "lng": -77.5619419 + }, + "La Libertad": { + "lat": -8.143593300000001, + "lng": -78.4751945 + }, + "Tumbes": { + "lat": -3.5564921, + "lng": -80.4270885 + }, + "Region de Huanuco": { + "lat": -9.4505511, + "lng": -76.2710833 + }, + "Region de San Martin": { + "lat": -7.244488100000001, + "lng": -76.8259652 + }, + "Piura": { + "lat": -5.1782884, + "lng": -80.6548882 + }, + "Lambayeque": { + "lat": -6.7044468, + "lng": -79.9042472 + }, + "Ucayali": { + "lat": -9.8251183, + "lng": -73.087749 + }, + "Amazonas": { + "lat": -5.115146, + "lng": -78.11082789999999 + }, + "Cajamarca": { + "lat": -7.1617465, + "lng": -78.51278549999999 + }, + "Lima region": { + "lat": -12.2720956, + "lng": -76.2710833 + }, + "Callao": { + "lat": -12.0508491, + "lng": -77.1259843 + }, + "Cusco": { + "lat": -13.53195, + "lng": -71.96746259999999 + }, + "Pasco": { + "lat": -10.4475753, + "lng": -75.1545381 + }, + "Junin": { + "lat": -11.1581925, + "lng": -75.9926306 + }, + "Puno": { + "lat": -15.8402218, + "lng": -70.0218805 + }, + "Tacna": { + "lat": -18.0067602, + "lng": -70.2460246 + }, + "Ica": { + "lat": -14.07546, + "lng": -75.7341811 + }, + "Arequipa": { + "lat": -16.4090474, + "lng": -71.53745099999999 + }, + "Departamento de Moquegua": { + "lat": -16.9428373, + "lng": -71.0022311 + }, + "Huancavelica": { + "lat": -12.7861978, + "lng": -74.9764024 + }, + "Madre de Dios": { + "lat": -11.7668705, + "lng": -70.81199529999999 + }, + "Lima": { + "lat": -12.0463731, + "lng": -77.042754 + }, + "Ayacucho": { + "lat": -13.1638737, + "lng": -74.22356409999999 + }, + "Region de Apurimac": { + "lat": -14.0504533, + "lng": -73.087749 + }, + "": { + "lat": -9.189967, + "lng": -75.015152 + } + }, + "PA": { + "Provincia de Herrera": { + "lat": 7.7704282, + "lng": -80.7214417 + }, + "Provincia de Veraguas": { + "lat": 8.1231033, + "lng": -81.0754657 + }, + "Panamá Oeste Province": { + "lat": 8.791318, + "lng": -80.0087746 + }, + "Provincia del Darien": { + "lat": 7.868171299999999, + "lng": -77.8367282 + }, + "Provincia de Panama": { + "lat": 9.1088003, + "lng": -78.9288242 + }, + "Provincia de Colon": { + "lat": 9.1851989, + "lng": -80.0534923 + }, + "Guna Yala": { + "lat": 9.238864999999999, + "lng": -78.2248291 + }, + "Provincia de Cocle": { + "lat": 8.400144899999999, + "lng": -80.42152460000001 + }, + "Bocas del Toro Province": { + "lat": 9.293417900000001, + "lng": -82.7347142 + }, + "Ngoebe-Bugle": { + "lat": 8.6595833, + "lng": -81.77870209999999 + }, + "Chiriquí Province": { + "lat": 8.3866964, + "lng": -82.2800546 + }, + "Embera-Wounaan": { + "lat": 8.3766983, + "lng": -77.6536125 + }, + "": { + "lat": 8.537981, + "lng": -80.782127 + } + }, + "HT": { + "Sud": { + "lat": 18.3320005, + "lng": -73.7007088 + }, + "Departement de l'Artibonite": { + "lat": 19.362902, + "lng": -72.4258145 + }, + "Departement de l'Ouest": { + "lat": 18.4957015, + "lng": -72.4731529 + }, + "Departement du Nord-Est": { + "lat": 19.4889723, + "lng": -71.8571331 + }, + "Nord-Ouest": { + "lat": 19.8374009, + "lng": -73.0405277 + }, + "Centre": { + "lat": 18.9582742, + "lng": -72.0468164 + }, + "Departement de Nippes": { + "lat": 18.3990735, + "lng": -73.4180211 + }, + "Grand'Anse": { + "lat": 18.5489259, + "lng": -74.07701 + }, + "Nord": { + "lat": 19.5687715, + "lng": -72.189 + }, + "": { + "lat": 18.971187, + "lng": -72.285215 + } + }, + "CL": { + "Region de Valparaiso": { + "lat": -32.5040172, + "lng": -71.0022311 + }, + "Coquimbo Region": { + "lat": -30.540181, + "lng": -70.81199529999999 + }, + "Region del Biobio": { + "lat": -37.4464428, + "lng": -72.1416132 + }, + "Maule Region": { + "lat": -35.5163603, + "lng": -71.5723953 + }, + "O'Higgins Region": { + "lat": -34.5755374, + "lng": -71.0022311 + }, + "Santiago Metropolitan": { + "lat": -33.4843354, + "lng": -70.6216794 + }, + "Region de la Araucania": { + "lat": -38.948921, + "lng": -72.331113 + }, + "Atacama": { + "lat": -23.8634189, + "lng": -69.1328491 + }, + "Los Ríos Region": { + "lat": -40.2310217, + "lng": -72.331113 + }, + "Antofagasta": { + "lat": -23.6509279, + "lng": -70.39750219999999 + }, + "Ñuble": { + "lat": -36.7225743, + "lng": -71.7622481 + }, + "Region of Magallanes": { + "lat": -53.4428344, + "lng": -72.1496817 + }, + "Los Lagos Region": { + "lat": -41.9197779, + "lng": -72.1416132 + }, + "Region de Arica y Parinacota": { + "lat": -18.5940485, + "lng": -69.4784541 + }, + "Aysén": { + "lat": -45.4037315, + "lng": -72.6864919 + }, + "Tarapacá": { + "lat": -19.9243492, + "lng": -69.5109513 + }, + "": { + "lat": -35.675147, + "lng": -71.542969 + } + }, + "BO": { + "Tarija Department": { + "lat": -21.5831595, + "lng": -63.95861110000001 + }, + "Beni Department": { + "lat": -14.3782747, + "lng": -65.0957792 + }, + "Chuquisaca Department": { + "lat": -20.0249144, + "lng": -64.1478236 + }, + "Santa Cruz Department": { + "lat": -16.7476037, + "lng": -62.0750998 + }, + "Departamento de Cochabamba": { + "lat": -17.5681675, + "lng": -65.475736 + }, + "Potosí Department": { + "lat": -20.624713, + "lng": -66.9988011 + }, + "Departamento de Pando": { + "lat": -10.7988901, + "lng": -66.9988011 + }, + "Oruro": { + "lat": -17.9716723, + "lng": -67.0931378 + }, + "La Paz Department": { + "lat": -15.0892416, + "lng": -68.52471489999999 + }, + "": { + "lat": -16.290154, + "lng": -63.588653 + } + }, + "PF": { + "Iles Marquises": { + "lat": -9.781216200000001, + "lng": -139.0817124 + }, + "Iles Tuamotu-Gambier": { + "lat": -18.033333, + "lng": -141.416667 + }, + "Leeward Islands": { + "lat": -16.39540465, + "lng": -152.7870564 + }, + "Iles du Vent": { + "lat": -17.53799845, + "lng": -149.41834955 + }, + "": { + "lat": -17.679742, + "lng": -149.406843 + } + }, + "TK": { + "Fakaofo": { + "lat": -9.380255499999999, + "lng": -171.2188355 + }, + "Nukunonu": { + "lat": -9.1977966, + "lng": -171.8507811 + }, + "Atafu": { + "lat": -8.55451154047545, + "lng": -172.4955714262993 + }, + "": { + "lat": -8.967363, + "lng": -171.855881 + } + }, + "TO": { + "Tongatapu": { + "lat": -21.1465968, + "lng": -175.2515482 + }, + "ʻEua": { + "lat": -21.3869753, + "lng": -174.9297206 + }, + "Vava'u": { + "lat": -18.622756, + "lng": -173.9902982 + }, + "Niuas": { + "lat": -15.9594, + "lng": -173.783 + }, + "Ha'apai": { + "lat": -19.75, + "lng": -174.366667 + }, + "": { + "lat": -21.178986, + "lng": -175.198242 + } + }, + "WF": { + "Uvea": { + "lat": -13.2959105, + "lng": -176.2056843 + }, + "Sigave": { + "lat": -14.2968356, + "lng": -178.1581081 + }, + "Alo": { + "lat": -14.2903576, + "lng": -178.1193763 + }, + "": { + "lat": -13.768752, + "lng": -177.156097 + } + }, + "WS": { + "Va'a-o-Fonoti": { + "lat": -13.9470903, + "lng": -171.5431872 + }, + "Palauli": { + "lat": -13.7585016, + "lng": -172.299737 + }, + "Satupa'itea": { + "lat": -13.7731169, + "lng": -172.3302677 + }, + "Fa'asaleleaga": { + "lat": -13.6307638, + "lng": -172.2365981 + }, + "Gagaifomauga": { + "lat": -13.5468007, + "lng": -172.4969331 + }, + "Gaga'emauga": { + "lat": -13.5428666, + "lng": -172.366887 + }, + "Aiga-i-le-Tai": { + "lat": -13.8513791, + "lng": -172.0325401 + }, + "Atua": { + "lat": -13.9787053, + "lng": -171.6254283 + }, + "A'ana": { + "lat": -13.898418, + "lng": -171.9752995 + }, + "Tuamasaga": { + "lat": -13.9163592, + "lng": -171.8224362 + }, + "Vaisigano": { + "lat": -13.5413827, + "lng": -172.7023383 + }, + "": { + "lat": -13.759029, + "lng": -172.104629 + } + }, + "MP": { + "Rota": { + "lat": 14.1508752, + "lng": 145.2148736 + }, + "Tinian": { + "lat": 15.0043455, + "lng": 145.6356577 + }, + "Northern Islands": { + "lat": 18.27354396328492, + "lng": 145.4797085491793 + }, + "Saipan": { + "lat": 15.1850483, + "lng": 145.7467259 + }, + "": { + "lat": 17.33083, + "lng": 145.38469 + } + }, + "UM": { + "Wake Island": { + "lat": 19.279619, + "lng": 166.6499348 + }, + "": { + "lat": 0, + "lng": 0 + } + }, + "US": { + "Texas": { + "lat": 31.9685988, + "lng": -99.9018131 + }, + "Alabama": { + "lat": 32.3182314, + "lng": -86.902298 + }, + "Virginia": { + "lat": 37.4315734, + "lng": -78.6568942 + }, + "West Virginia": { + "lat": 38.5976262, + "lng": -80.4549026 + }, + "Arkansas": { + "lat": 35.20105, + "lng": -91.8318334 + }, + "Delaware": { + "lat": 38.9108325, + "lng": -75.52766989999999 + }, + "Florida": { + "lat": 27.6648274, + "lng": -81.5157535 + }, + "Georgia": { + "lat": 32.1574351, + "lng": -82.90712300000001 + }, + "Illinois": { + "lat": 40.6331249, + "lng": -89.3985283 + }, + "Indiana": { + "lat": 40.5512165, + "lng": -85.60236429999999 + }, + "Maryland": { + "lat": 39.0457549, + "lng": -76.64127119999999 + }, + "Kansas": { + "lat": 39.011902, + "lng": -98.4842465 + }, + "Kentucky": { + "lat": 37.8393332, + "lng": -84.2700179 + }, + "Missouri": { + "lat": 37.9642529, + "lng": -91.8318334 + }, + "North Carolina": { + "lat": 35.7595731, + "lng": -79.01929969999999 + }, + "Ohio": { + "lat": 40.4172871, + "lng": -82.90712300000001 + }, + "Oklahoma": { + "lat": 35.0077519, + "lng": -97.092877 + }, + "South Carolina": { + "lat": 33.836081, + "lng": -81.1637245 + }, + "Tennessee": { + "lat": 35.5174913, + "lng": -86.5804473 + }, + "District of Columbia": { + "lat": 38.9071923, + "lng": -77.0368707 + }, + "Louisiana": { + "lat": 30.5190775, + "lng": -91.5208624 + }, + "Mississippi": { + "lat": 32.3546679, + "lng": -89.3985283 + }, + "New Jersey": { + "lat": 40.0583238, + "lng": -74.4056612 + }, + "Pennsylvania": { + "lat": 41.2033216, + "lng": -77.1945247 + }, + "Connecticut": { + "lat": 41.6032207, + "lng": -73.087749 + }, + "Iowa": { + "lat": 41.8780025, + "lng": -93.097702 + }, + "Massachusetts": { + "lat": 42.4072107, + "lng": -71.3824374 + }, + "Maine": { + "lat": 45.253783, + "lng": -69.4454689 + }, + "Michigan": { + "lat": 44.3148443, + "lng": -85.60236429999999 + }, + "Minnesota": { + "lat": 46.729553, + "lng": -94.6858998 + }, + "Nebraska": { + "lat": 41.4925374, + "lng": -99.9018131 + }, + "New York": { + "lat": 40.7127753, + "lng": -74.0059728 + }, + "South Dakota": { + "lat": 43.9695148, + "lng": -99.9018131 + }, + "Wisconsin": { + "lat": 43.7844397, + "lng": -88.7878678 + }, + "North Dakota": { + "lat": 47.5514926, + "lng": -101.0020119 + }, + "New Hampshire": { + "lat": 43.1938516, + "lng": -71.5723953 + }, + "Rhode Island": { + "lat": 41.5800945, + "lng": -71.4774291 + }, + "Vermont": { + "lat": 44.5588028, + "lng": -72.57784149999999 + }, + "Arizona": { + "lat": 34.0489281, + "lng": -111.0937311 + }, + "California": { + "lat": 36.778261, + "lng": -119.4179324 + }, + "New Mexico": { + "lat": 34.9727305, + "lng": -105.0323635 + }, + "Utah": { + "lat": 39.3209801, + "lng": -111.0937311 + }, + "Colorado": { + "lat": 39.5500507, + "lng": -105.7820674 + }, + "Nevada": { + "lat": 38.8026097, + "lng": -116.419389 + }, + "Idaho": { + "lat": 44.0682019, + "lng": -114.7420408 + }, + "Alaska": { + "lat": 63.588753, + "lng": -154.4930619 + }, + "Montana": { + "lat": 46.8796822, + "lng": -110.3625658 + }, + "Oregon": { + "lat": 43.8041334, + "lng": -120.5542012 + }, + "Washington": { + "lat": 47.7510741, + "lng": -120.7401386 + }, + "Wyoming": { + "lat": 43.0759678, + "lng": -107.2902839 + }, + "Hawaii": { + "lat": 19.8986819, + "lng": -155.6658568 + }, + "": { + "lat": 37.09024, + "lng": -95.712891 + } + }, + "VI": { + "Saint Thomas Island": { + "lat": 18.3380965, + "lng": -64.8940946 + }, + "Saint Croix Island": { + "lat": 17.7245968, + "lng": -64.83479919999999 + }, + "Saint John Island": { + "lat": 18.3368114, + "lng": -64.7280952 + }, + "": { + "lat": 18.335765, + "lng": -64.896335 + } + }, + "AS": { + "Western District": { + "lat": -14.3249786, + "lng": -170.7214561 + }, + "Manu'a District": { + "lat": -14.2302774, + "lng": -169.5154102 + }, + "Swains Island": { + "lat": -11.0552207, + "lng": -171.0782253 + }, + "Eastern District": { + "lat": -14.273298, + "lng": -170.7030625 + }, + "Rose Island": { + "lat": -14.5212082, + "lng": -168.1792434 + }, + "": { + "lat": -14.270972, + "lng": -170.132217 + } + }, + "CA": { + "British Columbia": { + "lat": 53.7266683, + "lng": -127.6476206 + }, + "Nova Scotia": { + "lat": 45.07784729999999, + "lng": -63.5466822 + }, + "Saskatchewan": { + "lat": 52.9399159, + "lng": -106.4508639 + }, + "Alberta": { + "lat": 53.9332706, + "lng": -116.5765035 + }, + "New Brunswick": { + "lat": 46.5653163, + "lng": -66.46191639999999 + }, + "Ontario": { + "lat": 51.253775, + "lng": -85.3232139 + }, + "Quebec": { + "lat": 46.8130816, + "lng": -71.20745959999999 + }, + "Prince Edward Island": { + "lat": 46.3188025, + "lng": -63.14610099999999 + }, + "Manitoba": { + "lat": 53.7608608, + "lng": -98.81387629999999 + }, + "Nunavut": { + "lat": 70.2997711, + "lng": -83.1075769 + }, + "Newfoundland and Labrador": { + "lat": 53.49436391800336, + "lng": -60.22052574294098 + }, + "Yukon": { + "lat": 64.2823274, + "lng": -135 + }, + "Northwest Territories": { + "lat": 63.588753, + "lng": -115.5069381 + }, + "": { + "lat": 56.130366, + "lng": -106.346771 + } + }, + "SG": { + "": { + "lat": 1.3214412, + "lng": 103.7954157 + } + } +} diff --git a/etc/geoip/iso3166-2.json b/etc/geoip/iso3166-2.json new file mode 100755 index 0000000..1dbc92a --- /dev/null +++ b/etc/geoip/iso3166-2.json @@ -0,0 +1,3411 @@ +{ + "IR-02": "Māzandarān", + "CY-02": "Limassol District", + "IR-28": "North Khorasan", + "IR-26": "Qazvin Province", + "IR-06": "Khuzestan", + "CY-04": "Ammochostos", + "IR-04": "West Azerbaijan Province", + "IR-05": "Kermanshah Province", + "IR-01": "Gilan Province", + "IR-03": "East Azerbaijan Province", + "IR-13": "Hamadan Province", + "IR-27": "Golestan", + "IR-00": "Markazi", + "IR-23": "Tehran", + "IR-08": "Kerman", + "SO-BK": "Bakool", + "SO-BN": "Banaadir", + "SO-SH": "Lower Shabeelle", + "SO-WO": "Woqooyi Galbeed", + "SO-JD": "Middle Juba", + "SO-NU": "Nugaal", + "SO-GE": "Gedo", + "SO-MU": "Mudug", + "SO-TO": "Togdheer", + "SO-BR": "Bari", + "SO-AW": "Awdal", + "SO-HI": "Hiiraan", + "SO-BY": "Bay", + "IR-17": "Kohgiluyeh and Boyer-Ahmad Province", + "YE-AB": "Abyan Governorate", + "YE-SN": "Sanaa Governorate", + "YE-HD": "Muhafazat Hadramaout", + "YE-SA": "Amanat Alasimah", + "YE-BA": "Al Bayda", + "YE-RA": "Raymah", + "YE-LA": "Laḩij", + "YE-IB": "Ibb Governorate", + "YE-HJ": "Ḩajjah", + "YE-SU": "Soqatra", + "YE-DH": "Dhamār", + "YE-AM": "Omran", + "YE-JA": "Al Jawf", + "YE-SH": "Shabwah", + "YE-HU": "Al Hudaydah", + "YE-TA": "Ta‘izz", + "YE-MW": "Al Mahwit Governorate", + "YE-MR": "Al Mahrah Governorate", + "YE-DA": "Aḑ Ḑāli‘", + "LY-BU": "Al Butnan", + "LY-JA": "Al Jabal al Akhdar", + "LY-DR": "Darnah", + "LY-BA": "Sha'biyat Banghazi", + "LY-KF": "Al Kufrah", + "LY-MJ": "Al Marj", + "LY-WA": "Al Wahat", + "IQ-BA": "Basra", + "IQ-DA": "Duhok", + "IQ-SD": "Salah ad Din", + "IQ-SU": "Sulaymaniyah", + "IQ-MA": "Maysan", + "IQ-KI": "Kirkuk", + "IQ-KA": "Muhafazat Karbala'", + "IQ-AR": "Erbil", + "IQ-DI": "Diyālá", + "IQ-BG": "Baghdad", + "IQ-WA": "Muhafazat Wasit", + "IQ-AN": "Al Anbar", + "IQ-DQ": "Dhi Qar", + "IQ-NA": "An Najaf", + "IQ-NI": "Nineveh", + "IQ-BB": "Muhafazat Babil", + "IQ-QA": "Muhafazat al Qadisiyah", + "SA-03": "Medina Region", + "SA-05": "Al-Qassim Region", + "SA-01": "Riyadh Region", + "SA-07": "Tabuk Region", + "SA-04": "Eastern Province", + "SA-14": "'Asir Region", + "SA-12": "Al Jawf Region", + "SA-09": "Jazan Region", + "SA-08": "Northern Borders Region", + "SA-02": "Mecca Region", + "SA-10": "Najran Region", + "SA-06": "Ha'il Region", + "SA-11": "Al Bahah Region", + "IR-15": "Lorestan Province", + "IR-19": "Zanjan", + "IR-21": "Yazd Province", + "IR-09": "Razavi Khorasan", + "IR-30": "Alborz Province", + "IR-07": "Fars", + "IR-22": "Hormozgan", + "IR-14": "Chaharmahal and Bakhtiari Province", + "IR-18": "Bushehr Province", + "IR-20": "Semnan Province", + "IR-12": "Kurdistan Province", + "IR-24": "Ardabil Province", + "IR-25": "Qom Province", + "IR-10": "Isfahan", + "IR-16": "Ilam Province", + "IR-29": "South Khorasan Province", + "AO-LSU": "Lunda Sul", + "AO-LNO": "Luanda Norte", + "CY-01": "Nicosia", + "CY-03": "Larnaka", + "CY-05": "Pafos", + "CY-06": "Keryneia", + "AZ-NX": "Nakhichevan", + "AZ-LAN": "Lankaran Rayon", + "AZ-AST": "Astara", + "TZ-15": "Zanzibar Urban/West", + "TZ-06": "Pemba North", + "TZ-27": "Geita", + "TZ-01": "Arusha", + "TZ-25": "Tanga", + "TZ-24": "Tabora", + "TZ-20": "Rukwa", + "TZ-30": "Simiyu", + "TZ-22": "Shinyanga", + "TZ-02": "Dar es Salaam Region", + "TZ-29": "Njombe", + "TZ-18": "Mwanza", + "TZ-13": "Mara", + "TZ-28": "Katavi", + "TZ-09": "Kilimanjaro", + "TZ-16": "Morogoro", + "TZ-14": "Mbeya", + "TZ-04": "Iringa", + "TZ-08": "Kigoma", + "TZ-19": "Pwani", + "TZ-07": "Zanzibar North", + "TZ-03": "Dodoma", + "TZ-10": "Pemba South", + "TZ-05": "Kagera", + "TZ-26": "Manyara", + "TM-B": "Balkan", + "TM-S": "Ashgabat", + "TM-A": "Ahal", + "IL-Z": "Northern District", + "SY-ID": "Idlib Governorate", + "SY-HI": "Homs Governorate", + "SY-HL": "Aleppo Governorate", + "SY-DI": "Damascus Governorate", + "SY-LA": "Latakia Governorate", + "SY-SU": "As-Suwayda Governorate", + "AM-VD": "Vayots Dzor", + "AM-AR": "Ararat", + "AM-SU": "Syunik", + "ZM-04": "Luapula Province", + "KE-46": "Wajir", + "KE-11": "Kakamega", + "KE-30": "Nairobi", + "KE-13": "Kiambu", + "KE-36": "Nyeri", + "KE-29": "Murang'A", + "KE-38": "Siaya", + "KE-10": "Kajiado", + "KE-33": "Narok", + "KE-20": "Laikipia", + "KE-32": "Nandi", + "KE-31": "Nakuru", + "KE-22": "Machakos", + "KE-14": "Kilifi", + "KE-28": "Mombasa", + "KE-25": "Marsabit", + "KE-27": "Migori", + "KE-26": "Meru", + "KE-45": "Vihiga", + "KE-37": "Samburu", + "KE-24": "Mandera District", + "KE-43": "Turkana", + "KE-12": "Kericho", + "KE-21": "Lamu", + "KE-19": "Kwale", + "KE-18": "Kitui", + "KE-42": "Trans Nzoia", + "KE-17": "Kisumu", + "KE-16": "Kisii", + "KE-15": "Kirinyaga", + "KE-34": "Nyamira", + "KE-05": "Marakwet District", + "KE-47": "West Pokot District", + "KE-01": "Baringo", + "KE-09": "Isiolo", + "KE-08": "Homa Bay", + "KE-40": "Tana River District", + "KE-35": "Nyandarua", + "KE-07": "Garissa", + "KE-06": "Embu", + "KE-44": "Uasin Gishu", + "KE-41": "Tharaka - Nithi", + "KE-03": "Bungoma", + "RW-03": "Northern Province", + "RW-01": "Kigali", + "RW-04": "Western Province", + "CD-TO": "Tshopo", + "CD-SU": "Sud-Ubangi", + "CD-HL": "Haut-Lomami", + "CD-LO": "Lomami", + "CD-KE": "Kasaï-Oriental", + "CD-MO": "Mongala", + "CD-KS": "Kasai", + "CD-SA": "Sankuru", + "CD-MA": "Maniema", + "CD-TA": "Tanganyika", + "CD-HU": "Haut-Uele", + "CD-NK": "Nord Kivu", + "CD-NU": "Nord-Ubangi", + "CD-IT": "Ituri", + "CD-KC": "Kasai-Central", + "CD-SK": "South Kivu Province", + "CD-TU": "Tshuapa", + "DJ-TA": "Tadjourah", + "DJ-OB": "Obock", + "DJ-DJ": "Djibouti", + "DJ-DI": "Dikhil", + "DJ-AS": "Ali Sabieh Region", + "DJ-AR": "Arta Region", + "UG-C": "Central Region", + "UG-W": "Western Region", + "UG-E": "Eastern Region", + "UG-N": "Northern Region", + "CF-HM": "Haut-Mbomou", + "CF-MB": "Mbomou", + "CF-VK": "Vakaga", + "CF-HK": "Haute-Kotto", + "CF-BB": "Bamingui-Bangoran", + "CF-BK": "Basse-Kotto", + "CF-UK": "Ouaka", + "SC-16": "English River", + "SC-23": "Takamaka", + "SC-21": "Port Glaud", + "SC-19": "Plaisance", + "SC-12": "Glacis", + "SC-11": "Cascade", + "SC-08": "Beau Vallon", + "SC-05": "Anse Royale", + "TD-SI": "Sila", + "TD-EO": "Ennedi-Ouest", + "TD-WF": "Wadi Fira Region", + "TD-SA": "Salamat Region", + "TD-OD": "Ouadaï", + "JO-MN": "Ma’an", + "JO-JA": "Jerash", + "JO-AM": "Amman Governorate", + "JO-MD": "Madaba", + "JO-IR": "Irbid", + "JO-AJ": "Ajloun", + "JO-AT": "Tafielah", + "JO-AZ": "Zarqa", + "JO-BA": "Balqa", + "JO-KA": "Karak", + "JO-MA": "Mafraq", + "JO-AQ": "Aqaba", + "GR-F": "Ionian Islands", + "GR-G": "West Greece", + "GR-J": "Peloponnese", + "GR-E": "Thessaly", + "GR-L": "South Aegean", + "GR-I": "Attica", + "GR-D": "Epirus", + "GR-H": "Central Greece", + "GR-M": "Crete", + "GR-B": "Central Macedonia", + "GR-K": "North Aegean", + "GR-C": "West Macedonia", + "LB-BI": "Mohafazat Beqaa", + "LB-AS": "Mohafazat Liban-Nord", + "LB-JL": "Mohafazat Mont-Liban", + "LB-JA": "South Governorate", + "LB-NA": "Mohafazat Nabatiye", + "LB-AK": "Mohafazat Aakkar", + "LB-BA": "Beyrouth", + "LB-BH": "Mohafazat Baalbek-Hermel", + "PS-RFH": "Rafah Governorate", + "PS-DEB": "Deir al-Balah Governorate", + "PS-KYS": "Khan Yunis Governorate", + "PS-NGZ": "North Gaza Governorate", + "PS-GZA": "Gaza Governorate", + "IL-JM": "Jerusalem", + "PS-TKM": "Tulkarm Governorate", + "PS-TBS": "Tubas Governorate", + "PS-SLT": "Salfit Governorate", + "PS-RBH": "Ramallah and al-Bireh Governorate", + "PS-QQA": "Qalqilya Governorate", + "PS-NBS": "Nablus Governorate", + "PS-JEN": "Jenin Governorate", + "IL-M": "Central District", + "PS-HBN": "Hebron", + "PS-BTH": "Bethlehem Governorate", + "PS-JRH": "Jericho Governorate", + "KW-JA": "Muhafazat al Jahra'", + "KW-KU": "Al Asimah", + "KW-HA": "Hawalli", + "KW-AH": "Al Aḩmadī", + "KW-MU": "Mubārak al Kabīr", + "KW-FA": "Al Farwaniyah", + "OM-MA": "Muscat", + "OM-SJ": "Southeastern Governorate", + "OM-BS": "Al Batinah North Governorate", + "OM-DA": "Ad Dakhiliyah", + "OM-WU": "Al Wusta Governorate", + "OM-SS": "Northeastern Governorate", + "OM-ZU": "Dhofar", + "OM-MU": "Musandam Governorate", + "OM-ZA": "Ad Dhahirah", + "OM-BJ": "Al Batinah South", + "OM-BU": "Al Buraimi", + "QA-US": "Baladiyat Umm Salal", + "QA-WA": "Al Wakrah", + "QA-SH": "Al-Shahaniya", + "QA-MS": "Baladiyat ash Shamal", + "QA-RA": "Baladiyat ar Rayyan", + "QA-KH": "Al Khor", + "QA-DA": "Baladiyat ad Dawhah", + "BH-13": "Manama", + "BH-17": "Northern", + "BH-15": "Muharraq", + "BH-14": "Southern Governorate", + "AE-UQ": "Imarat Umm al Qaywayn", + "AE-RK": "Imarat Ra's al Khaymah", + "AE-SH": "Sharjah", + "AE-DU": "Dubai", + "AE-AZ": "Abu Dhabi", + "AE-FU": "Fujairah", + "AE-AJ": "Ajman", + "IL-D": "Southern District", + "IL-HA": "Haifa", + "IL-TA": "Tel Aviv", + "TR-56": "Siirt", + "TR-01": "Adana", + "TR-35": "İzmir Province", + "TR-30": "Hakkâri", + "TR-07": "Antalya", + "TR-66": "Yozgat", + "TR-63": "Şanlıurfa", + "TR-31": "Hatay", + "TR-44": "Malatya", + "TR-06": "Ankara", + "TR-68": "Aksaray", + "TR-09": "Aydın", + "TR-42": "Konya", + "TR-58": "Sivas", + "TR-21": "Diyarbakır Province", + "TR-65": "Van", + "TR-64": "Uşak", + "TR-51": "Niğde Province", + "TR-73": "Şırnak", + "TR-48": "Muğla", + "TR-04": "Ağrı", + "TR-45": "Manisa", + "TR-62": "Tunceli", + "TR-12": "Bingöl", + "TR-13": "Bitlis", + "TR-33": "Mersin", + "TR-24": "Erzincan", + "TR-38": "Kayseri", + "TR-10": "Balıkesir", + "TR-23": "Elazığ", + "TR-03": "Afyonkarahisar Province", + "TR-43": "Kütahya", + "TR-26": "Eskişehir", + "TR-47": "Mardin", + "TR-46": "Kahramanmaraş", + "TR-20": "Denizli", + "TR-72": "Batman", + "TR-02": "Adıyaman Province", + "TR-32": "Isparta", + "TR-80": "Osmaniye", + "TR-79": "Kilis", + "TR-27": "Gaziantep", + "TR-50": "Nevşehir Province", + "TR-49": "Muş", + "TR-40": "Kırşehir", + "TR-71": "Kırıkkale", + "TR-15": "Burdur", + "TR-70": "Karaman", + "TR-76": "Iğdır", + "TR-25": "Erzurum", + "TR-11": "Bilecik", + "ET-SO": "Somali", + "ET-OR": "Oromiya", + "ER-SK": "Northern Red Sea", + "ER-AN": "Anseba Region", + "ET-AM": "Amhara", + "ET-HA": "Harari Region", + "ET-GA": "Gambela", + "ER-DK": "Southern Red Sea Region", + "ET-AF": "Afar Region", + "ET-DD": "Dire Dawa", + "ER-GB": "Gash-Barka Region", + "ET-SN": "Southern Nations, Nationalities, and People's Region", + "ET-BE": "Bīnshangul Gumuz", + "ER-MA": "Maekel Region", + "ET-TI": "Tigray", + "ER-DU": "Debub Region", + "ET-AA": "Addis Ababa", + "EG-GH": "Gharbia", + "EG-WAD": "New Valley", + "EG-KB": "Qalyubia", + "EG-DK": "Dakahlia", + "EG-MNF": "Monufia", + "EG-BNS": "Beni Suweif", + "EG-SHG": "Sohag", + "EG-MT": "Matruh", + "EG-C": "Cairo Governorate", + "EG-JS": "South Sinai", + "EG-BH": "Beheira", + "EG-SIN": "North Sinai", + "EG-KN": "Qena", + "EG-KFS": "Kafr el-Sheikh", + "EG-SHR": "Sharqia", + "EG-MN": "Minya", + "EG-AST": "Asyut", + "EG-GZ": "Giza", + "EG-FYM": "Faiyum", + "EG-DT": "Damietta Governorate", + "EG-ASN": "Aswan", + "EG-PTS": "Port Said", + "EG-SUZ": "Suez", + "EG-ALX": "Alexandria", + "EG-LX": "Luxor", + "EG-IS": "Ismailia Governorate", + "EG-BA": "Red Sea", + "AL-12": "Vlorë County", + "SD-NO": "Northern", + "SD-GZ": "Al Jazīrah", + "SD-DN": "Northern Darfur", + "SD-KH": "Khartoum", + "SD-SI": "Sinnār", + "SD-NW": "White Nile", + "SD-DS": "Southern Darfur", + "SD-KA": "Kassala", + "SD-KS": "Southern Kordofan", + "SS-EC": "Central Equatoria", + "SD-RS": "Red Sea", + "SD-DC": "Central Darfur", + "SD-DE": "Eastern Darfur", + "SD-NB": "Blue Nile", + "SD-KN": "North Kordofan", + "SD-GK": "West Kordofan State", + "SD-GD": "Al Qaḑārif", + "SD-DW": "Western Darfur", + "SD-NR": "River Nile", + "SO-SO": "Sool", + "YE-AD": "Aden", + "BI-MA": "Makamba Province", + "BI-BR": "Bururi Province", + "BI-RM": "Rumonge", + "BI-MW": "Mwaro", + "BI-BL": "Bujumbura Rural Province", + "BI-BM": "Bujumbura Mairie Province", + "BI-MU": "Muramvya Province", + "BI-GI": "Gitega Province", + "BI-RY": "Ruyigi Province", + "BI-CA": "Cankuzo Province", + "BI-BB": "Bubanza Province", + "BI-CI": "Cibitoke Province", + "BI-NG": "Ngozi Province", + "BI-KY": "Kayanza Province", + "BI-MY": "Muyinga Province", + "BI-KI": "Kirundo Province", + "BI-RT": "Rutana Province", + "SC-04": "Au Cap", + "RU-TVE": "Tver Oblast", + "RU-ORE": "Orenburg Oblast", + "LV-113": "Valmiera", + "LV-089": "Saulkrasti Municipality", + "LV-058": "Ludza Municipality", + "LV-041": "Jelgava Municipality", + "LV-080": "Ropaži Municipality", + "LV-015": "Balvi Municipality", + "LV-042": "Jēkabpils Municipality", + "LV-054": "Limbaži Municipality", + "LV-016": "Bauska Municipality", + "LV-VEN": "Ventspils", + "LV-102": "Varakļāni Municipality", + "LV-091": "Sigulda Municipality", + "LV-101": "Valka", + "LV-097": "Talsi Municipality", + "LV-112": "South Kurzeme Municipality", + "LV-099": "Tukums Municipality", + "LV-067": "Ogre", + "LV-022": "Cēsis Municipality", + "LV-094": "Smiltene Municipality", + "LV-050": "Kuldīga Municipality", + "LV-002": "Aizkraukle Municipality", + "LV-087": "Salaspils Municipality", + "LV-088": "Saldus Municipality", + "LV-RIX": "Riga", + "LV-REZ": "Rezekne", + "LV-052": "Ķekava", + "LV-073": "Preili Municipality", + "LV-062": "Mārupe", + "LV-106": "Ventspils Municipality", + "LV-059": "Madona Municipality", + "LV-056": "Līvāni", + "LV-LPX": "Liepaja", + "LV-111": "Augsdaugava Municipality", + "LV-026": "Dobele Municipality", + "LV-047": "Krāslava Municipality", + "LV-011": "Ādaži", + "LV-JUR": "Jurmala", + "LV-JEL": "Jelgava", + "LV-068": "Olaine", + "LV-033": "Gulbene Municipality", + "LV-DGV": "Daugavpils", + "LV-077": "Rēzekne Municipality", + "LV-007": "Alūksne Municipality", + "RU-VLA": "Vladimir Oblast", + "RU-MOS": "Moscow Oblast", + "RU-CHE": "Chelyabinsk Oblast", + "RU-ROS": "Rostov Oblast", + "RU-VGG": "Volgograd Oblast", + "RU-KLU": "Kaluga Oblast", + "RU-SAM": "Samara Oblast", + "RU-KRS": "Kursk Oblast", + "RU-STA": "Stavropol Kray", + "RU-KGD": "Kaliningrad Oblast", + "RU-MOW": "Moscow", + "RU-SPE": "St.-Petersburg", + "RU-TA": "Tatarstan Republic", + "RU-TAM": "Tambov Oblast", + "RU-NIZ": "Nizhny Novgorod Oblast", + "RU-KDA": "Krasnodar Krai", + "RU-LIP": "Lipetsk Oblast", + "RU-KR": "Karelia", + "RU-KO": "Komi", + "RU-PER": "Perm Krai", + "RU-ME": "Mariy-El Republic", + "RU-TUL": "Tula Oblast", + "RU-SMO": "Smolensk Oblast", + "RU-KIR": "Kirov Oblast", + "RU-YAR": "Yaroslavl Oblast", + "RU-CU": "Chuvashia", + "RU-BA": "Bashkortostan Republic", + "RU-LEN": "Leningrad Oblast", + "RU-AD": "Adygeya Republic", + "RU-VLG": "Vologda Oblast", + "RU-UD": "Udmurtiya Republic", + "RU-VOR": "Voronezh Oblast", + "RU-SAR": "Saratov Oblast", + "RU-BEL": "Belgorod Oblast", + "RU-KOS": "Kostroma Oblast", + "RU-SE": "North Ossetia–Alania", + "RU-ORL": "Oryol oblast", + "RU-ARK": "Arkhangelskaya", + "RU-PSK": "Pskov Oblast", + "RU-RYA": "Ryazan Oblast", + "RU-BRY": "Bryansk Oblast", + "RU-ULY": "Ulyanovsk Oblast", + "RU-KB": "Kabardino-Balkariya Republic", + "RU-IVA": "Ivanovo Oblast", + "RU-SVE": "Sverdlovsk Oblast", + "RU-MUR": "Murmansk", + "RU-PNZ": "Penza Oblast", + "RU-MO": "Mordoviya Republic", + "RU-CE": "Chechnya", + "RU-NGR": "Novgorod Oblast", + "RU-DA": "Dagestan", + "RU-AST": "Astrakhan Oblast", + "RU-IN": "Ingushetiya Republic", + "RU-NEN": "Nenets", + "RU-KL": "Kalmykiya Republic", + "RU-KC": "Karachayevo-Cherkesiya Republic", + "AZ-ZAQ": "Zaqatala Rayon", + "AZ-YE": "Yevlax City", + "AZ-UCA": "Ujar Rayon", + "AZ-SM": "Sumqayit City", + "AZ-SKR": "Shamkir Rayon", + "AZ-QUS": "Qusar Rayon", + "AZ-QAX": "Qakh Rayon", + "AZ-QAB": "Qabala Rayon", + "AZ-MI": "Mingacevir City", + "AZ-QOB": "Gobustan Rayon", + "AZ-SAB": "Sabirabad Rayon", + "AZ-ABS": "Absheron Rayon", + "AZ-BA": "Baku City", + "AZ-XAC": "Khachmaz Rayon", + "AZ-ISM": "Ismayilli Rayon", + "AZ-AGC": "Aghjabadi Rayon", + "AZ-GA": "Ganja City", + "AZ-SMI": "Shamakhi Rayon", + "AZ-BAR": "Barda", + "EE-68": "Pärnumaa", + "EE-87": "Võrumaa", + "EE-45": "Ida-Virumaa", + "EE-84": "Viljandimaa", + "EE-37": "Harjumaa", + "EE-60": "Lääne-Virumaa", + "EE-81": "Valgamaa", + "EE-79": "Tartu", + "EE-52": "Järvamaa", + "EE-56": "Lääne", + "EE-50": "Jõgevamaa", + "EE-71": "Raplamaa", + "EE-64": "Põlvamaa", + "EE-74": "Saare", + "EE-39": "Hiiumaa", + "LT-VL": "Vilnius", + "LT-KU": "Kaunas", + "LT-UT": "Utena", + "LT-SA": "Siauliai", + "LT-KL": "Klaipėda County", + "LT-MR": "Marijampolė County", + "LT-TE": "Telsiai", + "LT-AL": "Alytus", + "LT-PN": "Panevėžys", + "LT-TA": "Tauragė County", + "UZ-QR": "Karakalpakstan", + "SE-BD": "Norrbotten County", + "SE-AC": "Västerbotten County", + "KZ-47": "Mangistauskaya Oblast'", + "KZ-23": "Atyrau Oblysy", + "KZ-27": "West Kazakhstan", + "KZ-15": "Aktyubinskaya Oblast'", + "KZ-35": "Karaganda", + "GE-SZ": "Samegrelo and Zemo Svaneti", + "GE-MM": "Mtskheta-Mtianeti", + "GE-SK": "Shida Kartli", + "GE-KA": "Kakheti", + "GE-TB": "K'alak'i T'bilisi", + "GE-AB": "Abkhazia", + "GE-IM": "Imereti", + "GE-KK": "Kvemo Kartli", + "GE-GU": "Guria", + "GE-RL": "Racha-Lechkhumi and Kvemo Svaneti", + "UA-59": "Sumy", + "GE-AJ": "Achara", + "GE-SJ": "Samtskhe-Javakheti", + "AM-ER": "Yerevan", + "AM-AV": "Armavir", + "AM-KT": "Kotayk", + "AM-GR": "Gegharkunik", + "AM-TV": "Tavush", + "AM-LO": "Lori", + "AM-SH": "Shirak", + "UA-14": "Donetsk", + "AM-AG": "Aragatsotn", + "MD-ED": "Raionul Edineţ", + "MD-GA": "Gagauzia", + "MD-NI": "Nisporeni", + "MD-AN": "Anenii Noi", + "MD-RI": "Rîşcani", + "MD-CU": "Chișinău Municipality", + "MD-UN": "Ungheni", + "MD-TA": "Taraclia", + "MD-SN": "Transnistria", + "MD-TE": "Teleneşti", + "MD-CA": "Cahul", + "MD-CT": "Cantemir", + "MD-SV": "Raionul Stefan Voda", + "MD-BR": "Briceni", + "MD-OR": "Orhei", + "MD-GL": "Glodeni", + "MD-ST": "Strășeni", + "MD-SO": "Raionul Soroca", + "MD-SD": "Şoldăneşti", + "MD-HI": "Hînceşti", + "MD-CS": "Raionul Causeni", + "MD-RE": "Rezina", + "MD-FA": "Făleşti", + "MD-SI": "Sîngerei", + "MD-DU": "Raionul Dubasari", + "MD-OC": "Raionul Ocniţa", + "MD-CR": "Criuleni", + "MD-FL": "Floreşti", + "MD-LE": "Leova", + "MD-IA": "Ialoveni", + "MD-DR": "Drochia", + "MD-CL": "Raionul Calarasi", + "MD-CM": "Cimişlia", + "MD-BD": "Bender Municipality", + "MD-BS": "Basarabeasca", + "MD-BA": "Municipiul Balti", + "BY-MI": "Minsk", + "BY-HO": "Homyel’ Voblasc’", + "UA-46": "Lviv", + "BY-BR": "Brest", + "BY-VI": "Vitebsk", + "BY-HR": "Grodnenskaya", + "BY-MA": "Mogilev", + "BY-HM": "Minsk City", + "FI-12": "Ostrobothnia", + "FI-11": "Pirkanmaa", + "FI-14": "North Ostrobothnia", + "FI-17": "Satakunta", + "FI-19": "Southwest Finland", + "FI-13": "North Karelia", + "FI-05": "Kainuu", + "FI-09": "Kymenlaakso", + "FI-16": "Paijat-Hame Region", + "FI-08": "Central Finland", + "FI-18": "Uusimaa", + "FI-15": "North Savo", + "FI-02": "South Karelia Region", + "FI-03": "South Ostrobothnia", + "FI-06": "Kanta-Häme", + "FI-10": "Lapland", + "FI-04": "Southern Savonia", + "FI-07": "Central Ostrobothnia", + "RO-CS": "Caras-Severin", + "RO-AB": "Alba", + "RO-TR": "Teleorman", + "RO-BZ": "Buzau", + "RO-HD": "Hunedoara", + "RO-BV": "Brasov", + "RO-NT": "Neamt", + "RO-BR": "Braila", + "RO-SJ": "Salaj", + "RO-SB": "Sibiu", + "RO-AG": "Arges", + "RO-MS": "Mures", + "RO-MH": "Mehedinti", + "RO-BT": "Botosani", + "RO-IF": "Ilfov", + "RO-AR": "Arad", + "RO-SV": "Suceava", + "RO-IS": "Iasi", + "RO-HR": "Harghita", + "RO-DB": "Dambovita", + "RO-MM": "Maramureş", + "RO-DJ": "Dolj", + "RO-GL": "Galati", + "RO-VN": "Vrancea", + "RO-VS": "Vaslui", + "RO-CL": "Calarasi", + "RO-TM": "Timis", + "RO-CT": "Constanta", + "RO-PH": "Prahova", + "RO-BN": "Bistrita-Nasaud", + "RO-BH": "Bihor", + "RO-BC": "Bacau", + "RO-GR": "Giurgiu", + "RO-IL": "Ialomita", + "RO-CV": "Covasna", + "RO-CJ": "Cluj", + "RO-TL": "Tulcea", + "RO-GJ": "Gorj", + "RO-OT": "Olt", + "RO-SM": "Satu Mare", + "RO-VL": "Valcea", + "RO-B": "Bucuresti", + "UA-71": "Cherkasy Oblast", + "UA-09": "Luhansk", + "UA-63": "Kharkiv", + "UA-35": "Kirovohrad Oblast", + "UA-18": "Zhytomyr", + "UA-05": "Vinnytsia", + "UA-68": "Khmelnytskyi Oblast", + "UA-12": "Dnipropetrovsk Oblast", + "UA-32": "Kyiv Oblast", + "UA-53": "Poltava Oblast", + "UA-56": "Rivne", + "UA-61": "Ternopil Oblast", + "UA-21": "Zakarpattia Oblast", + "UA-07": "Volyn", + "UA-77": "Chernivtsi", + "UA-23": "Zaporizhzhia", + "UA-26": "Ivano-Frankivsk Oblast", + "UA-51": "Odessa", + "UA-43": "Crimea", + "UA-48": "Mykolaiv", + "UA-74": "Chernihiv", + "UA-65": "Kherson Oblast", + "UA-40": "Sebastopol City", + "UA-30": "Kyiv City", + "HU-HB": "Hajdú-Bihar", + "HU-BE": "Bekes County", + "HU-SZ": "Szabolcs-Szatmár-Bereg", + "HU-JN": "Jász-Nagykun-Szolnok", + "HU-HE": "Heves megye", + "HU-CS": "Csongrad megye", + "HU-BZ": "Borsod-Abaúj-Zemplén", + "HU-BK": "Bács-Kiskun", + "HU-PE": "Pest megye", + "SK-PV": "Presov", + "SK-KI": "Kosice", + "SK-BC": "Banska Bystrica", + "BG-03": "Varna", + "BG-08": "Dobrich", + "BG-09": "Kardzhali", + "BG-21": "Smolyan", + "BG-23": "Sofia", + "BG-16": "Plovdiv", + "BG-04": "Veliko Tarnovo", + "BG-01": "Blagoevgrad", + "BG-10": "Kyustendil", + "BG-06": "Vratsa", + "BG-02": "Burgas", + "BG-19": "Silistra", + "BG-17": "Razgrad", + "BG-13": "Pazardzhik", + "BG-15": "Pleven", + "BG-28": "Yambol", + "BG-20": "Sliven", + "BG-11": "Lovech", + "BG-12": "Montana", + "BG-22": "Sofia-Capital", + "BG-05": "Vidin", + "BG-18": "Ruse", + "BG-26": "Haskovo", + "BG-24": "Stara Zagora", + "BG-25": "Targovishte", + "BG-07": "Gabrovo", + "BG-14": "Pernik", + "BG-27": "Shumen", + "GR-A": "East Macedonia and Thrace", + "GR-69": "Mount Athos", + "TR-67": "Zonguldak", + "TR-41": "Kocaeli", + "TR-34": "Istanbul", + "TR-08": "Artvin", + "TR-61": "Trabzon", + "TR-28": "Giresun", + "TR-16": "Bursa Province", + "TR-78": "Karabük Province", + "TR-29": "Gümüşhane Province", + "TR-77": "Yalova", + "TR-39": "Kırklareli", + "TR-22": "Edirne", + "TR-52": "Ordu", + "TR-74": "Bartın", + "TR-57": "Sinop", + "TR-37": "Kastamonu", + "TR-36": "Kars Province", + "TR-60": "Tokat", + "TR-55": "Samsun", + "TR-59": "Tekirdağ", + "TR-19": "Çorum", + "TR-05": "Amasya", + "TR-54": "Sakarya", + "TR-14": "Bolu", + "TR-53": "Rize Province", + "TR-18": "Çankırı", + "TR-17": "Canakkale", + "TR-81": "Duezce", + "TR-69": "Bayburt Province", + "TR-75": "Ardahan", + "PL-06": "Lublin", + "PL-14": "Mazovia", + "PL-18": "Subcarpathia", + "PL-26": "Świętokrzyskie", + "PL-12": "Lesser Poland", + "PL-28": "Warmia-Masuria", + "PL-20": "Podlasie", + "PL-10": "Łódź Voivodeship", + "NO-54": "Troms og Finnmark", + "AL-05": "Gjirokastër County", + "AL-06": "Korçë County", + "AL-09": "Dibër County", + "AL-03": "Elbasan County", + "AL-07": "Kukës County", + "MK-204": "Zrnovci", + "RS-VO": "Vojvodina", + "RS-00": "Belgrade", + "MK-605": "Zhelino", + "RS-15": "Zajecar", + "RS-18": "Raska", + "RS-24": "Pcinja", + "RS-23": "Jablanica", + "MK-202": "Vinica", + "MK-301": "Vevchani", + "RS-11": "Branicevo", + "RS-10": "Podunavlje", + "MK-404": "Vasilevo", + "MK-403": "Valandovo", + "RS-09": "Kolubara", + "RS-19": "Rasina", + "RS-17": "Morava", + "RS-12": "Sumadija", + "MK-101": "Veles", + "MK-609": "Tetovo", + "MK-608": "Tearce", + "RS-20": "Nisava", + "RS-13": "Pomoravlje", + "MK-108": "Sveti Nikole", + "MK-410": "Strumica", + "MK-312": "Struga", + "MK-211": "Shtip", + "MK-810": "Petrovec", + "MK-304": "Debarca", + "ME-17": "Rožaje Municipality", + "MK-607": "Mavrovo and Rostuša", + "MK-107": "Rosoman", + "MK-509": "Resen", + "MK-705": "Rankovce", + "MK-409": "Radovish", + "RS-21": "Toplica", + "MK-209": "Probishtip", + "MK-508": "Prilep", + "RS-16": "Zlatibor", + "RS-14": "Bor", + "MK-311": "Plasnica", + "RS-22": "Pirot", + "MK-310": "Ohrid", + "MK-408": "Novo Selo", + "MK-208": "Pehchevo", + "MK-106": "Negotino", + "MK-502": "Demir Hisar", + "MK-506": "Mogila", + "MK-603": "Vrapchishte", + "MK-814": "Opstina Centar", + "MK-703": "Kumanovo", + "MK-702": "Kriva Palanka", + "MK-701": "Kratovo", + "MK-811": "Saraj", + "MK-206": "Kochani", + "MK-307": "Kichevo", + "MK-104": "Kavadarci", + "MK-205": "Karbinci", + "MK-207": "Makedonska Kamenica", + "MK-606": "Jegunovce", + "MK-102": "Gradsko", + "MK-604": "Gostivar", + "MK-808": "Karposh", + "MK-313": "Centar Zhupa", + "RS-08": "Macva", + "MK-405": "Gevgelija", + "MK-407": "Konche", + "MK-105": "Lozovo", + "MK-805": "Gjorche Petrov", + "MK-503": "Dolneni", + "MK-103": "Demir Kapija", + "MK-806": "Zelenikovo", + "MK-303": "Debar", + "MK-308": "Makedonski Brod", + "MK-601": "Bogovinje", + "MK-401": "Bogdanci", + "MK-501": "Bitola", + "MK-201": "Berovo", + "MK-813": "Studenichani", + "MK-803": "Butel", + "MK-816": "Chucher Sandevo", + "MK-809": "Kisela Voda", + "MK-802": "Arachinovo", + "AO-MOX": "Moxico", + "NA-CA": "Zambezi Region", + "TZ-21": "Ruvuma", + "TZ-17": "Mtwara", + "TZ-12": "Lindi", + "ZW-MN": "Matabeleland North", + "ZW-MA": "Manicaland", + "ZW-ME": "Mashonaland East Province", + "ZW-MV": "Masvingo Province", + "ZW-MI": "Midlands Province", + "ZW-MW": "Mashonaland West", + "ZW-HA": "Harare", + "ZW-MS": "Matabeleland South Province", + "ZW-BU": "Bulawayo", + "ZM-01": "Western Province", + "ZM-06": "North-Western Province", + "ZM-05": "Northern Province", + "ZM-08": "Copperbelt", + "ZM-10": "Muchinga", + "ZM-07": "Southern Province", + "ZM-09": "Lusaka Province", + "ZM-03": "Eastern Province", + "ZM-02": "Central Province", + "KM-M": "Mohéli", + "KM-A": "Ndzuwani", + "KM-G": "Grande Comore", + "CD-HK": "Haut-Katanga", + "MW-C": "Central Region", + "MW-N": "Northern Region", + "MW-S": "Southern Region", + "LS-D": "Berea", + "LS-G": "Quthing", + "LS-H": "Qacha's Nek", + "LS-C": "Leribe", + "LS-J": "Mokhotlong", + "LS-F": "Mohale's Hoek District", + "LS-A": "Maseru", + "LS-E": "Mafeteng District", + "LS-B": "Butha-Buthe", + "BW-KG": "Kgalagadi District", + "BW-SE": "South-East", + "BW-KW": "Kweneng District", + "BW-CE": "Central District", + "BW-SP": "Selibe Phikwe", + "BW-KL": "Kgatleng District", + "BW-NW": "North-West", + "BW-LO": "Lobatse", + "BW-CH": "Chobe District", + "BW-SO": "Ngwaketsi", + "BW-GA": "Gaborone", + "BW-FR": "City of Francistown", + "BW-GH": "Ghanzi District", + "MU-GP": "Grand Port District", + "MU-MO": "Moka District", + "MU-PW": "Plaines Wilhems District", + "MU-FL": "Flacq District", + "MU-PA": "Pamplemousses District", + "MU-RR": "Riviere du Rempart District", + "MU-BL": "Black River District", + "MU-SA": "Savanne District", + "MU-AG": "Agalega Islands", + "MU-PL": "Port Louis District", + "SZ-LU": "Lubombo District", + "SZ-SH": "Shiselweni District", + "SZ-HH": "Hhohho", + "SZ-MA": "Manzini", + "ZA-GP": "Gauteng", + "ZA-NW": "North West", + "ZA-LP": "Limpopo", + "ZA-FS": "Orange Free State", + "ZA-MP": "Mpumalanga", + "ZA-KZN": "KwaZulu-Natal", + "ZA-EC": "Eastern Cape", + "ZA-NC": "Northern Cape", + "ZA-WC": "Western Cape", + "MZ-G": "Gaza Province", + "MZ-I": "Inhambane Province", + "MZ-Q": "Provincia de Zambezia", + "MZ-B": "Manica Province", + "MZ-T": "Tete", + "MZ-P": "Cabo Delgado Province", + "MZ-N": "Nampula", + "MZ-L": "Maputo Province", + "MZ-MPM": "Cidade de Maputo", + "MZ-S": "Sofala Province", + "LS-K": "Thaba-Tseka", + "IR-11": "Sistan and Baluchestan", + "TH-77": "Prachuap Khiri Khan", + "AF-HEL": "Helmand", + "AF-BAL": "Balkh", + "AF-KAN": "Kandahar", + "AF-KAB": "Kabul", + "AF-NAN": "Nangarhar", + "AF-HER": "Herat", + "AF-GHA": "Ghazni", + "AF-PAR": "Parwan", + "AF-BGL": "Baghlan", + "TH-76": "Phetchaburi", + "TH-84": "Surat Thani", + "TH-57": "Chiang Rai", + "TH-92": "Trang", + "TH-80": "Nakhon Si Thammarat", + "TH-52": "Lampang", + "TH-86": "Chumphon", + "TH-83": "Phuket", + "TH-82": "Phang Nga", + "TH-63": "Tak", + "TH-64": "Sukhothai", + "TH-50": "Chiang Mai", + "TH-70": "Ratchaburi", + "TH-85": "Ranong", + "TH-62": "Kamphaeng Phet", + "TH-56": "Phayao", + "TH-58": "Mae Hong Son", + "TH-61": "Uthai Thani", + "TH-51": "Lamphun", + "TH-54": "Phrae", + "TH-91": "Satun", + "TH-81": "Krabi", + "TH-71": "Kanchanaburi", + "TH-72": "Suphanburi", + "TH-60": "Nakhon Sawan", + "TH-18": "Chai Nat", + "TH-73": "Nakhon Pathom", + "TH-75": "Samut Songkhram", + "PK-SD": "Sindh", + "PK-IS": "Islamabad", + "PK-BA": "Balochistan", + "PK-PB": "Punjab", + "PK-KP": "Khyber Pakhtunkhwa", + "PK-JK": "Azad Jammu and Kashmir", + "PK-GB": "Gilgit-Baltistan", + "BD-E": "Rajshahi Division", + "BD-F": "Rangpur Division", + "BD-C": "Dhaka Division", + "BD-B": "Chittagong", + "BD-G": "Sylhet Division", + "BD-H": "Mymensingh Division", + "BD-D": "Khulna Division", + "BD-A": "Barisal Division", + "ID-SU": "North Sumatra", + "ID-AC": "Aceh", + "UZ-SA": "Samarqand Region", + "UZ-QA": "Qashqadaryo", + "UZ-SU": "Surxondaryo Region", + "UZ-BU": "Bukhara", + "TM-M": "Mary", + "TM-L": "Lebap", + "TJ-KT": "Viloyati Khatlon", + "TJ-SU": "Viloyati Sughd", + "TJ-RA": "Republican Subordination", + "TJ-GB": "Gorno-Badakhshan", + "TJ-DU": "Dushanbe", + "MY-02": "Kedah", + "LK-1": "Western Province", + "LK-3": "Southern Province", + "LK-6": "North Western Province", + "LK-8": "Province of Uva", + "LK-9": "Sabaragamuwa Province", + "LK-4": "Northern Province", + "LK-2": "Central Province", + "LK-7": "North Central Province", + "LK-5": "Eastern Province", + "BT-32": "Trongsa Dzongkhag", + "BT-15": "Thimphu District", + "BT-TY": "Trashi Yangste", + "BT-41": "Trashigang District", + "BT-22": "Dagana", + "BT-34": "Zhemgang District", + "BT-31": "Sarpang District", + "BT-23": "Punakha Dzongkhag", + "BT-11": "Paro", + "BT-42": "Mongar", + "BT-44": "Lhuntse", + "BT-13": "Haa", + "BT-GA": "Gasa", + "BT-12": "Chukha", + "IN-MH": "Maharashtra", + "IN-KA": "Karnataka", + "IN-TG": "Telangana", + "IN-HR": "Haryana", + "IN-NL": "Nagaland", + "IN-WB": "West Bengal", + "IN-TN": "Tamil Nadu", + "IN-GJ": "Gujarat", + "IN-KL": "Kerala", + "IN-AP": "Andhra Pradesh", + "IN-MN": "Manipur", + "IN-MP": "Madhya Pradesh", + "IN-GA": "Goa", + "IN-UP": "Uttar Pradesh", + "IN-OR": "Odisha", + "IN-UT": "Uttarakhand", + "IN-CT": "Chhattisgarh", + "IN-HP": "Himachal Pradesh", + "IN-AS": "Assam", + "IN-RJ": "Rajasthan", + "IN-TR": "Tripura", + "IN-ML": "Meghalaya", + "IN-JH": "Jharkhand", + "IN-PB": "Punjab", + "IN-BR": "Bihar", + "IN-JK": "Jammu and Kashmir", + "IN-SK": "Sikkim", + "IN-DH": "Dadra and Nagar Haveli and Daman and Diu", + "IN-AN": "Andaman and Nicobar", + "IN-MZ": "Mizoram", + "IN-PY": "Union Territory of Puducherry", + "IN-DL": "National Capital Territory of Delhi", + "IN-LA": "Ladakh", + "IN-LD": "Lakshadweep", + "IN-AR": "Arunachal Pradesh", + "IN-CH": "Chandigarh", + "CN-XZ": "Tibet", + "CN-GS": "Gansu", + "CN-XJ": "Xinjiang Uyghur Autonomous Region", + "CN-QH": "Qinghai", + "CN-YN": "Yunnan", + "MV-01": "Addu Atoll", + "MV-26": "Kaafu Atoll", + "MV-23": "Haa Dhaalu Atholhu", + "NP-P1": "Province 1", + "NP-P7": "Sudurpashchim Pradesh", + "NP-P5": "Lumbini Province", + "NP-P4": "Gandaki Pradesh", + "NP-P6": "Karnali Pradesh", + "NP-P3": "Bagmati Province", + "NP-P2": "Province 2", + "MM-03": "Magway Region", + "MM-02": "Bago Region", + "MM-05": "Tanintharyi Region", + "MM-17": "Shan State", + "MM-06": "Yangon", + "MM-04": "Mandalay Region", + "MM-11": "Kachin State", + "MM-15": "Mon State", + "MM-01": "Sagaing Region", + "MM-12": "Kayah State", + "MM-13": "Kayin State", + "MM-14": "Chin State", + "MM-07": "Ayeyarwady Region", + "BT-33": "Bumthang District", + "BT-14": "Samtse District", + "BT-24": "Wangdue Phodrang Dzongkhag", + "BT-45": "Samdrup Jongkhar", + "MV-00": "Southern Ari Atoll", + "MV-20": "Baa Atholhu", + "MV-17": "Dhaalu Atholhu", + "MV-14": "Faafu Atholhu", + "MV-29": "Gnyaviyani Atoll", + "MV-07": "Haa Alifu Atholhu", + "MV-03": "Faadhippolhu Atoll", + "MV-12": "Meemu Atholhu", + "MV-24": "Shaviyani Atholhu", + "MV-08": "Thaa Atholhu", + "MV-04": "Vaavu Atholhu", + "MV-MLE": "Male", + "AF-KHO": "Khowst", + "UZ-TK": "Tashkent", + "KZ-75": "Almaty", + "RU-KGN": "Kurgan Oblast", + "RU-TYU": "Tyumen Oblast", + "RU-ALT": "Altai Krai", + "RU-KYA": "Krasnoyarsk Krai", + "RU-KEM": "Kemerovo Oblast", + "RU-YAN": "Yamalo-Nenets", + "RU-KK": "Khakasiya Republic", + "RU-KHM": "Khanty-Mansia", + "RU-IRK": "Irkutsk Oblast", + "RU-AL": "Altai", + "RU-TOM": "Tomsk Oblast", + "RU-TY": "Republic of Tyva", + "RU-OMS": "Omsk Oblast", + "RU-NVS": "Novosibirsk Oblast", + "UZ-TO": "Tashkent Region", + "UZ-XO": "Xorazm Region", + "UZ-SI": "Sirdaryo Region", + "UZ-NG": "Namangan", + "UZ-NW": "Navoiy Region", + "UZ-JI": "Jizzakh Region", + "UZ-FA": "Fergana", + "UZ-AN": "Andijan Region", + "MN-057": "Dzavhan Aymag", + "MN-046": "Uvs Province", + "MN-065": "Govi-Altai Province", + "MN-071": "Bayan-OElgiy Aymag", + "MN-043": "Hovd", + "KZ-63": "East Kazakhstan", + "KZ-62": "Ulytau Region", + "KZ-10": "Abai Region", + "KZ-31": "Zhambyl Oblysy", + "KZ-59": "North Kazakhstan", + "KZ-11": "Aqmola Oblysy", + "KZ-33": "Jetisu Region", + "KZ-61": "South Kazakhstan", + "KZ-79": "Shymkent", + "KZ-39": "Qostanay Oblysy", + "KZ-43": "Qyzylorda Oblysy", + "KZ-19": "Almaty Oblysy", + "KZ-55": "Pavlodar Region", + "KZ-71": "Astana", + "KG-Y": "Issyk-Kul", + "KG-C": "Chuyskaya Oblast'", + "KG-J": "Jalal-Abad oblast", + "KG-T": "Talas", + "KG-O": "Osh Oblasty", + "KG-B": "Batken", + "KG-GB": "Gorod Bishkek", + "MU-RO": "Rodrigues", + "CN-GZ": "Guizhou", + "CN-AH": "Anhui", + "PW-150": "State of Koror", + "PW-002": "State of Aimeliik", + "PW-004": "State of Airai", + "PW-010": "State of Angaur", + "PW-212": "State of Melekeok", + "PW-227": "State of Ngeremlengui", + "VN-22": "Tinh Nghe An", + "VN-52": "Tinh Soc Trang", + "VN-51": "Tinh Tra Vinh", + "VN-49": "Tinh Vinh Long", + "VN-HN": "Hanoi", + "VN-18": "Tinh Ninh Binh", + "VN-06": "Tinh Yen Bai", + "VN-39": "Tinh GJong Nai", + "VN-SG": "Ho Chi Minh", + "VN-43": "Tinh Ba Ria-Vung Tau", + "VN-68": "Tinh Phu Tho", + "VN-73": "Hậu Giang", + "VN-70": "Tinh Vinh Phuc", + "VN-31": "Tinh Binh GJinh", + "VN-34": "Tinh Khanh Hoa", + "VN-13": "Tinh Quang Ninh", + "VN-32": "Tinh Phu Yen", + "VN-07": "Tinh Tuyen Quang", + "VN-46": "Tiền Giang", + "VN-HP": "Haiphong", + "VN-57": "Tinh Binh Duong", + "VN-27": "Tinh Quang Nam", + "VN-21": "Tinh Thanh Hoa", + "VN-CT": "Can Tho", + "VN-01": "Tinh Lai Chau", + "VN-69": "Tinh Thai Nguyen", + "VN-20": "Tinh Thai Binh", + "VN-37": "Tây Ninh Province", + "VN-41": "Long An", + "VN-44": "An Giang", + "VN-05": "Tinh Son La", + "VN-02": "Tinh Lao Cai", + "VN-45": "Tinh GJong Thap", + "VN-47": "Tinh Kien Giang", + "VN-25": "Tinh Quang Tri", + "VN-24": "Tinh Quang Binh", + "VN-29": "Quảng Ngãi Province", + "VN-30": "Gia Lai", + "VN-56": "Tinh Bac Ninh", + "VN-14": "Tinh Hoa Binh", + "VN-63": "Tinh Ha Nam", + "VN-26": "Tinh Thua Thien-Hue", + "VN-40": "Tinh Binh Thuan", + "VN-36": "Tinh Ninh Thuan", + "VN-67": "Tinh Nam GJinh", + "VN-54": "Tinh Bac Giang", + "VN-09": "Tinh Lang Son", + "VN-35": "Tinh Lam GJong", + "VN-71": "Tinh Dien Bien", + "VN-28": "Kon Tum", + "VN-66": "Tinh Hung Yen", + "VN-23": "Tinh Ha Tinh", + "VN-61": "Tinh Hai Duong", + "VN-03": "Tinh Ha Giang", + "VN-72": "Dak Nong", + "VN-58": "Tinh Binh Phuoc", + "VN-DN": "Da Nang", + "VN-33": "Đắk Lắk", + "VN-50": "Tinh Ben Tre", + "VN-55": "Tinh Bac Lieu", + "VN-04": "Tinh Cao Bang", + "VN-59": "Tinh Ca Mau", + "VN-53": "Tinh Bac Kan", + "TH-16": "Lopburi", + "TH-13": "Pathum Thani", + "TH-34": "Ubon Ratchathani", + "TH-43": "Nong Khai", + "TH-41": "Udon Thani", + "TH-39": "Nong Bua Lam Phu", + "TH-96": "Narathiwat", + "TH-35": "Yasothon", + "TH-94": "Pattani", + "TH-10": "Bangkok", + "TH-46": "Kalasin", + "TH-95": "Yala", + "TH-19": "Saraburi", + "TH-67": "Phetchabun", + "TH-55": "Nan", + "TH-27": "Sa Kaeo", + "TH-65": "Phitsanulok", + "TH-14": "Phra Nakhon Si Ayutthaya", + "TH-33": "Si Sa Ket", + "TH-21": "Rayong", + "TH-53": "Uttaradit", + "TH-23": "Trat", + "TH-90": "Songkhla", + "TH-45": "Roi Et", + "TH-32": "Surin", + "TH-48": "Nakhon Phanom", + "TH-22": "Chanthaburi", + "TH-66": "Phichit", + "TH-30": "Nakhon Ratchasima", + "TH-20": "Chon Buri", + "TH-17": "Sing Buri", + "TH-25": "Prachin Buri", + "TH-47": "Sakon Nakhon", + "TH-31": "Buriram", + "TH-24": "Chachoengsao", + "TH-74": "Samut Sakhon", + "TH-11": "Samut Prakan", + "TH-12": "Nonthaburi", + "TH-40": "Khon Kaen", + "TH-42": "Loei", + "TH-44": "Maha Sarakham", + "TH-93": "Phatthalung", + "TH-37": "Amnat Charoen", + "TH-38": "Bueng Kan", + "TH-26": "Nakhon Nayok", + "TH-49": "Mukdahan", + "TH-36": "Chaiyaphum", + "TH-15": "Ang Thong", + "ID-YO": "Yogyakarta", + "ID-JT": "Central Java", + "ID-JI": "East Java", + "ID-SN": "South Sulawesi", + "TL-VI": "Viqueque", + "ID-SG": "Southeast Sulawesi", + "ID-JB": "West Java", + "ID-BA": "Bali", + "ID-SA": "North Sulawesi", + "ID-PT": "Central Papua", + "ID-MU": "North Maluku", + "ID-LA": "Lampung", + "ID-KI": "East Kalimantan", + "ID-RI": "Riau", + "ID-JK": "Jakarta", + "ID-KU": "North Kalimantan", + "ID-KR": "Riau Islands", + "ID-KS": "South Kalimantan", + "ID-BT": "Banten", + "ID-JA": "Jambi", + "ID-BB": "Bangka–Belitung Islands", + "ID-NB": "West Nusa Tenggara", + "ID-SS": "South Sumatra", + "TL-CO": "Cova Lima", + "ID-SB": "West Sumatra", + "ID-PS": "South Papua", + "ID-KB": "West Kalimantan", + "ID-KT": "Central Kalimantan", + "TL-MF": "Manufahi", + "ID-SR": "West Sulawesi", + "ID-MA": "Maluku", + "TL-OE": "Oecusse", + "ID-ST": "Central Sulawesi", + "ID-PB": "West Papua", + "TL-MT": "Manatuto", + "TL-BO": "Bobonaro", + "TL-LI": "Liquiçá", + "ID-NT": "East Nusa Tenggara", + "ID-PA": "Papua", + "TL-AN": "Ainaro", + "ID-GO": "Gorontalo", + "TL-DI": "Dili", + "ID-BE": "Bengkulu", + "TL-BA": "Baucau", + "TL-AL": "Aileu", + "LA-VT": "Vientiane Prefecture", + "LA-HO": "Houaphan", + "LA-SV": "Khoueng Savannakhet", + "LA-SL": "Salavan", + "LA-CH": "Champasak", + "LA-XA": "Xaignabouli", + "LA-VI": "Vientiane Province", + "LA-BL": "Bolikhamsai", + "LA-KH": "Khammouan", + "LA-LM": "Louangnamtha", + "LA-XI": "Xiangkhouang", + "LA-XS": "Xaisomboun", + "LA-PH": "Khoueng Phongsali", + "LA-OU": "Khoueng Oudomxai", + "LA-AT": "Attapu", + "TW-NWT": "New Taipei", + "TW-YUN": "Yunlin", + "TW-TNN": "Tainan", + "TW-CHA": "Changhua", + "TW-NAN": "Nantou", + "TW-ILA": "Yilan", + "TW-KHH": "Kaohsiung", + "TW-TAO": "Taoyuan", + "TW-TXG": "Taichung City", + "TW-HUA": "Hualien", + "TW-PIF": "Pingtung", + "TW-MIA": "Miaoli", + "TW-CYQ": "Chiayi County", + "TW-HSZ": "Hsinchu County", + "TW-TTT": "Taitung", + "TW-TPE": "Taipei City", + "TW-PEN": "Penghu County", + "TW-LIE": "Lienchiang", + "TW-HSQ": "Hsinchu", + "TW-KIN": "Kinmen County", + "TW-KEE": "Keelung", + "TW-CYI": "Chiayi", + "PH-06": "Western Visayas", + "PH-03": "Central Luzon", + "PH-09": "Zamboanga Peninsula", + "PH-14": "Autonomous Region in Muslim Mindanao", + "PH-05": "Bicol", + "PH-01": "Ilocos", + "PH-41": "Mimaropa", + "PH-40": "Calabarzon", + "PH-00": "Metro Manila", + "PH-07": "Central Visayas", + "PH-10": "Northern Mindanao", + "PH-02": "Cagayan Valley", + "PH-12": "Soccsksargen", + "PH-15": "Cordillera", + "PH-08": "Eastern Visayas", + "PH-13": "Caraga", + "PH-11": "Davao", + "MY-07": "Penang", + "MY-06": "Pahang", + "MY-08": "Perak", + "MY-10": "Selangor", + "MY-01": "Johor", + "MY-11": "Terengganu", + "MY-13": "Sarawak", + "MY-12": "Sabah", + "MY-03": "Kelantan", + "MY-14": "Kuala Lumpur", + "MY-15": "Labuan", + "MY-04": "Melaka", + "MY-05": "Negeri Sembilan", + "MY-09": "Perlis", + "CN-SD": "Shandong", + "CN-SC": "Sichuan", + "CN-HN": "Hunan", + "CN-HA": "Henan", + "CN-ZJ": "Zhejiang", + "CN-LN": "Liaoning", + "CN-NX": "Ningxia Hui Autonomous Region", + "CN-JS": "Jiangsu", + "CN-HE": "Hebei", + "CN-SX": "Shanxi", + "CN-GD": "Guangdong", + "CN-FJ": "Fujian", + "CN-HB": "Hubei", + "CN-GX": "Guangxi", + "CN-JX": "Jiangxi", + "CN-HI": "Hainan", + "CN-TJ": "Tianjin", + "CN-SN": "Shaanxi", + "CN-SH": "Shanghai", + "CN-CQ": "Chongqing", + "CN-BJ": "Beijing", + "HK-NIS": "Islands District", + "HK-NYL": "Yuen Long District", + "HK-KYT": "Yau Tsim Mong", + "HK-KWT": "Wong Tai Sin", + "HK-HSO": "Southern", + "HK-HWC": "Wan Chai", + "HK-HEA": "Eastern", + "HK-NTM": "Tuen Mun", + "HK-NSK": "Sai Kung District", + "HK-NKT": "Kwai Tsing", + "HK-NTW": "Tsuen Wan District", + "HK-KKC": "Kowloon City", + "HK-NTP": "Tai Po District", + "HK-NST": "Sha Tin", + "HK-NNO": "North", + "HK-HCW": "Central and Western District", + "HK-KSS": "Sham Shui Po", + "HK-KKT": "Kwun Tong", + "BN-TU": "Tutong", + "BN-BE": "Belait", + "BN-BM": "Brunei-Muara District", + "BN-TE": "Temburong", + "KH-12": "Phnom Penh", + "KH-21": "Takeo", + "KH-20": "Svay Rieng", + "KH-19": "Stung Treng", + "KH-17": "Siem Reap", + "KH-11": "Mondolkiri", + "KH-14": "Prey Veng", + "KH-15": "Pursat", + "KH-5": "Kampong Speu", + "KH-22": "Otar Meanchey", + "KH-4": "Kampong Chhnang", + "KH-25": "Tboung Khmum", + "KH-8": "Kandal", + "KH-1": "Banteay Meanchey", + "KH-6": "Kampong Thom", + "KH-13": "Preah Vihear", + "KH-24": "Pailin", + "KH-23": "Kep", + "KH-9": "Koh Kong", + "KH-10": "Kratie", + "KH-18": "Preah Sihanouk", + "KH-3": "Kampong Cham", + "KH-2": "Battambang", + "KR-47": "Gyeongsangbuk-do", + "KR-30": "Daejeon", + "KR-42": "Gangwon-do", + "KR-11": "Seoul", + "KR-46": "Jeollanam-do", + "KR-41": "Gyeonggi-do", + "KR-43": "North Chungcheong", + "KR-48": "Gyeongsangnam-do", + "KR-26": "Busan", + "KR-44": "Chungcheongnam-do", + "KR-45": "Jeollabuk-do", + "KR-29": "Gwangju", + "KR-31": "Ulsan", + "KR-27": "Daegu", + "KR-49": "Jeju-do", + "KR-28": "Incheon", + "JP-30": "Wakayama", + "JP-17": "Ishikawa", + "JP-14": "Kanagawa", + "JP-28": "Hyōgo", + "JP-25": "Shiga", + "JP-37": "Kagawa", + "JP-22": "Shizuoka", + "JP-31": "Tottori", + "JP-21": "Gifu", + "JP-06": "Yamagata", + "JP-35": "Yamaguchi", + "JP-40": "Fukuoka", + "JP-08": "Ibaraki", + "JP-13": "Tokyo", + "JP-29": "Nara", + "JP-11": "Saitama", + "JP-15": "Niigata", + "JP-20": "Nagano", + "JP-19": "Yamanashi", + "JP-47": "Okinawa", + "JP-33": "Okayama", + "JP-10": "Gunma", + "JP-24": "Mie", + "JP-44": "Oita", + "JP-12": "Chiba", + "JP-23": "Aichi", + "JP-26": "Kyoto", + "JP-43": "Kumamoto", + "JP-32": "Shimane", + "JP-27": "Ōsaka", + "JP-46": "Kagoshima", + "JP-09": "Tochigi", + "JP-18": "Fukui", + "JP-36": "Tokushima", + "JP-42": "Nagasaki", + "JP-34": "Hiroshima", + "JP-39": "Kochi", + "JP-16": "Toyama", + "JP-45": "Miyazaki", + "JP-41": "Saga", + "JP-05": "Akita", + "JP-07": "Fukushima", + "JP-38": "Ehime", + "KP-01": "Pyongyang", + "TL-LA": "Lautém", + "TL-ER": "Ermera", + "RU-ZAB": "Transbaikal Territory", + "RU-AMU": "Amur Oblast", + "RU-BU": "Buryatiya Republic", + "RU-SA": "Sakha", + "RU-PRI": "Primorye", + "RU-KHA": "Khabarovsk", + "RU-YEV": "Yevrey (Jewish) Autonomous Oblast", + "MN-1": "Ulaanbaatar Hot", + "MN-067": "Bulgan", + "MN-073": "Arkhangai Province", + "MN-059": "Middle Govĭ", + "MN-053": "Ömnögovĭ", + "MN-051": "Suehbaatar Aymag", + "MN-039": "Hentiy Aymag", + "MN-063": "East Gobi Aymag", + "MN-041": "Khövsgöl Province", + "MN-035": "Orhon Aymag", + "MN-055": "South Khangay", + "MN-047": "Central Aymag", + "MN-064": "Govi-Sumber", + "MN-061": "East Aimak", + "MN-069": "Bayanhongor Aymag", + "MN-049": "Selenge Aymag", + "CN-HL": "Heilongjiang", + "CN-JL": "Jilin", + "CN-NM": "Inner Mongolia Autonomous Region", + "KP-04": "Chagang-do", + "AU-SA": "South Australia", + "AU-WA": "Western Australia", + "AU-NT": "Northern Territory", + "AU-QLD": "Queensland", + "FM-TRK": "State of Chuuk", + "FM-KSA": "State of Kosrae", + "FM-PNI": "State of Pohnpei", + "PG-ESW": "East Sepik Province", + "PG-EPW": "Enga Province", + "PG-SAN": "West Sepik Province", + "PG-EBR": "East New Britain Province", + "PG-NCD": "National Capital", + "PG-NPP": "Northern Province", + "PG-NSB": "Bougainville", + "PG-GPK": "Gulf Province", + "PG-WHM": "Western Highlands Province", + "PG-NIK": "New Ireland", + "PG-SHM": "Southern Highlands Province", + "PG-MPM": "Madang Province", + "PG-MRL": "Manus Province", + "PG-MPL": "Morobe Province", + "PG-CPK": "Chimbu Province", + "PG-CPM": "Central Province", + "PG-HLA": "Hela", + "PG-WBK": "West New Britain Province", + "PG-EHG": "Eastern Highlands Province", + "PG-WPD": "Western Province", + "SB-CE": "Central Province", + "SB-WE": "Western Province", + "SB-GU": "Guadalcanal Province", + "SB-CT": "Honiara", + "SB-IS": "Isabel Province", + "SB-ML": "Malaita Province", + "KI-G": "Gilbert Islands", + "TV-NUI": "Nui", + "TV-FUN": "Funafuti", + "TV-VAI": "Vaitupu", + "NR-01": "Aiwo", + "NR-13": "Uaboe", + "NR-03": "Anetan", + "NR-05": "Baiti", + "NR-10": "Ijuw", + "NR-09": "Ewa", + "NR-04": "Anibare", + "NR-02": "Anabar", + "JP-04": "Miyagi", + "JP-03": "Iwate", + "MH-MAJ": "Majuro Atoll", + "RU-KAM": "Kamchatka", + "RU-SAK": "Sakhalin Oblast", + "RU-MAG": "Magadan Oblast", + "RU-CHU": "Chukotka", + "JP-01": "Hokkaido", + "JP-02": "Aomori", + "PG-MBA": "Milne Bay Province", + "VU-TOB": "Torba", + "VU-SEE": "Shefa Province", + "VU-MAP": "Malampa Province", + "VU-SAM": "Sanma Province", + "VU-PAM": "Penama Province", + "VU-TAE": "Tafea Province", + "NC-L": "Loyalty Islands", + "NC-N": "North Province", + "NC-S": "South Province", + "AU-NSW": "New South Wales", + "AU-VIC": "Victoria", + "AU-TAS": "Tasmania", + "AU-ACT": "Australian Capital Territory", + "SB-RB": "Rennell and Bellona", + "SB-TE": "Temotu Province", + "SB-MK": "Makira-Ulawa Province", + "NZ-AUK": "Auckland", + "NZ-STL": "Southland", + "NZ-CAN": "Canterbury", + "NZ-MBH": "Marlborough", + "NZ-OTA": "Otago", + "NZ-WKO": "Waikato", + "NZ-GIS": "Gisborne", + "NZ-BOP": "Bay of Plenty", + "NZ-WGN": "Wellington", + "NZ-TKI": "Taranaki", + "NZ-MWT": "Manawatu-Wanganui", + "NZ-TAS": "Tasman", + "NZ-NTL": "Northland", + "NZ-HKB": "Hawke's Bay", + "NZ-WTC": "West Coast", + "NZ-NSN": "Nelson", + "FJ-C": "Central", + "FJ-W": "Western", + "FJ-N": "Northern", + "FJ-R": "Rotuma", + "LY-NQ": "An Nuqat al Khams", + "LY-MI": "Sha'biyat Misratah", + "LY-JU": "Al Jufrah", + "LY-TB": "Tripoli", + "LY-SR": "Surt", + "LY-ZA": "Az Zawiyah", + "LY-SB": "Sabha", + "LY-NL": "Nalut", + "LY-MQ": "Murzuq", + "LY-JG": "Jabal al Gharbi", + "LY-GT": "Ghat", + "LY-WD": "Wadi al Hayat", + "LY-MB": "Al Marqab", + "CM-CE": "Centre", + "CM-NW": "North-West Region", + "CM-AD": "Adamaoua Region", + "CM-LT": "Littoral", + "CM-EN": "Far North Region", + "CM-SU": "South", + "CM-SW": "South-West Region", + "CM-OU": "West Region", + "AO-LUA": "Luanda Province", + "AO-UIG": "Uíge", + "AO-ZAI": "Zaire", + "AO-MAL": "Malanje Province", + "AO-BGO": "Bengo Province", + "AO-CNO": "Cuanza Norte Province", + "AO-CUS": "Kwanza Sul", + "SN-ZG": "Ziguinchor", + "SN-TH": "Region de Thies", + "SN-SE": "Region de Sedhiou", + "SN-FK": "Fatick", + "SN-MT": "Matam", + "SN-DB": "Diourbel", + "SN-SL": "Saint-Louis", + "SN-DK": "Dakar", + "SN-KE": "Region de Kedougou", + "SN-KL": "Kaolack", + "SN-LG": "Louga", + "SN-KD": "Kolda", + "SN-TC": "Tambacounda", + "SN-KA": "Region de Kaffrine", + "CG-2": "Lékoumou", + "CG-16": "Pointe-Noire", + "CG-13": "Sangha", + "CG-8": "Cuvette", + "CG-9": "Niari", + "CG-BZV": "Brazzaville", + "CG-7": "Likouala", + "CG-5": "Kouilou", + "CG-15": "Cuvette-Ouest", + "CG-14": "Plateaux", + "CG-12": "Pool", + "PT-14": "Santarém", + "PT-02": "Beja", + "PT-07": "Évora", + "PT-11": "Lisbon", + "PT-08": "Faro", + "PT-15": "Setúbal", + "PT-04": "Bragança", + "PT-05": "Castelo Branco", + "PT-10": "Leiria", + "PT-12": "Portalegre", + "PT-30": "Madeira", + "LR-GG": "Grand Gedeh County", + "LR-GK": "Grand Kru County", + "LR-LO": "Lofa County", + "LR-BM": "Bomi County", + "LR-GP": "Gbarpolu County", + "LR-NI": "Nimba County", + "LR-RI": "River Cess County", + "LR-MO": "Montserrado County", + "LR-MG": "Margibi County", + "LR-MY": "Maryland County", + "LR-SI": "Sinoe County", + "LR-BG": "Bong County", + "LR-GB": "Grand Bassa County", + "CI-YM": "Yamoussoukro Autonomous District", + "CI-LC": "Lacs", + "CI-AB": "Abidjan", + "CI-ZZ": "Zanzan", + "CI-BS": "Bas-Sassandra", + "CI-GD": "Goh-Djiboua", + "CI-DN": "Denguele", + "CI-WR": "Woroba", + "CI-MG": "Montagnes", + "CI-SV": "Savanes", + "CI-CM": "Comoe", + "CI-SM": "Sassandra-Marahoue", + "CI-LG": "Lagunes", + "CI-VB": "Vallee du Bandama", + "GH-NP": "Northern Region", + "GH-UW": "Upper West Region", + "GH-AA": "Greater Accra Region", + "GH-AH": "Ashanti Region", + "GH-WP": "Western Region", + "GH-BO": "Bono", + "GH-EP": "Eastern Region", + "GH-CP": "Central Region", + "GH-WN": "Western North", + "GH-UE": "Upper East Region", + "GH-NE": "North East", + "GH-AF": "Ahafo", + "GH-BE": "Bono East", + "GH-TV": "Volta Region", + "GH-OT": "Oti", + "GH-SV": "Savannah", + "GQ-AN": "Annobon", + "GQ-WN": "Wele-Nzas", + "GQ-KN": "Kié-Ntem", + "GQ-CS": "Centro Sur", + "GQ-BN": "Bioko Norte", + "GQ-BS": "Bioko Sur", + "GQ-LI": "Litoral", + "CD-KG": "Kwango", + "CD-KN": "Kinshasa City", + "CD-KL": "Kwilu", + "CD-MN": "Mai-Ndombe", + "CD-BC": "Bas-Congo", + "NG-KD": "Kaduna State", + "NG-AD": "Adamawa", + "NG-LA": "Lagos", + "NG-TA": "Taraba State", + "NG-DE": "Delta", + "NG-NA": "Nasarawa State", + "NG-AK": "Akwa Ibom State", + "NG-AB": "Abia State", + "NG-BO": "Borno State", + "NG-YO": "Yobe State", + "NG-SO": "Sokoto State", + "NG-OG": "Ogun State", + "NG-RI": "Rivers State", + "NG-GO": "Gombe State", + "NG-OY": "Oyo State", + "NG-IM": "Imo State", + "NG-OS": "Osun State", + "NG-KW": "Kwara State", + "NG-AN": "Anambra", + "NG-ON": "Ondo State", + "NG-KO": "Kogi State", + "NG-EN": "Enugu State", + "NG-CR": "Cross River State", + "NG-FC": "FCT", + "NG-NI": "Niger State", + "NG-PL": "Plateau State", + "NG-KT": "Katsina State", + "NG-KN": "Kano State", + "NG-KE": "Kebbi", + "NG-EB": "Ebonyi State", + "NG-ED": "Edo", + "NG-BA": "Bauchi", + "NG-ZA": "Zamfara State", + "NG-EK": "Ekiti State", + "BF-11": "Plateau-Central", + "BF-04": "Centre-Est", + "BF-10": "Nord", + "BF-03": "Centre", + "BF-07": "Centre-Sud", + "BF-01": "Boucle du Mouhoun", + "BF-05": "Centre-Nord", + "BF-12": "Sahel", + "BF-13": "Sud-Ouest", + "BF-08": "Est", + "BF-09": "Hauts-Bassins", + "BF-02": "Cascades Region", + "TG-C": "Centrale", + "TG-S": "Savanes", + "TG-M": "Maritime", + "TG-P": "Plateaux", + "TG-K": "Kara", + "GW-BM": "Biombo", + "GW-GA": "Gabu", + "GW-CA": "Cacheu Region", + "GW-BA": "Bafata", + "GW-BL": "Bolama", + "GW-QU": "Quinara", + "GW-OI": "Oio Region", + "GW-BS": "Bissau", + "MR-09": "Tagant", + "MR-02": "Hodh El Gharbi", + "MR-06": "Trarza", + "MR-01": "Hodh Ech Chargi", + "MR-04": "Gorgol", + "MR-03": "Assaba", + "MR-11": "Tiris Zemmour", + "MR-10": "Guidimaka", + "MR-07": "Adrar", + "MR-05": "Brakna", + "MR-12": "Inchiri", + "CF-OP": "Ouham-Pendé", + "CF-SE": "Sangha-Mbaéré", + "CF-LB": "Lobaye", + "CF-KB": "Nana-Grébizi", + "CF-AC": "Ouham", + "CF-KG": "Kémo", + "CF-MP": "Ombella-M'Poko", + "CF-HS": "Mambéré-Kadéï", + "CF-NM": "Nana-Mambéré", + "CF-BGF": "Bangui", + "BJ-CO": "Collines Department", + "BJ-AK": "Atakora Department", + "BJ-OU": "Ouémé", + "BJ-BO": "Borgou Department", + "BJ-AQ": "Atlantique Department", + "BJ-AL": "Alibori", + "BJ-MO": "Mono", + "BJ-KO": "Kouffo Department", + "BJ-DO": "Donga", + "BJ-LI": "Littoral", + "BJ-ZO": "Zou Department", + "GA-8": "Ogooué-Maritime", + "GA-9": "Woleu-Ntem", + "GA-3": "Moyen-Ogooué", + "GA-5": "Nyanga", + "GA-4": "Ngouni", + "GA-6": "Ogooué-Ivindo", + "GA-1": "Estuaire", + "GA-7": "Ogooué-Lolo", + "GA-2": "Haut-Ogooué", + "SL-W": "Western Area", + "SL-NW": "North West", + "SL-N": "Northern Province", + "SL-E": "Eastern Province", + "SL-S": "Southern Province", + "ST-01": "Agua Grande", + "ST-P": "Principe", + "ST-04": "Lemba", + "SH-AC": "Ascension", + "GM-W": "West Coast", + "GM-L": "Lower River Division", + "GM-B": "Banjul", + "GM-M": "Central River", + "GM-N": "North Bank", + "GN-M": "Mamou Region", + "GN-L": "Labe Region", + "GN-C": "Conakry Region", + "GN-B": "Boke Region", + "GN-F": "Faranah", + "GN-D": "Kindia", + "GN-K": "Kankan Region", + "TD-CB": "Chari-Baguirmi Region", + "TD-MO": "Mayo-Kebbi Ouest", + "TD-MC": "Moyen-Chari Region", + "TD-BG": "Barh el Gazel", + "TD-TA": "Tandjilé", + "TD-KA": "Kanem Region", + "TD-ND": "N’Djaména", + "TD-LO": "Logone Occidental Region", + "TD-GR": "Guéra", + "TD-MA": "Mandoul", + "TD-HL": "Hadjer-Lamis", + "TD-BO": "Borkou Region", + "TD-ME": "Mayo-Kebbi Est", + "TD-LC": "Lac Region", + "TD-LR": "Logone Oriental Region", + "TD-BA": "Batha Region", + "TD-TI": "Tibesti Region", + "NE-5": "Tahoua", + "NE-7": "Zinder", + "NE-6": "Tillaberi Region", + "NE-4": "Maradi", + "NE-8": "Niamey", + "NE-3": "Dosso Region", + "NE-2": "Diffa", + "NE-1": "Agadez", + "ML-6": "Tombouctou", + "ML-7": "Gao", + "ML-10": "Taoudénit", + "ML-3": "Sikasso", + "ML-4": "Ségou", + "ML-5": "Mopti", + "ML-2": "Koulikoro", + "ML-8": "Kidal", + "ML-1": "Kayes", + "ML-BKO": "Bamako Region", + "MA-11": "Laayoune-Sakia El Hamra", + "MA-12": "Dakhla-Oued Ed-Dahab", + "TN-22": "Zaghouan Governorate", + "TN-32": "Jendouba Governorate", + "TN-31": "Béja Governorate", + "TN-14": "Manouba", + "TN-71": "Gafsa", + "TN-11": "Tunis Governorate", + "TN-72": "Tozeur Governorate", + "TN-83": "Tataouine", + "TN-42": "Kasserine Governorate", + "TN-21": "Nabeul Governorate", + "TN-33": "Kef Governorate", + "TN-52": "Monastir Governorate", + "TN-51": "Sousse Governorate", + "TN-12": "Ariana Governorate", + "TN-34": "Siliana Governorate", + "TN-53": "Mahdia Governorate", + "TN-43": "Sidi Bouzid Governorate", + "TN-61": "Sfax Governorate", + "TN-13": "Ben Arous Governorate", + "TN-73": "Kebili Governorate", + "TN-82": "Medenine Governorate", + "TN-81": "Gabès Governorate", + "TN-23": "Bizerte Governorate", + "TN-41": "Kairouan", + "DZ-35": "Boumerdes", + "DZ-25": "Constantine", + "DZ-07": "Biskra", + "DZ-16": "Algiers", + "DZ-17": "Djelfa", + "DZ-33": "Illizi", + "DZ-15": "Tizi Ouzou", + "DZ-36": "El Tarf", + "DZ-55": "Touggourt", + "DZ-18": "Jijel", + "DZ-13": "Tlemcen", + "DZ-38": "Tissemsilt", + "DZ-42": "Tipaza", + "DZ-37": "Tindouf", + "DZ-49": "Timimoun", + "DZ-29": "Mascara", + "DZ-14": "Tiaret", + "DZ-02": "Chlef", + "DZ-43": "Mila", + "DZ-12": "Tébessa", + "DZ-05": "Batna", + "DZ-06": "Béjaïa", + "DZ-11": "Tamanrasset", + "DZ-21": "Skikda", + "DZ-26": "Medea", + "DZ-10": "Bouira", + "DZ-09": "Blida", + "DZ-41": "Souk Ahras", + "DZ-48": "Relizane", + "DZ-31": "Oran", + "DZ-46": "Aïn Témouchent", + "DZ-22": "Sidi Bel Abbès", + "DZ-28": "M'Sila", + "DZ-19": "Sétif", + "DZ-20": "Saida", + "DZ-39": "El Oued", + "DZ-01": "Adrar", + "DZ-34": "Bordj Bou Arréridj", + "DZ-04": "Oum el Bouaghi", + "DZ-51": "Ouled Djellal", + "DZ-24": "Guelma", + "DZ-30": "Ouargla", + "DZ-45": "Naama", + "DZ-27": "Mostaganem", + "DZ-52": "Beni Abbes", + "DZ-44": "Aïn Defla", + "DZ-03": "Laghouat", + "DZ-40": "Khenchela", + "DZ-08": "Béchar", + "DZ-56": "Djanet", + "DZ-53": "In Salah", + "DZ-47": "Ghardaia", + "DZ-32": "El Bayadh", + "DZ-54": "In Guezzam", + "DZ-57": "El Mghair", + "DZ-23": "Annaba", + "DZ-58": "El Menia", + "ES-AN": "Andalusia", + "ES-EX": "Extremadura", + "ES-MC": "Murcia", + "ES-CM": "Castille-La Mancha", + "ES-VC": "Valencia", + "ES-CN": "Canary Islands", + "ES-IB": "Balearic Islands", + "ES-ML": "Melilla", + "IT-78": "Calabria", + "IT-82": "Sicily", + "IT-88": "Sardinia", + "IT-77": "Basilicate", + "IT-75": "Apulia", + "IT-72": "Campania", + "MA-08": "Draa-Tafilalet", + "MA-07": "Marrakesh-Safi", + "MA-09": "Souss-Massa", + "MA-06": "Casablanca-Settat", + "MA-05": "Beni Mellal-Khenifra", + "MA-04": "Rabat-Sale-Kenitra", + "MA-01": "Tanger-Tetouan-Al Hoceima", + "MA-03": "Fes-Meknes", + "MA-02": "Oriental", + "MA-10": "Guelmim-Oued Noun", + "MT-28": "Marsaxlokk", + "MT-68": "Iz-Zurrieq", + "MT-67": "Iz-Zejtun", + "MT-65": "Iz-Zebbug", + "MT-64": "Haz-Zabbar", + "MT-63": "Ix-Xghajra", + "MT-62": "Ix-Xewkija", + "MT-61": "Ix-Xaghra", + "MT-60": "Valletta", + "MT-58": "Tarxien", + "MT-59": "Ta' Xbiex", + "MT-56": "Tas-Sliema", + "MT-55": "Is-Siggiewi", + "MT-54": "Saint Venera", + "MT-53": "Saint Lucia", + "MT-51": "Saint Paul’s Bay", + "MT-52": "Sannat", + "MT-50": "Saint Lawrence", + "MT-48": "Saint Julian", + "MT-47": "Safi", + "MT-39": "Paola", + "MT-45": "Victoria", + "MT-44": "Il-Qrendi", + "MT-43": "Qormi", + "MT-42": "Il-Qala", + "MT-41": "Tal-Pieta", + "MT-38": "In-Naxxar", + "MT-37": "In-Nadur", + "MT-36": "Il-Munxar", + "MT-34": "L-Imsida", + "MT-33": "L-Imqabba", + "MT-32": "Il-Mosta", + "MT-31": "L-Imgarr", + "MT-30": "Il-Mellieha", + "MT-29": "L-Imdina", + "MT-27": "Marsaskala", + "MT-26": "Il-Marsa", + "MT-57": "Is-Swieqi", + "MT-25": "Luqa", + "MT-20": "Senglea", + "MT-19": "L-Iklin", + "MT-24": "Lija", + "MT-22": "Ta' Kercem", + "MT-21": "Il-Kalkara", + "MT-12": "Il-Gzira", + "MT-09": "Floriana", + "MT-03": "Il-Birgu", + "MT-18": "Il-Hamrun", + "MT-11": "Il-Gudja", + "MT-17": "Hal Ghaxaq", + "MT-16": "L-Ghasri", + "MT-15": "Hal Gharghur", + "MT-14": "L-Gharb", + "MT-13": "Ghajnsielem", + "MT-08": "Il-Fgura", + "MT-07": "Dingli", + "MT-06": "Bormla", + "MT-05": "Birzebbuga", + "MT-04": "Birkirkara", + "MT-02": "Balzan", + "MT-01": "Attard", + "MT-49": "Saint John", + "DZ-50": "Bordj Badji Mokhtar", + "SE-C": "Uppsala County", + "AT-3": "Lower Austria", + "AT-4": "Upper Austria", + "AT-5": "Salzburg", + "AT-6": "Styria", + "DK-81": "North Denmark", + "DK-85": "Zealand", + "DK-83": "South Denmark", + "DK-84": "Capital Region", + "DK-82": "Central Jutland", + "IS-7": "East", + "IS-8": "South", + "IS-5": "Northwest", + "IS-6": "Northeast", + "GB-WLS": "Wales", + "GB-ENG": "England", + "GB-SCT": "Scotland", + "GB-NIR": "Northern Ireland", + "IE-U": "Ulster", + "CH-BL": "Basel-Landschaft", + "CH-BE": "Bern", + "CH-SG": "Saint Gallen", + "CH-AG": "Aargau", + "CH-ZH": "Zurich", + "CH-GR": "Grisons", + "CH-FR": "Fribourg", + "CH-ZG": "Zug", + "CH-SO": "Solothurn", + "CH-VS": "Valais", + "CH-VD": "Vaud", + "CH-SZ": "Schwyz", + "CH-LU": "Lucerne", + "CH-NW": "Nidwalden", + "CH-SH": "Schaffhausen", + "CH-TG": "Thurgau", + "CH-AR": "Appenzell Ausserrhoden", + "CH-TI": "Ticino", + "CH-JU": "Jura", + "CH-GE": "Geneva", + "CH-UR": "Uri", + "CH-NE": "Neuchâtel", + "CH-OW": "Obwalden", + "CH-GL": "Glarus", + "CH-AI": "Appenzell Innerrhoden", + "CH-BS": "Basel-City", + "SE-Z": "Jämtland County", + "SE-O": "Västra Götaland County", + "SE-M": "Skåne County", + "SE-F": "Jönköping", + "SE-T": "Örebro County", + "SE-E": "Östergötland County", + "SE-G": "Kronoberg County", + "SE-I": "Gotland County", + "SE-H": "Kalmar", + "SE-U": "Västmanland County", + "SE-D": "Södermanland County", + "SE-N": "Halland County", + "SE-X": "Gävleborg County", + "SE-AB": "Stockholm County", + "SE-W": "Dalarna County", + "SE-S": "Värmland County", + "SE-Y": "Västernorrland County", + "SE-K": "Blekinge County", + "SJ-21": "Svalbard", + "PT-18": "Viseu", + "PT-03": "Braga", + "PT-13": "Porto", + "PT-17": "Vila Real", + "PT-09": "Guarda", + "PT-16": "Viana do Castelo", + "PT-06": "Coimbra", + "PT-01": "Aveiro", + "NL-OV": "Overijssel", + "NL-GE": "Gelderland", + "NL-DR": "Drenthe", + "NL-ZH": "South Holland", + "NL-NH": "North Holland", + "NL-FR": "Friesland", + "NL-NB": "North Brabant", + "NL-GR": "Groningen", + "NL-ZE": "Zeeland", + "NL-UT": "Utrecht", + "NL-FL": "Flevoland", + "NL-LI": "Limburg", + "AT-2": "Carinthia", + "AT-1": "Burgenland", + "AT-7": "Tyrol", + "AT-8": "Vorarlberg", + "AT-9": "Vienna", + "BE-VLG": "Flanders", + "BE-WAL": "Wallonia", + "BE-BRU": "Brussels Capital", + "DE-SN": "Saxony", + "DE-HE": "Hesse", + "DE-BY": "Bavaria", + "DE-BW": "Baden-Wurttemberg", + "DE-RP": "Rheinland-Pfalz", + "DE-NW": "North Rhine-Westphalia", + "DE-BB": "Brandenburg", + "DE-ST": "Saxony-Anhalt", + "DE-MV": "Mecklenburg-Vorpommern", + "DE-NI": "Lower Saxony", + "DE-TH": "Thuringia", + "DE-BE": "Land Berlin", + "DE-SH": "Schleswig-Holstein", + "DE-SL": "Saarland", + "DE-HB": "Bremen", + "DE-HH": "Hamburg", + "LU-WI": "Wiltz", + "LU-CL": "Clervaux", + "LU-GR": "Grevenmacher", + "LU-LU": "Luxembourg", + "LU-VD": "Vianden", + "LU-ES": "Esch-sur-Alzette", + "LU-CA": "Capellen", + "LU-DI": "Diekirch", + "LU-RM": "Remich", + "LU-EC": "Echternach", + "LU-ME": "Mersch", + "LU-RD": "Redange", + "IE-C": "Connacht", + "IE-M": "Munster", + "IE-L": "Leinster", + "FR-NAQ": "Nouvelle-Aquitaine", + "FR-HDF": "Hauts-de-France", + "FR-GES": "Grand Est", + "FR-20R": "Corsica", + "FR-CVL": "Centre-Val de Loire", + "FR-ARA": "Auvergne-Rhone-Alpes", + "FR-PDL": "Pays de la Loire", + "FR-BRE": "Brittany", + "FR-NOR": "Normandy", + "FR-IDF": "Île-de-France", + "FR-BFC": "Bourgogne-Franche-Comte", + "FR-PAC": "Provence-Alpes-Côte d'Azur", + "FR-OCC": "Occitanie", + "AD-06": "Sant Julià de Loria", + "AD-07": "Andorra la Vella", + "AD-03": "Encamp", + "AD-05": "Ordino", + "AD-08": "Escaldes-Engordany", + "AD-04": "La Massana", + "AD-02": "Canillo", + "LI-11": "Vaduz", + "LI-10": "Triesenberg", + "LI-09": "Triesen", + "LI-08": "Schellenberg", + "LI-04": "Mauren", + "LI-07": "Schaan", + "LI-06": "Ruggell", + "LI-05": "Planken", + "LI-03": "Gamprin", + "LI-02": "Eschen", + "LI-01": "Balzers", + "HU-BU": "Budapest", + "HU-SO": "Somogy megye", + "HU-VE": "Veszprem megye", + "HU-FE": "Fejér", + "HU-ZA": "Zala", + "HU-GS": "Győr-Moson-Sopron", + "HU-KE": "Komárom-Esztergom", + "HU-VA": "Vas", + "HU-BA": "Baranya", + "HU-NO": "Nograd megye", + "HU-TO": "Tolna megye", + "SK-ZI": "Zilina", + "SK-TC": "Trencin", + "SK-NI": "Nitra", + "SK-TA": "Trnava", + "SK-BL": "Bratislava", + "CZ-52": "Kralovehradecky kraj", + "CZ-20": "Central Bohemia", + "CZ-71": "Olomoucky kraj", + "CZ-72": "Zlín", + "CZ-64": "South Moravian", + "CZ-41": "Karlovarsky kraj", + "CZ-31": "Jihocesky kraj", + "CZ-42": "Ustecky kraj", + "CZ-63": "Kraj Vysocina", + "CZ-32": "Plzeň Region", + "CZ-80": "Moravskoslezsky kraj", + "CZ-51": "Liberecky kraj", + "CZ-10": "Prague", + "CZ-53": "Pardubicky kraj", + "PL-24": "Silesia", + "PL-16": "Opole Voivodeship", + "PL-32": "West Pomerania", + "PL-08": "Lubusz", + "PL-22": "Pomerania", + "PL-02": "Lower Silesia", + "PL-04": "Kujawsko-Pomorskie", + "PL-30": "Greater Poland", + "ES-PV": "Basque Country", + "ES-AR": "Aragon", + "ES-GA": "Galicia", + "ES-MD": "Madrid", + "ES-CL": "Castille and León", + "ES-CB": "Cantabria", + "ES-CT": "Catalonia", + "ES-AS": "Principality of Asturias", + "ES-NC": "Navarre", + "ES-RI": "La Rioja", + "NO-18": "Nordland", + "NO-46": "Vestland", + "NO-30": "Viken", + "NO-15": "Møre og Romsdal", + "NO-11": "Rogaland", + "NO-34": "Innlandet", + "NO-50": "Trøndelag", + "NO-42": "Agder", + "NO-38": "Vestfold og Telemark", + "NO-03": "Oslo County", + "IT-34": "Veneto", + "IT-45": "Emilia-Romagna", + "IT-36": "Friuli Venezia Giulia", + "IT-25": "Lombardy", + "IT-42": "Liguria", + "IT-32": "Trentino-Alto Adige", + "IT-62": "Lazio", + "IT-21": "Piedmont", + "IT-52": "Tuscany", + "IT-65": "Abruzzo", + "IT-57": "The Marches", + "IT-67": "Molise", + "IT-55": "Umbria", + "SM-09": "Serravalle", + "SM-07": "Castello di San Marino Citta", + "IT-23": "Aosta Valley", + "SM-02": "Chiesanuova", + "SM-08": "Castello di Montegiardino", + "SM-04": "Castello di Faetano", + "SM-06": "Castello di Borgo Maggiore", + "SM-01": "Castello di Acquaviva", + "AL-11": "Tirana", + "AL-01": "Berat County", + "AL-10": "Shkodër County", + "AL-08": "Lezhë County", + "AL-04": "Fier County", + "AL-02": "Durrës County", + "HR-17": "Split-Dalmatia", + "BA-SRP": "Republika Srpska", + "SI-193": "Obcina Zuzemberk", + "HR-16": "Vukovar-Sirmium", + "HR-19": "Dubrovnik-Neretva", + "HR-18": "Istria", + "HR-02": "County of Krapina-Zagorje", + "BA-BIH": "Federation of B&H", + "SI-192": "Obcina Zirovnica", + "SI-147": "Obcina Ziri", + "SI-057": "Obcina Lasko", + "SI-135": "Videm", + "SI-163": "Jezersko", + "SI-207": "Gorje", + "SI-204": "Sveta Trojica v Slovenskih Goricah", + "SI-113": "Slovenska Bistrica", + "SI-055": "Kungota", + "SI-052": "Kranj", + "SI-191": "Obcina Zetale", + "HR-01": "County of Zagreb", + "SI-146": "Obcina Zelezniki", + "SI-054": "Krsko", + "SI-143": "Obcina Zavrc", + "SI-190": "Obcina Zalec", + "HR-04": "Karlovac", + "HR-21": "City of Zagreb", + "SI-039": "Obcina Ivancna Gorica", + "SI-142": "Zagorje ob Savi", + "HR-13": "County of Zadar", + "HR-06": "County of Koprivnica-Križevci", + "ME-21": "Opstina Zabljak", + "SI-141": "Vuzenica", + "SI-162": "Horjul", + "SI-140": "Vrhnika", + "HR-03": "County of Sisak-Moslavina", + "SI-019": "Obcina Divaca", + "HR-08": "County of Primorje-Gorski Kotar", + "HR-20": "County of Međimurje", + "SI-189": "Vransko", + "SI-201": "Obcina Rence-Vogrsko", + "SI-139": "Vojnik", + "SI-138": "Vodice", + "SI-001": "Obcina Ajdovscina", + "HR-15": "Šibenik-Knin", + "HR-10": "County of Virovitica-Podravina", + "ME-20": "Ulcinj", + "SI-182": "Obcina Sveti Andraz v Slovenskih Goricah", + "SI-137": "Vitanje", + "HR-14": "County of Osijek-Baranja", + "SI-136": "Vipava", + "SI-206": "Obcina Smarjeske Toplice", + "HR-05": "County of Varaždin", + "SI-188": "Obcina Verzej", + "SI-118": "Obcina Sentilj", + "HR-07": "Bjelovar-Bilogora", + "SI-130": "Trebnje", + "SI-134": "Obcina Velike Lasce", + "SI-187": "Velika Polana", + "SI-087": "Obcina Ormoz", + "SI-032": "Grosuplje", + "HR-11": "County of Požega-Slavonia", + "SI-085": "Mestna Obcina Novo mesto", + "ME-24": "Tuzi", + "SI-132": "Obcina Turnisce", + "SI-110": "Sevnica", + "SI-186": "Trzin", + "SI-131": "Obcina Trzic", + "SI-185": "Obcina Trnovska vas", + "SI-120": "Sentjur", + "SI-199": "Mokronog-Trebelno", + "SI-129": "Trbovlje", + "SI-025": "Dravograd", + "SI-205": "Obcina Sveti Tomaz", + "SI-128": "Obcina Tolmin", + "ME-19": "Tivat", + "SI-133": "Velenje", + "HR-09": "County of Lika-Senj", + "SI-011": "Celje", + "SI-184": "Tabor", + "SI-181": "Sveta Ana", + "SI-094": "Postojna", + "SI-144": "Obcina Zrece", + "SI-127": "Obcina Store", + "SI-115": "Obcina Starse", + "HR-12": "Brod-Posavina", + "SI-065": "Obcina Loska Dolina", + "SI-017": "Obcina Crnomelj", + "SI-048": "Obcina Kocevje", + "SI-049": "Komen", + "SI-043": "Kamnik", + "SI-006": "Obcina Bovec", + "SI-202": "Obcina Sredisce ob Dravi", + "ME-07": "Danilovgrad", + "SI-029": "Gornja Radgona", + "SI-026": "Duplek", + "SI-023": "Obcina Domzale", + "SI-058": "Lenart", + "SI-036": "Idrija", + "SI-159": "Hajdina", + "SI-126": "Obcina Sostanj", + "SI-180": "Obcina Solcava", + "SI-179": "Obcina Sodrazica", + "SI-071": "Medvode", + "SI-112": "Slovenj Gradec", + "SI-194": "Obcina Smartno pri Litiji", + "SI-125": "Obcina Smartno ob Paki", + "SI-083": "Nazarje", + "SI-111": "Obcina Sezana", + "SI-124": "Obcina Smarje pri Jelsah", + "SI-114": "Slovenske Konjice", + "SI-160": "Obcina Hoce-Slivnica", + "SI-037": "Ig", + "SI-123": "Obcina Skofljica", + "SI-122": "Škofja Loka", + "SI-050": "Koper", + "SI-211": "Obcina Sentrupert", + "SI-119": "Obcina Sentjernej", + "SI-117": "Obcina Sencur", + "SI-183": "Obcina Sempeter-Vrtojba", + "SI-084": "Nova Gorica", + "SI-109": "Obcina Semic", + "SI-090": "Piran", + "ME-18": "Opstina Savnik", + "SI-033": "Obcina Salovci", + "SI-108": "Obcina Ruse", + "SI-107": "Rogatec", + "SI-105": "Obcina Rogasovci", + "SI-106": "Obcina Rogaska Slatina", + "SI-044": "Obcina Kanal ob Soci", + "ME-10": "Kotor", + "SI-177": "Ribnica na Pohorju", + "SI-104": "Ribnica", + "SI-091": "Pivka", + "SI-209": "Obcina Recica ob Savinji", + "SI-103": "Obcina Ravne na Koroskem", + "SI-013": "Cerknica", + "SI-067": "Obcina Luce", + "SI-102": "Radovljica", + "SI-101": "Radlje ob Dravi", + "SI-100": "Radenci", + "SI-099": "Obcina Radece", + "SI-098": "Obcina Race-Fram", + "SI-097": "Puconci", + "SI-069": "Obcina Majsperk", + "SI-096": "Ptuj", + "SI-092": "Obcina Podcetrtek", + "SI-175": "Prevalje", + "SI-008": "Brezovica", + "SI-038": "Ilirska Bistrica", + "SI-095": "Preddvor", + "SI-174": "Prebold", + "SI-173": "Polzela", + "SI-060": "Litija", + "SI-200": "Obcina Poljcane", + "SI-027": "Gorenja Vas-Poljane", + "SI-021": "Dobrova-Polhov Gradec", + "SI-093": "Podvelka", + "SI-051": "Kozje", + "SI-082": "Naklo", + "SI-172": "Podlehnik", + "ME-16": "Podgorica", + "ME-15": "Opstina Pluzine", + "ME-14": "Pljevlja", + "ME-13": "Opstina Plav", + "SI-041": "Jesenice", + "ME-05": "Budva", + "SI-089": "Pesnica", + "SI-081": "Muta", + "SI-088": "Osilnica", + "SI-171": "Oplotnica", + "SI-086": "Odranci", + "ME-12": "Opstina Niksic", + "SI-080": "Murska Sobota", + "SI-079": "Mozirje", + "SI-078": "Moravske Toplice", + "SI-077": "Obcina Moravce", + "SI-053": "Kranjska Gora", + "ME-11": "Mojkovac", + "SI-076": "Mislinja", + "SI-170": "Obcina Mirna Pec", + "SI-212": "Mirna", + "SI-075": "Miren-Kostanjevica", + "SI-169": "Obcina Miklavz na Dravskem Polju", + "SI-074": "Obcina Mezica", + "SI-073": "Metlika", + "SI-072": "Obcina Menges", + "SI-035": "Hrpelje-Kozina", + "SI-070": "Maribor", + "SI-198": "Makole", + "SI-068": "Lukovica", + "SI-167": "Lovrenc na Pohorju", + "SI-066": "Obcina Loski Potok", + "SI-208": "Log–Dragomer", + "SI-063": "Ljutomer", + "SI-061": "Ljubljana", + "SI-059": "Lendava", + "SI-056": "Kuzma", + "SI-166": "Obcina Krizevci", + "SI-197": "Kostanjevica na Krki", + "ME-09": "Opstina Kolasin", + "SI-047": "Kobilje", + "SI-046": "Obcina Kobarid", + "SI-045": "Obcina Kidricevo", + "SI-042": "Obcina Jursinci", + "SI-210": "Sveti Jurij v Slovenskih Goricah", + "SI-116": "Obcina Sveti Jurij ob Scavnici", + "SI-040": "Izola", + "ME-03": "Berane", + "ME-08": "Herceg Novi", + "SI-034": "Hrastnik", + "SI-064": "Logatec", + "SI-161": "Hodos", + "ME-22": "Gusinje", + "SI-158": "Grad", + "SI-031": "Gornji Petrovci", + "SI-030": "Gornji Grad", + "SI-028": "Obcina Gorisnica", + "SI-203": "Obcina Straza", + "SI-151": "Obcina Braslovce", + "SI-009": "Obcina Brezice", + "SI-010": "Obcina Tisina", + "SI-022": "Dol pri Ljubljani", + "SI-157": "Dolenjske Toplice", + "SI-007": "Brda", + "SI-156": "Dobrovnik", + "SI-155": "Dobrna", + "SI-154": "Dobje", + "SI-018": "Destrnik", + "SI-016": "Obcina Crna na Koroskem", + "SI-015": "Obcina Crensovci", + "ME-06": "Cetinje", + "SI-153": "Cerkvenjak", + "SI-012": "Cerklje na Gorenjskem", + "SI-152": "Cankova", + "BA-BRC": "Brčko", + "SI-005": "Borovnica", + "SI-004": "Bohinj", + "SI-003": "Obcina Bled", + "SI-149": "Bistrica ob Sotli", + "ME-04": "Bijelo Polje", + "SI-148": "Benedikt", + "SI-002": "Beltinci", + "ME-02": "Bar", + "SI-195": "Obcina Apace", + "SI-176": "Obcina Razkrizje", + "SI-014": "Cerkno", + "SI-020": "Dobrepolje", + "SI-213": "Ankaran", + "ME-23": "Petnjica", + "SI-178": "Selnica ob Dravi", + "AO-HUI": "Huíla", + "AO-CNN": "Cunene Province", + "AO-NAM": "Namibe Province", + "AO-CCU": "Cuando Cobango", + "AO-BGU": "Benguela", + "AO-BIE": "Bíe", + "AO-HUA": "Huambo", + "NA-KH": "Khomas", + "NA-OT": "Oshikoto", + "NA-ER": "Erongo", + "NA-KE": "Kavango East", + "NA-KU": "Kunene", + "NA-OD": "Otjozondjupa", + "NA-ON": "Oshana", + "NA-KA": "Karas", + "NA-OS": "Omusati", + "NA-KW": "Kavango West", + "NA-HA": "Hardap", + "NA-OW": "Ohangwena", + "SH-TA": "Tristan da Cunha", + "SH-HL": "Saint Helena", + "PT-20": "Azores", + "BB-01": "Christ Church", + "BB-02": "Saint Andrew", + "BB-04": "Saint James", + "BB-05": "Saint John", + "BB-10": "Saint Philip", + "BB-09": "Saint Peter", + "BB-08": "Saint Michael", + "BB-03": "Saint George", + "BB-11": "Saint Thomas", + "BB-06": "Saint Joseph", + "CV-BR": "Brava", + "CV-MA": "Maio", + "CV-RB": "Ribeira Brava", + "CV-TA": "Tarrafal", + "CV-RG": "Ribeira Grande", + "CV-TS": "Tarrafal de São Nicolau", + "CV-SF": "São Filipe", + "CV-SD": "São Domingos", + "CV-SL": "Sal", + "CV-CR": "Santa Cruz", + "CV-BV": "Boa Vista", + "CV-PR": "Praia", + "CV-PN": "Porto Novo", + "CV-MO": "Mosteiros", + "CV-PA": "Paul", + "CV-SS": "São Salvador do Mundo", + "CV-SO": "São Lourenço dos Órgãos", + "CV-SV": "São Vicente", + "CV-SM": "São Miguel", + "CV-CF": "Santa Catarina do Fogo", + "CV-RS": "Ribeira Grande de Santiago", + "CV-CA": "Santa Catarina", + "GY-ES": "Essequibo Islands-West Demerara Region", + "GY-EB": "East Berbice-Corentyne Region", + "GY-MA": "Mahaica-Berbice Region", + "GY-PM": "Pomeroon-Supenaam Region", + "GY-PT": "Potaro-Siparuni Region", + "GY-DE": "Demerara-Mahaica Region", + "GY-BA": "Barima-Waini Region", + "GY-UD": "Upper Demerara-Berbice Region", + "GY-UT": "Upper Takutu-Upper Essequibo Region", + "GY-CU": "Cuyuni-Mazaruni Region", + "SR-WA": "Distrikt Wanica", + "SR-NI": "Distrikt Nickerie", + "SR-CR": "Distrikt Coronie", + "SR-PR": "Distrikt Para", + "SR-PM": "Distrikt Paramaribo", + "SR-CM": "Distrikt Commewijne", + "SR-MA": "Distrikt Marowijne", + "SR-SA": "Distrikt Saramacca", + "SR-BR": "Distrikt Brokopondo", + "SR-SI": "Distrikt Sipaliwini", + "BR-PB": "Paraíba", + "BR-PI": "Piaui", + "BR-PE": "Pernambuco", + "BR-TO": "Tocantins", + "BR-MA": "Maranhao", + "BR-PA": "Para", + "BR-AP": "Amapa", + "BR-CE": "Ceara", + "BR-AL": "Alagoas", + "BR-AM": "Amazonas", + "BR-RN": "Rio Grande do Norte", + "BR-BA": "Bahia", + "BR-SE": "Sergipe", + "BR-RR": "Roraima", + "IS-2": "Southern Peninsula", + "IS-4": "Westfjords", + "IS-3": "West", + "IS-1": "Capital Region", + "GL-AV": "Avannaata", + "GL-QE": "Qeqqata", + "GL-QT": "Qeqertalik", + "GL-KU": "Kujalleq", + "GL-SM": "Sermersooq", + "PM-P": "Commune de Saint-Pierre", + "PM-M": "Miquelon-Langlade", + "AR-B": "Buenos Aires", + "AR-N": "Misiones", + "AR-C": "Buenos Aires F.D.", + "AR-E": "Entre Rios", + "AR-S": "Santa Fe", + "AR-P": "Formosa", + "AR-W": "Corrientes", + "AR-H": "Chaco", + "PY-6": "Departamento de Caazapa", + "PY-11": "Departamento Central", + "PY-14": "Departamento de Canindeyu", + "PY-4": "Departamento del Guaira", + "PY-12": "Departamento de Neembucu", + "PY-15": "Departamento de Presidente Hayes", + "PY-13": "Departamento del Amambay", + "PY-8": "Departamento de Misiones", + "PY-10": "Departamento del Alto Parana", + "PY-2": "Departamento de San Pedro", + "PY-3": "Departamento de la Cordillera", + "PY-9": "Departamento de Paraguari", + "PY-7": "Departamento de Itapua", + "PY-19": "Departamento de Boqueron", + "PY-5": "Departamento de Caaguazu", + "PY-16": "Departamento de Alto Paraguay", + "PY-1": "Departamento de Concepcion", + "PY-ASU": "Asuncion", + "UY-DU": "Durazno Department", + "UY-RV": "Rivera Department", + "UY-TT": "Treinta y Tres Department", + "UY-FD": "Florida", + "UY-CL": "Cerro Largo", + "UY-MO": "Montevideo Department", + "UY-FS": "Flores Department", + "UY-CA": "Canelones", + "UY-CO": "Colonia", + "UY-TA": "Tacuarembó Department", + "UY-SO": "Soriano", + "UY-LA": "Lavalleja", + "UY-SJ": "San José Department", + "UY-MA": "Maldonado", + "UY-SA": "Salto Department", + "UY-RO": "Rocha Department", + "UY-PA": "Paysandú Department", + "UY-RN": "Río Negro Department", + "UY-AR": "Artigas", + "BR-SC": "Santa Catarina", + "BR-SP": "Sao Paulo", + "BR-PR": "Parana", + "BR-RJ": "Rio de Janeiro", + "BR-MG": "Minas Gerais", + "BR-ES": "Espirito Santo", + "BR-RS": "Rio Grande do Sul", + "BR-GO": "Goias", + "BR-MT": "Mato Grosso", + "BR-MS": "Mato Grosso do Sul", + "BR-DF": "Federal District", + "VE-O": "Nueva Esparta", + "VE-A": "Distrito Federal", + "VE-G": "Carabobo", + "MX-TAM": "Tamaulipas", + "MX-SLP": "San Luis Potosí", + "VE-B": "Anzoátegui", + "VE-E": "Barinas", + "JM-03": "Saint Thomas", + "JM-07": "Trelawny", + "JM-02": "Saint Andrew", + "JM-11": "Saint Elizabeth", + "JM-06": "Parish of Saint Ann", + "JM-14": "Saint Catherine", + "JM-13": "Clarendon", + "JM-05": "Saint Mary", + "JM-04": "Portland", + "JM-10": "Westmoreland", + "JM-08": "Saint James", + "JM-12": "Manchester", + "JM-09": "Hanover", + "JM-01": "Kingston", + "DO-29": "Provincia de Monte Plata", + "DO-19": "Provincia de Hermanas Mirabal", + "DO-03": "Provincia de Baoruco", + "DO-25": "Provincia de Santiago", + "DO-21": "Provincia de San Cristobal", + "DO-09": "Provincia Espaillat", + "DO-11": "Provincia de La Altagracia", + "DO-05": "Provincia de Dajabon", + "DO-18": "Puerto Plata", + "DO-01": "Nacional", + "DO-08": "Provincia de El Seibo", + "DO-04": "Provincia de Barahona", + "DO-20": "Samaná", + "DO-23": "Provincia de San Pedro de Macoris", + "DO-22": "Provincia de San Juan", + "DO-31": "Provincia de San Jose de Ocoa", + "DO-32": "Provincia de Santo Domingo", + "DO-06": "Provincia Duarte", + "DO-15": "Provincia de Monte Cristi", + "DO-26": "Provincia de Santiago Rodriguez", + "DO-14": "Provincia Maria Trinidad Sanchez", + "DO-07": "Provincia de Elias Pina", + "DO-16": "Provincia de Pedernales", + "DO-02": "Provincia de Azua", + "DO-27": "Provincia de Valverde", + "DO-28": "Provincia de Monsenor Nouel", + "DO-17": "Provincia de Peravia", + "DO-13": "Provincia de La Vega", + "DO-10": "Provincia de Independencia", + "DO-12": "Provincia de La Romana", + "DO-24": "Provincia Sanchez Ramirez", + "DO-30": "Provincia de Hato Mayor", + "BQ-SA": "Saba", + "BQ-SE": "Sint Eustatius", + "BQ-BO": "Bonaire", + "MX-MEX": "México", + "MX-GRO": "Guerrero", + "MX-VER": "Veracruz", + "MX-HID": "Hidalgo", + "MX-PUE": "Puebla", + "MX-MOR": "Morelos", + "MX-OAX": "Oaxaca", + "MX-YUC": "Yucatán", + "MX-CHP": "Chiapas", + "MX-CMX": "Mexico City", + "MX-TLA": "Tlaxcala", + "MX-TAB": "Tabasco", + "MX-ROO": "Quintana Roo", + "MX-QUE": "Querétaro", + "MX-CAM": "Campeche", + "MX-NLE": "Nuevo León", + "CU-04": "Matanzas Province", + "CU-15": "Artemisa", + "CU-13": "Provincia de Santiago de Cuba", + "CU-03": "Havana", + "CU-10": "Las Tunas", + "CU-01": "Provincia de Pinar del Rio", + "CU-11": "Holguín Province", + "CU-14": "Guantánamo Province", + "CU-06": "Cienfuegos Province", + "CU-09": "Provincia de Camagueey", + "CU-12": "Granma Province", + "BS-WG": "West Grand Bahama District", + "BS-NE": "North Eleuthera", + "BS-SE": "South Eleuthera", + "BS-EG": "East Grand Bahama District", + "BS-SW": "Spanish Wells District", + "BS-NP": "New Providence District", + "BS-IN": "Inagua", + "BS-CO": "Central Abaco District", + "BS-FP": "City of Freeport District", + "BS-HI": "Harbour Island", + "BS-NO": "North Abaco District", + "BS-SS": "San Salvador District", + "BS-LI": "Long Island", + "BS-BY": "Berry Islands District", + "BS-CS": "Central Andros District", + "BS-BP": "Black Point District", + "BS-NS": "North Andros District", + "BM-HA": "Hamilton", + "BM-SH": "Southampton Parish", + "BM-SA": "Sandys Parish", + "BM-GC": "Saint George", + "BM-PB": "Pembroke Parish", + "BM-PG": "Paget", + "BM-SG": "Saint George's Parish", + "BM-HC": "Hamilton city", + "BM-DS": "Devonshire Parish", + "TT-POS": "Port of Spain", + "TT-PRT": "Princes Town", + "TT-TUP": "Tunapuna/Piarco", + "TT-SGE": "Sangre Grande", + "TT-TOB": "Tobago", + "TT-SIP": "Siparia", + "TT-SJL": "San Juan/Laventille", + "TT-SFO": "San Fernando", + "TT-MRC": "Mayaro", + "TT-CTT": "Couva-Tabaquite-Talparo", + "TT-PTF": "Point Fortin", + "TT-DMN": "Diego Martin", + "TT-PED": "Penal/Debe", + "TT-CHA": "Chaguanas", + "TT-ARI": "Borough of Arima", + "KN-15": "Trinity Palmetto Point", + "KN-02": "Saint Anne Sandy Point", + "KN-13": "Middle Island", + "KN-01": "Christ Church Nichola Town", + "KN-09": "Saint Paul Capesterre", + "KN-05": "Saint James Windward", + "KN-11": "Saint Peter Basseterre", + "KN-04": "Saint George Gingerland", + "KN-12": "Saint Thomas Lowland", + "KN-10": "Saint Paul Charlestown", + "KN-08": "Saint Mary Cayon", + "KN-03": "Saint George Basseterre", + "DM-06": "Saint Joseph", + "DM-04": "Saint George", + "DM-03": "Saint David", + "DM-05": "Saint John", + "DM-07": "Saint Luke", + "DM-02": "Saint Andrew", + "DM-11": "Saint Peter", + "DM-09": "Saint Patrick", + "AG-04": "Parish of Saint John", + "AG-03": "Parish of Saint George", + "AG-05": "Parish of Saint Mary", + "AG-06": "Parish of Saint Paul", + "AG-08": "Parish of Saint Philip", + "AG-10": "Barbuda", + "AG-07": "Parish of Saint Peter", + "LC-11": "Vieux-Fort", + "LC-08": "Micoud", + "LC-02": "Castries", + "LC-07": "Laborie", + "LC-06": "Gros-Islet", + "LC-03": "Choiseul", + "LC-12": "Canaries", + "VC-04": "Parish of Saint George", + "VC-05": "Parish of Saint Patrick", + "VC-02": "Parish of Saint Andrew", + "VC-06": "Grenadines", + "VC-01": "Parish of Charlotte", + "VC-03": "Parish of Saint David", + "GD-05": "Saint Mark", + "GD-03": "Saint George", + "GD-06": "Saint Patrick", + "GD-02": "Saint David", + "GD-10": "Carriacou and Petite Martinique", + "GD-01": "Saint Andrew", + "GD-04": "Saint John", + "BZ-OW": "Orange Walk District", + "BZ-CY": "Cayo District", + "BZ-BZ": "Belize District", + "BZ-TOL": "Toledo District", + "BZ-SC": "Stann Creek District", + "BZ-CZL": "Corozal District", + "SV-LI": "Departamento de La Libertad", + "SV-PA": "Departamento de La Paz", + "SV-US": "Departamento de Usulutan", + "SV-CH": "Departamento de Chalatenango", + "SV-SS": "Departamento de San Salvador", + "SV-SO": "Departamento de Sonsonate", + "SV-SV": "Departamento de San Vicente", + "SV-UN": "Departamento de La Union", + "SV-SA": "Departamento de Santa Ana", + "SV-SM": "Departamento de San Miguel", + "SV-CU": "Departamento de Cuscatlan", + "SV-AH": "Departamento de Ahuachapan", + "SV-MO": "Departamento de Morazan", + "GT-ZA": "Zacapa", + "GT-GU": "Guatemala", + "GT-TO": "Totonicapán", + "GT-SO": "Sololá", + "GT-SR": "Santa Rosa Department", + "GT-SM": "San Marcos", + "GT-SA": "Sacatepéquez", + "GT-PE": "Petén", + "GT-BV": "Baja Verapaz", + "GT-QC": "Quiché", + "GT-QZ": "Quetzaltenango", + "GT-IZ": "Izabal Department", + "GT-AV": "Alta Verapaz", + "GT-ES": "Departamento de Escuintla", + "GT-SU": "Suchitepeque", + "GT-JU": "Departamento de Jutiapa", + "GT-JA": "Jalapa", + "GT-CQ": "Chiquimula", + "GT-HU": "Departamento de Huehuetenango", + "GT-PR": "El Progreso", + "GT-CM": "Chimaltenango", + "HN-CR": "Cortés Department", + "HN-YO": "Yoro Department", + "HN-LE": "Lempira Department", + "HN-SB": "Santa Bárbara Department", + "HN-IB": "Bay Islands", + "HN-CL": "Colón Department", + "HN-AT": "Atlántida Department", + "HN-FM": "Francisco Morazán Department", + "HN-CM": "Comayagua Department", + "HN-CP": "Copán Department", + "HN-LP": "La Paz Department", + "HN-CH": "Choluteca Department", + "HN-VA": "Valle Department", + "HN-OL": "Olancho Department", + "HN-GD": "Gracias a Dios Department", + "HN-OC": "Ocotepeque Department", + "HN-IN": "Intibucá Department", + "HN-EP": "El Paraíso Department", + "NI-MN": "Managua Department", + "NI-MD": "Madriz Department", + "NI-CI": "Departamento de Chinandega", + "NI-MT": "Matagalpa Department", + "NI-RI": "Departamento de Rivas", + "NI-MS": "Masaya Department", + "NI-LE": "León Department", + "NI-CO": "Chontales Department", + "NI-JI": "Jinotega Department", + "NI-GR": "Granada Department", + "NI-ES": "Estelí Department", + "NI-BO": "Boaco Department", + "CR-A": "Alajuela Province", + "CR-SJ": "Provincia de San Jose", + "CR-P": "Puntarenas Province", + "CR-H": "Heredia Province", + "CR-C": "Cartago Province", + "CR-G": "Guanacaste Province", + "CR-L": "Limón Province", + "VE-V": "Zulia", + "VE-U": "Yaracuy", + "VE-D": "Aragua", + "VE-P": "Portuguesa", + "VE-J": "Guárico", + "VE-F": "Bolívar", + "VE-T": "Estado Trujillo", + "VE-Y": "Delta Amacuro", + "VE-I": "Falcón", + "VE-H": "Cojedes", + "VE-S": "Táchira", + "VE-K": "Lara", + "VE-R": "Sucre", + "VE-M": "Miranda", + "VE-Z": "Amazonas", + "VE-N": "Monagas", + "VE-L": "Mérida", + "VE-X": "Vargas", + "VE-W": "Dependencias Federales", + "VE-C": "Apure", + "EC-Z": "Zamora Chinchipe", + "EC-O": "Provincia de El Oro", + "EC-L": "Provincia de Loja", + "EC-G": "Provincia del Guayas", + "EC-R": "Provincia de Los Rios", + "EC-I": "Provincia de Imbabura", + "EC-T": "Tungurahua", + "EC-P": "Provincia de Pichincha", + "EC-C": "Provincia del Carchi", + "EC-M": "Provincia de Manabi", + "EC-N": "Provincia de Napo", + "EC-F": "Canar", + "EC-S": "Morona Santiago", + "EC-A": "Provincia del Azuay", + "EC-U": "Provincia de Sucumbios", + "EC-Y": "Pastaza", + "EC-X": "Provincia de Cotopaxi", + "EC-SD": "Provincia de Santo Domingo de los Tsachilas", + "EC-SE": "Provincia de Santa Elena", + "EC-E": "Provincia de Esmeraldas", + "EC-B": "Provincia de Bolivar", + "EC-H": "Chimborazo", + "EC-W": "Provincia de Galapagos", + "EC-D": "Orellana", + "BR-AC": "Acre", + "BR-RO": "Rondonia", + "CO-CUN": "Cundinamarca", + "CO-VAC": "Departamento del Valle del Cauca", + "CO-HUI": "Departamento del Huila", + "CO-SAN": "Departamento de Santander", + "CO-CAS": "Casanare Department", + "CO-ANT": "Antioquia", + "CO-CAL": "Caldas Department", + "CO-MET": "Departamento del Meta", + "CO-CAU": "Departamento del Cauca", + "CO-NSA": "Norte de Santander Department", + "CO-LAG": "La Guajira Department", + "CO-ATL": "Atlántico", + "CO-BOY": "Departamento de Boyaca", + "CO-PUT": "Departamento del Putumayo", + "CO-MAG": "Departamento del Magdalena", + "CO-SUC": "Departamento de Sucre", + "CO-CES": "Departamento del Cesar", + "CO-DC": "Bogota D.C.", + "CO-AMA": "Amazonas", + "CO-BOL": "Departamento de Bolivar", + "CO-NAR": "Departamento de Narino", + "CO-COR": "Departamento de Cordoba", + "CO-ARA": "Departamento de Arauca", + "CO-CHO": "Departamento del Choco", + "CO-RIS": "Departamento de Risaralda", + "CO-TOL": "Departamento de Tolima", + "CO-CAQ": "Departamento del Caqueta", + "CO-GUA": "Guainía Department", + "CO-VID": "Departamento del Vichada", + "CO-QUI": "Quindio Department", + "CO-VAU": "Departamento del Vaupes", + "CO-GUV": "Departamento del Guaviare", + "PE-LOR": "Loreto", + "PE-ANC": "Ancash", + "PE-LAL": "La Libertad", + "PE-TUM": "Tumbes", + "PE-HUC": "Region de Huanuco", + "PE-SAM": "Region de San Martin", + "PE-PIU": "Piura", + "PE-LAM": "Lambayeque", + "PE-UCA": "Ucayali", + "PE-AMA": "Amazonas", + "PE-CAJ": "Cajamarca", + "PA-6": "Provincia de Herrera", + "PA-9": "Provincia de Veraguas", + "PA-10": "Panamá Oeste Province", + "PA-5": "Provincia del Darien", + "PA-8": "Provincia de Panama", + "PA-3": "Provincia de Colon", + "PA-KY": "Guna Yala", + "PA-2": "Provincia de Cocle", + "PA-1": "Bocas del Toro Province", + "PA-NB": "Ngoebe-Bugle", + "PA-4": "Chiriquí Province", + "HT-SD": "Sud", + "HT-AR": "Departement de l'Artibonite", + "HT-OU": "Departement de l'Ouest", + "HT-NE": "Departement du Nord-Est", + "HT-NO": "Nord-Ouest", + "HT-CE": "Centre", + "HT-NI": "Departement de Nippes", + "HT-GA": "Grand'Anse", + "HT-ND": "Nord", + "BM-SM": "Smith's Parish", + "PA-EM": "Embera-Wounaan", + "AR-Q": "Neuquen", + "AR-Y": "Jujuy", + "AR-T": "Tucuman", + "AR-L": "La Pampa", + "AR-X": "Cordoba", + "AR-M": "Mendoza", + "AR-F": "La Rioja", + "AR-G": "Santiago del Estero", + "AR-R": "Rio Negro", + "AR-J": "San Juan", + "AR-Z": "Santa Cruz", + "AR-A": "Salta", + "AR-V": "Tierra del Fuego", + "AR-U": "Chubut", + "AR-K": "Catamarca", + "AR-D": "San Luis", + "CL-VS": "Region de Valparaiso", + "CL-CO": "Coquimbo Region", + "CL-BI": "Region del Biobio", + "CL-ML": "Maule Region", + "CL-LI": "O'Higgins Region", + "CL-RM": "Santiago Metropolitan", + "CL-AR": "Region de la Araucania", + "CL-AT": "Atacama", + "CL-LR": "Los Ríos Region", + "CL-AN": "Antofagasta", + "CL-NB": "Ñuble", + "CL-MA": "Region of Magallanes", + "CL-LL": "Los Lagos Region", + "CL-AP": "Region de Arica y Parinacota", + "CL-AI": "Aysén", + "CL-TA": "Tarapacá", + "BO-T": "Tarija Department", + "BO-B": "Beni Department", + "BO-H": "Chuquisaca Department", + "BO-S": "Santa Cruz Department", + "BO-C": "Departamento de Cochabamba", + "BO-P": "Potosí Department", + "BO-N": "Departamento de Pando", + "BO-O": "Oruro", + "BO-L": "La Paz Department", + "PE-LIM": "Lima region", + "PE-CAL": "Callao", + "PE-CUS": "Cusco", + "PE-PAS": "Pasco", + "PE-JUN": "Junin", + "PE-PUN": "Puno", + "PE-TAC": "Tacna", + "PE-ICA": "Ica", + "PE-ARE": "Arequipa", + "PE-MOQ": "Departamento de Moquegua", + "PE-HUV": "Huancavelica", + "PE-MDD": "Madre de Dios", + "PE-LMA": "Lima", + "PE-AYA": "Ayacucho", + "PE-APU": "Region de Apurimac", + "MX-SON": "Sonora", + "MX-ZAC": "Zacatecas", + "MX-CHH": "Chihuahua", + "MX-SIN": "Sinaloa", + "MX-JAL": "Jalisco", + "MX-DUR": "Durango", + "MX-BCN": "Baja California", + "MX-MIC": "Michoacán", + "MX-NAY": "Nayarit", + "MX-GUA": "Guanajuato", + "MX-COA": "Coahuila", + "MX-COL": "Colima", + "MX-BCS": "Baja California Sur", + "MX-AGU": "Aguascalientes", + "PF-M": "Iles Marquises", + "PF-T": "Iles Tuamotu-Gambier", + "KI-L": "Line Islands", + "TK-F": "Fakaofo", + "TO-04": "Tongatapu", + "TO-01": "ʻEua", + "TO-05": "Vava'u", + "TO-03": "Niuas", + "NZ-CIT": "Chatham Islands", + "PF-S": "Leeward Islands", + "PF-V": "Iles du Vent", + "WF-UV": "Uvea", + "WF-SG": "Sigave", + "WF-AL": "Alo", + "WS-VF": "Va'a-o-Fonoti", + "WS-PA": "Palauli", + "WS-SA": "Satupa'itea", + "WS-FA": "Fa'asaleleaga", + "WS-GI": "Gagaifomauga", + "WS-GE": "Gaga'emauga", + "WS-AL": "Aiga-i-le-Tai", + "WS-AT": "Atua", + "WS-AA": "A'ana", + "WS-TU": "Tuamasaga", + "FM-YAP": "State of Yap", + "PW-224": "State of Ngatpang", + "PW-218": "State of Ngarchelong", + "MP-R": "Rota", + "UM-79": "Wake Island", + "US-TX": "Texas", + "US-AL": "Alabama", + "US-VA": "Virginia", + "US-WV": "West Virginia", + "US-AR": "Arkansas", + "US-DE": "Delaware", + "US-FL": "Florida", + "US-GA": "Georgia", + "US-IL": "Illinois", + "US-IN": "Indiana", + "US-MD": "Maryland", + "US-KS": "Kansas", + "US-KY": "Kentucky", + "US-MO": "Missouri", + "US-NC": "North Carolina", + "US-OH": "Ohio", + "US-OK": "Oklahoma", + "US-SC": "South Carolina", + "US-TN": "Tennessee", + "US-DC": "District of Columbia", + "US-LA": "Louisiana", + "US-MS": "Mississippi", + "US-NJ": "New Jersey", + "US-PA": "Pennsylvania", + "VI-T": "Saint Thomas Island", + "VI-C": "Saint Croix Island", + "VI-J": "Saint John Island", + "US-CT": "Connecticut", + "US-IA": "Iowa", + "US-MA": "Massachusetts", + "US-ME": "Maine", + "US-MI": "Michigan", + "US-MN": "Minnesota", + "US-NE": "Nebraska", + "US-NY": "New York", + "US-SD": "South Dakota", + "US-WI": "Wisconsin", + "US-ND": "North Dakota", + "US-NH": "New Hampshire", + "US-RI": "Rhode Island", + "US-VT": "Vermont", + "US-AZ": "Arizona", + "US-CA": "California", + "US-NM": "New Mexico", + "US-UT": "Utah", + "US-CO": "Colorado", + "US-NV": "Nevada", + "US-ID": "Idaho", + "US-AK": "Alaska", + "US-MT": "Montana", + "US-OR": "Oregon", + "US-WA": "Washington", + "US-WY": "Wyoming", + "US-HI": "Hawaii", + "AS-W": "Western District", + "AS-M": "Manu'a District", + "AS-S": "Swains Island", + "AS-E": "Eastern District", + "CA-BC": "British Columbia", + "CA-NS": "Nova Scotia", + "CA-SK": "Saskatchewan", + "CA-AB": "Alberta", + "CA-NB": "New Brunswick", + "CA-ON": "Ontario", + "CA-QC": "Quebec", + "CA-PE": "Prince Edward Island", + "CA-MB": "Manitoba", + "CA-NU": "Nunavut", + "CA-NL": "Newfoundland and Labrador", + "CA-YT": "Yukon", + "CA-NT": "Northwest Territories", + "ES-CE": "Ceuta", + "MM-18": "Nay Pyi Taw", + "MY-16": "Putrajaya", + "MH-KWA": "Kwajalein Atoll", + "NG-BE": "Benue State", + "SI-196": "Cirkulane", + "SI-164": "Komenda", + "WS-VS": "Vaisigano", + "MP-T": "Tinian", + "PS-JEM": "Quds Governorate", + "MH-WTH": "Wotho Atoll", + "YE-MA": "Ma’rib", + "YE-SD": "Şa‘dah", + "TK-N": "Nukunonu", + "TK-A": "Atafu", + "SJ-22": "Jan Mayen", + "KI-P": "Phoenix Islands", + "TV-NKL": "Nukulaelae", + "TV-NMA": "Nanumea", + "TV-NMG": "Nanumanga", + "TV-NIT": "Niutao", + "TV-NKF": "Nukufetau", + "NR-14": "Yaren", + "NR-07": "Buada", + "KE-02": "Bomet", + "PW-370": "State of Sonsorol", + "PW-100": "State of Kayangel", + "PW-350": "State of Peleliu", + "PW-222": "State of Ngardmau", + "BW-ST": "Sowa Town", + "MP-N": "Northern Islands", + "MP-S": "Saipan", + "MV-05": "Laamu Atholhu", + "MU-CC": "Cargados Carajos", + "BW-JW": "Jwaneng", + "MT-10": "Il-Fontana", + "MT-40": "Pembroke", + "SI-165": "Kostel", + "TO-02": "Ha'apai", + "MK-801": "Aerodrom", + "SC-14": "Grand Anse Praslin", + "SI-121": "Obcina Skocjan", + "MV-02": "Northern Ari Atoll", + "MR-15": "Nouakchott Sud", + "TD-EE": "Ennedi-Est", + "LA-XE": "Khoueng Xekong", + "NG-BY": "Bayelsa State", + "SC-01": "Anse-aux-Pins", + "SC-20": "Pointe Larue", + "SC-06": "Baie Lazare", + "CU-08": "Ciego de Ávila Province", + "MR-13": "Nouakchott Ouest", + "SC-15": "La Digue", + "SC-18": "Mont Fleuri", + "SB-CH": "Choiseul", + "KR-50": "Sejong-si", + "BS-GC": "Grand Cay District", + "SC-03": "Anse Etoile", + "SC-07": "Baie Sainte Anne", + "SC-09": "Bel Air", + "SC-17": "Mont Buxton", + "SC-24": "Les Mamelles", + "AS-R": "Rose Island", + "NR-06": "Boe", + "NR-11": "Meneng", + "NR-12": "Nibok", + "NR-08": "Denigomodu", + "GQ-DJ": "Djibloho" +} diff --git a/src/Aptabase.csproj b/src/Aptabase.csproj new file mode 100755 index 0000000..c0faa34 --- /dev/null +++ b/src/Aptabase.csproj @@ -0,0 +1,48 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Data/ClickHouseMigrationRunner.cs b/src/Data/ClickHouseMigrationRunner.cs new file mode 100755 index 0000000..eae95e0 --- /dev/null +++ b/src/Data/ClickHouseMigrationRunner.cs @@ -0,0 +1,36 @@ +using Dapper; +using ClickHouse.Client.ADO; +using Aptabase.Features; + +namespace Aptabase.Data; + +public interface IClickHouseMigrationRunner +{ + void MigrateUp(); +} + +public class ClickHouseMigrationRunner : IClickHouseMigrationRunner +{ + private readonly ClickHouseConnection _conn; + private readonly EnvSettings _env; + private readonly ILogger _logger; + + public ClickHouseMigrationRunner(ClickHouseConnection conn, EnvSettings env, ILogger logger) + { + _env = env ?? throw new ArgumentNullException(nameof(env)); + _conn = conn ?? throw new ArgumentNullException(nameof(conn)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void MigrateUp() + { + var pathToMigrations = Path.Combine(_env.EtcDirectoryPath, "clickhouse"); + var files = Directory.GetFiles(pathToMigrations).OrderBy(x => x); + foreach (string file in files) + { + var content = File.ReadAllText(file); + _logger.LogDebug($"Executing ClickHouse migration: {file}"); + _conn.Execute(content); + } + } +} \ No newline at end of file diff --git a/src/Data/DbContext.cs b/src/Data/DbContext.cs new file mode 100755 index 0000000..356f17b --- /dev/null +++ b/src/Data/DbContext.cs @@ -0,0 +1,20 @@ +using System.Data.Common; + +namespace Aptabase.Data; + +public interface IDbContext +{ + DbConnection Connection { get; } +} + +public class DbContext : IDbContext +{ + private readonly DbDataSource _ds; + + public DbContext(DbDataSource ds) + { + _ds = ds ?? throw new ArgumentNullException(nameof(ds)); + } + + public DbConnection Connection => _ds.CreateConnection(); +} \ No newline at end of file diff --git a/src/Data/Migrations/0001_InitialSetup.cs b/src/Data/Migrations/0001_InitialSetup.cs new file mode 100755 index 0000000..f3eeb6a --- /dev/null +++ b/src/Data/Migrations/0001_InitialSetup.cs @@ -0,0 +1,44 @@ +using FluentMigrator; + +namespace Aptabase.Data.Migrations; + +[Migration(0001)] +public class InitialSetup : Migration +{ + public override void Up() + { + Create.Table("apps") + .WithNanoIdColumn("id").PrimaryKey() + .WithNanoIdColumn("owner_id") + .WithColumn("name").AsString(40).NotNullable() + .WithColumn("app_key").AsString(20).NotNullable() + .WithColumn("deleted_at").AsDateTime().Nullable() + .WithTimestamps(); + + Create.Index("idx_apps_owner") + .OnTable("apps") + .OnColumn("owner_id").Ascending(); + + Create.Index("idx_apps_key") + .OnTable("apps") + .OnColumn("app_key").Ascending() + .WithOptions().Unique(); + + Create.Table("users") + .WithNanoIdColumn("id").PrimaryKey() + .WithColumn("name").AsString(40).NotNullable() + .WithColumn("email").AsString(300).NotNullable() + .WithTimestamps(); + + Create.Index("idx_users_email") + .OnTable("users") + .OnColumn("email") + .Unique(); + } + + public override void Down() + { + Delete.Table("apps"); + Delete.Table("users"); + } +} \ No newline at end of file diff --git a/src/Data/Migrations/0002_UserProvider.cs b/src/Data/Migrations/0002_UserProvider.cs new file mode 100755 index 0000000..0a9084e --- /dev/null +++ b/src/Data/Migrations/0002_UserProvider.cs @@ -0,0 +1,21 @@ +using FluentMigrator; + +namespace Aptabase.Data.Migrations; + +[Migration(0002)] +public class UserProvider : Migration +{ + public override void Up() + { + Create.Table("user_providers") + .WithColumn("provider_name").AsString(40).NotNullable().PrimaryKey() + .WithColumn("provider_uid").AsString(40).NotNullable().PrimaryKey() + .WithNanoIdColumn("user_id").ForeignKey("users", "id") + .WithTimestamps(); + } + + public override void Down() + { + Delete.Table("user_providers"); + } +} \ No newline at end of file diff --git a/src/Data/Migrations/0003_AddBlobs.cs b/src/Data/Migrations/0003_AddBlobs.cs new file mode 100755 index 0000000..8dd4280 --- /dev/null +++ b/src/Data/Migrations/0003_AddBlobs.cs @@ -0,0 +1,25 @@ +using FluentMigrator; + +namespace Aptabase.Data.Migrations; + +[Migration(0003)] +public class AddBlobs : Migration +{ + public override void Up() + { + Create.Table("blobs") + .WithColumn("path").AsString(80).NotNullable().PrimaryKey() + .WithColumn("content").AsBinary().NotNullable() + .WithColumn("content_type").AsString(80).NotNullable() + .WithTimestamps(); + + Alter.Table("apps") + .AddColumn("icon_path").AsString(80).Nullable(); + } + + public override void Down() + { + Delete.Column("icon_path").FromTable("apps"); + Delete.Table("blobs"); + } +} \ No newline at end of file diff --git a/src/Data/Migrations/0004_AddAppSalt.cs b/src/Data/Migrations/0004_AddAppSalt.cs new file mode 100755 index 0000000..77c500f --- /dev/null +++ b/src/Data/Migrations/0004_AddAppSalt.cs @@ -0,0 +1,21 @@ +using FluentMigrator; + +namespace Aptabase.Data.Migrations; + +[Migration(0004)] +public class AddAppSalt : Migration +{ + + public override void Up() + { + Create.Table("app_salts") + .WithNanoIdColumn("app_id").ForeignKey("apps", "id").PrimaryKey() + .WithColumn("date").AsString(10).NotNullable().PrimaryKey() + .WithColumn("salt").AsBinary(16).NotNullable(); + } + + public override void Down() + { + Delete.Table("app_salts"); + } +} \ No newline at end of file diff --git a/src/Data/Migrations/0005_AppShares.cs b/src/Data/Migrations/0005_AppShares.cs new file mode 100755 index 0000000..0dbe605 --- /dev/null +++ b/src/Data/Migrations/0005_AppShares.cs @@ -0,0 +1,21 @@ +using FluentMigrator; + +namespace Aptabase.Data.Migrations; + +[Migration(0005)] +public class AppShares : Migration +{ + + public override void Up() + { + Create.Table("app_shares") + .WithNanoIdColumn("app_id").ForeignKey("apps", "id").PrimaryKey() + .WithColumn("email").AsString(300).NotNullable().PrimaryKey() + .WithTimestamps(); + } + + public override void Down() + { + Delete.Table("app_shares"); + } +} \ No newline at end of file diff --git a/src/Data/Migrations/0006_AddSubscriptions.cs b/src/Data/Migrations/0006_AddSubscriptions.cs new file mode 100755 index 0000000..4711048 --- /dev/null +++ b/src/Data/Migrations/0006_AddSubscriptions.cs @@ -0,0 +1,30 @@ +using FluentMigrator; + +namespace Aptabase.Data.Migrations; + +[Migration(0006)] +public class AddSubscriptions : Migration +{ + public override void Up() + { + Create.Table("subscriptions") + .WithColumn("id").AsInt64().NotNullable().PrimaryKey() + .WithNanoIdColumn("owner_id").NotNullable() + .WithColumn("customer_id").AsInt64().NotNullable() + .WithColumn("product_id").AsInt64().NotNullable() + .WithColumn("variant_id").AsInt64().NotNullable() + .WithColumn("status").AsString().NotNullable() + .WithColumn("ends_at").AsDateTimeOffset().Nullable() + .WithTimestamps(); + + Create.Index("idx_subscriptions_owner") + .OnTable("subscriptions") + .OnColumn("owner_id"); + } + + public override void Down() + { + Delete.Index("idx_subscriptions_owner"); + Delete.Table("billing"); + } +} \ No newline at end of file diff --git a/src/Data/Migrations/0007_AddOnboardingFlag.cs b/src/Data/Migrations/0007_AddOnboardingFlag.cs new file mode 100755 index 0000000..d74a3ee --- /dev/null +++ b/src/Data/Migrations/0007_AddOnboardingFlag.cs @@ -0,0 +1,23 @@ +using FluentMigrator; + +namespace Aptabase.Data.Migrations; + +[Migration(0007)] +public class AddOnboardingFlag : Migration +{ + public override void Up() + { + Alter.Table("apps") + .AddColumn("has_events").AsBoolean().Nullable(); + + Execute.Sql("UPDATE apps SET has_events = true"); + + Alter.Table("apps") + .AlterColumn("has_events").AsBoolean().NotNullable(); + } + + public override void Down() + { + Delete.Column("has_events").FromTable("apps"); + } +} \ No newline at end of file diff --git a/src/Data/Migrations/0008_AddLockReason.cs b/src/Data/Migrations/0008_AddLockReason.cs new file mode 100755 index 0000000..d300f0f --- /dev/null +++ b/src/Data/Migrations/0008_AddLockReason.cs @@ -0,0 +1,18 @@ +using FluentMigrator; + +namespace Aptabase.Data.Migrations; + +[Migration(0008)] +public class AddLockReason : Migration +{ + public override void Up() + { + Alter.Table("users") + .AddColumn("lock_reason").AsFixedLengthString(1).Nullable(); + } + + public override void Down() + { + Delete.Column("lock_reason").FromTable("users"); + } +} \ No newline at end of file diff --git a/src/Data/Migrations/0009_AddFreeTrial.cs b/src/Data/Migrations/0009_AddFreeTrial.cs new file mode 100755 index 0000000..f26237a --- /dev/null +++ b/src/Data/Migrations/0009_AddFreeTrial.cs @@ -0,0 +1,20 @@ +using FluentMigrator; + +namespace Aptabase.Data.Migrations; + +[Migration(0009)] +public class AddFreeTrial : Migration +{ + public override void Up() + { + Alter.Table("users") + .AddColumn("free_trial_ends_at").AsDateTimeOffset().Nullable() + .AddColumn("free_quota").AsInt64().Nullable(); + } + + public override void Down() + { + Delete.Column("free_trial_ends_at").FromTable("users"); + Delete.Column("free_quota").FromTable("users"); + } +} \ No newline at end of file diff --git a/src/Data/Migrations/0010_AddCache.cs b/src/Data/Migrations/0010_AddCache.cs new file mode 100755 index 0000000..8b83fac --- /dev/null +++ b/src/Data/Migrations/0010_AddCache.cs @@ -0,0 +1,20 @@ +using FluentMigrator; + +namespace Aptabase.Data.Migrations; + +[Migration(0010)] +public class AddCache : Migration +{ + public override void Up() + { + Create.Table("cache") + .WithColumn("key").AsString(100).NotNullable().PrimaryKey() + .WithColumn("value").AsCustom("TEXT").NotNullable() + .WithColumn("expires_at").AsDateTimeOffset().NotNullable(); + } + + public override void Down() + { + Delete.Table("cache"); + } +} \ No newline at end of file diff --git a/src/Data/Migrations/0011_AddFeatureFlags.cs b/src/Data/Migrations/0011_AddFeatureFlags.cs new file mode 100755 index 0000000..29ee597 --- /dev/null +++ b/src/Data/Migrations/0011_AddFeatureFlags.cs @@ -0,0 +1,31 @@ +using FluentMigrator; + +namespace Aptabase.Data.Migrations; + +[Migration(0011)] +public class AddFeatureFlags : Migration +{ + public override void Up() + { + Create.Table("feature_flags") + .WithNanoIdColumn("id").PrimaryKey() + .WithNanoIdColumn("app_id").ForeignKey("apps", "id") + .WithColumn("key").AsString(255).NotNullable() + .WithColumn("value").AsString(4000).NotNullable() + .WithColumn("environment").AsString(50).NotNullable() + .WithColumn("conditions").AsString(4000).NotNullable() + .WithTimestamps(); + + Create.Index("idx_feature_flags_app_key_env") + .OnTable("feature_flags") + .OnColumn("app_id").Ascending() + .OnColumn("key").Ascending() + .OnColumn("environment").Ascending(); + } + + public override void Down() + { + Delete.Index("idx_feature_flags_app_key_env"); + Delete.Table("feature_flags"); + } +} \ No newline at end of file diff --git a/src/Data/Migrations/MigrationExtensions.cs b/src/Data/Migrations/MigrationExtensions.cs new file mode 100755 index 0000000..e0aa9d6 --- /dev/null +++ b/src/Data/Migrations/MigrationExtensions.cs @@ -0,0 +1,22 @@ +using FluentMigrator; +using FluentMigrator.Builders.Create.Table; + +namespace Aptabase.Data.Migrations; + +internal static class MigrationExtensions +{ + public static ICreateTableColumnOptionOrWithColumnSyntax WithNanoIdColumn(this ICreateTableWithColumnSyntax tableWithColumnSyntax, string name) + { + return tableWithColumnSyntax + .WithColumn(name) + .AsString(22) + .NotNullable(); + } + + public static ICreateTableColumnOptionOrWithColumnSyntax WithTimestamps(this ICreateTableWithColumnSyntax tableWithColumnSyntax) + { + return tableWithColumnSyntax + .WithColumn("created_at").AsDateTimeOffset().WithDefault(SystemMethods.CurrentUTCDateTime).NotNullable() + .WithColumn("modified_at").AsDateTimeOffset().WithDefault(SystemMethods.CurrentUTCDateTime).NotNullable(); + } +} \ No newline at end of file diff --git a/src/Data/Migrations/VersionInfoTable.cs b/src/Data/Migrations/VersionInfoTable.cs new file mode 100755 index 0000000..4e6fa74 --- /dev/null +++ b/src/Data/Migrations/VersionInfoTable.cs @@ -0,0 +1,17 @@ +using FluentMigrator.Runner.VersionTableInfo; + +namespace Aptabase.Data.Migrations; + +[VersionTableMetaData] +public class VersionTable : IVersionTableMetaData +{ + public string ColumnName => "version"; + public string SchemaName => ""; + public string TableName => "migration_history"; + public string UniqueIndexName => "idx_version"; + public string AppliedOnColumnName => "applied_at"; + public string DescriptionColumnName => "description"; + public object? ApplicationContext { get; set; } + public bool OwnsSchema => true; + public bool CreateWithPrimaryKey => false; +} \ No newline at end of file diff --git a/src/Data/NanoId.cs b/src/Data/NanoId.cs new file mode 100755 index 0000000..5830e5b --- /dev/null +++ b/src/Data/NanoId.cs @@ -0,0 +1,13 @@ +using NanoidDotNet; + +namespace Aptabase.Data; + +public class NanoId +{ + private const string ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private const string NUMBERS = "0123456789"; + + public static string New(string preffix) => $"{preffix}{Nanoid.Generate(ALPHABET, 22 - preffix.Length)}"; + public static string New() => New(""); + public static string Numbers(int len) => Nanoid.Generate(NUMBERS, len); +} \ No newline at end of file diff --git a/src/Extensions/ExceptionMiddleware.cs b/src/Extensions/ExceptionMiddleware.cs new file mode 100755 index 0000000..7d98477 --- /dev/null +++ b/src/Extensions/ExceptionMiddleware.cs @@ -0,0 +1,57 @@ +// Custom Exception Middleware to catch common exceptions caused by client disconnects +// Some conditions can be removed on .NET 8 because of https://github.com/dotnet/aspnetcore/pull/46330 +using System.Net; + +public class ExceptionMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public ExceptionMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task Invoke(HttpContext context) + { + try + { + await _next(context); + } + catch (TaskCanceledException) when (context.RequestAborted.IsCancellationRequested) + { + context.Response.StatusCode = 418; // I'm a teapot + } + catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) + { + context.Response.StatusCode = 418; // I'm a teapot + } + catch (BadHttpRequestException ex) when (context.RequestAborted.IsCancellationRequested || ex.StatusCode == (int)HttpStatusCode.RequestTimeout) + { + context.Response.StatusCode = 418; // I'm a teapot + } +#pragma warning disable CS0618 // Type or member is obsolete + catch (Microsoft.AspNetCore.Server.Kestrel.Core.BadHttpRequestException) + { + context.Response.StatusCode = 418; // I'm a teapot + } +#pragma warning restore CS0618 // Type or member is obsolete + catch (HttpRequestException ex) + { + context.Response.StatusCode = (int)(ex.StatusCode ?? HttpStatusCode.InternalServerError); + + _logger.LogError(ex, "Dependency error on {Path}", context.Request.Path.Value); + } + catch (Microsoft.AspNetCore.Authentication.AuthenticationFailureException) + { + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + } + catch (Exception ex) + { + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + + _logger.LogError(ex, "Unexpected error on {Path}", context.Request.Path.Value); + } + } +} \ No newline at end of file diff --git a/src/Extensions/HttpExtensions.cs b/src/Extensions/HttpExtensions.cs new file mode 100755 index 0000000..071c13a --- /dev/null +++ b/src/Extensions/HttpExtensions.cs @@ -0,0 +1,36 @@ +namespace Microsoft.AspNetCore.Http; + +public static class HttpContextExtensions +{ + public static string ResolveClientIpAddress(this HttpContext httpContext) + { + if (httpContext.Request.Headers.TryGetValue("X-Real-Ip", out var ip) && !string.IsNullOrEmpty(ip)) + return ip.ToString(); + + if (httpContext.Request.Headers.TryGetValue("X-Forwarded-For", out var forwardedIp) && !string.IsNullOrEmpty(forwardedIp)) + return forwardedIp.ToString(); + + var cfViewerAddress = httpContext.Request.Headers["CloudFront-Viewer-Address"]; + if (cfViewerAddress.Count > 0) + { + var parts = (cfViewerAddress[0] ?? string.Empty).Split(":"); + if (parts.Length == 1) + return parts[0]; + + if (parts.Length >= 1) + return string.Join(":", parts[0..^1]); + } + + return httpContext.Connection.RemoteIpAddress?.ToString() ?? ""; + } + + public static async Task EnsureSuccessWithLog(this HttpResponseMessage response, ILogger logger) + { + if (!response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync(); + logger.LogError("Request failed with {StatusCode} with body {ResponseBody}", response.StatusCode, responseBody); + response.EnsureSuccessStatusCode(); + } + } +} \ No newline at end of file diff --git a/src/Extensions/StringExtensions.cs b/src/Extensions/StringExtensions.cs new file mode 100755 index 0000000..074fb93 --- /dev/null +++ b/src/Extensions/StringExtensions.cs @@ -0,0 +1,15 @@ +namespace System; + +public static class StringExtensions +{ + public static string Truncate(this string value, int maxLength, string suffix = "...") + { + if (string.IsNullOrEmpty(value)) + return value; + + if (value.Length <= maxLength) + return value; + + return $"{value[..(maxLength - suffix.Length)]}{suffix}"; + } +} \ No newline at end of file diff --git a/src/Extensions/TelemetryExtensions.cs b/src/Extensions/TelemetryExtensions.cs new file mode 100755 index 0000000..4b30af6 --- /dev/null +++ b/src/Extensions/TelemetryExtensions.cs @@ -0,0 +1,108 @@ +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + return builder; + } + + public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddAspNetCoreInstrumentation() + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/src/Features/Apps/AppQueries.cs b/src/Features/Apps/AppQueries.cs new file mode 100755 index 0000000..d153cf4 --- /dev/null +++ b/src/Features/Apps/AppQueries.cs @@ -0,0 +1,74 @@ +using Aptabase.Data; +using Dapper; + +namespace Aptabase.Features.Apps; + +public class Application +{ + public string Id { get; set; } = ""; + public string Name { get; set; } = ""; + public string AppKey { get; set; } = ""; + public string IconPath { get; set; } = ""; + public bool HasEvents { get; set; } = false; + public bool HasOwnership { get; set; } = false; + public char? LockReason { get; set; } +} + +public interface IAppQueries +{ + Task GetActiveAppByAppKey(string appKey, CancellationToken cancellationToken); + Task MaskAsOnboarded(string appId, CancellationToken cancellationToken); + Task GetOwnedAppAsync(string appId, string userId); +} + +public class AppQueries : IAppQueries +{ + private readonly IDbContext _db; + + public AppQueries(IDbContext db) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + } + + public Task GetActiveAppByAppKey(string appKey, CancellationToken cancellationToken) + { + var cmd = new CommandDefinition(@" + SELECT a.id, a.name, a.icon_path, + a.app_key, a.has_events, + u.lock_reason + FROM apps a + INNER JOIN users u + ON u.id = a.owner_id + WHERE a.app_key = @appKey AND a.deleted_at IS NULL", + new { appKey }, + cancellationToken: cancellationToken + ); + + return _db.Connection.QueryFirstOrDefaultAsync(cmd); + } + + public async Task MaskAsOnboarded(string appId, CancellationToken cancellationToken) + { + var cmd = new CommandDefinition($"UPDATE apps SET has_events = true WHERE id = @appId", + new { appId }, + cancellationToken: cancellationToken + ); + + await _db.Connection.ExecuteAsync(cmd); + } + + public async Task GetOwnedAppAsync(string appId, string userId) + { + return await _db.Connection.QueryFirstOrDefaultAsync( + @"SELECT a.id, a.name, a.icon_path, a.app_key, true as has_ownership, + a.has_events, u.lock_reason + FROM apps a + INNER JOIN users u + ON u.id = a.owner_id + WHERE a.id = @appId + AND a.owner_id = @userId + AND a.deleted_at IS NULL", + new { appId, userId } + ); + } +} \ No newline at end of file diff --git a/src/Features/Apps/AppsController.cs b/src/Features/Apps/AppsController.cs new file mode 100755 index 0000000..0655fab --- /dev/null +++ b/src/Features/Apps/AppsController.cs @@ -0,0 +1,222 @@ +using Dapper; +using Aptabase.Data; +using Aptabase.Features.Blob; +using Microsoft.AspNetCore.Mvc; +using Aptabase.Features.Authentication; +using System.ComponentModel.DataAnnotations; + +namespace Aptabase.Features.Apps; + +public class ApplicationShare +{ + public string Email { get; set; } = ""; + public DateTime CreatedAt { get; set; } +} + +public class CreateAppRequestBody +{ + [Required] + [StringLength(40, MinimumLength = 2)] + public string Name { get; set; } = ""; +} + +public class UpdateAppRequestBody +{ + public string Icon { get; set; } = ""; + + [Required] + [StringLength(40, MinimumLength = 2)] + public string Name { get; set; } = ""; +} + +[ApiController, IsAuthenticated] +[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] +public class AppsController : Controller +{ + private readonly IDbContext _db; + private readonly EnvSettings _env; + private readonly IBlobService _blobService; + + public AppsController(IDbContext db, EnvSettings env, IBlobService blobService) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + _env = env ?? throw new ArgumentNullException(nameof(env)); + _blobService = blobService ?? throw new ArgumentNullException(nameof(blobService)); + } + + [HttpGet("/api/_apps")] + public async Task ListApps() + { + var user = this.GetCurrentUserIdentity(); + + var apps = await _db.Connection.QueryAsync( + @"SELECT a.id, a.name, a.icon_path, a.app_key, + a.owner_id = @userId AS has_ownership, a.has_events, + u.lock_reason + FROM apps a + LEFT JOIN app_shares s + ON s.app_id = a.id + INNER JOIN users u + ON u.id = a.owner_id + WHERE (a.owner_id = @userId OR s.email = @userEmail) + AND a.deleted_at IS NULL + GROUP BY a.id, a.name, a.icon_path, a.app_key, u.lock_reason + ORDER by a.name", new { userId = user.Id, userEmail = user.Email }); + + return Ok(apps); + } + + [HttpPost("/api/_apps")] + public async Task Create([FromBody] CreateAppRequestBody body) + { + var user = this.GetCurrentUserIdentity(); + var app = new Application + { + Id = NanoId.New(), + Name = body.Name, + AppKey = $"A-{_env.Region}-{NanoId.Numbers(10)}" + }; + + await _db.Connection.ExecuteScalarAsync(@" + INSERT INTO apps (id, owner_id, name, app_key, has_events) + VALUES (@appId, @ownerId, @name, @appKey, false)", + new + { + appId = app.Id, + ownerId = user.Id, + name = app.Name, + appKey = app.AppKey + }); + + return Ok(app); + } + + [HttpGet("/api/_apps/{appId}")] + public async Task GetAppById(string appId) + { + var user = this.GetCurrentUserIdentity(); + + var app = await _db.Connection.QueryFirstOrDefaultAsync( + @"SELECT a.id, a.name, a.icon_path, a.app_key, + a.owner_id = @userId as has_ownership, a.has_events, + u.lock_reason + FROM apps a + LEFT JOIN app_shares s + ON s.app_id = a.id + INNER JOIN users u + ON u.id = a.owner_id + WHERE a.id = @appId + AND (a.owner_id = @userId OR s.email = @userEmail) + AND a.deleted_at IS NULL + GROUP BY a.id, a.name, a.icon_path, a.app_key, u.lock_reason + ORDER by a.name", + new { appId, userId = user.Id, userEmail = user.Email } + ); + + return Ok(app); + } + + [HttpPut("/api/_apps/{appId}")] + public async Task Update(string appId, [FromBody] UpdateAppRequestBody body, CancellationToken cancellationToken) + { + var app = await GetOwnedApp(appId); + if (app == null) + return NotFound(); + + if (!string.IsNullOrEmpty(body.Icon)) + { + var content = Convert.FromBase64String(body.Icon); + app.IconPath = await _blobService.UploadAsync("icons", content, "image/png", cancellationToken); + } + + app.Name = body.Name; + await _db.Connection.ExecuteScalarAsync("UPDATE apps SET name = @name, icon_path = @iconPath WHERE id = @appId", new + { + appId = app.Id, + name = app.Name, + iconPath = app.IconPath, + }); + + return Ok(app); + } + + [HttpDelete("/api/_apps/{appId}")] + public async Task Delete(string appId) + { + var app = await GetOwnedApp(appId); + if (app == null) + return NotFound(); + + await _db.Connection.ExecuteScalarAsync("UPDATE apps SET deleted_at = now() WHERE id = @appId", new + { + appId = app.Id, + }); + + return Ok(new { }); + } + + [HttpGet("/api/_apps/{appId}/shares")] + public async Task ListAppShares(string appId) + { + var app = await GetOwnedApp(appId); + if (app == null) + return NotFound(); + + var shares = await _db.Connection.QueryAsync( + @"SELECT email, created_at + FROM app_shares + WHERE app_id = @appId", new { appId }); + return Ok(shares); + } + + [HttpPut("/api/_apps/{appId}/shares/{email}")] + public async Task ShareApp(string appId, string email) + { + var app = await GetOwnedApp(appId); + if (app == null) + return NotFound(); + + await _db.Connection.ExecuteScalarAsync(@" + INSERT INTO app_shares (app_id, email) + VALUES (@appId, @email) + ON CONFLICT DO NOTHING", new + { + appId, + email = email.ToLower(), + }); + + return Ok(new { }); + } + + [HttpDelete("/api/_apps/{appId}/shares/{email}")] + public async Task UnshareApp(string appId, string email) + { + var app = await GetOwnedApp(appId); + if (app == null) + return NotFound(); + + await _db.Connection.ExecuteScalarAsync(@"DELETE FROM app_shares WHERE app_id = @appId AND email = @email", new + { + appId, + email, + }); + + return Ok(new { }); + } + + private async Task GetOwnedApp(string appId) + { + var user = this.GetCurrentUserIdentity(); + return await _db.Connection.QueryFirstOrDefaultAsync( + @"SELECT a.id, a.name, a.icon_path, a.app_key, true as has_ownership, + a.has_events, u.lock_reason + FROM apps a + INNER JOIN users u + ON u.id = a.owner_id + WHERE a.id = @appId + AND a.owner_id = @userId + AND a.deleted_at IS NULL", + new { appId, userId = user.Id } + ); + } +} diff --git a/src/Features/Authentication/AuthController.cs b/src/Features/Authentication/AuthController.cs new file mode 100755 index 0000000..d81c7ef --- /dev/null +++ b/src/Features/Authentication/AuthController.cs @@ -0,0 +1,140 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.IdentityModel.Tokens; + +namespace Aptabase.Features.Authentication; + +public class SignInBodyRequest +{ + [EmailAddress] + public string Email { get; set; } = ""; +} + +public class RegisterBodyRequest +{ + [StringLength(40, MinimumLength = 2)] + public string Name { get; set; } = ""; + + [EmailAddress] + public string Email { get; set; } = ""; +} + +[ApiController] +[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] +public class AuthController : Controller +{ + private readonly ILogger _logger; + private readonly EnvSettings _env; + private readonly IAuthService _authService; + private readonly IAuthTokenManager _tokenManager; + + public AuthController( + ILogger logger, + EnvSettings env, + IAuthService authService, + IAuthTokenManager tokenManager) + { + _env = env ?? throw new ArgumentNullException(nameof(env)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _authService = authService ?? throw new ArgumentNullException(nameof(authService)); + _tokenManager = tokenManager ?? throw new ArgumentNullException(nameof(tokenManager)); + } + + [HttpPost("/api/_auth/signin")] + public async Task SignIn([FromBody] SignInBodyRequest body, CancellationToken cancellationToken) + { + var found = await _authService.SendSignInEmailAsync(body.Email.Trim(), cancellationToken); + + if (!found) + return NotFound(new { }); + + return Ok(new { }); + } + + [HttpPost("/api/_auth/register")] + [EnableRateLimiting("SignUp")] + public async Task Register([FromBody] RegisterBodyRequest body, CancellationToken cancellationToken) + { + await _authService.SendRegisterEmailAsync(body.Name.Trim(), body.Email.Trim(), cancellationToken); + return Ok(new { }); + } + + [HttpGet("/api/_auth/github")] + public IActionResult GitHub() + { + return Challenge(new AuthenticationProperties { RedirectUri = $"{_env.SelfBaseUrl}/" }, "github"); + } + + [HttpGet("/api/_auth/google")] + public IActionResult Google() + { + return Challenge(new AuthenticationProperties { RedirectUri = $"{_env.SelfBaseUrl}/" }, "google"); + } + + [HttpGet("/api/_auth/me")] + [IsAuthenticated] + [EnableCors("AllowAptabaseCom")] + public async Task Me(CancellationToken cancellationToken) + { + var identity = this.GetCurrentUserIdentity(); + var user = await _authService.FindUserByIdAsync(identity.Id, cancellationToken); + if (user is null) + return NotFound(); + + return Ok(user); + } + + [HttpPost("/api/_auth/account/delete")] + [IsAuthenticated] + [EnableCors("AllowAptabaseCom")] + public async Task DeleteAccount(CancellationToken cancellationToken) + { + var identity = this.GetCurrentUserIdentity(); + var user = await _authService.FindUserByIdAsync(identity.Id, cancellationToken); + if (user is null) + return NotFound(); + + await _authService.DeleteUserByIdAsync(identity.Id, cancellationToken); + + return Ok(user); + } + + [HttpPost("/api/_auth/signout")] + public async Task ForceSignOut() + { + await _authService.SignOutAsync(); + return Redirect($"{_env.SelfBaseUrl}/auth"); + } + + [HttpGet("/api/_auth/continue")] + public async Task HandleMagicLink([FromQuery] string token, CancellationToken cancellationToken) + { + try + { + var result = _tokenManager.ParseAuthToken(token); + var user = await _authService.FindUserByEmailAsync(result.Email, cancellationToken); + + if (result.Type == AuthTokenType.Register && user == null) + user = await _authService.CreateAccountAsync(result.Name, result.Email, cancellationToken); + + if (user != null) + await _authService.SignInAsync(user); + else + _logger.LogError("Tried to authenticate user with email {email}, but account was not found", result.Email); + } + catch (SecurityTokenExpiredException) + { + return Redirect($"{_env.SelfBaseUrl}/auth?error=expired"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to validate auth token"); + return Redirect($"{_env.SelfBaseUrl}/auth?error=invalid"); + } + + return Redirect($"{_env.SelfBaseUrl}/"); + } +} diff --git a/src/Features/Authentication/AuthExtensions.cs b/src/Features/Authentication/AuthExtensions.cs new file mode 100755 index 0000000..4a5e892 --- /dev/null +++ b/src/Features/Authentication/AuthExtensions.cs @@ -0,0 +1,27 @@ +using Aptabase.Features.Authentication; + +namespace Microsoft.AspNetCore.Mvc; + +public static class AuthExtensions +{ + public static bool IsAuthenticated(this HttpContext context) + { + return context.User.Identity?.IsAuthenticated ?? false; + } + + public static UserIdentity GetCurrentUserIdentity(this Controller controller) + { + return controller.HttpContext.GetCurrentUserIdentity(); + } + + public static UserIdentity GetCurrentUserIdentity(this HttpContext context) + { + if (!context.IsAuthenticated()) + throw new InvalidOperationException("User is not authenticated."); + + var id = context.User.FindFirst(x => x.Type == "id")?.Value ?? ""; + var email = context.User.FindFirst(x => x.Type == "email")?.Value ?? ""; + var name = context.User.FindFirst(x => x.Type == "name")?.Value ?? ""; + return new UserIdentity(id, name, email); + } +} \ No newline at end of file diff --git a/src/Features/Authentication/AuthService.cs b/src/Features/Authentication/AuthService.cs new file mode 100755 index 0000000..7fe3689 --- /dev/null +++ b/src/Features/Authentication/AuthService.cs @@ -0,0 +1,221 @@ +using Aptabase.Features.Notification; +using Aptabase.Data; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using System.Security.Claims; +using Dapper; + +namespace Aptabase.Features.Authentication; + +public interface IAuthService +{ + Task SendSignInEmailAsync(string email, CancellationToken cancellationToken); + Task SendRegisterEmailAsync(string name, string email, CancellationToken cancellationToken); + Task SignInAsync(UserAccount user); + Task SignOutAsync(); + Task FindUserByIdAsync(string id, CancellationToken cancellationToken); + Task FindUserByEmailAsync(string email, CancellationToken cancellationToken); + Task FindUserByOAuthProviderAsync(string providerName, string providerUid, CancellationToken cancellationToken); + Task FindOrCreateAccountWithOAuthAsync(string name, string email, string providerName, string providerUid, CancellationToken cancellationToken); + Task CreateAccountAsync(string name, string email, CancellationToken cancellationToken); + Task AttachUserAuthProviderAsync(UserAccount user, string providerName, string providerUid, CancellationToken cancellationToken); + Task DeleteUserByIdAsync(string id, CancellationToken cancellationToken); +} + +public class AuthService : IAuthService +{ + private readonly ILogger _logger; + private readonly IDbContext _db; + private readonly IAuthTokenManager _tokenManager; + private readonly EnvSettings _env; + private readonly IEmailClient _emailClient; + private readonly IHttpContextAccessor _httpContextAccessor; + + public AuthService( + ILogger logger, + IDbContext db, + EnvSettings env, + IHttpContextAccessor httpContextAccessor, + IEmailClient emailClient, + IAuthTokenManager tokenManager) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); + _env = env ?? throw new ArgumentNullException(nameof(env)); + _tokenManager = tokenManager ?? throw new ArgumentNullException(nameof(tokenManager)); + _emailClient = emailClient ?? throw new ArgumentNullException(nameof(emailClient)); + _db = db ?? throw new ArgumentNullException(nameof(db)); ; + } + + public async Task SendSignInEmailAsync(string email, CancellationToken cancellationToken) + { + var user = await FindUserByEmailAsync(email, cancellationToken); + if (user != null) + { + var token = _tokenManager.CreateAuthToken(AuthTokenType.SignIn, user.Name, user.Email); + await _emailClient.SendEmailAsync(email, "Log in to Aptabase", "SignIn", new() + { + { "name", user.Name }, + { "url", GenerateAuthUrl(token) } + }, cancellationToken); + + return true; + } + + return false; + } + + public async Task SendRegisterEmailAsync(string name, string email, CancellationToken cancellationToken) + { + var user = await FindUserByEmailAsync(email, cancellationToken); + if (user != null) + { + await SendSignInEmailAsync(email, cancellationToken); + return; + } + + var token = _tokenManager.CreateAuthToken(AuthTokenType.Register, name, email); + + await _emailClient.SendEmailAsync(email, "Confirm your registration", "Register", new() + { + { "name", name }, + { "url", GenerateAuthUrl(token) } + }, cancellationToken); + } + + public async Task CreateAccountAsync(string name, string email, CancellationToken cancellationToken) + { + var userId = NanoId.New(); + var cmd = new CommandDefinition( + "INSERT INTO users (id, name, email, free_quota) VALUES (@userId, @name, @email, 20000)", + new { userId, name, email = email.ToLower() }, + cancellationToken: cancellationToken + ); + + await _db.Connection.ExecuteAsync(cmd); + + return new UserAccount(new UserIdentity(userId, name, email)); + } + + public async Task SignOutAsync() + { + if (_httpContextAccessor.HttpContext == null) + return; + + await _httpContextAccessor.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + } + + public async Task SignInAsync(UserAccount user) + { + if (_httpContextAccessor.HttpContext == null) + { + _logger.LogError("Tried to authenticate without an active HTTP Context"); + return; + } + + var claims = new List + { + new("id", user.Id), + new("name", user.Name), + new("email", user.Email), + }; + + var claimsIdentity = new ClaimsIdentity( + claims, CookieAuthenticationDefaults.AuthenticationScheme); + + var authProperties = new AuthenticationProperties + { + ExpiresUtc = DateTime.UtcNow.AddDays(365), + IsPersistent = true, + }; + + await _httpContextAccessor.HttpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(claimsIdentity), + authProperties); + } + + public Task DeleteUserByIdAsync(string id, CancellationToken cancellationToken) + { + var sql = @" + BEGIN; + DELETE FROM public.app_shares + WHERE app_id IN (SELECT id FROM public.apps WHERE owner_id = @userId); + + DELETE FROM public.app_salts + WHERE app_id IN (SELECT id FROM public.apps WHERE owner_id = @userId); + + DELETE FROM public.apps + WHERE owner_id = @userId; + + DELETE FROM public.user_providers + WHERE user_id = @userId; + + DELETE FROM public.subscriptions + WHERE owner_id = @userId; + + DELETE FROM public.users + WHERE id = @userId; + COMMIT;"; + + var cmd = new CommandDefinition( + sql, + new { userId = id }, + cancellationToken: cancellationToken + ); + + return _db.Connection.ExecuteAsync(cmd); + } + + public async Task FindUserByIdAsync(string id, CancellationToken cancellationToken) + { + var cmd = new CommandDefinition($"SELECT id, name, email, lock_reason FROM users WHERE id = @id", new { id }, cancellationToken: cancellationToken); + return await _db.Connection.QuerySingleOrDefaultAsync(cmd); + } + + public async Task FindUserByEmailAsync(string email, CancellationToken cancellationToken) + { + var cmd = new CommandDefinition($"SELECT id, name, email, lock_reason FROM users WHERE email = @email", new { email = email.ToLower() }, cancellationToken: cancellationToken); + return await _db.Connection.QuerySingleOrDefaultAsync(cmd); + } + + public async Task FindOrCreateAccountWithOAuthAsync(string name, string email, string providerName, string providerUid, CancellationToken cancellationToken) + { + var user = await FindUserByOAuthProviderAsync(providerName, providerUid, cancellationToken); + if (user is not null) + return user; + + user = await FindUserByEmailAsync(email, cancellationToken); + if (user is not null) + { + await AttachUserAuthProviderAsync(user, providerName, providerUid, cancellationToken); + return user; + } + + user = await CreateAccountAsync(name, email, cancellationToken); + await AttachUserAuthProviderAsync(user, providerName, providerUid, cancellationToken); + return user; + } + + public async Task FindUserByOAuthProviderAsync(string providerName, string providerUid, CancellationToken cancellationToken) + { + var cmd = new CommandDefinition($@" + SELECT u.id, u.name, u.email, u.lock_reason + FROM user_providers up + INNER JOIN users u + ON u.id = up.user_id + WHERE up.provider_name = @name + AND up.provider_uid = @uid", new { name = providerName, uid = providerUid }, cancellationToken: cancellationToken); + return await _db.Connection.QuerySingleOrDefaultAsync(cmd); + } + + public Task AttachUserAuthProviderAsync(UserAccount user, string providerName, string providerUid, CancellationToken cancellationToken) + { + var cmd = new CommandDefinition($@" + INSERT INTO user_providers (provider_name, provider_uid, user_id) + VALUES (@providerName, @providerUid, @userId)", new { userId = user.Id, providerName, providerUid }, cancellationToken: cancellationToken); + return _db.Connection.ExecuteAsync(cmd); + } + + private string GenerateAuthUrl(string token) => $"{_env.SelfBaseUrl}/api/_auth/continue?token={token}"; +} \ No newline at end of file diff --git a/src/Features/Authentication/AuthTokenManager.cs b/src/Features/Authentication/AuthTokenManager.cs new file mode 100755 index 0000000..05ae8bb --- /dev/null +++ b/src/Features/Authentication/AuthTokenManager.cs @@ -0,0 +1,84 @@ +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; + +namespace Aptabase.Features.Authentication; + +public enum AuthTokenType +{ + SignIn, + Register +} + +public class ParsedAuthToken +{ + public AuthTokenType Type { get; set; } + public string Name { get; set; } = ""; + public string Email { get; set; } = ""; +} + +public interface IAuthTokenManager +{ + string CreateAuthToken(AuthTokenType type, string name, string email); + ParsedAuthToken ParseAuthToken(string token); +} + +public class AuthTokenManager : IAuthTokenManager +{ + private readonly JwtSecurityTokenHandler _tokenHandler = new JwtSecurityTokenHandler(); + private readonly SymmetricSecurityKey _signingKey; + private readonly string _issuer; + + public AuthTokenManager(EnvSettings env) + { + _issuer = $"aptabase-{env.Region.ToLower()}"; + _signingKey = new SymmetricSecurityKey(env.AuthSecret); + } + + public string CreateAuthToken(AuthTokenType type, string name, string email) + { + var claims = new[] + { + new Claim("type", type.ToString()), + new Claim("name", name), + new Claim("email", email) + }; + var tokenDescriptor = new SecurityTokenDescriptor + { + Issuer = _issuer, + Subject = new ClaimsIdentity(claims), + Expires = DateTime.UtcNow.Add(TimeSpan.FromMinutes(15)), + SigningCredentials = new SigningCredentials(_signingKey, SecurityAlgorithms.HmacSha256) + }; + + var token = _tokenHandler.CreateToken(tokenDescriptor); + return _tokenHandler.WriteToken(token); + } + + public ParsedAuthToken ParseAuthToken(string token) + { + if (string.IsNullOrEmpty(token)) + throw new ArgumentNullException(nameof(token)); + + _tokenHandler.ValidateToken(token, new TokenValidationParameters + { + ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256 }, + ValidIssuer = _issuer, + ValidateIssuer = true, + IssuerSigningKey = _signingKey, + ValidateIssuerSigningKey = true, + ValidateAudience = false, + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero, + }, out SecurityToken validatedToken); + + var jwtToken = (JwtSecurityToken)validatedToken; + + return new ParsedAuthToken + { + Type = (AuthTokenType)Enum.Parse(typeof(AuthTokenType), jwtToken.Claims.First(x => x.Type == "type").Value), + Name = jwtToken.Claims.First(x => x.Type == "name").Value, + Email = jwtToken.Claims.First(x => x.Type == "email").Value + }; + } +} \ No newline at end of file diff --git a/src/Features/Authentication/IsAuthenticated.cs b/src/Features/Authentication/IsAuthenticated.cs new file mode 100755 index 0000000..7af7a72 --- /dev/null +++ b/src/Features/Authentication/IsAuthenticated.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Aptabase.Features.Authentication; + +public class IsAuthenticatedAttribute : ActionFilterAttribute +{ + public override void OnActionExecuting(ActionExecutingContext context) + { + if (!context.HttpContext.IsAuthenticated()) + { + context.Result = new UnauthorizedResult(); + return; + } + + base.OnActionExecuting(context); + } +} \ No newline at end of file diff --git a/src/Features/Authentication/OAuthExtensions.cs b/src/Features/Authentication/OAuthExtensions.cs new file mode 100755 index 0000000..51ada58 --- /dev/null +++ b/src/Features/Authentication/OAuthExtensions.cs @@ -0,0 +1,164 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.Json.Serialization; +using Aptabase.Features; +using Aptabase.Features.Authentication; +using Microsoft.AspNetCore.Authentication.OAuth; + +namespace Microsoft.AspNetCore.Authentication; + +public static class OAuthExtensions +{ + public class GitHubUser + { + [JsonPropertyName("id")] + public long Id { get; set; } + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + [JsonPropertyName("login")] + public string Login { get; set; } = ""; + [JsonPropertyName("email")] + public string Email { get; set; } = ""; + } + + public class GitHubEmail + { + [JsonPropertyName("email")] + public string Email { get; set; } = ""; + [JsonPropertyName("primary")] + public bool Primary { get; set; } + [JsonPropertyName("verified")] + public bool Verified { get; set; } + } + + public class GoogleUser + { + [JsonPropertyName("sub")] + public string Id { get; set; } = ""; + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + [JsonPropertyName("email")] + public string Email { get; set; } = ""; + [JsonPropertyName("email_verified")] + public bool EmailVerified { get; set; } + } + + public static AuthenticationBuilder AddGitHub(this AuthenticationBuilder builder, EnvSettings env) + { + if (string.IsNullOrWhiteSpace(env.OAuthGitHubClientId)) + return builder; + + return builder.AddOAuth("github", o => + { + o.ClientId = env.OAuthGitHubClientId; + o.ClientSecret = env.OAuthGitHubClientSecret; + o.CallbackPath = new PathString("/api/_auth/github/callback"); + o.Scope.Add("read:user"); + o.Scope.Add("user:email"); + o.CorrelationCookie.SameSite = env.IsDevelopment ? SameSiteMode.Unspecified : SameSiteMode.None; + o.CorrelationCookie.HttpOnly = true; + o.CorrelationCookie.IsEssential = true; + o.CorrelationCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + o.AuthorizationEndpoint = "https://github.com/login/oauth/authorize"; + o.TokenEndpoint = "https://github.com/login/oauth/access_token"; + o.UserInformationEndpoint = "https://api.github.com/user"; + o.ClaimActions.MapJsonKey("id", "id"); + o.ClaimActions.MapJsonKey("name", "name"); + o.ClaimActions.MapJsonKey("email", "email"); + o.Events = new OAuthEvents + { + OnAccessDenied = context => + { + context.HandleResponse(); + context.HttpContext.Response.Redirect($"{env.SelfBaseUrl}/auth"); + return Task.CompletedTask; + }, + OnCreatingTicket = async context => + { + var ghUser = await MakeOAuthRequest(context, context.Options.UserInformationEndpoint); + if (ghUser is null) + throw new Exception("Failed to retrieve GitHub user information."); + + if (string.IsNullOrWhiteSpace(ghUser.Name)) + ghUser.Name = ghUser.Login; + + if (string.IsNullOrWhiteSpace(ghUser.Email)) + ghUser.Email = await GetGitHubPreferredEmail(context); + + if (string.IsNullOrWhiteSpace(ghUser.Email)) + throw new Exception("Could not find a verified email, can't login with GitHub."); + + var authService = context.HttpContext.RequestServices.GetRequiredService(); + var user = await authService.FindOrCreateAccountWithOAuthAsync(ghUser.Name, ghUser.Email, "github", ghUser.Id.ToString(), context.HttpContext.RequestAborted); + context.RunClaimActions(JsonSerializer.SerializeToElement(new { id = user.Id, name = user.Name, email = user.Email })); + } + }; + }); + } + + public static AuthenticationBuilder AddGoogle(this AuthenticationBuilder builder, EnvSettings env) + { + if (string.IsNullOrWhiteSpace(env.OAuthGoogleClientId)) + return builder; + + return builder.AddOAuth("google", o => + { + o.ClientId = env.OAuthGoogleClientId; + o.ClientSecret = env.OAuthGoogleClientSecret; + o.Scope.Add("openid"); + o.Scope.Add("profile"); + o.Scope.Add("email"); + o.CallbackPath = new PathString("/api/_auth/google/callback"); + o.CorrelationCookie.SameSite = env.IsDevelopment ? SameSiteMode.Unspecified : SameSiteMode.None; + o.CorrelationCookie.HttpOnly = true; + o.CorrelationCookie.IsEssential = true; + o.CorrelationCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + o.AuthorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"; + o.TokenEndpoint = "https://oauth2.googleapis.com/token"; + o.UserInformationEndpoint = "https://www.googleapis.com/oauth2/v3/userinfo"; + o.ClaimActions.MapJsonKey("id", "id"); + o.ClaimActions.MapJsonKey("name", "name"); + o.ClaimActions.MapJsonKey("email", "email"); + o.Events = new OAuthEvents + { + OnAccessDenied = context => + { + context.HandleResponse(); + context.HttpContext.Response.Redirect($"{env.SelfBaseUrl}/auth"); + return Task.CompletedTask; + }, + OnCreatingTicket = async context => + { + var googleUser = await MakeOAuthRequest(context, context.Options.UserInformationEndpoint); + if (googleUser is null) + throw new Exception("Failed to retrieve Google user information."); + + if (!googleUser.EmailVerified) + throw new Exception("Email not verified, can't login with Google."); + + var authService = context.HttpContext.RequestServices.GetRequiredService(); + var user = await authService.FindOrCreateAccountWithOAuthAsync(googleUser.Name, googleUser.Email, "google", googleUser.Id, context.HttpContext.RequestAborted); + context.RunClaimActions(JsonSerializer.SerializeToElement(new { id = user.Id, name = user.Name, email = user.Email })); + } + }; + }); + } + + private static async Task MakeOAuthRequest(OAuthCreatingTicketContext context, string endpoint) + { + using var request = new HttpRequestMessage(HttpMethod.Get, endpoint); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.AccessToken); + using var response = await context.Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, context.HttpContext.RequestAborted); + + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(); + } + + private static async Task GetGitHubPreferredEmail(OAuthCreatingTicketContext context) + { + var emails = await MakeOAuthRequest(context, "https://api.github.com/user/emails"); + return emails?.Where(e => e.Verified).OrderBy(e => e.Primary ? 0 : 1).FirstOrDefault()?.Email ?? ""; + } + +} \ No newline at end of file diff --git a/src/Features/Authentication/UserAccount.cs b/src/Features/Authentication/UserAccount.cs new file mode 100755 index 0000000..94132d9 --- /dev/null +++ b/src/Features/Authentication/UserAccount.cs @@ -0,0 +1,36 @@ +using System.Text; +using System.Security.Cryptography; + +namespace Aptabase.Features.Authentication; + +public class UserAccount +{ + public string Id { get; set; } = ""; + public string Name { get; set; } = ""; + public string Email { get; set; } = ""; + public string AvatarUrl => GetAvatarUrl(); + public char? LockReason { get; set; } + + public UserAccount() + { + + } + + public UserAccount(UserIdentity identity) + { + Id = identity.Id; + Name = identity.Name; + Email = identity.Email; + } + + private static MD5 md5 = MD5.Create(); + private string GetAvatarUrl() + { + var hash = md5.ComputeHash(Encoding.UTF8.GetBytes(Email.Trim().ToLower())); + var sb = new StringBuilder(); + foreach (var b in hash) + sb.Append(b.ToString("x2")); + + return $"https://md.sudovanilla.org/images/icons/Aptabase.jpg"; + } +} \ No newline at end of file diff --git a/src/Features/Authentication/UserIdentifier.cs b/src/Features/Authentication/UserIdentifier.cs new file mode 100755 index 0000000..e1cbfbe --- /dev/null +++ b/src/Features/Authentication/UserIdentifier.cs @@ -0,0 +1,15 @@ +namespace Aptabase.Features.Authentication; + +public struct UserIdentity +{ + public string Id { get; private set; } = ""; + public string Name { get; private set; } = ""; + public string Email { get; private set; } = ""; + + public UserIdentity(string id, string name, string email) + { + Id = id; + Name = name; + Email = email; + } +} \ No newline at end of file diff --git a/src/Features/Billing/BillingController.cs b/src/Features/Billing/BillingController.cs new file mode 100755 index 0000000..20f272b --- /dev/null +++ b/src/Features/Billing/BillingController.cs @@ -0,0 +1,93 @@ +using Aptabase.Features.Authentication; +using Aptabase.Features.Billing.LemonSqueezy; +using Aptabase.Features.Stats; +using Microsoft.AspNetCore.Mvc; + +namespace Aptabase.Features.Billing; + +[ApiController, IsAuthenticated] +[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] +public class BillingController : Controller +{ + private readonly IQueryClient _queryClient; + private readonly IBillingQueries _billingQueries; + private readonly LemonSqueezyClient _lsClient; + + public BillingController(IBillingQueries billingQueries, LemonSqueezyClient lsClient, IQueryClient queryClient) + { + _billingQueries = billingQueries ?? throw new ArgumentNullException(nameof(billingQueries)); + _lsClient = lsClient ?? throw new ArgumentNullException(nameof(lsClient)); + _queryClient = queryClient ?? throw new ArgumentNullException(nameof(queryClient)); + } + + [HttpGet("/api/_billing")] + public async Task BillingState(CancellationToken cancellationToken) + { + var user = this.GetCurrentUserIdentity(); + var appIds = await _billingQueries.GetOwnedAppIds(user); + + var usage = await _queryClient.NamedQuerySingleAsync("get_billing_usage__v1", new { + app_ids = appIds, + }, cancellationToken); + + var sub = await _billingQueries.GetUserSubscription(user); + var plan = sub is null || sub.Status == "expired" + ? SubscriptionPlan.GetFreeVariant(await _billingQueries.GetUserFreeTierOrTrial(user)) + : SubscriptionPlan.GetByVariantId(sub.VariantId); + + var state = (usage?.Count ?? 0) < plan.MonthlyEvents ? "OK" : "OVERUSE"; + + return Ok(new + { + State = state, + Usage = usage?.Count ?? 0, + DateTime.UtcNow.Month, + DateTime.UtcNow.Year, + Subscription = sub != null ? new + { + sub.Status, + sub.EndsAt, + } : null, + Plan = plan + }); + } + + [HttpGet("/api/_billing/historical")] + public async Task HistoricalUsage(CancellationToken cancellationToken) + { + var user = this.GetCurrentUserIdentity(); + var appIds = await _billingQueries.GetOwnedAppIds(user); + + var rows = await _queryClient.NamedQueryAsync("billing_historical_usage__v1", new + { + app_ids = appIds, + }, cancellationToken); + + return Ok(rows); + } + + [HttpPost("/api/_billing/checkout")] + public async Task GenerateCheckoutUrl(CancellationToken cancellationToken) + { + var user = this.GetCurrentUserIdentity(); + var url = await _lsClient.CreateCheckout(user, cancellationToken); + + return Ok(new { url }); + } + + [HttpPost("/api/_billing/portal")] + public async Task GeneratePortalUrl(CancellationToken cancellationToken) + { + var user = this.GetCurrentUserIdentity(); + var sub = await _billingQueries.GetUserSubscription(user); + + if (sub is null) + { + return NotFound(); + } + + var url = await _lsClient.GetBillingPortalUrl(sub.Id, cancellationToken); + + return Ok(new { url }); + } +} diff --git a/src/Features/Billing/BillingQueries.cs b/src/Features/Billing/BillingQueries.cs new file mode 100755 index 0000000..129554e --- /dev/null +++ b/src/Features/Billing/BillingQueries.cs @@ -0,0 +1,122 @@ +using Aptabase.Data; +using Aptabase.Features.Authentication; +using Aptabase.Features.Stats; +using Dapper; + +namespace Aptabase.Features.Billing; + +public interface IBillingQueries +{ + Task GetTrialsDueSoon(); + Task GetUserSubscription(UserIdentity user); + Task GetUserFreeTierOrTrial(UserIdentity user); + Task GetOwnedAppIds(UserIdentity user); + Task LockUsersWithExpiredTrials(); + Task LockUser(string userId, string reason); + Task UnlockOveruseAccounts(); + Task> GetBillingUsageByApp(int year, int month); + Task> GetUserQuotaForApps(string[] appIds); +} + +public class BillingQueries : IBillingQueries +{ + private readonly IDbContext _db; + private readonly IQueryClient _queryClient; + + public BillingQueries(IDbContext db, IQueryClient queryClient) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + _queryClient = queryClient ?? throw new ArgumentNullException(nameof(queryClient)); + } + + public async Task GetUserSubscription(UserIdentity user) + { + return await _db.Connection.QueryFirstOrDefaultAsync( + @"SELECT * FROM subscriptions + WHERE owner_id = @userId + ORDER BY created_at DESC LIMIT 1", + new { userId = user.Id }); + } + + public async Task GetUserFreeTierOrTrial(UserIdentity user) + { + return await _db.Connection.QueryFirstAsync( + @"SELECT free_quota, free_trial_ends_at FROM users WHERE id = @userId", + new { userId = user.Id }); + } + + public async Task GetOwnedAppIds(UserIdentity user) + { + var releaseAppIds = await _db.Connection.QueryAsync(@"SELECT id FROM apps WHERE owner_id = @userId", new { userId = user.Id }); + var debugAppIds = releaseAppIds.Select(id => $"{id}_DEBUG"); + return releaseAppIds.Concat(debugAppIds).ToArray(); + } + + public async Task> GetBillingUsageByApp(int year, int month) + { + var period = $"{year}-{month.ToString().PadLeft(2, '0')}-01"; + return await _queryClient.NamedQueryAsync("billing_usage_per_app__v1", new { period }, default); + } + + public async Task> GetUserQuotaForApps(string[] appIds) + { + return await _db.Connection.QueryAsync( + @"SELECT u.id AS id, + u.email AS email, + u.name AS name, + ARRAY_AGG(a.id) as app_ids, + u.free_quota, + s.variant_id + FROM users u + LEFT JOIN subscriptions s + ON s.owner_id = u.id + AND s.status != 'expired' + INNER JOIN apps a + ON a.owner_id = u.id + AND a.id = ANY(@appIds) + WHERE u.lock_reason IS NULL + GROUP BY u.id, u.email, u.name, u.free_quota, s.variant_id", new { appIds }); + } + + public async Task GetTrialsDueSoon() + { + var start = DateTime.UtcNow.AddDays(5).Date; + var end = start.AddDays(1).Date; + + var users = await _db.Connection.QueryAsync( + @"SELECT DISTINCT u.id, u.name, u.email + FROM users u + LEFT JOIN subscriptions s + ON s.owner_id = u.id + INNER JOIN apps a + ON a.owner_id = u.id + AND a.has_events = true + WHERE u.free_trial_ends_at BETWEEN @start AND @end + AND s.id IS NULL", new { start, end }); + return users.ToArray(); + } + + public async Task LockUsersWithExpiredTrials() + { + var count = await _db.Connection.ExecuteAsync( + @"UPDATE users u + SET lock_reason = 'T' + WHERE u.free_quota IS NULL + AND u.free_trial_ends_at <= now() + AND u.lock_reason IS NULL + AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.owner_id = u.id)"); + return count; + } + + public async Task LockUser(string userId, string reason) + { + return await _db.Connection.ExecuteAsync( + @"UPDATE users SET lock_reason = @reason WHERE id = @userId", + new { userId, reason }); + } + + public async Task UnlockOveruseAccounts() + { + return await _db.Connection.ExecuteAsync(@"UPDATE users SET lock_reason = NULL WHERE lock_reason = 'O'"); + } +} \ No newline at end of file diff --git a/src/Features/Billing/BillingUsage.cs b/src/Features/Billing/BillingUsage.cs new file mode 100755 index 0000000..8fb1d32 --- /dev/null +++ b/src/Features/Billing/BillingUsage.cs @@ -0,0 +1,36 @@ +namespace Aptabase.Features.Billing; + +public class BillingUsage +{ + public long Count { get; set; } +} + +public class BillingUsageByApp +{ + public string AppId { get; set; } = ""; + public long Count { get; set; } +} + +public class UserQuota +{ + public string Id { get; set; } = ""; + public string Email { get; set; } = ""; + public string Name { get; set; } = ""; + public string[] AppIds { get; set; } = Array.Empty(); + public long? FreeQuota { get; set; } + public long? VariantID { get; set; } + + public long GetQuota() + { + if (VariantID is not null) + return SubscriptionPlan.GetByVariantId(VariantID.Value).MonthlyEvents; + + return FreeQuota ?? SubscriptionPlan.FreeTrialMonthlyQuota; + } +} + +public class BillingHistoricUsage +{ + public DateTime Date { get; set; } + public long Events { get; set; } +} \ No newline at end of file diff --git a/src/Features/Billing/LemonSqueezy/LemonSqueezyClient.cs b/src/Features/Billing/LemonSqueezy/LemonSqueezyClient.cs new file mode 100755 index 0000000..e8c0bf6 --- /dev/null +++ b/src/Features/Billing/LemonSqueezy/LemonSqueezyClient.cs @@ -0,0 +1,79 @@ +using System.Text.Json; +using Aptabase.Features.Authentication; +using Yoh.Text.Json.NamingPolicies; + +namespace Aptabase.Features.Billing.LemonSqueezy; + +public class LemonSqueezyClient +{ + private HttpClient _httpClient; + private EnvSettings _env; + private ILogger _logger; + + public static readonly JsonSerializerOptions JsonSettings = new() + { + WriteIndented = false, + PropertyNamingPolicy = JsonNamingPolicies.SnakeCaseLower + }; + + public LemonSqueezyClient(IHttpClientFactory factory, EnvSettings env, ILogger logger) + { + _httpClient = factory.CreateClient("LemonSqueezy"); + _env = env ?? throw new ArgumentNullException(nameof(env)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task CreateCheckout(UserIdentity user, CancellationToken cancellationToken) + { + var body = new { + data = new { + type = "checkouts", + attributes = new { + checkout_data = new { + email = user.Email, + custom = new { + user_id = user.Id, + region = _env.Region + } + }, + product_options = new { + redirect_url = $"{_env.SelfBaseUrl}/billing", + }, + checkout_options = new { + embed = false, + dark = false + } + }, + relationships = new { + store = new { + data = new { + type = "stores", + id = "30508" + } + }, + variant = new { + data = new { + type = "variants", + id = _env.IsProduction ? "103474" : "85183" + } + } + } + } + }; + + var response = await _httpClient.PostAsJsonAsync("/v1/checkouts", body, JsonSettings, cancellationToken); + + await response.EnsureSuccessWithLog(_logger); + var result = await response.Content.ReadFromJsonAsync>>(JsonSettings); + return result?.Data.Attributes.Url ?? ""; + } + + public async Task GetBillingPortalUrl(long subscriptionId, CancellationToken cancellationToken) + { + var response = await _httpClient.GetAsync($"/v1/subscriptions/{subscriptionId}", cancellationToken); + + await response.EnsureSuccessWithLog(_logger); + var result = await response.Content.ReadFromJsonAsync>>(JsonSettings); + return result?.Data.Attributes.Urls.CustomerPortal ?? ""; + } +} \ No newline at end of file diff --git a/src/Features/Billing/LemonSqueezy/LemonSqueezyExtensions.cs b/src/Features/Billing/LemonSqueezy/LemonSqueezyExtensions.cs new file mode 100755 index 0000000..0f92306 --- /dev/null +++ b/src/Features/Billing/LemonSqueezy/LemonSqueezyExtensions.cs @@ -0,0 +1,16 @@ +using System.Net.Http.Headers; + +namespace Aptabase.Features.Billing.LemonSqueezy; + +public static class LemonSqueezyExtensions +{ + public static void AddLemonSqueezy(this IServiceCollection services, EnvSettings env) + { + services.AddSingleton(); + services.AddHttpClient("LemonSqueezy", client => + { + client.BaseAddress = new Uri("https://api.lemonsqueezy.com"); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", env.LemonSqueezyApiKey); + }); + } +} \ No newline at end of file diff --git a/src/Features/Billing/LemonSqueezy/Models.cs b/src/Features/Billing/LemonSqueezy/Models.cs new file mode 100755 index 0000000..8b03c61 --- /dev/null +++ b/src/Features/Billing/LemonSqueezy/Models.cs @@ -0,0 +1,38 @@ +namespace Aptabase.Features.Billing.LemonSqueezy; + +public class PagedList where T : new() +{ + public IEnumerable> Data { get; set; } = Enumerable.Empty>(); +} + +public class Resource where T : new() +{ + public string Id { get; set; } = ""; + public string Type { get; set; } = ""; + public T Attributes { get; set; } = new T(); +} + +public class GetResponse where T : new() +{ + public T Data { get; set; } = new T(); +} + +public class CheckoutAttributes +{ + public string Url { get; set; } = ""; +} + +public class SubscriptionAttributes +{ + public int ProductID { get; set; } + public int VariantID { get; set; } + public int CustomerID { get; set; } + public string Status { get; set; } = ""; + public DateTime? EndsAt { get; set; } + public SubscriptionUrls Urls { get; set; } = new SubscriptionUrls(); +} + +public class SubscriptionUrls +{ + public string CustomerPortal { get; set; } = ""; +} \ No newline at end of file diff --git a/src/Features/Billing/LemonSqueezy/WebhookEvent.cs b/src/Features/Billing/LemonSqueezy/WebhookEvent.cs new file mode 100755 index 0000000..110ad73 --- /dev/null +++ b/src/Features/Billing/LemonSqueezy/WebhookEvent.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Nodes; + +namespace Aptabase.Features.Billing.LemonSqueezy; + +public class WebhookEvent +{ + public WebhookEventMeta Meta { get; set; } = new WebhookEventMeta(); + public WebhookEventData Data { get; set; } = new WebhookEventData(); +} + +public class WebhookEventData +{ + public string Id { get; set; } = ""; + public string Type { get; set; } = ""; + public JsonObject Attributes { get; set; } = new JsonObject(); +} + +public class WebhookEventMeta +{ + public string EventName { get; set; } = ""; + public Dictionary CustomData { get; set; } = new Dictionary(); +} \ No newline at end of file diff --git a/src/Features/Billing/LemonSqueezyWebhookController.cs b/src/Features/Billing/LemonSqueezyWebhookController.cs new file mode 100755 index 0000000..c90dff4 --- /dev/null +++ b/src/Features/Billing/LemonSqueezyWebhookController.cs @@ -0,0 +1,163 @@ +using Aptabase.Data; +using Aptabase.Features.Billing.LemonSqueezy; +using Dapper; +using Microsoft.AspNetCore.Mvc; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace Aptabase.Features.Billing; + +[ApiController] +[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] +public class LemonSqueezyWebhookController : Controller +{ + private readonly IDbContext _db; + private readonly LemonSqueezyClient _lsClient; + private readonly ILogger _logger; + + private readonly string _region; + private readonly byte[] _signingSecret; + + public LemonSqueezyWebhookController(EnvSettings env, IDbContext db, LemonSqueezyClient lsClient, ILogger logger) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + _lsClient = lsClient ?? throw new ArgumentNullException(nameof(lsClient)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + _region = env.Region; + _signingSecret = Encoding.UTF8.GetBytes(env.LemonSqueezySigningSecret); + } + + [HttpPost("/webhook/lemonsqueezy")] + public async Task Post(CancellationToken cancellationToken) + { + var (isValid, body) = await ValidateSignatureAsync(HttpContext); + + if (!isValid) + { + _logger.LogWarning("Invalid LemonSqueezy Signature"); + + return Unauthorized(new { message = "Invalid LemonSqueezy Signature" }); + } + + var ev = JsonSerializer.Deserialize(body, LemonSqueezyClient.JsonSettings); + + if (ev == null) + { + _logger.LogWarning("Invalid JSON Body: {Body}", body); + + return BadRequest(new { message = "Invalid JSON Body" }); + } + + if (ev.Meta.CustomData.TryGetValue("region", out var eventRegion) == false) + { + _logger.LogError("LemonSqueezy event is missing region"); + + return BadRequest(new { message = "Missing 'region' on meta.custom_data" }); + } + + if (eventRegion != _region) + { + return Ok(new { message = "Ignoring event from different region" }); + } + + var task = ev.Meta.EventName switch + { + "subscription_created" => HandleSubscriptionCreatedOrUpdated(ev, cancellationToken), + "subscription_updated" => HandleSubscriptionCreatedOrUpdated(ev, cancellationToken), + _ => HandleUnknownEvent(ev), + }; + + return await task; + } + + private async Task HandleSubscriptionCreatedOrUpdated([FromBody] WebhookEvent ev, CancellationToken cancellationToken) + { + var body = JsonSerializer.Deserialize(ev.Data.Attributes, LemonSqueezyClient.JsonSettings); + + if (body == null) + { + return BadRequest(new { message = "Event body is null" }); + } + + var subId = Convert.ToInt64(ev.Data.Id); + var ownerId = ev.Meta.CustomData["user_id"]; + + if (string.IsNullOrEmpty(ownerId)) + { + _logger.LogError("LemonSqueezy event is missing user_id"); + + return BadRequest(new { message = "Missing 'user_id' on meta.custom_data" }); + } + + await _db.Connection.ExecuteAsync( + @"UPDATE users SET lock_reason = null WHERE id = @ownerId", + new { ownerId } + ); + + if (body.Status == "expired") + { + await _db.Connection.ExecuteAsync( + @"DELETE FROM subscriptions WHERE id = @subId", + new + { + subId, + }); + } + else + { + await _db.Connection.ExecuteAsync( + @"INSERT INTO subscriptions + (id, owner_id, customer_id, product_id, variant_id, status, ends_at) + VALUES + (@subId, @ownerId, @customerId, @productId, @variantId, @status, @endsAt) + ON CONFLICT (id) + DO UPDATE SET product_id = @productId, + variant_id = @variantId, + status = @status, + ends_at = @endsAt, + modified_at = now()", + new + { + subId, + ownerId, + customerId = body.CustomerID, + productId = body.ProductID, + variantId = body.VariantID, + status = body.Status, + endsAt = body.EndsAt, + }); + } + + return Ok(new {}); + } + + private async Task<(bool, string)> ValidateSignatureAsync(HttpContext http) + { + using var reader = new StreamReader(Request.Body); + string body = await reader.ReadToEndAsync(); + + using var hmac = new HMACSHA256(_signingSecret); + var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body)); + var digest = BitConverter.ToString(hash).Replace("-", string.Empty).ToLower(); + var digestBytes = Encoding.UTF8.GetBytes(digest); + + var signature = http.Request.Headers["X-Signature"].ToString() ?? string.Empty; + var signatureBytes = Encoding.UTF8.GetBytes(signature); + + if (!CryptographicOperations.FixedTimeEquals(digestBytes, signatureBytes)) + { + return (false, string.Empty); + } + + return (true, body); + } + + private Task HandleUnknownEvent(WebhookEvent ev) + { + _logger.LogError("Unknown LemonSqueezy event: {EventName}", ev.Meta.EventName); + + return Task.FromResult(BadRequest(new { message = "Unknown event" })); + } +} diff --git a/src/Features/Billing/OveruseNotificationCronJob.cs b/src/Features/Billing/OveruseNotificationCronJob.cs new file mode 100755 index 0000000..cad53b7 --- /dev/null +++ b/src/Features/Billing/OveruseNotificationCronJob.cs @@ -0,0 +1,100 @@ +using Aptabase.Features.Notification; +using Features.Cache; +using Sgbj.Cron; + +namespace Aptabase.Features.Billing; + +public class OveruseNotificationCronJob : BackgroundService +{ + private readonly IBillingQueries _billingQueries; + private readonly IEmailClient _emailClient; + private readonly EnvSettings _env; + private readonly ICache _cache; + private readonly ILogger _logger; + + public OveruseNotificationCronJob(IBillingQueries billingQueries, IEmailClient emailClient, EnvSettings env, ICache cache, ILogger logger) + { + _billingQueries = billingQueries ?? throw new ArgumentNullException(nameof(billingQueries)); + _emailClient = emailClient ?? throw new ArgumentNullException(nameof(emailClient)); + _env = env ?? throw new ArgumentNullException(nameof(env)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + _logger.LogInformation("OveruseNotificationCronJob is starting."); + + using var timer = new CronTimer("10 * * * *", TimeZoneInfo.Utc); + + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + _logger.LogInformation("Looking for accounts with events overuse."); + var usagePerApp = await _billingQueries.GetBillingUsageByApp(DateTime.UtcNow.Year, DateTime.UtcNow.Month); + var users = await _billingQueries.GetUserQuotaForApps(usagePerApp.Select(x => x.AppId).ToArray()); + foreach (var user in users) + { + try + { + var quota = user.GetQuota(); + var usage = usagePerApp.Where(x => user.AppIds.Contains(x.AppId)).Sum(x => x.Count); + var perc = usage * 1.0 / quota; + _logger.LogInformation("User {UserId} has used {Usage} ({Perc:P}) out of {Quota} events.", user.Id, usage, perc, quota); + + var (subject, templateName) = GetSubjectTemplateName(perc); + if (string.IsNullOrEmpty(templateName) || string.IsNullOrEmpty(subject)) + continue; // No need to send notification + + var cacheKey = $"OveruseNotification.{user.Id}.{templateName}.{DateTime.UtcNow.Year}-{DateTime.UtcNow.Month.ToString().PadLeft(2, '0')}"; + if (await _cache.Exists(cacheKey)) + continue; // Already sent notification + + await _emailClient.SendEmailAsync(user.Email, subject, templateName, new() + { + { "name", user.Name.Split(" ").ElementAtOrDefault(0) ?? user.Name }, + { "quota", quota.ToString("N0") }, + { "url", _env.SelfBaseUrl }, + }, cancellationToken); + + if (usage >= quota) + { + _logger.LogInformation("User {UserId} has reached the limit, pausing ingestion.", user.Id); + await _billingQueries.LockUser(user.Id, "O"); + } + + await _cache.Set(cacheKey, DateTime.UtcNow.ToString("o"), TimeSpan.FromDays(10)); + } + catch (Exception ex) + { + _logger.LogError(ex, "OveruseNotificationCronJob failed to process usage for user {UserId}.", user.Id); + } + } + _logger.LogInformation("Finished processing overuse notifications."); + } + } + catch (OperationCanceledException) + { + _logger.LogInformation("OveruseNotificationCronJob stopped."); + } + catch (Exception ex) + { + _logger.LogError(ex, "OveruseNotificationCronJob crashed."); + } + } + } + + private static (string?, string?) GetSubjectTemplateName(double perc) + { + if (perc >= 1) + return ("ACTION REQUIRED: Event ingestion paused", "UsageLevel100"); + if (perc >= 0.9) + return ("WARNING: You have used 90% of your monthly limit, incoming events will be dropped soon", "UsageLevel90"); + if (perc >= 0.8) + return ("You have used 80% of your monthly limit, incoming events will be dropped soon", "UsageLevel80"); + return (null, null); + } +} diff --git a/src/Features/Billing/ResetOveruseCronJob.cs b/src/Features/Billing/ResetOveruseCronJob.cs new file mode 100755 index 0000000..21b7c95 --- /dev/null +++ b/src/Features/Billing/ResetOveruseCronJob.cs @@ -0,0 +1,43 @@ +using Sgbj.Cron; + +namespace Aptabase.Features.Billing; + +public class ResetOveruseCronJob : BackgroundService +{ + private readonly IBillingQueries _billingQueries; + private readonly ILogger _logger; + + public ResetOveruseCronJob(IBillingQueries billingQueries, ILogger logger) + { + _billingQueries = billingQueries ?? throw new ArgumentNullException(nameof(billingQueries)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + _logger.LogInformation("ResetOveruseCronJob is starting."); + + using var timer = new CronTimer("5 0 1 * *", TimeZoneInfo.Utc); + + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + _logger.LogInformation("Unlocking accounts with overuse."); + var count = await _billingQueries.UnlockOveruseAccounts(); + _logger.LogInformation("Unlocked {count} accounts with overuse status.", count); + } + } + catch (OperationCanceledException) + { + _logger.LogInformation("ResetOveruseCronJob stopped."); + } + catch (Exception ex) + { + _logger.LogError(ex, "ResetOveruseCronJob crashed."); + } + } + } +} diff --git a/src/Features/Billing/Subscription.cs b/src/Features/Billing/Subscription.cs new file mode 100755 index 0000000..9e55d02 --- /dev/null +++ b/src/Features/Billing/Subscription.cs @@ -0,0 +1,87 @@ + +namespace Aptabase.Features.Billing; + +public class Subscription +{ + public long Id { get; set; } + public string OwnerId { get; set; } = ""; + public long CustomerId { get; set; } + public long ProductId { get; set; } + public long VariantId { get; set; } + public string Status { get; set; } = ""; + public DateTime? EndsAt { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime ModifiedAt { get; set; } +} + +public class FreeSubscription +{ + public long? FreeQuota { get; set; } + public DateTime? FreeTrialEndsAt { get; set; } +} + +public class SubscriptionPlan +{ + public const int FreeTrialMonthlyQuota = 1_000_000; + + public string Name { get; } + public int MonthlyPrice { get; } + public int MonthlyEvents { get; } + public long VariantId { get; } + public DateTime? FreeTrialEndsAt { get; } + + public SubscriptionPlan(string name, int monthlyEvents, int monthlyPrice, long variantId, DateTime? freeTrialEndsAt) + { + Name = name; + MonthlyEvents = monthlyEvents; + MonthlyPrice = monthlyPrice; + VariantId = variantId; + FreeTrialEndsAt = freeTrialEndsAt; + } + + public static SubscriptionPlan GetFreeVariant(FreeSubscription sub) + { + if (sub.FreeQuota.HasValue) + return new SubscriptionPlan("Free Plan", (int)sub.FreeQuota.Value, 0, 0, null); + + if (sub.FreeTrialEndsAt.HasValue) + return new SubscriptionPlan("Free Trial", FreeTrialMonthlyQuota, 0, 0, sub.FreeTrialEndsAt); + + throw new InvalidOperationException("No free subscription found"); + } + + public static SubscriptionPlan GetByVariantId(long variantId) + { + return ProductionPlans.FirstOrDefault(plan => plan.VariantId == variantId) + ?? DevelopmentPlans.FirstOrDefault(plan => plan.VariantId == variantId) + ?? throw new InvalidOperationException($"Subscription Variant not found for ID {variantId}"); + } + + private static readonly SubscriptionPlan[] DevelopmentPlans = + [ + new SubscriptionPlan("200k Plan", 200_000, 10, 85183, null), + new SubscriptionPlan("1M Plan", 1_000_000, 20, 85184, null), + new SubscriptionPlan("2M Plan", 2_000_000, 40, 85185, null), + new SubscriptionPlan("5M Plan", 5_000_000, 75, 85187, null), + new SubscriptionPlan("10M Plan", 10_000_000, 140, 85188, null), + new SubscriptionPlan("20M Plan", 20_000_000, 240, 85190, null), + new SubscriptionPlan("30M Plan", 30_000_000, 300, 85192, null), + new SubscriptionPlan("50M Plan", 50_000_000, 450, 85194, null), + ]; + + private static readonly SubscriptionPlan[] ProductionPlans = + [ + new SubscriptionPlan("200k Plan", 200_000, 10, 103474, null), + new SubscriptionPlan("1M Plan", 1_000_000, 20, 103475, null), + new SubscriptionPlan("2M Plan", 2_000_000, 40, 103476, null), + new SubscriptionPlan("5M Plan", 5_000_000, 75, 103477, null), + new SubscriptionPlan("10M Plan", 10_000_000, 140, 103478, null), + new SubscriptionPlan("20M Plan", 20_000_000, 240, 103479, null), + new SubscriptionPlan("30M Plan", 30_000_000, 300, 103480, null), + new SubscriptionPlan("50M Plan", 50_000_000, 450, 103481, null), + new SubscriptionPlan("100M Plan", 100_000_000, 750, 614418, null), + new SubscriptionPlan("200M Plan", 200_000_000, 1200, 614417, null), + new SubscriptionPlan("500M Plan", 500_000_000, 1500, 636430, null), + + ]; +} \ No newline at end of file diff --git a/src/Features/Billing/TrialExpiredCronJob.cs b/src/Features/Billing/TrialExpiredCronJob.cs new file mode 100755 index 0000000..4aa9c0e --- /dev/null +++ b/src/Features/Billing/TrialExpiredCronJob.cs @@ -0,0 +1,43 @@ +using Sgbj.Cron; + +namespace Aptabase.Features.Billing; + +public class TrialExpiredCronJob : BackgroundService +{ + private readonly IBillingQueries _billingQueries; + private readonly ILogger _logger; + + public TrialExpiredCronJob(IBillingQueries billingQueries, ILogger logger) + { + _billingQueries = billingQueries ?? throw new ArgumentNullException(nameof(billingQueries)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + _logger.LogInformation("TrialExpiredCronJob is starting."); + + using var timer = new CronTimer("0 0 * * *", TimeZoneInfo.Utc); + + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + _logger.LogInformation("Searching for users with expired trial."); + var count = await _billingQueries.LockUsersWithExpiredTrials(); + _logger.LogInformation("Locked {count} users with expired trial.", count); + } + } + catch (OperationCanceledException) + { + _logger.LogInformation("TrialExpiredCronJob stopped."); + } + catch (Exception ex) + { + _logger.LogError(ex, "TrialExpiredCronJob crashed."); + } + } + } +} \ No newline at end of file diff --git a/src/Features/Billing/TrialNotificationCronJob.cs b/src/Features/Billing/TrialNotificationCronJob.cs new file mode 100755 index 0000000..ba948d6 --- /dev/null +++ b/src/Features/Billing/TrialNotificationCronJob.cs @@ -0,0 +1,58 @@ +using Aptabase.Features.Notification; +using Sgbj.Cron; + +namespace Aptabase.Features.Billing; + +public class TrialNotificationCronJob : BackgroundService +{ + private readonly IBillingQueries _billingQueries; + private readonly IEmailClient _emailClient; + private readonly EnvSettings _env; + private readonly ILogger _logger; + + public TrialNotificationCronJob(IBillingQueries billingQueries, IEmailClient emailClient, EnvSettings env, ILogger logger) + { + _billingQueries = billingQueries ?? throw new ArgumentNullException(nameof(billingQueries)); + _emailClient = emailClient ?? throw new ArgumentNullException(nameof(emailClient)); + _env = env ?? throw new ArgumentNullException(nameof(env)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + _logger.LogInformation("TrialNotificationCronJob is starting."); + + using var timer = new CronTimer("0 0 * * *", TimeZoneInfo.Utc); + + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + _logger.LogInformation("Searching for users to notify about trial expiration."); + var users = await _billingQueries.GetTrialsDueSoon(); + foreach (var user in users) + { + _logger.LogInformation("Sending trial notification to {name} ({user})", user.Name, user.Email); + await _emailClient.SendEmailAsync(user.Email, "Your Trial ends in 5 days", "TrialEndsSoon", new() + { + { "name", user.Name }, + { "url", _env.SelfBaseUrl }, + }, cancellationToken); + } + + _logger.LogInformation("Sent trial notifications to {count} users", users.Length); + } + } + catch (OperationCanceledException) + { + _logger.LogInformation("TrialNotificationCronJob stopped."); + } + catch (Exception ex) + { + _logger.LogError(ex, "TrialNotificationCronJob crashed."); + } + } + } +} \ No newline at end of file diff --git a/src/Features/Blob/DatabaseBlobService.cs b/src/Features/Blob/DatabaseBlobService.cs new file mode 100755 index 0000000..ec84b71 --- /dev/null +++ b/src/Features/Blob/DatabaseBlobService.cs @@ -0,0 +1,36 @@ +using Aptabase.Data; +using ClickHouse.Client.Utility; +using Dapper; + +namespace Aptabase.Features.Blob; + +public class DatabaseBlobService : IBlobService +{ + private readonly IDbContext _db; + + public DatabaseBlobService(IDbContext db) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + } + + public async Task UploadAsync(string prefix, byte[] content, string contentType, CancellationToken cancellationToken) + { + var path = $"{prefix}/{Guid.NewGuid().ToString()}.png"; + + var cmd = new CommandDefinition("INSERT INTO blobs (path, content, content_type) VALUES (@path, @content, @contentType)", new { + path, + content = content, + contentType = "image/png" + }, cancellationToken: cancellationToken); + + await _db.Connection.ExecuteScalarAsync(cmd); + + return path; + } + + public async Task DownloadAsync(string path, CancellationToken cancellationToken) + { + var cmd = new CommandDefinition("SELECT path, content, content_type FROM blobs WHERE path = @path", new { path }, cancellationToken: cancellationToken); + return await _db.Connection.QuerySingleOrDefaultAsync(cmd); + } +} \ No newline at end of file diff --git a/src/Features/Blob/IBlobService.cs b/src/Features/Blob/IBlobService.cs new file mode 100755 index 0000000..4abab21 --- /dev/null +++ b/src/Features/Blob/IBlobService.cs @@ -0,0 +1,14 @@ +namespace Aptabase.Features.Blob; + +public class Blob +{ + public string Path { get; set; } = ""; + public byte[] Content { get; set; } = new byte[0]; + public string ContentType { get; set; } = ""; +} + +public interface IBlobService +{ + Task UploadAsync(string prefix, byte[] content, string contentType, CancellationToken cancellationToken); + Task DownloadAsync(string path, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Features/Blob/UploadsController.cs b/src/Features/Blob/UploadsController.cs new file mode 100755 index 0000000..2dd8ea2 --- /dev/null +++ b/src/Features/Blob/UploadsController.cs @@ -0,0 +1,33 @@ +using Aptabase.Features.Blob; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Aptabase.Features.Blob; + +public class UploadedFile +{ + public byte[] Content { get; set; } = new byte[0]; + public string ContentType { get; set; } = ""; +} + +[ApiController, AllowAnonymous] +public class UploadsController : Controller +{ + private readonly IBlobService _blobService; + + public UploadsController(IBlobService blobService) + { + _blobService = blobService ?? throw new ArgumentNullException(nameof(blobService)); + } + + [HttpGet("/uploads/{**path}")] + [ResponseCache(Duration = 365 * 24 * 60 * 60)] // Cache for 1 year because uploaded files are immutable + public async Task ReadFile(string path, CancellationToken cancellationToken) + { + var file = await _blobService.DownloadAsync(path, cancellationToken); + if (file == null) + return NotFound(); + + return File(file.Content, file.ContentType); + } +} diff --git a/src/Features/Cache/CacheService.cs b/src/Features/Cache/CacheService.cs new file mode 100755 index 0000000..ec61a35 --- /dev/null +++ b/src/Features/Cache/CacheService.cs @@ -0,0 +1,43 @@ +using Aptabase.Data; +using Dapper; + +namespace Features.Cache; + +public interface ICache +{ + Task Get(string key); + Task Set(string key, string value, TimeSpan ttl); + Task Exists(string key); +} + +public class DatabaseCache : ICache +{ + private readonly IDbContext _db; + + public DatabaseCache(IDbContext db) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + } + + public async Task Get(string key) + { + return await _db.Connection.ExecuteScalarAsync( + "SELECT value FROM cache WHERE key = @key AND expires_at > now()", + new { key }); + } + + public async Task Set(string key, string value, TimeSpan ttl) + { + var expiresAt = DateTime.UtcNow.Add(ttl); + await _db.Connection.ExecuteAsync( + "INSERT INTO cache (key, value, expires_at) VALUES (@key, @value, @expiresAt) " + + "ON CONFLICT (key) DO UPDATE SET value = @value, expires_at = @expiresAt", + new { key, value, expiresAt }); + } + + public async Task Exists(string key) + { + var value = await Get(key); + return !string.IsNullOrEmpty(value); + } +} \ No newline at end of file diff --git a/src/Features/EnvSettings.cs b/src/Features/EnvSettings.cs new file mode 100755 index 0000000..118fd59 --- /dev/null +++ b/src/Features/EnvSettings.cs @@ -0,0 +1,153 @@ +using System.Text; + +namespace Aptabase.Features; + +public class EnvSettings +{ + // The base URL of the application, used for generating links + // E.g: https://analytics.yourdomain.com + // Variable Name: BASE_URL + public string SelfBaseUrl { get; private set; } = ""; + + // The full connection string to postgres using .NET format + // E.g: Server=localhost;Port=5444;User Id=aptabase;Password=aptabase_pw;Database=aptabase + // Variable Name: DATABASE_URL + public string ConnectionString { get; private set; } = ""; + + // The full connection string to ClickHouse using .NET format + // E.g: Host=my.clickhouse;Protocol=https;Port=12345;Username=user + // Variable Name: CLICKHOUSE_URL + public string ClickHouseConnectionString { get; private set; } = ""; + + // The base URI of the Tinybird API + // E.g: https://api.tinybird.co + // Variable Name: TINYBIRD_BASE_URL + public string TinybirdBaseUrl { get; private set; } = ""; + + // The token for Tinybird API, must have write and read access + // E.g: p.eyJ1Ijo... + // Variable Name: TINYBIRD_TOKEN + public string TinybirdToken { get; private set; } = ""; + + // A random secret key used for signing auth tokens + // E.g: GMvqFuPEiRZt6RtaB5OT + // Variable Name: AUTH_SECRET + public byte[] AuthSecret { get; private set; } = []; + + public string? MailCatcherConnectionString { get; private set; } + + // The host of the SMTP server + // E.g: smtp.someprovider.com + // Variable Name: SMTP_HOST + public string SmtpHost { get; private set; } = ""; + + // The port used for sending emails via SMTP + // E.g: 587 + // Variable Name: SMTP_PORT + public int SmtpPort { get; private set; } = 0; + + // The username for the SMTP server + // Variable Name: SMTP_USERNAME + public string SmtpUsername { get; private set; } = ""; + + // The password for the SMTP server + // Variable Name: SMTP_PASSWORD + public string SmtpPassword { get; private set; } = ""; + + // Address to send the email from + // E.g.: notification@yourdomain.com + // Variable Name: SMTP_FROM_ADDRESS + public string SmtpFromAddress { get; private set; } = ""; + + // The GitHub Client ID for OAuth + // Variable Name: OAUTH_GITHUB_CLIENT_ID + public string OAuthGitHubClientId { get; private set; } = ""; + + // The GitHub Client Secret for OAuth + // Variable Name: OAUTH_GITHUB_CLIENT_SECRET + public string OAuthGitHubClientSecret { get; private set; } = ""; + + // The Google Client ID for OAuth + // Variable Name: OAUTH_GOOGLE_CLIENT_ID + public string OAuthGoogleClientId { get; private set; } = ""; + + // The Google Client Secret for OAuth + // Variable Name: OAUTH_GOOGLE_CLIENT_SECRET + public string OAuthGoogleClientSecret { get; private set; } = ""; + + // The following properties are derived from the other settings + public bool IsManagedCloud => Region == "EU" || Region == "US"; + public bool IsBillingEnabled => IsManagedCloud || IsDevelopment; + public bool IsProduction => !IsDevelopment; + public bool IsDevelopment { get; private set; } + public string Region { get; private set; } = ""; + public string LemonSqueezyApiKey { get; private set; } = ""; + public string LemonSqueezySigningSecret { get; private set; } = ""; + public string EtcDirectoryPath { get; private set; } = ""; + + public static EnvSettings Load() + { + var isDevelopment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development"; + var region = isDevelopment ? "DEV" : Get("REGION").ToUpper(); + if (string.IsNullOrEmpty(region)) + region = "ZV"; // Self Hosted + + return new EnvSettings + { + IsDevelopment = isDevelopment, + Region = region, + SelfBaseUrl = MustGet("BASE_URL"), + ConnectionString = GetOrNull("ConnectionStrings__postgresdb") ?? MustGet("DATABASE_URL"), + ClickHouseConnectionString = GetOrNull("ConnectionStrings__clickhousedb") ?? Get("CLICKHOUSE_URL"), + TinybirdBaseUrl = Get("TINYBIRD_BASE_URL"), + TinybirdToken = Get("TINYBIRD_TOKEN"), + AuthSecret = Encoding.ASCII.GetBytes(MustGet("AUTH_SECRET")), + LemonSqueezyApiKey = Get("LEMONSQUEEZY_API_KEY"), + LemonSqueezySigningSecret = Get("LEMONSQUEEZY_SIGNING_SECRET"), + + SmtpHost = Get("SMTP_HOST"), + SmtpPort = GetInt("SMTP_PORT"), + SmtpUsername = Get("SMTP_USERNAME"), + SmtpPassword = Get("SMTP_PASSWORD"), + SmtpFromAddress = Get("SMTP_FROM_ADDRESS"), + MailCatcherConnectionString = GetOrNull("ConnectionStrings__mailcatcher"), + + OAuthGitHubClientId = Get("OAUTH_GITHUB_CLIENT_ID"), + OAuthGitHubClientSecret = Get("OAUTH_GITHUB_CLIENT_SECRET"), + OAuthGoogleClientId = Get("OAUTH_GOOGLE_CLIENT_ID"), + OAuthGoogleClientSecret = Get("OAUTH_GOOGLE_CLIENT_SECRET"), + + // On the container, the etc directory is mounted at ./etc + // But during development, it's at ../etc + EtcDirectoryPath = Directory.Exists("./etc") ? "./etc" : "../etc" + }; + } + + private EnvSettings() + { + + } + + private static string? GetOrNull(string name) + { + return Environment.GetEnvironmentVariable(name); + } + + private static string Get(string name) + { + return Environment.GetEnvironmentVariable(name) ?? ""; + } + + private static int GetInt(string name) + { + var value = Environment.GetEnvironmentVariable(name) ?? ""; + if (int.TryParse(value, out var result)) + return result; + return 0; + } + + private static string MustGet(string name) + { + return Environment.GetEnvironmentVariable(name) ?? throw new Exception($"Missing {name} environment variable"); + } +} \ No newline at end of file diff --git a/src/Features/Export/ExportController.cs b/src/Features/Export/ExportController.cs new file mode 100755 index 0000000..d27844a --- /dev/null +++ b/src/Features/Export/ExportController.cs @@ -0,0 +1,436 @@ +using Aptabase.Features.Authentication; +using Aptabase.Features.Stats; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Aptabase.Features.Export; + +public class DownloadRequest +{ + public string BuildMode { get; set; } = ""; + public string AppId { get; set; } = ""; + public string AppName { get; set; } = ""; + public string Format { get; set; } = ""; + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } +} + +public class MonthlyUsage +{ + public int Year { get; set; } + public int Month { get; set; } + public long Events { get; set; } +} + +[ApiController, IsAuthenticated, HasReadAccessToApp] +[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] +public partial class ExportController(IQueryClient queryClient, ILogger logger) : Controller +{ + private readonly IQueryClient _queryClient = queryClient ?? throw new ArgumentNullException(nameof(queryClient)); + private readonly ILogger _logger = logger; + + [HttpGet("/api/_export/usage")] + public async Task MonthlyUsage([FromQuery] string buildMode, [FromQuery] string appId, CancellationToken cancellationToken) + { + var rows = await _queryClient.NamedQueryAsync("monthly_usage__v1", new { + app_id = GetAppId(buildMode, appId) + }, cancellationToken); + + if (buildMode.Equals("debug", StringComparison.OrdinalIgnoreCase)) + { + return Ok(rows.Take(5)); + } + + return Ok(rows); + } + + [HttpGet("/api/_export/download")] + public async Task Download([FromQuery] DownloadRequest body) + { + if (!body.StartDate.HasValue || !body.EndDate.HasValue) + { + return BadRequest(); + } + + var startDate = body.StartDate.Value; + var endDate = body.EndDate.Value; + + var (formatName, contentType, fileExtension) = GetFormat(body.Format); + var appName = UnsafeCharacters().Replace(body.AppName, "").ToLower(); + var fileName = $"{appName}-{body.BuildMode.ToLower()}-{startDate:yyyy-MM-dd}.{fileExtension}"; + + var countQuery = $@"SELECT COUNT(*) as event_count + FROM events + WHERE app_id = '{GetAppId(body.BuildMode, body.AppId)}' + AND timestamp BETWEEN '{startDate:yyyy-MM-dd HH:mm:ss}' AND '{endDate:yyyy-MM-dd HH:mm:ss}' + FORMAT JSON"; + + using var stream = await _queryClient.StreamResponseAsync(countQuery, HttpContext.RequestAborted); + long eventCount = 0; + using var reader = new StreamReader(stream); + var jsonString = await reader.ReadToEndAsync(); + using var doc = JsonDocument.Parse(jsonString); + var root = doc.RootElement; + + if (root.TryGetProperty("data", out JsonElement data) && + data.GetArrayLength() > 0 && + data[0].TryGetProperty("event_count", out JsonElement countElement)) + { + switch (countElement.ValueKind) + { + case JsonValueKind.Number: + _ = countElement.TryGetInt64(out eventCount); + break; + case JsonValueKind.String: + var countString = countElement.GetString(); + _ = long.TryParse(countString, out eventCount); + break; + default: + throw new InvalidOperationException($"Unexpected event_count type: {countElement.ValueKind}"); + } + } + + if (formatName == "Parquet") + { + var query = $@"SELECT timestamp, user_id, session_id, + event_name, + replace(replace(string_props, '\u0022', '\''), '\u0027', '\'') as string_props, + numeric_props, + os_name, os_version, + locale, app_version, app_build_number, + engine_name, engine_version, + country_code, {COUNTRY_NAME_COLUMN}, region_name + FROM events + WHERE app_id = '{GetAppId(body.BuildMode, body.AppId)}' + AND timestamp BETWEEN '{startDate:yyyy-MM-dd HH:mm:ss}' AND '{endDate:yyyy-MM-dd HH:mm:ss}' + ORDER BY timestamp DESC + FORMAT {formatName}"; + + return File(await _queryClient.StreamResponseAsync(query, HttpContext.RequestAborted), contentType, fileName); + } + + return new StreamingFileResult(async (stream, httpContext, cancellationToken) => + { + httpContext.Response.StatusCode = StatusCodes.Status200OK; + + // todo use s3 buckets + try + { + const int pageSize = 100000; + long processedRecords = 0; + + while (processedRecords < eventCount && !cancellationToken.IsCancellationRequested) + { + var query = $@" + SELECT timestamp, user_id, session_id, + event_name, + replace(replace(string_props, '\u0022', '\''), '\u0027', '\'') as string_props, + numeric_props, + os_name, os_version, + locale, app_version, app_build_number, + engine_name, engine_version, + country_code, {COUNTRY_NAME_COLUMN}, region_name + FROM events + WHERE app_id = '{GetAppId(body.BuildMode, body.AppId)}' + AND timestamp BETWEEN '{startDate:yyyy-MM-dd HH:mm:ss}' AND '{endDate:yyyy-MM-dd HH:mm:ss}' + ORDER BY timestamp DESC + LIMIT {pageSize} + OFFSET {processedRecords} + FORMAT {formatName}"; + + using var pageStream = await _queryClient.StreamResponseAsync(query, cancellationToken); + await pageStream.CopyToAsync(stream, cancellationToken); + + processedRecords += pageSize; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while streaming download"); + + if (!httpContext.Response.HasStarted) + { + httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; + await httpContext.Response.WriteAsync("An error occurred while processing your request.", cancellationToken); + } + } + + }, contentType, fileName); + } + + private static string GetAppId(string buildMode, string appId) + { + return buildMode.ToLower() switch + { + "debug" => $"{appId}_DEBUG", + _ => appId, + }; + } + + private static (string, string, string) GetFormat(string format) + { + return format.ToLower() switch + { + "parquet" => ("Parquet", "application/octet-stream", "parquet"), + _ => ("CSVWithNames", "text/csv", "csv"), + }; + } + + [GeneratedRegex("[^a-zA-Z0-9]")] + private static partial Regex UnsafeCharacters(); + + private static readonly string COUNTRY_NAME_COLUMN = @" + CASE country_code + WHEN 'AF' THEN 'Afghanistan' + WHEN 'AX' THEN 'Aland Islands' + WHEN 'AL' THEN 'Albania' + WHEN 'DZ' THEN 'Algeria' + WHEN 'AS' THEN 'American Samoa' + WHEN 'AD' THEN 'Andorra' + WHEN 'AO' THEN 'Angola' + WHEN 'AI' THEN 'Anguilla' + WHEN 'AQ' THEN 'Antarctica' + WHEN 'AG' THEN 'Antigua And Barbuda' + WHEN 'AR' THEN 'Argentina' + WHEN 'AM' THEN 'Armenia' + WHEN 'AW' THEN 'Aruba' + WHEN 'AU' THEN 'Australia' + WHEN 'AT' THEN 'Austria' + WHEN 'AZ' THEN 'Azerbaijan' + WHEN 'BS' THEN 'Bahamas' + WHEN 'BH' THEN 'Bahrain' + WHEN 'BD' THEN 'Bangladesh' + WHEN 'BB' THEN 'Barbados' + WHEN 'BY' THEN 'Belarus' + WHEN 'BE' THEN 'Belgium' + WHEN 'BZ' THEN 'Belize' + WHEN 'BJ' THEN 'Benin' + WHEN 'BM' THEN 'Bermuda' + WHEN 'BT' THEN 'Bhutan' + WHEN 'BO' THEN 'Bolivia' + WHEN 'BA' THEN 'Bosnia And Herzegovina' + WHEN 'BW' THEN 'Botswana' + WHEN 'BV' THEN 'Bouvet Island' + WHEN 'BR' THEN 'Brazil' + WHEN 'IO' THEN 'British Indian Ocean Territory' + WHEN 'BN' THEN 'Brunei Darussalam' + WHEN 'BQ' THEN 'Bonaire, Saba, Sint Eustatius' + WHEN 'BG' THEN 'Bulgaria' + WHEN 'BF' THEN 'Burkina Faso' + WHEN 'BI' THEN 'Burundi' + WHEN 'KH' THEN 'Cambodia' + WHEN 'CM' THEN 'Cameroon' + WHEN 'CA' THEN 'Canada' + WHEN 'CV' THEN 'Cape Verde' + WHEN 'KY' THEN 'Cayman Islands' + WHEN 'CF' THEN 'Central African Republic' + WHEN 'TD' THEN 'Chad' + WHEN 'CL' THEN 'Chile' + WHEN 'CN' THEN 'China' + WHEN 'CX' THEN 'Christmas Island' + WHEN 'CC' THEN 'Cocos (Keeling) Islands' + WHEN 'CO' THEN 'Colombia' + WHEN 'KM' THEN 'Comoros' + WHEN 'CG' THEN 'Congo' + WHEN 'CD' THEN 'Congo, Democratic Republic' + WHEN 'CK' THEN 'Cook Islands' + WHEN 'CR' THEN 'Costa Rica' + WHEN 'CI' THEN 'Cote D\' Ivoire' + WHEN 'HR' THEN 'Croatia' + WHEN 'CU' THEN 'Cuba' + WHEN 'CY' THEN 'Cyprus' + WHEN 'CZ' THEN 'Czech Republic' + WHEN 'DK' THEN 'Denmark' + WHEN 'DJ' THEN 'Djibouti' + WHEN 'DM' THEN 'Dominica' + WHEN 'DO' THEN 'Dominican Republic' + WHEN 'EC' THEN 'Ecuador' + WHEN 'EG' THEN 'Egypt' + WHEN 'SV' THEN 'El Salvador' + WHEN 'GQ' THEN 'Equatorial Guinea' + WHEN 'ER' THEN 'Eritrea' + WHEN 'EE' THEN 'Estonia' + WHEN 'ET' THEN 'Ethiopia' + WHEN 'FK' THEN 'Falkland Islands (Malvinas)' + WHEN 'FO' THEN 'Faroe Islands' + WHEN 'FJ' THEN 'Fiji' + WHEN 'FI' THEN 'Finland' + WHEN 'FR' THEN 'France' + WHEN 'GF' THEN 'French Guiana' + WHEN 'PF' THEN 'French Polynesia' + WHEN 'TF' THEN 'French Southern Territories' + WHEN 'GA' THEN 'Gabon' + WHEN 'GM' THEN 'Gambia' + WHEN 'GE' THEN 'Georgia' + WHEN 'DE' THEN 'Germany' + WHEN 'GH' THEN 'Ghana' + WHEN 'GI' THEN 'Gibraltar' + WHEN 'GR' THEN 'Greece' + WHEN 'GL' THEN 'Greenland' + WHEN 'GD' THEN 'Grenada' + WHEN 'GP' THEN 'Guadeloupe' + WHEN 'GU' THEN 'Guam' + WHEN 'GT' THEN 'Guatemala' + WHEN 'GG' THEN 'Guernsey' + WHEN 'GN' THEN 'Guinea' + WHEN 'GW' THEN 'Guinea-Bissau' + WHEN 'GY' THEN 'Guyana' + WHEN 'HT' THEN 'Haiti' + WHEN 'HM' THEN 'Heard Island & Mcdonald Islands' + WHEN 'VA' THEN 'Holy See (Vatican City State)' + WHEN 'HN' THEN 'Honduras' + WHEN 'HK' THEN 'Hong Kong' + WHEN 'HU' THEN 'Hungary' + WHEN 'IS' THEN 'Iceland' + WHEN 'IN' THEN 'India' + WHEN 'ID' THEN 'Indonesia' + WHEN 'IR' THEN 'Iran' + WHEN 'IQ' THEN 'Iraq' + WHEN 'IE' THEN 'Ireland' + WHEN 'IM' THEN 'Isle Of Man' + WHEN 'IL' THEN 'Israel' + WHEN 'IT' THEN 'Italy' + WHEN 'JM' THEN 'Jamaica' + WHEN 'JP' THEN 'Japan' + WHEN 'JE' THEN 'Jersey' + WHEN 'JO' THEN 'Jordan' + WHEN 'KZ' THEN 'Kazakhstan' + WHEN 'KE' THEN 'Kenya' + WHEN 'KI' THEN 'Kiribati' + WHEN 'KR' THEN 'Korea' + WHEN 'KP' THEN 'North Korea' + WHEN 'KW' THEN 'Kuwait' + WHEN 'KG' THEN 'Kyrgyzstan' + WHEN 'LA' THEN 'Lao People\'s Democratic Republic' + WHEN 'LV' THEN 'Latvia' + WHEN 'LB' THEN 'Lebanon' + WHEN 'LS' THEN 'Lesotho' + WHEN 'LR' THEN 'Liberia' + WHEN 'LY' THEN 'Libyan Arab Jamahiriya' + WHEN 'LI' THEN 'Liechtenstein' + WHEN 'LT' THEN 'Lithuania' + WHEN 'LU' THEN 'Luxembourg' + WHEN 'MO' THEN 'Macao' + WHEN 'MK' THEN 'Macedonia' + WHEN 'MG' THEN 'Madagascar' + WHEN 'MW' THEN 'Malawi' + WHEN 'MY' THEN 'Malaysia' + WHEN 'MV' THEN 'Maldives' + WHEN 'ML' THEN 'Mali' + WHEN 'MT' THEN 'Malta' + WHEN 'MH' THEN 'Marshall Islands' + WHEN 'MQ' THEN 'Martinique' + WHEN 'MR' THEN 'Mauritania' + WHEN 'MU' THEN 'Mauritius' + WHEN 'YT' THEN 'Mayotte' + WHEN 'MX' THEN 'Mexico' + WHEN 'FM' THEN 'Micronesia, Federated States Of' + WHEN 'MD' THEN 'Moldova' + WHEN 'MC' THEN 'Monaco' + WHEN 'MN' THEN 'Mongolia' + WHEN 'ME' THEN 'Montenegro' + WHEN 'MS' THEN 'Montserrat' + WHEN 'MA' THEN 'Morocco' + WHEN 'MZ' THEN 'Mozambique' + WHEN 'MM' THEN 'Myanmar' + WHEN 'NA' THEN 'Namibia' + WHEN 'NR' THEN 'Nauru' + WHEN 'NP' THEN 'Nepal' + WHEN 'NL' THEN 'Netherlands' + WHEN 'AN' THEN 'Netherlands Antilles' + WHEN 'NC' THEN 'New Caledonia' + WHEN 'NZ' THEN 'New Zealand' + WHEN 'NI' THEN 'Nicaragua' + WHEN 'NE' THEN 'Niger' + WHEN 'NG' THEN 'Nigeria' + WHEN 'NU' THEN 'Niue' + WHEN 'NF' THEN 'Norfolk Island' + WHEN 'MP' THEN 'Northern Mariana Islands' + WHEN 'NO' THEN 'Norway' + WHEN 'OM' THEN 'Oman' + WHEN 'PK' THEN 'Pakistan' + WHEN 'PW' THEN 'Palau' + WHEN 'PS' THEN 'Palestine' + WHEN 'PA' THEN 'Panama' + WHEN 'PG' THEN 'Papua New Guinea' + WHEN 'PY' THEN 'Paraguay' + WHEN 'PE' THEN 'Peru' + WHEN 'PH' THEN 'Philippines' + WHEN 'PN' THEN 'Pitcairn' + WHEN 'PL' THEN 'Poland' + WHEN 'PT' THEN 'Portugal' + WHEN 'PR' THEN 'Puerto Rico' + WHEN 'QA' THEN 'Qatar' + WHEN 'RE' THEN 'Reunion' + WHEN 'RO' THEN 'Romania' + WHEN 'RU' THEN 'Russian Federation' + WHEN 'RW' THEN 'Rwanda' + WHEN 'BL' THEN 'Saint Barthelemy' + WHEN 'SH' THEN 'Saint Helena' + WHEN 'KN' THEN 'Saint Kitts And Nevis' + WHEN 'LC' THEN 'Saint Lucia' + WHEN 'MF' THEN 'Saint Martin' + WHEN 'PM' THEN 'Saint Pierre And Miquelon' + WHEN 'VC' THEN 'Saint Vincent And Grenadines' + WHEN 'WS' THEN 'Samoa' + WHEN 'SM' THEN 'San Marino' + WHEN 'ST' THEN 'Sao Tome And Principe' + WHEN 'SA' THEN 'Saudi Arabia' + WHEN 'SN' THEN 'Senegal' + WHEN 'RS' THEN 'Serbia' + WHEN 'SC' THEN 'Seychelles' + WHEN 'SL' THEN 'Sierra Leone' + WHEN 'SG' THEN 'Singapore' + WHEN 'SK' THEN 'Slovakia' + WHEN 'SI' THEN 'Slovenia' + WHEN 'SB' THEN 'Solomon Islands' + WHEN 'SO' THEN 'Somalia' + WHEN 'ZA' THEN 'South Africa' + WHEN 'GS' THEN 'South Georgia And Sandwich Isl.' + WHEN 'SS' THEN 'South Sudan' + WHEN 'ES' THEN 'Spain' + WHEN 'LK' THEN 'Sri Lanka' + WHEN 'SD' THEN 'Sudan' + WHEN 'SR' THEN 'Suriname' + WHEN 'SJ' THEN 'Svalbard And Jan Mayen' + WHEN 'SZ' THEN 'Swaziland' + WHEN 'SE' THEN 'Sweden' + WHEN 'CH' THEN 'Switzerland' + WHEN 'SY' THEN 'Syrian Arab Republic' + WHEN 'TW' THEN 'Taiwan' + WHEN 'TJ' THEN 'Tajikistan' + WHEN 'TZ' THEN 'Tanzania' + WHEN 'TH' THEN 'Thailand' + WHEN 'TL' THEN 'Timor-Leste' + WHEN 'TG' THEN 'Togo' + WHEN 'TK' THEN 'Tokelau' + WHEN 'TO' THEN 'Tonga' + WHEN 'TT' THEN 'Trinidad And Tobago' + WHEN 'TN' THEN 'Tunisia' + WHEN 'TR' THEN 'Turkey' + WHEN 'TM' THEN 'Turkmenistan' + WHEN 'TC' THEN 'Turks And Caicos Islands' + WHEN 'TV' THEN 'Tuvalu' + WHEN 'UG' THEN 'Uganda' + WHEN 'UA' THEN 'Ukraine' + WHEN 'AE' THEN 'United Arab Emirates' + WHEN 'GB' THEN 'United Kingdom' + WHEN 'US' THEN 'United States' + WHEN 'UM' THEN 'United States Outlying Islands' + WHEN 'UY' THEN 'Uruguay' + WHEN 'UZ' THEN 'Uzbekistan' + WHEN 'VU' THEN 'Vanuatu' + WHEN 'VE' THEN 'Venezuela' + WHEN 'VN' THEN 'Vietnam' + WHEN 'VG' THEN 'Virgin Islands, British' + WHEN 'VI' THEN 'Virgin Islands, U.S.' + WHEN 'WF' THEN 'Wallis And Futuna' + WHEN 'EH' THEN 'Western Sahara' + WHEN 'YE' THEN 'Yemen' + WHEN 'ZM' THEN 'Zambia' + WHEN 'ZW' THEN 'Zimbabwe' + ELSE '' + END AS country_name"; + +} diff --git a/src/Features/Export/StreamingFileResult.cs b/src/Features/Export/StreamingFileResult.cs new file mode 100755 index 0000000..0e4bd58 --- /dev/null +++ b/src/Features/Export/StreamingFileResult.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Aptabase.Features.Export; + +public class StreamingFileResult(Func streamContent, string contentType, string fileName) : IActionResult +{ + private readonly Func _streamContent = streamContent; + private readonly string _contentType = contentType; + private readonly string _fileName = fileName; + + public async Task ExecuteResultAsync(ActionContext context) + { + var httpContext = context.HttpContext; + var response = httpContext.Response; + + response.Headers.TryAdd("Content-Disposition", new[] { $"attachment; filename={_fileName}" }); + response.ContentType = _contentType; + + await _streamContent(response.Body, httpContext, httpContext.RequestAborted); + } +} diff --git a/src/Features/FeatureFlags/FeatureFlag.cs b/src/Features/FeatureFlags/FeatureFlag.cs new file mode 100755 index 0000000..0cc8bfc --- /dev/null +++ b/src/Features/FeatureFlags/FeatureFlag.cs @@ -0,0 +1,11 @@ +namespace Aptabase.Features.FeatureFlags +{ + public class FeatureFlag + { + public string? Id { get; set; } + public string? AppId { get; set; } + public string Key { get; set; } = ""; + public string Value { get; set; } = ""; + public string Environment { get; set; } = ""; + } +} diff --git a/src/Features/FeatureFlags/FeatureFlagStateController.cs b/src/Features/FeatureFlags/FeatureFlagStateController.cs new file mode 100755 index 0000000..2ad2078 --- /dev/null +++ b/src/Features/FeatureFlags/FeatureFlagStateController.cs @@ -0,0 +1,105 @@ +using Aptabase.Data; +using Aptabase.Features.Ingestion; +using Dapper; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using System.Text.Json; + +namespace Aptabase.Features.FeatureFlags; + +public class GetFeatureFlagBody +{ + public SystemProperties SystemProps { get; set; } = new(); + public JsonDocument? Props { get; set; } +} + +[ApiController] +[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] +public class FeatureFlagStateController : Controller +{ + private readonly ILogger _logger; + private readonly IIngestionCache _cache; + private readonly IDbContext _db; + + public FeatureFlagStateController( + IDbContext db, + IIngestionCache cache, + ILogger logger) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + [HttpGet("/api/v0/feature-flags/{featureFlagKey}")] + [EnableCors("AllowAny")] + [EnableRateLimiting("FeatureFlags")] + public async Task Single( + string featureFlagKey, + [FromHeader(Name = "App-Key")] string? appKey, + [FromHeader(Name = "User-Agent")] string? userAgent, + [FromBody] GetFeatureFlagBody body) + { + appKey = appKey?.ToUpper() ?? ""; + + var app = await _cache.FindByAppKey(appKey, HttpContext.RequestAborted); + + if (string.IsNullOrEmpty(app.Id)) + { + return NotFound($"Appplication not found with given app key: {appKey}"); + } + + if (app.IsLocked) + { + return BadRequest($"Owner account is locked."); + } + + var flag = await _db.Connection.QueryFirstOrDefaultAsync(@" + SELECT f.key, f.value + FROM feature_flags f + WHERE f.app_id = @appId and f.key = @key", + new + { + appId = app.Id, + key = featureFlagKey, + }); + + return Ok(flag); + } + + [HttpGet("/api/v0/feature-flags")] + [EnableCors("AllowAny")] + [EnableRateLimiting("FeatureFlags")] + public async Task All( + [FromHeader(Name = "App-Key")] string? appKey, + [FromHeader(Name = "User-Agent")] string? userAgent, + [FromBody] GetFeatureFlagBody body) + { + appKey = appKey?.ToUpper() ?? ""; + + var app = await _cache.FindByAppKey(appKey, HttpContext.RequestAborted); + + if (string.IsNullOrEmpty(app.Id)) + { + return NotFound($"Appplication not found with given app key: {appKey}"); + } + + if (app.IsLocked) + { + return BadRequest($"Owner account is locked."); + } + + var flags = await _db.Connection.QueryAsync(@" + SELECT f.key, f.value + FROM feature_flags f + WHERE f.app_id = @appId + LIMIT 50", + new + { + appId = app.Id, + }); + + return Ok(flags); + } +} diff --git a/src/Features/FeatureFlags/FeatureFlagsController.cs b/src/Features/FeatureFlags/FeatureFlagsController.cs new file mode 100755 index 0000000..1d59bc0 --- /dev/null +++ b/src/Features/FeatureFlags/FeatureFlagsController.cs @@ -0,0 +1,173 @@ +using Aptabase.Data; +using Aptabase.Features.Apps; +using Aptabase.Features.Authentication; +using Dapper; +using Microsoft.AspNetCore.Mvc; +using System.ComponentModel.DataAnnotations; + +namespace Aptabase.Features.FeatureFlags; + +public class CreateFeatureFlagRequestBody : UpdateFeatureFlagRequestBody +{ + public string AppId { get; set; } = ""; +} + +public class UpdateFeatureFlagRequestBody +{ + [Required] + [StringLength(255, MinimumLength = 2)] + public string Key { get; set; } = ""; + + [Required] + [StringLength(4000, MinimumLength = 1)] + public string Value { get; set; } = ""; + + [Required] + [StringLength(50, MinimumLength = 2)] + public string Environment { get; set; } = ""; +} + +[ApiController, IsAuthenticated] +[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] +public class FeatureFlagsController : Controller +{ + private readonly IDbContext _db; + private readonly IAppQueries _appQueries; + + public FeatureFlagsController(IDbContext db, IAppQueries appQueries) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + _appQueries = appQueries ?? throw new ArgumentNullException(nameof(appQueries)); + } + + [HttpGet("/api/_flags/{appId}")] + public async Task List(string appId) + { + var user = this.GetCurrentUserIdentity(); + + var app = await _appQueries.GetOwnedAppAsync(appId, user.Id); + + if (app == null) + { + return NotFound(); + } + + var flags = await _db.Connection.QueryAsync(@" + SELECT f.id, f.app_id, f.key, f.value, f.environment, f.conditions + FROM feature_flags f + WHERE f.app_id = @appId + LIMIT 50", + new + { + appId, + }); + + return Ok(flags); + } + + [HttpPost("/api/_flags")] + public async Task Create([FromBody] CreateFeatureFlagRequestBody body) + { + var user = this.GetCurrentUserIdentity(); + + var app = await _appQueries.GetOwnedAppAsync(body.AppId, user.Id); + + if (app == null) + { + return NotFound(); + } + + var flag = new FeatureFlag + { + Id = NanoId.New(), + AppId = body.AppId, + Key = body.Key, + Value = body.Value, + Environment = body.Environment, + }; + + await _db.Connection.ExecuteAsync(@" + INSERT INTO feature_flags (id, app_id, key, value, environment, created_at, modified_at) + VALUES (@id, @appId, @key, @value, @environment, now(), now())", + new + { + id = flag.Id, + appId = flag.AppId, + key = flag.Key, + value = flag.Value, + environment = flag.Environment, + }); + + return Ok(app); + } + + [HttpPut("/api/_flags/{flagId}")] + public async Task Update(string flagId, [FromBody] UpdateFeatureFlagRequestBody body) + { + var user = this.GetCurrentUserIdentity(); + + var flag = await GetOwnedFeatureFlagAsync(flagId, user.Id); + + if (flag == null) + { + return NotFound(); + } + + flag.Key = body.Key; + flag.Value = body.Value; + flag.Environment = body.Environment; + + await _db.Connection.ExecuteAsync( + "UPDATE feature_flags SET Key = @key, Value = @value, Environment = @environment, WHERE id = @flagId", + new + { + flagId, + key = flag.Key, + value = flag.Value, + environment = flag.Environment, + }); + + return Ok(flag); + } + + [HttpDelete("/api/_flags/{flagId}")] + public async Task Delete(string flagId) + { + var user = this.GetCurrentUserIdentity(); + + var flag = await GetOwnedFeatureFlagAsync(flagId, user.Id); + + if (flag == null) + { + return NotFound(); + } + + await _db.Connection.ExecuteAsync( + "DELETE FROM feature_flags WHERE id = @flagId", + new + { + flagId, + }); + + return NoContent(); + } + + private Task GetOwnedFeatureFlagAsync(string flagId, string userId) + { + return _db.Connection.QueryFirstOrDefaultAsync(@" + SELECT f.id, f.app_id, f.key, f.value, f.environment, f.conditions + FROM feature_flags f + JOIN apps a + ON f.app_id = a.id + INNER JOIN users u + ON u.id = a.owner_id + WHERE f.id = @flagId + AND a.owner_id = @userId + AND a.deleted_at IS NULL", + new + { + flagId, + userId, + }); + } +} diff --git a/src/Features/GeoIP/CloudGeoClient.cs b/src/Features/GeoIP/CloudGeoClient.cs new file mode 100755 index 0000000..434cb20 --- /dev/null +++ b/src/Features/GeoIP/CloudGeoClient.cs @@ -0,0 +1,44 @@ +using System.Text.Json; + +namespace Aptabase.Features.GeoIP; + +public class CloudGeoClient : GeoIPClient +{ + private readonly Dictionary _regions; + + public CloudGeoClient(EnvSettings env) + : base(env) + { + var text = File.ReadAllText(Path.Combine(env.EtcDirectoryPath, "geoip/iso3166-2.json")); + var regions = JsonSerializer.Deserialize>(text); + if (regions == null) + throw new Exception("Failed to deserialize geoip/iso3166-2.json"); + + _regions = regions; + } + + public override GeoLocation GetClientLocation(HttpContext httpContext) + { + var countryCode = GetHeader(httpContext, "cdn-requestcountrycode", "CloudFront-Viewer-Country", "CF-IPCountry"); + var regionCode = GetHeader(httpContext, "cdn-requeststatecode", "Cloudfront-Viewer-Country-Region"); + var regionName = _regions.TryGetValue($"{countryCode}-{regionCode}", out var name) ? name : ""; + + return new GeoLocation + { + CountryCode = countryCode ?? "", + RegionName = regionName ?? "" + }; + } + + private static string GetHeader(HttpContext httpContext, params string[] names) + { + foreach (var name in names) + { + var value = httpContext.Request.Headers[name].ToString()?.ToUpper() ?? ""; + if (!string.IsNullOrEmpty(value)) + return value; + } + + return ""; + } +} \ No newline at end of file diff --git a/src/Features/GeoIP/DatabaseGeoClient.cs b/src/Features/GeoIP/DatabaseGeoClient.cs new file mode 100755 index 0000000..e12f288 --- /dev/null +++ b/src/Features/GeoIP/DatabaseGeoClient.cs @@ -0,0 +1,28 @@ +using MaxMind.GeoIP2; + +namespace Aptabase.Features.GeoIP; + +public class DatabaseGeoClient : GeoIPClient +{ + private readonly DatabaseReader _db; + + public DatabaseGeoClient(EnvSettings env) + : base(env) + { + _db = new DatabaseReader(Path.Combine(env.EtcDirectoryPath, "geoip/GeoLite2-City.mmdb")); + } + + public override GeoLocation GetClientLocation(HttpContext httpContext) + { + var ip = httpContext.ResolveClientIpAddress(); + + if (string.IsNullOrEmpty(ip)) + return GeoLocation.Empty; + + return _db.TryCity(ip, out var city) ? new GeoLocation + { + CountryCode = city?.Country?.IsoCode?.ToUpper() ?? "", + RegionName = city?.MostSpecificSubdivision.Name ?? "" + } : GeoLocation.Empty; + } +} \ No newline at end of file diff --git a/src/Features/GeoIP/GeoIPExtensions.cs b/src/Features/GeoIP/GeoIPExtensions.cs new file mode 100755 index 0000000..13c8b27 --- /dev/null +++ b/src/Features/GeoIP/GeoIPExtensions.cs @@ -0,0 +1,15 @@ +namespace Aptabase.Features.GeoIP; + +public static class GeoIPExtensions +{ + public static void AddGeoIPClient(this IServiceCollection services, EnvSettings env) + { + if (env.IsManagedCloud) + { + services.AddSingleton(); + return; + } + + services.AddSingleton(); + } +} \ No newline at end of file diff --git a/src/Features/GeoIP/IGeoIPClient.cs b/src/Features/GeoIP/IGeoIPClient.cs new file mode 100755 index 0000000..1a7825b --- /dev/null +++ b/src/Features/GeoIP/IGeoIPClient.cs @@ -0,0 +1,54 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Aptabase.Features.GeoIP; + +public readonly struct GeoLocation +{ + public readonly string CountryCode { get; init; } + public readonly string RegionName { get; init; } + + public static GeoLocation Empty => new() + { + CountryCode = "", + RegionName = "" + }; +} + +public readonly struct Coordinates +{ + [JsonPropertyName("lat")] + public readonly double Latitude { get; init; } + [JsonPropertyName("lng")] + public readonly double Longitude { get; init; } +} + +public abstract class GeoIPClient +{ + public abstract GeoLocation GetClientLocation(HttpContext httpContext); + + private readonly Dictionary> _coordinates; + + public GeoIPClient(EnvSettings env) + { + var text = File.ReadAllText(Path.Combine(env.EtcDirectoryPath, "geoip/coordinates.json")); + var coordinates = JsonSerializer.Deserialize>>(text); + if (coordinates == null) + throw new Exception("Failed to deserialize coordinates.json"); + + _coordinates = coordinates; + } + + public (double, double) GetLatLng(string countryCode, string regionName) + { + if (_coordinates.TryGetValue(countryCode, out var regions)) + { + if (regions?.TryGetValue(regionName, out var coordinates) == true) + { + return (coordinates.Latitude, coordinates.Longitude); + } + } + + return (0, 0); + } +} \ No newline at end of file diff --git a/src/Features/Ingestion/Buffer/ClickHouseIngestionClient.cs b/src/Features/Ingestion/Buffer/ClickHouseIngestionClient.cs new file mode 100755 index 0000000..d3d19ad --- /dev/null +++ b/src/Features/Ingestion/Buffer/ClickHouseIngestionClient.cs @@ -0,0 +1,100 @@ +using Dapper; +using ClickHouse.Client.Copy; +using ClickHouse.Client.ADO; + +namespace Aptabase.Features.Ingestion.Buffer; + +public class ClickHouseIngestionClient : IIngestionClient +{ + private readonly ClickHouseConnection _conn; + + private readonly string[] COLUMNS = [ + "app_id", + "timestamp", + "event_name", + "user_id", + "session_id", + "os_name", + "os_version", + "device_model", + "locale", + "app_version", + "app_build_number", + "engine_name", + "engine_version", + "sdk_version", + "country_code", + "region_name", + "city", + "string_props", + "numeric_props", + "ttl" + ]; + + public ClickHouseIngestionClient(ClickHouseConnection conn, ILogger logger) + { + _conn = conn ?? throw new ArgumentNullException(nameof(conn)); + } + + public async Task SendEventAsync(EventRow row) + { + return await _conn.ExecuteAsync($@"INSERT INTO events ({string.Join(",", COLUMNS)}) VALUES (@{string.Join(", @", COLUMNS)})", new { + app_id = row.AppId, + timestamp = row.Timestamp, + event_name = row.EventName, + user_id = row.UserId, + session_id = row.SessionId, + os_name = row.OSName, + os_version = row.OSVersion, + device_model = row.DeviceModel, + locale = row.Locale, + app_version = row.AppVersion, + app_build_number = row.AppBuildNumber, + engine_name = row.EngineName, + engine_version = row.EngineVersion, + sdk_version = row.SdkVersion, + country_code = row.CountryCode, + region_name = row.RegionName, + city = row.City, + string_props = row.StringProps, + numeric_props = row.NumericProps, + ttl = row.TTL, + }); + } + + public async Task BulkSendEventAsync(IEnumerable rows, CancellationToken ct = default) + { + using var bulkCopy = new ClickHouseBulkCopy(_conn) + { + DestinationTableName = "events", + BatchSize = 1000, + ColumnNames = COLUMNS, + }; + + var values = rows.Select(row => new object[] { + row.AppId, + row.Timestamp, + row.EventName, + row.UserId, + row.SessionId, + row.OSName, + row.OSVersion, + row.DeviceModel, + row.Locale, + row.AppVersion, + row.AppBuildNumber, + row.EngineName, + row.EngineVersion, + row.SdkVersion, + row.CountryCode, + row.RegionName, + row.City, + row.StringProps, + row.NumericProps, + row.TTL, + }); + await bulkCopy.InitAsync(); + await bulkCopy.WriteToServerAsync(values, ct); + return bulkCopy.RowsWritten; + } +} \ No newline at end of file diff --git a/src/Features/Ingestion/Buffer/EventBackgroundWritter.cs b/src/Features/Ingestion/Buffer/EventBackgroundWritter.cs new file mode 100755 index 0000000..3bd2146 --- /dev/null +++ b/src/Features/Ingestion/Buffer/EventBackgroundWritter.cs @@ -0,0 +1,75 @@ + + +using System.Diagnostics; +using Aptabase.Features.Privacy; + +namespace Aptabase.Features.Ingestion.Buffer; + +public class EventBackgroundWritter : BackgroundService +{ + private readonly IEventBuffer _buffer; + private readonly IIngestionClient _client; + private readonly ILogger _logger; + private readonly IUserHasher _hasher; + private readonly Stopwatch _watch = new(); + + public EventBackgroundWritter(IEventBuffer buffer, IUserHasher hasher, IIngestionClient client, ILogger logger) + { + _hasher = hasher ?? throw new ArgumentNullException(nameof(hasher)); + _buffer = buffer ?? throw new ArgumentNullException(nameof(buffer)); + _client = client ?? throw new ArgumentNullException(nameof(client)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("EventBackgroundWritter is starting."); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await FlushEvents(); + await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); + } + catch { } + } + + // We need to wait a few seconds when cancellation is requested + // because some events may be added to the buffer after the cancellation + // After flushing we can safely exit + _logger.LogInformation("EventBackgroundWritter is stopping."); + await Task.Delay(TimeSpan.FromSeconds(2)); + await FlushEvents(); + _logger.LogInformation("EventBackgroundWritter stopped."); + } + + public int Count() => _buffer.TakeAll().Length; + + public async Task FlushEvents() + { + var events = _buffer.TakeAll(); + if (events.Length == 0) return; + + try + { + _watch.Restart(); + + var rows = await Task.WhenAll(events.Select(ToEventRow)); + + await _client.BulkSendEventAsync(rows); + _watch.Stop(); + _logger.LogInformation("Flushed {Count} events in {TimeMs}ms.", events.Length, _watch.ElapsedMilliseconds); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send events. {Count} events were discarded.", events.Length); + } + } + + private async Task ToEventRow(TrackingEvent e) + { + var userId = await _hasher.CalculateHash(e.Timestamp, e.AppId, e.SessionId, e.ClientIpAddress, e.UserAgent); + return new EventRow(ref e, userId); + } +} \ No newline at end of file diff --git a/src/Features/Ingestion/Buffer/EventRow.cs b/src/Features/Ingestion/Buffer/EventRow.cs new file mode 100755 index 0000000..2c495f9 --- /dev/null +++ b/src/Features/Ingestion/Buffer/EventRow.cs @@ -0,0 +1,89 @@ +using System.Text.Json; + +namespace Aptabase.Features.Ingestion.Buffer; + +public readonly struct EventRow +{ + // Events from Debug builds are kept for 6 months + public static readonly TimeSpan DebugTTL = TimeSpan.FromDays(182); + // Events from Release builds are kept for 5 years + public static readonly TimeSpan ReleaseTTL = TimeSpan.FromDays(5 * 365); + + public readonly string AppId; + public readonly DateTime Timestamp; + public readonly string EventName; + public readonly string UserId; + public readonly string SessionId; + public readonly string OSName; + public readonly string OSVersion; + public readonly string DeviceModel; + public readonly string Locale; + public readonly string AppVersion; + public readonly string AppBuildNumber; + public readonly string EngineName; + public readonly string EngineVersion; + public readonly string SdkVersion; + public readonly string CountryCode; + public readonly string RegionName; + public readonly string City; + public readonly string StringProps; + public readonly string NumericProps; + public readonly DateTime TTL; + + public EventRow(ref TrackingEvent e, string userId) + { + var ttl = e.IsDebug ? DebugTTL : ReleaseTTL; + + AppId = e.IsDebug ? $"{e.AppId}_DEBUG" : e.AppId; + Timestamp = e.Timestamp; + EventName = e.EventName; + UserId = userId; + SessionId = e.SessionId; + OSName = e.OSName; + OSVersion = e.OSVersion; + DeviceModel = e.DeviceModel; + Locale = e.Locale; + AppVersion = e.AppVersion; + AppBuildNumber = e.AppBuildNumber; + EngineName = e.EngineName; + EngineVersion = e.EngineVersion; + SdkVersion = e.SdkVersion; + CountryCode = e.CountryCode; + RegionName = e.RegionName; + City = ""; + StringProps = e.StringProps; + NumericProps = e.NumericProps; + TTL = e.Timestamp.Add(ttl); + } + + public void WriteJson(StringWriter writer) + { + writer.Write("{"); + WriteProperty(writer, "appId", AppId); + WriteProperty(writer, "timestamp", Timestamp.ToString("o")); + WriteProperty(writer, "eventName", EventName); + WriteProperty(writer, "userId", UserId); + WriteProperty(writer, "sessionId", SessionId); + WriteProperty(writer, "osName", OSName); + WriteProperty(writer, "osVersion", OSVersion); + WriteProperty(writer, "deviceModel", DeviceModel); + WriteProperty(writer, "locale", Locale); + WriteProperty(writer, "appVersion", AppVersion); + WriteProperty(writer, "appBuildNumber", AppBuildNumber); + WriteProperty(writer, "engineName", EngineName); + WriteProperty(writer, "engineVersion", EngineVersion); + WriteProperty(writer, "sdkVersion", SdkVersion); + WriteProperty(writer, "countryCode", CountryCode); + WriteProperty(writer, "regionName", RegionName); + WriteProperty(writer, "city", ""); + WriteProperty(writer, "stringProps", StringProps); + WriteProperty(writer, "numericProps", NumericProps); + WriteProperty(writer, "ttl", TTL.ToString("o"), true); + writer.Write("}"); + } + + private static void WriteProperty(StringWriter writer, string name, string value, bool isLast = false) + { + writer.Write($"\"{name}\": \"{JsonEncodedText.Encode(value)}\" {(isLast ? "" : ",")}"); + } +} \ No newline at end of file diff --git a/src/Features/Ingestion/Buffer/IIngestionClient.cs b/src/Features/Ingestion/Buffer/IIngestionClient.cs new file mode 100755 index 0000000..98c77cc --- /dev/null +++ b/src/Features/Ingestion/Buffer/IIngestionClient.cs @@ -0,0 +1,8 @@ +namespace Aptabase.Features.Ingestion.Buffer; + +public interface IIngestionClient +{ + Task SendEventAsync(EventRow row); + Task BulkSendEventAsync(IEnumerable rows, CancellationToken ct = default); +} + diff --git a/src/Features/Ingestion/Buffer/InMemoryEventBuffer.cs b/src/Features/Ingestion/Buffer/InMemoryEventBuffer.cs new file mode 100755 index 0000000..2849af6 --- /dev/null +++ b/src/Features/Ingestion/Buffer/InMemoryEventBuffer.cs @@ -0,0 +1,49 @@ +namespace Aptabase.Features.Ingestion.Buffer; + +public interface IEventBuffer +{ + void Add(ref TrackingEvent @event); + void AddRange(ref TrackingEvent[] events); + void AddRange(ref IEnumerable events); + TrackingEvent[] TakeAll(); +} + +public class InMemoryEventBuffer : IEventBuffer +{ + private List _buffer = new(); + private object _lock = new object(); + + public void Add(ref TrackingEvent @event) + { + lock (_lock) + { + _buffer.Add(@event); + } + } + + public void AddRange(ref TrackingEvent[] events) + { + lock (_lock) + { + _buffer.AddRange(events); + } + } + + public void AddRange(ref IEnumerable events) + { + lock (_lock) + { + _buffer.AddRange(events); + } + } + + public TrackingEvent[] TakeAll() + { + lock (_lock) + { + var items = _buffer.ToArray(); + _buffer.Clear(); + return items; + } + } +} \ No newline at end of file diff --git a/src/Features/Ingestion/Buffer/TinybirdIngestionClient.cs b/src/Features/Ingestion/Buffer/TinybirdIngestionClient.cs new file mode 100755 index 0000000..f61c069 --- /dev/null +++ b/src/Features/Ingestion/Buffer/TinybirdIngestionClient.cs @@ -0,0 +1,77 @@ +using System.Text.Json.Serialization; + +namespace Aptabase.Features.Ingestion.Buffer; + +public class InsertResult +{ + [JsonPropertyName("successful_rows")] + public int SuccessfulRows { get; set; } + [JsonPropertyName("quarantined_rows")] + public int QuarantinedRows { get; set; } +} + +public class TinybirdIngestionClient : IIngestionClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly TimeSpan[] _retriesDelay = + [ + TimeSpan.FromMilliseconds(1000), + TimeSpan.FromMilliseconds(3000), + TimeSpan.FromMilliseconds(5000), + ]; + + public TinybirdIngestionClient(IHttpClientFactory factory, ILogger logger) + { + _httpClient = factory.CreateClient("Tinybird"); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + private static string EventsPath => $"/v0/events?name=events&wait=true"; + + public Task SendEventAsync(EventRow row) + { + return PostAsync(EventsPath, [row]); + } + + public Task BulkSendEventAsync(IEnumerable rows, CancellationToken ct = default) + { + return PostAsync(EventsPath, rows, ct); + } + + private async Task PostAsync(string path, IEnumerable rows, CancellationToken ct = default) + { + using var content = SerializeBody(rows); + + for (var i = 0; i < _retriesDelay.Length; i++) + { + try + { + var response = await _httpClient.PostAsync(path, content, ct); + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync() ?? new InsertResult(); + return result.SuccessfulRows; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send events to Tinybird. Will retry again after {Delay}ms.", _retriesDelay[i].TotalMilliseconds); + await Task.Delay(_retriesDelay[i]); + } + } + + throw new Exception($"Failed to send events to Tinybird after {_retriesDelay.Length} retries."); + } + + private static StringContent SerializeBody(IEnumerable rows) + { + using var writer = new StringWriter(); + foreach (var row in rows) + { + row.WriteJson(writer); + writer.Write("\n"); + } + + return new StringContent(writer.ToString()); + } +} \ No newline at end of file diff --git a/src/Features/Ingestion/Buffer/TrackingEvent.cs b/src/Features/Ingestion/Buffer/TrackingEvent.cs new file mode 100755 index 0000000..d28f71e --- /dev/null +++ b/src/Features/Ingestion/Buffer/TrackingEvent.cs @@ -0,0 +1,28 @@ +using System.Text.Json; + +namespace Aptabase.Features.Ingestion; + +public readonly struct TrackingEvent +{ + public readonly string ClientIpAddress { get; init; } + public readonly string UserAgent { get; init; } + + public readonly string AppId { get; init; } + public readonly DateTime Timestamp { get; init; } + public readonly string EventName { get; init; } + public readonly string SessionId { get; init; } + public readonly string OSName { get; init; } + public readonly string OSVersion { get; init; } + public readonly string DeviceModel { get; init; } + public readonly string Locale { get; init; } + public readonly string AppVersion { get; init; } + public readonly string AppBuildNumber { get; init; } + public readonly string EngineName { get; init; } + public readonly string EngineVersion { get; init; } + public readonly string SdkVersion { get; init; } + public readonly string CountryCode { get; init; } + public readonly string RegionName { get; init; } + public readonly string StringProps { get; init; } + public readonly string NumericProps { get; init; } + public readonly bool IsDebug { get; init; } +} \ No newline at end of file diff --git a/src/Features/Ingestion/EventBody.cs b/src/Features/Ingestion/EventBody.cs new file mode 100755 index 0000000..8750be9 --- /dev/null +++ b/src/Features/Ingestion/EventBody.cs @@ -0,0 +1,193 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Aptabase.Features.Ingestion; + +public class SystemProperties +{ + public bool IsDebug { get; set; } + + [StringLength(30)] + public string? OSName { get; set; } + + [StringLength(100)] + public string? OSVersion { get; set; } + + [StringLength(10)] + public string? Locale { get; set; } + + [StringLength(50)] + public string? AppVersion { get; set; } + + [StringLength(20)] + public string? AppBuildNumber { get; set; } + + [StringLength(30)] + public string? EngineName { get; set; } + + [StringLength(30)] + public string? EngineVersion { get; set; } + + [Required, StringLength(40)] + public string SdkVersion { get; set; } = ""; + + [StringLength(100)] + public string? DeviceModel { get; set; } +} + +public class EventBody +{ + [Required, StringLength(60)] + public string EventName { get; set; } = ""; + + private DateTime _ts; + public DateTime Timestamp + { + get => _ts; + set => _ts = value > DateTime.UtcNow ? DateTime.UtcNow : value; + } + + [Required] + public string SessionId { get; set; } = ""; + + public SystemProperties SystemProps { get; set; } = new(); + public JsonDocument? Props { get; set; } + + public (JsonObject, JsonObject) SplitProps() + { + var stringValues = new JsonObject(); + var numericValues = new JsonObject(); + + if (Props != null) + { + // Sort by key to ensure consistent order might be useful in future! + foreach (var property in Props.RootElement.EnumerateObject().OrderBy(x => x.Name)) + { + if (property.Value.ValueKind == JsonValueKind.Number) + numericValues.Add(property.Name, property.Value.GetDecimal()); + else if (property.Value.ValueKind == JsonValueKind.String) + { + var propertyValue = property.Value.GetString() ?? ""; + stringValues.Add(property.Name, propertyValue.Truncate(180, "...")); + } + else if (property.Value.ValueKind == JsonValueKind.True) + stringValues.Add(property.Name, "true"); + else if (property.Value.ValueKind == JsonValueKind.False) + stringValues.Add(property.Name, "false"); + else if (property.Value.ValueKind == JsonValueKind.Array) + stringValues.Add(property.Name, "[Array]"); + else if (property.Value.ValueKind == JsonValueKind.Object) + stringValues.Add(property.Name, "{Object}"); + else if (property.Value.ValueKind == JsonValueKind.Null) + stringValues.Add(property.Name, ""); + else if (property.Value.ValueKind == JsonValueKind.Undefined) + stringValues.Add(property.Name, ""); + } + } + + return (stringValues, numericValues); + } + + public (bool, string) IsValid(ILogger logger) + { + var (valid, msg) = ValidateSessionId(logger); + if (!valid) + return (false, msg); + + if (Timestamp > DateTime.UtcNow.AddMinutes(10)) + return (false, "Future events are not allowed."); + + if (Timestamp < DateTime.UtcNow.AddDays(-1)) + { + logger.LogWarning("Event timestamp {EventTimestamp} is too old.", Timestamp); + return (false, "Event is too old."); + } + + var locale = LocaleFormatter.FormatLocale(SystemProps.Locale); + if (locale is null) + logger.LogWarning("Invalid locale {Locale} received from {OS} using {SdkVersion}", locale, SystemProps.OSName, SystemProps.SdkVersion); + + SystemProps.Locale = locale; + + (valid, msg) = ValidateProps(); + if (!valid) + return (false, msg); + + return (true, string.Empty); + } + + private (bool, string) ValidateSessionId(ILogger logger) + { + if (ulong.TryParse(SessionId, out var numericSessionId)) + return ValidateNumericSessionId(numericSessionId, logger); + + if (SessionId.Length > 36) + return (false, $"SessionId must be less than or equal to 36 characters, was: {SessionId}"); + + return (true, string.Empty); + } + + private (bool, string) ValidateNumericSessionId(ulong id, ILogger logger) + { + var secondsSinceEpoch = id / 100_000_000; + var sessionStartedAt = DateTimeOffset.FromUnixTimeSeconds((long)secondsSinceEpoch).UtcDateTime; + + if (sessionStartedAt > DateTime.UtcNow.AddMinutes(10)) + { + logger.LogWarning("Session {SessionId} timestamp {StartedAt} is in future, received from {SdkVersion}.", id, sessionStartedAt, SystemProps.SdkVersion); + return (false, "Future sessions are not allowed."); + } + + if (sessionStartedAt < DateTime.UtcNow.AddDays(-7)) + { + logger.LogWarning("Session {SessionId} timestamp {StartedAt} is too old, received from {SdkVersion}.", id, sessionStartedAt, SystemProps.SdkVersion); + return (false, "Session is too old."); + } + + return (true, string.Empty); + } + + private (bool, string) ValidateProps() + { + if (Props is not null) + { + if (Props.RootElement.ValueKind == JsonValueKind.String) + { + var valueAsString = Props.RootElement.GetString() ?? ""; + if (TryParseDocument(valueAsString, out var doc) && doc is not null) + Props = doc; + else + return (false, $"Props must be an object or a valid stringified JSON, was: {Props.RootElement.GetRawText()}"); + } + + if (Props.RootElement.ValueKind != JsonValueKind.Object) + return (false, $"Props must be an object or a valid stringified JSON, was: {Props.RootElement.GetRawText()}"); + + foreach (var prop in Props.RootElement.EnumerateObject()) + { + if (string.IsNullOrWhiteSpace(prop.Name)) + return (false, "Property key must not be empty."); + + if (prop.Name.Length > 40) + return (false, $"Property key '{prop.Name}' must be less than or equal to 40 characters. Props was: {Props.RootElement.GetRawText()}"); + } + } + + return (true, string.Empty); + } + + private static bool TryParseDocument(string json, out JsonDocument? doc) + { + try + { + doc = JsonDocument.Parse(json); + return true; + } + catch + { + doc = null; + return false; + } + } +} \ No newline at end of file diff --git a/src/Features/Ingestion/EventsController.cs b/src/Features/Ingestion/EventsController.cs new file mode 100755 index 0000000..a5e9176 --- /dev/null +++ b/src/Features/Ingestion/EventsController.cs @@ -0,0 +1,157 @@ +using Aptabase.Features.GeoIP; +using Aptabase.Features.Ingestion.Buffer; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace Aptabase.Features.Ingestion; + +[ApiController] +[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] +public class EventsController : Controller +{ + private readonly ILogger _logger; + private readonly IIngestionCache _cache; + private readonly IEventBuffer _buffer; + private readonly GeoIPClient _geoIP; + + public EventsController(IIngestionCache cache, + IEventBuffer buffer, + GeoIPClient geoIP, + ILogger logger) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _buffer = buffer ?? throw new ArgumentNullException(nameof(buffer)); + _geoIP = geoIP ?? throw new ArgumentNullException(nameof(geoIP)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + [HttpPost("/api/v0/event")] + [EnableCors("AllowAny")] + [EnableRateLimiting("EventIngestion")] + public async Task Single( + [FromHeader(Name = "App-Key")] string? appKey, + [FromHeader(Name = "User-Agent")] string? userAgent, + [FromBody] EventBody body, + CancellationToken cancellationToken + ) + { + appKey = appKey?.ToUpper() ?? ""; + + var (valid, validationMessage) = body.IsValid(_logger); + if (!valid) + { + _logger.LogWarning($"Dropping event from {appKey} because: {validationMessage}."); + return BadRequest(validationMessage); + } + + var app = await _cache.FindByAppKey(appKey, cancellationToken); + if (string.IsNullOrEmpty(app.Id)) + return AppNotFound(appKey); + + if (app.IsLocked) + return BadRequest($"Owner account is locked."); + + // We never expect the Web SDK to send the OS name, so it's safe to assume that if it's missing the event is coming from a browser + var isWeb = string.IsNullOrEmpty(body.SystemProps.OSName); + + // For web events, we need to parse the user agent to get the OS name and version + if (isWeb && !string.IsNullOrEmpty(userAgent)) + { + var (osName, osVersion) = UserAgentParser.ParseOperatingSystem(userAgent); + body.SystemProps.OSName = osName; + body.SystemProps.OSVersion = osVersion; + + var (engineName, engineVersion) = UserAgentParser.ParseBrowser(userAgent); + body.SystemProps.EngineName = engineName; + body.SystemProps.EngineVersion = engineVersion; + } + + // We can't rely on User-Agent header sent by the SDK for non-web events, so we fabricate one + // This can be removed when this issue is implemented: https://github.com/aptabase/aptabase/issues/23 + if (!isWeb) + userAgent = $"{body.SystemProps.OSName}/{body.SystemProps.OSVersion} {body.SystemProps.EngineName}/{body.SystemProps.EngineVersion} {body.SystemProps.Locale}"; + + var clientIp = HttpContext.ResolveClientIpAddress(); + var location = _geoIP.GetClientLocation(HttpContext); + var trackingEvent = NewTrackingEvent(app.Id, location.CountryCode, location.RegionName, clientIp, userAgent ?? "", body); + _buffer.Add(ref trackingEvent); + + return Ok(new { }); + } + + [HttpPost("/api/v0/events")] + [EnableRateLimiting("EventIngestion")] + public async Task Multiple( + [FromHeader(Name = "App-Key")] string? appKey, + [FromHeader(Name = "User-Agent")] string? userAgent, + [FromBody] EventBody[] events, + CancellationToken cancellationToken + ) + { + appKey = appKey?.ToUpper() ?? ""; + + if (events.Length > 25) + return BadRequest($"Too many events ({events.Length}) in a single request. Maximum is 25."); + + var validEvents = events.Where(e => { + var (valid, validationMessage) = e.IsValid(_logger); + if (!valid) + _logger.LogWarning("Dropping event from {AppKey}. {ValidationMessage}", appKey, validationMessage); + return valid; + }).ToArray(); + + if (!validEvents.Any()) + return Ok(new { }); + + var app = await _cache.FindByAppKey(appKey, cancellationToken); + if (string.IsNullOrEmpty(app.Id)) + return AppNotFound(appKey); + + if (app.IsLocked) + return BadRequest($"Owner account is locked."); + + var clientIp = HttpContext.ResolveClientIpAddress(); + var location = _geoIP.GetClientLocation(HttpContext); + var trackingEvents = validEvents.Select(e => NewTrackingEvent(app.Id, location.CountryCode, location.RegionName, clientIp, userAgent ?? "", e)); + + _buffer.AddRange(ref trackingEvents); + + return Ok(new { }); + } + + private IActionResult AppNotFound(string appKey) + { + _logger.LogWarning("Appplication not found with given app key: {AppKey}", appKey); + return NotFound($"Appplication not found with given app key: {appKey}"); + } + + private static TrackingEvent NewTrackingEvent(string appId, string countryCode, string regionName, string clientIp, string userAgent, EventBody body) + { + var (stringProps, numericProps) = body.SplitProps(); + return new TrackingEvent + { + ClientIpAddress = clientIp, + UserAgent = userAgent, + + AppId = appId, + EventName = body.EventName, + Timestamp = body.Timestamp.ToUniversalTime(), + SessionId = body.SessionId?.ToString() ?? "", + OSName = body.SystemProps.OSName ?? "", + OSVersion = body.SystemProps.OSVersion ?? "", + DeviceModel = body.SystemProps.DeviceModel ?? "", + Locale = body.SystemProps.Locale ?? "", + AppVersion = body.SystemProps.AppVersion ?? "", + EngineName = body.SystemProps.EngineName ?? "", + EngineVersion = body.SystemProps.EngineVersion ?? "", + AppBuildNumber = body.SystemProps.AppBuildNumber ?? "", + SdkVersion = body.SystemProps.SdkVersion ?? "", + CountryCode = countryCode, + RegionName = regionName, + StringProps = stringProps.ToJsonString(), + NumericProps = numericProps.ToJsonString(), + IsDebug = body.SystemProps.IsDebug, + }; + } +} diff --git a/src/Features/Ingestion/IngestionCache.cs b/src/Features/Ingestion/IngestionCache.cs new file mode 100755 index 0000000..243ae5c --- /dev/null +++ b/src/Features/Ingestion/IngestionCache.cs @@ -0,0 +1,67 @@ +using Aptabase.Features.Apps; +using Microsoft.Extensions.Caching.Memory; + +namespace Aptabase.Features.Ingestion; + +public class CachedApplication +{ + public string Id { get; private set; } + public bool IsLocked { get; private set; } + + public CachedApplication() + { + Id = string.Empty; + } + + public CachedApplication(Application app) + { + Id = app.Id; + IsLocked = app.LockReason.HasValue; + } + + public static CachedApplication Empty => new CachedApplication(); +} + +public interface IIngestionCache +{ + Task FindByAppKey(string appKey, CancellationToken cancellationToken); +} + +public class IngestionCache : IIngestionCache +{ + private readonly IMemoryCache _cache; + private readonly IAppQueries _appQueries; + + private readonly TimeSpan SuccessCacheDuration = TimeSpan.FromMinutes(30); + private readonly TimeSpan FailureCacheDuration = TimeSpan.FromMinutes(5); + + public IngestionCache(IMemoryCache cache, IAppQueries appQueries) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _appQueries = appQueries ?? throw new ArgumentNullException(nameof(appQueries)); + } + + public async Task FindByAppKey(string appKey, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(appKey)) + return CachedApplication.Empty; + + var cacheKey = $"APP-KEY-STATUS-{appKey}"; + if (_cache.TryGetValue(cacheKey, out CachedApplication? cachedApp) && cachedApp is not null) + return cachedApp; + + var app = await _appQueries.GetActiveAppByAppKey(appKey, cancellationToken); + if (app is null) + { + _cache.Set(cacheKey, CachedApplication.Empty, FailureCacheDuration); + return CachedApplication.Empty; + } + + if (!app.HasEvents) + await _appQueries.MaskAsOnboarded(app.Id, cancellationToken); + + cachedApp = new CachedApplication(app); + _cache.Set(cacheKey, cachedApp, SuccessCacheDuration); + return cachedApp; + } +} \ No newline at end of file diff --git a/src/Features/Ingestion/LocaleFormatter.cs b/src/Features/Ingestion/LocaleFormatter.cs new file mode 100755 index 0000000..8d64dec --- /dev/null +++ b/src/Features/Ingestion/LocaleFormatter.cs @@ -0,0 +1,38 @@ +namespace Aptabase.Features.Ingestion; + +public static class LocaleFormatter +{ + + // List of special locales and the expected format + private static Dictionary SpecialCases = new() + { + { "en-001", "en" }, + { "en-150", "en" }, + { "en-us-posix", "en" }, + }; + + // Validates and formats a locale string + // Return a lowercase locale string + // Return empty string for empty or the special 'C' locale + // Returns null for invalid/unsupported locales + public static string? FormatLocale(string? locale) + { + if (string.IsNullOrEmpty(locale) || locale == "C") + return ""; + + // Some systems may use '_' instead of '-' + // Lowercase it to be consistent + locale = locale.Replace("_", "-").ToLower(); + + if (SpecialCases.ContainsKey(locale)) + return SpecialCases[locale]; + + return locale.Length switch + { + 2 => locale, + (>= 5) and (<= 7) => locale[2] == '-' ? locale : null, + 10 => locale[2] == '-' && locale[7] == '-' ? locale : null, + _ => null + }; + } +} \ No newline at end of file diff --git a/src/Features/Ingestion/UserAgentParser.cs b/src/Features/Ingestion/UserAgentParser.cs new file mode 100755 index 0000000..4047f81 --- /dev/null +++ b/src/Features/Ingestion/UserAgentParser.cs @@ -0,0 +1,88 @@ +using System.Text.RegularExpressions; + +namespace Aptabase.Features.Ingestion; + +public static class UserAgentParser +{ + private static Dictionary osKeys = new Dictionary + { + { "ipad", "iPadOS" }, + { "iphone", "iOS" }, + { "ios", "iOS" }, + + { "windows nt", "Windows" }, + + { "mac os x", "macOS" }, + { "macos", "macOS" }, + { "macintosh", "macOS" }, + + { "android", "Android" }, + + { "cros", "Chrome OS" }, + { "ubuntu", "Ubuntu" }, + { "fedora", "Fedora" }, + { "arch linux", "Arch Linux" }, + { "archlinux", "Arch Linux" }, + { "linux", "Linux" } + }; + + public static (string, string) ParseOperatingSystem(string userAgent) + { + var lcUserAgent = userAgent.ToLowerInvariant(); + + foreach (var (key, value) in osKeys) + { + if (lcUserAgent.Contains(key)) + return (value, ""); + } + + return ("", ""); + } + + + private static Dictionary browserKeys = new Dictionary + { + { "Edg", "Edge" }, + { "Firefox", "Firefox" }, + { "OPiOS", "Opera" }, + { "OPR", "Opera" }, + { "YaBrowser", "Yandex Browser" } + }; + + private static Regex browserRegex = new Regex(@"\((?.*?)\)(\s|$)|(?.*?)\/(?.*?)(\s|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + public static (string, string) ParseBrowser(string userAgent) + { + var lcUserAgent = userAgent.ToLowerInvariant(); + var matches = browserRegex.Matches(userAgent); + + var chromeVersion = ""; + foreach (Match match in matches) + { + var name = match.Groups["name"].Value; + var version = match.Groups["version"].Value; + + foreach (var (key, prettyName) in browserKeys) + { + if (name == key) + return (prettyName, version); + } + + if (name == "Chrome") + chromeVersion = version; + + if (name == "Safari") + { + // It's not Safari unless it has a "Version" group + var safariVersion = matches.FirstOrDefault(x => x.Groups["name"].Value == "Version")?.Groups["version"].Value ?? ""; + if (!string.IsNullOrEmpty(safariVersion)) + return ("Safari", safariVersion); + } + } + + // if we didn't find a browser, but we found a chrome version, we assume it's chrome + if (!string.IsNullOrEmpty(chromeVersion)) + return ("Chrome", chromeVersion); + + return ("", ""); + } +} \ No newline at end of file diff --git a/src/Features/Notification/IEmailClient.cs b/src/Features/Notification/IEmailClient.cs new file mode 100755 index 0000000..af6ed8a --- /dev/null +++ b/src/Features/Notification/IEmailClient.cs @@ -0,0 +1,6 @@ +namespace Aptabase.Features.Notification; + +public interface IEmailClient +{ + Task SendEmailAsync(string to, string subject, string templateName, Dictionary? properties, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Features/Notification/LoggerEmailClient.cs b/src/Features/Notification/LoggerEmailClient.cs new file mode 100755 index 0000000..1a8eea0 --- /dev/null +++ b/src/Features/Notification/LoggerEmailClient.cs @@ -0,0 +1,18 @@ +namespace Aptabase.Features.Notification; + +public class LoggerEmailClient : IEmailClient +{ + private readonly TemplateEngine _engine = new(); + private readonly ILogger _logger; + + public LoggerEmailClient(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task SendEmailAsync(string to, string subject, string templateName, Dictionary? properties, CancellationToken cancellationToken) + { + _logger.LogInformation("Sending email to '{to}' with template '{templateName}' and '{properties}'", to, templateName, properties); + await Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Features/Notification/MailCatcherSmtpClient.cs b/src/Features/Notification/MailCatcherSmtpClient.cs new file mode 100755 index 0000000..df33b70 --- /dev/null +++ b/src/Features/Notification/MailCatcherSmtpClient.cs @@ -0,0 +1,27 @@ +using System.Net.Mail; + +namespace Aptabase.Features.Notification; + +public class MailCatcherSmtpClient : IEmailClient +{ + private readonly TemplateEngine _engine = new(); + private readonly SmtpClient _client; + + public MailCatcherSmtpClient(string host, int port) + { + _client = new SmtpClient(host, port); + } + + public async Task SendEmailAsync(string to, string subject, string templateName, Dictionary? properties, CancellationToken cancellationToken) + { + var body = await _engine.Render(templateName, subject, properties); + + using var message = new MailMessage("hi@aptabase.com", to) + { + Subject = subject, + Body = body + }; + + await _client.SendMailAsync(message, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Features/Notification/NotificationExtensions.cs b/src/Features/Notification/NotificationExtensions.cs new file mode 100755 index 0000000..5b1a462 --- /dev/null +++ b/src/Features/Notification/NotificationExtensions.cs @@ -0,0 +1,32 @@ +namespace Aptabase.Features.Notification; + +public static class NotificationExtensions +{ + public static void AddEmailClient(this IServiceCollection services, EnvSettings env) + { + if (env.IsManagedCloud) + { + services.AddSingleton(); + return; + } + + if (!string.IsNullOrEmpty(env.MailCatcherConnectionString)) + { + services.AddSingleton(sp => + { + var smtpUri = new Uri(env.MailCatcherConnectionString); + + return new MailCatcherSmtpClient(smtpUri.Host, smtpUri.Port); + }); + return; + } + + if (!string.IsNullOrEmpty(env.SmtpHost)) + { + services.AddSingleton(); + return; + } + + services.AddSingleton(); + } +} \ No newline at end of file diff --git a/src/Features/Notification/SESEmailClient.cs b/src/Features/Notification/SESEmailClient.cs new file mode 100755 index 0000000..4d5577a --- /dev/null +++ b/src/Features/Notification/SESEmailClient.cs @@ -0,0 +1,62 @@ +using Amazon.Runtime.CredentialManagement; +using Amazon.SimpleEmail; +using Amazon.SimpleEmail.Model; + +namespace Aptabase.Features.Notification; + +public class SESEmailClient : IEmailClient +{ + private readonly IAmazonSimpleEmailService _ses; + private readonly EnvSettings _env; + private readonly TemplateEngine _engine = new(); + + public SESEmailClient(EnvSettings env) + { + _env = env ?? throw new ArgumentNullException(nameof(env)); + _ses = CreateClient(); + } + + public async Task SendEmailAsync(string to, string subject, string templateName, Dictionary? properties, CancellationToken cancellationToken) + { + var body = await _engine.Render(templateName, subject, properties); + var request = NewRequest(to, subject, body); + await _ses.SendEmailAsync(request, cancellationToken); + } + + private AmazonSimpleEmailServiceClient CreateClient() + { + if (_env.IsProduction) + return new AmazonSimpleEmailServiceClient(); + + var chain = new CredentialProfileStoreChain(); + if (!chain.TryGetAWSCredentials("aptabase", out var credentials)) + throw new Exception("Failed to find the aptabase profile"); + + return new AmazonSimpleEmailServiceClient(credentials, Amazon.RegionEndpoint.USEast1); + } + + private static SendEmailRequest NewRequest(string to, string subject, string body) + { + return new SendEmailRequest + { + Source = "Aptabase ", + Destination = new Destination + { + ToAddresses = + new List { to } + }, + Message = new Message + { + Subject = new Content(subject), + Body = new Body + { + Html = new Content + { + Charset = "UTF-8", + Data = body + }, + } + }, + }; + } +} \ No newline at end of file diff --git a/src/Features/Notification/SmtpEmailClient.cs b/src/Features/Notification/SmtpEmailClient.cs new file mode 100755 index 0000000..12f6a08 --- /dev/null +++ b/src/Features/Notification/SmtpEmailClient.cs @@ -0,0 +1,35 @@ +using MimeKit; +using MailKit.Net.Smtp; + +namespace Aptabase.Features.Notification; + +public class SmtpEmailClient : IEmailClient +{ + private readonly EnvSettings _env; + private readonly TemplateEngine _engine = new(); + + public SmtpEmailClient(EnvSettings env) + { + _env = env ?? throw new ArgumentNullException(nameof(env)); + } + + public async Task SendEmailAsync(string to, string subject, string templateName, Dictionary? properties, CancellationToken cancellationToken) + { + using var smtp = new SmtpClient(); + await smtp.ConnectAsync(_env.SmtpHost, _env.SmtpPort, IsTLSPort(_env.SmtpPort), cancellationToken); + + if (!string.IsNullOrEmpty(_env.SmtpUsername)) + await smtp.AuthenticateAsync(_env.SmtpUsername, _env.SmtpPassword, cancellationToken); + + var body = await _engine.Render(templateName, subject, properties); + var msg = new MimeMessage(); + msg.From.Add(new MailboxAddress("", _env.SmtpFromAddress)); + msg.To.Add(new MailboxAddress("", to)); + msg.Subject = subject; + msg.Body = new TextPart("html") { Text = body }; + await smtp.SendAsync(msg, cancellationToken); + await smtp.DisconnectAsync(true, cancellationToken); + } + + private static bool IsTLSPort(int port) => port == 587 || port == 465; +} \ No newline at end of file diff --git a/src/Features/Notification/TemplateEngine.cs b/src/Features/Notification/TemplateEngine.cs new file mode 100755 index 0000000..c60b89a --- /dev/null +++ b/src/Features/Notification/TemplateEngine.cs @@ -0,0 +1,40 @@ +using System.Reflection; +using System.Text; +using System.Web; + +namespace Aptabase.Features.Notification; + +public class TemplateEngine +{ + private readonly Assembly _assembly = Assembly.GetExecutingAssembly() ?? throw new Exception("Failed to find the entry assembly"); + private readonly Dictionary _templates = new(); + + public async Task Render(string name, string subject, Dictionary? properties) + { + var baseTemplate = await GetTemplate("Base"); + var emailTemplate = await GetTemplate(name); + + if (properties is not null) + { + foreach (var (key, value) in properties) + emailTemplate = emailTemplate.Replace($"##{key.ToUpper()}##", HttpUtility.HtmlEncode(value)); + } + + return baseTemplate.Replace("##SUBJECT##", subject).Replace("##BODY##", emailTemplate); + } + + public async Task GetTemplate(string name) + { + if (_templates.ContainsKey(name)) + return _templates[name]; + + var resourceStream = _assembly.GetManifestResourceStream($"Aptabase.assets.Templates.{name}.html"); + if (resourceStream == null) + throw new Exception($"Failed to find the embedded resource named {name}"); + + var reader = new StreamReader(resourceStream, Encoding.UTF8); + var content = await reader.ReadToEndAsync(); + _templates.Add(name, content); + return content; + } +} \ No newline at end of file diff --git a/src/Features/Privacy/DailyUserHasher.cs b/src/Features/Privacy/DailyUserHasher.cs new file mode 100755 index 0000000..16ce50a --- /dev/null +++ b/src/Features/Privacy/DailyUserHasher.cs @@ -0,0 +1,70 @@ +using Aptabase.Data; +using System.Text; +using System.Security.Cryptography; +using Microsoft.Extensions.Caching.Memory; +using Dapper; +using FastHashes; + +namespace Aptabase.Features.Privacy; + +public interface IUserHasher +{ + Task CalculateHash(DateTime timestamp, string appId, string sessionId, string clientIP, string userAgent); +} + +public class DailyUserHasher : IUserHasher +{ + private readonly IMemoryCache _cache; + private readonly IDbContext _db; + + public DailyUserHasher(IMemoryCache cache, IDbContext db) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + } + + public async Task CalculateHash(DateTime timestamp, string appId, string sessionId, string clientIP, string userAgent) + { + var cacheKey = $"USERID-{appId}-{sessionId}"; + + // If we already have a cached user ID for this session, return it immediately + // This avoid issues with the user ID changing in the middle of a session because of an IP change + if (_cache.TryGetValue(cacheKey, out string? userId) && !string.IsNullOrEmpty(userId)) + return userId; + + var salt = await GetSaltFor(timestamp.Date.ToString("yyyy-MM-dd"), appId); + var bytes = Encoding.UTF8.GetBytes($"{clientIP}|${userAgent}"); + var hash = ComputeHash(salt, bytes); + userId = Convert.ToHexString(hash); + + _cache.Set(cacheKey, userId, TimeSpan.FromHours(48)); + return userId; + } + + private static byte[] ComputeHash(Span salt, byte[] bytes) + { + var key1 = BitConverter.ToUInt64(salt[..8]); + var key2 = BitConverter.ToUInt64(salt[0..]); + var hasher = new SipHash(SipHashVariant.V24, key1, key2); + return hasher.ComputeHash(bytes); + } + + private async Task GetSaltFor(string date, string appId) + { + var cacheKey = $"DAILY-SALT-{appId}-{date}"; + if (_cache.TryGetValue(cacheKey, out byte[]? cachedSalt) && cachedSalt != null) + return cachedSalt; + + var storedSalt = await ReadOrCreateSalt(date, appId); + _cache.Set(cacheKey, storedSalt, TimeSpan.FromDays(2)); + return storedSalt; + } + + private async Task ReadOrCreateSalt(string date, string appId) + { + var newSalt = RandomNumberGenerator.GetBytes(16); + await _db.Connection.ExecuteAsync($"INSERT INTO app_salts (app_id, date, salt) VALUES (@appId, @date, @newSalt) ON CONFLICT DO NOTHING", new { appId, date, newSalt }); + var bytes = await _db.Connection.ExecuteScalarAsync($"SELECT salt FROM app_salts WHERE app_id = @appId AND date = @date", new { appId, date }); + return bytes ?? newSalt; + } +} \ No newline at end of file diff --git a/src/Features/Privacy/PrivacyQueries.cs b/src/Features/Privacy/PrivacyQueries.cs new file mode 100755 index 0000000..2485778 --- /dev/null +++ b/src/Features/Privacy/PrivacyQueries.cs @@ -0,0 +1,29 @@ +using Aptabase.Data; +using Dapper; + +namespace Aptabase.Features.Privacy; + +public interface IPrivacyQueries +{ + Task PurgeOldSalts(CancellationToken cancellationToken); +} + +public class PrivacyQueries : IPrivacyQueries +{ + private readonly IDbContext _db; + + public PrivacyQueries(IDbContext db) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + } + + public async Task PurgeOldSalts(CancellationToken cancellationToken) + { + var cmd = new CommandDefinition( + "DELETE FROM app_salts WHERE TO_DATE(date, 'YYYY/MM/DD') <= CURRENT_DATE - INTERVAL '2' DAY", + cancellationToken: cancellationToken + ); + + return await _db.Connection.ExecuteAsync(cmd); + } +} \ No newline at end of file diff --git a/src/Features/Privacy/PurgeDailySaltsCronJob.cs b/src/Features/Privacy/PurgeDailySaltsCronJob.cs new file mode 100755 index 0000000..58c2fbf --- /dev/null +++ b/src/Features/Privacy/PurgeDailySaltsCronJob.cs @@ -0,0 +1,43 @@ +using Sgbj.Cron; + +namespace Aptabase.Features.Privacy; + +public class PurgeDailySaltsCronJob : BackgroundService +{ + private readonly ILogger _logger; + private readonly IPrivacyQueries _privacyQueries; + + public PurgeDailySaltsCronJob(IPrivacyQueries privacyQueries, ILogger logger) + { + _privacyQueries = privacyQueries ?? throw new ArgumentNullException(nameof(privacyQueries)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + _logger.LogInformation("PurgeDailySaltsCronJob is starting."); + + using var timer = new CronTimer("0 0 * * *", TimeZoneInfo.Utc); + + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + _logger.LogInformation("Purging daily salts"); + var rows = await _privacyQueries.PurgeOldSalts(cancellationToken); + _logger.LogInformation("Deleted {rows} rows", rows); + } + } + catch (OperationCanceledException) + { + _logger.LogInformation("PurgeDailySaltsCronJob stopped."); + } + catch (Exception ex) + { + _logger.LogError(ex, "PurgeDailySaltsCronJob crashed."); + } + } + } +} \ No newline at end of file diff --git a/src/Features/Stats/ClickHouseQueryClient.cs b/src/Features/Stats/ClickHouseQueryClient.cs new file mode 100755 index 0000000..edfd6a2 --- /dev/null +++ b/src/Features/Stats/ClickHouseQueryClient.cs @@ -0,0 +1,65 @@ +using Dapper; +using Scriban; +using ClickHouse.Client.ADO; +using System.Collections.Concurrent; + +namespace Aptabase.Features.Stats; + +public class ClickHouseQueryClient : IQueryClient +{ + private readonly ClickHouseConnection _conn; + private readonly EnvSettings _env; + + public ClickHouseQueryClient(ClickHouseConnection conn, EnvSettings env) + { + _env = env ?? throw new ArgumentNullException(nameof(env)); + _conn = conn ?? throw new ArgumentNullException(nameof(conn)); + } + + public async Task> NamedQueryAsync(string name, object args, CancellationToken cancellationToken) + { + var dict = args.GetType().GetProperties().ToDictionary(p => p.Name, p => FormatArg(p.GetValue(args, null))); + var template = await ReadNamedQuery(name); + var query = await template.RenderAsync(dict); + return await _conn.QueryAsync(query, cancellationToken); + } + + public async Task NamedQuerySingleAsync(string name, object args, CancellationToken cancellationToken) where T : new() + { + var rows = await NamedQueryAsync(name, args, cancellationToken); + return rows.FirstOrDefault() ?? new T(); + } + + public async Task StreamResponseAsync(string query, CancellationToken cancellationToken) + { + using var command = _conn.CreateCommand(); + command.CommandText = query; + var result = await command.ExecuteRawResultAsync(cancellationToken); + return await result.ReadAsStreamAsync(); + } + + private readonly ConcurrentDictionary _namedQueries = new(); + private async Task