commit feb4366a549db07f7350641a19083467bf7bb1e8 Author: digimint Date: Mon Nov 17 05:34:51 2025 -0600 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83ad24c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.ruff-cache +/.vscode +__pycache__ +/instance +/src/instance +/.local \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bae94e1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e69de29 diff --git a/src/dependencies.md b/src/dependencies.md new file mode 100644 index 0000000..b698706 --- /dev/null +++ b/src/dependencies.md @@ -0,0 +1,8 @@ +### TODO: turn this into a proper file + +- flask (the web framework) +- sqlalchemy, flask-sqlalchemy (database) +- flask-login (for logging in) +- wtforms (for parsing form data) +- psycopg2 (for postgresql) +- pyargon2 (for HashV1) \ No newline at end of file diff --git a/src/taskflower/__init__.py b/src/taskflower/__init__.py new file mode 100644 index 0000000..97e681b --- /dev/null +++ b/src/taskflower/__init__.py @@ -0,0 +1,32 @@ +from flask import Flask, render_template + +from taskflower.auth import taskflower_login_manager +from taskflower.config import config +from taskflower.db import db +from taskflower.api import APIBase +from taskflower.web import web_base + +from taskflower.tools.hibp import hibp_bp + +app = Flask(__name__) + +app.config['SQLALCHEMY_DATABASE_URI'] = config.db_url +app.config['SECRET_KEY'] = config.app_secret + +taskflower_login_manager.init_app(app) # pyright:ignore[reportUnknownMemberType] + +db.init_app(app) + +app.register_blueprint(web_base) +app.register_blueprint(hibp_bp) + +APIBase.register(app) +# print(f'Routes: \n{"\n".join([str(r) for r in APIBase.routes])}') + +@app.route('/') +def index(): + return render_template('home.html') + +@app.route('/license') +def license(): + return render_template('license.html') \ No newline at end of file diff --git a/src/taskflower/__main__.py b/src/taskflower/__main__.py new file mode 100644 index 0000000..d5c512c --- /dev/null +++ b/src/taskflower/__main__.py @@ -0,0 +1,6 @@ +from taskflower import app, db + +if __name__ == '__main__': + with app.app_context(): + db.create_all() + app.run(debug=True) \ No newline at end of file diff --git a/src/taskflower/api/__init__.py b/src/taskflower/api/__init__.py new file mode 100644 index 0000000..93cfd73 --- /dev/null +++ b/src/taskflower/api/__init__.py @@ -0,0 +1,5 @@ +from taskflower.api.v1 import APIv1 +from taskflower.types.route import RouteSet + +APIBase = RouteSet('/api') +APIBase += APIv1 \ No newline at end of file diff --git a/src/taskflower/api/v1/__init__.py b/src/taskflower/api/v1/__init__.py new file mode 100644 index 0000000..5ccc562 --- /dev/null +++ b/src/taskflower/api/v1/__init__.py @@ -0,0 +1,5 @@ +from taskflower.api.v1.task import TaskAPIv1 +from taskflower.types.route import RouteSet + +APIv1 = RouteSet('/v1') +APIv1 += TaskAPIv1 \ No newline at end of file diff --git a/src/taskflower/api/v1/helpers.py b/src/taskflower/api/v1/helpers.py new file mode 100644 index 0000000..3588e53 --- /dev/null +++ b/src/taskflower/api/v1/helpers.py @@ -0,0 +1,66 @@ +from http import HTTPStatus +import json +from typing import Callable, final +from flask import Response + +from taskflower.types import FlaskViewReturnType, JSONSerializeableDict +from taskflower.types.either import Either, Left, Right +from taskflower.types.resource import RES_INTERNAL_ERROR + + +def as_json( + to_json: JSONSerializeableDict +) -> Either[Exception, str]: + try: + return Right(json.dumps(to_json)) + except Exception as e: + return Left(e) + +def as_json_response( + to_json: JSONSerializeableDict, + ok_status: HTTPStatus = HTTPStatus.OK, + parse_error_status: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR, + parse_error_handler: Callable[[Exception], FlaskViewReturnType]|None = None +) -> FlaskViewReturnType: + return as_json(to_json).and_then( + if_okay = lambda x: Response( + x, + mimetype='text/json', + status=ok_status + ), + if_not = lambda err: ( + parse_error_handler(err) if parse_error_handler is not None + else Response( + RES_INTERNAL_ERROR, + mimetype='text/json', + status=parse_error_status + ) + ) + ) + +@final +class StandardResponseV1: + OK = as_json_response( + { + 'status': 'OK', + 'error': None + } + ) + BAD_REQUEST = as_json_response( + { + 'status': 'error', + 'error': 'Bad Request' + }, + HTTPStatus.BAD_REQUEST + ) + NOT_FOUND = as_json_response( + { + 'status': 'error', + 'error': 'Not Found' + }, + HTTPStatus.NOT_FOUND + ) + +def log_error_and_400(e: Exception): + print(e) + return StandardResponseV1.BAD_REQUEST \ No newline at end of file diff --git a/src/taskflower/api/v1/task.py b/src/taskflower/api/v1/task.py new file mode 100644 index 0000000..dd1b78c --- /dev/null +++ b/src/taskflower/api/v1/task.py @@ -0,0 +1,147 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Any +from taskflower.types import JSONSerializableData +from taskflower.types.either import Either, Left, Right +from taskflower.types.json import JSONObjectSchema +from taskflower.types.option import Option, Some +from taskflower.types.resource import APISerializable +from taskflower.types.route import RouteSet +from flask import request +from taskflower.db import db +from taskflower.db.model.task import Task + +from taskflower.api.v1.helpers import StandardResponseV1 as SR, log_error_and_400 + +TaskAPIv1 = RouteSet('/task') + + +@dataclass +class CreateTaskJSONSchema(JSONObjectSchema): + name: str + due: datetime + +@TaskAPIv1.add('', methods=['POST']) +def create_task(): + def _add_to_db(t: CreateTaskJSONSchema): + dbt = Task(name=t.name, due=t.due) # pyright:ignore[reportCallIssue] + db.session.add(dbt) + db.session.commit() + + return dbt + + return Option[JSONSerializableData].encapsulate( + request.get_json(silent=True) + ).and_then( + lambda raw_data: CreateTaskJSONSchema.parse(raw_data), + lambda: Left(ValueError('No JSON data provided.')) + ).map(_add_to_db).and_then( + lambda task: task.as_json(), + lambda exc: log_error_and_400(exc) + ) + + +@TaskAPIv1.add('/', methods=['GET']) +def get_single_task(id: int): + return Option[Task].encapsulate(db.session.get(Task, id)).and_then( + lambda v: v.as_json(), + lambda: SR.NOT_FOUND + ) + + +@TaskAPIv1.add('', methods=['GET']) +def get_task_list(): + task_list: list[Task] = db.session.execute(db.select(Task)).scalars() # pyright:ignore[reportAssignmentType,reportAny] + return APISerializable.all_as_json(tuple(task_list)) + + +@dataclass +class UpdateTaskJSONSchema(JSONObjectSchema): + name: Option[str] + due: Option[datetime] + +@TaskAPIv1.add('/', methods=['PUT']) +def update_task(id: int): + def _get_task(id: int) -> Either[Exception, Task]: + return Option[Task].encapsulate( + db.session.get(Task, id) + ).and_then( + lambda v: Right[Exception, Task](v), + lambda: Left[Exception, Task](KeyError(f'No Task found with primary key {id}!')) + ) + + def _apply_updates(t: Task, updates: dict[str, Any]) -> Either[Exception, Task]: # pyright:ignore[reportExplicitAny] + for k, v in updates.items(): # pyright:ignore[reportAny] + if hasattr(t, k): + if isinstance(v, Option): + if isinstance(v, Some): + setattr(t, k, v.val) # pyright:ignore[reportUnknownMemberType] + else: + setattr(t, k, v) + else: + db.session.rollback() + return Left(KeyError(f'Task has no key ``{k}``')) + + db.session.commit() + return Right(t) + + raw_data = Option[JSONSerializableData].encapsulate( + request.get_json(silent=True) + ).and_then( + lambda json_data: UpdateTaskJSONSchema.parse(json_data), + lambda: Left(ValueError('Request did not contain JSON data!')) + ) + + if isinstance(raw_data, Right): + data = raw_data.val + else: + return log_error_and_400(raw_data.and_then( + lambda val: NotImplementedError('Should never reach here.'), + lambda exc: exc + )) + + return _get_task(id).flat_map(lambda task: ( + _apply_updates(task, { + k: v + for k, v in data.__dict__.items() # pyright:ignore[reportAny] + if v is not None + }) + )).and_then( + lambda task: task.as_json(), + lambda exc: log_error_and_400(exc) + ) + + +@TaskAPIv1.add('/', methods=['DELETE']) +def delete_task(id: int): + def _delete(t: Task): + db.session.delete(t) + db.session.commit() + return SR.OK + + return Option[Task].encapsulate(db.session.get(Task, id)).and_then( + _delete, + lambda: SR.NOT_FOUND + ) + +@TaskAPIv1.add('//complete', methods=['POST']) +def complete_task(id: int): + def _complete(t: Task): + t.complete = True + db.session.commit() + return t.as_json() + return Option[Task].encapsulate(db.session.get(Task, id)).and_then( + _complete, + lambda: SR.NOT_FOUND + ) + +@TaskAPIv1.add('//complete', methods=['DELETE']) +def uncomplete_task(id: int): + def _uncomplete(t: Task): + t.complete = False + db.session.commit() + return t.as_json() + return Option[Task].encapsulate(db.session.get(Task, id)).and_then( + _uncomplete, + lambda: SR.NOT_FOUND + ) \ No newline at end of file diff --git a/src/taskflower/api/v1/user.py b/src/taskflower/api/v1/user.py new file mode 100644 index 0000000..e69de29 diff --git a/src/taskflower/auth/__init__.py b/src/taskflower/auth/__init__.py new file mode 100644 index 0000000..d073b24 --- /dev/null +++ b/src/taskflower/auth/__init__.py @@ -0,0 +1,163 @@ +import hashlib +import logging +import os +from flask import redirect, url_for +from flask_login import LoginManager # pyright:ignore[reportMissingTypeStubs] +import requests + +from taskflower.config import HIBPLocalCacheMode, HIBPMode, config +from taskflower.db import db +from taskflower.db.model.hibp import PwnedPassword +from taskflower.db.model.user import User +from taskflower.types.option import Nothing, Option, Some + +log = logging.getLogger(__name__) + +HIBP_API_BASE = 'https://api.pwnedpasswords.com/range' + +taskflower_login_manager = LoginManager() + +@taskflower_login_manager.user_loader # pyright:ignore[reportUnknownMemberType] +def load_user(id: int) -> User|None: + try: + return Option[User].encapsulate( + db.session.get(User, id) + ).and_then( + lambda u: u, + lambda: None + ) + except Exception as e: + print(f'[in login_manager.user_loader]: {e}') + return None + +@taskflower_login_manager.unauthorized_handler # pyright:ignore[reportUnknownMemberType] +def unauthorized(): + return redirect(url_for('web.auth.login')) + +def _get_breach_count_through_api(hash: str) -> Option[int]: + prefix = hash[:5].upper() + res = requests.get( + f'{HIBP_API_BASE}/{prefix}', + headers={ + 'Add-Padding': 'true' + } + ) + + if not res.ok: + return Nothing() + + try: + hashes = res.text.split() + for hash_line in hashes: + h, c = ( + hash_line.split(':')[0], + int(hash_line.split(':')[1]) + ) + if prefix + h.upper() == hash.upper(): + return Some(c) + + return Some(0) + + except Exception: + return Nothing() + +def _get_bc_from_localfs(hash: str) -> Option[int]: + def _parse_fnames(fnames: list[str]) -> list[tuple[str, int, int]]: + valid_fnames: list[tuple[str, int, int]] = [] + for fname in fnames: + fname_split = fname.split('-') + if len(fname_split) != 2: + log.warning(f'While parsing password hash filenames: `{fname}` is not named correctly! Hash files should be named `[start_prefix]-[end_prefix]`; e.g. `00000-1FFFF`') + continue + + try: + st, en = [int(x[-5:], base=16) for x in fname_split] + except Exception: + log.warning(f'While parsing password hash filenames: `{fname}` is not named correctly! Hash files should be named `[start_prefix]-[end_prefix]`; e.g. `00000-1FFFF`') + else: + valid_fnames.append((fname, st, en)) + + return valid_fnames + + + def _get_file_containing(fnames: list[str], hash: str) -> str|None: + try: + hash_prefix = int(hash[:5], base=16) + except Exception: + log.warning('While looking up password hash prefix: Unable to convert hash prefix name to int!') + else: + valid_fnames = _parse_fnames(fnames) + for fname, st, en in valid_fnames: + if hash_prefix >= st and hash_prefix <= en: + return fname + + return None + + + if isinstance(config.hibp_local_dir, Some): + try: + fname = _get_file_containing( + os.listdir(config.hibp_local_dir.val), + hash + ) + except Exception: + log.warning('While looking for password hash files: Encountered an error! Does the directory exist?') + return Nothing() + + if not fname: + return Nothing() + try: + with open(f'{config.hibp_local_dir.val}/{fname}', 'r') as fin: + while True: + ln = fin.readline() + if not ln: + return Nothing() + + try: + h, c = ln.split(':') + + if h.upper() == hash.upper(): + return Some(int(c)) + except Exception: + log.warning('While looking up password hash: file contains invalid hash line.') + continue + except Exception: + log.warning('While looking up password hash: encountered an error while opening localfs file!') + return Nothing() + else: + log.warning('While looking up password hash: tried to lookup hash from localfs, but ``config.hibp_local_dir`` is not set!') + return Nothing() + +def _get_bc_from_localdb(hash: str) -> Option[int]: + return Option[PwnedPassword].encapsulate( + db.session.get(PwnedPassword, hash.upper()) + ).map( + lambda val: val.count + ) + +def _get_breach_count_through_local_cache(hash: str) -> Option[int]: + match config.hibp_local_mode: + case HIBPLocalCacheMode.STORE_AS_FILES: + return _get_bc_from_localfs(hash) + case HIBPLocalCacheMode.STORE_IN_DB: + return _get_bc_from_localdb(hash) + +def password_breach_count(password: str) -> Option[int]: + hash_obj = hashlib.sha1() + hash_obj.update(password.encode('UTF-8')) + hash = hash_obj.hexdigest().upper() + + match config.hibp_mode: + case HIBPMode.ONLINE_ONLY: + return _get_breach_count_through_api(hash) + case HIBPMode.HYBRID: + return _get_breach_count_through_api(hash).and_then( + lambda val: Some(val), + lambda: _get_breach_count_through_local_cache(hash) + ) + case HIBPMode.LOCAL_ONLY: + return _get_breach_count_through_local_cache(hash) + +def report_incorrect_login_attempt(user: User): + # TODO: Implement account lockout + log.warning(f'Incorrect login attempt for user {user.username}!') \ No newline at end of file diff --git a/src/taskflower/auth/hash.py b/src/taskflower/auth/hash.py new file mode 100644 index 0000000..cd354dd --- /dev/null +++ b/src/taskflower/auth/hash.py @@ -0,0 +1,94 @@ +from dataclasses import dataclass +from enum import Enum, auto +from secrets import token_bytes +from pyargon2 import hash_bytes + +from taskflower.config import config +from taskflower.types.either import Either, Left, Right + +class HashVersion(Enum): + ''' List of all supported hash versions. + + This is currently only ``HASH_V1``; however, this may change in the + future as security considerations evolve. + + HASH_V1: argon2id with t=3 iterations, p=4 lanes, m=2^(16), 128-bit + salt, and 256-bit tag size (the 'second-choice' recommendation + in RFC9106 - we don't use the 'first choice' recommendation + because that one requires 2GiB of RAM per hash, which is not + feasible in a server environment). + ''' + HASH_V1 = auto() + + +@dataclass(frozen=True) +class HashResult: + hash: str + salt: str + hash_params: str + +def make_hash_v1(password: str) -> Either[Exception, HashResult]: + salt_b: bytes = token_bytes(128 // 8) + + hash = hash_bytes( + password.encode('utf-16'), + salt_b, + pepper=config.db_secret.encode('utf-16'), + time_cost=3, + memory_cost=2**16, + parallelism=4, + encoding='hex' + ).upper() + + salt = salt_b.hex().upper() + hash_params = 'v1' + + return Right(HashResult( + hash, + salt, + hash_params + )) + +def check_hash_v1( + password: str, + hash_to_check: str, + salt: str, + _: list[str] +) -> Either[Exception, bool]: + try: + salt_b = bytes.fromhex(salt) + except Exception: + return Left(ValueError('Salt value is not valid hex!')) + hash = hash_bytes( + password.encode('utf-16'), + salt_b, + pepper=config.db_secret.encode('utf-16'), + time_cost=3, + memory_cost=2**16, + parallelism=4, + encoding='hex' + ).upper() + + if hash == hash_to_check.upper(): + return Right(True) + else: + return Right(False) + +def check_hash( + password: str, + hash_to_check: str, + salt: str, + hash_params: str +) -> Either[Exception, bool]: + hash_params_split = hash_params.split(':') + + match hash_params_split[0]: + case 'v1': + return check_hash_v1( + password, + hash_to_check, + salt, + hash_params_split[1:] + ) + case _: + return Left(KeyError(f'Unknown hashing protocol {hash_params_split[0]}! This may happen if you\'re trying to run an older version of the Taskflower application on a database created or edited by a newer version. Please ensure your Taskflower version is up-to-date!')) \ No newline at end of file diff --git a/src/taskflower/config/__init__.py b/src/taskflower/config/__init__.py new file mode 100644 index 0000000..2d75d69 --- /dev/null +++ b/src/taskflower/config/__init__.py @@ -0,0 +1,241 @@ +from dataclasses import Field, dataclass, field +import dataclasses +from enum import Enum, auto +import logging +import os +from typing import Any, Self, get_args, get_origin, override + +from taskflower.types.either import Either, Left, Right, gather_results +from taskflower.types.option import Nothing, Option, Some + +log = logging.getLogger(__name__) + +class ConfigFormatError(Exception): + pass + +class ConfigKeyError[T](Exception): + @override + def __init__( + self, + field: Field[T], + exc: Exception + ): + self.field: Field[T] = field + self.exc: Exception = exc + + super().__init__(str(self)) + + @override + def __str__(self) -> str: + return f'[Config key `{self.field.name}`] {self.__class__.__name__}: {str(self.exc)}' + + @override + def __repr__(self) -> str: + return f'{self.__class__.__name__}({repr(self.field)}, {repr(self.exc)})' + +class ConfigKeyMissingError[T](ConfigKeyError[T]): + @override + def __init__(self, field: Field[T]): + super().__init__(field, KeyError(f'Configuration key {field.name} not found in configuration source!')) + +class ConfigKeyInvalidError[T](ConfigKeyError[T]): + pass + +@dataclass(frozen=True) +class FieldVal[T]: + key: str + val: T + +class EnumFromEnv(Enum): + @classmethod + def from_env(cls, env_val: str) -> Either[Exception, Self]: + for k, v in cls._member_map_.items(): + if k.upper() == env_val.upper(): + if isinstance(v, cls): + return Right(v) + else: + return Left(ValueError(f'{cls.__name__}._member_map_ value is not an instance of {cls.__name__}!')) + + return Left(KeyError(f'No such key `{env_val}` in enum {cls.__name__}')) + +class HIBPMode(EnumFromEnv): + ''' Whether to download a local copy of the HaveIBeenPwned API. Note that + the database is very large (about 40GB at time of writing). + + LOCAL_ONLY: Download the database and ONLY use the downloaded copy. With + this option set, password hashes will never be sent to the + API. + HYBRID: Download the database and keep it as a backup in case the API + goes offline. With this option set, password hashes will be sent + to the API whenever possible, but if the API goes down, the site + will still be able to check passwords. + ONLINE_ONLY: Do NOT download the database. With this option set, + password hashes will ALWAYS be sent to the API. If the API + goes down, breachlist checks will be bypassed entirely. + ''' + LOCAL_ONLY = auto() + HYBRID = auto() + ONLINE_ONLY = auto() + +class HIBPLocalCacheMode(EnumFromEnv): + ''' Whether to keep the local API copy in the database or store it as local + files. Note that there are literal billions of rows in the data, and as + such, the database size will be much, much larger than the local file + size (more than 256GB in my testing). + + STORE_IN_DB: Store the local cache data in the database. This allows + for faster lookup times, but requires a very large amount + of space in the database. + STORE_AS_FILES: Store the local cache as files in the local filesystem. + This is a bit slower, but requires far less space. + ''' + STORE_IN_DB = auto() + STORE_AS_FILES = auto() + +@dataclass(frozen=True) +class ConfigType: + # Application secrets + db_secret : str # Secret value used to 'pepper' password hashes. This MUST + # be generated randomly and cryptographically securely. + # For an example of how to do this: + # https://flask.palletsprojects.com/en/stable/config/#SECRET_KEY + # + # In a multi-node environment, this key must be the same + # across all nodes. + + app_secret : str # Secret value used to generate session tokens. This should + # ALSO be generated securely, and it should be different from + # ``db_secret`` + + # URL to submit issues to + issue_url: Option[str] = field(default_factory=Nothing[str]) + + # Whether to keep a local copy of the HaveIBeenPwned API + hibp_mode: HIBPMode = HIBPMode.ONLINE_ONLY + + # How to keep the local HIBP API copy + hibp_local_mode: HIBPLocalCacheMode = HIBPLocalCacheMode.STORE_AS_FILES + hibp_local_dir : Option[str] = field(default_factory=Nothing[str]) + + # Database connection URL + db_url: str = 'sqlite:///site.db' + + + @classmethod + def from_env(cls) -> Either[list[ConfigKeyError[Any]], Self]: # pyright:ignore[reportExplicitAny] + def _try_get_field[T](fld: Field[T]) -> Either[ConfigKeyError[T], FieldVal[T]]: + def _try_as_type(raw: str, as_t: type[T]) -> Either[Exception, T]: + origin, args = get_origin(as_t), get_args(as_t) + + check_t = ( + origin if origin + else as_t + ) + + if not isinstance(check_t, type): + return Left(TypeError(f'Configuration key `{fld.name}` uses unsupported type {str(check_t)}!')) # pyright:ignore[reportAny] + elif check_t is str: + return Right[Exception, T](raw) # pyright:ignore[reportArgumentType] + elif check_t is int: + try: + return Right[Exception, T](int(raw)) # pyright:ignore[reportArgumentType] + except Exception as e: + return Left(ValueError(f'Couldn\'t coerce `{raw}` to int: {e}')) + elif check_t is bool: + valid_trues = ['y', 'yes', 't', 'true'] + valid_falses = ['n', 'no', 'f', 'false'] + + if raw.lower() in valid_trues: + return Right[Exception, T](True) # pyright:ignore[reportArgumentType] + elif raw.lower() in valid_falses: + return Right[Exception, T](False) # pyright:ignore[reportArgumentType] + else: + return Left(ValueError(f'Couldn\'t coerce `{raw}` to bool!')) + elif issubclass(check_t, EnumFromEnv): + return check_t.from_env(raw).lmap( + lambda exc: ValueError(f'Couldn\'t coerce `{raw}` to {check_t.__name__}: {exc}') + ) # pyright:ignore[reportReturnType] + elif check_t is Option and len(args) == 1: + return _try_as_type( # pyright:ignore[reportReturnType] + raw, + args[0] # pyright:ignore[reportAny] + ).map( + lambda res: Some(res) + ) + else: + return Left(TypeError(f'Configuration key `{fld.name}` uses unsupported type {str(check_t)}!')) # pyright:ignore[reportUnknownArgumentType] + + return ( + Right[ConfigKeyError[T], str](os.environ[fld.name]) if fld.name in os.environ + else Right[ConfigKeyError[T], str](os.environ[fld.name.upper()]) if fld.name.upper() in os.environ + else Left[ConfigKeyError[T], str](ConfigKeyMissingError[T](fld)) + ).flat_map( + lambda vraw: _try_as_type( + vraw, fld.type # pyright:ignore[reportArgumentType] + ).map( + lambda val: FieldVal[T](fld.name, val) + ).lmap( + lambda exc: ConfigKeyInvalidError[T](fld, exc) + ) + ) + + fields = [ + _try_get_field(f) + for f in dataclasses.fields(cls) + ] + + errs, field_vals = gather_results(fields) + true_errs: list[ConfigKeyError[Any]] = [] # pyright:ignore[reportExplicitAny] + for err in errs: + # If the value has a default, it's okay if it's not in the + # environment vars + if isinstance(err, ConfigKeyMissingError): + if err.field.default_factory != dataclasses.MISSING: + log.debug(f'Populating {err.field.name} from default_factory') + field_vals.append( + FieldVal( + err.field.name, + err.field.default_factory() # pyright:ignore[reportAny] + ) + ) + elif err.field.default != dataclasses.MISSING: + log.debug(f'Populating {err.field.name} from default') + field_vals.append( + FieldVal( + err.field.name, + err.field.default # pyright:ignore[reportAny] + ) + ) + else: + true_errs.append(err) + log.error(f'Required field {err.field.name} is missing a value!') + else: + true_errs.append(err) + log.error(f'While parsing fields: {err}') + + if true_errs: + return Left(true_errs) + + return Right( + cls( + **{ + f.key: f.val # pyright:ignore[reportAny] + for f in field_vals + } + ) + ) + +def get_cfg() -> ConfigType: + c = ConfigType.from_env() + if isinstance(c, Left): + log.error('Unable to build config! The following errors may be the cause:') + for er in c.val: + log.error(str(er)) + raise ConfigFormatError() + elif isinstance(c, Right): + return c.val + else: + # should never happen + raise TypeError() + +config = get_cfg() \ No newline at end of file diff --git a/src/taskflower/config/version.py b/src/taskflower/config/version.py new file mode 100644 index 0000000..d0460b3 --- /dev/null +++ b/src/taskflower/config/version.py @@ -0,0 +1,9 @@ +from enum import Enum, auto + + +class VersionType(Enum): + DEVELOPMENT = auto() # An unstable "development" version. This should not be used in production! + PRERELEASE = auto() # An intermediate 'pre-release' version. May contain serious bugs! + RELEASE = auto() # A full release. + +version: str = '0.0.1-DEVEL' \ No newline at end of file diff --git a/src/taskflower/db/__init__.py b/src/taskflower/db/__init__.py new file mode 100644 index 0000000..2d70b23 --- /dev/null +++ b/src/taskflower/db/__init__.py @@ -0,0 +1,7 @@ +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy.orm import DeclarativeBase + +class Base(DeclarativeBase): + pass + +db = SQLAlchemy(model_class=Base) \ No newline at end of file diff --git a/src/taskflower/db/helpers/__init__.py b/src/taskflower/db/helpers/__init__.py new file mode 100644 index 0000000..d94684f --- /dev/null +++ b/src/taskflower/db/helpers/__init__.py @@ -0,0 +1,15 @@ +from taskflower.db import db +from taskflower.types.either import Either, Left, Right + +def add_to_db[T]( + ob: T, + do_commit: bool = True +) -> Either[Exception, T]: + try: + db.session.add(ob) + if do_commit: + db.session.commit() + except Exception as e: + return Left(e) + else: + return Right(ob) \ No newline at end of file diff --git a/src/taskflower/db/helpers/date.py b/src/taskflower/db/helpers/date.py new file mode 100644 index 0000000..16a7a62 --- /dev/null +++ b/src/taskflower/db/helpers/date.py @@ -0,0 +1,9 @@ +from datetime import datetime + +def form_response_to_date(resp: str|None): + if resp is None: + return None + try: + return datetime.strptime(resp, '%Y-%m-%d') + except Exception: + return None \ No newline at end of file diff --git a/src/taskflower/db/model/hibp.py b/src/taskflower/db/model/hibp.py new file mode 100644 index 0000000..26a6d4e --- /dev/null +++ b/src/taskflower/db/model/hibp.py @@ -0,0 +1,20 @@ +from sqlalchemy import Integer, String +from sqlalchemy.orm import Mapped, mapped_column +from taskflower.db import db + +class PwnedPassword(db.Model): + ''' An offline copy of the HIBP API. + + Whether and when it's used depends on the value of config.hibp_mode: + - HIBPMode.LOCAL_ONLY - download the database and use it exclusively. + password hashes are never sent to the API. + - HIBPMode.HYBRID - download the database, but only use it as a backup + in case we lose access to the API. Password hashes + are sent to the API when possible. + - HIBPMode.ONLINE_ONLY - don't download the database. only use the + API, and if the API is unavailable, ignore + the breachlist check entirely. Password + hashes are always sent to the API. + ''' + hash: Mapped[str] = mapped_column(String, primary_key=True) + count: Mapped[int] = mapped_column(Integer) \ No newline at end of file diff --git a/src/taskflower/db/model/sign_up_code.py b/src/taskflower/db/model/sign_up_code.py new file mode 100644 index 0000000..bd3266a --- /dev/null +++ b/src/taskflower/db/model/sign_up_code.py @@ -0,0 +1,15 @@ +from datetime import datetime +from sqlalchemy import DateTime, ForeignKey, Integer, String +from sqlalchemy.sql import func +from sqlalchemy.orm import Mapped, mapped_column +from taskflower.db import db +from taskflower.types.resource import APISerializable + +class SignUpCode(db.Model, APISerializable): + id: Mapped[int] = mapped_column(Integer, primary_key=True) + code: Mapped[str] = mapped_column(String, unique=True) + created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + expires: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + + # Relations + created_by: Mapped[int] = mapped_column(Integer, ForeignKey('user.id')) \ No newline at end of file diff --git a/src/taskflower/db/model/task.py b/src/taskflower/db/model/task.py new file mode 100644 index 0000000..22425cf --- /dev/null +++ b/src/taskflower/db/model/task.py @@ -0,0 +1,97 @@ +from datetime import datetime +from sqlalchemy.sql import func +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import Boolean, ForeignKey, Integer, String, DateTime +from typing import Self, override +from wtforms import DateTimeLocalField, Form, StringField, validators +import humanize + +from taskflower.db import db +from taskflower.db.model.user import User +from taskflower.types import JSONSerializeableDict +from taskflower.types.either import Either, Left, Right +from taskflower.types.resource import APISerializable + +def _due_str(due: datetime) -> str: + now = datetime.now() + delta = datetime.now() - due + if now > due: + return humanize.naturaldelta( + delta + ) + ' ago' + else: + return 'in ' + humanize.naturaldelta( + delta + ) + +class TaskForm(Form): + name: StringField = StringField( + 'Task Name', + [ + validators.Length(min=1, message='Task name is too short!') + ] + ) + due: DateTimeLocalField = DateTimeLocalField( + 'Due Date' + ) + description: StringField = StringField( + 'Description', + [ + validators.Optional() + ] + ) + + +class Task(db.Model, APISerializable): + __tablename__: str = 'task' + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String) + description: Mapped[str] = mapped_column(String) + due: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + complete: Mapped[bool] = mapped_column(Boolean, default=False) + owner: Mapped[int] = mapped_column(Integer, ForeignKey('user.id')) + + @override + def _serialize(self) -> Either[Exception, JSONSerializeableDict]: + return Right( + { + 'id' : self.id, + 'name' : self.name, + 'due' : str(self.due), + 'due_rel' : _due_str(self.due), + 'description' : self.description, + 'created' : str(self.created), + 'complete' : self.complete + } + ) + + form: type[Form] = TaskForm + + @classmethod + def from_form( + cls: type[Self], + form_data: Form, + current_user: User + ) -> Either[Exception, Self]: + if isinstance(form_data, TaskForm): + if form_data.validate(): + return Right(cls( + name=form_data.name.data, # pyright:ignore[reportCallIssue] + due=form_data.due.data, # pyright:ignore[reportCallIssue] + description=( # pyright:ignore[reportCallIssue] + form_data.description.data + if form_data.description.data + else '' + ), + owner=current_user.id # pyright:ignore[reportCallIssue] + )) + else: + return Left(ValueError( + '``form_data`` failed validation!' + )) + else: + return Left(TypeError( + '``form_data`` must be a subclass of ``TaskForm``' + )) \ No newline at end of file diff --git a/src/taskflower/db/model/user.py b/src/taskflower/db/model/user.py new file mode 100644 index 0000000..19d3cbb --- /dev/null +++ b/src/taskflower/db/model/user.py @@ -0,0 +1,38 @@ +from datetime import datetime +from typing import override +from sqlalchemy import Boolean, DateTime, Integer, String +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func +from taskflower.db import db +from taskflower.types.resource import APISerializable + +from flask_login import UserMixin # pyright:ignore[reportMissingTypeStubs] + + +class User(db.Model, UserMixin, APISerializable): + __tablename__: str = 'user' + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + username: Mapped[str] = mapped_column(String, unique=True) + display_name: Mapped[str] = mapped_column(String(256)) + created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + # Status + enabled: Mapped[bool] = mapped_column(Boolean) + + # Pronouns + pr_sub: Mapped[str] = mapped_column(String) + pr_obj: Mapped[str] = mapped_column(String) + pr_dep: Mapped[str] = mapped_column(String) + pr_ind: Mapped[str] = mapped_column(String) + pr_ref: Mapped[str] = mapped_column(String) + pr_plr: Mapped[bool] = mapped_column(Boolean) + + # Authentication + password: Mapped[str] = mapped_column(String(256)) + salt: Mapped[str] = mapped_column(String(256)) + hash_params: Mapped[str] = mapped_column(String(256)) + + @override + def get_id(self) -> str: + return str(self.id) \ No newline at end of file diff --git a/src/taskflower/static/forms.css b/src/taskflower/static/forms.css new file mode 100644 index 0000000..e93d347 --- /dev/null +++ b/src/taskflower/static/forms.css @@ -0,0 +1,38 @@ +.default-form .callout { + background-color: var(--form-bg-1); + color: var(--on-form); + padding: 1rem; + border: 1px solid var(--accent-1); + border-radius: 1rem; +} + +.default-form dl { + margin-bottom: 1rem; + margin-top: 1rem; +} + +.suppress-labels label { + clip-path: inset(50%); + height: 1px; + margin: -1px; + overflow: hidden; + width: 1px; + white-space: nowrap; + position: absolute; +} + +#submit-form { + padding: 1rem; + margin-top: 2rem; + background-color: var(--accent-1-hlt); + color: var(--on-accent-1); + font-size: larger; + font-weight: bold; + border: 2px solid var(--accent-1); + border-radius: 1rem; + transition: background-color 0.5s; +} + +#submit-form:hover { + background-color: var(--accent-1); +} \ No newline at end of file diff --git a/src/taskflower/static/license.txt b/src/taskflower/static/license.txt new file mode 100644 index 0000000..bae94e1 --- /dev/null +++ b/src/taskflower/static/license.txt @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. \ No newline at end of file diff --git a/src/taskflower/static/scelune-logo-narrow.svg b/src/taskflower/static/scelune-logo-narrow.svg new file mode 100644 index 0000000..eeebec9 --- /dev/null +++ b/src/taskflower/static/scelune-logo-narrow.svg @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/taskflower/static/style.css b/src/taskflower/static/style.css new file mode 100644 index 0000000..48c2a32 --- /dev/null +++ b/src/taskflower/static/style.css @@ -0,0 +1,134 @@ +:root { + --bg: #0e0c15; + --fg: #ffffff; + --accent-1: #d12f7b; + --on-accent-1: var(--bg); + --accent-1-hlt: #c7749b; + + --footer: #222027; + --on-footer: #9b9b9b; + + --block-1: #3b2631; + --on-block-1: #ffa1cd; + + --header-font: sans-serif; + --body-font: sans-serif; + --footer-font: sans-serif; + + --table-row-bg-1: #331826; + --table-row-bg-2: #462837; + --on-table-row: #ffc4d1; + + --form-bg-1: #331826; + --form-bg-2: #462837; + --on-form: #ffc4d1; +} + +html { + height: 100%; + width: 100%; + background-color: var(--bg); + color: var(--fg); + margin: 0; +} + +body { + height: 100%; + display: flex; + flex-direction: column; + margin: 0; +} + +a { + color: var(--accent-1); +} + +#content { + display: flex; + width: 100%; + flex: 1 0; +} + +#sidebar { + background-color: var(--block-1); + color: var(--on-block-1); + display: flex; + flex-direction: column; +} + +#sidebar a { + display: block; + border-top: 1px solid var(--on-block-1); + border-bottom: 1px solid var(--on-block-1); +} + +#sidebar-top { + font-size: larger; + border-top: unset !important; +} + +#sidebar a { + color: var(--on-block-1); + text-decoration: none; + font-family: var(--header-font); + padding: 0.5rem 2rem; +} + +#footer { + background-color: var(--footer); + color: var(--on-footer); + flex: 0 0; + padding: 1rem; + font-family: var(--footer-font); + display: flex; + flex-direction: row; +} + +#footer .footer-logo { + flex: 0 1; + margin: auto; + margin-right: 1rem; +} + +#footer .footer-content { + flex: 1 0; + margin: auto; + margin-left: 1rem; +} + +#scelune-logo { + min-width: 8rem; +} + +#main-content { + margin: 2rem; + font-family: var(--body-font); + width: 100% +} + +h1, h2, h3 { + margin: 0; + font-family: var(--header-font); +} + +h1 { + font-size: xx-large; + font-weight: bold; +} + +h2 { + font-size: larger; + font-weight: bold; + text-decoration: underline; +} + +h3 { + font-size: large; + font-style: italic; +} + +#sidebar-spacer { + flex: 1 1; + border-top: 1px solid var(--on-block-1); + border-bottom: 1px solid var(--on-block-1); +} \ No newline at end of file diff --git a/src/taskflower/templates/_formhelpers.html b/src/taskflower/templates/_formhelpers.html new file mode 100644 index 0000000..6df368a --- /dev/null +++ b/src/taskflower/templates/_formhelpers.html @@ -0,0 +1,26 @@ +{% macro render_field(field) %} +
{{ field.label }}
+
{{ field(**kwargs)|safe }} + {% if field.errors %} +
+

