commit 185d204bdcb3f2deb3417e0cd8201feb8efd8439 Author: Izalia Mae Date: Fri Feb 18 16:45:32 2022 -0500 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b5b798d --- /dev/null +++ b/.gitignore @@ -0,0 +1,121 @@ +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# Symlink to izzylib +izzylib + +test*.py +reload.cfg diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e6dfff4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,524 @@ +IzzyLib +Copyright Zoey Mae 2021 + +COOPERATIVE NON-VIOLENT PUBLIC LICENSE v6 + +Preamble + +The Cooperative Non-Violent Public license is a freedom-respecting sharealike +license for both the author of a work as well as those subject to a work. +It aims to protect the basic rights of human beings from exploitation, +the earth from plunder, and the equal treatment of the workers involved in the +creation of the work. It aims to ensure a copyrighted work is forever +available for public use, modification, and redistribution under the same +terms so long as the work is not used for harm. For more information about +the CNPL refer to the official webpage + +Official Webpage: https://thufie.lain.haus/NPL.html + +Terms and Conditions + +THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS +COOPERATIVE NON-VIOLENT PUBLIC LICENSE v5 ("LICENSE"). THE WORK IS +PROTECTED BY COPYRIGHT AND ALL OTHER APPLICABLE LAWS. ANY USE OF THE +WORK OTHER THAN AS AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS +PROHIBITED. BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED IN THIS +LICENSE, YOU AGREE TO BE BOUND BY THE TERMS OF THIS LICENSE. +TO THE EXTENT THIS LICENSE MAY BE CONSIDERED TO BE A CONTRACT, +THE LICENSOR GRANTS YOU THE RIGHTS CONTAINED HERE IN AS CONSIDERATION +FOR ACCEPTING THE TERMS AND CONDITIONS OF THIS LICENSE AND FOR AGREEING +TO BE BOUND BY THE TERMS AND CONDITIONS OF THIS LICENSE. + +1. DEFINITIONS + + a. "Act of War" means any action of one country against any group + either with an intention to provoke a conflict or an action that + occurs during a declared war or during armed conflict between + military forces of any origin. This includes but is not limited + to enforcing sanctions or sieges, supplying armed forces, + or profiting from the manufacture of tools or weaponry used in + military conflict. + + b. "Adaptation" means a work based upon the Work, or upon the + Work and other pre-existing works, such as a translation, + adaptation, derivative work, arrangement of music or other + alterations of a literary or artistic work, or phonogram or + performance and includes cinematographic adaptations or any + other form in which the Work may be recast, transformed, or + adapted including in any form recognizably derived from the + original, except that a work that constitutes a Collection will + not be considered an Adaptation for the purpose of this License. + For the avoidance of doubt, where the Work is a musical work, + performance or phonogram, the synchronization of the Work in + timed-relation with a moving image ("synching") will be + considered an Adaptation for the purpose of this License. In + addition, where the Work is designed to output a neural network + the output of the neural network will be considered an + Adaptation for the purpose of this license. + + c. "Bodily Harm" means any physical hurt or injury to a person that + interferes with the health or comfort of the person and that is more + than merely transient or trifling in nature. + + d. "Collection" means a collection of literary or artistic + works, such as encyclopedias and anthologies, or performances, + phonograms or broadcasts, or other works or subject matter other + than works listed in Section 1(i) below, which, by reason of the + selection and arrangement of their contents, constitute + intellectual creations, in which the Work is included in its + entirety in unmodified form along with one or more other + contributions, each constituting separate and independent works + in themselves, which together are assembled into a collective + whole. A work that constitutes a Collection will not be + considered an Adaptation (as defined above) for the purposes of + this License. + + e. "Distribute" means to make available to the public the + original and copies of the Work or Adaptation, as appropriate, + through sale, gift or any other transfer of possession or + ownership. + + f. "Incarceration" means confinement in a jail, prison, or + any other place where individuals of any kind are held against + either their will or (if their will cannot be determined) the + will of their legal guardian or guardians. In the case of a + conflict between the will of the individual and the will of + their legal guardian or guardians, the will of the + individual will take precedence. + + g. "Licensor" means the individual, individuals, entity or + entities that offer(s) the Work under the terms of this License. + + h. "Original Author" means, in the case of a literary or + artistic work, the individual, individuals, entity or entities + who created the Work or if no individual or entity can be + identified, the publisher; and in addition (i) in the case of a + performance the actors, singers, musicians, dancers, and other + persons who act, sing, deliver, declaim, play in, interpret or + otherwise perform literary or artistic works or expressions of + folklore; (ii) in the case of a phonogram the producer being the + person or legal entity who first fixes the sounds of a + performance or other sounds; and, (iii) in the case of + broadcasts, the organization that transmits the broadcast. + + i. "Work" means the literary and/or artistic work offered under + the terms of this License including without limitation any + production in the literary, scientific and artistic domain, + whatever may be the mode or form of its expression including + digital form, such as a book, pamphlet and other writing; a + lecture, address, sermon or other work of the same nature; a + dramatic or dramatico-musical work; a choreographic work or + entertainment in dumb show; a musical composition with or + without words; a cinematographic work to which are assimilated + works expressed by a process analogous to cinematography; a work + of drawing, painting, architecture, sculpture, engraving or + lithography; a photographic work to which are assimilated works + expressed by a process analogous to photography; a work of + applied art; an illustration, map, plan, sketch or + three-dimensional work relative to geography, topography, + architecture or science; a performance; a broadcast; a + phonogram; a compilation of data to the extent it is protected + as a copyrightable work; or a work performed by a variety or + circus performer to the extent it is not otherwise considered a + literary or artistic work. + + j. "You" means an individual or entity exercising rights under + this License who has not previously violated the terms of this + License with respect to the Work, or who has received express + permission from the Licensor to exercise rights under this + License despite a previous violation. + + k. "Publicly Perform" means to perform public recitations of the + Work and to communicate to the public those public recitations, + by any means or process, including by wire or wireless means or + public digital performances; to make available to the public + Works in such a way that members of the public may access these + Works from a place and at a place individually chosen by them; + to perform the Work to the public by any means or process and + the communication to the public of the performances of the Work, + including by public digital performance; to broadcast and + rebroadcast the Work by any means including signs, sounds or + images. + + l. "Reproduce" means to make copies of the Work by any means + including without limitation by sound or visual recordings and + the right of fixation and reproducing fixations of the Work, + including storage of a protected performance or phonogram in + digital form or other electronic medium. + + m. "Software" means any digital Work which, through use of a + third-party piece of Software or through the direct usage of + itself on a computer system, the memory of the computer is + modified dynamically or semi-dynamically. "Software", + secondly, processes or interprets information. + + n. "Source Code" means the human-readable form of Software + through which the Original Author and/or Distributor originally + created, derived, and/or modified it. + + o. "Surveilling" means the use of the Work to either + overtly or covertly observe and record persons and or their + activities. + + p. "Network Service" means the use of a piece of Software to + interpret or modify information that is subsequently and directly + served to users over the Internet. + + q. "Discriminate" means the use of a work to differentiate between + humans in a such a way which prioritizes some above others on the + basis of percieved membership within certain groups. + + r. "Hate Speech" means communication or any form + of expression which is solely for the purpose of expressing hatred + for some group or advocating a form of Discrimination + (to Discriminate per definition in (q)) between humans. + + s. "Coercion" means leveraging of the threat of force or use of force + to intimidate a person in order to gain compliance, or to offer + large incentives which aim to entice a person to act against their + will. + +2. FAIR DEALING RIGHTS + + Nothing in this License is intended to reduce, limit, or restrict any + uses free from copyright or rights arising from limitations or + exceptions that are provided for in connection with the copyright + protection under copyright law or other applicable laws. + +3. LICENSE GRANT + + Subject to the terms and conditions of this License, Licensor hereby + grants You a worldwide, royalty-free, non-exclusive, perpetual (for the + duration of the applicable copyright) license to exercise the rights in + the Work as stated below: + + a. to Reproduce the Work, to incorporate the Work into one or + more Collections, and to Reproduce the Work as incorporated in + the Collections; + + b. to create and Reproduce Adaptations provided that any such + Adaptation, including any translation in any medium, takes + reasonable steps to clearly label, demarcate or otherwise + identify that changes were made to the original Work. For + example, a translation could be marked "The original work was + translated from English to Spanish," or a modification could + indicate "The original work has been modified."; + + c. to Distribute and Publicly Perform the Work including as + incorporated in Collections; and, + + d. to Distribute and Publicly Perform Adaptations. The above + rights may be exercised in all media and formats whether now + known or hereafter devised. The above rights include the right + to make such modifications as are technically necessary to + exercise the rights in other media and formats. Subject to + Section 8(g), all rights not expressly granted by Licensor are + hereby reserved, including but not limited to the rights set + forth in Section 4(i). + +4. RESTRICTIONS + + The license granted in Section 3 above is expressly made subject to and + limited by the following restrictions: + + a. You may Distribute or Publicly Perform the Work only under + the terms of this License. You must include a copy of, or the + Uniform Resource Identifier (URI) for, this License with every + copy of the Work You Distribute or Publicly Perform. You may not + offer or impose any terms on the Work that restrict the terms of + this License or the ability of the recipient of the Work to + exercise the rights granted to that recipient under the terms of + the License. You may not sublicense the Work. You must keep + intact all notices that refer to this License and to the + disclaimer of warranties with every copy of the Work You + Distribute or Publicly Perform. When You Distribute or Publicly + Perform the Work, You may not impose any effective technological + measures on the Work that restrict the ability of a recipient of + the Work from You to exercise the rights granted to that + recipient under the terms of the License. This Section 4(a) + applies to the Work as incorporated in a Collection, but this + does not require the Collection apart from the Work itself to be + made subject to the terms of this License. If You create a + Collection, upon notice from any Licensor You must, to the + extent practicable, remove from the Collection any credit as + required by Section 4(h), as requested. If You create an + Adaptation, upon notice from any Licensor You must, to the + extent practicable, remove from the Adaptation any credit as + required by Section 4(h), as requested. + + b. Subject to the exception in Section 4(e), you may not + exercise any of the rights granted to You in Section 3 above in + any manner that is primarily intended for or directed toward + commercial advantage or private monetary compensation. The + exchange of the Work for other copyrighted works by means of + digital file-sharing or otherwise shall not be considered to be + intended for or directed toward commercial advantage or private + monetary compensation, provided there is no payment of any + monetary compensation in connection with the exchange of + copyrighted works. + + c. If the Work meets the definition of Software, You may exercise + the rights granted in Section 3 only if You provide a copy of the + corresponding Source Code from which the Work was derived in digital + form, or You provide a URI for the corresponding Source Code of + the Work, to any recipients upon request. + + d. If the Work is used as or for a Network Service, You may exercise + the rights granted in Section 3 only if You provide a copy of the + corresponding Source Code from which the Work was derived in digital + form, or You provide a URI for the corresponding Source Code to the + Work, to any recipients of the data served or modified by the Web + Service. + + e. You may exercise the rights granted in Section 3 for + commercial purposes only if: + + i. You are a worker-owned business or worker-owned + collective; and + ii. after tax, all financial gain, surplus, profits and + benefits produced by the business or collective are + distributed among the worker-owners unless a set amount + is to be allocated towards community projects as decided + by a previously-established consensus agreement between the + worker-owners where all worker-owners agreed + iii. You are not using such rights on behalf of a business + other than those specified in 4(e.i) and elaborated upon in + 4(e.ii), nor are using such rights as a proxy on behalf of a + business with the intent to circumvent the aforementioned + restrictions on such a business. + + f. Any use by a business that is privately owned and managed, + and that seeks to generate profit from the labor of employees + paid by salary or other wages, is not permitted under this + license. + + g. You may exercise the rights granted in Section 3 for + any purposes only if: + + i. You do not use the Work for the purpose of inflicting + Bodily Harm on human beings (subject to criminal + prosecution or otherwise) outside of providing medical aid + or undergoing a voluntary procedure under no form of + Coercion. + ii.You do not use the Work for the purpose of Surveilling + or tracking individuals for financial gain. + iii. You do not use the Work in an Act of War. + iv. You do not use the Work for the purpose of supporting + or profiting from an Act of War. + v. You do not use the Work for the purpose of Incarceration. + vi. You do not use the Work for the purpose of extracting, + processing, or refining, oil, gas, or coal. Or to in any other + way to deliberately pollute the environment as a byproduct + of manufacturing or irresponsible disposal of hazardous materials. + vii. You do not use the Work for the purpose of + expediting, coordinating, or facilitating paid work + undertaken by individuals under the age of 12 years. + viii. You do not use the Work to either Discriminate or + spread Hate Speech on the basis of sex, sexual orientation, + gender identity, race, age, disability, color, national origin, + religion, or lower economic status. + + h. If You Distribute, or Publicly Perform the Work or any + Adaptations or Collections, You must, unless a request has been + made pursuant to Section 4(a), keep intact all copyright notices + for the Work and provide, reasonable to the medium or means You + are utilizing: (i) the name of the Original Author (or + pseudonym, if applicable) if supplied, and/or if the Original + Author and/or Licensor designate another party or parties (e.g., + a sponsor institute, publishing entity, journal) for attribution + ("Attribution Parties") in Licensor's copyright notice, terms of + service or by other reasonable means, the name of such party or + parties; (ii) the title of the Work if supplied; (iii) to the + extent reasonably practicable, the URI, if any, that Licensor + to be associated with the Work, unless such URI does + not refer to the copyright notice or licensing information for + the Work; and, (iv) consistent with Section 3(b), in the case of + an Adaptation, a credit identifying the use of the Work in the + Adaptation (e.g., "French translation of the Work by Original + Author," or "Screenplay based on original Work by Original + Author"). The credit required by this Section 4(h) may be + implemented in any reasonable manner; provided, however, that in + the case of an Adaptation or Collection, at a minimum such credit + will appear, if a credit for all contributing authors of the + Adaptation or Collection appears, then as part of these credits + and in a manner at least as prominent as the credits for the + other contributing authors. For the avoidance of doubt, You may + only use the credit required by this Section for the purpose of + attribution in the manner set out above and, by exercising Your + rights under this License, You may not implicitly or explicitly + assert or imply any connection with, sponsorship or endorsement + by the Original Author, Licensor and/or Attribution Parties, as + appropriate, of You or Your use of the Work, without the + separate, express prior written permission of the Original + Author, Licensor and/or Attribution Parties. + + i. For the avoidance of doubt: + + i. Non-waivable Compulsory License Schemes. In those + jurisdictions in which the right to collect royalties + through any statutory or compulsory licensing scheme + cannot be waived, the Licensor reserves the exclusive + right to collect such royalties for any exercise by You of + the rights granted under this License; + + ii. Waivable Compulsory License Schemes. In those + jurisdictions in which the right to collect royalties + through any statutory or compulsory licensing scheme can + be waived, the Licensor reserves the exclusive right to + collect such royalties for any exercise by You of the + rights granted under this License if Your exercise of such + rights is for a purpose or use which is otherwise than + noncommercial as permitted under Section 4(b) and + otherwise waives the right to collect royalties through + any statutory or compulsory licensing scheme; and, + iii.Voluntary License Schemes. The Licensor reserves the + right to collect royalties, whether individually or, in + the event that the Licensor is a member of a collecting + society that administers voluntary licensing schemes, via + that society, from any exercise by You of the rights + granted under this License that is for a purpose or use + which is otherwise than noncommercial as permitted under + Section 4(b). + + j. Except as otherwise agreed in writing by the Licensor or as + may be otherwise permitted by applicable law, if You Reproduce, + Distribute or Publicly Perform the Work either by itself or as + part of any Adaptations or Collections, You must not distort, + mutilate, modify or take other derogatory action in relation to + the Work which would be prejudicial to the Original Author's + honor or reputation. Licensor agrees that in those jurisdictions + (e.g. Japan), in which any exercise of the right granted in + Section 3(b) of this License (the right to make Adaptations) + would be deemed to be a distortion, mutilation, modification or + other derogatory action prejudicial to the Original Author's + honor and reputation, the Licensor will waive or not assert, as + appropriate, this Section, to the fullest extent permitted by + the applicable national law, to enable You to reasonably + exercise Your right under Section 3(b) of this License (right to + make Adaptations) but not otherwise. + + k. Do not make any legal claim against anyone accusing the + Work, with or without changes, alone or with other works, + of infringing any patent claim. + +5. REPRESENTATIONS, WARRANTIES AND DISCLAIMER + + UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING, LICENSOR + OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY + KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE, + INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY, + FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF + LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF + ERRORS, WHETHER OR NOT DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW + THE EXCLUSION OF IMPLIED WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO + YOU. + +6. LIMITATION ON LIABILITY + + EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE LAW, IN NO EVENT WILL + LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR ANY SPECIAL, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES ARISING OUT OF + THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS BEEN ADVISED + OF THE POSSIBILITY OF SUCH DAMAGES. + +7. TERMINATION + + a. This License and the rights granted hereunder will terminate + automatically upon any breach by You of the terms of this + License. Individuals or entities who have received Adaptations + or Collections from You under this License, however, will not + have their licenses terminated provided such individuals or + entities remain in full compliance with those licenses. Sections + 1, 2, 5, 6, 7, and 8 will survive any termination of this + License. + + b. Subject to the above terms and conditions, the license + granted here is perpetual (for the duration of the applicable + copyright in the Work). Notwithstanding the above, Licensor + reserves the right to release the Work under different license + terms or to stop distributing the Work at any time; provided, + however that any such election will not serve to withdraw this + License (or any other license that has been, or is required to + be, granted under the terms of this License), and this License + will continue in full force and effect unless terminated as + stated above. + +8. REVISED LICENSE VERSIONS + + a. This License may receive future revisions in the original + spirit of the license intended to strengthen This License. + Each version of This License has an incrementing version number. + + b. Unless otherwise specified like in Section 8(c) The Licensor + has only granted this current version of This License for The Work. + In this case future revisions do not apply. + + c. The Licensor may specify that the latest available + revision of This License be used for The Work by either explicitly + writing so or by suffixing the License URI with a "+" symbol. + + d. The Licensor may specify that The Work is also available + under the terms of This License's current revision as well + as specific future revisions. The Licensor may do this by + writing it explicitly or suffixing the License URI with any + additional version numbers each separated by a comma. + +9. MISCELLANEOUS + + a. Each time You Distribute or Publicly Perform the Work or a + Collection, the Licensor offers to the recipient a license to + the Work on the same terms and conditions as the license granted + to You under this License. + + b. Each time You Distribute or Publicly Perform an Adaptation, + Licensor offers to the recipient a license to the original Work + on the same terms and conditions as the license granted to You + under this License. + + c. If the Work is classified as Software, each time You Distribute + or Publicly Perform an Adaptation, Licensor offers to the recipient + a copy and/or URI of the corresponding Source Code on the same + terms and conditions as the license granted to You under this License. + + d. If the Work is used as a Network Service, each time You Distribute + or Publicly Perform an Adaptation, or serve data derived from the + Software, the Licensor offers to any recipients of the data a copy + and/or URI of the corresponding Source Code on the same terms and + conditions as the license granted to You under this License. + + e. If any provision of this License is invalid or unenforceable + under applicable law, it shall not affect the validity or + enforceability of the remainder of the terms of this License, + and without further action by the parties to this agreement, + such provision shall be reformed to the minimum extent necessary + to make such provision valid and enforceable. + + f. No term or provision of this License shall be deemed waived + and no breach consented to unless such waiver or consent shall + be in writing and signed by the party to be charged with such + waiver or consent. + + g. This License constitutes the entire agreement between the + parties with respect to the Work licensed here. There are no + understandings, agreements or representations with respect to + the Work not specified here. Licensor shall not be bound by any + additional provisions that may appear in any communication from + You. This License may not be modified without the mutual written + agreement of the Licensor and You. + + h. The rights granted under, and the subject matter referenced, + in this License were drafted utilizing the terminology of the + Berne Convention for the Protection of Literary and Artistic + Works (as amended on September 28, 1979), the Rome Convention of + 1961, the WIPO Copyright Treaty of 1996, the WIPO Performances + and Phonograms Treaty of 1996 and the Universal Copyright + Convention (as revised on July 24, 1971). These rights and + subject matter take effect in the relevant jurisdiction in which + the License terms are sought to be enforced according to the + corresponding provisions of the implementation of those treaty + provisions in the applicable national law. If the standard suite + of rights granted under applicable copyright law includes + additional rights not granted under this License, such + additional rights are deemed to be included in the License; this + License is not intended to restrict the license of any rights + under applicable law. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..89e06ca --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +recursive-include barkshark_http/frontend * diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..34a2941 --- /dev/null +++ b/Pipfile @@ -0,0 +1,20 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +colour = "*" +hamlish-jinja = "*" +http-router = "*" +python-magic = "*" +pycryptodome = "*" +tldextract = "*" +jinja2 = "*" +argon2-cffi = "*" +markdown = "*" + +[dev-packages] + +[requires] +python_version = "3.9" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..467422c --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,351 @@ +{ + "_meta": { + "hash": { + "sha256": "b556a2c8ff27882c23a6c2eb27b3f661501055d8cafc22e88db1508e6af072a2" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.9" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "argon2-cffi": { + "hashes": [ + "sha256:8c976986f2c5c0e5000919e6de187906cfd81fb1c72bf9d88c01177e77da7f80", + "sha256:d384164d944190a7dd7ef22c6aa3ff197da12962bd04b17f64d4e93d934dba5b" + ], + "index": "pypi", + "version": "==21.3.0" + }, + "argon2-cffi-bindings": { + "hashes": [ + "sha256:20ef543a89dee4db46a1a6e206cd015360e5a75822f76df533845c3cbaf72670", + "sha256:2c3e3cc67fdb7d82c4718f19b4e7a87123caf8a93fde7e23cf66ac0337d3cb3f", + "sha256:3b9ef65804859d335dc6b31582cad2c5166f0c3e7975f324d9ffaa34ee7e6583", + "sha256:3e385d1c39c520c08b53d63300c3ecc28622f076f4c2b0e6d7e796e9f6502194", + "sha256:58ed19212051f49a523abb1dbe954337dc82d947fb6e5a0da60f7c8471a8476c", + "sha256:5e00316dabdaea0b2dd82d141cc66889ced0cdcbfa599e8b471cf22c620c329a", + "sha256:603ca0aba86b1349b147cab91ae970c63118a0f30444d4bc80355937c950c082", + "sha256:6a22ad9800121b71099d0fb0a65323810a15f2e292f2ba450810a7316e128ee5", + "sha256:8cd69c07dd875537a824deec19f978e0f2078fdda07fd5c42ac29668dda5f40f", + "sha256:93f9bf70084f97245ba10ee36575f0c3f1e7d7724d67d8e5b08e61787c320ed7", + "sha256:9524464572e12979364b7d600abf96181d3541da11e23ddf565a32e70bd4dc0d", + "sha256:b2ef1c30440dbbcba7a5dc3e319408b59676e2e039e2ae11a8775ecf482b192f", + "sha256:b746dba803a79238e925d9046a63aa26bf86ab2a2fe74ce6b009a1c3f5c8f2ae", + "sha256:bb89ceffa6c791807d1305ceb77dbfacc5aa499891d2c55661c6459651fc39e3", + "sha256:bd46088725ef7f58b5a1ef7ca06647ebaf0eb4baff7d1d0d177c6cc8744abd86", + "sha256:ccb949252cb2ab3a08c02024acb77cfb179492d5701c7cbdbfd776124d4d2367", + "sha256:d4966ef5848d820776f5f562a7d45fdd70c2f330c961d0d745b784034bd9f48d", + "sha256:e415e3f62c8d124ee16018e491a009937f8cf7ebf5eb430ffc5de21b900dad93", + "sha256:ed2937d286e2ad0cc79a7087d3c272832865f779430e0cc2b4f3718d3159b0cb", + "sha256:f1152ac548bd5b8bcecfb0b0371f082037e47128653df2e8ba6e914d384f3c3e", + "sha256:f9f8b450ed0547e3d473fdc8612083fd08dd2120d6ac8f73828df9b7d45bb351" + ], + "markers": "python_version >= '3.6'", + "version": "==21.2.0" + }, + "certifi": { + "hashes": [ + "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", + "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" + ], + "version": "==2021.10.8" + }, + "cffi": { + "hashes": [ + "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3", + "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2", + "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636", + "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20", + "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728", + "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27", + "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66", + "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443", + "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0", + "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7", + "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39", + "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605", + "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a", + "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37", + "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029", + "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139", + "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc", + "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df", + "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14", + "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880", + "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2", + "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a", + "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e", + "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474", + "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024", + "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8", + "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0", + "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e", + "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a", + "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e", + "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032", + "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6", + "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e", + "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b", + "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e", + "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954", + "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962", + "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c", + "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4", + "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55", + "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962", + "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023", + "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c", + "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6", + "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8", + "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382", + "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7", + "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc", + "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997", + "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796" + ], + "version": "==1.15.0" + }, + "charset-normalizer": { + "hashes": [ + "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597", + "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df" + ], + "markers": "python_version >= '3'", + "version": "==2.0.12" + }, + "colour": { + "hashes": [ + "sha256:33f6db9d564fadc16e59921a56999b79571160ce09916303d35346dddc17978c", + "sha256:af20120fefd2afede8b001fbef2ea9da70ad7d49fafdb6489025dae8745c3aee" + ], + "index": "pypi", + "version": "==0.1.5" + }, + "filelock": { + "hashes": [ + "sha256:9cd540a9352e432c7246a48fe4e8712b10acb1df2ad1f30e8c070b82ae1fed85", + "sha256:f8314284bfffbdcfa0ff3d7992b023d4c628ced6feb957351d4c48d059f56bc0" + ], + "markers": "python_version >= '3.7'", + "version": "==3.6.0" + }, + "hamlish-jinja": { + "hashes": [ + "sha256:4a694a0eb51d0ab7237a96dd7d87aab478bcbdd112b9601097f9731ed7bd173c" + ], + "index": "pypi", + "version": "==0.3.3" + }, + "http-router": { + "hashes": [ + "sha256:0a754392dff0169489daebbc6dc521a452347dff4b56aeea631087dafd8d2223", + "sha256:0bf70959e6fe412b58d010af07310f4d703af27df23b2a13caaf7c69e5499d86", + "sha256:0eb96e9148ab1500f825e6f6b40c20571dcbb7ac7846e52414305b24fb9f6779", + "sha256:3a4cc60da8fe811efb8a5cfc0e55ee205b7a0b08c48074c6f05afc87f6bca6c0", + "sha256:3a675ca74f3c5b2e8532d4a3a5f9441eded46313776be17595e7e53bb9c3bb2f", + "sha256:3d8ba7209051c03b6e78e304774966fc0177850a0f836f307ab79174816f15a5", + "sha256:508ecb308bae9a9f42cf4a48245fae027aaf826211808c9da0fca6c851f15e3d", + "sha256:74e2cf082d7b3bb5c9837b0c583837e6accd77f2ec1fe69aa0a90751c546a123", + "sha256:814694ca90adb79793ae8822d5ab6bf39631f3e35526dafd1f54c8ecfa26c54f", + "sha256:8d4d635aacbc8d110a0ee6331ca5feaab27e77ef1a6841a2c994fe49f8a18458", + "sha256:91d6615d25d28420d8e701d14e50fc8c5e2e6ca05dc609f896852304f49f3dac", + "sha256:9c0e9e324c26ffbac83cc588a6141bca31e875ef9558a273109d4e159684b380", + "sha256:a03ad3addba310f9ea5a0a52fb852e27a19dabff95419e1661a93b56d70c0d51", + "sha256:c37a415951532c3db79e69412bd290c9dafa19d2aad4180c24eaa114a45604b3", + "sha256:cb6cc3da118eb4a41e566632d5dc5860fbbd9b58bf836fb1c9ceec19fffa35fa", + "sha256:dd1a72901b30dd65b72556e291b992735784730e8802b0e943e0aafea110bf80", + "sha256:dd2c513e3fd385a3b15a6ff3fd59f15890bccfa41746c5b305d898e5a66e6d0e", + "sha256:e531cc214c63bb640a0cd03c73c83601d5ee69ffc64f763bd905b024b9b7c3eb", + "sha256:efe4280a999d2d7b68062a085e3d12a73b6a6551f1da546a4ac3bcdc793eae0b", + "sha256:fb7455d318f43abe26a525d7963cdacb9b0763b5e9fdd04054af2d2e2b60dc73" + ], + "index": "pypi", + "version": "==2.6.5" + }, + "idna": { + "hashes": [ + "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", + "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" + ], + "markers": "python_version >= '3.5'", + "version": "==3.3" + }, + "importlib-metadata": { + "hashes": [ + "sha256:175f4ee440a0317f6e8d81b7f8d4869f93316170a65ad2b007d2929186c8052c", + "sha256:e0bc84ff355328a4adfc5240c4f211e0ab386f80aa640d1b11f0618a1d282094" + ], + "markers": "python_version < '3.10'", + "version": "==4.11.1" + }, + "jinja2": { + "hashes": [ + "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8", + "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7" + ], + "index": "pypi", + "version": "==3.0.3" + }, + "markdown": { + "hashes": [ + "sha256:76df8ae32294ec39dcf89340382882dfa12975f87f45c3ed1ecdb1e8cefc7006", + "sha256:9923332318f843411e9932237530df53162e29dc7a4e2b91e35764583c46c9a3" + ], + "index": "pypi", + "version": "==3.3.6" + }, + "markupsafe": { + "hashes": [ + "sha256:023af8c54fe63530545f70dd2a2a7eed18d07a9a77b94e8bf1e2ff7f252db9a3", + "sha256:09c86c9643cceb1d87ca08cdc30160d1b7ab49a8a21564868921959bd16441b8", + "sha256:142119fb14a1ef6d758912b25c4e803c3ff66920635c44078666fe7cc3f8f759", + "sha256:1d1fb9b2eec3c9714dd936860850300b51dbaa37404209c8d4cb66547884b7ed", + "sha256:204730fd5fe2fe3b1e9ccadb2bd18ba8712b111dcabce185af0b3b5285a7c989", + "sha256:24c3be29abb6b34052fd26fc7a8e0a49b1ee9d282e3665e8ad09a0a68faee5b3", + "sha256:290b02bab3c9e216da57c1d11d2ba73a9f73a614bbdcc027d299a60cdfabb11a", + "sha256:3028252424c72b2602a323f70fbf50aa80a5d3aa616ea6add4ba21ae9cc9da4c", + "sha256:30c653fde75a6e5eb814d2a0a89378f83d1d3f502ab710904ee585c38888816c", + "sha256:3cace1837bc84e63b3fd2dfce37f08f8c18aeb81ef5cf6bb9b51f625cb4e6cd8", + "sha256:4056f752015dfa9828dce3140dbadd543b555afb3252507348c493def166d454", + "sha256:454ffc1cbb75227d15667c09f164a0099159da0c1f3d2636aa648f12675491ad", + "sha256:598b65d74615c021423bd45c2bc5e9b59539c875a9bdb7e5f2a6b92dfcfc268d", + "sha256:599941da468f2cf22bf90a84f6e2a65524e87be2fce844f96f2dd9a6c9d1e635", + "sha256:5ddea4c352a488b5e1069069f2f501006b1a4362cb906bee9a193ef1245a7a61", + "sha256:62c0285e91414f5c8f621a17b69fc0088394ccdaa961ef469e833dbff64bd5ea", + "sha256:679cbb78914ab212c49c67ba2c7396dc599a8479de51b9a87b174700abd9ea49", + "sha256:6e104c0c2b4cd765b4e83909cde7ec61a1e313f8a75775897db321450e928cce", + "sha256:736895a020e31b428b3382a7887bfea96102c529530299f426bf2e636aacec9e", + "sha256:75bb36f134883fdbe13d8e63b8675f5f12b80bb6627f7714c7d6c5becf22719f", + "sha256:7d2f5d97fcbd004c03df8d8fe2b973fe2b14e7bfeb2cfa012eaa8759ce9a762f", + "sha256:80beaf63ddfbc64a0452b841d8036ca0611e049650e20afcb882f5d3c266d65f", + "sha256:84ad5e29bf8bab3ad70fd707d3c05524862bddc54dc040982b0dbcff36481de7", + "sha256:8da5924cb1f9064589767b0f3fc39d03e3d0fb5aa29e0cb21d43106519bd624a", + "sha256:961eb86e5be7d0973789f30ebcf6caab60b844203f4396ece27310295a6082c7", + "sha256:96de1932237abe0a13ba68b63e94113678c379dca45afa040a17b6e1ad7ed076", + "sha256:a0a0abef2ca47b33fb615b491ce31b055ef2430de52c5b3fb19a4042dbc5cadb", + "sha256:b2a5a856019d2833c56a3dcac1b80fe795c95f401818ea963594b345929dffa7", + "sha256:b8811d48078d1cf2a6863dafb896e68406c5f513048451cd2ded0473133473c7", + "sha256:c532d5ab79be0199fa2658e24a02fce8542df196e60665dd322409a03db6a52c", + "sha256:d3b64c65328cb4cd252c94f83e66e3d7acf8891e60ebf588d7b493a55a1dbf26", + "sha256:d4e702eea4a2903441f2735799d217f4ac1b55f7d8ad96ab7d4e25417cb0827c", + "sha256:d5653619b3eb5cbd35bfba3c12d575db2a74d15e0e1c08bf1db788069d410ce8", + "sha256:d66624f04de4af8bbf1c7f21cc06649c1c69a7f84109179add573ce35e46d448", + "sha256:e67ec74fada3841b8c5f4c4f197bea916025cb9aa3fe5abf7d52b655d042f956", + "sha256:e6f7f3f41faffaea6596da86ecc2389672fa949bd035251eab26dc6697451d05", + "sha256:f02cf7221d5cd915d7fa58ab64f7ee6dd0f6cddbb48683debf5d04ae9b1c2cc1", + "sha256:f0eddfcabd6936558ec020130f932d479930581171368fd728efcfb6ef0dd357", + "sha256:fabbe18087c3d33c5824cb145ffca52eccd053061df1d79d4b66dafa5ad2a5ea", + "sha256:fc3150f85e2dbcf99e65238c842d1cfe69d3e7649b19864c1cc043213d9cd730" + ], + "markers": "python_version >= '3.7'", + "version": "==2.1.0" + }, + "pycparser": { + "hashes": [ + "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", + "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" + ], + "version": "==2.21" + }, + "pycryptodome": { + "hashes": [ + "sha256:028dcbf62d128b4335b61c9fbb7dd8c376594db607ef36d5721ee659719935d5", + "sha256:12ef157eb1e01a157ca43eda275fa68f8db0dd2792bc4fe00479ab8f0e6ae075", + "sha256:2562de213960693b6d657098505fd4493c45f3429304da67efcbeb61f0edfe89", + "sha256:27e92c1293afcb8d2639baf7eb43f4baada86e4de0f1fb22312bfc989b95dae2", + "sha256:36e3242c4792e54ed906c53f5d840712793dc68b726ec6baefd8d978c5282d30", + "sha256:50a5346af703330944bea503106cd50c9c2212174cfcb9939db4deb5305a8367", + "sha256:53dedbd2a6a0b02924718b520a723e88bcf22e37076191eb9b91b79934fb2192", + "sha256:69f05aaa90c99ac2f2af72d8d7f185f729721ad7c4be89e9e3d0ab101b0ee875", + "sha256:75a3a364fee153e77ed889c957f6f94ec6d234b82e7195b117180dcc9fc16f96", + "sha256:766a8e9832128c70012e0c2b263049506cbf334fb21ff7224e2704102b6ef59e", + "sha256:7fb90a5000cc9c9ff34b4d99f7f039e9c3477700e309ff234eafca7b7471afc0", + "sha256:893f32210de74b9f8ac869ed66c97d04e7d351182d6d39ebd3b36d3db8bda65d", + "sha256:8b5c28058102e2974b9868d72ae5144128485d466ba8739abd674b77971454cc", + "sha256:924b6aad5386fb54f2645f22658cb0398b1f25bc1e714a6d1522c75d527deaa5", + "sha256:9924248d6920b59c260adcae3ee231cd5af404ac706ad30aa4cd87051bf09c50", + "sha256:9ec761a35dbac4a99dcbc5cd557e6e57432ddf3e17af8c3c86b44af9da0189c0", + "sha256:a36ab51674b014ba03da7f98b675fcb8eabd709a2d8e18219f784aba2db73b72", + "sha256:aae395f79fa549fb1f6e3dc85cf277f0351e15a22e6547250056c7f0c990d6a5", + "sha256:c880a98376939165b7dc504559f60abe234b99e294523a273847f9e7756f4132", + "sha256:ce7a875694cd6ccd8682017a7c06c6483600f151d8916f2b25cf7a439e600263", + "sha256:d1b7739b68a032ad14c5e51f7e4e1a5f92f3628bba024a2bda1f30c481fc85d8", + "sha256:dcd65355acba9a1d0fc9b923875da35ed50506e339b35436277703d7ace3e222", + "sha256:e04e40a7f8c1669195536a37979dd87da2c32dbdc73d6fe35f0077b0c17c803b", + "sha256:e0c04c41e9ade19fbc0eff6aacea40b831bfcb2c91c266137bcdfd0d7b2f33ba", + "sha256:e24d4ec4b029611359566c52f31af45c5aecde7ef90bf8f31620fd44c438efe7", + "sha256:e64738207a02a83590df35f59d708bf1e7ea0d6adce712a777be2967e5f7043c", + "sha256:ea56a35fd0d13121417d39a83f291017551fa2c62d6daa6b04af6ece7ed30d84", + "sha256:f2772af1c3ef8025c85335f8b828d0193fa1e43256621f613280e2c81bfad423", + "sha256:f403a3e297a59d94121cb3ee4b1cf41f844332940a62d71f9e4a009cc3533493", + "sha256:f572a3ff7b6029dd9b904d6be4e0ce9e309dcb847b03e3ac8698d9d23bb36525" + ], + "index": "pypi", + "version": "==3.14.1" + }, + "python-magic": { + "hashes": [ + "sha256:1a2c81e8f395c744536369790bd75094665e9644110a6623bcc3bbea30f03973", + "sha256:21f5f542aa0330f5c8a64442528542f6215c8e18d2466b399b0d9d39356d83fc" + ], + "index": "pypi", + "version": "==0.4.25" + }, + "requests": { + "hashes": [ + "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61", + "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==2.27.1" + }, + "requests-file": { + "hashes": [ + "sha256:07d74208d3389d01c38ab89ef403af0cfec63957d53a0081d8eca738d0247d8e", + "sha256:dfe5dae75c12481f68ba353183c53a65e6044c923e64c24b2209f6c7570ca953" + ], + "version": "==1.5.1" + }, + "six": { + "hashes": [ + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.16.0" + }, + "tldextract": { + "hashes": [ + "sha256:d2034c3558651f7d8fdadea83fb681050b2d662dc67a00d950326dc902029444", + "sha256:f55e05f6bf4cc952a87d13594386d32ad2dd265630a8bdfc3df03bd60425c6b0" + ], + "index": "pypi", + "version": "==3.1.2" + }, + "urllib3": { + "hashes": [ + "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed", + "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "version": "==1.26.8" + }, + "zipp": { + "hashes": [ + "sha256:9f50f446828eb9d45b267433fd3e9da8d801f614129124863f9c51ebceafb87d", + "sha256:b47250dd24f92b7dd6a0a8fc5244da14608f3ca90a5efcd37a3b1642fac9a375" + ], + "markers": "python_version >= '3.7'", + "version": "==3.7.0" + } + }, + "develop": {} +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..989f379 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# Barkshark Async HTTP + +A simple HTTP client and server framework based on the built-in asyncio module. + +# NOTE + +Not in a stable state yet. Expect major changes. diff --git a/barkshark_http_async/__init__.py b/barkshark_http_async/__init__.py new file mode 100644 index 0000000..0a0618a --- /dev/null +++ b/barkshark_http_async/__init__.py @@ -0,0 +1,7 @@ +__software__ = 'Barkshark Async HTTP' +__version__ = '0.0.1' + +from . import client + +try: from . import server +except ImportError: pass diff --git a/barkshark_http_async/activitypub.py b/barkshark_http_async/activitypub.py new file mode 100644 index 0000000..346ceb6 --- /dev/null +++ b/barkshark_http_async/activitypub.py @@ -0,0 +1,771 @@ +import json, mimetypes, traceback + +from datetime import datetime, timezone +from functools import partial +from typing import Union +from xml.etree.ElementTree import fromstring + +from .dotdict import DotDict +from .misc import DateString, Url, boolean + + +pubstr = 'https://www.w3.org/ns/activitystreams#Public' +actor_types = ['Application', 'Group', 'Organization', 'Person', 'Service'] +activity_types = [ + 'Accept', 'Add', 'Announce', 'Arrive', 'Block', 'Create', 'Delete', 'Dislike', + 'Flag', 'Follow', 'Ignore', 'Invite', 'Join', 'Leave', 'Like', 'Listen', + 'Move', 'Offer', 'Question', 'Reject', 'Read', 'Remove', 'TentativeAccept', + 'TentativeReject', 'Travel', 'Undo', 'Update', 'View' + +] + +link_types = ['Mention'] +object_types = [ + 'Article', 'Audio', 'Document', 'Event', 'Image', 'Note', 'Page', 'Place', + 'Profile', 'Relationship', 'Tombstone', 'Video' +] + +url_keys = [ + 'attributedTo', 'url', 'href', 'object', 'id', 'actor', 'partOf', 'target' +] + + +def parse_privacy_level(to: list=[], cc: list=[], followers=None): + if pubstr in to and followers in cc: + return 'public' + + elif followers in to and pubstr in cc: + return 'unlisted' + + elif pubstr not in to and pubstr not in cc and followers in cc: + return 'private' + + elif not tuple(item for item in [*to, *cc] if item not in [pubstr, followers]): + return 'direct' + + else: + logging.warning('Not sure what this privacy level is') + logging.debug(f'to: {json.dumps(to)}') + logging.debug(f'cc: {json.dumps(cc)}') + logging.debug(f'followers: {followers}') + + +def generate_privacy_fields(privacy='public', followers=None, to=[], cc=[]): + if privacy == 'public': + to = [pubstr, *to] + cc = [followers, *to] + + elif privacy == 'unlisted': + to = [followers, *to] + cc = [pubstr, *to] + + elif privacy == 'private': + cc = [followers, *cc] + + elif privacy == 'direct': + pass + + else: + raise ValueError(f'Unknown privacy level: {privacy}') + + return to, cc + +class Object(DotDict): + def __setitem__(self, key, value): + if type(key) == str and key in url_keys: + value = Url(value) + + elif key == 'object' and isinstance(key, dict): + value = Object(value) + + super().__setitem__(key, value) + + + @classmethod + def new_activity(cls, id: str, type: str, actor_src: Union[str, dict], object: Union[str, dict], to: list=[pubstr], cc: list=[]): + assert type in activity_types + + activity = cls({ + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': id, + 'object': object, + 'type': type, + 'actor': actor_src + }) + + if to: + activity.to = to + + if cc: + activity.cc = cc + + return activity + + + @classmethod + def new_note(cls, id, url, actor, content, **kwargs): + assert False not in map(isinstance, [id, actor, url], [Url]) + + if kwargs.get('date'): + date = DateString.from_datetime(kwargs['date'], 'activitypub') + + else: + date = DateString.now('activitypub') + + return cls({ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + #"votersCount": "toot:votersCount", + #"litepub": "http://litepub.social/ns#", + #"directMessage": "litepub:directMessage" + } + ], + "id": id, + "type": "Note", + "summary": kwargs.get('summary'), + #"inReplyTo": kwargs.get('replyto'), + "published": date, + "url": url, + "attributedTo": actor, + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + f'{actor}/followers' + ], + "sensitive": kwargs.get('sensitive', False), + "content": f'{content}', + #"contentMap": { + #"en": content + #}, + #"attachment": [], + #"tag": [], + #"replies": { + #"id": f"{id}/replies", + #"type": "Collection", + #"first": { + #"type": "CollectionPage", + #"next": f"{id}/replies?only_other_accounts=true&page=true", + #"partOf": f"{id}/replies", + #"items": [] + #} + #} + }) + + + @classmethod + def new_actor(cls, actor, handle, pubkey, published=None, table={}, full=True, **kwargs): + actor_type = kwargs.get('type', 'Person').title() + + assert actor_type in actor_types + + actor = Url(actor) + data = cls({ + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + { + 'schema': 'http://schema.org', + 'toot': 'https://joinmastodon.org/ns#', + #'Device': 'toot:Device', + #'Ed25519Signature': 'toot:Ed25519Signature', + #'Ed25519Key': 'toot:Ed25519Key', + #'Curve25519Key': 'toot:Curve25519Key', + #'EncryptedMessage': 'toot:EncryptedMessage', + #'publicKeyBase64': 'toot:publicKeyBase64', + #'deviceId': 'toot:deviceId', + #'messageFranking': 'toot:messageFranking', + 'messageType': 'toot:messageType', + #'cipherText': 'toot:cipherText', + #'suspended': 'toot:suspended', + "claim": { + "@type": "@id", + "@id": "toot:claim" + } + } + ], + 'id': actor, + 'type': actor_type, + 'inbox': kwargs.get('inbox', f'{actor}'), + 'outbox': f'{actor}/outbox', + 'preferredUsername': handle, + 'url': kwargs.get('url', actor), + 'manuallyApprovesFollowers': kwargs.get('locked', False), + 'discoverable': kwargs.get('discoverable', False), + 'published': published or DateString.now('activitypub'), + 'publicKey': { + 'id': f'{actor}#main-key', + 'owner': actor, + 'publicKeyPem': pubkey + }, + 'endpoints': { + 'sharedInbox': kwargs.get('shared_inbox', f'https://{actor.host}/inbox') + } + }) + + for key, value in table.items(): + data.attachment.append(PropertyValue(key, value)) + + if kwargs.get('avatar_url'): + data.icon = Object.new_image(kwargs.get('avatar_url'), kwargs.get('avatar_type')) + + if full: + data.update({ + 'name': kwargs.get('display_name', handle), + 'summary': kwargs.get('bio'), + 'featured': f'{actor}/collections/featured', + 'tags': f'{actor}/collections/tags', + 'following': f'{actor}/following', + 'followers': f'{actor}/followers', + 'tag': [], + 'attachment': [], + }) + + data['@context'][2].update({ + 'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers', + 'discoverable': 'toot:discoverable', + 'PropertyValue': 'schema:PropertyValue', + 'value': 'schema:value', + 'Emoji': 'toot:Emoji', + "featured": { + "@id": "toot:featured", + "@type": "@id" + }, + "featuredTags": { + "@id": "toot:featuredTags", + "@type": "@id" + }, + "alsoKnownAs": { + "@id": "as:alsoKnownAs", + "@type": "@id" + }, + "movedTo": { + "@id": "as:movedTo", + "@type": "@id" + }, + "claim": { + "@type": "@id", + "@id": "toot:claim" + }, + "focalPoint": { + "@container": "@list", + "@id": "toot:focalPoint" + } + }) + + return data + + + # not complete + @classmethod + def new_actor_old(cls, actor, handle, pubkey, published=None, table={}, full=True, **kwargs): + actor_type = kwargs.get('type', 'Person').title() + + assert actor_type in actor_types + + actor = Url(actor) + data = cls({ + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + { + 'schema': 'http://schema.org', + 'toot': 'https://joinmastodon.org/ns#', + 'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers', + 'PropertyValue': 'schema:PropertyValue', + 'value': 'schema:value', + 'IdentityProof': 'toot:IdentityProof', + 'discoverable': 'toot:discoverable', + 'Device': 'toot:Device', + 'Ed25519Signature': 'toot:Ed25519Signature', + 'Ed25519Key': 'toot:Ed25519Key', + 'Curve25519Key': 'toot:Curve25519Key', + 'EncryptedMessage': 'toot:EncryptedMessage', + 'publicKeyBase64': 'toot:publicKeyBase64', + 'deviceId': 'toot:deviceId', + 'messageFranking': 'toot:messageFranking', + 'messageType': 'toot:messageType', + 'cipherText': 'toot:cipherText', + 'suspended': 'toot:suspended', + 'Emoji': 'toot:Emoji', + "featured": { + "@id": "toot:featured", + "@type": "@id" + }, + "featuredTags": { + "@id": "toot:featuredTags", + "@type": "@id" + }, + "alsoKnownAs": { + "@id": "as:alsoKnownAs", + "@type": "@id" + }, + "movedTo": { + "@id": "as:movedTo", + "@type": "@id" + }, + "claim": { + "@type": "@id", + "@id": "toot:claim" + }, + "fingerprintKey": { + "@type": "@id", + "@id": "toot:fingerprintKey" + }, + "identityKey": { + "@type": "@id", + "@id": "toot:identityKey" + }, + "devices": { + "@type": "@id", + "@id": "toot:devices" + }, + "focalPoint": { + "@container": "@list", + "@id": "toot:focalPoint" + } + } + ], + 'id': actor, + 'type': actor_type, + 'following': f'{actor}/following', + 'followers': f'{actor}/followers', + 'inbox': kwargs.get('inbox', f'{actor}'), + 'outbox': f'{actor}/outbox', + 'featured': f'{actor}/collections/featured', + 'featuredTags': f'{actor}/collections/tags', + 'preferredUsername': handle, + 'name': kwargs.get('display_name', handle), + 'summary': kwargs.get('bio'), + 'url': kwargs.get('url', actor), + 'manuallyApprovesFollowers': kwargs.get('locked', False), + 'discoverable': kwargs.get('discoverable', False), + 'published': published or DateString.now('activitypub'), + 'devices': f'{actor}/collections/devices', + 'publicKey': { + 'id': f'{actor}#main-key', + 'owner': actor, + 'publicKeyPem': pubkey + }, + 'tag': [], + 'attachment': [], + 'endpoints': { + 'sharedInbox': kwargs.get('shared_inbox', f'https://{actor.host}/inbox') + } + }) + + for key, value in table.items(): + data.attachment.append(PropertyValue(key, value)) + + if kwargs.get('avatar_url'): + data.icon = Object.new_image(kwargs.get('avatar_url'), kwargs.get('avatar_type')) + + if kwargs.get('header_url'): + data.image = Object.new_image(kwargs.get('header_url'), kwargs.get('header_type')) + + # need to add data when "full" is true + if not full: + del data.featured + del data.featuredTags + del data.devices + del data.following + del data.followers + del data.outbox + + return data + + + @classmethod + def new_follow(cls, id, actor, target): + return cls({ + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': id, + 'type': 'Follow', + 'actor': actor, + 'object': target + }) + + + @classmethod + def new_emoji(cls, id, name, url, image): + return cls({ + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': id, + 'type': 'Emoji', + 'name': name, + 'updated': updated or DateTime.now('activitypub'), + 'icon': image + }) + + + @property + def privacy_level(self): + return parse_privacy_level( + self.get('to', []), + self.get('cc', []), + self.get('attributedTo', '') + '/followers' + ) + + + @property + def shared_inbox(self): + try: return self.endpoints.shared_inbox + except AttributeError: pass + + + @property + def pubkey(self): + try: return self.publicKey.publicKeyPem + except AttributeError: pass + + + @property + def handle(self): + return self['preferredUsername'] + + + @property + def display_name(self): + return self.get('name') + + + @property + def type(self): + return self['type'].capitalize() + + + @property + def info_table(self): + return DotDict({p['name']: p['value'] for p in self.get('attachment', {})}) + + + @property + def domain(self): + return self.id.host + + + @property + def bio(self): + return self.get('summary') + + + @property + def avatar(self): + return self.icon.url + + + @property + def header(self): + return self.image.url + + +class Collection(Object): + @classmethod + def new_replies(cls, statusid): + return cls({ + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': f'{statusid}/replies', + 'type': 'Collection', + 'first': { + 'type': 'CollectionPage', + 'next': f'{statusid}/replies?only_other_accounts=true&page=true', + 'partOf': f'{statusid}/replies', + 'items': [] + } + }) + + + @classmethod + def new_collection(cls, outbox, min_id=0, total=0): + return cls({ + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': outbox, + 'type': 'OrderedCollection', + 'totalItems': total, + 'first': f'{outbox}?page=true', + 'last': f'{outbox}?min_id=0&page=true' + }) + + + @classmethod + def new_page(cls, outbox, min_id, max_id, *items): + return cls({ + '@context': [ + 'https://www.w3.org/ns/activitystreams', + { + 'sensitive': 'as:sensitive', + 'toot': 'http://joinmastodon.org/ns#', + 'votersCount': 'toot:votersCount', + 'litepub': 'http://litepub.social/ns#', + 'directMessage': 'litepub:directMessage', + } + ], + 'id': f'{outbox}?page=true', + 'type': 'OrderedCollectionPage', + 'next': f'{outbox}?max_id={max_id}&page=true', + 'prev': f'{outbox}?min_id={min_id}&page=true', + 'partOf': outbox, + 'orderedItems': items + }) + + +### sub-objects ### + +class PropertyValue(DotDict): + def __init__(self, key, value): + super().__init__({ + 'type': 'PropertyValue', + 'name': key, + 'value': value + }) + + + def __setitem__(self, key, value): + key = key.lower() + + assert key in ['type', 'name', 'value'] + assert type(value) == str + + super().__setitem__(key, value) + + + def set_pair(self, key, value): + self.name = key + self.value = value + + +class Media(Object): + @classmethod + def new(cls, type, url, mime=None): + return cls( + type = 'Image', + mediaType = mime or mimetypes.guess_type(url)[0] or 'image/png', + url = url + ) + + + @classmethod + def new_image(cls, url, mime=None): + return cls.new('Image', url, mime) + + + @classmethod + def new_video(cls, url, mime=None): + return cls.new('Video', url, mime) + + + @classmethod + def new_audio(cls, url, mime=None): + return cls.new('Audio', url, mime) + + +class Emoji(DotDict): + @classmethod + def new(cls, id, name, image): + return cls({ + 'id': id, + 'type': Emoji, + 'name': f':{name}:', + 'icon': image + }) + + +### Not activitypub objects, but related ### + +class Nodeinfo(DotDict): + @property + def name(self): + return self.software.name + + + @property + def version(self): + return self.software.version + + + @property + def repo(self): + return self.software.repository + + + @property + def homepage(self): + return self.software.homepage + + + @property + def users(self): + return self.usage.users.total + + + @property + def posts(self): + return self.usage.localPosts + + + @classmethod + def new_20(cls, name, version, **metadata): + return cls.new(name, version, '2.0', **metadata) + + + @classmethod + def new_21(cls, name, version, **metadata): + return cls.new(name, version, '2.1', **metadata) + + + @classmethod + def new(cls, name, version, niversion='2.1', **kwargs): + assert niversion in ['2.0', '2.1'] + + open_regs = boolean(kwargs.pop('open_regs', True)) + posts = int(kwargs.pop('posts', 0)) + users = int(kwargs.pop('users', 0)) + users_halfyear = int(kwargs.pop('halfyear', 0)) + users_month = int(kwargs.pop('month', 0)) + comments = int(kwargs.pop('comments', 0)) + repository = kwargs.pop('repository', None) + homepage = kwargs.pop('homepage', None) + + data = cls( + version = niversion, + openRegistrations = open_regs, + software = { + 'name': name.lower().replace(' ', '-'), + 'version': version + }, + usage = { + 'users': { + 'total': users + } + }, + protocols = [ + 'activitypub' + ], + services = { + 'inbound': kwargs.pop('inbound', []), + 'outbound': kwargs.pop('outbound', []) + } + ) + + if niversion == '2.1': + if repository: + data.software.repository = repository + + if homepage: + data.software.homepage = homepage + + if users_halfyear: + data.users.activeHalfyear = halfyear + + if users_month: + data.users.activeMonth = month + + if posts: + data.usage.localPosts = posts + + if comments: + data.usage.localComments = comments + + if kwargs: + data.metadata = kwargs + + return data + + +class WellknownNodeinfo(DotDict): + def url(self, version='2.1'): + assert version in ['2.0', '2.1'] + + for link in self.links: + if link['rel'].endswith(version): + return link['href'] + + + @classmethod + def new(cls, path, version='2.1'): + data = cls(links=[]) + data.append(path, version) + + return data + + + def append(self, path, version='2.1'): + assert version in ['2.0', '2.1'] + + self.links.append({ + 'rel': f'http://nodeinfo.dispora.software/ns/schema/{version}', + 'href': path + }) + + +class Hostmeta(str): + def __new__(cls, text): + return str.__new__(cls, text) + + + @classmethod + def new(cls, domain): + return cls(f'') + + + @property + def link(self): + return Url(fromstring(self)[0].attrib['template']) + + +class Webfinger(DotDict): + @property + def profile(self): + for link in self.links: + if link['rel'] == 'http://webfinger.net/rel/profile-page': + return link['href'] + + + @property + def actor(self): + for link in self.links: + if link['rel'] == 'self': + return link['href'] + + + @property + def fullname(self): + return self.subject[5:] + + + @property + def handle(self): + return self.fullname.split('@')[0] + + + @property + def domain(self): + return self.fullname.split('@')[1] + + + @classmethod + def new(cls, handle, domain, actor, profile=None): + data = cls( + subject = f'acct:{handle}@{domain}', + aliases = [actor], + links = [ + { + 'rel': 'self', + 'type': 'application/activity+json', + 'href': actor + } + ] + ) + + if profile: + data.aliases.append(profile) + data.links.append({ + 'rel': 'http://webfinger.net/rel/profile-page', + 'type': 'text/html', + 'href': profile + }) + + return data diff --git a/barkshark_http_async/client/__init__.py b/barkshark_http_async/client/__init__.py new file mode 100644 index 0000000..97ed8fa --- /dev/null +++ b/barkshark_http_async/client/__init__.py @@ -0,0 +1,5 @@ +__version__ = '0.1.0' + +from .client import Client +from .request import Request +from .response import Response diff --git a/barkshark_http_async/client/client.py b/barkshark_http_async/client/client.py new file mode 100644 index 0000000..1c85541 --- /dev/null +++ b/barkshark_http_async/client/client.py @@ -0,0 +1,67 @@ +import asyncio + +from functools import partial + +from .config import Config + +from .. import __version__ +from ..http_utils import methods + + +class Client: + def __init__(self, loop=None, **kwargs): + self.cfg = Config(**kwargs) + + if not loop: + try: + self.loop = asyncio.get_event_loop() + + except: + self.loop = asyncio.new_event_loop() + + else: + self.loop = loop + + for method in methods: + setattr(self, method.lower(), partial(self.request, method=method)) + + + async def request(self, *args, **kwargs): + request = self.create_request(*args, **kwargs) + + return await self.send_request(request) + + + async def send_request(self, request): + connection = asyncio.open_connection(request.host, request.port, ssl=request.secure) + reader, writer = await asyncio.wait_for(connection, timeout=self.cfg.timeout) + + transport = self.cfg.transport_class(reader, writer, self.cfg.timeout) + + for key, value in self.cfg.headers.items(): + request.headers.setall(key, value) + + await transport.write(request.compile()) + + data = await transport.read_headers(request=False) + return self.cfg.response_class(transport, *data) + + + def create_request(self, url, body=b'', headers={}, cookies={}, method='GET', privkey=None, keyid=None): + if (keyid and not privkey) or (not keyid and privkey): + raise ValueError('Please provide a keyid and a privkey, not either') + + request = self.cfg.request_class(url, body, headers, cookies, method) + + if keyid and privkey: + request.sign_headers(privkey, key_id) + + return request + + + def run_request(self, request): + return self.loop.run_until_complete(self.send_request(request)) + + + def read_body(self, response): + return self.loop.run_until_complete(response.read()) diff --git a/barkshark_http_async/client/config.py b/barkshark_http_async/client/config.py new file mode 100644 index 0000000..5a7e8d8 --- /dev/null +++ b/barkshark_http_async/client/config.py @@ -0,0 +1,38 @@ +from izzylib import ( + BaseConfig, + LowerdotDict +) + +from .request import Request +from .response import Response + +from .. import __version__ +from ..http_utils import AsyncTransport + + +class Config(BaseConfig): + def __init__(self, **kwargs): + super().__init__( + headers = LowerDotDict({'User-Agent': f'IzzyLib/{__version__}'}), + request_class = Request, + response_class = Response, + transport_class = AsyncTransport, + timeout = 60 + ) + + useragent = kwargs.pop('useragent', None) + appagent = kwargs.pop('appagent', None) + + if useragent: + self.headers['User-Agent'] = useragent + appagent = None + + if appagent: + self.headers['User-Agent'] = f'IzzyLib/{__version__} ({appagent})' + + self.update(kwargs) + + + @property + def agent(self): + return self.headers['user-agent'] diff --git a/barkshark_http_async/client/request.py b/barkshark_http_async/client/request.py new file mode 100644 index 0000000..fe33af4 --- /dev/null +++ b/barkshark_http_async/client/request.py @@ -0,0 +1,85 @@ +import json + +from datetime import datetime +from izzylib import url + +from ..http_utils import ( + Headers, + Cookies, + convert_to_bytes, + create_message, + first_line, + methods, + ports +) + +try: from ..http_signatures import sign_request +except ImportError: sign_request = None + + +class Request: + def __init__(self, url, body=None, headers={}, cookies={}, method='GET'): + method = method.upper() + + if method not in methods: + raise ValueError(f'Invalid HTTP method: {method}') + + self._body = b'' + + self.version = 1.1 + self.url = Url(url) + self.headers = Headers(headers) + self.cookies = Cookies(cookies) + self.method = method + + if self.url.proto not in ['http', 'https', 'ws', 'wss']: + raise ValueError(f'Invalid protocol in url: {self.url.proto}') + + if not self.headers.get('host'): + self.headers.host = self.host + + + @property + def body(self): + return self._body + + + @body.setter + def body(self, data): + self._body = convert_to_bytes(data) + self.headers.setall('Content-Length', str(len(self._body))) + + + @property + def host(self): + return self.url.host + + + @property + def port(self): + return self.url.port or ports[self.url.proto] + + + @property + def secure(self): + return self.url.proto in ['https', 'wss'] + + + def set_header(self, key, value): + self.headers.setall(key, value) + + + def unset_header(self, key): + self.headers.pop(key, None) + + + def sign_headers(self, privkey, keyid): + if not sign_request: + raise ImportError(f'Could not import HTTP signatures. Header signing disabled') + + return sign_request(self, privkey, keyid) + + + def compile(self): + first = first_line(method=self.method, path=self.url.path_full) + return create_message(first, self.headers, self.cookies, self.body) diff --git a/barkshark_http_async/client/response.py b/barkshark_http_async/client/response.py new file mode 100644 index 0000000..9f2d777 --- /dev/null +++ b/barkshark_http_async/client/response.py @@ -0,0 +1,60 @@ +from asyncio.exceptions import TimeoutError +from io import BytesIO +from izzylib import DotDict + +from ..http_utils import ( + Cookies, + Headers +) + +class Response: + def __init__(self, transport, status, message, version, headers, cookies): + self._body = BytesIO() + + self.transport = transport + self.version = version + self.status = status + self.message = message + self.headers = headers if type(headers) == Headers else Headers(headers) + self.cookies = cookies if type(cookies) == Cookies else Cookies(cookies) + + + @property + def type(self): + return self.headers.getone('content-type') + + + @type.setter + def type(self, data): + self.headers.setall('content-type', data) + + + @property + def length(self): + return int(self.headers.getone('content-length', 0)) + + + async def read(self, length=None): + data = await self.transport.read(length or self.length or 8192) + + if data: + self._body.write(data) + + return data + + + def body(self, encoding=None): + data = self._body.readall() + return data.decode(encoding) if encoding else data + + + def text(self, encoding='utf-8'): + return self.body(encoding) + + + def dict(self): + return DotDict(self.text()) + + + def close(self): + self._body.close() diff --git a/barkshark_http_async/exceptions.py b/barkshark_http_async/exceptions.py new file mode 100644 index 0000000..3df8b17 --- /dev/null +++ b/barkshark_http_async/exceptions.py @@ -0,0 +1,47 @@ +# todo: rename any *Exception class to *Error + +__all__ = [ + 'HttpError', + 'HttpFileDownloadedError', + 'InvalidMethodException', + 'MethodNotHandledException', + 'NoBlueprintForPath', + 'NoConnectionError', + 'MaxConnectionsError' +] + + +class HttpError(Exception): + def __init__(self, status, message): + self.status = status + self.message = message + + super().__init__(f'HTTP ERROR {status}: {message[:100]}') + + +class HttpFileDownloadedError(Exception): + 'raise when a download failed for any reason' + + +class InvalidMethodException(Exception): + def __init__(self, method): + super().__init__(f'Invalid HTTP method: {method}') + self.method = method + + +class MethodNotHandledException(Exception): + def __init__(self, method): + super().__init__(f'HTTP method not handled by handler: {method}') + self.method = method + + +class NoBlueprintForPath(Exception): + 'raise when no blueprint is found for a specific path' + + +class NoConnectionError(Exception): + 'Raise when a function requiring a connection gets called when there is no connection' + + +class MaxConnectionsError(Exception): + 'Raise when the max amount of connections has been reached' diff --git a/barkshark_http_async/frontend/base.css b/barkshark_http_async/frontend/base.css new file mode 100644 index 0000000..40454a1 --- /dev/null +++ b/barkshark_http_async/frontend/base.css @@ -0,0 +1,461 @@ +:root { + --text: #eee; + --hover: {{primary.desaturate(50).lighten(50)}}; + --primary: {{primary}}; + --background: {{background}}; + --ui: {{primary.desaturate(25).lighten(5)}}; + --ui-background: {{background.lighten(7.5)}}; + --shadow-color: {{black.rgba(25)}}; + --shadow: 0 4px 4px 0 var(--shadow-color), 3px 0 4px 0 var(--shadow-color); + + --negative: {{negative}}; + --negative-dark: {{negative.darken(85)}}; + --positive: {{positive}}; + --positive-dark: {{positive.darken(85)}}; + + --message: var(--positive); + --error: var(--negative); + --gap: 15px; + --easing: cubic-bezier(.6, .05, .28, .91); + --trans-speed: {{speed}}ms; +} + +body { + color: var(--text); + background-color: var(--background); + font-family: sans undertale; + font-size: 16px; + margin: 15px 0; +} + +a, a:visited { + color: var(--primary); + text-decoration: none; +} + +a:hover { + color: var(--hover); + text-decoration: underline; +} + +input:not([type='checkbox']), select, textarea { + color: var(--text); + background-color: var(--background); + border: 1px solid var(--background); + box-shadow: 0 2px 2px 0 var(--shadow-color); + padding: 5px; +} + +input:hover, select:hover, textarea:hover { + border-color: var(--hover); +} + +input:focus, select:focus, textarea:focus { + outline: 0; + border-color: var(--primary); +} + +details:focus, summary:focus { + outline: 0; +} + +/* Classes */ +.button { + display: inline-block; + padding: 5px; + background-color: {{primary.darken(85)}}; + text-align: center; + box-shadow: var(--shadow); +} + +.button:hover { + background-color: {{primary.darken(65)}}; + text-decoration: none; +} + +.grid-container { + display: grid; + grid-template-columns: auto; + grid-gap: var(--gap); +} + +.grid-item { + display: inline-grid; +} + +.flex-container { + display: flex; + flex-wrap; wrap; +} + +.menu { + list-style-type: none; + padding: 0; + margin: 0; +} + +.menu li { + display: inline-block; + text-align: center; + min-width: 60px; + background-color: {{background.lighten(20)}}; +} + +.menu li a { + display: block; + padding-left: 5px; + padding-right: 5px; +} + +.menu li:hover { + background-color: {{primary.lighten(25).desaturate(25)}}; +} + +.menu li a:hover { + text-decoration: none; + color: {{primary.darken(90).desaturate(50)}}; +} + +.section { + padding: 8px; + background-color: var(--ui-background); + box-shadow: var(--shadow); +} + +.shadow { + box-shadow: 0 4px 4px 0 var(--shadow-color), 3px 0 4px 0 var(--shadow-color); +} + +.message { + line-height: 2em; + display: block; +} + +/* # this is kinda hacky and needs to be replaced */ +.tooltip:hover::after { + position: relative; + padding: 8px; + bottom: 35px; + border-radius: 5px; + white-space: nowrap; + border: 1px solid var(--text); + color: var(--text); + background-color: {{primary.desaturate(50).darken(75)}}; + box-shadow: var(--shadow); + /*z-index: -1;*/ +} + + +/* ids */ +#title { + font-size: 36px; + font-weight: bold; + text-align: center; +} + +#message, #error { + padding: 10px; + color: var(--background); + margin-bottom: var(--gap); + text-align: center; +} + +#message { + background-color: var(--message); +} + +#error { + background-color: var(--error); +} + +#body { + width: 790px; + margin: 0 auto; +} + +#header { + display: flex; + margin-bottom: var(--gap); + text-align: center; + font-size: 2em; + line-height: 40px; + font-weight: bold; +} + +#header > div { + /*display: inline-block;*/ + height: 40px; +} + +#header .page-title { + text-align: {% if menu_left %}right{% else %}left{% endif %}; + white-space: nowrap; + overflow: hidden; + width: 100%; +} + +#content-body .title { + text-align: center; + font-size: 2em; + font-weight: bold; + color: var(--primary); +} + +#footer { + margin-top: var(--gap); + display: flex; + grid-gap: 5px; + font-size: 0.80em; + line-height: 20px; +} + +#footer > div { + height: 20px; +} + +#footer .avatar img { + margin: 0 auto; +} + +#footer .user { + white-space: nowrap; + overflow: hidden; + width: 100%; +} + +#footer .source { + white-space: nowrap; +} + +#logreg input, textarea { + display: block; + margin: 8px auto; +} + +#logreg textarea, input:not([type='submit']) { + width: 50%; +} + + +/* Main menu */ +#btn { + cursor: pointer; + transition: left 500ms var(--easing); +} + +#btn { + transition: background-color var(--trans-speed); + width: 55px; + margin-left: var(--gap); + background-image: url('/framework/static/menu.svg'); + background-size: 50px; + background-position: center center; + background-repeat: no-repeat; +} + +#btn div { + transition: transform var(--trans-speed) ease, opacity var(--trans-speed), background-color var(--trans-speed); +} + +#btn.active { + margin-left: 0; + position: fixed; + z-index: 5; + top: 12px; + {% if menu_left %}right: calc(100% - 250px + 12px){% else %}right: 12px{% endif %}; + background-color: {{primary.darken(75)}}; + color: {{background}}; +} + +/*#btn.active div { + width: 35px; + height: 2px; + margin-bottom: 8px; +}*/ + +#btn.active:parent { + grid-template-columns: auto; +} + + +#menu { + position: fixed; + z-index: 4; + overflow: auto; + top: 0px; + opacity: 0; + padding: 20px 0px; + width: 250px; + height: 100%; + transition: all var(--trans-speed) ease; + {% if menu_left %}left{% else %}right{% endif %}: -250px; +} + +#menu.active { + {% if menu_left %}left{% else %}right{% endif %}: 0; + opacity: 1; +} + +#menu #items { + /*margin-top: 50px;*/ + margin-bottom: 30px; +} + +#menu a:hover { + text-decoration: none; +} + +#menu { + font-weight: bold; +} + +#menu .item { + display: block; + position: relative; + font-size: 2em; + transition: all var(--trans-speed); + padding-left: 20px; +} + +#menu .title-item { + color: var(--primary); +} + +#items .sub-item { + padding-left: 40px; +} + +#items .item:not(.title-item):hover { + padding-left: 40px; +} + +#items .sub-item:hover { + padding-left: 60px !important; +} + +/*#menu details .item:hover { + padding-left: 60px; +}*/ + +#items summary { + cursor: pointer; + color: var(--primary); +} + +#items details[open]>.item:not(details) { + animation-name: fadeInDown; + animation-duration: var(--trans-speed); +} + + +#items summary::-webkit-details-marker { + display: none; +} + +#items details summary:after { + content: " +"; +} + +#items details[open] summary:after { + content: " -"; +} + + +#btn, #btn * { + will-change: transform; +} + +#menu { + will-change: transform, opacity; +} + + +@keyframes fadeInDown { + 0% { + opacity: 0; + transform: translateY(-1.25em); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + + +{% for name in cssfiles %} + {% include 'style/' + name + '.css' %} +{% endfor %} + + +/* responsive design */ +@media (max-width: 810px) { + body { + margin: 0; + } + + #body { + width: auto; + } +} + +@media (max-width: 610px) { + .settings .grid-container { + grid-template-columns: auto; + } + + .settings .label { + text-align: center; + } + + #logreg textarea, input:not([type='submit']) { + width: calc(100% - 16px); + } +} + + +/* scrollbar */ +body {scrollbar-width: 15px; scrollbar-color: var(--primary) {{background.darken(10)}};} +::-webkit-scrollbar {width: 15px;} +::-webkit-scrollbar-track {background: {{background.darken(10)}};} +/*::-webkit-scrollbar-button {background: var(--primary);} +::-webkit-scrollbar-button:hover {background: var(--text);}*/ +::-webkit-scrollbar-thumb {background: var(--primary);} +::-webkit-scrollbar-thumb:hover {background: {{primary.lighten(25)}};} + + +/* page font */ +@font-face { + font-family: 'sans undertale'; + src: local('Nunito Sans Bold'), + url('/framework/static/nunito/NunitoSans-SemiBold.woff2') format('woff2'), + url('/framework/static/nunito/NunitoSans-SemiBold.ttf') format('ttf'); + font-weight: bold; + font-style: normal; +} + +@font-face { + font-family: 'sans undertale'; + src: local('Nunito Sans Light Italic'), + url('/framework/static/nunito/NunitoSans-ExtraLightItalic.woff2') format('woff2'), + url('/framework/static/nunito/NunitoSans-ExtraLightItalic.ttf') format('ttf'); + font-weight: normal; + font-style: italic; +} + +@font-face { + font-family: 'sans undertale'; + src: local('Nunito Sans Bold Italic'), + url('/framework/static/nunito/NunitoSans-Italic.woff2') format('woff2'), + url('/framework/static/nunito/NunitoSans-Italic.ttf') format('ttf'); + font-weight: bold; + font-style: italic; +} + +@font-face { + font-family: 'sans undertale'; + src: local('Nunito Sans Light'), + url('/framework/static/nunito/NunitoSans-Light.woff2') format('woff2'), + url('/framework/static/nunito/NunitoSans-Light.ttf') format('ttf'); + font-weight: normal; + font-style: normal; +} diff --git a/barkshark_http_async/frontend/base.haml b/barkshark_http_async/frontend/base.haml new file mode 100644 index 0000000..7d9cc0a --- /dev/null +++ b/barkshark_http_async/frontend/base.haml @@ -0,0 +1,51 @@ + +%html + %head + %title << {{cfg.title}}: {{page}} + %link(rel='shortcut icon', type='image/png', href='{{cfg.tpl_favicon_path}}') + %link(rel='stylesheet' type='text/css' href='/framework/style.css') + %link(rel='manifest' href='/framework/manifest.json') + %meta(charset='UTF-8') + %meta(name='viewport' content='width=device-width, initial-scale=1') + -block head + + %body + #body + #header.flex-container + -if menu_left + #btn.section + .page-title.section -> %a.title(href='/') << {{cfg.title}} + + -else + .page-title.section -> %a.title(href='/') << {{cfg.title}} + #btn.section + + -if message + #message.section << {{message}} + + -if error + #error.secion << {{error}} + + #menu.section + .title-item.item << Menu + #items + -if not len(cfg.menu): + -include 'menu.haml' + + -else: + -for label, path_data in cfg.menu.items() + -if path_data[1] == 1000 and request.user_level == 0: + .item -> %a(href='{{path_data[0]}}') << {{label}} + -elif request.user_level >= path_data[1] + .item -> %a(href='{{path_data[0]}}') << {{label}} + + #content-body.section + -block content + + #footer.grid-container.section + .avatar + .user + .source + %a(href='{{cfg.git_repo}}' target='_new') << {{cfg.name}}/{{cfg.version}} + + %script(type='application/javascript' src='/framework/static/menu.js') diff --git a/barkshark_http_async/frontend/error.haml b/barkshark_http_async/frontend/error.haml new file mode 100644 index 0000000..5279a4f --- /dev/null +++ b/barkshark_http_async/frontend/error.haml @@ -0,0 +1,8 @@ +-extends 'base.haml' +-set page = 'Error' +-block content + %center + %font size='8' + HTTP {{response.status}} + %br + =error_message diff --git a/barkshark_http_async/frontend/menu.haml b/barkshark_http_async/frontend/menu.haml new file mode 100644 index 0000000..20d9e66 --- /dev/null +++ b/barkshark_http_async/frontend/menu.haml @@ -0,0 +1 @@ +.item -> %a(href='/') << Home diff --git a/barkshark_http_async/frontend/static/icon512.png b/barkshark_http_async/frontend/static/icon512.png new file mode 100644 index 0000000..dce6a50 Binary files /dev/null and b/barkshark_http_async/frontend/static/icon512.png differ diff --git a/barkshark_http_async/frontend/static/icon64.png b/barkshark_http_async/frontend/static/icon64.png new file mode 100644 index 0000000..1f5b602 Binary files /dev/null and b/barkshark_http_async/frontend/static/icon64.png differ diff --git a/barkshark_http_async/frontend/static/menu.js b/barkshark_http_async/frontend/static/menu.js new file mode 100644 index 0000000..0f1aee0 --- /dev/null +++ b/barkshark_http_async/frontend/static/menu.js @@ -0,0 +1,29 @@ +const sidebarBox = document.querySelector('#menu'), + sidebarBtn = document.querySelector('#btn'), + pageWrapper = document.querySelector('html'); + header = document.querySelector('#header') + +sidebarBtn.addEventListener('click', event => { + sidebarBtn.classList.toggle('active'); + sidebarBox.classList.toggle('active'); +}); + +pageWrapper.addEventListener('click', event => { + itemId = event.srcElement.id + itemClass = event.srcElement.className + indexId = ['menu', 'btn', 'items'].indexOf(itemId) + indexClass = ['item', 'item name', 'items'].indexOf(itemClass) + + if (sidebarBox.classList.contains('active') && (indexId == -1 && indexClass == -1)) { + sidebarBtn.classList.remove('active'); + sidebarBox.classList.remove('active'); + } +}); + +window.addEventListener('keydown', event => { + + if (sidebarBox.classList.contains('active') && event.keyCode === 27) { + sidebarBtn.classList.remove('active'); + sidebarBox.classList.remove('active'); + } +}); diff --git a/barkshark_http_async/frontend/static/menu.svg b/barkshark_http_async/frontend/static/menu.svg new file mode 100644 index 0000000..aa7866e --- /dev/null +++ b/barkshark_http_async/frontend/static/menu.svg @@ -0,0 +1,84 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-Black.ttf b/barkshark_http_async/frontend/static/nunito/NunitoSans-Black.ttf new file mode 100644 index 0000000..093fbf9 Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-Black.ttf differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-Black.woff2 b/barkshark_http_async/frontend/static/nunito/NunitoSans-Black.woff2 new file mode 100644 index 0000000..9b7309a Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-Black.woff2 differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-BlackItalic.ttf b/barkshark_http_async/frontend/static/nunito/NunitoSans-BlackItalic.ttf new file mode 100644 index 0000000..00db009 Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-BlackItalic.ttf differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-BlackItalic.woff2 b/barkshark_http_async/frontend/static/nunito/NunitoSans-BlackItalic.woff2 new file mode 100644 index 0000000..7951598 Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-BlackItalic.woff2 differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-Bold.ttf b/barkshark_http_async/frontend/static/nunito/NunitoSans-Bold.ttf new file mode 100644 index 0000000..a3ca4b6 Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-Bold.ttf differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-Bold.woff2 b/barkshark_http_async/frontend/static/nunito/NunitoSans-Bold.woff2 new file mode 100644 index 0000000..a23fbda Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-Bold.woff2 differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-BoldItalic.ttf b/barkshark_http_async/frontend/static/nunito/NunitoSans-BoldItalic.ttf new file mode 100644 index 0000000..1bccb97 Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-BoldItalic.ttf differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-BoldItalic.woff2 b/barkshark_http_async/frontend/static/nunito/NunitoSans-BoldItalic.woff2 new file mode 100644 index 0000000..43622e6 Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-BoldItalic.woff2 differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-ExtraBold.ttf b/barkshark_http_async/frontend/static/nunito/NunitoSans-ExtraBold.ttf new file mode 100644 index 0000000..a732425 Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-ExtraBold.ttf differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-ExtraBold.woff2 b/barkshark_http_async/frontend/static/nunito/NunitoSans-ExtraBold.woff2 new file mode 100644 index 0000000..dd16c01 Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-ExtraBold.woff2 differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-ExtraBoldItalic.ttf b/barkshark_http_async/frontend/static/nunito/NunitoSans-ExtraBoldItalic.ttf new file mode 100644 index 0000000..05e26d6 Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-ExtraBoldItalic.ttf differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-ExtraBoldItalic.woff2 b/barkshark_http_async/frontend/static/nunito/NunitoSans-ExtraBoldItalic.woff2 new file mode 100644 index 0000000..cb8cc31 Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-ExtraBoldItalic.woff2 differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-ExtraLight.ttf b/barkshark_http_async/frontend/static/nunito/NunitoSans-ExtraLight.ttf new file mode 100644 index 0000000..2ad4ac0 Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-ExtraLight.ttf differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-ExtraLight.woff2 b/barkshark_http_async/frontend/static/nunito/NunitoSans-ExtraLight.woff2 new file mode 100644 index 0000000..737badc Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-ExtraLight.woff2 differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-ExtraLightItalic.ttf b/barkshark_http_async/frontend/static/nunito/NunitoSans-ExtraLightItalic.ttf new file mode 100644 index 0000000..b73b0fa Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-ExtraLightItalic.ttf differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-ExtraLightItalic.woff2 b/barkshark_http_async/frontend/static/nunito/NunitoSans-ExtraLightItalic.woff2 new file mode 100644 index 0000000..53e80bb Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-ExtraLightItalic.woff2 differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-Italic.ttf b/barkshark_http_async/frontend/static/nunito/NunitoSans-Italic.ttf new file mode 100644 index 0000000..85269a1 Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-Italic.ttf differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-Italic.woff2 b/barkshark_http_async/frontend/static/nunito/NunitoSans-Italic.woff2 new file mode 100644 index 0000000..b052fc4 Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-Italic.woff2 differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-Light.ttf b/barkshark_http_async/frontend/static/nunito/NunitoSans-Light.ttf new file mode 100644 index 0000000..2f5d049 Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-Light.ttf differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-Light.woff2 b/barkshark_http_async/frontend/static/nunito/NunitoSans-Light.woff2 new file mode 100644 index 0000000..eb054ec Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-Light.woff2 differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-LightItalic.ttf b/barkshark_http_async/frontend/static/nunito/NunitoSans-LightItalic.ttf new file mode 100644 index 0000000..bac17d0 Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-LightItalic.ttf differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-LightItalic.woff2 b/barkshark_http_async/frontend/static/nunito/NunitoSans-LightItalic.woff2 new file mode 100644 index 0000000..de69781 Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-LightItalic.woff2 differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-Regular.ttf b/barkshark_http_async/frontend/static/nunito/NunitoSans-Regular.ttf new file mode 100644 index 0000000..9abe932 Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-Regular.ttf differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-Regular.woff2 b/barkshark_http_async/frontend/static/nunito/NunitoSans-Regular.woff2 new file mode 100644 index 0000000..fbc66d6 Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-Regular.woff2 differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-SemiBold.ttf b/barkshark_http_async/frontend/static/nunito/NunitoSans-SemiBold.ttf new file mode 100644 index 0000000..c27eaf4 Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-SemiBold.ttf differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-SemiBold.woff2 b/barkshark_http_async/frontend/static/nunito/NunitoSans-SemiBold.woff2 new file mode 100644 index 0000000..21314cf Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-SemiBold.woff2 differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-SemiBoldItalic.ttf b/barkshark_http_async/frontend/static/nunito/NunitoSans-SemiBoldItalic.ttf new file mode 100644 index 0000000..59b6402 Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-SemiBoldItalic.ttf differ diff --git a/barkshark_http_async/frontend/static/nunito/NunitoSans-SemiBoldItalic.woff2 b/barkshark_http_async/frontend/static/nunito/NunitoSans-SemiBoldItalic.woff2 new file mode 100644 index 0000000..1abd05c Binary files /dev/null and b/barkshark_http_async/frontend/static/nunito/NunitoSans-SemiBoldItalic.woff2 differ diff --git a/barkshark_http_async/frontend/static/nunito/SIL Open Font License.txt b/barkshark_http_async/frontend/static/nunito/SIL Open Font License.txt new file mode 100644 index 0000000..3a7ebff --- /dev/null +++ b/barkshark_http_async/frontend/static/nunito/SIL Open Font License.txt @@ -0,0 +1,44 @@ +Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com), +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the copyright statement(s). + +"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission. + +5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/barkshark_http_async/hasher.py b/barkshark_http_async/hasher.py new file mode 100644 index 0000000..d47e316 --- /dev/null +++ b/barkshark_http_async/hasher.py @@ -0,0 +1,78 @@ +import argon2, os + +from .misc import time_function_pprint + + +class PasswordHasher: + ''' + Argon2 password hasher and validator + + Attributes: + config (dict): The settings used for the hasher + + Methods: + get_config(key): Get the value of a config options + set_config(key, value): Set a config option + hash(password): hash a password and return the digest as a hex string + verify(hash, password): verify a password and the password hash match + iteration_test(string, passes, iterations): Time the hashing functionality + ''' + + aliases = { + 'iterations': 'time_cost', + 'memory': 'memory_cost', + 'threads': 'parallelism' + } + + + def __init__(self, iterations=16, memory=100, threads=os.cpu_count(), type=argon2.Type.ID): + if not argon2: + raise ValueError('password hashing disabled') + + self.config = { + 'time_cost': iterations, + 'memory_cost': memory * 1024, + 'parallelism': threads, + 'encoding': 'utf-8', + 'type': type, + } + + self.hasher = argon2.PasswordHasher(**self.config) + + + def get_config(self, key): + key = self.aliases.get(key, key) + + value = self.config[key] + return value / 1024 if key == 'memory_cost' else value + + + def set_config(self, key, value): + key = self.aliases.get(key, key) + self.config[key] = value * 1024 if key == 'memory_cost' else value + self.hasher = argon2.PasswordHasher(**self.config) + + + def hash(self, password: str): + return self.hasher.hash(password) + + + def verify(self, passhash: str, password: str): + try: + return self.hasher.verify(passhash, password) + + except argon2.exceptions.VerifyMismatchError: + return False + + + def iteration_test(self, string='hecking heck', passes=3, iterations=[8,16,24,32,40,48,56,64]): + original_iter = self.get_config('iterations') + + for iteration in iterations: + self.set_config('iterations', iteration) + print('\nTesting hash iterations:', iteration) + + time_function_pprint(self.verify, self.hash(string), string, passes=passes) + + self.set_config('iterations', original_iter) + diff --git a/barkshark_http_async/server/__init__.py b/barkshark_http_async/server/__init__.py new file mode 100644 index 0000000..634eace --- /dev/null +++ b/barkshark_http_async/server/__init__.py @@ -0,0 +1,22 @@ +http_methods = ['CONNECT', 'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT', 'TRACE'] +applications = {} + + +def get_app(name='default'): + return applications[name] + + +def set_app(app): + applications[app.name] = app + return app + + +def create_app(appname, **kwargs): + return set_app(Application(appname=appname, **kwargs)) + + +from .application import Application, Blueprint +from .middleware import MediaCacheControl +from .request import Request +from .response import Response +from .view import View, Static diff --git a/barkshark_http_async/server/application.py b/barkshark_http_async/server/application.py new file mode 100644 index 0000000..56418e3 --- /dev/null +++ b/barkshark_http_async/server/application.py @@ -0,0 +1,398 @@ +import asyncio +import signal +import socket +import sys +import time +import traceback + +from functools import partial +from http_router import Router, MethodNotAllowed, NotFound +from izzylib import DotDict, Path, logging, signal_handler +from jinja2.exceptions import TemplateNotFound + +from . import http_methods, error, __file__ as module_root +from .config import Config +from .response import Response +#from .router import Router +from .view import Static, Manifest, Robots, Style + +from ..exceptions import MethodNotHandledException, NoBlueprintForPath +from ..http_utils import AsyncTransport +from ..template import Template + +try: + from izzylib import Database +except ImportError: + Database = NotImplementedError('Failed to import SQL database class') + + +frontend = Path(module_root).parent.join('frontend') + + +class ApplicationBase: + ctx = DotDict() + + def __init__(self, appname='default', views=[], middleware=[], dbtype=None, dbargs={}, dbclass=Database, **kwargs): + self.name = appname + self.cfg = Config(**kwargs) + self.db = None + self.router = Router(trim_last_slash=True) + self.middleware = DotDict({'request': [], 'response': []}) + self.routes = {} + + for view in views: + self.add_view(view) + + for mw in middleware: + self.add_middleware(mw) + + if dbtype or dbargs: + if isinstance(Database, Exception): + raise Database from None + + self.db = dbclass(dbtype, **dbargs, app=self) + + + def __getitem__(self, key): + return self.ctx[key] + + + def __setitem__(self, key, value): + self.ctx[key] = value + + + def get_route(self, path, method='GET'): + return self.router(str(path), method.upper()) + + + def add_route(self, handler, path, method='GET'): + self.router.bind(handler, path, methods=[method.upper()]) + self.routes[f'{method.upper()}:{path}'] = handler + + + def compare_routes(self, route, path, method='GET'): + try: + return self.get_route(path, method) == self.routes[f'{method.upper()}:{route}'] + except: + return False + + + async def run_handler(self, request, response, path, method=None, **kwargs): + handler = self.get_route(path, method or request.method) + return await handler.target(request, response, **kwargs) + + + def add_view(self, view): + paths = view.__path__ if isinstance(view.__path__, list) else [view.__path__] + + view_class = view(self) + + for path in paths: + for method in http_methods: + try: + self.add_route(view_class.get_handler(method), path, method) + + except MethodNotHandledException: + pass + + + def add_static(self, path, src): + if Path(src).isdir: + path = Path(path).join('{path:.*}') + + self.add_route(Static(src), path) + + + def add_middleware(self, handler, attach=None): + if not asyncio.iscoroutinefunction(handler): + raise TypeError('Middleware handler must be a coroutine function or method') + + if not attach: + try: + arg_len = handler.__code__.co_argcount + + if arg_len == 1: + attach = 'request' + + elif arg_len == 2: + attach = 'response' + + else: + raise TypeError(f'Middleware handler must have 1 (request) or 2 (response) arguments, not {arg_len}') + + except Exception as e: + raise e from None + + assert attach in ['request', 'response'] + mwlist = self.middleware[attach] + + if handler in mwlist: + return logging.error(f'Middleware handler already added to {attach}: {handler}') + + mwlist.append(handler) + + + async def handle_request(self, request, response, path=None): + if request.host not in self.cfg.hosts and not request.path.startswith('/framework'): + raise error.BadRequest(f'Host not handled on this server: {request.host}') + + handler = self.get_route(path or request.path, request.method) + request._params = handler.params + + await self.handle_middleware(request) + + if handler.params: + handler_response = await handler.target(request, response, **handler.params) + + else: + handler_response = await handler.target(request, response) + + if isinstance(handler_response, dict): + response = self.cfg.response_class(**handler_response) + + elif isinstance(handler_response, Response): + response = handler_response + + elif not handler_response: + pass + + else: + raise error.InternalServerError() + + await self.handle_middleware(request, response) + + return response + + + async def handle_middleware(self, request, response=None): + for middleware in self.middleware['response' if response else 'request']: + if response: + await middleware(request, response) + + else: + await middleware(request) + + +class Application(ApplicationBase): + def __init__(self, loop=None, **kwargs): + super().__init__(**kwargs) + + if loop: + self.loop = loop + + else: + try: + self.loop = asyncio.get_running_loop() + + except RuntimeError: + self.loop = asyncio.new_event_loop() + + self.client = self.cfg.client_class(loop, *self.cfg.client_args, **self.cfg.client_kwargs) + + self._blueprints = {} + self._server = None + self._tasks = [] + + if self.cfg.tpl_default: + if type(Template) == NotImplementedError: + raise Template + + self.template = Template( + self.cfg.tpl_search, + self.cfg.tpl_globals, + self.cfg.tpl_context, + self.cfg.tpl_autoescape + ) + + self.template.add_env('app', self) + self.template.add_env('cfg', self.cfg) + self.template.add_env('len', len) + + self.template.add_search_path(frontend) + self.add_view(Manifest) + #self.add_view(Robots) + self.add_view(Style) + self.add_static('/framework/static/', frontend.join('static')) + + else: + self.template = None + + + def add_blueprint(self, bp): + assert bp.prefix not in self._blueprints.values() + + self._blueprints[bp.prefix] = bp + + + def get_blueprint_for_path(self, path): + for bppath, bp in self._blueprints.items(): + if path.startswith(bppath): + return bp + + raise NoBlueprintForPath(path) + + + def render(self, *args, **kwargs): + return self.template.render(*args, **kwargs) + + + async def create_task(self, log=True): + if self.cfg.socket: + if log: + logging.info(f'Starting server on {self.cfg.socket}') + + return await asyncio.start_unix_server( + self.handle_client, + path = self.cfg.socket + ) + + else: + if log: + logging.info(f'Starting server on {self.cfg.listen}:{self.cfg.port}') + + return await asyncio.start_server( + self.handle_client, + host = self.cfg.listen, + port = self.cfg.port, + family = socket.AF_INET, + reuse_address = True, + reuse_port = True + ) + + + def stop(self, *_): + if not self._server: + print('server not running') + return + + self._server.close() + + for task in self._tasks: + task.cancel() + self._tasks.remove(task) + + signal_handler(None) + + + def start(self, *tasks, log=True): + if self._server: + return + + if self.cfg.socket: + if log: + logging.info(f'Starting server on {self.cfg.socket}') + + server = asyncio.start_unix_server( + self.handle_client, + path = self.cfg.socket + ) + + else: + if log: + logging.info(f'Starting server on {self.cfg.listen}:{self.cfg.port}') + + server = asyncio.start_server( + self.handle_client, + host = self.cfg.listen, + port = self.cfg.port, + family = socket.AF_INET, + reuse_address = True, + reuse_port = True + ) + + signal_handler(self.stop) + self._server = self.loop.run_until_complete(server) + + for task in tasks: + asyncio.ensure_future(task, loop=self.loop) + + self.loop.run_until_complete(self.handle_run_server()) + + + async def handle_run_server(self): + while self._server.is_serving(): + await asyncio.sleep(0.1) + + await self._server.wait_closed() + self._server = None + + logging.info('Server stopped') + + + async def handle_client(self, reader, writer): + transport = AsyncTransport(reader, writer, self.cfg.timeout) + request = None + response = None + + try: + request = self.cfg.request_class(self, transport) + response = self.cfg.response_class(request=request) + + try: + await request.parse_headers() + + except asyncio.exceptions.IncompleteReadError as e: + request = None + raise e from None + + try: + # this doesn't work all the time for some reason + blueprint = self.get_blueprint_for_path(request.path) + response = await blueprint.handle_request(request, response, blueprint.prefix) + + except NoBlueprintForPath: + response = await self.handle_request(request, response) + + #except Exception as e: + #traceback.print_exc() + + except NotFound: + response = self.cfg.response_class(request=request).set_error('Not Found', 404) + + except MethodNotAllowed: + response = self.cfg.response_class(request=request).set_error('Method Not Allowed', 405) + + except error.RedirError as e: + response = self.cfg.response_class.new_redir(e.path, e.status) + + except error.HttpError as e: + response = self.cfg.response_class(request=request).set_error(e.message, e.status) + + except TemplateNotFound as e: + response = self.cfg.response_class(request=request).set_error(f'Template not found: {e}', 500) + + except: + traceback.print_exc() + + if not response.streaming: + ## Don't use a custom response class here just in case it caused the error + response = Response(request=request).set_error('Server Error', 500) + + if not response.streaming: + try: + response.headers.update(self.cfg.default_headers) + await transport.write(response.compile()) + + if request and request.log and not request.path.startswith('/framework'): + logging.info(f'{request.remote} {request.method} {request.path} {response.status} {len(response.body)} {request.agent}') + + except: + traceback.print_exc() + + await transport.close() + + +class Blueprint(ApplicationBase): + def __init__(self, prefix, **kwargs): + super().__init__(**kwargs) + + self.prefix = prefix + + +## might keep this +def set_response(request, resp_class, func, *args, **kwargs): + try: + return getattr(resp_class, func)(*args, **kwargs) + except: + traceback.print_exc() + return Response(request=request).set_error('Server Error', 500) diff --git a/barkshark_http_async/server/config.py b/barkshark_http_async/server/config.py new file mode 100644 index 0000000..5b75ed6 --- /dev/null +++ b/barkshark_http_async/server/config.py @@ -0,0 +1,101 @@ +from .request import Request +from .response import Response + +from .. import __version__ +from ..config import BaseConfig +from ..dotdict import LowerDotDict +from ..http_client_async import HttpClient +from ..misc import boolean + + +class Config(BaseConfig): + _startup = True + + def __init__(self, **kwargs): + super().__init__( + name = 'IzzyLib Http Server', + title = None, + version = '0.0.1', + git_repo = 'https://git.barkshark.xyz/izaliamae/izzylib', + socket = None, + listen = 'localhost', + host = None, + web_host = None, + alt_hosts = [], + port = 8080, + proto = 'http', + access_log = True, + timeout = 60, + default_headers = LowerDotDict(), + request_class = Request, + response_class = Response, + sig_handler = None, + sig_handler_args = [], + sig_handler_kwargs = {}, + tpl_search = [], + tpl_globals = {}, + tpl_context = None, + tpl_autoescape = True, + tpl_default = True, + tpl_favicon_path = '/framework/static/icon64.png', + client_class = HttpClient, + client_args = [], + client_kwargs = {} + ) + + self._startup = False + self.default_headers.update(kwargs.pop('default_headers', {})) + self.set_data(kwargs) + + if not self.default_headers.get('server'): + self.default_headers['server'] = f'{self.name}/{__version__}' + + if not self.client_kwargs.get('appagent') and not self.client_kwargs.get('useragent'): + self.client_kwargs['appagent'] = f'{self.name}/{__version__}' + + + @property + def hosts(self): + return (f'{self.listen}:{self.port}', self.host, self.web_host, *self.alt_hosts) + + + def parse_value(self, key, value): + if self._startup: + return value + + if key == 'listen': + if not self.host: + self.host = value + + if not self.web_host: + self.web_host = value + + elif key == 'host': + if not self.web_host or self.web_host == self.listen: + self.web_host = value + + elif key == 'port' and not isinstance(value, int): + raise TypeError(f'{key} must be an integer') + + elif key == 'socket': + value = Path(value) + + elif key in ['access_log', 'tpl_autoescape', 'tpl_default'] and not isinstance(value, bool): + raise TypeError(f'{key} must be a boolean') + + elif key in ['alt_hosts', 'sig_handler_args', 'tpl_search'] and not isinstance(value, list): + raise TypeError(f'{key} must be a list') + + elif key in ['sig_handler_kwargs', 'tpl_globals'] and not isinstance(value, dict): + raise TypeError(f'{key} must be a dict') + + elif key == 'tpl_context' and not getattr(value, '__call__', None): + raise TypeError(f'{key} must be a callable') + + elif key == 'request_class' and not issubclass(value, Request): + raise TypeError(f'{key} must be a subclass of izzylib.http_server_async.Request') + + elif key == 'response_class' and not issubclass(value, Response): + raise TypeError(f'{key} must be a subclass of izzylib.http_server_async.Response') + + return value diff --git a/barkshark_http_async/server/error.py b/barkshark_http_async/server/error.py new file mode 100644 index 0000000..feb5388 --- /dev/null +++ b/barkshark_http_async/server/error.py @@ -0,0 +1,65 @@ +from functools import partial + + +class HttpError(Exception): + def __init__(self, message, status=500): + super().__init__(f'HTTP Error {status}: {message}') + + self.status = status + self.message = message + + +class RedirError(Exception): + def __init__(self, path, status=301): + super().__init__(f'HTTP Error {status}: {path}') + + self.status = status + self.path = path + + +## 200 Errors +Ok = partial(HttpError, status=200) +Created = partial(HttpError,status=201) +Accepted = partial(HttpError,status=202) +NoContent = partial(HttpError,status=204) +ResetContent = partial(HttpError,status=205) +PartialContent = partial(HttpError,status=206) + +## 300 Errors +NotModified = partial(HttpError, status=304) + +## 400 Errors +BadRequest = partial(HttpError, status=400) +Unauthorized = partial(HttpError, status=401) +Forbidden = partial(HttpError, status=403) +NotFound = partial(HttpError, status=404) +MethodNotAllowed = partial(HttpError, status=405) +RequestTimeout = partial(HttpError,status=408) +Gone = partial(HttpError,status=410) +LengthRequired = partial(HttpError,status=411) +PreconditionFailed = partial(HttpError,status=412) +PayloadTooLarge = partial(HttpError,status=413) +UriTooLong = partial(HttpError,status=414) +UnsupportedMediaType = partial(HttpError,status=415) +RangeNotSatisfiable = partial(HttpError,status=416) +Teapot = partial(HttpError, status=418) +UpgradeRequired = partial(HttpError,status=426) +TooManyRequests = partial(HttpError,status=429) +RequestHeaderFieldsTooLarge = partial(HttpError,status=431) +UnavailableForLegalReasons = partial(HttpError,status=451) + +## 500 Errors +InternalServerError = partial(HttpError, status=500) +NotImplemented = partial(HttpError,status=501) +BadGateway = partial(HttpError,status=502) +ServiceUnavailable = partial(HttpError,status=503) +GatewayTimeout = partial(HttpError,status=504) +HttpVersionNotSuported = partial(HttpError,status=505) +NetworkAuthenticationRequired = partial(HttpError,status=511) + +## Redirects +MovedPermanently = partial(RedirError, status=301) +Found = partial(RedirError, status=302) +SeeOther = partial(RedirError, status=303) +TemporaryRedirect = partial(RedirError, status=307) +PermanentRedirect = partial(RedirError, status=309) diff --git a/barkshark_http_async/server/middleware.py b/barkshark_http_async/server/middleware.py new file mode 100644 index 0000000..9ee6e10 --- /dev/null +++ b/barkshark_http_async/server/middleware.py @@ -0,0 +1,14 @@ +media_types = [ + 'application/octet-stream' +] + +media_main_types = [ + 'audio', + 'font', + 'image', + 'video' +] + +async def MediaCacheControl(request, response): + if response.content_type in media_types or any(map(response.content_type.startswith, media_main_types)): + response.headers['Cache-Control'] = 'public,max-age=2628000,immutable' diff --git a/barkshark_http_async/server/misc.py b/barkshark_http_async/server/misc.py new file mode 100644 index 0000000..c29da99 --- /dev/null +++ b/barkshark_http_async/server/misc.py @@ -0,0 +1,343 @@ +from datetime import datetime, timezone, timedelta + +from .. import logging, boolean +from ..dotdict import DotDict +from ..path import Path + + +UtcTime = timezone.utc +LocalTime = datetime.now(UtcTime).astimezone().tzinfo + +cookie_params = { + 'Expires': 'expires', + 'Max-Age': 'maxage', + 'Domain': 'domain', + 'Path': 'path', + 'SameSite': 'samesite', + 'Secure': 'secure', + 'HttpOnly': 'httponly' +} + +request_methods = [ + 'GET', 'HEAD', 'POST', 'PUT', 'DELETE', + 'CONNECT', 'OPTIONS', 'TRACE', 'PATCH' +] + + +class Cookies(DotDict): + def __setitem__(self, key, value): + if type(value) != CookieItem: + value = CookieItem(key, value) + + super().__setitem__(key, value) + + +class Headers(DotDict): + def __getattr__(self, key): + return self[key.replace('_', '-')] + + + def __setattr__(self, key, value): + self[key.replace('_', '-')] = value + + + def __getitem__(self, key): + return super().__getitem__(key.title()) + + + def __setitem__(self, key, value): + key = key.title() + + if key in ['Cookie', 'Set-Cookie']: + logging.warning('Do not set the "Cookie" or "Set-Cookie" headers') + return + + elif key == 'Date': + value = DateItem(value) + + try: + self[key].append(value) + + except KeyError: + super().__setitem__(key, HeaderItem(key, value)) + + + def get(self, key, default=None): + return super().get(key.title(), default) + + + def as_dict(self): + data = {} + + for k,v in self.items(): + data[k] = str(v) + + return data + + + def getone(self, key, default=None): + try: + return self[key].one() + + except (KeyError, IndexError): + return default + + + def setall(self, key, value): + self[key].set(value) + + + #def update(self, data): + #for k,v in data.items(): + #self.__setitem__(k,v) + + +class CookieItem: + def __init__(self, key, value, **kwargs): + self.key = key + self.value = value + self.args = DotDict() + + for k,v in kwargs.items(): + if k not in cookie_params.values(): + raise AttributeError(f'Not a valid cookie parameter: {key}') + + setattr(self, k, v) + + + def __str__(self): + text = f'{self.key}={self.value}' + + if self.expires: + text += f'; Expires={self.expires.strftime("%a, %d %b %Y %H:%M:%S GMT")}' + + if self.maxage != None: + text += f'; Max-Age={self.maxage}' + + if self.domain: + text += f'; Domain={self.domain}' + + if self.path: + text += f'; Path={self.path}' + + if self.samesite: + text += f'; SameSite={self.samesite}' + + if self.secure: + text += f'; Secure' + + if self.httponly: + text += f'; HttpOnly' + + return text + + + @classmethod + def from_string(cls, data): + kwargs = {} + + for idx, pairs in enumerate(data.split(';')): + try: + k, v = pairs.split('=', 1) + v = v.strip() + + except: + k, v = pairs, True + + k = k.replace(' ', '') + + if isinstance(v, str) and v.startswith('"') and v.endswith('"'): + v = v[1:-1] + + if idx == 0: + key = k + value = v + + elif k in cookie_params.keys(): + kwargs[cookie_params[k]] = v + + else: + logging.info(f'Invalid key/value pair for cookie: {k} = {v}') + + return cls(key, value, **kwargs) + + + @property + def expires(self): + return self.args.get('Expires') + + + @expires.setter + def expires(self, data): + if isinstance(data, str): + data = datetime.strptime(data, '%a, %d %b %Y %H:%M:%S GMT').replace(tzinfo=UtcTime) + + elif isinstance(data, int) or isinstance(data, float): + data = datetime.fromtimestamp(data).replace(tzinfo=UtcTime) + + elif isinstance(data, datetime): + if not data.tzinfo: + data = data.replace(tzinfo=UtcTime) + + elif isinstance(data, timedelta): + data = datetime.now(UtcTime) + data + + else: + raise TypeError(f'Expires must be a http date string, timestamp, or datetime object, not {data.__class__.__name__}') + + self.args['Expires'] = data + + + @property + def maxage(self): + return self.args.get('Max-Age') + + + @maxage.setter + def maxage(self, data): + if isinstance(data, int): + pass + + elif isinstance(date, timedelta): + data = data.seconds + + elif isinstance(date, datetime): + data = (datetime.now() - date).seconds + + else: + raise TypeError(f'Max-Age must be an integer, timedelta object, or datetime object, not {data.__class__.__name__}') + + self.args['Max-Age'] = data + + + @property + def domain(self): + return self.args.get('Domain') + + + @domain.setter + def domain(self, data): + if not isinstance(data, str): + raise ValueError(f'Domain must be a string, not {data.__class__.__name__}') + + self.args['Domain'] = data + + + @property + def path(self): + return self.args.get('Path') + + + @path.setter + def path(self, data): + if not isinstance(data, str): + raise ValueError(f'Path must be a string or izzylib.Path object, not {data.__class__.__name__}') + + self.args['Path'] = Path(data) + + + @property + def secure(self): + return self.args.get('Secure') + + + @secure.setter + def secure(self, data): + self.args['Secure'] = boolean(data) + + + @property + def httponly(self): + return self.args.get('HttpOnly') + + + @httponly.setter + def httponly(self, data): + self.args['HttpOnly'] = boolean(data) + + + @property + def samesite(self): + return self.args.get('SameSite') + + + @samesite.setter + def samesite(self, data): + if isinstance(data, bool): + data = 'Strict' if data else 'None' + + elif isinstance(data, str) and data.title() in ['Strict', 'Lax', 'None']: + data = data.title() + + else: + raise TypeError(f'SameSite must be a boolean or one of Strict, Lax, or None, not {data.__class__.__name__}') + + self.args['SameSite'] = data + self.args['Secure'] = True + + + def as_dict(self): + data = DotDict({self.key: self.value}) + data.update(self.args) + + return data + + + def set_defaults(self): + for key in list(self.args.keys()): + del self.args[key] + + + def set_delete(self): + self.args.pop('Expires', None) + self.maxage = 0 + + return self + + +class HeaderItem(list): + def __init__(self, key, values): + super().__init__() + self.update(values) + + self.key = key + + + def __str__(self): + return ','.join(str(v) for v in self) + + + def set(self, *values): + self.clear() + + for value in values: + self.append(value) + + + def one(self): + return self[0] + + + def update(self, *items): + for item in items: + self.append(item) + + +class DateItem(str): + _datetime = None + + def __new__(cls, date): + new_date = str.__new__(cls, date) + new_date._datetime = datetime.strptime(date, '%a, %d %b %Y %H:%M:%S GMT').replace(tzinfo=UtcTime) + return new_date + + + @property + def utc(self): + return self._datetime.astimezone(UtcTime) + + + @property + def local(self): + return self._datetime.astimezone(LocalTime) + diff --git a/barkshark_http_async/server/request.py b/barkshark_http_async/server/request.py new file mode 100644 index 0000000..23631e5 --- /dev/null +++ b/barkshark_http_async/server/request.py @@ -0,0 +1,209 @@ +import asyncio, email, traceback + +from datetime import datetime, timezone +from urllib.parse import unquote_plus + +from .misc import Cookies, Headers, CookieItem + +from ..dotdict import DotDict, MultiDotDict +from ..misc import Url + +try: from ..http_signatures import verify_headers +except ImportError: verify_headers = None + + +UtcTime = timezone.utc +LocalTime = datetime.now(UtcTime).astimezone().tzinfo + + +class Request: + __slots__ = [ + '_body', '_form', '_method', '_app', '_params', + 'address', 'path', 'version', 'headers', 'cookies', + 'query', 'raw_query', 'transport', 'log' + ] + + ctx = DotDict() + + def __init__(self, app, transport): + super().__init__() + + self._app = app + self._body = b'' + self._form = DotDict() + self._method = None + self._params = None + + self.transport = transport + self.headers = Headers() + self.cookies = Cookies() + self.query = DotDict() + + self.address = transport.client_address + self.path = None + self.version = None + self.raw_query = None + self.log = True + + self.verified = False + + + def __getitem__(self, key): + return self.ctx[key] + + + def __setitem__(self, key, value): + self.ctx[key] = value + + + def __getattr__(self, key): + if key in self.__slots__: + return super().__getattribute__(self, key) + + try: + return self.ctx[key] + except: + raise AttributeError(key) + + + def __setattr__(self, key, value): + try: + super().__setattr__(key, value) + + except: + self.ctx[key] = value + + + @property + def app(self): + return self._app or get_app() + + + @app.setter + def app(self, app): + self._app = app + + + @property + def agent(self): + return self.headers.getone('User-Agent', 'no agent') + + + @property + def accept(self): + return self.headers.getone('Accept', '') + + + @property + def content_type(self): + return self.headers.getone('Content-Type', '') + + + @property + def date(self): + date_str = self.headers.getone('Date') + + if date_str: + date = datetime.strptime(date_str, '%a, %d %b %Y %H:%M:%S GMT') + date = date.replace(tzinfo=UtcTime) + return date.astimezone(LocalTime) + + # not sure if this should stay + return datetime.now(LocalTime) + + + @property + def host(self): + return self.headers.getone('Host') + + + @property + def length(self): + return int(self.headers.getone('Content-Length', 0)) + + + @property + def method(self): + return self._method + + + @method.setter + def method(self, data): + self._method = data.upper() + + + @property + def params(self): + return self._params + + + @property + def protocol(self): + return self.headers.getone('X-Forwarded-Proto', 'http') + + + @property + def remote(self): + return self.headers.getone('X-Real-Ip', self.headers.getone('X-Forwarded-For', self.address)) + + + async def body(self): + if not self._body and self.length: + self._body = await self.transport.read(self.length) + + return self._body + + + async def text(self): + return (await self.body()).decode('utf-8') + + + async def dict(self): + logging.warning('Request.dict will be removed in a future update') + return DotDict(await self.body()) + + + async def json(self): + return DotDict(await self.body()) + + + async def form(self): + if not self._form and 'application/x-www-form-urlencoded' in self.content_type: + for line in unquote_plus(await self.text()).split('&'): + try: key, value = line.split('=', 1) + except: key, value = line, None + + self._form[key] = value + + return self._form + + + async def parse_headers(self): + method, path, version, headers, cookies = await self.transport.read_headers() + + self.path = path or '' + self.version = version + self.method = method + self.headers = headers + self.cookies = cookies + + self.query = Url(f'{self.protocol}://{self.host}{self.path or "/"}').query + + + def new_response(self, *args, **kwargs): + return self.app.cfg.response_class(*args, **kwargs) + + + async def verify_signature(self, actor): + if not verify_headers: + raise ImportError('Failed to import verify_headers from izzylib.http_signatures.') + + self.verified = verify_headers( + headers = {k: self.headers.getone(k) for k in self.headers.keys()}, + method = self.method, + path = self.path, + actor = actor, + body = await self.body() + ) + + return self.verified diff --git a/barkshark_http_async/server/response.py b/barkshark_http_async/server/response.py new file mode 100644 index 0000000..7ab53d8 --- /dev/null +++ b/barkshark_http_async/server/response.py @@ -0,0 +1,201 @@ +import json, traceback + +from datetime import datetime + +from . import get_app +from .misc import Cookies, Headers, CookieItem + +from ..dotdict import MultiDotDict +from ..http_utils import convert_to_bytes, create_message, first_line + + +class Response: + __slots__ = ['_app', '_body', 'headers', 'cookies', 'status', 'request', 'version'] + + + def __init__(self, body=b'', status=200, headers={}, cookies={}, content_type='text/plain', request=None): + self._app = None + self._body = b'' + + self.headers = Headers(headers) + self.cookies = Cookies(cookies) + + self.version = '1.1' + self.body = body + self.status = status + self.content_type = content_type + self.request = request + + + @property + def app(self): + return self._app or get_app() + + + @app.setter + def app(self, app): + self._app = app + + + @property + def body(self): + return self._body + + + @body.setter + def body(self, data): + self._body = convert_to_bytes(data) + + + @property + def content_type(self): + return self.headers.getone('Content-Type') + + + @content_type.setter + def content_type(self, data): + try: + self.headers['Content-Type'].set(data) + + except KeyError: + self.headers['Content-Type'] = data + + + @property + def content_length(self): + return len(self.body) + + + @property + def streaming(self): + return self.headers.getone('Transfer-Encoding') == 'chunked' + + + def append(self, data): + self._body += convert_to_bytes(data) + + + def delete_cookie(self, cookie): + cookie.set_delete() + self.cookies[cookie.key] = cookie + + + @classmethod + def new_html(cls, body, **kwargs): + response = cls(**kwargs) + response.set_html(body) + + return response + + + @classmethod + def new_json(cls, body, activity=False, **kwargs): + response = cls(**kwargs) + response.set_json(body, activity) + + return response + + + @classmethod + def new_error(cls, message, status=500, **kwargs): + response = cls(**kwargs) + response.set_error(message, status) + + return response + + + @classmethod + def new_redir(cls, path, status=302, **kwargs): + response = cls(**kwargs) + response.set_redir(path, status) + + return response + + + def set_text(self, body=b'', status=None): + self.body = body + + if status: + self.status = status + + return self + + + def set_html(self, body=b'', status=None): + self.content_type = 'text/html' + self.body = body + + if status: + self.status = status + + return self + + + def set_template(self, template, context={}, content_type='text/html', status=None, request=None): + if not request: + request = self.request + + if status: + self.status = status + + self.body = request.app.render(template, context_data=context, request=request) + self.content_type = content_type + + return self + + + def set_json(self, body={}, status=None, activity=False): + self.content_type = 'application/activity+json' if activity else 'application/json' + self.body = body + + if status: + self.status = status + + return self + + + def set_redir(self, path, status=302): + self.headers['Location'] = path + self.status = status + return self + + + def set_error(self, message, status=500): + try: + if self.request and 'json' in self.request.headers.getone('accept', ''): + return self.set_json({'error': message, 'code': status}, status=status) + + return self.set_template('error.haml', + context = { + 'error_message': message, + 'response': self + }, + status = status + ) + + except AttributeError: + pass + + except Exception: + traceback.print_exc() + + self.body = f'HTTP Error {status}: {message}' + self.status = status + return self + + + async def set_streaming(self, transport, headers={}): + self.headers.update(headers) + self.headers.update(transport.app.cfg.default_headers) + self.headers.setall('Transfer-encoding', 'chunked') + + transport.write(self.compile(body=False)) + + + def set_cookie(self, key, value, **kwargs): + self.cookies[key] = CookieItem(key, value, **kwargs) + + + def compile(self, body=True): + first = first_line(status=self.status) + return create_message(first, self.headers, self.cookies, self.body if body else None) diff --git a/barkshark_http_async/server/transport.py b/barkshark_http_async/server/transport.py new file mode 100644 index 0000000..2c75d62 --- /dev/null +++ b/barkshark_http_async/server/transport.py @@ -0,0 +1,65 @@ +import asyncio + +from ..dotdict import DotDict + + +class Transport: + def __init__(self, app, reader, writer): + self.app = app + self.reader = reader + self.writer = writer + + + @property + def client_address(self): + return self.writer.get_extra_info('peername')[0] + + + @property + def client_port(self): + return self.writer.get_extra_info('peername')[1] + + + @property + def closed(self): + return self.writer.is_closing() + + + async def read(self, length=2048, timeout=None): + return await asyncio.wait_for(self.reader.read(length), timeout or self.app.cfg.timeout) + + + async def readuntil(self, bytes, timeout=None): + return await asyncio.wait_for(self.reader.readuntil(bytes), timeout or self.app.cfg.timeout) + + + async def write(self, data): + if isinstance(data, DotDict): + data = data.to_json() + + elif any(map(isinstance, [data], [dict, list, tuple])): + data = json.dumps(data) + + # not sure if there's a better type to use, but this should be fine for now + elif any(map(isinstance, [data], [float, int])): + data = str(data) + + elif isinstance(data, bytearray): + data = str(data) + + elif not any(map(isinstance, [data], [bytes, str])): + raise TypeError('Data must be or a str, bytes, bytearray, float, it, dict, list, or tuple') + + if isinstance(data, str): + data = data.encode('utf-8') + + self.writer.write(data) + await self.writer.drain() + + + async def close(self): + if self.closed: + return + + self.writer.close() + await self.writer.wait_closed() diff --git a/barkshark_http_async/server/view.py b/barkshark_http_async/server/view.py new file mode 100644 index 0000000..c526c4f --- /dev/null +++ b/barkshark_http_async/server/view.py @@ -0,0 +1,135 @@ +import mimetypes + +from . import http_methods, error + +from ..dotdict import DotDict +from ..path import Path +from ..exceptions import ( + InvalidMethodException, + MethodNotHandledException +) + +try: import magic +except ImportError: magic = None + +try: + from ..template import Color + + default_theme = DotDict( + primary = Color('#e7a'), + secondary = Color('#a7e'), + background = Color('#191919'), + positive = Color('#aea'), + negative = Color('#e99'), + white = Color('#eee'), + black = Color('#111'), + speed = 250 + ) + +except ModuleNotFoundError: + pass + + +class View: + __path__ = '' + + def __init__(self, app): + self.app = app + + + def get_handler(self, method): + if method.upper() not in http_methods: + raise InvalidMethodException(method) + + try: + return getattr(self, method.lower()) + except AttributeError: + raise MethodNotHandledException(method) + + + #def get(self, request): + #pass + + +def Static(src): + src = Path(src) + + async def StaticHandler(request, response, path=None): + src_path = src if not path else src.join(path) + + try: + with open(src_path, 'rb') as fd: + data = fd.read() + + if magic: + mime = mimetypes.guess_type(path)[0] or magic.from_buffer(data[:2048], mime=True) + + else: + mime = mimetypes.guess_type(path) + + except FileNotFoundError: + raise error.NotFound('Static file not found') + + except IsADirectoryError: + index = src_path.join('index.html') + + if not index.isfile: + raise error.NotFound('Static file not found') + + with open(index, 'rb') as fd: + data = fd.read() + mime = 'text/html' + + response.body = data + response.content_type = mime + + return StaticHandler + + +### Frontend Template Views ### + +class Manifest(View): + __path__ = '/framework/manifest.json' + + + async def get(self, request, response): + data = { + 'name': self.cfg.name, + 'short_name': self.cfg.name.replace(' ', ''), + 'description': 'UvU', + 'icons': [ + { + 'src': "/framework/static/icon512.png", + 'sizes': '512x512', + 'type': 'image/png' + }, + { + 'src': "/framework/static/icon64.png", + 'sizes': '64x64', + 'type': 'image/png' + } + ], + 'theme_color': str(response.default_theme.primary), + 'background_color': str(response.default_theme.background), + 'display': 'standalone', + 'start_url': '/', + 'scope': f'{self.cfg.proto}://{self.cfg.web_host}' + } + + response.set_json(data) + + +class Robots(View): + __path__ = '/robots.txt' + + async def get(self, request, response): + data = '# Disallow all\nUser-agent: *\nDisallow: /' + response.body = data + + +class Style(View): + __path__ = '/framework/style.css' + + async def get(self, request, response): + response.body = self.app.render('base.css', default_theme) + response.content_type = 'text/css' diff --git a/barkshark_http_async/signatures.py b/barkshark_http_async/signatures.py new file mode 100644 index 0000000..becaea0 --- /dev/null +++ b/barkshark_http_async/signatures.py @@ -0,0 +1,218 @@ +import json, sys + +from Crypto.Hash import SHA256 +from Crypto.PublicKey import RSA +from Crypto.Signature import PKCS1_v1_5 +from base64 import b64decode, b64encode +from datetime import datetime +from functools import lru_cache +from tldextract import extract + +from . import izzylog +from .dotdict import DefaultDotDict, DotDict +from .misc import Url + + +def generate_rsa_key(): + privkey = RSA.generate(2048) + + key = DotDict({'PRIVKEY': privkey, 'PUBKEY': privkey.publickey()}) + key.update({'privkey': key.PRIVKEY.export_key().decode(), 'pubkey': key.PUBKEY.export_key().decode()}) + + return key + + +def parse_signature(signature: str): + return Signature(signature) + + +def verify_headers(headers: dict, method: str, path: str, actor: dict, body=None): + '''Verify a header signature + + headers: A dictionary containing all the headers from a request + method: The HTTP method of the request + path: The path of the HTTP request + actor (optional): A dictionary containing the activitypub actor and the link to the pubkey used for verification + body (optional): The body of the request. Only needed if the signature includes the digest header + fail (optional): If set to True, raise an error instead of returning False if any step of the process fails + ''' + + headers = {k.lower(): headers[k] for k in headers} + headers['(request-target)'] = f'{method.lower()} {path}' + signature = Signature(headers.get('signature')) + digest = headers.get('digest') + missing_headers = [k for k in headers if k in ['date', 'host'] if headers.get(k) == None] + + if not signature: + raise AssertionError('Missing signature') + + ## Add digest header to missing headers list if it doesn't exist + if method.lower() == 'post' and not digest: + missing_headers.append('digest') + + ## Fail if missing date, host or digest (if POST) headers + if missing_headers: + raise AssertionError(f'Missing headers: {missing_headers}') + + ## Fail if body verification fails + if digest: + digest_hash = parse_body_digest(headers.get('digest')) + + if not verify_string(body, digest_hash.sig, digest_hash.alg): + raise AssertionError('Failed body digest verification') + + pubkey = actor.publicKey['publicKeyPem'] + + return sign_pkcs_headers(pubkey, {k:v for k,v in headers.items() if k in signature.headers}, sig=signature) + + +def verify_request(request, actor: dict): + '''Verify a header signature from a SimpleASGI request + + request: The request with the headers to verify + actor: A dictionary containing the activitypub actor and the link to the pubkey used for verification + ''' + + return verify_headers( + headers = request.headers, + method = request.method, + path = request.path, + actor = actor, + body = request.body + ) + + +### Helper functions that shouldn't be used directly ### +def parse_body_digest(digest): + if not digest: + raise AssertionError('Empty digest') + + parsed = DotDict() + alg, sig = digest.split('=', 1) + + parsed.sig = sig + parsed.alg = alg.replace('-', '') + + return parsed + + +def sign_pkcs_headers(key: str, headers: dict, sig=None): + if sig: + head_items = [f'{item}: {headers[item]}' for item in sig.headers] + + else: + head_items = [f'{k.lower()}: {v}' for k,v in headers.items()] + + head_string = '\n'.join(head_items) + head_bytes = head_string.encode('UTF-8') + + KEY = RSA.importKey(key) + pkcs = PKCS1_v1_5.new(KEY) + h = SHA256.new(head_bytes) + + if sig: + try: + return pkcs.verify(h, b64decode(sig.signature)) + + except ValueError: + return False + + else: + return pkcs.sign(h) + + +def sign_request(request, privkey, keyid): + assert isinstance(request.body, bytes) + + request.set_header('(request-target)', f'{request.method.lower()} {request.url.path}') + request.set_header('host', request.url.host) + request.set_header('date', datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')) + + if request.body: + body_hash = b64encode(SHA256.new(request.body).digest()).decode("UTF-8") + request.set_header('digest', f'SHA-256={body_hash}') + request.set_header('content-length', str(len(request.body))) + + sig = { + 'keyId': keyid, + 'algorithm': 'rsa-sha256', + 'headers': ' '.join([k.lower() for k in request.headers.keys()]), + 'signature': b64encode(sign_pkcs_headers(privkey, request.headers)).decode('UTF-8') + } + + sig_items = [f'{k}="{v}"' for k,v in sig.items()] + sig_string = ','.join(sig_items) + + request.set_header('signature', sig_string) + + request.unset_header('(request-target)') + request.unset_header('host') + + return request + + +def verify_string(string, enc_string, alg='SHA256', fail=False): + if type(string) != bytes: + string = string.encode('UTF-8') + + body_hash = b64encode(SHA256.new(string).digest()).decode('UTF-8') + + if body_hash == enc_string: + return True + + if fail: + raise AssertionError('String failed validation') + + else: + return False + + +class Signature(str): + __parts = {} + + def __init__(self, signature: str): + if not signature: + raise AssertionError('Missing signature header') + + split_sig = signature.split(',') + + for part in split_sig: + key, value = part.split('=', 1) + value = value.replace('"', '') + + self.__parts[key.lower()] = value.split() if key == 'headers' else value + + + def __new__(cls, signature: str): + return str.__new__(cls, signature) + + + def __new2__(cls, signature: str): + data = str.__new__(cls, signature) + data.__init__(signature) + + return + + + def __getattr__(self, key): + return self.__parts[key] + + + @property + def sig(self): + return self.__parts['signature'] + + + @property + def actor(self): + return Url(self.keyid.split('#')[0]) + + + @property + def domain(self): + return self.actor.host + + + @property + def top_domain(self): + return '.'.join(extract(self.domain)[1:]) diff --git a/barkshark_http_async/template.py b/barkshark_http_async/template.py new file mode 100644 index 0000000..62b34a8 --- /dev/null +++ b/barkshark_http_async/template.py @@ -0,0 +1,232 @@ +import codecs, traceback, os, json, xml + +from colour import Color as Colour +from functools import partial +from hamlish_jinja import HamlishExtension +from jinja2 import Environment, FileSystemLoader, ChoiceLoader, select_autoescape, Markup +from os import listdir, makedirs +from os.path import isfile, isdir, getmtime, abspath +from xml.dom import minidom + +from . import izzylog +from .dotdict import DotDict +from .path import Path + +try: + from sanic import response as Response + +except ModuleNotFoundError: + Response = None + + +class Template(Environment): + def __init__(self, search=[], global_vars={}, context=None, autoescape=True): + self.search = FileSystemLoader([]) + + super().__init__( + loader=self.search, + extensions=[HamlishExtension], + lstrip_blocks=True, + trim_blocks=True + ) + + self.autoescape = autoescape + self.func_context = context + + self.hamlish_file_extensions=('.haml',) + self.hamlish_enable_div_shortcut=True + self.hamlish_mode = 'indented' + + for path in search: + self.add_search_path(Path(path)) + + self.globals.update({ + 'markup': Markup, + 'cleanhtml': lambda text: ''.join(xml.etree.ElementTree.fromstring(text).itertext()), + 'color': Color, + 'lighten': partial(color_func, 'lighten'), + 'darken': partial(color_func, 'darken'), + 'saturate': partial(color_func, 'saturate'), + 'desaturate': partial(color_func, 'desaturate'), + 'rgba': partial(color_func, 'rgba') + }) + + self.globals.update(global_vars) + + + def add_search_path(self, path, index=None): + if not path.exists: + raise FileNotFoundError(f'Cannot find search path: {path}') + + if path not in self.search.searchpath: + loader = os.fspath(path) + + if index != None: + self.search.searchpath.insert(index, loader) + else: + self.search.searchpath.append(loader) + + + def set_context(self, context): + if not hasattr(context, '__call__'): + izzylog.error('Context is not callable') + return + + if not isinstance(context({}), dict): + izzylog.error('Context does not return a dict or dict-like object') + return + + self.func_context = context + + + def add_env(self, k, v): + self.globals[k] = v + + + def del_env(self, var): + if not self.globals.get(var): + raise ValueError(f'"{var}" not in global variables') + + del self.var[var] + + + def update_env(self, data): + if not isinstance(data, dict): + raise ValueError(f'Environment data not a dict') + + self.globals.update(data) + + + def add_filter(self, funct, name=None): + name = funct.__name__ if not name else name + self.filters[name] = funct + + + def del_filter(self, name): + if not self.filters.get(name): + raise valueError(f'"{name}" not in global filters') + + del self.filters[name] + + + def update_filter(self, data): + if not isinstance(data, dict): + raise ValueError(f'Filter data not a dict') + + self.filters.update(data) + + + def render(self, tplfile, context_data={}, headers={}, cookies={}, request=None, pprint=False): + if not isinstance(context_data, dict): + raise TypeError(f'context for {tplfile} not a dict: {type(context)} {context}') + + context = DotDict(self.globals) + context.update(context_data) + context['request'] = request if request else {'headers': headers, 'cookies': cookies} + + if self.func_context: + # Backwards compat + try: + context = self.func_context(context) + + except TypeError: + context = self.func_context(context, {}) + + if context == None: + izzylog.warning('Template context was set to "None"') + context = {} + + result = self.get_template(tplfile).render(context) + + if pprint and any(map(tplfile.endswith, ['haml', 'html', 'xml'])): + return minidom.parseString(result).toprettyxml(indent=" ") + + else: + return result + + + def response(self, request, tpl, ctype='text/html', status=200, **kwargs): + if not Response: + raise ModuleNotFoundError('Sanic is not installed') + + html = self.render(tpl, request=request, **kwargs) + return Response.HTTPResponse(body=html, status=status, content_type=ctype, headers=kwargs.get('headers', {})) + + +class Color(Colour): + def __init__(self, color): + if isinstance(color, str): + super().__init__(f'#{str(color)}' if not color.startswith('#') else color) + + elif isinstance(color, Colour): + super().__init__(str(color)) + + else: + raise TypeError(f'Color has to be a string or Color class, not {type(color)}') + + + def __repr__(self): + return self.__str__() + + + def __str__(self): + return self.hex_l + + + def lighten(self, multiplier): + return self.alter('lighten', multiplier) + + + def darken(self, multiplier): + return self.alter('darken', multiplier) + + + def saturate(self, multiplier): + return self.alter('saturate', multiplier) + + + def desaturate(self, multiplier): + return self.alter('desaturate', multiplier) + + + def rgba(self, multiplier): + return self.alter('rgba', multiplier) + + + def multi(self, multiplier): + if multiplier >= 100: + return 100 + + elif multiplier <= 0: + return 0 + + return multiplier / 100 + + + def alter(self, action, multiplier): + new_color = Color(self) + + if action == 'lighten': + new_color.luminance += ((1 - self.luminance) * self.multi(multiplier)) + + elif action == 'darken': + new_color.luminance -= (self.luminance * self.multi(multiplier)) + + elif action == 'saturate': + new_color.saturation += ((1 - self.saturation) * self.multi(multiplier)) + + elif action == 'desaturate': + new_color.saturation -= (self.saturation * self.multi(multiplier)) + + elif action == 'rgba': + red = self.red*255 + green = self.green*255 + blue = self.blue*255 + trans = self.multi(multiplier) + return f'rgba({red:0.2f}, {green:0.2f}, {blue:0.2f}, {trans})' + + return new_color + + +def color_func(action, color, multi): + return Color(color).alter(action, multi) diff --git a/barkshark_http_async/utils.py b/barkshark_http_async/utils.py new file mode 100644 index 0000000..b254424 --- /dev/null +++ b/barkshark_http_async/utils.py @@ -0,0 +1,638 @@ +import asyncio, json + +from datetime import datetime, timezone +from io import BytesIO + +from .dotdict import DotDict +from .misc import DateString, Url + +try: + from .http_signatures import sign_headers, verify_headers +except ImportError: + sign_headers = None + verify_headers = None + + +ports = { + 'http': 80, + 'https': 443, + 'ws': 80, + 'wss': 443 +} + +methods = [ + 'CONNECT', + 'DELETE', + 'GET', + 'HEAD', + 'OPTIONS', + 'PATCH', + 'POST', + 'PUT', + 'TRACE' +] + + +class Headers(DotDict): + def __getattr__(self, key): + return self[key.replace('_', '-')] + + + def __setattr__(self, key, value): + self[key.replace('_', '-')] = value + + + def __getitem__(self, key): + return super().__getitem__(key.title()) + + + def __setitem__(self, key, value): + key = key.title() + + if key in ['Cookie', 'Set-Cookie']: + logging.warning('Do not set the "Cookie" or "Set-Cookie" headers') + return + + elif key == 'Date': + value = DateString(value, 'http') + + try: + self[key].append(value) + + except KeyError: + super().__setitem__(key, HeaderItem(key, value)) + + + def get(self, key, default=None): + return super().get(key.title(), default) + + + def as_dict(self): + data = {} + + for k,v in self.items(): + data[k] = str(v) + + return data + + + def getone(self, key, default=None): + try: + return self[key].one() + + except (KeyError, IndexError): + return default + + + def setall(self, key, value): + try: + self[key].set(value) + + except KeyError: + self[key] = value + + +class Cookies(DotDict): + def __setitem__(self, key, value): + if type(value) != CookieItem: + value = CookieItem(key, value) + + super().__setitem__(key, value) + + +class HeaderItem(list): + def __init__(self, key, values): + super().__init__() + self.update(values) + + self.key = key + + + def __str__(self): + return ','.join(str(v) for v in self) + + + def set(self, *values): + self.clear() + + for value in values: + self.append(value) + + + def one(self): + return self[0] + + + def update(self, *items): + for item in items: + self.append(item) + + +class CookieItem: + def __init__(self, key, value, **kwargs): + self.key = key + self.value = value + self.args = DotDict() + + for k,v in kwargs.items(): + if k not in cookie_params.values(): + raise AttributeError(f'Not a valid cookie parameter: {key}') + + setattr(self, k, v) + + + def __str__(self): + text = f'{self.key}={self.value}' + + if self.expires: + text += f'; Expires={self.expires.strftime("%a, %d %b %Y %H:%M:%S GMT")}' + + if self.maxage != None: + text += f'; Max-Age={self.maxage}' + + if self.domain: + text += f'; Domain={self.domain}' + + if self.path: + text += f'; Path={self.path}' + + if self.samesite: + text += f'; SameSite={self.samesite}' + + if self.secure: + text += f'; Secure' + + if self.httponly: + text += f'; HttpOnly' + + return text + + + @classmethod + def from_string(cls, data): + kwargs = {} + + for idx, pairs in enumerate(data.split(';')): + try: + k, v = pairs.split('=', 1) + v = v.strip() + + except: + k, v = pairs, True + + k = k.replace(' ', '') + + if isinstance(v, str) and v.startswith('"') and v.endswith('"'): + v = v[1:-1] + + if idx == 0: + key = k + value = v + + elif k in cookie_params.keys(): + kwargs[cookie_params[k]] = v + + else: + logging.info(f'Invalid key/value pair for cookie: {k} = {v}') + + return cls(key, value, **kwargs) + + + @property + def expires(self): + return self.args.get('Expires') + + + @expires.setter + def expires(self, data): + if isinstance(data, str): + data = DateString(data, 'http') + + elif isinstance(data, int) or isinstance(data, float): + data = datetime.fromtimestamp(data).replace(tzinfo=timezone.utc) + + elif isinstance(data, datetime): + if not data.tzinfo: + data = data.replace(tzinfo=timezone.utc) + + elif isinstance(data, timedelta): + data = datetime.now(timezone.utc) + data + + else: + raise TypeError(f'Expires must be a http date string, timestamp, or datetime object, not {data.__class__.__name__}') + + self.args['Expires'] = data + + + @property + def maxage(self): + return self.args.get('Max-Age') + + + @maxage.setter + def maxage(self, data): + if isinstance(data, int): + pass + + elif isinstance(date, timedelta): + data = data.seconds + + elif isinstance(date, datetime): + data = (datetime.now() - date).seconds + + else: + raise TypeError(f'Max-Age must be an integer, timedelta object, or datetime object, not {data.__class__.__name__}') + + self.args['Max-Age'] = data + + + @property + def domain(self): + return self.args.get('Domain') + + + @domain.setter + def domain(self, data): + if not isinstance(data, str): + raise ValueError(f'Domain must be a string, not {data.__class__.__name__}') + + self.args['Domain'] = data + + + @property + def path(self): + return self.args.get('Path') + + + @path.setter + def path(self, data): + if not isinstance(data, str): + raise ValueError(f'Path must be a string or izzylib.Path object, not {data.__class__.__name__}') + + self.args['Path'] = Path(data) + + + @property + def secure(self): + return self.args.get('Secure') + + + @secure.setter + def secure(self, data): + self.args['Secure'] = boolean(data) + + + @property + def httponly(self): + return self.args.get('HttpOnly') + + + @httponly.setter + def httponly(self, data): + self.args['HttpOnly'] = boolean(data) + + + @property + def samesite(self): + return self.args.get('SameSite') + + + @samesite.setter + def samesite(self, data): + if isinstance(data, bool): + data = 'Strict' if data else 'None' + + elif isinstance(data, str) and data.title() in ['Strict', 'Lax', 'None']: + data = data.title() + + else: + raise TypeError(f'SameSite must be a boolean or one of Strict, Lax, or None, not {data.__class__.__name__}') + + self.args['SameSite'] = data + self.args['Secure'] = True + + + def as_dict(self): + data = DotDict({self.key: self.value}) + data.update(self.args) + + return data + + + def set_defaults(self): + for key in list(self.args.keys()): + del self.args[key] + + + def set_delete(self): + self.args.pop('Expires', None) + self.maxage = 0 + + return self + + +class AsyncTransport: + def __init__(self, reader, writer, timeout=60): + self.reader = reader + self.writer = writer + self.timeout = timeout + + + @property + def client_address(self): + return self.writer.get_extra_info('peername')[0] + + + @property + def client_port(self): + return self.writer.get_extra_info('peername')[1] + + + @property + def closed(self): + return self.writer.is_closing() + + + def eof(self): + return self.reader.at_eof() + + + async def read(self, length=None, timeout=None): + if timeout == False: + return await self.reader.read(length or 2048) + + return await asyncio.wait_for(self.reader.read(length or 2048), timeout or self.timeout) + + + async def read_until(self, bytes, timeout=None): + return await asyncio.wait_for(self.reader.readuntil(bytes), timeout or self.timeout) + + + async def read_headers(self, request=True, timeout=None): + raw_headers = await self.read_until(b'\r\n\r\n', timeout=timeout) + return parse_headers(raw_headers.decode('utf-8'), request) + + + async def write(self, data): + self.writer.write(convert_to_bytes(data)) + await self.writer.drain() + + + async def close(self): + if self.closed: + return + + self.writer.close() + await self.writer.wait_closed() + + +class Request: + def __init__(self, url, body=None, headers={}, cookies={}, method='GET'): + method = method.upper() + + if method not in http_methods: + raise ValueError(f'Invalid HTTP method: {method}') + + self._body = b'' + + self.version = 1.1 + self.url = Url(url) + self.headers = Headers(headers) + self.cookies = Cookies(cookies) + self.method = method + + if self.url.proto not in ['http', 'https', 'ws', 'wss']: + raise ValueError(f'Invalid protocol in url: {self.url.proto}') + + if not self.headers.get('host'): + self.headers.host = self.host + + + @property + def body(self): + return self._body + + + @body.setter + def body(self, data): + self._body = convert_to_bytes(data) + self.headers.setall('Content-Length', str(len(self._body))) + + + @property + def host(self): + return self.url.host + + + @property + def length(self): + return self._body.getbuffer().nbytes + + + @property + def port(self): + return self.url.port or http_ports[self.url.proto] + + + @property + def secure(self): + return self.url.proto in ['https', 'wss'] + + + def set_header(self, key, value): + self.headers.setall(key, value) + + + def unset_header(self, key): + self.headers.pop(key, None) + + + def sign_headers(self, privkey, keyid): + if not sign_request: + raise ImportError(f'Could not import HTTP signatures. Header signing disabled') + + return sign_request(self, privkey, keyid) + + + def verify_headers(self, headhash, pubkey): + pass + + + def compile(self): + first = bytes(f'{self.method} {self.path} HTTP/{self.version}', 'utf-8') + + self.set_header('Content-Length', self.length) + + return create_message(first, self.headers, self.cookies, self.body) + + +class Response: + def __init__(self, status, message, version, headers, cookies): + self._body = BytesIO() + + self.version = version + self.status = status + self.message = message + self.headers = headers if type(headers) == Headers else Headers(headers) + self.cookies = cookies if type(cookies) == Cookies else Cookies(cookies) + + + @property + def body(self): + return self._body + + + @body.setter + def body(self, data): + self._body = convert_to_bytes(data) + self.headers.setall('Content-Length', str(len(self._body))) + + + @property + def host(self): + return self.url.host + + + @property + def port(self): + return self.url.port or http_ports[self.url.proto] + + + @property + def secure(self): + return self.url.proto in ['https', 'wss'] + + + def set_header(self, key, value): + self.headers.setall(key, value) + + + def unset_header(self, key): + self.headers.pop(key, None) + + + async def read(self, length=None): + data = await self.transport.read(length or self.length or 8192) + + if data: + self._body.write(data) + + return data + + + def body(self, encoding=None): + data = self._body.readall() + return data.decode(encoding) if encoding else data + + + def text(self, encoding='utf-8'): + return self.body(encoding) + + + def dict(self): + return DotDict(self.text()) + + + def close(self): + self._body.close() + + + +def parse_headers(raw_headers, request=True): + headers = Headers() + cookies = Cookies() + + for idx, line in enumerate(raw_headers.splitlines()): + if idx == 0: + if request: + method, path, version = line.split() + + else: + split_line = line.split() + version = split_line[0] + status = split_line[1] + + try: message = ' '.join(split_line[2:]) + except IndexError: message = None + + else: + try: key, value = line.split(': ', 1) + except: continue + + if key.lower() == 'cookie': + for cookie in value.split(';'): + try: + item = CookieItem.from_string(cookie) + + except: + traceback.print_exc() + continue + + cookies[item.key] = item + + continue + + else: + headers[key] = value + + version = float(version.replace('HTTP/', '')) + + if request: + return method, path, version, headers, cookies + + return int(status), message, version, headers, cookies + + +def convert_to_bytes(data): + if isinstance(data, str): + data = data.encode('utf-8') + + elif isinstance(data, bytearray): + data = bytes(data) + + elif any(map(isinstance, [data], [dict, list, tuple])): + data = json.dumps(data).encode('utf-8') + + elif not isinstance(data, bytes): + data = str(data).encode('utf-8') + + return data + + +def create_message(data, headers, cookies=None, body=None): + for k,v in headers.items(): + for value in v: + data += bytes(f'\r\n{k}: {value}', 'utf-8') + + if cookies: + for cookie in cookies.values(): + data += bytes(f'\r\nSet-Cookie: {cookie}', 'utf-8') + + if not headers.get('Date'): + data += bytes(f'\r\nDate: {DateString.now("http")}', 'utf-8') + + if not headers.get('Content-Length'): + data += bytes(f'\r\nContent-Length: {len(body)}', 'utf-8') + + if body and not headers.get('Content-Type'): + data += bytes(f'\r\nContent-Type: text/plain') + + data += b'\r\n\r\n' + + if body: + data += body + + return data + + +def first_line(method=None, path=None, status=None, message=None, version='1.1'): + if method and path: + return bytes(f'{method} {path} HTTP/{version}', 'utf-8') + + elif status: + if message: + return bytes(f'HTTP/{version} {status} {message}', 'utf-8') + + return bytes(f'HTTP/{version} {status}', 'utf-8') + + raise TypeError('Please only provide a method and path or a status and message') diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..ddd89e5 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,55 @@ +[metadata] +name = Barkshark HTTP +version = 0.1.0 +author = Zoey Mae +author_email = zoey@barkshark.xyz +url = https://git.barkshark.xyz/izaliamae/barkshark-http +description = Simple async HTTP client and server +license = CNPL +license_file = LICENSE +platform = any +keywords = python http activitypub html css +classifiers = + Development Status :: 3 - Alpha + Intended Audience :: Developers + Operating System :: OS Independent + Programming Language :: Python + Programming Language :: Python 3.6 + Programming Language :: Python 3.7 + Programming Language :: Python 3.8 + Programming Language :: Python 3.9 + Topic :: Software Development :: Libraries :: Python Modules +project_urls = + Bug Tracker = https://git.barkshark.xyz/izaliamae/barkshark-http/issues + Documentation = https://git.barkshark.xyz/izaliamae/barkshark-http/wiki + Source Code = https://git.barkshark.xyz/izaliamae/barkshark-http + +[options] +include_package_data = true +python_requires = >= 3.6 +packages = + barkshark_http + barkshark_http.client + barkshark_http.server +setup_requires = + setuptools >= 38.3.0 +install_requires = + izzylib >= 0.7.0 + argon2-cffi == 21.3.0 + colour == 0.1.5 + hamlish-jinja == 0.3.3 + http-router == 2.6.5 + jinja2 == 3.0.3 + markdown == 3.3.6 + python-magic == 0.4.25 + pycryptodome == 3.14.1 + tldextract == 3.1.2 + +[options.package_data] +barkshark_http = barkshark_http/* + +[bdist_wheel] +universal = false + +[sdist] +formats = zip, gztar diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..a4f49f9 --- /dev/null +++ b/setup.py @@ -0,0 +1,2 @@ +import setuptools +setuptools.setup()