Validation issues:

+
    + {% for error in field.errors %} +
  • {{ error }}
  • + {% endfor %} +
+
+ {% endif %} +
+{% endmacro %} + +{% macro render_inline(field) %} +{{ field.label }} +{{ field(**kwargs)|safe }} +{% endmacro %} + +{% macro render_errors_for(field) %} +{% for error in field.errors %} +
  • {{ error }}
  • +{% endfor %} +{% endmacro %} \ No newline at end of file diff --git a/src/taskflower/templates/auth/login.html b/src/taskflower/templates/auth/login.html new file mode 100644 index 0000000..adde08f --- /dev/null +++ b/src/taskflower/templates/auth/login.html @@ -0,0 +1,29 @@ +{% extends "main.html" %} +{% from "_formhelpers.html" import render_field %} + +{% block head_extras %} + +{% endblock %} +{% block title %}Log In{% endblock %} + +{% block main_content %} +
    +

    Log In

    + +
    + {{ render_field(form.username) }} + {{ render_field(form.password) }} +
    + + {% if login_err %} +
    +

    Login Error

    +

    {{ login_err }}

    +
    + {% endif %} + + +
    + +

    No account? Register here!

    +{% endblock %} \ No newline at end of file diff --git a/src/taskflower/templates/base.html b/src/taskflower/templates/base.html new file mode 100644 index 0000000..f6ef605 --- /dev/null +++ b/src/taskflower/templates/base.html @@ -0,0 +1,28 @@ + + + + {% block head %} + + {% block raw_title %}{% endblock %} + {% block head_extras %}{% endblock %} + {% endblock %} + + +
    + {% block content %}{% endblock %} +
    + + + \ No newline at end of file diff --git a/src/taskflower/templates/error/error.html b/src/taskflower/templates/error/error.html new file mode 100644 index 0000000..face059 --- /dev/null +++ b/src/taskflower/templates/error/error.html @@ -0,0 +1,11 @@ +{% extends "main.html" %} + +{% block title %}Error: {{ err_name }}{% endblock %} + +{% block main_content %} +

    Error: {{ err_name }}

    +

    {{ err_description }}

    + +

    Return Home

    + +{% endblock %} \ No newline at end of file diff --git a/src/taskflower/templates/home.html b/src/taskflower/templates/home.html new file mode 100644 index 0000000..bab845b --- /dev/null +++ b/src/taskflower/templates/home.html @@ -0,0 +1,8 @@ +{% extends "main.html" %} + +{% block title %}Main Page{% endblock %} + +{% block main_content %} +

    Main Content

    +

    Here's the content of the page. Some text, some text, some text.

    +{% endblock %} \ No newline at end of file diff --git a/src/taskflower/templates/license.html b/src/taskflower/templates/license.html new file mode 100644 index 0000000..24663b0 --- /dev/null +++ b/src/taskflower/templates/license.html @@ -0,0 +1,11 @@ +{% extends "main.html" %} + +{% block title %}License Information{% endblock %} + +{% block main_content %} +

    License Information

    + +

    Taskflower is released under the GNU Affero General Public License, +Version 3. Click +here for the full license text.

    +{% endblock %} \ No newline at end of file diff --git a/src/taskflower/templates/main.html b/src/taskflower/templates/main.html new file mode 100644 index 0000000..9085506 --- /dev/null +++ b/src/taskflower/templates/main.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block raw_title %}{% block title %}{% endblock %} | Taskflower🌺{% endblock %} +{% block content %} + +
    + {% block main_content %}{% endblock %} +
    +{% endblock %} \ No newline at end of file diff --git a/src/taskflower/templates/new_task.html b/src/taskflower/templates/new_task.html new file mode 100644 index 0000000..2ebd84f --- /dev/null +++ b/src/taskflower/templates/new_task.html @@ -0,0 +1,16 @@ +{% extends "main.html" %} +{% from "_formhelpers.html" import render_field %} + +{% block title %}Create Task{% endblock %} + +{% block main_content %} +

    Create Task

    +
    +
    + {{ render_field(form.name) }} + {{ render_field(form.due) }} + {{ render_field(form.description) }} +
    +

    Create Task

    +
    +{% endblock %} \ No newline at end of file diff --git a/src/taskflower/templates/task/_shorttask.html b/src/taskflower/templates/task/_shorttask.html new file mode 100644 index 0000000..296a27b --- /dev/null +++ b/src/taskflower/templates/task/_shorttask.html @@ -0,0 +1,44 @@ +{% macro inline_task_header() %} + + + +

    Task

    + + +

    Due

    + + +{% endmacro %} + +{% macro inline_task(task) %} + + + {% if task.complete %} + + {% else %} + + {% endif %} + + + + + +

    {{ task.due_rel }}

    + + + + + +

    Task Details

    +

    + Task ID: {{ task.id }} +
    Created: {{ task.created }} +
    Due: {{ task.due }} +

    +

    {{ task.description }}

    + + +{% endmacro %} \ No newline at end of file diff --git a/src/taskflower/templates/task/list.html b/src/taskflower/templates/task/list.html new file mode 100644 index 0000000..639b7fc --- /dev/null +++ b/src/taskflower/templates/task/list.html @@ -0,0 +1,109 @@ +{% extends "main.html" %} +{% from "task/_shorttask.html" import inline_task, inline_task_header %} + +{% block title %}My Tasks{% endblock %} + +{% block main_content %} +

    My Tasks

    + + + + + + + + + {{ inline_task_header() }} + {% for task in tasks %} + {{ inline_task(task) }} + {% endfor %} + +
    + + + + + +{% endblock %} \ No newline at end of file diff --git a/src/taskflower/templates/task_detail.html b/src/taskflower/templates/task_detail.html new file mode 100644 index 0000000..00be5a2 --- /dev/null +++ b/src/taskflower/templates/task_detail.html @@ -0,0 +1,8 @@ +{% extends "main.html" %} + +{% block title %}{{ task.name }}{% endblock %} + +{% block main_content %} +

    Task {{ task.id }}: {{ task.name }}

    +

    Due {{ task.due }}

    +{% endblock %} \ No newline at end of file diff --git a/src/taskflower/templates/user/new_user.html b/src/taskflower/templates/user/new_user.html new file mode 100644 index 0000000..3d977b5 --- /dev/null +++ b/src/taskflower/templates/user/new_user.html @@ -0,0 +1,97 @@ +{% extends "main.html" %} +{% from "_formhelpers.html" import render_field, render_inline, render_errors_for %} + +{% block head_extras %} + +{% endblock %} +{% block title %}Register{% endblock %} + +{% block main_content %} +
    +

    New User Registration

    +
    + {{ render_field(form.username) }} + {{ render_field(form.display_name) }} + {{ render_field(form.pronouns) }} +
    + +
    +

    Please fill in the blanks:

    +

    + One day, USER was browsing the web. + {{ render_inline(form.pr_sub) }} opened up a website, and a new + world of digital data opened itself before + {{ render_inline(form.pr_obj) }}. USER couldn't believe + {{ render_inline(form.pr_dep)}} eyes - all this data, and it + was all {{ render_inline(form.pr_ind) }}! USER congratulated + {{ render_inline(form.pr_ref) }} on this amazing discovery. +

    +
      + {{ render_errors_for(form.pr_sub) }} + {{ render_errors_for(form.pr_obj) }} + {{ render_errors_for(form.pr_dep) }} + {{ render_errors_for(form.pr_ind) }} + {{ render_errors_for(form.pr_ref) }} +
    + +
    + {{ render_field(form.pr_plr) }} +
    +
    + +
    + {{ render_field(form.password) }} + {{ render_field(form.confirm_password) }} +
    + +
    +

    Password requirements:

    +
      +
    • Password must be between 16 and 512 characters long
    • +
    • Password cannot have been breached in the Pwned Passwords Database
    • +
    +

    + Numbers, spaces, special characters (e.g. *_-^&), and Unicode characters + (e.g. 💚⚠️☢️🌇🌺) are all encouraged but not required! Consider using a + password manager + to make your life easier! +

    +
    + +
    + + + + + +{% endblock %} \ No newline at end of file diff --git a/src/taskflower/tools/__init__.py b/src/taskflower/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/taskflower/tools/hibp/__init__.py b/src/taskflower/tools/hibp/__init__.py new file mode 100644 index 0000000..cc1c434 --- /dev/null +++ b/src/taskflower/tools/hibp/__init__.py @@ -0,0 +1,439 @@ +''' Populates the database with pwned password hashes from HaveIBeenPwned. + + This should be run on initial setup, and again annually (to ensure that + the records are up-to-date). +''' + +import asyncio +from dataclasses import dataclass +from datetime import datetime, timedelta +import multiprocessing +from multiprocessing.synchronize import BoundedSemaphore +import os +import click +from flask import Blueprint +import requests + +from taskflower.auth import password_breach_count +from taskflower.db import db +from taskflower.db.model.hibp import PwnedPassword +from taskflower.types.either import Either, Left, Right, reduce_either + +API_BASE = 'https://api.pwnedpasswords.com/range' +HASH_CHARS = '0123456789ABCDEF' + +hibp_bp = Blueprint( + 'populate_hibp', + __name__, + cli_group='runtool' +) + +class APIResponseError(Exception): + pass + +class DBError(Exception): + pass + +db_sem = asyncio.Semaphore(1) +api_sem = asyncio.Semaphore(10) + +@dataclass(frozen=True) +class PwnData: + hash: str + count: int + +def _try_get(hash: str) -> list[PwnData]|None: + response = requests.get(f'{API_BASE}/{hash}') + if response.status_code != 200: + print(f'[ >{hash}]: Received response code {response.status_code}!') + return None + + hashes = response.text.split() + pwn_datas: list[PwnData] = [] + for hash in hashes: + hsplit = hash.split(':') + if len(hsplit) != 2: + return None + pwn_datas.append(PwnData(hsplit[0], int(hsplit[1]))) + + return pwn_datas + +def download_hash( + chunk: list[str], + dl_dir: str, + dl_sem: BoundedSemaphore, + retry_count: int = 32 +) -> Either[Exception, str]: + os.makedirs(dl_dir, exist_ok=True) + fname = f'{dl_dir}/{chunk[0]}-{chunk[-1]}' + count = 0 + with open(fname, 'w') as ofile: + for hash in chunk: + tries = 0 + while True: + with dl_sem: + pwn_datas = _try_get(hash) + if not pwn_datas: + print(f'[{hash}]: Error retrieving data!') + tries += 1 + if tries > retry_count: + print(f'[{hash}]: Max retries exceeded. Cancelling.') + return Left(ValueError(f'[{chunk[0]}-{chunk[-1]}]: Max retries exceeded while downloading data.')) + else: + continue + + for pd in pwn_datas: + count += 1 + _ = ofile.write(f'{hash}{pd.hash}:{pd.count}\n') + break + + print(f'[{chunk[0]}-{chunk[-1]}] Downloaded {count} hashes.') + return Right(fname) + +def insert_into_db(from_fname: str) -> Either[Exception, int]: + with open(from_fname, 'r') as fin: + lines = fin.readlines() + datas: list[PwnedPassword] = [] + for line in lines: + line_split = line.split(':') + if len(line_split) != 2: + return Left(ValueError(f'[{from_fname}]: Couldn\'t parse input line {line}!')) + else: + datas.append(PwnedPassword(hash=line_split[0], count=int(line_split[1]))) # pyright:ignore[reportCallIssue] + + try: + db.session.add_all(datas) + db.session.commit() + print(f'[{lines[0][:5]}]: Inserted {len(datas)} hashes into the database.') + return Right(len(datas)) + except Exception as e: + db.session.rollback() + print(f'[{lines[0][:5]}]: Error while inserting values into the database: {e}') + return Left(e) + +dl_semaphore: BoundedSemaphore = multiprocessing.BoundedSemaphore(10) + +def _inner(chunk: list[str]): + return download_hash( + chunk, + 'tmp/', + dl_semaphore + ) + +def _inner2(chunk: list[str], dl_dir: str): + return download_hash( + chunk, + dl_dir, + dl_semaphore + ) + +@hibp_bp.cli.command('dl-hibp') +@click.option('-s', '--start', default="00000", help="First hash prefix to download") +@click.option('-e', '--end', default="FFFFF", help="Final hash prefix to download") +@click.option('-d', '--save-to', 'save_dir', default='./tmp', help='Directory to save files to') +@click.option('-c', '--chunk-size', default=500, help='Number of hash prefixes to store in one file') +@click.option('-y', '--yes', is_flag=True, help='Always answer yes to user prompts.') +def dl_one( + start: str, + end: str, + save_dir: str, + chunk_size: int, + yes: bool +): + print(f'[main] Downloading hashes {start}-{end} into {save_dir} with {chunk_size} hashes per file.') + + start_i = int(start, base=16) + end_i = int(end, base=16) + + hashes = [ + f'{i:05X}' + for i in range(start_i, end_i+1) + ] + + chunks = [ + hashes[i:i+chunk_size] + for i in range(0, len(hashes), chunk_size) + ] + + print(f'[main] Assembled {len(hashes)} hashes into {len(chunks)} chunks.') + + if not yes: + resp = input('Proceed? [Y/n] >') + if resp.lower() != 'y': + print('Operation cancelled.') + return + + print('Very well. Initiating download...') + + args_asm = zip( + chunks, + [save_dir for _ in range(len(chunks))] + ) + + with multiprocessing.Pool(10) as pool: + res = reduce_either( + pool.starmap( + _inner2, + args_asm + ) + ) + + print(res.and_then( + lambda r: f'Wrote results to {len(r)} files', + lambda exc: f'Encountered an exception while downloading files: {exc}' + )) + +def _tformat(td: timedelta) -> str: + s = '' + if td.days > 0: + s += f'{td.days} days, ' + + secs = td.seconds + + if secs > 3600: + s += f'{secs // 3600} hours, ' + secs %= 3600 + + if secs > 60: + s += f'{secs // 60} minutes, and ' + secs %= 60 + + s += f'{int(secs)} seconds.' + + return s + + +@hibp_bp.cli.command('hibp-to-database') +@click.option('-s', '--start', default="00000", help="First hash prefix to insert") +@click.option('-e', '--end', default="FFFFF", help="Final hash prefix to insert") +@click.option('-X', '--clear-db', is_flag=True, help='Clear the database before populating') +@click.option('-y', '--yes', is_flag=True, help='Always answer yes to user prompts.') +@click.option('-d', '--data-dir', default='./tmp', help='Directory where data files are stored') +@click.option('-x', '--delete-files', is_flag=True, help='Delete local hash files after uploading them') +def hibp_to_database( + start: str, + end: str, + clear_db: bool, + yes: bool, + data_dir: str, + delete_files: bool +): + try: + fnames = [ + f'{data_dir}/{fn}' + for fn in os.listdir(data_dir) + ] + except Exception as e: + print(f'[main] Error encountered while looking for files in {data_dir}: {e}') + return + + if len(fnames) == 0: + print(f'[main] Error: Couldn\'t find any files in {data_dir}') + return + + print(f'[main] Checking {len(fnames)} files in {data_dir}...') + def _get_containing(fnames: list[str], x: int) -> tuple[str, int]: + for fname in fnames: + try: + st, en = fname.split('-') + st, en = [ + int(n, base=16) + for n in (st[-5:], en) + ] + except Exception: + continue + + if x >= st and x <= en: + return (fname, en) + + return '', -1 + + st: int = int(start, base=16) + en: int = int(end, base=16) + x: int = st + + print(f'[main] Starting from {st:05X} and ending at {en:05X}') + + fnames_to_read: list[str] = [] + + while True: + fname, new_x = _get_containing(fnames, x) + if len(fname) == 0: + print(f'[main] Error: Couldn\'t find a file containing the hash prefix {x:05X}! Cancelling.') + return + fnames_to_read.append(fname) + fnames.remove(fname) + + if new_x >= en: + break + + x = new_x + 1 + + print(f'[main] Found {len(fnames_to_read)} relevant files.') + + if len(fnames_to_read) == 0: + print('[main] Error: No files to read!') + return + + print( + f'[main] About to read {en-st} hash prefixes from {len(fnames_to_read)} files.\n' + + (' This operation will clear all existing hashes from the database!\n' if clear_db else '') + + (' This operation will delete all local hash files!\n' if delete_files else '') + + (' This might take a while.') + ) + + if not yes: + user_res = input('Continue? [Y/n] >') + + if user_res.lower() != 'y': + print('[main] Operation cancelled.') + + print('[main] Very well. Beginning operation.') + + if clear_db: + print('[main] Clearing database...') + try: + PwnedPassword.__table__.drop(db.engine, checkfirst=True) # pyright:ignore[reportAttributeAccessIssue,reportUnknownMemberType] + PwnedPassword.__table__.create(db.engine) # pyright:ignore[reportAttributeAccessIssue,reportUnknownMemberType] + print('[main] Database cleared.') + except Exception as e: + print(f'[main] Error while clearing database: {e}. Cancelling.') + return + + op_start = datetime.now() + + print(f'[main] Started reading data at {op_start}') + + for dex, fname in enumerate(fnames_to_read): + with open(fname, 'r') as fin: + try: + db_objects = [ + PwnedPassword( + hash=ln.split(':')[0], # pyright:ignore[reportCallIssue] + count=int(ln.split(':')[1]) # pyright:ignore[reportCallIssue] + ) + for ln in fin.readlines() + if ( + (int(ln[:5], base=16) >= st) + and (int(ln[:5], base=16) <= en) + ) + ] + + db.session.add_all(db_objects) + db.session.commit() + print(f'[{fname}] Successfully inserted {len(db_objects)} hashes into the database.') + if delete_files: + os.remove(fname) + print(f'[{fname}] Deleted local file.') + except Exception as e: + print(f'[{fname}] Error while processing file: {e}') + finally: + cur_time = datetime.now() + elapsed = cur_time - op_start + pct_done = (dex+1)/len(fnames_to_read) + pct_left = 1.0 - pct_done + + time_est = (elapsed/pct_done)*pct_left + + print(f'[main] Processed {dex+1}/{len(fnames_to_read)} files ({(dex+1)/len(fnames_to_read)*100:.02}%) in {_tformat(elapsed)} - ETA {_tformat(time_est)}') + + print('[main] Finished processing files!') + + +@hibp_bp.cli.command('populate-hibp') +def populate_hibp_wrapper(): + print('This tool will download the entire HaveIBeenPwned API into the database. The database will grow by 40GB or more. Is that okay?') + resp = input('[Y/n]: ') + if resp != 'y': + print('Cancelled.') + return + + print('The existing HIBP cache table will be dropped. Is this okay?') + resp = input('[Y/n]: ') + if resp != 'y': + print('Cancelled') + return + + print('Very well. Clearing table and assembling hashes...') + + PwnedPassword.__table__.drop(db.engine, checkfirst=True) # pyright:ignore[reportAttributeAccessIssue,reportUnknownMemberType] + PwnedPassword.__table__.create(db.engine) # pyright:ignore[reportAttributeAccessIssue,reportUnknownMemberType] + + hashes: list[str] = [] + + for hash_a in HASH_CHARS: + for hash_b in HASH_CHARS: + for hash_c in HASH_CHARS: + for hash_d in HASH_CHARS: + for hash_e in HASH_CHARS: + hashes.append( + hash_a + + hash_b + + hash_c + + hash_d + + hash_e + ) + + chunk_size = 500 + + chunks = [ + hashes[i:i+chunk_size] + for i in range(0, len(hashes), chunk_size) + ] + + # chunks = chunks[:100] + + print(f'[main] Assembled {len(hashes)} hashes into {len(chunks)} chunks. Downloading...') + + # dl_semaphore = + + + + with multiprocessing.Pool(10) as pool: + res = reduce_either( + pool.map( + _inner, + chunks + ) + ) + + if isinstance(res, Right): + files = res.val + print(f'[main] Downloaded {len(hashes)} hashes into {len(files)} files. Adding to database...') + final_res_list: list[Either[Exception, int]] = [] + for fname in files: + r = insert_into_db(fname) + os.remove(fname) + final_res_list.append(r) + final_res = reduce_either( + final_res_list + ) + + if isinstance(final_res, Right): + print(f'Inserted {sum(final_res.val)} hashes into the database.') + else: + print(f'Encountered an error while adding to the database: {final_res.val}') + else: + print(f'Encountered an error while downloading files: {res.val}') + +@hibp_bp.cli.command('check-hibp') +@click.argument('pwd') +def check_pwd(pwd: str): + print( + password_breach_count(pwd).and_then( + lambda c: f'Password has been breached {c} times!', + lambda: 'Password was not found in the breach database.' + ) + ) + + # hash = sha1() + # hash.update(pwd.encode('utf-8')) + # res = hash.hexdigest().upper() + + # print(Option[PwnedPassword].encapsulate(db.session.get( + # PwnedPassword, + # res + # )).and_then( + # lambda p: f'Password with hash {res} has been breached {p.count} times!', + # lambda: f'Password with hash {res} was not found in the breach db.' + # )) \ No newline at end of file diff --git a/src/taskflower/types/__init__.py b/src/taskflower/types/__init__.py new file mode 100644 index 0000000..f134c0d --- /dev/null +++ b/src/taskflower/types/__init__.py @@ -0,0 +1,28 @@ +from http import HTTPStatus +from types import NoneType +from typing import Any, Callable, TypeAlias + +from flask import Response + +FlaskViewReturnType = ( + Response + | tuple[str, HTTPStatus] + | str +) + +JSONSerializeableDict = dict[Any, Any]|list[Any] # pyright:ignore[reportExplicitAny] + +ArbitraryKwargs = dict[str, Any] # pyright:ignore[reportExplicitAny] +ViewFunction = Callable[..., FlaskViewReturnType] +JSONSerializableData: TypeAlias = 'str|int|float|dict[str, JSONSerializableData]|list[JSONSerializableData]|bool|NoneType' + +def ann[T](x: T|None) -> T: + ''' Assert Not None + + Raises an ``AssertionError`` if ``x`` is None; otherwise, + return s ``x`` unmodified. + ''' + if x is not None: + return x + + raise AssertionError('``ann()`` called on None!') \ No newline at end of file diff --git a/src/taskflower/types/either.py b/src/taskflower/types/either.py new file mode 100644 index 0000000..b92a565 --- /dev/null +++ b/src/taskflower/types/either.py @@ -0,0 +1,206 @@ +from abc import ABC, abstractmethod +from functools import reduce +from typing import Callable, final, override + +class Either[L, R](ABC): + @property + @abstractmethod + def is_left(self) -> bool: + raise NotImplementedError + + @property + @abstractmethod + def is_right(self) -> bool: + raise NotImplementedError + + @property + @abstractmethod + def val(self) -> L|R: + raise NotImplementedError + + @abstractmethod + def map[X](self, f: Callable[[R], X]) -> 'Either[L, X]': + raise NotImplementedError + + @abstractmethod + def lmap[X](self, f: Callable[[L], X]) -> 'Either[X, R]': + raise NotImplementedError() + + @abstractmethod + def side_effect[X](self, f: Callable[[R], X]) -> 'Either[L, R]': + raise NotImplementedError() + + @abstractmethod + def lside_effect[X](self, f: Callable[[L], X]) -> 'Either[L, R]': + raise NotImplementedError() + + @abstractmethod + def flat_map[X](self, f: Callable[[R], 'Either[L, X]']) -> 'Either[L, X]': + raise NotImplementedError + + @abstractmethod + def and_then[X]( + self, + if_okay: Callable[[R], X], + if_not: Callable[[L], X] + ) -> X: + raise NotImplementedError + +@final +class Left[L, R](Either[L, R]): + def __init__(self, lf: L): + self._l = lf + + @property + @override + def is_left(self) -> bool: + return True + + @property + @override + def is_right(self) -> bool: + return False + + @property + @override + def val(self) -> L: + return self._l + + @override + def map[X](self, f: Callable[[R], X]) -> 'Either[L, X]': + return Left[L, X](self.val) + + @override + def lmap[X](self, f: Callable[[L], X]) -> 'Either[X, R]': + return Left[X, R](f(self.val)) + + @override + def side_effect[X](self, f: Callable[[R], X]) -> 'Either[L, R]': + return self + + @override + def lside_effect[X](self, f: Callable[[L], X]) -> 'Either[L, R]': + _ = f(self.val) + return self + + @override + def flat_map[X](self, f: Callable[[R], 'Either[L, X]']) -> 'Either[L, X]': + return Left[L, X](self.val) + + @override + def and_then[X]( + self, + if_okay: Callable[[R], X], + if_not: Callable[[L], X] + ) -> X: + return if_not(self.val) + + @override + def __eq__(self, value: object) -> bool: + if isinstance(value, Left): + return self.val == value.val # pyright:ignore[reportUnknownMemberType, reportUnknownVariableType] + else: + return False + + @override + def __str__(self) -> str: + return f'Left({self.val.__str__()})' + + @override + def __repr__(self) -> str: + return f'Left({self.val.__repr__()})' + + +@final +class Right[L, R](Either[L, R]): + def __init__(self, r: R): + self._r = r + + @property + @override + def is_left(self) -> bool: + return False + + @property + @override + def is_right(self) -> bool: + return True + + @property + @override + def val(self) -> R: + return self._r + + @override + def map[X](self, f: Callable[[R], X]) -> 'Either[L, X]': + return Right[L, X](f(self.val)) + + @override + def lmap[X](self, f: Callable[[L], X]) -> 'Either[X, R]': + return Right[X, R](self.val) + + @override + def side_effect[X](self, f: Callable[[R], X]) -> 'Either[L, R]': + _ = f(self.val) + return self + + @override + def lside_effect[X](self, f: Callable[[L], X]) -> 'Either[L, R]': + return self + + @override + def flat_map[X](self, f: Callable[[R], 'Either[L, X]']) -> 'Either[L, X]': + return f(self.val) + + @override + def and_then[X]( + self, + if_okay: Callable[[R], X], + if_not: Callable[[L], X] + ) -> X: + return if_okay(self.val) + + @override + def __eq__(self, value: object) -> bool: + if isinstance(value, Right): + return self.val == value.val + else: + return False + + @override + def __str__(self) -> str: + return f'Right({self.val.__str__()})' + + @override + def __repr__(self) -> str: + return f'Right({self.val.__repr__()})' + + +def reduce_either[L, R](vs: list[Either[L, R]]) -> Either[L, list[R]]: + return reduce( + lambda prev, cur: prev.flat_map( + lambda v: ( + Right[L, list[R]](v + [cur.val]) if isinstance(cur, Right) + else Left[L, list[R]](cur.val) # pyright:ignore[reportArgumentType] (cur MUST be Left if it is not Right, but python doesn't have the mechanics to enforce) + ) + ), + vs, + Right[L, list[R]]([]) + ) + +def gather_errors[L, R](vs: list[Either[L, R]]) -> list[L]: + return [ + v.val + for v in vs + if isinstance(v, Left) + ] + +def gather_successes[L, R](vs: list[Either[L, R]]) -> list[R]: + return [ + v.val + for v in vs + if isinstance(v, Right) + ] + +def gather_results[L, R](vs: list[Either[L, R]]) -> tuple[list[L], list[R]]: + return gather_errors(vs), gather_successes(vs) \ No newline at end of file diff --git a/src/taskflower/types/json.py b/src/taskflower/types/json.py new file mode 100644 index 0000000..3e607c1 --- /dev/null +++ b/src/taskflower/types/json.py @@ -0,0 +1,264 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from datetime import datetime +from types import GenericAlias, NoneType, UnionType +from typing import Any, Self, TypeAliasType, override +import typing +from taskflower.types import JSONSerializableData +from taskflower.types.either import Either, Left, Right, reduce_either +from taskflower.types.option import Option, Some, Nothing + +type JSONSchemaAttrWithoutList = str|int|float|bool|JSONSchema|NoneType|datetime|Option[JSONSchemaAttrWithoutList] +type JSONSchemaAttr = str|int|float|bool|JSONSchema|NoneType|datetime|JSONListSchema[JSONSchemaAttr]|Option[JSONSchemaAttr] +type InstanceableType = type|UnionType + +def _is_basic_attr(v: type[JSONSchemaAttrWithoutList]|type[Any]) -> bool: # pyright:ignore[reportExplicitAny] + return ( + v is str or + v is bool or + v is int or + v is float or + v is NoneType or + v is datetime or + issubclass(v, JSONSchema) + ) + +def _is_attr(v: type[JSONSchemaAttr]|type[Any]) -> bool: # pyright:ignore[reportExplicitAny] + if _origin(v) is JSONListSchema: + return _attrib(v).and_then( + lambda vv: _is_attr(vv), + lambda: False + ) + if _origin(v) is Option: + return _attrib(v).and_then( + lambda vv: _is_attr(vv), + lambda: False + ) + + return _is_basic_attr(v) + +def _unpack_aliased( + t: GenericAlias|UnionType|type +) -> Option[tuple[InstanceableType, tuple[InstanceableType, ...]]]: + origin, args = typing.get_origin(t), typing.get_args(t) + if origin: + if not isinstance(origin, TypeAliasType): + return Some((origin, args)) + else: + return Nothing() + else: + return Some((t, ())) + + +def _origin(t: GenericAlias|type) -> type: + origin = typing.get_origin(t) + return origin if origin else t # pyright:ignore[reportReturnType] + +def _attrib(t: GenericAlias|type) -> Option[type]: + attrib = typing.get_args(t) + if len(attrib) == 1: + return Some(attrib[0]) + else: + return Nothing() + +def _datetime_from_json(jstr: str) -> Either[Exception, datetime]: + try: + return Right(datetime.strptime(jstr, '%Y-%m-%d')) + except Exception as e: + return Left(e) + + +def _datetime_to_json(dt: datetime) -> str: + return dt.strftime('%Y-%m-%d') + + +def _to_json(d: JSONSchemaAttr) -> JSONSerializableData: + return ( + d if ( + isinstance(d, bool) or + isinstance(d, int) or + isinstance(d, str) or + isinstance(d, float) or + isinstance(d, NoneType) + ) + else d.and_then( + lambda val: _to_json(val), + lambda: None + ) if isinstance(d, Option) + else _datetime_to_json(d) if isinstance(d, datetime) + else d.to_json() if isinstance(d, JSONSchema) # pyright:ignore[reportUnnecessaryIsInstance] + else None + ) + + +def _from_json[T: JSONSchemaAttr]( + json_obj: JSONSerializableData, + as_type: type[T], + key: str = '' +) -> Either[Exception, JSONSchemaAttr]: + if ( + _origin(as_type) is Option and + isinstance(opattr:=_attrib(as_type), Some) + ): + if json_obj is None: + return Right(Nothing()) + else: + return _from_json(json_obj, opattr.val, key).map( + lambda v: Some(v) + ) + if ( + isinstance(json_obj, str) and + as_type is datetime + ): + return _datetime_from_json(json_obj) # pyright:ignore[reportReturnType] + + if ( + isinstance(json_obj, dict) and + issubclass(jschem:=_origin(as_type), JSONSchema) + ): + return jschem.parse(json_obj, key) + + if ( + isinstance(json_obj, list) and + issubclass(jlst:=_origin(as_type), JSONListSchema) and + isinstance(lsattr:=_attrib(as_type), Some) + ): + return jlst.parse(json_obj, key, lsattr.val) + + if ( + isinstance(json_obj, bool) or + isinstance(json_obj, int) or + isinstance(json_obj, str) or + isinstance(json_obj, float) or + isinstance(json_obj, NoneType) + ): + return ( + Right[Exception, T](json_obj) + if isinstance(json_obj, _origin(as_type)) + else Left(TypeError(f'[At key ``{key}``]: Type mismatch: expected ``{str(as_type)}``; got ``{str(type(json_obj))}`` ({str(json_obj)})')) + ) + + return Left(TypeError(f'[At key ``{key}``]: Unexpected or mismatched type ``{str(type(json_obj))}`` (value ``{str(json_obj)}``)')) + + +class JSONSchema(ABC): + @abstractmethod + def __init__(self, *args: Any, **kwargs: Any): # pyright:ignore[reportExplicitAny, reportAny] + raise NotImplementedError() + + @abstractmethod + def to_json(self) -> JSONSerializableData: + raise NotImplementedError() + + @classmethod + @abstractmethod + def parse( + cls, + json_data: JSONSerializableData, + key: str = '' + ) -> 'Either[Exception, Self]': + raise NotImplementedError() + +@dataclass +class JSONObjectSchema(JSONSchema): + @classmethod + def _schema(cls) -> dict[str, type[JSONSchemaAttr]]: + annotations = { + k: v + for k, v in typing.get_type_hints(cls).items() + if k not in ['self', 'return'] + } + return { + k:v + for k, v in annotations.items() # pyright:ignore[reportAny] + if _is_attr(v) + } + + + @override + def to_json(self) -> JSONSerializableData: + return { + k: getattr(self, k) + for k in self._schema().keys() + } + + @override + @classmethod + def parse( + cls, + json_data: JSONSerializableData, + key: str = '' + ) -> 'Either[Exception, Self]': + if not isinstance(json_data, dict): + return Left(TypeError(f'[At key ``{key}``]: Tried to parse non-dict JSON data type {str(type(json_data))} as a dict!')) + + for k, v in cls._schema().items(): + if k not in json_data: + if _origin(v) is Option: + json_data[k] = None + else: + return Left(KeyError(f'[At key ``{key}``]: Required key ``{k}`` not found!')) + + return reduce_either([ + _from_json(json_data[k], t, f'{key}/{k}') + for k, t in cls._schema().items() + ]).map(lambda values: ( + dict(zip(cls._schema().keys(), values)) + )).map(lambda kwargs: cls(**kwargs)) + + +class JSONListSchema[T:JSONSchemaAttr](JSONSchema): + def __init__(self, ls: list[T], t: type[T]): + self._l: list[T] = ls + self._t: type[T] = t + + def __getitem__(self, key: int) -> T: + if key < len(self._l): + return self._l[key] + else: + raise IndexError(f'Index {key} out of range for sequence with length {len(self._l)}') + + @override + def __str__(self) -> str: + return ( + f'JSONListSchema[{str(self._t)}]: [' + + ','.join([str(v) for v in self._l]) + + ']' + ) + + @override + def __repr__(self) -> str: + return ( + 'JSONListSchema([' + + ','.join([repr(v) for v in self._l]) + + f'],{repr(self._t)})' + ) + + @override + def to_json(self) -> JSONSerializableData: + return [_to_json(v) for v in self._l] + + @override + @classmethod + def parse( + cls, + json_data: list[T]|JSONSerializableData, + key: str = '', + t: type[T]|NoneType = None + ) -> Either[Exception, Self]: + if not isinstance(json_data, list): + return Left(TypeError(f'[At key ``{key}``]: JSONListSchema can only parse lists; input data is .')) + + if t is None: + return Left(TypeError(f'[At key ``{key}``]: JSONListSchema must have an inner type (``t``) defined.')) + + res_data: list[Either[Exception, JSONSchemaAttr]] = [ + _from_json(val, t, str(dex)) + for dex, val in enumerate(json_data) + ] + + return reduce_either( + res_data + ).map(lambda vs: ( + cls(vs, t) # pyright:ignore[reportArgumentType] + )) \ No newline at end of file diff --git a/src/taskflower/types/option.py b/src/taskflower/types/option.py new file mode 100644 index 0000000..3a3dc30 --- /dev/null +++ b/src/taskflower/types/option.py @@ -0,0 +1,123 @@ +from abc import ABC, abstractmethod +from functools import reduce +from typing import Callable, final, override + + +class Option[T](ABC): + @property + @abstractmethod + def val(self) -> T|None: + raise NotImplementedError + + @abstractmethod + def map[Y](self, f: Callable[[T], Y]) -> 'Option[Y]': + raise NotImplementedError + + @abstractmethod + def flat_map[Y](self, f: 'Callable[[T], Option[Y]]') -> 'Option[Y]': + raise NotImplementedError + + @abstractmethod + def and_then[Y]( + self, + if_some: Callable[[T], Y], + if_none: Callable[[], Y] + ) -> Y: + raise NotImplementedError + + @staticmethod + def encapsulate(v: T|None) -> 'Option[T]': + return Some(v) if v is not None else Nothing() + + +@final +class Some[T](Option[T]): + def __init__(self, t: T): + self._t = t + + @property + @override + def val(self) -> T: + return self._t + + @override + def map[Y](self, f: Callable[[T], Y]) -> 'Option[Y]': + return Some(f(self._t)) + + @override + def flat_map[Y](self, f: 'Callable[[T], Option[Y]]') -> 'Option[Y]': + return f(self._t) + + @override + def and_then[Y]( + self, + if_some: Callable[[T], Y], + if_none: Callable[[], Y] + ) -> Y: + return if_some(self._t) + + @override + def __str__(self) -> str: + return f'Some({str(self.val)})' + + @override + def __repr__(self) -> str: + return f'Some({repr(self.val)})' + + @override + def __eq__(self, value: object) -> bool: + if isinstance(value, Some): + return self.val == value.val # pyright:ignore[reportUnknownMemberType, reportUnknownVariableType] + else: + return False + +@final +class Nothing[T](Option[T]): + def __init__(self): + pass + + @property + @override + def val(self) -> None: + return None + + @override + def map[Y](self, f: Callable[[T], Y]) -> 'Option[Y]': + return Nothing() + + @override + def flat_map[Y](self, f: 'Callable[[T], Option[Y]]') -> 'Option[Y]': + return Nothing() + + @override + def and_then[Y]( + self, + if_some: Callable[[T], Y], + if_none: Callable[[], Y] + ) -> Y: + return if_none() + + @override + def __str__(self) -> str: + return 'Nothing()' + + @override + def __repr__(self) -> str: + return 'Nothing()' + + @override + def __eq__(self, value: object) -> bool: + return isinstance(value, Nothing) + + +def reduce_option[T](vs: list[Option[T]]) -> Option[list[T]]: + return reduce( + lambda prev, cur: prev.flat_map( + lambda v: ( + Some(v + [cur.val]) if isinstance(cur, Some) + else Nothing() + ) + ), + vs, + Some[list[T]]([]) + ) \ No newline at end of file diff --git a/src/taskflower/types/resource.py b/src/taskflower/types/resource.py new file mode 100644 index 0000000..91b3f99 --- /dev/null +++ b/src/taskflower/types/resource.py @@ -0,0 +1,121 @@ +# from abc import ABC, abstractmethod +from flask import Response +from functools import reduce +from http import HTTPStatus +import json +from typing import Callable, Self +from wtforms import Form + +from taskflower.types import FlaskViewReturnType, JSONSerializeableDict +from taskflower.types.either import Either, Left, Right +import taskflower.wrap as wrap + +RES_BAD_REQUEST = json.dumps({'error': 'Bad Request'}) +RES_INTERNAL_ERROR = json.dumps({'error': 'Internal server error'}) + +def _debug_print_and_400(e: Exception): + print(e) + return Response( + RES_BAD_REQUEST, + mimetype='text/json', + status=HTTPStatus.BAD_REQUEST + ) + +def _200(jstr: str): + return Response( + jstr, + mimetype='text/json', + status=HTTPStatus.OK + ) + +def _to_json(o: JSONSerializeableDict) -> Either[Exception, str]: + return wrap.as_either(json.dumps)(o) + +class FormCreatable: + ''' Trait that indicates the object has an associated form object for + creating it. + + Members must implement the ``form`` property (returning the + default creation form) and the ``from_form()`` classmethod + (constructing an object instance from form data). + ''' + form: type[Form] = None # pyright:ignore[reportAssignmentType] + + @classmethod + def from_form( + cls: type[Self], + form_data: Form # pyright:ignore[reportUnusedParameter] + ) -> Either[Exception, Self]: + raise NotImplementedError() + + +class APISerializable: + ''' Trait that indicates the object can be returned directly by the JSON API. + + Members must implement ``_serialize()``; the trait then implements + methods to generate a JSON response based on the serialized result. + ''' + + # @abstractmethod + def _serialize(self) -> Either[Exception, JSONSerializeableDict]: + ''' Serialize this object and return a dict that can be parsed into + JSON. + ''' + raise NotImplementedError() + + def sanitize(self) -> Either[Exception, JSONSerializeableDict]: + return self._serialize() + + def as_json( + self, + on_success: Callable[[str], FlaskViewReturnType] + = _200, + on_failure: Callable[[Exception], FlaskViewReturnType] + = _debug_print_and_400 + ) -> FlaskViewReturnType: + ''' Convert the object into a JSON API response. + + If the object fails to serialize or the serialized response cannot + be converted to JSON, ``on_failure`` is called with the returned + exception. If serialization and JSON conversion succeed, + ``on_success`` is called. + + ``on_failure`` and ``on_success`` should return a response, in any + format that Flask accepts (i.e. anything that you can return from a + view function). + ''' + return self._serialize().flat_map( + lambda val: _to_json(val) + ).and_then( + if_okay = lambda val: on_success(val), + if_not = lambda err: on_failure(err) + ) + + @staticmethod + def all_as_json( + items: 'tuple[APISerializable, ...]', + on_success: Callable[[str], FlaskViewReturnType] + = _200, + on_failure: Callable[[Exception], FlaskViewReturnType] + = _debug_print_and_400 + ) -> FlaskViewReturnType: + def _reduce(acc: Either[Exception, list[JSONSerializeableDict]], val: Either[Exception, JSONSerializeableDict]) -> Either[Exception, list[JSONSerializeableDict]]: + if isinstance(acc, Left): + return acc + elif isinstance(val, Left): + return Left(val.val) + elif isinstance(val, Right) and isinstance(acc, Right): + return Right(acc.val + [val.val]) + else: + return Left(ValueError()) + + return reduce( + _reduce, + [itm._serialize() for itm in items], + Right([]) + ).flat_map( + lambda v: _to_json(v) + ).and_then( + if_okay = lambda val: on_success(val), + if_not = lambda err: on_failure(err) + ) \ No newline at end of file diff --git a/src/taskflower/types/route.py b/src/taskflower/types/route.py new file mode 100644 index 0000000..0110853 --- /dev/null +++ b/src/taskflower/types/route.py @@ -0,0 +1,75 @@ +from dataclasses import dataclass +from typing import Any, Callable +from flask import Flask + +from taskflower.types import ArbitraryKwargs, ViewFunction + +@dataclass(frozen=True) +class Route(): + url: str + endpoint: str|None + view_func: ViewFunction|None + provide_automatic_options: bool|None + options: ArbitraryKwargs + + @property + def kwargs(self) -> ArbitraryKwargs: + return { + 'rule': self.url, + 'endpoint': self.endpoint, + 'view_func': self.view_func, + 'provide_automatic_options': self.provide_automatic_options, + **self.options + } + + def prefix(self, prefix: str) -> 'Route': + return Route( + prefix + self.url, + self.endpoint, + self.view_func, + self.provide_automatic_options, + self.options + ) + + +class RouteSet(): + def __init__( + self, + prefix: str = '', + routes: list[Route]|None = None + ): + self.routes: list[Route] = routes if routes else [] + self.prefix: str = prefix + + def register(self, app: Flask): + for route in self.routes: + app.add_url_rule(**route.kwargs) # pyright:ignore[reportAny] + + def add( + self, + url : str, + endpoint : str |None = None, + provide_automatic_options: bool|None = None, + **options : Any # pyright:ignore[reportAny, reportExplicitAny] + ) -> Callable[[ViewFunction], ViewFunction]: + def inner(func: ViewFunction): + self.routes.append( + Route( + url, endpoint, func, + provide_automatic_options, options + ).prefix(self.prefix) + ) + return func + return inner + + + def __add__(self, o: 'RouteSet') -> 'RouteSet': + return RouteSet( + self.prefix, + ( + self.routes + [ + r.prefix(self.prefix) + for r in o.routes + ] + ) + ) \ No newline at end of file diff --git a/src/taskflower/web/__init__.py b/src/taskflower/web/__init__.py new file mode 100644 index 0000000..b70667d --- /dev/null +++ b/src/taskflower/web/__init__.py @@ -0,0 +1,10 @@ +from flask import Blueprint +from taskflower.web.task import web_tasks +from taskflower.web.user import web_user +from taskflower.web.auth import web_auth + +web_base = Blueprint('web', __name__, url_prefix='/') + +web_base.register_blueprint(web_tasks) +web_base.register_blueprint(web_user) +web_base.register_blueprint(web_auth) \ No newline at end of file diff --git a/src/taskflower/web/auth/__init__.py b/src/taskflower/web/auth/__init__.py new file mode 100644 index 0000000..ddb9dd4 --- /dev/null +++ b/src/taskflower/web/auth/__init__.py @@ -0,0 +1,140 @@ + +from flask import Blueprint, redirect, render_template, request, url_for +from flask_login import login_required, login_user, logout_user # pyright:ignore[reportMissingTypeStubs, reportUnknownVariableType] +from wtforms import Form, PasswordField, StringField +from wtforms.validators import Length + +from taskflower.auth import report_incorrect_login_attempt +from taskflower.auth.hash import check_hash +from taskflower.db import db +from taskflower.db.model.user import User +from taskflower.types import ann +from taskflower.types.either import Either, Left, Right +from taskflower.types.option import Option +from taskflower.web.errors import ResponseErrorForbidden, ResponseErrorNotFound + + +web_auth: Blueprint = Blueprint( + 'auth', + __name__, + '/templates', + url_prefix='/auth' +) + +class LogInForm(Form): + username: StringField = StringField( + 'Username', + [ + Length( + min=1, + max=32, + message='Usernames must be between 1 and 32 characters long!' + ) + ] + ) + password: PasswordField = PasswordField( + 'Password', + [ + Length( + min=16, + max=512, + message='Passwords must be between 16 and 512 characters long!' + ) + ] + ) + +@web_auth.route('/login', methods=['GET', 'POST']) +def login(): + def _get_user(username: str) -> Either[Exception, User]: + return Option[User].encapsulate( + db.session.execute( + db.select( # pyright:ignore[reportAny] + User + ).filter_by( + username=username + ) + ).scalar_one_or_none() + ).and_then( + lambda val: Right[Exception, User](val), + lambda: Left[Exception, User](ResponseErrorNotFound( + request.method, + request.path, + 'User not found in database' + )) + ) + + def _validate_user(user: User, password: str) -> Either[Exception, User]: + return check_hash( + password, + user.password, + user.salt, + user.hash_params + ).flat_map( + lambda is_valid: ( + Right(user) if is_valid + else Left(ResponseErrorNotFound( + request.method, + request.path, + 'Incorrect login attempt!' + )) + ) + ).lside_effect( + lambda exc: report_incorrect_login_attempt(user) + ) + + form_data = LogInForm(request.form) + if request.method == 'POST' and form_data.validate(): + return _get_user( + ann(form_data.username.data) + ).flat_map( + lambda usr: _validate_user( + usr, + ann(form_data.password.data) + ) + ).flat_map( + lambda usr: Option[bool].encapsulate( + login_user(usr) + ).and_then( + lambda okay: ( + Right(usr) if okay + else Left(ResponseErrorForbidden( + request.method, + request.path, + 'Account disabled.' + )) + ), + lambda: Left(ResponseErrorForbidden( + request.method, + request.path, + 'Account disabled.' + )) + ) + ).and_then( + lambda usr: redirect(url_for('web.task.all')), + lambda exc: render_template( + 'auth/login.html', + form=form_data, + login_err=( + ( + 'Invalid username or password.' + ) if isinstance(exc, ResponseErrorNotFound) + else ( + 'Your account has been disabled. Please contact an administrator if you believe this is an error.' + ) if isinstance(exc, ResponseErrorForbidden) + else ( + 'An unknown error occurred.' + ) + ) + ) + ) + + return render_template( + 'auth/login.html', + form=form_data + ) + +@web_auth.route('/logout') +@login_required +def logout(): + _ = logout_user() + return redirect(url_for('index')) \ No newline at end of file diff --git a/src/taskflower/web/errors/__init__.py b/src/taskflower/web/errors/__init__.py new file mode 100644 index 0000000..0507949 --- /dev/null +++ b/src/taskflower/web/errors/__init__.py @@ -0,0 +1,125 @@ +from http import HTTPMethod, HTTPStatus +import logging +from typing import override +from flask import render_template + +from taskflower.config import config +from taskflower.types import FlaskViewReturnType +from taskflower.types.option import Nothing, Option, Some + +log = logging.getLogger(__name__) + +def str_to_method(s: str) -> Option[HTTPMethod]: + for mth in HTTPMethod: + if str(mth).upper() == s.upper(): + return Some(mth) + + return Nothing() + +class ResponseErrorType(Exception): + status: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR + + @override + def __init__( + self, + method: HTTPMethod|str, + page_name: str, + reason: str = '', + user_reason: str = '' # CAUTION: This message will be displayed to the user! + ) -> None: + self.method: HTTPMethod = ( + ( + method + ) if isinstance(method, HTTPMethod) + else ( + str_to_method(method).and_then( + lambda v: v, + lambda: HTTPMethod.GET + ) + ) + ) + self.page: str = page_name + self.reason: str = reason + + as_str = ( + f'Request: ``{str(method)} {page_name}`` resulted in {str(self.status)}. Reason: {reason}' + ) + + super().__init__(as_str) + + @override + def __str__(self) -> str: + return ( + f'Request: ``{str(self.method)} {self.page}`` ' + + f'resulted in {str(self.status)}. Reason: {self.reason}' + ) + + @override + def __repr__(self) -> str: + return ( + f'{self.__class__.__name__}({repr(self.method)},' + + f'{self.page},{self.reason})' + ) + +class ResponseErrorUnauthorized(ResponseErrorType): + status: HTTPStatus = HTTPStatus.UNAUTHORIZED + +class ResponseErrorForbidden(ResponseErrorType): + status: HTTPStatus = HTTPStatus.FORBIDDEN + +class ResponseErrorNotFound(ResponseErrorType): + status: HTTPStatus = HTTPStatus.NOT_FOUND + +class ResponseErrorInternalServerError(ResponseErrorType): + status: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR + +def _render_error_page( + err_name: str, + err_description: str, + err_details: str|None = None +) -> FlaskViewReturnType: + return render_template( + 'error/error.html', + err_name=err_name, + err_description=err_description, + err_details=err_details + ) + +def status_response(code: HTTPStatus, user_description: str|None = None) -> FlaskViewReturnType: + match code: + case HTTPStatus.BAD_REQUEST: + return _render_error_page( + 'Bad Request', + 'Your client sent a request the server could not understand.', + user_description + ) + case HTTPStatus.NOT_FOUND: + return _render_error_page( + 'Not Found', + 'The requested resource does not exist, or you aren\'t authorized to access it.', + user_description + ) + case HTTPStatus.INTERNAL_SERVER_ERROR: + return _render_error_page( + 'Internal Server Error', + ( + 'The server encountered an error trying to process your request.' + + (f' Please open an issue in the project repository at {config.issue_url.val}!' if isinstance(config.issue_url, Some) else '') + ), + user_description + ) + case _: + return _render_error_page( + code.name, + code.phrase, + user_description + ) + +def response_from_exception(exc: Exception|None = None) -> FlaskViewReturnType: + if exc: + log.warning(f'Request generated exception: {str(exc)}') + + if isinstance(exc, ResponseErrorType): + return status_response(exc.status) + else: + return status_response(HTTPStatus.INTERNAL_SERVER_ERROR) \ No newline at end of file diff --git a/src/taskflower/web/task/__init__.py b/src/taskflower/web/task/__init__.py new file mode 100644 index 0000000..3cdfe98 --- /dev/null +++ b/src/taskflower/web/task/__init__.py @@ -0,0 +1,64 @@ +from flask import Blueprint, redirect, render_template, request, url_for +from flask_login import current_user, login_required # pyright:ignore[reportUnknownVariableType,reportMissingTypeStubs] + +from taskflower.db import db +from taskflower.db.helpers import add_to_db +from taskflower.db.model.task import Task +from taskflower.types.either import reduce_either +from taskflower.web.errors import ( + response_from_exception +) + +web_tasks = Blueprint( + 'task', + __name__, + '/templates', + url_prefix='/tasks' +) + +@web_tasks.route('/') +@login_required +def all(): + task_list: list[Task] = db.session.execute( + db.select( # pyright:ignore[reportAssignmentType,reportAny] + Task + ).filter_by( + owner=current_user.id # pyright:ignore[reportAny] + ).order_by( + Task.due + ) + ).scalars() + + return reduce_either([ + t.sanitize() + for t in task_list + ]).and_then( + lambda task_list_sanitized: render_template( + 'task/list.html', + tasks=task_list_sanitized + ), + lambda exc: response_from_exception(exc) + ) + +@web_tasks.route('/new', methods=['GET', 'POST']) +@login_required +def new(): + form_data = Task.form(request.form) + if request.method == 'POST' and form_data.validate(): + return Task.from_form( + form_data, + current_user # pyright:ignore[reportArgumentType] + ).flat_map( + lambda task: add_to_db(task) + ).and_then( + lambda task: redirect(url_for( + 'web.task.all', + id=task.id + )), + lambda exc: response_from_exception(exc) + ) + + return render_template( + 'new_task.html', + form=form_data + ) \ No newline at end of file diff --git a/src/taskflower/web/user/__init__.py b/src/taskflower/web/user/__init__.py new file mode 100644 index 0000000..6dcac9f --- /dev/null +++ b/src/taskflower/web/user/__init__.py @@ -0,0 +1,271 @@ +from flask import Blueprint, redirect, render_template, request, url_for +from flask_login import login_user # pyright:ignore[reportMissingTypeStubs,reportUnknownVariableType] +from wtforms import Field, Form, PasswordField, SelectField, StringField, ValidationError +from wtforms.validators import DataRequired, EqualTo, Length + +from taskflower.auth import password_breach_count +from taskflower.auth.hash import make_hash_v1 +from taskflower.db import db +from taskflower.db.model.user import User +from taskflower.types import ann +from taskflower.types.either import Either, Left, Right +from taskflower.web.errors import ResponseErrorInternalServerError, response_from_exception + + +web_user: Blueprint = Blueprint( + 'user', + __name__, + '/templates', + url_prefix='/users' +) + +def pwned_validator(_: Form, field: Field): + if isinstance(field.data, str): # pyright:ignore[reportAny] + breaches = password_breach_count( + field.data + ).and_then( + lambda val: val, + lambda: 0 + ) + + if breaches > 0: + raise ValidationError(f'Password has been breached {breaches} times!') + +def optional_if_pronoun_specified(form: 'CreateUserForm|Form', field: Field): + if isinstance(form, CreateUserForm): + if form.pronouns.data == 'custom': # pyright:ignore[reportAny] + if not field.data: # pyright:ignore[reportAny] + raise ValidationError(f'{field.name} is required if using custom pronouns!') + +def unique_username(_: Form, field: Field) : + if isinstance(field.data, str): # pyright:ignore[reportAny] + res = db.session.execute( + db.select( # pyright:ignore[reportAny] + User + ).filter_by( + username=field.data + ) + ).scalar_one_or_none() + + if res is not None: + raise ValidationError('Sorry, that username is taken!') + else: + raise ValidationError(f'Unexpected datatype {type(field.data)} passed to ``unique_username`` validator!') # pyright:ignore[reportAny] + +default_pronoun_sets = { + 'they': ( + 'they', + 'them', + 'their', + 'theirs', + 'themself', + True + ), + 'it': ( + 'it', + 'it', + 'its', + 'its', + 'itself', + False + ), + 'fae': ( + 'fae', + 'faer', + 'faer', + 'faers', + 'faerself', + False + ), + 'xe': ( + 'xe', + 'xem', + 'xyr', + 'xyrs', + 'xyrself', + False + ), + 'she': ( + 'she', + 'her', + 'her', + 'hers', + 'herself', + False + ), + 'he': ( + 'he', + 'him', + 'his', + 'his', + 'himself', + False + ) +} + +class CreateUserForm(Form): + username: StringField = StringField( + 'Enter a unique username', + [ + Length( + 1, + message='Usernames must be at least one character long!' + ), + Length( + max=32, + message='Usernames can\'t be longer than 32 characters!' + ), + unique_username + ] + ) + display_name: StringField = StringField( + 'Choose a display name', + [ + Length( + min=1, + message='Display names must be at least one character long!' + ), + Length( + max=64, + message='Display names can\'t be longer than 64 characters!' + ) + ] + ) + + pronouns: SelectField = SelectField( + 'Select your pronouns', + choices=[ + ('they', 'they/them'), + ('it', 'it/its'), + ('fae', 'fae/faer'), + ('xe', 'xe/xem'), + ('she', 'she/her'), + ('he', 'he/him'), + ('custom', 'Custom...') + ], + validators=[DataRequired()] + ) + pr_sub: StringField = StringField( + 'Custom Subjective Pronoun', + [optional_if_pronoun_specified] + ) + pr_obj: StringField = StringField( + 'Custom Objective Pronoun', + [optional_if_pronoun_specified] + ) + pr_dep: StringField = StringField( + 'Custom Dependent Possessive Pronoun', + [optional_if_pronoun_specified] + ) + pr_ind: StringField = StringField( + 'Custom Independent Possessive Pronoun', + [optional_if_pronoun_specified] + ) + pr_ref: StringField = StringField( + 'Custom Reflexive Pronoun', + [optional_if_pronoun_specified] + ) + pr_plr: SelectField = SelectField( + 'My pronouns are:', + choices=[ + ('singular', 'Singular (e.g. "she has")'), + ('plural', 'Plural (e.g. "they have")') + ], + validators=[optional_if_pronoun_specified] + ) + password: PasswordField = PasswordField( + 'Enter a secure password', + [ + Length( + min = 16, + message='Passwords must be at least 16 characters long!' + ), + Length( + max = 512, + message='The maximum password length is 512!' + ), + pwned_validator, + EqualTo('confirm_password') + ] + ) + confirm_password: PasswordField = PasswordField( + 'Type password again to confirm' + ) + +@web_user.route('/new', methods=['GET', 'POST']) +def create_user_page(): + def _user_from_form(form: CreateUserForm) -> Either[Exception, User]: + if not form.validate(): + return Left(ResponseErrorInternalServerError( + request.method, + request.path + )) + + if form.pronouns.data in default_pronoun_sets: # pyright:ignore[reportAny] + pronouns: tuple[ + str, str, + str, str, + str, bool + ] = default_pronoun_sets[form.pronouns.data] # pyright:ignore[reportAny] + else: + pronouns = ( + ann(form.pr_sub.data), ann(form.pr_obj.data), + ann(form.pr_dep.data), ann(form.pr_ind.data), + ann(form.pr_ref.data), ( + True if form.pr_plr.data == 'plural' # pyright:ignore[reportAny] + else False + ) + ) + + return make_hash_v1( + form.password.data # pyright:ignore[reportArgumentType] + ).map( + lambda hres:User( + username=form.username.data, # pyright:ignore[reportCallIssue] + display_name=form.display_name.data, # pyright:ignore[reportCallIssue] + enabled=True, # pyright:ignore[reportCallIssue] + pr_sub=pronouns[0], # pyright:ignore[reportCallIssue] + pr_obj=pronouns[1], # pyright:ignore[reportCallIssue] + pr_dep=pronouns[2], # pyright:ignore[reportCallIssue] + pr_ind=pronouns[3], # pyright:ignore[reportCallIssue] + pr_ref=pronouns[4], # pyright:ignore[reportCallIssue] + pr_plr=pronouns[5], # pyright:ignore[reportCallIssue] + password=hres.hash, # pyright:ignore[reportCallIssue] + salt=hres.salt, # pyright:ignore[reportCallIssue] + hash_params=hres.hash_params # pyright:ignore[reportCallIssue] + ) + ) + + def _add_to_db(user: User) -> Either[Exception, User]: + try: + db.session.add(user) + db.session.commit() + return Right(user) + except Exception as e: + db.session.rollback() + return Left(ResponseErrorInternalServerError( + request.method, + request.path, + f'Error while inserting user into the database: {str(e)}', + 'We couldn\'t complete your registration due to an unknown error. Please try again later!' + )) + + form_data = CreateUserForm(request.form) + if request.method == 'POST' and form_data.validate(): + return _user_from_form(form_data).flat_map( + lambda usr: _add_to_db(usr) + ).side_effect( + lambda usr: login_user(usr) + ).and_then( + lambda usr: redirect(url_for('web.task.all_tasks_view')), + lambda exc: response_from_exception(exc) + ) + + return render_template( + 'user/new_user.html', + form=form_data + ) + +@web_user.route('/profile/') +def profile(id: int): + return redirect(url_for('web.task.all_tasks_view')) \ No newline at end of file diff --git a/src/taskflower/wrap.py b/src/taskflower/wrap.py new file mode 100644 index 0000000..ad0bc97 --- /dev/null +++ b/src/taskflower/wrap.py @@ -0,0 +1,36 @@ +from functools import wraps +from typing import Callable, Concatenate +from taskflower.types.either import Either, Right, Left +from taskflower.types.option import Option, Some, Nothing + +def as_either[R, **P]( + func: Callable[Concatenate[P], R] +) -> Callable[Concatenate[P], Either[Exception, R]]: + ''' Decorator that converts a 'traditional' Exception-throwing function + to use ``Either`` objects instead. + + If the inner function throws an exception ``e``, ``Left(e)`` is + returned; otherwise, the return value is wrapped in a ``Right()``. + ''' + @wraps(func) + def inner(*args: P.args, **kwargs: P.kwargs) -> Either[Exception, R]: + try: + return Right(func(*args, **kwargs)) + except Exception as e: + return Left(e) + return inner + +def as_option[R, **P]( + func: Callable[Concatenate[P], R] +) -> Callable[Concatenate[P], Option[R]]: + @wraps(func) + def inner(*args: P.args, **kwargs: P.kwargs) -> Option[R]: + try: + ret = func(*args, **kwargs) + if ret is not None: + return Some(ret) + else: + return Nothing() + except Exception: + return Nothing() + return inner \ No newline at end of file