diff --git a/.env.example b/.env.example index 5fb3f9c..ad1d1e6 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,44 @@ -DATABASE_NAME=postgres -DATABASE_USER=postgres -DATABASE_PASSWORD=postgres -DATABASE_HOST=localhost +# Key used to sign tokens. Generate this with `go run . generate key` +HMAC_KEY= +# PostgreSQL connection URL (postgresql://user:pass@host:port/dbname) +DATABASE_URL= + +# Redis connection URL (redis://user:pass@host:port) +REDIS= + +# Port for the backend to listen on; frontend assumes this will be 8080 for dev +PORT=8080 + +# Frontend base URL, used to construct URLs that point back to the frontend +BASE_URL=http://localhost:5173 + +# S3/MinIO configuration, required for avatars, pride flags, and data exports +# Note: MINIO_ENDPOINT must be set and look like a minio endpoint, but doesn't +# have to actually point to anything real +MINIO_ENDPOINT=example.com +MINIO_BUCKET= +MINIO_ACCESS_KEY_ID= +MINIO_ACCESS_KEY_SECRET= +MINIO_SSL= + +# IP address of the frontend; requests from here will never be ratelimited +FRONTEND_IP= + +# Auth providers - fill in OAuth app info to enable OAuth login for each + +# https://discord.com/developers/applications +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= + +# https://developers.google.com/identity/protocols/oauth2#basicsteps +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# https://www.tumblr.com/oauth/apps +TUMBLR_CLIENT_ID= +TUMBLR_CLIENT_SECRET= + +# Discord bot config - provide the app's public key in addition to client ID/ +# secret above to let the bot respond to command interactions over HTTP +DISCORD_PUBLIC_KEY= diff --git a/.gitignore b/.gitignore index 4ec93b7..9169dd9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,13 @@ -__pycache__/ -*.py[cod] -*$py.class -local_settings.py -__pypackages__/ -celerybeat-schedule -celerybeat.pid +.vscode +node_modules +*.log* .env -venv/ +.env.* +!.env.example +dist +dump.rdb +build +.svelte-kit +package +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0ad25db --- /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 +. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1b15eff --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +all: generate backend frontend + +.PHONY: backend +backend: + go build -v -o pronouns -ldflags="-buildid= -X codeberg.org/pronounscc/pronouns.cc/backend/server.Revision=`git rev-parse --short HEAD` -X codeberg.org/pronounscc/pronouns.cc/backend/server.Tag=`git describe --tags --long`" . + +.PHONY: generate +generate: + go generate ./... + +.PHONY: frontend +frontend: + cd frontend && pnpm install && pnpm build + +.PHONY: dev +dev: + cd frontend && pnpm dev diff --git a/README.md b/README.md index e6e83d7..c0ac541 100644 --- a/README.md +++ b/README.md @@ -1 +1,57 @@ # pronouns.cc + +A work-in-progress site to share your names, pronouns, and other preferred terms. + +## Stack + +- API server is written in Go with the [chi](https://github.com/go-chi/chi) router +- Persistent data is stored in PostgreSQL +- Temporary data is stored in Redis +- The frontend is written in TypeScript with Svelte, using [SvelteKit](https://kit.svelte.dev/) for server-side rendering +- Avatars are stored in S3-compatible storage ([MinIO](https://github.com/minio/minio) for development) + +## Development + +When working on the frontend, run the API and then use `pnpm dev` in `frontend/` for hot reloading. + +Note that the Vite dev server assumes that the backend listens on `:8080` and MinIO listens on `:9000`. +If these ports differ on your development environment, you must edit `vite.config.ts`. + +## Development + +Requirements: + +- Go 1.18 or later +- PostgreSQL (any currently supported version should work) +- Redis 6.0 or later +- Node.js (latest version) +- MinIO **if using avatars, flags, or data exports** (_not_ required otherwise) + +### Setup + +1. Create a PostgreSQL user and database (the user should own the database). + For example: `create user pronouns with password 'password'; create database pronouns with owner pronouns;` +2. Copy `.env.example` in the repository root to a new file named `.env` and fill out the required options. +3. Run `go run -v . database migrate` to initialize the database, then optionally `go run -v . database seed` to insert a test user. +4. Run `go run -v . web` to run the backend. +5. Copy `frontend/.env.example` into `frontend/.env` and tweak as necessary. +6. cd into the `frontend` directory and run `pnpm dev` to run the frontend. + +See [`docs/production.md`](/docs/production.md#configuration) for more information about keys in the backend and frontend `.env` files. + +## License + + Copyright (C) 2022 Sam + + 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 . diff --git a/api_v2/admin.py b/api_v2/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/api_v2/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/api_v2/api.py b/api_v2/api.py deleted file mode 100644 index 9261db8..0000000 --- a/api_v2/api.py +++ /dev/null @@ -1,8 +0,0 @@ -from ninja import NinjaAPI - -from .views.users import router as users_router - -api = NinjaAPI() - -api.add_router("/users/", users_router) - diff --git a/api_v2/apps.py b/api_v2/apps.py deleted file mode 100644 index 02d0a4a..0000000 --- a/api_v2/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class ApiV2Config(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "api_v2" diff --git a/api_v2/migrations/__init__.py b/api_v2/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/api_v2/models.py b/api_v2/models.py deleted file mode 100644 index 71a8362..0000000 --- a/api_v2/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/api_v2/tests.py b/api_v2/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/api_v2/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/api_v2/urls.py b/api_v2/urls.py deleted file mode 100644 index 14dbc29..0000000 --- a/api_v2/urls.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.urls import path - -from .api import api - -urlpatterns = [path("/", api.urls)] diff --git a/api_v2/views.py b/api_v2/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/api_v2/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/api_v2/views/__init__.py b/api_v2/views/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/api_v2/views/users.py b/api_v2/views/users.py deleted file mode 100644 index 00ad636..0000000 --- a/api_v2/views/users.py +++ /dev/null @@ -1,38 +0,0 @@ -from ninja import Router, Field, Schema -from ninja.errors import HttpError - -from pronounscc.models import User - - -router = Router() - - -class UserOut(Schema): - id: str = Field(..., alias="uid") - username: str - display_name: str = Field(..., alias="userprofile.display_name") - bio: str = Field(..., alias="userprofile.bio") - - -@router.get("/@me") -def get_me(request): - return {"data": "me endpoint, eventually"} - - -@router.get("/{user_ref}", response=UserOut) -def get_user(request, user_ref: str): - try: - user_id = int(user_ref) - try: - user = User.objects.get(pk=user_id) - return user - except User.DoesNotExist: - pass - except ValueError: - pass - - try: - user = User.objects.get(username=user_ref) - return user - except User.DoesNotExist: - raise HttpError(404, "User not found") diff --git a/backend/common/common.go b/backend/common/common.go new file mode 100644 index 0000000..92c6169 --- /dev/null +++ b/backend/common/common.go @@ -0,0 +1,11 @@ +// Package common contains functions and types common to all (or most) packages. +package common + +import "unicode/utf8" + +func StringLength(s *string) int { + if s == nil { + return -1 + } + return utf8.RuneCountInString(*s) +} diff --git a/backend/db/avatars.go b/backend/db/avatars.go new file mode 100644 index 0000000..e59c682 --- /dev/null +++ b/backend/db/avatars.go @@ -0,0 +1,176 @@ +package db + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + _ "image/gif" + _ "image/png" + "io" + "strings" + + "emperror.dev/errors" + "github.com/davidbyttow/govips/v2/vips" + "github.com/minio/minio-go/v7" + "github.com/rs/xid" +) + +const ErrInvalidDataURI = errors.Sentinel("invalid data URI") +const ErrInvalidContentType = errors.Sentinel("invalid avatar content type") +const ErrFileTooLarge = errors.Sentinel("file to be converted exceeds maximum size") + +// ConvertAvatar parses an avatar from a data URI, converts it to WebP and JPEG, and returns the results. +func (db *DB) ConvertAvatar(data string) ( + webpOut *bytes.Buffer, + jpgOut *bytes.Buffer, + err error, +) { + defer vips.ShutdownThread() + + data = strings.TrimSpace(data) + if !strings.Contains(data, ",") || !strings.Contains(data, ":") || !strings.Contains(data, ";") { + return nil, nil, ErrInvalidDataURI + } + split := strings.Split(data, ",") + + rawData, err := base64.StdEncoding.DecodeString(split[1]) + if err != nil { + return nil, nil, errors.Wrap(err, "invalid base64 data") + } + + image, err := vips.LoadImageFromBuffer(rawData, nil) + if err != nil { + return nil, nil, errors.Wrap(err, "decoding image") + } + + err = image.ThumbnailWithSize(512, 512, vips.InterestingCentre, vips.SizeBoth) + if err != nil { + return nil, nil, errors.Wrap(err, "resizing image") + } + + webpExport := vips.NewWebpExportParams() + webpExport.Quality = 90 + webpB, _, err := image.ExportWebp(webpExport) + if err != nil { + return nil, nil, errors.Wrap(err, "exporting webp image") + } + webpOut = bytes.NewBuffer(webpB) + + jpegExport := vips.NewJpegExportParams() + jpegExport.Quality = 80 + jpegB, _, err := image.ExportJpeg(jpegExport) + if err != nil { + return nil, nil, errors.Wrap(err, "exporting jpeg image") + } + jpgOut = bytes.NewBuffer(jpegB) + + return webpOut, jpgOut, nil +} + +func (db *DB) WriteUserAvatar(ctx context.Context, + userID xid.ID, webp *bytes.Buffer, jpeg *bytes.Buffer, +) ( + hash string, err error, +) { + hasher := sha256.New() + _, err = hasher.Write(webp.Bytes()) + if err != nil { + return "", errors.Wrap(err, "hashing webp avatar") + } + hash = hex.EncodeToString(hasher.Sum(nil)) + + _, err = db.minio.PutObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{ + ContentType: "image/webp", + SendContentMd5: true, + }) + if err != nil { + return "", errors.Wrap(err, "uploading webp avatar") + } + + _, err = db.minio.PutObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{ + ContentType: "image/jpeg", + SendContentMd5: true, + }) + if err != nil { + return "", errors.Wrap(err, "uploading jpeg avatar") + } + + return hash, nil +} + +func (db *DB) WriteMemberAvatar(ctx context.Context, + memberID xid.ID, webp *bytes.Buffer, jpeg *bytes.Buffer, +) ( + hash string, err error, +) { + hasher := sha256.New() + _, err = hasher.Write(webp.Bytes()) + if err != nil { + return "", errors.Wrap(err, "hashing webp avatar") + } + hash = hex.EncodeToString(hasher.Sum(nil)) + + _, err = db.minio.PutObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{ + ContentType: "image/webp", + SendContentMd5: true, + }) + if err != nil { + return "", errors.Wrap(err, "uploading webp avatar") + } + + _, err = db.minio.PutObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{ + ContentType: "image/jpeg", + SendContentMd5: true, + }) + if err != nil { + return "", errors.Wrap(err, "uploading jpeg avatar") + } + + return hash, nil +} + +func (db *DB) DeleteUserAvatar(ctx context.Context, userID xid.ID, hash string) error { + err := db.minio.RemoveObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".webp", minio.RemoveObjectOptions{}) + if err != nil { + return errors.Wrap(err, "deleting webp avatar") + } + + err = db.minio.RemoveObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".jpg", minio.RemoveObjectOptions{}) + if err != nil { + return errors.Wrap(err, "deleting jpeg avatar") + } + + return nil +} + +func (db *DB) DeleteMemberAvatar(ctx context.Context, memberID xid.ID, hash string) error { + err := db.minio.RemoveObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".webp", minio.RemoveObjectOptions{}) + if err != nil { + return errors.Wrap(err, "deleting webp avatar") + } + + err = db.minio.RemoveObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".jpg", minio.RemoveObjectOptions{}) + if err != nil { + return errors.Wrap(err, "deleting jpeg avatar") + } + + return nil +} + +func (db *DB) UserAvatar(ctx context.Context, userID xid.ID, hash string) (io.ReadCloser, error) { + obj, err := db.minio.GetObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".webp", minio.GetObjectOptions{}) + if err != nil { + return nil, errors.Wrap(err, "getting object") + } + return obj, nil +} + +func (db *DB) MemberAvatar(ctx context.Context, memberID xid.ID, hash string) (io.ReadCloser, error) { + obj, err := db.minio.GetObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".webp", minio.GetObjectOptions{}) + if err != nil { + return nil, errors.Wrap(err, "getting object") + } + return obj, nil +} diff --git a/backend/db/db.go b/backend/db/db.go new file mode 100644 index 0000000..13e12f2 --- /dev/null +++ b/backend/db/db.go @@ -0,0 +1,157 @@ +package db + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "sync" + + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "emperror.dev/errors" + "github.com/Masterminds/squirrel" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/mediocregopher/radix/v4" + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" + "github.com/prometheus/client_golang/prometheus" +) + +var sq = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar) + +const ErrNothingToUpdate = errors.Sentinel("nothing to update") + +const ( + uniqueViolation = "23505" + foreignKeyViolation = "23503" +) + +type Execer interface { + Exec(ctx context.Context, sql string, arguments ...interface{}) (commandTag pgconn.CommandTag, err error) +} + +type DB struct { + *pgxpool.Pool + + Redis radix.Client + + minio *minio.Client + minioBucket string + baseURL *url.URL + + TotalRequests prometheus.Counter + + activeUsersDay, activeUsersWeek, activeUsersMonth int64 + usersTotal, membersTotal int64 + countMu sync.RWMutex +} + +func New() (*DB, error) { + log.Debug("creating postgres client") + pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL")) + if err != nil { + return nil, errors.Wrap(err, "creating postgres client") + } + + log.Debug("creating redis client") + redis, err := (&radix.PoolConfig{}).New(context.Background(), "tcp", os.Getenv("REDIS")) + if err != nil { + return nil, errors.Wrap(err, "creating redis client") + } + + log.Debug("creating minio client") + minioClient, err := minio.New(os.Getenv("MINIO_ENDPOINT"), &minio.Options{ + Creds: credentials.NewStaticV4(os.Getenv("MINIO_ACCESS_KEY_ID"), os.Getenv("MINIO_ACCESS_KEY_SECRET"), ""), + Secure: os.Getenv("MINIO_SSL") == "true", + }) + if err != nil { + return nil, errors.Wrap(err, "creating minio client") + } + + baseURL, err := url.Parse(os.Getenv("BASE_URL")) + if err != nil { + return nil, errors.Wrap(err, "parsing base URL") + } + + db := &DB{ + Pool: pool, + Redis: redis, + + minio: minioClient, + minioBucket: os.Getenv("MINIO_BUCKET"), + baseURL: baseURL, + } + + log.Debug("initializing metrics") + err = db.initMetrics() + if err != nil { + return nil, errors.Wrap(err, "initializing metrics") + } + + return db, nil +} + +// MultiCmd executes the given Redis commands in order. +// If any return an error, the function is aborted. +func (db *DB) MultiCmd(ctx context.Context, cmds ...radix.Action) error { + for _, cmd := range cmds { + err := db.Redis.Do(ctx, cmd) + if err != nil { + return err + } + } + return nil +} + +// SetJSON sets the given key to v marshaled as JSON. +func (db *DB) SetJSON(ctx context.Context, key string, v any, args ...string) error { + b, err := json.Marshal(v) + if err != nil { + return errors.Wrap(err, "marshaling json") + } + + cmdArgs := make([]string, 0, len(args)+2) + cmdArgs = append(cmdArgs, key, string(b)) + cmdArgs = append(cmdArgs, args...) + + err = db.Redis.Do(ctx, radix.Cmd(nil, "SET", cmdArgs...)) + if err != nil { + return errors.Wrap(err, "writing to Redis") + } + return nil +} + +// GetJSON gets the given key as a JSON object. +func (db *DB) GetJSON(ctx context.Context, key string, v any) error { + var b []byte + + err := db.Redis.Do(ctx, radix.Cmd(&b, "GET", key)) + if err != nil { + return errors.Wrap(err, "reading from Redis") + } + + if b == nil { + return nil + } + + if v == nil { + return fmt.Errorf("nil pointer passed into GetJSON") + } + + err = json.Unmarshal(b, v) + if err != nil { + return errors.Wrap(err, "unmarshaling json") + } + return nil +} + +// NotNull is a little helper that returns an *empty slice* when the slice's length is 0. +// This is to prevent nil slices from being marshaled as JSON null +func NotNull[T any](slice []T) []T { + if len(slice) == 0 { + return []T{} + } + return slice +} diff --git a/backend/db/entries.go b/backend/db/entries.go new file mode 100644 index 0000000..86e7a25 --- /dev/null +++ b/backend/db/entries.go @@ -0,0 +1,107 @@ +package db + +import ( + "fmt" + "strings" +) + +type WordStatus string + +func (w *WordStatus) UnmarshalJSON(src []byte) error { + if string(src) == "null" { + return nil + } + + s := strings.Trim(string(src), `"`) + switch s { + case "1": + *w = "favourite" + case "2": + *w = "okay" + case "3": + *w = "jokingly" + case "4": + *w = "friends_only" + case "5": + *w = "avoid" + default: + *w = WordStatus(s) + } + + return nil +} + +func (w WordStatus) Valid(extra CustomPreferences) bool { + if w == "favourite" || w == "okay" || w == "jokingly" || w == "friends_only" || w == "avoid" { + return true + } + + for k := range extra { + if string(w) == k { + return true + } + } + return false +} + +type FieldEntry struct { + Value string `json:"value"` + Status WordStatus `json:"status"` +} + +func (fe FieldEntry) Validate(custom CustomPreferences) string { + if fe.Value == "" { + return "value cannot be empty" + } + + if len([]rune(fe.Value)) > FieldEntryMaxLength { + return fmt.Sprintf("name must be %d characters or less, is %d", FieldEntryMaxLength, len([]rune(fe.Value))) + } + + if !fe.Status.Valid(custom) { + return "status is invalid" + } + + return "" +} + +type PronounEntry struct { + Pronouns string `json:"pronouns"` + DisplayText *string `json:"display_text"` + Status WordStatus `json:"status"` +} + +func (p PronounEntry) Validate(custom CustomPreferences) string { + if p.Pronouns == "" { + return "pronouns cannot be empty" + } + + if p.DisplayText != nil { + if len([]rune(*p.DisplayText)) > FieldEntryMaxLength { + return fmt.Sprintf("display_text must be %d characters or less, is %d", FieldEntryMaxLength, len([]rune(*p.DisplayText))) + } + } + + if len([]rune(p.Pronouns)) > FieldEntryMaxLength { + return fmt.Sprintf("pronouns must be %d characters or less, is %d", FieldEntryMaxLength, len([]rune(p.Pronouns))) + } + + if !p.Status.Valid(custom) { + return "status is invalid" + } + + return "" +} + +func (p PronounEntry) String() string { + if p.DisplayText != nil { + return *p.DisplayText + } + + split := strings.Split(p.Pronouns, "/") + if len(split) <= 2 { + return strings.Join(split, "/") + } + + return strings.Join(split[:1], "/") +} diff --git a/backend/db/export.go b/backend/db/export.go new file mode 100644 index 0000000..6141aac --- /dev/null +++ b/backend/db/export.go @@ -0,0 +1,105 @@ +package db + +import ( + "bytes" + "context" + "time" + + "emperror.dev/errors" + "github.com/georgysavva/scany/v2/pgxscan" + "github.com/jackc/pgx/v5" + "github.com/minio/minio-go/v7" + "github.com/rs/xid" +) + +type DataExport struct { + ID int64 + UserID xid.ID + Filename string + CreatedAt time.Time +} + +func (de DataExport) Path() string { + return "exports/" + de.UserID.String() + "/" + de.Filename + ".zip" +} + +const ErrNoExport = errors.Sentinel("no data export exists") + +const KeepExportTime = 7 * 24 * time.Hour + +func (db *DB) UserExport(ctx context.Context, userID xid.ID) (de DataExport, err error) { + sql, args, err := sq.Select("*"). + From("data_exports"). + Where("user_id = ?", userID). + OrderBy("id DESC"). + Limit(1).ToSql() + if err != nil { + return de, errors.Wrap(err, "building query") + } + + err = pgxscan.Get(ctx, db, &de, sql, args...) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return de, ErrNoExport + } + + return de, errors.Wrap(err, "executing sql") + } + return de, nil +} + +const recentExport = 24 * time.Hour + +func (db *DB) HasRecentExport(ctx context.Context, userID xid.ID) (hasExport bool, err error) { + err = db.QueryRow(ctx, + "SELECT EXISTS(SELECT * FROM data_exports WHERE user_id = $1 AND created_at > $2)", + userID, time.Now().Add(-recentExport)).Scan(&hasExport) + if err != nil { + return false, errors.Wrap(err, "executing query") + } + return hasExport, nil +} + +func (db *DB) CreateExport(ctx context.Context, userID xid.ID, filename string, file *bytes.Buffer) (de DataExport, err error) { + de = DataExport{ + UserID: userID, + Filename: filename, + } + + _, err = db.minio.PutObject(ctx, db.minioBucket, de.Path(), file, int64(file.Len()), minio.PutObjectOptions{ + ContentType: "application/zip", + SendContentMd5: true, + }) + if err != nil { + return de, errors.Wrap(err, "writing export file") + } + + sql, args, err := sq.Insert("data_exports").Columns("user_id", "filename").Values(userID, filename).ToSql() + if err != nil { + return de, errors.Wrap(err, "building query") + } + + pgxscan.Get(ctx, db, &de, sql, args...) + if err != nil { + return de, errors.Wrap(err, "executing sql") + } + return de, nil +} + +func (db *DB) DeleteExport(ctx context.Context, de DataExport) (err error) { + sql, args, err := sq.Delete("data_exports").Where("id = ?", de.ID).ToSql() + if err != nil { + return errors.Wrap(err, "building query") + } + + err = db.minio.RemoveObject(ctx, db.minioBucket, de.Path(), minio.RemoveObjectOptions{}) + if err != nil { + return errors.Wrap(err, "deleting export zip") + } + + _, err = db.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing sql") + } + return nil +} diff --git a/backend/db/fediverse.go b/backend/db/fediverse.go new file mode 100644 index 0000000..eeadd79 --- /dev/null +++ b/backend/db/fediverse.go @@ -0,0 +1,103 @@ +package db + +import ( + "context" + "os" + + "emperror.dev/errors" + "github.com/georgysavva/scany/v2/pgxscan" + "github.com/jackc/pgx/v5" + "golang.org/x/oauth2" +) + +type FediverseApp struct { + ID int64 + // Instance is the instance's base API url, excluding schema + Instance string + ClientID string + ClientSecret string + InstanceType string +} + +func (f FediverseApp) ClientConfig() *oauth2.Config { + if f.MastodonCompatible() { + return &oauth2.Config{ + ClientID: f.ClientID, + ClientSecret: f.ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://" + f.Instance + "/oauth/authorize", + TokenURL: "https://" + f.Instance + "/oauth/token", + AuthStyle: oauth2.AuthStyleInParams, + }, + Scopes: []string{"read:accounts"}, + RedirectURL: os.Getenv("BASE_URL") + "/auth/login/mastodon/" + f.Instance, + } + } + + return &oauth2.Config{ + ClientID: f.ClientID, + ClientSecret: f.ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://" + f.Instance + "/auth", + TokenURL: "https://" + f.Instance + "/api/auth/session/oauth", + AuthStyle: oauth2.AuthStyleInHeader, + }, + Scopes: []string{"read:account"}, + RedirectURL: os.Getenv("BASE_URL") + "/auth/login/misskey/" + f.Instance, + } +} + +func (f FediverseApp) MastodonCompatible() bool { + return f.InstanceType == "mastodon" || f.InstanceType == "pleroma" || f.InstanceType == "akkoma" || f.InstanceType == "pixelfed" || f.InstanceType == "gotosocial" +} + +func (f FediverseApp) Misskey() bool { + return f.InstanceType == "misskey" || f.InstanceType == "foundkey" || f.InstanceType == "calckey" +} + +const ErrNoInstanceApp = errors.Sentinel("instance doesn't have an app") + +func (db *DB) FediverseApp(ctx context.Context, instance string) (fa FediverseApp, err error) { + sql, args, err := sq.Select("*").From("fediverse_apps").Where("instance = ?", instance).ToSql() + if err != nil { + return fa, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &fa, sql, args...) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return fa, ErrNoInstanceApp + } + return fa, errors.Wrap(err, "executing query") + } + return fa, nil +} + +func (db *DB) FediverseAppByID(ctx context.Context, id int64) (fa FediverseApp, err error) { + sql, args, err := sq.Select("*").From("fediverse_apps").Where("id = ?", id).ToSql() + if err != nil { + return fa, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &fa, sql, args...) + if err != nil { + return fa, errors.Wrap(err, "executing query") + } + return fa, nil +} + +func (db *DB) CreateFediverseApp(ctx context.Context, instance, instanceType, clientID, clientSecret string) (fa FediverseApp, err error) { + sql, args, err := sq.Insert("fediverse_apps"). + Columns("instance", "instance_type", "client_id", "client_secret"). + Values(instance, instanceType, clientID, clientSecret). + Suffix("RETURNING *").ToSql() + if err != nil { + return fa, errors.Wrap(err, "building query") + } + + err = pgxscan.Get(ctx, db, &fa, sql, args...) + if err != nil { + return fa, errors.Wrap(err, "executing query") + } + return fa, nil +} diff --git a/backend/db/field.go b/backend/db/field.go new file mode 100644 index 0000000..285d5d4 --- /dev/null +++ b/backend/db/field.go @@ -0,0 +1,123 @@ +package db + +import ( + "context" + "fmt" + + "emperror.dev/errors" + "github.com/georgysavva/scany/v2/pgxscan" + "github.com/jackc/pgx/v5" + "github.com/rs/xid" +) + +const ( + MaxFields = 25 + FieldNameMaxLength = 100 + FieldEntriesLimit = 100 + FieldEntryMaxLength = 100 +) + +type Field struct { + ID int64 `json:"-"` + Name string `json:"name"` + Entries []FieldEntry `json:"entries"` +} + +// Validate validates this field. If it is invalid, a non-empty string is returned as error message. +func (f Field) Validate(custom CustomPreferences) string { + if f.Name == "" { + return "name cannot be empty" + } + + if length := len([]rune(f.Name)); length > FieldNameMaxLength { + return fmt.Sprintf("name max length is %d characters, length is %d", FieldNameMaxLength, length) + } + + if length := len(f.Entries); length > FieldEntriesLimit { + return fmt.Sprintf("max number of entries is %d, current number is %d", FieldEntriesLimit, length) + } + + for i, entry := range f.Entries { + if length := len([]rune(entry.Value)); length > FieldEntryMaxLength { + return fmt.Sprintf("entries.%d: max length is %d characters, length is %d", i, FieldEntryMaxLength, length) + } + + if !entry.Status.Valid(custom) { + return fmt.Sprintf("entries.%d: status is invalid", i) + } + } + + return "" +} + +// UserFields returns the fields associated with the given user ID. +func (db *DB) UserFields(ctx context.Context, id xid.ID) (fs []Field, err error) { + sql, args, err := sq.Select("id", "name", "entries").From("user_fields").Where("user_id = ?", id).OrderBy("id").ToSql() + if err != nil { + return fs, errors.Wrap(err, "building sql") + } + + err = pgxscan.Select(ctx, db, &fs, sql, args...) + if err != nil { + return fs, errors.Wrap(err, "executing query") + } + + return fs, nil +} + +// SetUserFields updates the fields for the given user. +func (db *DB) SetUserFields(ctx context.Context, tx pgx.Tx, userID xid.ID, fields []Field) (err error) { + sql, args, err := sq.Delete("user_fields").Where("user_id = ?", userID).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = tx.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "deleting existing fields") + } + + for _, field := range fields { + _, err := tx.Exec(ctx, "INSERT INTO user_fields (user_id, name, entries) VALUES ($1, $2, $3)", userID, field.Name, field.Entries) + if err != nil { + return errors.Wrap(err, "inserting new fields") + } + } + return nil +} + +// MemberFields returns the fields associated with the given member ID. +func (db *DB) MemberFields(ctx context.Context, id xid.ID) (fs []Field, err error) { + sql, args, err := sq.Select("id", "name", "entries").From("member_fields").Where("member_id = ?", id).OrderBy("id").ToSql() + if err != nil { + return fs, errors.Wrap(err, "building sql") + } + + err = pgxscan.Select(ctx, db, &fs, sql, args...) + if err != nil { + return fs, errors.Wrap(err, "executing query") + } + + return fs, nil +} + +// SetMemberFields updates the fields for the given member. +func (db *DB) SetMemberFields(ctx context.Context, tx pgx.Tx, memberID xid.ID, fields []Field) (err error) { + sql, args, err := sq.Delete("member_fields").Where("member_id = ?", memberID).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = tx.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "deleting existing fields") + } + + for _, field := range fields { + _, err := tx.Exec(ctx, "INSERT INTO member_fields (member_id, name, entries) VALUES ($1, $2, $3)", memberID, field.Name, field.Entries) + if err != nil { + return errors.Wrap(err, "inserting new fields") + } + } + return nil +} diff --git a/backend/db/flags.go b/backend/db/flags.go new file mode 100644 index 0000000..151cb92 --- /dev/null +++ b/backend/db/flags.go @@ -0,0 +1,323 @@ +package db + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "io" + "strings" + + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "emperror.dev/errors" + "github.com/davidbyttow/govips/v2/vips" + "github.com/georgysavva/scany/v2/pgxscan" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/minio/minio-go/v7" + "github.com/rs/xid" +) + +type PrideFlag struct { + ID xid.ID `json:"id"` + UserID xid.ID `json:"-"` + Hash string `json:"hash"` + Name string `json:"name"` + Description *string `json:"description"` +} + +type UserFlag struct { + ID int64 `json:"-"` + UserID xid.ID `json:"-"` + FlagID xid.ID `json:"id"` + + Hash string `json:"hash"` + Name string `json:"name"` + Description *string `json:"description"` +} + +type MemberFlag struct { + ID int64 `json:"-"` + MemberID xid.ID `json:"-"` + FlagID xid.ID `json:"id"` + + Hash string `json:"hash"` + Name string `json:"name"` + Description *string `json:"description"` +} + +const ( + MaxPrideFlags = 500 + MaxPrideFlagTitleLength = 100 + MaxPrideFlagDescLength = 500 +) + +const ( + ErrInvalidFlagID = errors.Sentinel("invalid flag ID") + ErrFlagNotFound = errors.Sentinel("flag not found") +) + +func (db *DB) AccountFlags(ctx context.Context, userID xid.ID) (fs []PrideFlag, err error) { + sql, args, err := sq.Select("*").From("pride_flags").Where("user_id = ?", userID).OrderBy("lower(name)", "id").ToSql() + if err != nil { + return nil, errors.Wrap(err, "building query") + } + + err = pgxscan.Select(ctx, db, &fs, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "executing query") + } + return NotNull(fs), nil +} + +func (db *DB) UserFlag(ctx context.Context, flagID xid.ID) (f PrideFlag, err error) { + sql, args, err := sq.Select("*").From("pride_flags").Where("id = ?", flagID).ToSql() + if err != nil { + return f, errors.Wrap(err, "building query") + } + + err = pgxscan.Get(ctx, db, &f, sql, args...) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return f, ErrFlagNotFound + } + + return f, errors.Wrap(err, "executing query") + } + return f, nil +} + +func (db *DB) UserFlags(ctx context.Context, userID xid.ID) (fs []UserFlag, err error) { + sql, args, err := sq.Select("u.id", "u.flag_id", "f.user_id", "f.hash", "f.name", "f.description"). + From("user_flags AS u"). + Where("u.user_id = $1", userID). + Join("pride_flags AS f ON u.flag_id = f.id"). + OrderBy("u.id ASC"). + ToSql() + if err != nil { + return nil, errors.Wrap(err, "building query") + } + + err = pgxscan.Select(ctx, db, &fs, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "executing query") + } + return NotNull(fs), nil +} + +func (db *DB) MemberFlags(ctx context.Context, memberID xid.ID) (fs []MemberFlag, err error) { + sql, args, err := sq.Select("m.id", "m.flag_id", "m.member_id", "f.hash", "f.name", "f.description"). + From("member_flags AS m"). + Where("m.member_id = $1", memberID). + Join("pride_flags AS f ON m.flag_id = f.id"). + OrderBy("m.id ASC"). + ToSql() + if err != nil { + return nil, errors.Wrap(err, "building query") + } + + err = pgxscan.Select(ctx, db, &fs, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "executing query") + } + return NotNull(fs), nil +} + +func (db *DB) SetUserFlags(ctx context.Context, tx pgx.Tx, userID xid.ID, flags []xid.ID) (err error) { + sql, args, err := sq.Delete("user_flags").Where("user_id = ?", userID).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = tx.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "deleting existing flags") + } + + n, err := tx.CopyFrom(ctx, pgx.Identifier{"user_flags"}, []string{"user_id", "flag_id"}, + pgx.CopyFromSlice(len(flags), func(i int) ([]any, error) { + return []any{userID, flags[i]}, nil + })) + if err != nil { + pge := &pgconn.PgError{} + if errors.As(err, &pge) { + if pge.Code == foreignKeyViolation { + return ErrInvalidFlagID + } + } + + return errors.Wrap(err, "copying new flags") + } + if n > 0 { + log.Debugf("set %v flags for user %v", n, userID) + } + return nil +} + +func (db *DB) SetMemberFlags(ctx context.Context, tx pgx.Tx, memberID xid.ID, flags []xid.ID) (err error) { + sql, args, err := sq.Delete("member_flags").Where("member_id = ?", memberID).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = tx.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "deleting existing flags") + } + + n, err := tx.CopyFrom(ctx, pgx.Identifier{"member_flags"}, []string{"member_id", "flag_id"}, + pgx.CopyFromSlice(len(flags), func(i int) ([]any, error) { + return []any{memberID, flags[i]}, nil + })) + if err != nil { + pge := &pgconn.PgError{} + if errors.As(err, &pge) { + if pge.Code == foreignKeyViolation { + return ErrInvalidFlagID + } + } + + return errors.Wrap(err, "copying new flags") + } + if n > 0 { + log.Debugf("set %v flags for member %v", n, memberID) + } + return nil +} + +func (db *DB) CreateFlag(ctx context.Context, tx pgx.Tx, userID xid.ID, name, desc string) (f PrideFlag, err error) { + description := &desc + if desc == "" { + description = nil + } + + sql, args, err := sq.Insert("pride_flags"). + SetMap(map[string]any{ + "id": xid.New(), + "hash": "", + "user_id": userID.String(), + "name": name, + "description": description, + }).Suffix("RETURNING *").ToSql() + if err != nil { + return f, errors.Wrap(err, "building query") + } + + err = pgxscan.Get(ctx, tx, &f, sql, args...) + if err != nil { + return f, errors.Wrap(err, "executing query") + } + return f, nil +} + +func (db *DB) EditFlag(ctx context.Context, tx pgx.Tx, flagID xid.ID, name, desc, hash *string) (f PrideFlag, err error) { + b := sq.Update("pride_flags"). + Where("id = ?", flagID) + if name != nil { + b = b.Set("name", *name) + } + if desc != nil { + if *desc == "" { + b = b.Set("description", nil) + } else { + b = b.Set("description", *desc) + } + } + if hash != nil { + b = b.Set("hash", *hash) + } + + sql, args, err := b.Suffix("RETURNING *").ToSql() + if err != nil { + return f, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, tx, &f, sql, args...) + if err != nil { + return f, errors.Wrap(err, "executing query") + } + return f, nil +} + +func (db *DB) WriteFlag(ctx context.Context, flagID xid.ID, flag *bytes.Buffer) (hash string, err error) { + hasher := sha256.New() + _, err = hasher.Write(flag.Bytes()) + if err != nil { + return "", errors.Wrap(err, "hashing flag") + } + hash = hex.EncodeToString(hasher.Sum(nil)) + + _, err = db.minio.PutObject(ctx, db.minioBucket, "flags/"+hash+".webp", flag, -1, minio.PutObjectOptions{ + ContentType: "image/webp", + SendContentMd5: true, + }) + if err != nil { + return "", errors.Wrap(err, "uploading flag") + } + + return hash, nil +} + +func (db *DB) DeleteFlag(ctx context.Context, flagID xid.ID, hash string) error { + sql, args, err := sq.Delete("pride_flags").Where("id = ?", flagID).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = db.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + return nil +} + +func (db *DB) FlagObject(ctx context.Context, flagID xid.ID, hash string) (io.ReadCloser, error) { + obj, err := db.minio.GetObject(ctx, db.minioBucket, "/flags/"+flagID.String()+"/"+hash+".webp", minio.GetObjectOptions{}) + if err != nil { + return nil, errors.Wrap(err, "getting object") + } + return obj, nil +} + +const MaxFlagInputSize = 512_000 + +// ConvertFlag parses a flag from a data URI, converts it to WebP, and returns the result. +func (db *DB) ConvertFlag(data string) (webpOut *bytes.Buffer, err error) { + defer vips.ShutdownThread() + + data = strings.TrimSpace(data) + if !strings.Contains(data, ",") || !strings.Contains(data, ":") || !strings.Contains(data, ";") { + return nil, ErrInvalidDataURI + } + split := strings.Split(data, ",") + + rawData, err := base64.StdEncoding.DecodeString(split[1]) + if err != nil { + return nil, errors.Wrap(err, "invalid base64 data") + } + + if len(rawData) > MaxFlagInputSize { + return nil, ErrFileTooLarge + } + + image, err := vips.LoadImageFromBuffer(rawData, nil) + if err != nil { + return nil, errors.Wrap(err, "decoding image") + } + + err = image.ThumbnailWithSize(256, 256, vips.InterestingNone, vips.SizeBoth) + if err != nil { + return nil, errors.Wrap(err, "resizing image") + } + + webpExport := vips.NewWebpExportParams() + webpExport.Lossless = true + webpB, _, err := image.ExportWebp(webpExport) + if err != nil { + return nil, errors.Wrap(err, "exporting webp image") + } + webpOut = bytes.NewBuffer(webpB) + + return webpOut, nil +} diff --git a/backend/db/invites.go b/backend/db/invites.go new file mode 100644 index 0000000..d56662e --- /dev/null +++ b/backend/db/invites.go @@ -0,0 +1,111 @@ +package db + +import ( + "context" + "crypto/rand" + "encoding/base64" + "time" + + "emperror.dev/errors" + "github.com/georgysavva/scany/v2/pgxscan" + "github.com/jackc/pgx/v5" + "github.com/rs/xid" +) + +type Invite struct { + UserID xid.ID + Code string + Created time.Time + Used bool +} + +func (db *DB) UserInvites(ctx context.Context, userID xid.ID) (is []Invite, err error) { + sql, args, err := sq.Select("*").From("invites").Where("user_id = ?", userID).OrderBy("created").ToSql() + if err != nil { + return nil, errors.Wrap(err, "building sql") + } + + err = pgxscan.Select(ctx, db, &is, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "querying database") + } + if len(is) == 0 { + is = []Invite{} + } + + return is, nil +} + +const ErrTooManyInvites = errors.Sentinel("user invite limit reached") + +func (db *DB) CreateInvite(ctx context.Context, userID xid.ID) (i Invite, err error) { + tx, err := db.Begin(ctx) + if err != nil { + return i, errors.Wrap(err, "beginning transaction") + } + defer tx.Rollback(ctx) + + var maxInvites, inviteCount int + err = tx.QueryRow(ctx, "SELECT max_invites FROM users WHERE id = $1", userID).Scan(&maxInvites) + if err != nil { + return i, errors.Wrap(err, "querying invite limit") + } + err = tx.QueryRow(ctx, "SELECT count(*) FROM invites WHERE user_id = $1", userID).Scan(&inviteCount) + if err != nil { + return i, errors.Wrap(err, "querying current invite count") + } + + if inviteCount >= maxInvites { + return i, ErrTooManyInvites + } + + b := make([]byte, 32) + + _, err = rand.Read(b) + if err != nil { + panic(err) + } + + code := base64.RawURLEncoding.EncodeToString(b) + + sql, args, err := sq.Insert("invites").Columns("user_id", "code").Values(userID, code).Suffix("RETURNING *").ToSql() + if err != nil { + return i, errors.Wrap(err, "building insert invite sql") + } + + err = pgxscan.Get(ctx, db, &i, sql, args...) + if err != nil { + return i, errors.Wrap(err, "inserting invite") + } + + err = tx.Commit(ctx) + if err != nil { + return i, errors.Wrap(err, "committing transaction") + } + return i, nil +} + +func (db *DB) InvalidateInvite(ctx context.Context, tx pgx.Tx, code string) (valid, alreadyUsed bool, err error) { + err = tx.QueryRow(ctx, "SELECT used FROM invites WHERE code = $1", code).Scan(&alreadyUsed) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return false, false, nil + } + + return false, false, errors.Wrap(err, "checking if invite exists and is used") + } + + // valid: true, already used: true + if alreadyUsed { + return true, true, nil + } + + // invite is valid, not already used + _, err = tx.Exec(ctx, "UPDATE invites SET used = true WHERE code = $1", code) + if err != nil { + return false, false, errors.Wrap(err, "updating invite usage") + } + + // valid: true, already used: false + return true, false, nil +} diff --git a/backend/db/member.go b/backend/db/member.go new file mode 100644 index 0000000..e893348 --- /dev/null +++ b/backend/db/member.go @@ -0,0 +1,296 @@ +package db + +import ( + "context" + "regexp" + "time" + + "emperror.dev/errors" + "github.com/Masterminds/squirrel" + "github.com/georgysavva/scany/v2/pgxscan" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/rs/xid" +) + +const ( + MaxMemberCount = 500 + MaxMemberNameLength = 100 +) + +type Member struct { + ID xid.ID + UserID xid.ID + SID string `db:"sid"` + Name string + DisplayName *string + Bio *string + Avatar *string + Links []string + Names []FieldEntry + Pronouns []PronounEntry + Unlisted bool +} + +const ( + ErrMemberNotFound = errors.Sentinel("member not found") + ErrMemberNameInUse = errors.Sentinel("member name already in use") +) + +// member names must match this regex +var memberNameRegex = regexp.MustCompile("^[^@\\?!#/\\\\[\\]\"\\{\\}'$%&()+<=>^|~`,]{1,100}$") + +func MemberNameValid(name string) bool { + // These two names will break routing, but periods should still be allowed in names otherwise. + if name == "." || name == ".." { + return false + } + + return memberNameRegex.MatchString(name) +} + +func (db *DB) Member(ctx context.Context, id xid.ID) (m Member, err error) { + sql, args, err := sq.Select("*").From("members").Where("id = ?", id).ToSql() + if err != nil { + return m, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &m, sql, args...) + if err != nil { + return m, errors.Wrap(err, "executing query") + } + return m, nil +} + +// UserMember returns a member scoped by user. +func (db *DB) UserMember(ctx context.Context, userID xid.ID, memberRef string) (m Member, err error) { + sql, args, err := sq.Select("*").From("members").Where("user_id = ?", userID).Where("(id = ? or name = ?)", memberRef, memberRef).ToSql() + if err != nil { + return m, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &m, sql, args...) + if err != nil { + return m, errors.Wrap(err, "executing query") + } + return m, nil +} + +// MemberBySID gets a user by their short ID. +func (db *DB) MemberBySID(ctx context.Context, sid string) (u Member, err error) { + sql, args, err := sq.Select("*").From("members").Where("sid = ?", sid).ToSql() + if err != nil { + return u, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &u, sql, args...) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return u, ErrMemberNotFound + } + + return u, errors.Wrap(err, "getting members from db") + } + + return u, nil +} + +// UserMembers returns all of a user's members, sorted by name. +func (db *DB) UserMembers(ctx context.Context, userID xid.ID, showHidden bool) (ms []Member, err error) { + builder := sq.Select("*"). + From("members").Where("user_id = ?", userID). + OrderBy("name", "id") + if !showHidden { + builder = builder.Where("unlisted = ?", false) + } + + sql, args, err := builder.ToSql() + if err != nil { + return nil, errors.Wrap(err, "building sql") + } + + err = pgxscan.Select(ctx, db, &ms, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "retrieving members") + } + + if ms == nil { + ms = make([]Member, 0) + } + return ms, nil +} + +// CreateMember creates a member. +func (db *DB) CreateMember( + ctx context.Context, tx pgx.Tx, userID xid.ID, + name string, displayName *string, bio string, links []string, +) (m Member, err error) { + sql, args, err := sq.Insert("members"). + Columns("user_id", "id", "sid", "name", "display_name", "bio", "links"). + Values(userID, xid.New(), squirrel.Expr("find_free_member_sid()"), name, displayName, bio, links). + Suffix("RETURNING *").ToSql() + if err != nil { + return m, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, tx, &m, sql, args...) + if err != nil { + pge := &pgconn.PgError{} + if errors.As(err, &pge) { + // unique constraint violation + if pge.Code == uniqueViolation { + return m, ErrMemberNameInUse + } + } + + return m, errors.Wrap(err, "executing query") + } + return m, nil +} + +// DeleteMember deletes a member by the given ID. This is irreversible. +func (db *DB) DeleteMember(ctx context.Context, id xid.ID) (err error) { + sql, args, err := sq.Delete("members").Where("id = ?", id).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = db.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "deleting member") + } + return nil +} + +// MemberCount returns the number of members that the given user has. +func (db *DB) MemberCount(ctx context.Context, userID xid.ID) (n int64, err error) { + sql, args, err := sq.Select("count(id)").From("members").Where("user_id = ?", userID).ToSql() + if err != nil { + return 0, errors.Wrap(err, "building sql") + } + + err = db.QueryRow(ctx, sql, args...).Scan(&n) + if err != nil { + return 0, errors.Wrap(err, "executing query") + } + + return n, nil +} + +func (db *DB) UpdateMember( + ctx context.Context, + tx pgx.Tx, id xid.ID, + name, displayName, bio *string, + unlisted *bool, + links *[]string, + avatar *string, +) (m Member, err error) { + if name == nil && displayName == nil && bio == nil && links == nil && avatar == nil { + // get member + sql, args, err := sq.Select("*").From("members").Where("id = ?", id).ToSql() + if err != nil { + return m, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, tx, &m, sql, args...) + if err != nil { + return m, errors.Wrap(err, "executing query") + } + return m, nil + } + + builder := sq.Update("members").Where("id = ?", id).Suffix("RETURNING *") + if name != nil { + if *name == "" { + return m, errors.Wrap(err, "name was empty") + } else { + builder = builder.Set("name", *name) + } + } + if displayName != nil { + if *displayName == "" { + builder = builder.Set("display_name", nil) + } else { + builder = builder.Set("display_name", *displayName) + } + } + if bio != nil { + if *bio == "" { + builder = builder.Set("bio", nil) + } else { + builder = builder.Set("bio", *bio) + } + } + if links != nil { + builder = builder.Set("links", *links) + } + if unlisted != nil { + builder = builder.Set("unlisted", *unlisted) + } + + if avatar != nil { + if *avatar == "" { + builder = builder.Set("avatar", nil) + } else { + builder = builder.Set("avatar", avatar) + } + } + + sql, args, err := builder.ToSql() + if err != nil { + return m, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, tx, &m, sql, args...) + if err != nil { + pge := &pgconn.PgError{} + if errors.As(err, &pge) { + if pge.Code == uniqueViolation { + return m, ErrMemberNameInUse + } + } + + return m, errors.Wrap(err, "executing sql") + } + return m, nil +} + +func (db *DB) RerollMemberSID(ctx context.Context, userID, memberID xid.ID) (newID string, err error) { + tx, err := db.Begin(ctx) + if err != nil { + return "", errors.Wrap(err, "beginning transaction") + } + defer tx.Rollback(ctx) + + sql, args, err := sq.Update("members"). + Set("sid", squirrel.Expr("find_free_member_sid()")). + Where("id = ?", memberID). + Suffix("RETURNING sid").ToSql() + if err != nil { + return "", errors.Wrap(err, "building sql") + } + + err = tx.QueryRow(ctx, sql, args...).Scan(&newID) + if err != nil { + return "", errors.Wrap(err, "executing query") + } + + sql, args, err = sq.Update("users"). + Set("last_sid_reroll", time.Now()). + Where("id = ?", userID).ToSql() + if err != nil { + return "", errors.Wrap(err, "building sql") + } + + _, err = tx.Exec(ctx, sql, args...) + if err != nil { + return "", errors.Wrap(err, "executing query") + } + + err = tx.Commit(ctx) + if err != nil { + return "", errors.Wrap(err, "committing transaction") + } + + return newID, nil +} diff --git a/backend/db/metrics.go b/backend/db/metrics.go new file mode 100644 index 0000000..96661dd --- /dev/null +++ b/backend/db/metrics.go @@ -0,0 +1,198 @@ +package db + +import ( + "context" + "time" + + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "emperror.dev/errors" + "github.com/jackc/pgx/v5/pgconn" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/rs/xid" +) + +func (db *DB) initMetrics() (err error) { + err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "pronouns_users_total", + Help: "The total number of registered users", + }, func() float64 { + count, err := db.TotalUserCount(context.Background()) + if err != nil { + log.Errorf("getting user count for metrics: %v", err) + } + + db.countMu.Lock() + db.usersTotal = count + db.countMu.Unlock() + + return float64(count) + })) + if err != nil { + return errors.Wrap(err, "registering user count gauge") + } + + err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "pronouns_members_total", + Help: "The total number of registered members", + }, func() float64 { + count, err := db.TotalMemberCount(context.Background()) + if err != nil { + log.Errorf("getting member count for metrics: %v", err) + } + + db.countMu.Lock() + db.membersTotal = count + db.countMu.Unlock() + + return float64(count) + })) + if err != nil { + return errors.Wrap(err, "registering member count gauge") + } + + err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "pronouns_users_active", + Help: "The number of users active in the past 30 days", + }, func() float64 { + count, err := db.ActiveUsers(context.Background(), ActiveMonth) + if err != nil { + log.Errorf("getting active user count for metrics: %v", err) + } + + db.countMu.Lock() + db.activeUsersMonth = count + db.countMu.Unlock() + + return float64(count) + })) + if err != nil { + return errors.Wrap(err, "registering active user count gauge") + } + + err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "pronouns_users_active_week", + Help: "The number of users active in the past 7 days", + }, func() float64 { + count, err := db.ActiveUsers(context.Background(), ActiveWeek) + if err != nil { + log.Errorf("getting active user count for metrics: %v", err) + } + + db.countMu.Lock() + db.activeUsersWeek = count + db.countMu.Unlock() + + return float64(count) + })) + if err != nil { + return errors.Wrap(err, "registering active user count gauge") + } + + err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "pronouns_users_active_day", + Help: "The number of users active in the past 1 day", + }, func() float64 { + count, err := db.ActiveUsers(context.Background(), ActiveDay) + if err != nil { + log.Errorf("getting active user count for metrics: %v", err) + } + + db.countMu.Lock() + db.activeUsersDay = count + db.countMu.Unlock() + + return float64(count) + })) + if err != nil { + return errors.Wrap(err, "registering active user count gauge") + } + + err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "pronouns_database_latency", + Help: "The latency to the database in nanoseconds", + }, func() float64 { + start := time.Now() + _, err = db.Exec(context.Background(), "SELECT 1") + if err != nil { + log.Errorf("pinging database: %v", err) + return -1 + } + return float64(time.Since(start)) + })) + if err != nil { + return errors.Wrap(err, "registering database latency gauge") + } + + db.TotalRequests = promauto.NewCounter(prometheus.CounterOpts{ + Name: "pronouns_api_requests_total", + Help: "The total number of API requests since the last restart", + }) + + return nil +} + +func (db *DB) Counts(ctx context.Context) (numUsers, numMembers, usersDay, usersWeek, usersMonth int64) { + db.countMu.Lock() + if db.usersTotal != 0 { + defer db.countMu.Unlock() + return db.usersTotal, db.membersTotal, db.activeUsersDay, db.activeUsersWeek, db.activeUsersMonth + } + db.countMu.Unlock() + + numUsers, _ = db.TotalUserCount(ctx) + numMembers, _ = db.TotalMemberCount(ctx) + usersDay, _ = db.ActiveUsers(ctx, ActiveDay) + usersWeek, _ = db.ActiveUsers(ctx, ActiveWeek) + usersMonth, _ = db.ActiveUsers(ctx, ActiveMonth) + return numUsers, numMembers, usersDay, usersWeek, usersMonth +} + +func (db *DB) TotalUserCount(ctx context.Context) (numUsers int64, err error) { + err = db.QueryRow(ctx, "SELECT COUNT(*) FROM users WHERE deleted_at IS NULL").Scan(&numUsers) + if err != nil { + return 0, errors.Wrap(err, "querying user count") + } + return numUsers, nil +} + +func (db *DB) TotalMemberCount(ctx context.Context) (numMembers int64, err error) { + err = db.QueryRow(ctx, "SELECT COUNT(*) FROM members WHERE unlisted = false AND user_id = ANY(SELECT id FROM users WHERE deleted_at IS NULL)").Scan(&numMembers) + if err != nil { + return 0, errors.Wrap(err, "querying member count") + } + return numMembers, nil +} + +const ( + ActiveMonth = 30 * 24 * time.Hour + ActiveWeek = 7 * 24 * time.Hour + ActiveDay = 24 * time.Hour +) + +func (db *DB) ActiveUsers(ctx context.Context, dur time.Duration) (numUsers int64, err error) { + t := time.Now().Add(-dur) + err = db.QueryRow(ctx, "SELECT COUNT(*) FROM users WHERE deleted_at IS NULL AND last_active > $1", t).Scan(&numUsers) + if err != nil { + return 0, errors.Wrap(err, "querying active user count") + } + return numUsers, nil +} + +type connOrTx interface { + Exec(ctx context.Context, sql string, arguments ...any) (commandTag pgconn.CommandTag, err error) +} + +// UpdateActiveTime is called on create and update endpoints (PATCH /users/@me, POST/PATCH/DELETE /members) +func (db *DB) UpdateActiveTime(ctx context.Context, tx connOrTx, userID xid.ID) (err error) { + sql, args, err := sq.Update("users").Set("last_active", time.Now().UTC()).Where("id = ?", userID).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = tx.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + return nil +} diff --git a/backend/db/names_pronouns.go b/backend/db/names_pronouns.go new file mode 100644 index 0000000..4f66ffd --- /dev/null +++ b/backend/db/names_pronouns.go @@ -0,0 +1,35 @@ +package db + +import ( + "context" + + "emperror.dev/errors" + "github.com/jackc/pgx/v5" + "github.com/rs/xid" +) + +func (db *DB) SetUserNamesPronouns(ctx context.Context, tx pgx.Tx, userID xid.ID, names []FieldEntry, pronouns []PronounEntry) (err error) { + sql, args, err := sq.Update("users").Set("names", names).Set("pronouns", pronouns).Where("id = ?", userID).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = tx.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + return nil +} + +func (db *DB) SetMemberNamesPronouns(ctx context.Context, tx pgx.Tx, memberID xid.ID, names []FieldEntry, pronouns []PronounEntry) (err error) { + sql, args, err := sq.Update("members").Set("names", names).Set("pronouns", pronouns).Where("id = ?", memberID).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = tx.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + return nil +} diff --git a/backend/db/redis.go b/backend/db/redis.go new file mode 100644 index 0000000..741ac41 --- /dev/null +++ b/backend/db/redis.go @@ -0,0 +1,20 @@ +package db + +import ( + "context" + "net" + + "emperror.dev/errors" + "github.com/mediocregopher/radix/v4" +) + +var _ radix.Client = (*dummyRedis)(nil) + +type dummyRedis struct{} + +func (*dummyRedis) Addr() net.Addr { return &net.IPAddr{} } +func (*dummyRedis) Close() error { return nil } + +func (*dummyRedis) Do(context.Context, radix.Action) error { + return errors.Sentinel("this is a dummy client") +} diff --git a/backend/db/report.go b/backend/db/report.go new file mode 100644 index 0000000..2f6c4c0 --- /dev/null +++ b/backend/db/report.go @@ -0,0 +1,229 @@ +package db + +import ( + "context" + "time" + + "emperror.dev/errors" + "github.com/georgysavva/scany/v2/pgxscan" + "github.com/jackc/pgx/v5" + "github.com/rs/xid" +) + +type Report struct { + ID int64 `json:"id"` + UserID xid.ID `json:"user_id"` + UserName string `json:"user_name"` + MemberID xid.ID `json:"member_id"` + MemberName *string `json:"member_name"` + Reason string `json:"reason"` + ReporterID xid.ID `json:"reporter_id"` + + CreatedAt time.Time `json:"created_at"` + ResolvedAt *time.Time `json:"resolved_at"` + AdminID xid.ID `json:"admin_id"` + AdminComment *string `json:"admin_comment"` +} + +const ReportPageSize = 100 +const ErrReportNotFound = errors.Sentinel("report not found") + +func (db *DB) Reports(ctx context.Context, closed bool, before int) (rs []Report, err error) { + builder := sq.Select("*", + "(SELECT username FROM users WHERE id = reports.user_id) AS user_name", + "(SELECT name FROM members WHERE id = reports.member_id) AS member_name"). + From("reports"). + Limit(ReportPageSize). + OrderBy("id DESC") + if before != 0 { + builder = builder.Where("id < ?", before) + } + if closed { + builder = builder.Where("resolved_at IS NOT NULL") + } else { + builder = builder.Where("resolved_at IS NULL") + } + sql, args, err := builder.ToSql() + if err != nil { + return nil, errors.Wrap(err, "building sql") + } + + err = pgxscan.Select(ctx, db, &rs, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "executing query") + } + if len(rs) == 0 { + return []Report{}, nil + } + return rs, nil +} + +func (db *DB) ReportsByUser(ctx context.Context, userID xid.ID, before int) (rs []Report, err error) { + builder := sq.Select("*", + "(SELECT username FROM users WHERE id = reports.user_id) AS user_name", + "(SELECT name FROM members WHERE id = reports.member_id) AS member_name"). + From("reports"). + Where("user_id = ?", userID). + Limit(ReportPageSize). + OrderBy("id DESC") + if before != 0 { + builder = builder.Where("id < ?", before) + } + sql, args, err := builder.ToSql() + if err != nil { + return nil, errors.Wrap(err, "building sql") + } + + err = pgxscan.Select(ctx, db, &rs, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "executing query") + } + if len(rs) == 0 { + return []Report{}, nil + } + return rs, nil +} + +func (db *DB) ReportsByReporter(ctx context.Context, reporterID xid.ID, before int) (rs []Report, err error) { + builder := sq.Select("*", + "(SELECT username FROM users WHERE id = reports.user_id) AS user_name", + "(SELECT name FROM members WHERE id = reports.member_id) AS member_name"). + From("reports"). + Where("reporter_id = ?", reporterID). + Limit(ReportPageSize). + OrderBy("id DESC") + if before != 0 { + builder = builder.Where("id < ?", before) + } + sql, args, err := builder.ToSql() + if err != nil { + return nil, errors.Wrap(err, "building sql") + } + + err = pgxscan.Select(ctx, db, &rs, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "executing query") + } + if len(rs) == 0 { + return []Report{}, nil + } + return rs, nil +} + +func (db *DB) Report(ctx context.Context, tx pgx.Tx, id int64) (r Report, err error) { + sql, args, err := sq.Select("*").From("reports").Where("id = ?", id).ToSql() + if err != nil { + return r, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, tx, &r, sql, args...) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return r, ErrReportNotFound + } + + return r, errors.Wrap(err, "executing query") + } + return r, nil +} + +func (db *DB) CreateReport(ctx context.Context, reporterID, userID xid.ID, memberID *xid.ID, reason string) (r Report, err error) { + sql, args, err := sq.Insert("reports").SetMap(map[string]any{ + "user_id": userID, + "reporter_id": reporterID, + "member_id": memberID, + "reason": reason, + }).Suffix("RETURNING *").ToSql() + if err != nil { + return r, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &r, sql, args...) + if err != nil { + return r, errors.Wrap(err, "executing query") + } + return r, nil +} + +func (db *DB) ResolveReport(ctx context.Context, ex Execer, id int64, adminID xid.ID, comment string) error { + sql, args, err := sq.Update("reports"). + Set("admin_id", adminID). + Set("admin_comment", comment). + Set("resolved_at", time.Now().UTC()). + Where("id = ?", id).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = ex.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + return nil +} + +type Warning struct { + ID int64 `json:"id"` + UserID xid.ID `json:"-"` + Reason string `json:"reason"` + CreatedAt time.Time `json:"created_at"` + ReadAt *time.Time `json:"-"` +} + +func (db *DB) CreateWarning(ctx context.Context, tx pgx.Tx, userID xid.ID, reason string) (w Warning, err error) { + sql, args, err := sq.Insert("warnings").SetMap(map[string]any{ + "user_id": userID, + "reason": reason, + }).ToSql() + if err != nil { + return w, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, tx, &w, sql, args...) + if err != nil { + return w, errors.Wrap(err, "executing query") + } + return w, nil +} + +func (db *DB) Warnings(ctx context.Context, userID xid.ID, unread bool) (ws []Warning, err error) { + builder := sq.Select("*").From("warnings").Where("user_id = ?", userID).OrderBy("id DESC") + if unread { + builder = builder.Where("read_at IS NULL") + } + sql, args, err := builder.ToSql() + + if err != nil { + return ws, errors.Wrap(err, "building sql") + } + + err = pgxscan.Select(ctx, db, &ws, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "executing query") + } + if len(ws) == 0 { + return []Warning{}, nil + } + return ws, nil +} + +func (db *DB) AckWarning(ctx context.Context, userID xid.ID, id int64) (ok bool, err error) { + sql, args, err := sq.Update("warnings"). + Set("read_at", time.Now().UTC()). + Where("user_id = ?", userID). + Where("id = ?", id). + Where("read_at IS NULL").ToSql() + if err != nil { + return false, errors.Wrap(err, "building sql") + } + + ct, err := db.Exec(ctx, sql, args...) + if err != nil { + return false, errors.Wrap(err, "executing query") + } + + if ct.RowsAffected() == 0 { + return false, nil + } + return true, nil +} diff --git a/backend/db/tokens.go b/backend/db/tokens.go new file mode 100644 index 0000000..367c492 --- /dev/null +++ b/backend/db/tokens.go @@ -0,0 +1,118 @@ +package db + +import ( + "context" + "time" + + "emperror.dev/errors" + "github.com/georgysavva/scany/v2/pgxscan" + "github.com/jackc/pgx/v5" + "github.com/rs/xid" +) + +type Token struct { + UserID xid.ID + TokenID xid.ID + Invalidated bool + APIOnly bool `db:"api_only"` + ReadOnly bool + Created time.Time + Expires time.Time +} + +func (db *DB) TokenValid(ctx context.Context, userID, tokenID xid.ID) (valid bool, err error) { + sql, args, err := sq.Select("*").From("tokens"). + Where("user_id = ?", userID). + Where("token_id = ?", tokenID). + ToSql() + if err != nil { + return false, errors.Wrap(err, "building sql") + } + + var t Token + err = pgxscan.Get(ctx, db, &t, sql, args...) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return false, nil + } + + return false, errors.Wrap(err, "getting from database") + } + + now := time.Now() + return !t.Invalidated && t.Created.Before(now) && t.Expires.After(now), nil +} + +func (db *DB) Tokens(ctx context.Context, userID xid.ID) (ts []Token, err error) { + sql, args, err := sq.Select("*").From("tokens"). + Where("user_id = ?", userID). + Where("expires > ?", time.Now()). + OrderBy("created"). + ToSql() + if err != nil { + return nil, errors.Wrap(err, "building sql") + } + + err = pgxscan.Select(ctx, db, &ts, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "getting from database") + } + return ts, nil +} + +// 3 months, might be customizable later +const TokenExpiryTime = 3 * 30 * 24 * time.Hour + +// SaveToken saves a token to the database. +func (db *DB) SaveToken(ctx context.Context, userID xid.ID, tokenID xid.ID, apiOnly, readOnly bool) (t Token, err error) { + sql, args, err := sq.Insert("tokens"). + SetMap(map[string]any{ + "user_id": userID, + "token_id": tokenID, + "expires": time.Now().Add(TokenExpiryTime), + "api_only": apiOnly, + "read_only": readOnly, + }). + Suffix("RETURNING *"). + ToSql() + if err != nil { + return t, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &t, sql, args...) + if err != nil { + return t, errors.Wrap(err, "inserting token") + } + return t, nil +} + +func (db *DB) InvalidateToken(ctx context.Context, userID xid.ID, tokenID xid.ID) (t Token, err error) { + sql, args, err := sq.Update("tokens"). + Where("user_id = ?", userID). + Where("token_id = ?", tokenID). + Set("invalidated", true). + Suffix("RETURNING *"). + ToSql() + if err != nil { + return t, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &t, sql, args...) + if err != nil { + return t, errors.Wrap(err, "invalidating token") + } + return t, nil +} + +func (db *DB) InvalidateAllTokens(ctx context.Context, tx pgx.Tx, userID xid.ID) error { + sql, args, err := sq.Update("tokens").Where("user_id = ?", userID).Set("invalidated", true).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = tx.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + return nil +} diff --git a/backend/db/user.go b/backend/db/user.go new file mode 100644 index 0000000..ccb0965 --- /dev/null +++ b/backend/db/user.go @@ -0,0 +1,762 @@ +package db + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "regexp" + "time" + + "codeberg.org/pronounscc/pronouns.cc/backend/common" + "codeberg.org/pronounscc/pronouns.cc/backend/icons" + "emperror.dev/errors" + "github.com/Masterminds/squirrel" + "github.com/bwmarrin/discordgo" + "github.com/georgysavva/scany/v2/pgxscan" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/rs/xid" +) + +type User struct { + ID xid.ID + SID string `db:"sid"` + Username string + DisplayName *string + Bio *string + MemberTitle *string + LastActive time.Time + + Avatar *string + Links []string + + Names []FieldEntry + Pronouns []PronounEntry + + Discord *string + DiscordUsername *string + + Fediverse *string + FediverseUsername *string + FediverseAppID *int64 + FediverseInstance *string + + Tumblr *string + TumblrUsername *string + + Google *string + GoogleUsername *string + + MaxInvites int + IsAdmin bool + ListPrivate bool + LastSIDReroll time.Time `db:"last_sid_reroll"` + + DeletedAt *time.Time + SelfDelete *bool + DeleteReason *string + + CustomPreferences CustomPreferences +} + +type CustomPreferences = map[string]CustomPreference + +type CustomPreference struct { + Icon string `json:"icon"` + Tooltip string `json:"tooltip"` + Size PreferenceSize `json:"size"` + Muted bool `json:"muted"` + Favourite bool `json:"favourite"` +} + +func (c CustomPreference) Validate() string { + if !icons.IsValid(c.Icon) { + return fmt.Sprintf("custom preference icon %q is invalid", c.Icon) + } + + if c.Tooltip == "" { + return "custom preference tooltip is empty" + } + if common.StringLength(&c.Tooltip) > FieldEntryMaxLength { + return fmt.Sprintf("custom preference tooltip is too long, max %d characters, is %d characters", FieldEntryMaxLength, common.StringLength(&c.Tooltip)) + } + + if c.Size != PreferenceSizeLarge && c.Size != PreferenceSizeNormal && c.Size != PreferenceSizeSmall { + return fmt.Sprintf("custom preference size %q is invalid", string(c.Size)) + } + + return "" +} + +type PreferenceSize string + +const ( + PreferenceSizeLarge PreferenceSize = "large" + PreferenceSizeNormal PreferenceSize = "normal" + PreferenceSizeSmall PreferenceSize = "small" +) + +func (u User) NumProviders() (numProviders int) { + if u.Discord != nil { + numProviders++ + } + if u.Fediverse != nil { + numProviders++ + } + if u.Tumblr != nil { + numProviders++ + } + if u.Google != nil { + numProviders++ + } + return numProviders +} + +type Badge int32 + +const ( + BadgeAdmin Badge = 1 << 0 +) + +// usernames must match this regex +var usernameRegex = regexp.MustCompile(`^[\w-.]{2,40}$`) + +func UsernameValid(username string) (err error) { + // This name would break routing, but periods should still be allowed in names otherwise. + if username == ".." { + return ErrInvalidUsername + } + + if !usernameRegex.MatchString(username) { + if len(username) < 2 { + return ErrUsernameTooShort + } else if len(username) > 40 { + return ErrUsernameTooLong + } + + return ErrInvalidUsername + } + return nil +} + +const ( + ErrUserNotFound = errors.Sentinel("user not found") + + ErrUsernameTaken = errors.Sentinel("username is already taken") + ErrInvalidUsername = errors.Sentinel("username contains invalid characters") + ErrUsernameTooShort = errors.Sentinel("username is too short") + ErrUsernameTooLong = errors.Sentinel("username is too long") +) + +const ( + MaxUsernameLength = 40 + MaxDisplayNameLength = 100 + MaxUserBioLength = 1000 + MaxUserLinksLength = 25 + MaxLinkLength = 256 +) + +const ( + SelfDeleteAfter = 30 * 24 * time.Hour + ModDeleteAfter = 180 * 24 * time.Hour +) + +// CreateUser creates a user with the given username. +func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u User, err error) { + // check if the username is valid + // if not, return an error depending on what failed + if err := UsernameValid(username); err != nil { + return u, err + } + + sql, args, err := sq.Insert("users").Columns("id", "username", "sid").Values(xid.New(), username, squirrel.Expr("find_free_user_sid()")).Suffix("RETURNING *").ToSql() + if err != nil { + return u, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, tx, &u, sql, args...) + if err != nil { + pge := &pgconn.PgError{} + if errors.As(err, &pge) { + // unique constraint violation + if pge.Code == uniqueViolation { + return u, ErrUsernameTaken + } + } + + return u, errors.Cause(err) + } + return u, nil +} + +func (db *DB) FediverseUser(ctx context.Context, userID string, instanceAppID int64) (u User, err error) { + sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance"). + From("users"). + Where("fediverse = ?", userID).Where("fediverse_app_id = ?", instanceAppID). + ToSql() + if err != nil { + return u, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &u, sql, args...) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return u, ErrUserNotFound + } + + return u, errors.Wrap(err, "executing query") + } + return u, nil +} + +func (u *User) UpdateFromFedi(ctx context.Context, ex Execer, userID, username string, appID int64) error { + sql, args, err := sq.Update("users"). + Set("fediverse", userID). + Set("fediverse_username", username). + Set("fediverse_app_id", appID). + Where("id = ?", u.ID). + ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = ex.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + + u.Fediverse = &userID + u.FediverseUsername = &username + u.FediverseAppID = &appID + return nil +} + +func (u *User) UnlinkFedi(ctx context.Context, ex Execer) error { + sql, args, err := sq.Update("users"). + Set("fediverse", nil). + Set("fediverse_username", nil). + Set("fediverse_app_id", nil). + Where("id = ?", u.ID). + ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = ex.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + + u.Fediverse = nil + u.FediverseUsername = nil + u.FediverseAppID = nil + return nil +} + +// DiscordUser fetches a user by Discord user ID. +func (db *DB) DiscordUser(ctx context.Context, discordID string) (u User, err error) { + sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance"). + From("users").Where("discord = ?", discordID).ToSql() + if err != nil { + return u, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &u, sql, args...) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return u, ErrUserNotFound + } + + return u, errors.Wrap(err, "executing query") + } + return u, nil +} + +func (u *User) UpdateFromDiscord(ctx context.Context, ex Execer, du *discordgo.User) error { + sql, args, err := sq.Update("users"). + Set("discord", du.ID). + Set("discord_username", du.String()). + Where("id = ?", u.ID). + ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = ex.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + + u.Discord = &du.ID + username := du.String() + u.DiscordUsername = &username + + return nil +} + +func (u *User) UnlinkDiscord(ctx context.Context, ex Execer) error { + sql, args, err := sq.Update("users"). + Set("discord", nil). + Set("discord_username", nil). + Where("id = ?", u.ID). + ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = ex.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + + u.Discord = nil + u.DiscordUsername = nil + + return nil +} + +// TumblrUser fetches a user by Tumblr user ID. +func (db *DB) TumblrUser(ctx context.Context, tumblrID string) (u User, err error) { + sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance"). + From("users").Where("tumblr = ?", tumblrID).ToSql() + if err != nil { + return u, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &u, sql, args...) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return u, ErrUserNotFound + } + + return u, errors.Wrap(err, "executing query") + } + return u, nil +} + +func (u *User) UpdateFromTumblr(ctx context.Context, ex Execer, tumblrID, tumblrUsername string) error { + sql, args, err := sq.Update("users"). + Set("tumblr", tumblrID). + Set("tumblr_username", tumblrUsername). + Where("id = ?", u.ID). + ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = ex.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + + u.Tumblr = &tumblrID + u.TumblrUsername = &tumblrUsername + + return nil +} + +func (u *User) UnlinkTumblr(ctx context.Context, ex Execer) error { + sql, args, err := sq.Update("users"). + Set("tumblr", nil). + Set("tumblr_username", nil). + Where("id = ?", u.ID). + ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = ex.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + + u.Tumblr = nil + u.TumblrUsername = nil + + return nil +} + +// GoogleUser fetches a user by Google user ID. +func (db *DB) GoogleUser(ctx context.Context, googleID string) (u User, err error) { + sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance"). + From("users").Where("google = ?", googleID).ToSql() + if err != nil { + return u, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &u, sql, args...) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return u, ErrUserNotFound + } + + return u, errors.Wrap(err, "executing query") + } + return u, nil +} + +func (u *User) UpdateFromGoogle(ctx context.Context, ex Execer, googleID, googleUsername string) error { + sql, args, err := sq.Update("users"). + Set("google", googleID). + Set("google_username", googleUsername). + Where("id = ?", u.ID). + ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = ex.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + + u.Google = &googleID + u.GoogleUsername = &googleUsername + + return nil +} + +func (u *User) UnlinkGoogle(ctx context.Context, ex Execer) error { + sql, args, err := sq.Update("users"). + Set("google", nil). + Set("google_username", nil). + Where("id = ?", u.ID). + ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = ex.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + + u.Google = nil + u.GoogleUsername = nil + + return nil +} + +// User gets a user by ID. +func (db *DB) User(ctx context.Context, id xid.ID) (u User, err error) { + sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance"). + From("users").Where("id = ?", id).ToSql() + if err != nil { + return u, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &u, sql, args...) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return u, ErrUserNotFound + } + + return u, errors.Wrap(err, "getting user from db") + } + + return u, nil +} + +// Username gets a user by username. +func (db *DB) Username(ctx context.Context, name string) (u User, err error) { + sql, args, err := sq.Select("*").From("users").Where("username = ?", name).ToSql() + if err != nil { + return u, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &u, sql, args...) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return u, ErrUserNotFound + } + + return u, errors.Wrap(err, "getting user from db") + } + + return u, nil +} + +// UserBySID gets a user by their short ID. +func (db *DB) UserBySID(ctx context.Context, sid string) (u User, err error) { + sql, args, err := sq.Select("*").From("users").Where("sid = ?", sid).ToSql() + if err != nil { + return u, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &u, sql, args...) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return u, ErrUserNotFound + } + + return u, errors.Wrap(err, "getting user from db") + } + + return u, nil +} + +// UsernameTaken checks if the given username is already taken. +func (db *DB) UsernameTaken(ctx context.Context, username string) (valid, taken bool, err error) { + if err := UsernameValid(username); err != nil { + return false, false, nil + } + + err = db.QueryRow(ctx, "select exists (select id from users where username = $1)", username).Scan(&taken) + return true, taken, err +} + +// UpdateUsername validates the given username, then updates the given user's name to it if valid. +func (db *DB) UpdateUsername(ctx context.Context, tx pgx.Tx, id xid.ID, newName string) error { + if err := UsernameValid(newName); err != nil { + return err + } + + sql, args, err := sq.Update("users").Set("username", newName).Where("id = ?", id).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = db.Exec(ctx, sql, args...) + if err != nil { + pge := &pgconn.PgError{} + if errors.As(err, &pge) { + // unique constraint violation + if pge.Code == uniqueViolation { + return ErrUsernameTaken + } + } + + return errors.Wrap(err, "executing query") + } + return nil +} + +func (db *DB) UpdateUser( + ctx context.Context, + tx pgx.Tx, id xid.ID, + displayName, bio *string, + memberTitle *string, listPrivate *bool, + links *[]string, + avatar *string, + customPreferences *CustomPreferences, +) (u User, err error) { + if displayName == nil && bio == nil && links == nil && avatar == nil && memberTitle == nil && listPrivate == nil && customPreferences == nil { + sql, args, err := sq.Select("*").From("users").Where("id = ?", id).ToSql() + if err != nil { + return u, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &u, sql, args...) + if err != nil { + return u, errors.Wrap(err, "getting user from db") + } + + return u, nil + } + + builder := sq.Update("users").Where("id = ?", id).Suffix("RETURNING *") + if displayName != nil { + if *displayName == "" { + builder = builder.Set("display_name", nil) + } else { + builder = builder.Set("display_name", *displayName) + } + } + if bio != nil { + if *bio == "" { + builder = builder.Set("bio", nil) + } else { + builder = builder.Set("bio", *bio) + } + } + if memberTitle != nil { + if *memberTitle == "" { + builder = builder.Set("member_title", nil) + } else { + builder = builder.Set("member_title", *memberTitle) + } + } + if links != nil { + builder = builder.Set("links", *links) + } + if listPrivate != nil { + builder = builder.Set("list_private", *listPrivate) + } + if customPreferences != nil { + builder = builder.Set("custom_preferences", *customPreferences) + } + + if avatar != nil { + if *avatar == "" { + builder = builder.Set("avatar", nil) + } else { + builder = builder.Set("avatar", avatar) + } + } + + sql, args, err := builder.ToSql() + if err != nil { + return u, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, tx, &u, sql, args...) + if err != nil { + return u, errors.Wrap(err, "executing sql") + } + return u, nil +} + +func (db *DB) DeleteUser(ctx context.Context, tx pgx.Tx, id xid.ID, selfDelete bool, reason string) error { + builder := sq.Update("users").Set("deleted_at", time.Now().UTC()).Set("self_delete", selfDelete).Where("id = ?", id) + if !selfDelete { + builder = builder.Set("delete_reason", reason) + } + sql, args, err := builder.ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = tx.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + return nil +} + +func (db *DB) RerollUserSID(ctx context.Context, id xid.ID) (newID string, err error) { + sql, args, err := sq.Update("users"). + Set("sid", squirrel.Expr("find_free_user_sid()")). + Set("last_sid_reroll", time.Now()). + Where("id = ?", id). + Suffix("RETURNING sid").ToSql() + if err != nil { + return "", errors.Wrap(err, "building sql") + } + + err = db.QueryRow(ctx, sql, args...).Scan(&newID) + if err != nil { + return "", errors.Wrap(err, "executing query") + } + return newID, nil +} + +func (db *DB) UndoDeleteUser(ctx context.Context, id xid.ID) error { + sql, args, err := sq.Update("users"). + Set("deleted_at", nil). + Set("self_delete", nil). + Set("delete_reason", nil). + Where("id = ?", id).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = db.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + return nil +} + +func (db *DB) ForceDeleteUser(ctx context.Context, id xid.ID) error { + sql, args, err := sq.Delete("users").Where("id = ?", id).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = db.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + return nil +} + +func (db *DB) DeleteUserMembers(ctx context.Context, tx pgx.Tx, id xid.ID) error { + sql, args, err := sq.Delete("members").Where("user_id = ?", id).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = tx.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + return nil +} + +func (db *DB) ResetUser(ctx context.Context, tx pgx.Tx, id xid.ID) error { + err := db.SetUserFields(ctx, tx, id, []Field{}) + if err != nil { + return errors.Wrap(err, "deleting fields") + } + + hasher := sha256.New() + _, err = hasher.Write(id.Bytes()) + if err != nil { + return errors.Wrap(err, "hashing user id") + } + hash := hex.EncodeToString(hasher.Sum(nil)) + + sql, args, err := sq.Update("users"). + Set("username", "deleted-"+hash). + Set("display_name", nil). + Set("bio", nil). + Set("links", nil). + Set("names", "[]"). + Set("pronouns", "[]"). + Set("avatar", nil). + Where("id = ?", id).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = tx.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + return nil +} + +func (db *DB) CleanUser(ctx context.Context, id xid.ID) error { + u, err := db.User(ctx, id) + if err != nil { + return errors.Wrap(err, "getting user") + } + + if u.Avatar != nil { + err = db.DeleteUserAvatar(ctx, u.ID, *u.Avatar) + if err != nil { + return errors.Wrap(err, "deleting user avatar") + } + } + + var exports []DataExport + err = pgxscan.Select(ctx, db, &exports, "SELECT * FROM data_exports WHERE user_id = $1", u.ID) + if err != nil { + return errors.Wrap(err, "getting export iles") + } + + for _, de := range exports { + err = db.DeleteExport(ctx, de) + if err != nil { + continue + } + } + + members, err := db.UserMembers(ctx, u.ID, true) + if err != nil { + return errors.Wrap(err, "getting members") + } + + for _, m := range members { + if m.Avatar == nil { + continue + } + + err = db.DeleteMemberAvatar(ctx, m.ID, *m.Avatar) + if err != nil { + continue + } + } + return nil +} diff --git a/backend/exporter/exporter.go b/backend/exporter/exporter.go new file mode 100644 index 0000000..1c5dbc6 --- /dev/null +++ b/backend/exporter/exporter.go @@ -0,0 +1,280 @@ +package exporter + +import ( + "archive/zip" + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "os" + "os/signal" + "sync" + + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/rs/xid" + "github.com/urfave/cli/v2" +) + +var Command = &cli.Command{ + Name: "exporter", + Usage: "Data exporter service", + Action: run, +} + +type server struct { + Router chi.Router + DB *db.DB + + exporting map[xid.ID]struct{} + exportingMu sync.Mutex +} + +func run(c *cli.Context) error { + port := ":" + os.Getenv("EXPORTER_PORT") + + db, err := db.New() + if err != nil { + log.Fatalf("creating database: %v", err) + return err + } + + s := &server{ + Router: chi.NewRouter(), + DB: db, + exporting: make(map[xid.ID]struct{}), + } + + // set up middleware + the single route + s.Router.Use(middleware.Recoverer) + s.Router.Get("/start/{id}", s.startExport) + + e := make(chan error) + + // run server in another goroutine (for gracefully shutting down, see below) + go func() { + e <- http.ListenAndServe(port, s.Router) + }() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + log.Infof("API server running at %v!", port) + + select { + case <-ctx.Done(): + log.Info("Interrupt signal received, shutting down...") + s.DB.Close() + return nil + case err := <-e: + log.Fatalf("Error running server: %v", err) + } + + return nil +} + +func (s *server) startExport(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id, err := xid.FromString(chi.URLParam(r, "id")) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + u, err := s.DB.User(ctx, id) + if err != nil { + log.Errorf("getting user %v: %v", id, err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + go s.doExport(u) + + w.WriteHeader(http.StatusAccepted) +} + +func (s *server) doExport(u db.User) { + s.exportingMu.Lock() + if _, ok := s.exporting[u.ID]; ok { + s.exportingMu.Unlock() + log.Debugf("user %v is already being exported, aborting", u.ID) + return + } + s.exporting[u.ID] = struct{}{} + s.exportingMu.Unlock() + + defer func() { + s.exportingMu.Lock() + delete(s.exporting, u.ID) + s.exportingMu.Unlock() + }() + + ctx := context.Background() + + log.Debugf("[%v] starting export of user", u.ID) + + jsonBuffer := new(bytes.Buffer) + encoder := json.NewEncoder(jsonBuffer) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + + outBuffer := new(bytes.Buffer) + zw := zip.NewWriter(outBuffer) + defer zw.Close() + + w, err := zw.Create("user.json") + if err != nil { + log.Errorf("[%v] creating file in zip archive: %v", u.ID, err) + return + } + + log.Debugf("[%v] getting user fields", u.ID) + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + log.Errorf("[%v] getting user fields: %v", u.ID, err) + return + } + + log.Debugf("[%v] getting user warnings", u.ID) + + warnings, err := s.DB.Warnings(ctx, u.ID, false) + if err != nil { + log.Errorf("[%v] getting warnings: %v", u.ID, err) + return + } + + log.Debugf("[%v] writing user json", u.ID) + + err = encoder.Encode(dbUserToExport(u, fields, warnings)) + if err != nil { + log.Errorf("[%v] marshaling user: %v", u.ID, err) + return + } + + _, err = io.Copy(w, jsonBuffer) + if err != nil { + log.Errorf("[%v] writing user: %v", u.ID, err) + return + } + jsonBuffer.Reset() + + if u.Avatar != nil { + log.Debugf("[%v] getting user avatar", u.ID) + + w, err := zw.Create("user_avatar.webp") + if err != nil { + log.Errorf("[%v] creating file in zip archive: %v", u.ID, err) + return + } + + r, err := s.DB.UserAvatar(ctx, u.ID, *u.Avatar) + if err != nil { + log.Errorf("[%v] getting user avatar: %v", u.ID, err) + return + } + defer r.Close() + + _, err = io.Copy(w, r) + if err != nil { + log.Errorf("[%v] writing user avatar: %v", u.ID, err) + return + } + + log.Debugf("[%v] exported user avatar", u.ID) + } + + members, err := s.DB.UserMembers(ctx, u.ID, true) + if err != nil { + log.Errorf("[%v] getting user members: %v", u.ID, err) + return + } + + for _, m := range members { + log.Debugf("[%v] starting export for member %v", u.ID, m.ID) + + fields, err := s.DB.MemberFields(ctx, m.ID) + if err != nil { + log.Errorf("[%v] getting fields for member %v: %v", u.ID, m.ID, err) + return + } + + w, err := zw.Create("members/" + m.Name + "-" + m.ID.String() + ".json") + if err != nil { + log.Errorf("[%v] creating file in zip archive: %v", u.ID, err) + return + } + + err = encoder.Encode(dbMemberToExport(m, fields)) + if err != nil { + log.Errorf("[%v] marshaling member %v: %v", u.ID, m.ID, err) + return + } + + _, err = io.Copy(w, jsonBuffer) + if err != nil { + log.Errorf("[%v] writing member %v json: %v", u.ID, m.ID, err) + return + } + jsonBuffer.Reset() + + if m.Avatar != nil { + log.Debugf("[%v] getting member %v avatar", u.ID, m.ID) + + w, err := zw.Create("members/" + m.Name + "-" + m.ID.String() + "-avatar.webp") + if err != nil { + log.Errorf("[%v] creating file in zip archive: %v", u.ID, err) + return + } + + r, err := s.DB.MemberAvatar(ctx, m.ID, *m.Avatar) + if err != nil { + log.Errorf("[%v] getting member %v avatar: %v", u.ID, m.ID, err) + return + } + defer r.Close() + + _, err = io.Copy(w, r) + if err != nil { + log.Errorf("[%v] writing member %v avatar: %v", u.ID, m.ID, err) + return + } + + log.Debugf("[%v] exported member %v avatar", u.ID, m.ID) + } + + log.Debugf("[%v] finished export for member %v", u.ID, m.ID) + } + + log.Debugf("[%v] finished export, uploading to object storage and saving in database", u.ID) + + err = zw.Close() + if err != nil { + log.Errorf("[%v] closing zip file: %v", u.ID, err) + return + } + + de, err := s.DB.CreateExport(ctx, u.ID, randomFilename(), outBuffer) + if err != nil { + log.Errorf("[%v] writing export: %v", u.ID, err) + return + } + + log.Debugf("[%v] finished writing export. path: %q", u.ID, de.Path()) +} + +func randomFilename() string { + b := make([]byte, 32) + + _, err := rand.Read(b) + if err != nil { + panic(err) + } + + return base64.RawURLEncoding.EncodeToString(b) +} diff --git a/backend/exporter/types.go b/backend/exporter/types.go new file mode 100644 index 0000000..3d4f14e --- /dev/null +++ b/backend/exporter/types.go @@ -0,0 +1,86 @@ +package exporter + +import ( + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "github.com/rs/xid" +) + +type userExport struct { + ID xid.ID `json:"id"` + Username string `json:"name"` + DisplayName *string `json:"display_name"` + Bio *string `json:"bio"` + + Links []string `json:"links"` + + Names []db.FieldEntry `json:"names"` + Pronouns []db.PronounEntry `json:"pronouns"` + Fields []db.Field `json:"fields"` + + Fediverse *string `json:"fediverse"` + FediverseUsername *string `json:"fediverse_username"` + FediverseInstance *string `json:"fediverse_instance"` + + Discord *string `json:"discord"` + DiscordUsername *string `json:"discord_username"` + + Tumblr *string `json:"tumblr"` + TumblrUsername *string `json:"tumblr_username"` + + Google *string `json:"google"` + GoogleUsername *string `json:"google_username"` + + MaxInvites int `json:"max_invites"` + + Warnings []db.Warning `json:"warnings"` +} + +func dbUserToExport(u db.User, fields []db.Field, warnings []db.Warning) userExport { + return userExport{ + ID: u.ID, + Username: u.Username, + DisplayName: u.DisplayName, + Bio: u.Bio, + Links: db.NotNull(u.Links), + Names: db.NotNull(u.Names), + Pronouns: db.NotNull(u.Pronouns), + Fields: db.NotNull(fields), + Discord: u.Discord, + DiscordUsername: u.DiscordUsername, + Tumblr: u.Tumblr, + TumblrUsername: u.TumblrUsername, + Google: u.Google, + GoogleUsername: u.GoogleUsername, + MaxInvites: u.MaxInvites, + Fediverse: u.Fediverse, + FediverseUsername: u.FediverseUsername, + FediverseInstance: u.FediverseInstance, + Warnings: db.NotNull(warnings), + } +} + +type memberExport struct { + ID xid.ID `json:"id"` + Name string `json:"name"` + DisplayName *string `json:"display_name"` + Bio *string `json:"bio"` + Links []string `json:"links"` + Names []db.FieldEntry `json:"names"` + Pronouns []db.PronounEntry `json:"pronouns"` + Fields []db.Field `json:"fields"` + Unlisted bool `json:"unlisted"` +} + +func dbMemberToExport(m db.Member, fields []db.Field) memberExport { + return memberExport{ + ID: m.ID, + Name: m.Name, + DisplayName: m.DisplayName, + Bio: m.Bio, + Links: db.NotNull(m.Links), + Names: db.NotNull(m.Names), + Pronouns: db.NotNull(m.Pronouns), + Fields: db.NotNull(fields), + Unlisted: m.Unlisted, + } +} diff --git a/backend/icons/icons.go b/backend/icons/icons.go new file mode 100644 index 0000000..3bc0e80 --- /dev/null +++ b/backend/icons/icons.go @@ -0,0 +1,1968 @@ +// Generated code. DO NOT EDIT +package icons + +var icons = [...]string{ + "123", + "alarm-fill", + "alarm", + "align-bottom", + "align-center", + "align-end", + "align-middle", + "align-start", + "align-top", + "alt", + "app-indicator", + "app", + "archive-fill", + "archive", + "arrow-90deg-down", + "arrow-90deg-left", + "arrow-90deg-right", + "arrow-90deg-up", + "arrow-bar-down", + "arrow-bar-left", + "arrow-bar-right", + "arrow-bar-up", + "arrow-clockwise", + "arrow-counterclockwise", + "arrow-down-circle-fill", + "arrow-down-circle", + "arrow-down-left-circle-fill", + "arrow-down-left-circle", + "arrow-down-left-square-fill", + "arrow-down-left-square", + "arrow-down-left", + "arrow-down-right-circle-fill", + "arrow-down-right-circle", + "arrow-down-right-square-fill", + "arrow-down-right-square", + "arrow-down-right", + "arrow-down-short", + "arrow-down-square-fill", + "arrow-down-square", + "arrow-down-up", + "arrow-down", + "arrow-left-circle-fill", + "arrow-left-circle", + "arrow-left-right", + "arrow-left-short", + "arrow-left-square-fill", + "arrow-left-square", + "arrow-left", + "arrow-repeat", + "arrow-return-left", + "arrow-return-right", + "arrow-right-circle-fill", + "arrow-right-circle", + "arrow-right-short", + "arrow-right-square-fill", + "arrow-right-square", + "arrow-right", + "arrow-up-circle-fill", + "arrow-up-circle", + "arrow-up-left-circle-fill", + "arrow-up-left-circle", + "arrow-up-left-square-fill", + "arrow-up-left-square", + "arrow-up-left", + "arrow-up-right-circle-fill", + "arrow-up-right-circle", + "arrow-up-right-square-fill", + "arrow-up-right-square", + "arrow-up-right", + "arrow-up-short", + "arrow-up-square-fill", + "arrow-up-square", + "arrow-up", + "arrows-angle-contract", + "arrows-angle-expand", + "arrows-collapse", + "arrows-expand", + "arrows-fullscreen", + "arrows-move", + "aspect-ratio-fill", + "aspect-ratio", + "asterisk", + "at", + "award-fill", + "award", + "back", + "backspace-fill", + "backspace-reverse-fill", + "backspace-reverse", + "backspace", + "badge-3d-fill", + "badge-3d", + "badge-4k-fill", + "badge-4k", + "badge-8k-fill", + "badge-8k", + "badge-ad-fill", + "badge-ad", + "badge-ar-fill", + "badge-ar", + "badge-cc-fill", + "badge-cc", + "badge-hd-fill", + "badge-hd", + "badge-tm-fill", + "badge-tm", + "badge-vo-fill", + "badge-vo", + "badge-vr-fill", + "badge-vr", + "badge-wc-fill", + "badge-wc", + "bag-check-fill", + "bag-check", + "bag-dash-fill", + "bag-dash", + "bag-fill", + "bag-plus-fill", + "bag-plus", + "bag-x-fill", + "bag-x", + "bag", + "bar-chart-fill", + "bar-chart-line-fill", + "bar-chart-line", + "bar-chart-steps", + "bar-chart", + "basket-fill", + "basket", + "basket2-fill", + "basket2", + "basket3-fill", + "basket3", + "battery-charging", + "battery-full", + "battery-half", + "battery", + "bell-fill", + "bell", + "bezier", + "bezier2", + "bicycle", + "binoculars-fill", + "binoculars", + "blockquote-left", + "blockquote-right", + "book-fill", + "book-half", + "book", + "bookmark-check-fill", + "bookmark-check", + "bookmark-dash-fill", + "bookmark-dash", + "bookmark-fill", + "bookmark-heart-fill", + "bookmark-heart", + "bookmark-plus-fill", + "bookmark-plus", + "bookmark-star-fill", + "bookmark-star", + "bookmark-x-fill", + "bookmark-x", + "bookmark", + "bookmarks-fill", + "bookmarks", + "bookshelf", + "bootstrap-fill", + "bootstrap-reboot", + "bootstrap", + "border-all", + "border-bottom", + "border-center", + "border-inner", + "border-left", + "border-middle", + "border-outer", + "border-right", + "border-style", + "border-top", + "border-width", + "border", + "bounding-box-circles", + "bounding-box", + "box-arrow-down-left", + "box-arrow-down-right", + "box-arrow-down", + "box-arrow-in-down-left", + "box-arrow-in-down-right", + "box-arrow-in-down", + "box-arrow-in-left", + "box-arrow-in-right", + "box-arrow-in-up-left", + "box-arrow-in-up-right", + "box-arrow-in-up", + "box-arrow-left", + "box-arrow-right", + "box-arrow-up-left", + "box-arrow-up-right", + "box-arrow-up", + "box-seam", + "box", + "braces", + "bricks", + "briefcase-fill", + "briefcase", + "brightness-alt-high-fill", + "brightness-alt-high", + "brightness-alt-low-fill", + "brightness-alt-low", + "brightness-high-fill", + "brightness-high", + "brightness-low-fill", + "brightness-low", + "broadcast-pin", + "broadcast", + "brush-fill", + "brush", + "bucket-fill", + "bucket", + "bug-fill", + "bug", + "building", + "bullseye", + "calculator-fill", + "calculator", + "calendar-check-fill", + "calendar-check", + "calendar-date-fill", + "calendar-date", + "calendar-day-fill", + "calendar-day", + "calendar-event-fill", + "calendar-event", + "calendar-fill", + "calendar-minus-fill", + "calendar-minus", + "calendar-month-fill", + "calendar-month", + "calendar-plus-fill", + "calendar-plus", + "calendar-range-fill", + "calendar-range", + "calendar-week-fill", + "calendar-week", + "calendar-x-fill", + "calendar-x", + "calendar", + "calendar2-check-fill", + "calendar2-check", + "calendar2-date-fill", + "calendar2-date", + "calendar2-day-fill", + "calendar2-day", + "calendar2-event-fill", + "calendar2-event", + "calendar2-fill", + "calendar2-minus-fill", + "calendar2-minus", + "calendar2-month-fill", + "calendar2-month", + "calendar2-plus-fill", + "calendar2-plus", + "calendar2-range-fill", + "calendar2-range", + "calendar2-week-fill", + "calendar2-week", + "calendar2-x-fill", + "calendar2-x", + "calendar2", + "calendar3-event-fill", + "calendar3-event", + "calendar3-fill", + "calendar3-range-fill", + "calendar3-range", + "calendar3-week-fill", + "calendar3-week", + "calendar3", + "calendar4-event", + "calendar4-range", + "calendar4-week", + "calendar4", + "camera-fill", + "camera-reels-fill", + "camera-reels", + "camera-video-fill", + "camera-video-off-fill", + "camera-video-off", + "camera-video", + "camera", + "camera2", + "capslock-fill", + "capslock", + "card-checklist", + "card-heading", + "card-image", + "card-list", + "card-text", + "caret-down-fill", + "caret-down-square-fill", + "caret-down-square", + "caret-down", + "caret-left-fill", + "caret-left-square-fill", + "caret-left-square", + "caret-left", + "caret-right-fill", + "caret-right-square-fill", + "caret-right-square", + "caret-right", + "caret-up-fill", + "caret-up-square-fill", + "caret-up-square", + "caret-up", + "cart-check-fill", + "cart-check", + "cart-dash-fill", + "cart-dash", + "cart-fill", + "cart-plus-fill", + "cart-plus", + "cart-x-fill", + "cart-x", + "cart", + "cart2", + "cart3", + "cart4", + "cash-stack", + "cash", + "cast", + "chat-dots-fill", + "chat-dots", + "chat-fill", + "chat-left-dots-fill", + "chat-left-dots", + "chat-left-fill", + "chat-left-quote-fill", + "chat-left-quote", + "chat-left-text-fill", + "chat-left-text", + "chat-left", + "chat-quote-fill", + "chat-quote", + "chat-right-dots-fill", + "chat-right-dots", + "chat-right-fill", + "chat-right-quote-fill", + "chat-right-quote", + "chat-right-text-fill", + "chat-right-text", + "chat-right", + "chat-square-dots-fill", + "chat-square-dots", + "chat-square-fill", + "chat-square-quote-fill", + "chat-square-quote", + "chat-square-text-fill", + "chat-square-text", + "chat-square", + "chat-text-fill", + "chat-text", + "chat", + "check-all", + "check-circle-fill", + "check-circle", + "check-square-fill", + "check-square", + "check", + "check2-all", + "check2-circle", + "check2-square", + "check2", + "chevron-bar-contract", + "chevron-bar-down", + "chevron-bar-expand", + "chevron-bar-left", + "chevron-bar-right", + "chevron-bar-up", + "chevron-compact-down", + "chevron-compact-left", + "chevron-compact-right", + "chevron-compact-up", + "chevron-contract", + "chevron-double-down", + "chevron-double-left", + "chevron-double-right", + "chevron-double-up", + "chevron-down", + "chevron-expand", + "chevron-left", + "chevron-right", + "chevron-up", + "circle-fill", + "circle-half", + "circle-square", + "circle", + "clipboard-check", + "clipboard-data", + "clipboard-minus", + "clipboard-plus", + "clipboard-x", + "clipboard", + "clock-fill", + "clock-history", + "clock", + "cloud-arrow-down-fill", + "cloud-arrow-down", + "cloud-arrow-up-fill", + "cloud-arrow-up", + "cloud-check-fill", + "cloud-check", + "cloud-download-fill", + "cloud-download", + "cloud-drizzle-fill", + "cloud-drizzle", + "cloud-fill", + "cloud-fog-fill", + "cloud-fog", + "cloud-fog2-fill", + "cloud-fog2", + "cloud-hail-fill", + "cloud-hail", + "cloud-haze-fill", + "cloud-haze", + "cloud-haze2-fill", + "cloud-lightning-fill", + "cloud-lightning-rain-fill", + "cloud-lightning-rain", + "cloud-lightning", + "cloud-minus-fill", + "cloud-minus", + "cloud-moon-fill", + "cloud-moon", + "cloud-plus-fill", + "cloud-plus", + "cloud-rain-fill", + "cloud-rain-heavy-fill", + "cloud-rain-heavy", + "cloud-rain", + "cloud-slash-fill", + "cloud-slash", + "cloud-sleet-fill", + "cloud-sleet", + "cloud-snow-fill", + "cloud-snow", + "cloud-sun-fill", + "cloud-sun", + "cloud-upload-fill", + "cloud-upload", + "cloud", + "clouds-fill", + "clouds", + "cloudy-fill", + "cloudy", + "code-slash", + "code-square", + "code", + "collection-fill", + "collection-play-fill", + "collection-play", + "collection", + "columns-gap", + "columns", + "command", + "compass-fill", + "compass", + "cone-striped", + "cone", + "controller", + "cpu-fill", + "cpu", + "credit-card-2-back-fill", + "credit-card-2-back", + "credit-card-2-front-fill", + "credit-card-2-front", + "credit-card-fill", + "credit-card", + "crop", + "cup-fill", + "cup-straw", + "cup", + "cursor-fill", + "cursor-text", + "cursor", + "dash-circle-dotted", + "dash-circle-fill", + "dash-circle", + "dash-square-dotted", + "dash-square-fill", + "dash-square", + "dash", + "diagram-2-fill", + "diagram-2", + "diagram-3-fill", + "diagram-3", + "diamond-fill", + "diamond-half", + "diamond", + "dice-1-fill", + "dice-1", + "dice-2-fill", + "dice-2", + "dice-3-fill", + "dice-3", + "dice-4-fill", + "dice-4", + "dice-5-fill", + "dice-5", + "dice-6-fill", + "dice-6", + "disc-fill", + "disc", + "discord", + "display-fill", + "display", + "distribute-horizontal", + "distribute-vertical", + "door-closed-fill", + "door-closed", + "door-open-fill", + "door-open", + "dot", + "download", + "droplet-fill", + "droplet-half", + "droplet", + "earbuds", + "easel-fill", + "easel", + "egg-fill", + "egg-fried", + "egg", + "eject-fill", + "eject", + "emoji-angry-fill", + "emoji-angry", + "emoji-dizzy-fill", + "emoji-dizzy", + "emoji-expressionless-fill", + "emoji-expressionless", + "emoji-frown-fill", + "emoji-frown", + "emoji-heart-eyes-fill", + "emoji-heart-eyes", + "emoji-laughing-fill", + "emoji-laughing", + "emoji-neutral-fill", + "emoji-neutral", + "emoji-smile-fill", + "emoji-smile-upside-down-fill", + "emoji-smile-upside-down", + "emoji-smile", + "emoji-sunglasses-fill", + "emoji-sunglasses", + "emoji-wink-fill", + "emoji-wink", + "envelope-fill", + "envelope-open-fill", + "envelope-open", + "envelope", + "eraser-fill", + "eraser", + "exclamation-circle-fill", + "exclamation-circle", + "exclamation-diamond-fill", + "exclamation-diamond", + "exclamation-octagon-fill", + "exclamation-octagon", + "exclamation-square-fill", + "exclamation-square", + "exclamation-triangle-fill", + "exclamation-triangle", + "exclamation", + "exclude", + "eye-fill", + "eye-slash-fill", + "eye-slash", + "eye", + "eyedropper", + "eyeglasses", + "facebook", + "file-arrow-down-fill", + "file-arrow-down", + "file-arrow-up-fill", + "file-arrow-up", + "file-bar-graph-fill", + "file-bar-graph", + "file-binary-fill", + "file-binary", + "file-break-fill", + "file-break", + "file-check-fill", + "file-check", + "file-code-fill", + "file-code", + "file-diff-fill", + "file-diff", + "file-earmark-arrow-down-fill", + "file-earmark-arrow-down", + "file-earmark-arrow-up-fill", + "file-earmark-arrow-up", + "file-earmark-bar-graph-fill", + "file-earmark-bar-graph", + "file-earmark-binary-fill", + "file-earmark-binary", + "file-earmark-break-fill", + "file-earmark-break", + "file-earmark-check-fill", + "file-earmark-check", + "file-earmark-code-fill", + "file-earmark-code", + "file-earmark-diff-fill", + "file-earmark-diff", + "file-earmark-easel-fill", + "file-earmark-easel", + "file-earmark-excel-fill", + "file-earmark-excel", + "file-earmark-fill", + "file-earmark-font-fill", + "file-earmark-font", + "file-earmark-image-fill", + "file-earmark-image", + "file-earmark-lock-fill", + "file-earmark-lock", + "file-earmark-lock2-fill", + "file-earmark-lock2", + "file-earmark-medical-fill", + "file-earmark-medical", + "file-earmark-minus-fill", + "file-earmark-minus", + "file-earmark-music-fill", + "file-earmark-music", + "file-earmark-person-fill", + "file-earmark-person", + "file-earmark-play-fill", + "file-earmark-play", + "file-earmark-plus-fill", + "file-earmark-plus", + "file-earmark-post-fill", + "file-earmark-post", + "file-earmark-ppt-fill", + "file-earmark-ppt", + "file-earmark-richtext-fill", + "file-earmark-richtext", + "file-earmark-ruled-fill", + "file-earmark-ruled", + "file-earmark-slides-fill", + "file-earmark-slides", + "file-earmark-spreadsheet-fill", + "file-earmark-spreadsheet", + "file-earmark-text-fill", + "file-earmark-text", + "file-earmark-word-fill", + "file-earmark-word", + "file-earmark-x-fill", + "file-earmark-x", + "file-earmark-zip-fill", + "file-earmark-zip", + "file-earmark", + "file-easel-fill", + "file-easel", + "file-excel-fill", + "file-excel", + "file-fill", + "file-font-fill", + "file-font", + "file-image-fill", + "file-image", + "file-lock-fill", + "file-lock", + "file-lock2-fill", + "file-lock2", + "file-medical-fill", + "file-medical", + "file-minus-fill", + "file-minus", + "file-music-fill", + "file-music", + "file-person-fill", + "file-person", + "file-play-fill", + "file-play", + "file-plus-fill", + "file-plus", + "file-post-fill", + "file-post", + "file-ppt-fill", + "file-ppt", + "file-richtext-fill", + "file-richtext", + "file-ruled-fill", + "file-ruled", + "file-slides-fill", + "file-slides", + "file-spreadsheet-fill", + "file-spreadsheet", + "file-text-fill", + "file-text", + "file-word-fill", + "file-word", + "file-x-fill", + "file-x", + "file-zip-fill", + "file-zip", + "file", + "files-alt", + "files", + "film", + "filter-circle-fill", + "filter-circle", + "filter-left", + "filter-right", + "filter-square-fill", + "filter-square", + "filter", + "flag-fill", + "flag", + "flower1", + "flower2", + "flower3", + "folder-check", + "folder-fill", + "folder-minus", + "folder-plus", + "folder-symlink-fill", + "folder-symlink", + "folder-x", + "folder", + "folder2-open", + "folder2", + "fonts", + "forward-fill", + "forward", + "front", + "fullscreen-exit", + "fullscreen", + "funnel-fill", + "funnel", + "gear-fill", + "gear-wide-connected", + "gear-wide", + "gear", + "gem", + "geo-alt-fill", + "geo-alt", + "geo-fill", + "geo", + "gift-fill", + "gift", + "github", + "globe", + "globe2", + "google", + "graph-down", + "graph-up", + "grid-1x2-fill", + "grid-1x2", + "grid-3x2-gap-fill", + "grid-3x2-gap", + "grid-3x2", + "grid-3x3-gap-fill", + "grid-3x3-gap", + "grid-3x3", + "grid-fill", + "grid", + "grip-horizontal", + "grip-vertical", + "hammer", + "hand-index-fill", + "hand-index-thumb-fill", + "hand-index-thumb", + "hand-index", + "hand-thumbs-down-fill", + "hand-thumbs-down", + "hand-thumbs-up-fill", + "hand-thumbs-up", + "handbag-fill", + "handbag", + "hash", + "hdd-fill", + "hdd-network-fill", + "hdd-network", + "hdd-rack-fill", + "hdd-rack", + "hdd-stack-fill", + "hdd-stack", + "hdd", + "headphones", + "headset", + "heart-fill", + "heart-half", + "heart", + "heptagon-fill", + "heptagon-half", + "heptagon", + "hexagon-fill", + "hexagon-half", + "hexagon", + "hourglass-bottom", + "hourglass-split", + "hourglass-top", + "hourglass", + "house-door-fill", + "house-door", + "house-fill", + "house", + "hr", + "hurricane", + "image-alt", + "image-fill", + "image", + "images", + "inbox-fill", + "inbox", + "inboxes-fill", + "inboxes", + "info-circle-fill", + "info-circle", + "info-square-fill", + "info-square", + "info", + "input-cursor-text", + "input-cursor", + "instagram", + "intersect", + "journal-album", + "journal-arrow-down", + "journal-arrow-up", + "journal-bookmark-fill", + "journal-bookmark", + "journal-check", + "journal-code", + "journal-medical", + "journal-minus", + "journal-plus", + "journal-richtext", + "journal-text", + "journal-x", + "journal", + "journals", + "joystick", + "justify-left", + "justify-right", + "justify", + "kanban-fill", + "kanban", + "key-fill", + "key", + "keyboard-fill", + "keyboard", + "ladder", + "lamp-fill", + "lamp", + "laptop-fill", + "laptop", + "layer-backward", + "layer-forward", + "layers-fill", + "layers-half", + "layers", + "layout-sidebar-inset-reverse", + "layout-sidebar-inset", + "layout-sidebar-reverse", + "layout-sidebar", + "layout-split", + "layout-text-sidebar-reverse", + "layout-text-sidebar", + "layout-text-window-reverse", + "layout-text-window", + "layout-three-columns", + "layout-wtf", + "life-preserver", + "lightbulb-fill", + "lightbulb-off-fill", + "lightbulb-off", + "lightbulb", + "lightning-charge-fill", + "lightning-charge", + "lightning-fill", + "lightning", + "link-45deg", + "link", + "linkedin", + "list-check", + "list-nested", + "list-ol", + "list-stars", + "list-task", + "list-ul", + "list", + "lock-fill", + "lock", + "mailbox", + "mailbox2", + "map-fill", + "map", + "markdown-fill", + "markdown", + "mask", + "megaphone-fill", + "megaphone", + "menu-app-fill", + "menu-app", + "menu-button-fill", + "menu-button-wide-fill", + "menu-button-wide", + "menu-button", + "menu-down", + "menu-up", + "mic-fill", + "mic-mute-fill", + "mic-mute", + "mic", + "minecart-loaded", + "minecart", + "moisture", + "moon-fill", + "moon-stars-fill", + "moon-stars", + "moon", + "mouse-fill", + "mouse", + "mouse2-fill", + "mouse2", + "mouse3-fill", + "mouse3", + "music-note-beamed", + "music-note-list", + "music-note", + "music-player-fill", + "music-player", + "newspaper", + "node-minus-fill", + "node-minus", + "node-plus-fill", + "node-plus", + "nut-fill", + "nut", + "octagon-fill", + "octagon-half", + "octagon", + "option", + "outlet", + "paint-bucket", + "palette-fill", + "palette", + "palette2", + "paperclip", + "paragraph", + "patch-check-fill", + "patch-check", + "patch-exclamation-fill", + "patch-exclamation", + "patch-minus-fill", + "patch-minus", + "patch-plus-fill", + "patch-plus", + "patch-question-fill", + "patch-question", + "pause-btn-fill", + "pause-btn", + "pause-circle-fill", + "pause-circle", + "pause-fill", + "pause", + "peace-fill", + "peace", + "pen-fill", + "pen", + "pencil-fill", + "pencil-square", + "pencil", + "pentagon-fill", + "pentagon-half", + "pentagon", + "people-fill", + "people", + "percent", + "person-badge-fill", + "person-badge", + "person-bounding-box", + "person-check-fill", + "person-check", + "person-circle", + "person-dash-fill", + "person-dash", + "person-fill", + "person-lines-fill", + "person-plus-fill", + "person-plus", + "person-square", + "person-x-fill", + "person-x", + "person", + "phone-fill", + "phone-landscape-fill", + "phone-landscape", + "phone-vibrate-fill", + "phone-vibrate", + "phone", + "pie-chart-fill", + "pie-chart", + "pin-angle-fill", + "pin-angle", + "pin-fill", + "pin", + "pip-fill", + "pip", + "play-btn-fill", + "play-btn", + "play-circle-fill", + "play-circle", + "play-fill", + "play", + "plug-fill", + "plug", + "plus-circle-dotted", + "plus-circle-fill", + "plus-circle", + "plus-square-dotted", + "plus-square-fill", + "plus-square", + "plus", + "power", + "printer-fill", + "printer", + "puzzle-fill", + "puzzle", + "question-circle-fill", + "question-circle", + "question-diamond-fill", + "question-diamond", + "question-octagon-fill", + "question-octagon", + "question-square-fill", + "question-square", + "question", + "rainbow", + "receipt-cutoff", + "receipt", + "reception-0", + "reception-1", + "reception-2", + "reception-3", + "reception-4", + "record-btn-fill", + "record-btn", + "record-circle-fill", + "record-circle", + "record-fill", + "record", + "record2-fill", + "record2", + "reply-all-fill", + "reply-all", + "reply-fill", + "reply", + "rss-fill", + "rss", + "rulers", + "save-fill", + "save", + "save2-fill", + "save2", + "scissors", + "screwdriver", + "search", + "segmented-nav", + "server", + "share-fill", + "share", + "shield-check", + "shield-exclamation", + "shield-fill-check", + "shield-fill-exclamation", + "shield-fill-minus", + "shield-fill-plus", + "shield-fill-x", + "shield-fill", + "shield-lock-fill", + "shield-lock", + "shield-minus", + "shield-plus", + "shield-shaded", + "shield-slash-fill", + "shield-slash", + "shield-x", + "shield", + "shift-fill", + "shift", + "shop-window", + "shop", + "shuffle", + "signpost-2-fill", + "signpost-2", + "signpost-fill", + "signpost-split-fill", + "signpost-split", + "signpost", + "sim-fill", + "sim", + "skip-backward-btn-fill", + "skip-backward-btn", + "skip-backward-circle-fill", + "skip-backward-circle", + "skip-backward-fill", + "skip-backward", + "skip-end-btn-fill", + "skip-end-btn", + "skip-end-circle-fill", + "skip-end-circle", + "skip-end-fill", + "skip-end", + "skip-forward-btn-fill", + "skip-forward-btn", + "skip-forward-circle-fill", + "skip-forward-circle", + "skip-forward-fill", + "skip-forward", + "skip-start-btn-fill", + "skip-start-btn", + "skip-start-circle-fill", + "skip-start-circle", + "skip-start-fill", + "skip-start", + "slack", + "slash-circle-fill", + "slash-circle", + "slash-square-fill", + "slash-square", + "slash", + "sliders", + "smartwatch", + "snow", + "snow2", + "snow3", + "sort-alpha-down-alt", + "sort-alpha-down", + "sort-alpha-up-alt", + "sort-alpha-up", + "sort-down-alt", + "sort-down", + "sort-numeric-down-alt", + "sort-numeric-down", + "sort-numeric-up-alt", + "sort-numeric-up", + "sort-up-alt", + "sort-up", + "soundwave", + "speaker-fill", + "speaker", + "speedometer", + "speedometer2", + "spellcheck", + "square-fill", + "square-half", + "square", + "stack", + "star-fill", + "star-half", + "star", + "stars", + "stickies-fill", + "stickies", + "sticky-fill", + "sticky", + "stop-btn-fill", + "stop-btn", + "stop-circle-fill", + "stop-circle", + "stop-fill", + "stop", + "stoplights-fill", + "stoplights", + "stopwatch-fill", + "stopwatch", + "subtract", + "suit-club-fill", + "suit-club", + "suit-diamond-fill", + "suit-diamond", + "suit-heart-fill", + "suit-heart", + "suit-spade-fill", + "suit-spade", + "sun-fill", + "sun", + "sunglasses", + "sunrise-fill", + "sunrise", + "sunset-fill", + "sunset", + "symmetry-horizontal", + "symmetry-vertical", + "table", + "tablet-fill", + "tablet-landscape-fill", + "tablet-landscape", + "tablet", + "tag-fill", + "tag", + "tags-fill", + "tags", + "telegram", + "telephone-fill", + "telephone-forward-fill", + "telephone-forward", + "telephone-inbound-fill", + "telephone-inbound", + "telephone-minus-fill", + "telephone-minus", + "telephone-outbound-fill", + "telephone-outbound", + "telephone-plus-fill", + "telephone-plus", + "telephone-x-fill", + "telephone-x", + "telephone", + "terminal-fill", + "terminal", + "text-center", + "text-indent-left", + "text-indent-right", + "text-left", + "text-paragraph", + "text-right", + "textarea-resize", + "textarea-t", + "textarea", + "thermometer-half", + "thermometer-high", + "thermometer-low", + "thermometer-snow", + "thermometer-sun", + "thermometer", + "three-dots-vertical", + "three-dots", + "toggle-off", + "toggle-on", + "toggle2-off", + "toggle2-on", + "toggles", + "toggles2", + "tools", + "tornado", + "trash-fill", + "trash", + "trash2-fill", + "trash2", + "tree-fill", + "tree", + "triangle-fill", + "triangle-half", + "triangle", + "trophy-fill", + "trophy", + "tropical-storm", + "truck-flatbed", + "truck", + "tsunami", + "tv-fill", + "tv", + "twitch", + "twitter", + "type-bold", + "type-h1", + "type-h2", + "type-h3", + "type-italic", + "type-strikethrough", + "type-underline", + "type", + "ui-checks-grid", + "ui-checks", + "ui-radios-grid", + "ui-radios", + "umbrella-fill", + "umbrella", + "union", + "unlock-fill", + "unlock", + "upc-scan", + "upc", + "upload", + "vector-pen", + "view-list", + "view-stacked", + "vinyl-fill", + "vinyl", + "voicemail", + "volume-down-fill", + "volume-down", + "volume-mute-fill", + "volume-mute", + "volume-off-fill", + "volume-off", + "volume-up-fill", + "volume-up", + "vr", + "wallet-fill", + "wallet", + "wallet2", + "watch", + "water", + "whatsapp", + "wifi-1", + "wifi-2", + "wifi-off", + "wifi", + "wind", + "window-dock", + "window-sidebar", + "window", + "wrench", + "x-circle-fill", + "x-circle", + "x-diamond-fill", + "x-diamond", + "x-octagon-fill", + "x-octagon", + "x-square-fill", + "x-square", + "x", + "youtube", + "zoom-in", + "zoom-out", + "bank", + "bank2", + "bell-slash-fill", + "bell-slash", + "cash-coin", + "check-lg", + "coin", + "currency-bitcoin", + "currency-dollar", + "currency-euro", + "currency-exchange", + "currency-pound", + "currency-yen", + "dash-lg", + "exclamation-lg", + "file-earmark-pdf-fill", + "file-earmark-pdf", + "file-pdf-fill", + "file-pdf", + "gender-ambiguous", + "gender-female", + "gender-male", + "gender-trans", + "headset-vr", + "info-lg", + "mastodon", + "messenger", + "piggy-bank-fill", + "piggy-bank", + "pin-map-fill", + "pin-map", + "plus-lg", + "question-lg", + "recycle", + "reddit", + "safe-fill", + "safe2-fill", + "safe2", + "sd-card-fill", + "sd-card", + "skype", + "slash-lg", + "translate", + "x-lg", + "safe", + "apple", + "microsoft", + "windows", + "behance", + "dribbble", + "line", + "medium", + "paypal", + "pinterest", + "signal", + "snapchat", + "spotify", + "stack-overflow", + "strava", + "wordpress", + "vimeo", + "activity", + "easel2-fill", + "easel2", + "easel3-fill", + "easel3", + "fan", + "fingerprint", + "graph-down-arrow", + "graph-up-arrow", + "hypnotize", + "magic", + "person-rolodex", + "person-video", + "person-video2", + "person-video3", + "person-workspace", + "radioactive", + "webcam-fill", + "webcam", + "yin-yang", + "bandaid-fill", + "bandaid", + "bluetooth", + "body-text", + "boombox", + "boxes", + "dpad-fill", + "dpad", + "ear-fill", + "ear", + "envelope-check-fill", + "envelope-check", + "envelope-dash-fill", + "envelope-dash", + "envelope-exclamation-fill", + "envelope-exclamation", + "envelope-plus-fill", + "envelope-plus", + "envelope-slash-fill", + "envelope-slash", + "envelope-x-fill", + "envelope-x", + "explicit-fill", + "explicit", + "git", + "infinity", + "list-columns-reverse", + "list-columns", + "meta", + "nintendo-switch", + "pc-display-horizontal", + "pc-display", + "pc-horizontal", + "pc", + "playstation", + "plus-slash-minus", + "projector-fill", + "projector", + "qr-code-scan", + "qr-code", + "quora", + "quote", + "robot", + "send-check-fill", + "send-check", + "send-dash-fill", + "send-dash", + "send-exclamation-fill", + "send-exclamation", + "send-fill", + "send-plus-fill", + "send-plus", + "send-slash-fill", + "send-slash", + "send-x-fill", + "send-x", + "send", + "steam", + "terminal-dash", + "terminal-plus", + "terminal-split", + "ticket-detailed-fill", + "ticket-detailed", + "ticket-fill", + "ticket-perforated-fill", + "ticket-perforated", + "ticket", + "tiktok", + "window-dash", + "window-desktop", + "window-fullscreen", + "window-plus", + "window-split", + "window-stack", + "window-x", + "xbox", + "ethernet", + "hdmi-fill", + "hdmi", + "usb-c-fill", + "usb-c", + "usb-fill", + "usb-plug-fill", + "usb-plug", + "usb-symbol", + "usb", + "boombox-fill", + "displayport", + "gpu-card", + "memory", + "modem-fill", + "modem", + "motherboard-fill", + "motherboard", + "optical-audio-fill", + "optical-audio", + "pci-card", + "router-fill", + "router", + "thunderbolt-fill", + "thunderbolt", + "usb-drive-fill", + "usb-drive", + "usb-micro-fill", + "usb-micro", + "usb-mini-fill", + "usb-mini", + "cloud-haze2", + "device-hdd-fill", + "device-hdd", + "device-ssd-fill", + "device-ssd", + "displayport-fill", + "mortarboard-fill", + "mortarboard", + "terminal-x", + "arrow-through-heart-fill", + "arrow-through-heart", + "badge-sd-fill", + "badge-sd", + "bag-heart-fill", + "bag-heart", + "balloon-fill", + "balloon-heart-fill", + "balloon-heart", + "balloon", + "box2-fill", + "box2-heart-fill", + "box2-heart", + "box2", + "braces-asterisk", + "calendar-heart-fill", + "calendar-heart", + "calendar2-heart-fill", + "calendar2-heart", + "chat-heart-fill", + "chat-heart", + "chat-left-heart-fill", + "chat-left-heart", + "chat-right-heart-fill", + "chat-right-heart", + "chat-square-heart-fill", + "chat-square-heart", + "clipboard-check-fill", + "clipboard-data-fill", + "clipboard-fill", + "clipboard-heart-fill", + "clipboard-heart", + "clipboard-minus-fill", + "clipboard-plus-fill", + "clipboard-pulse", + "clipboard-x-fill", + "clipboard2-check-fill", + "clipboard2-check", + "clipboard2-data-fill", + "clipboard2-data", + "clipboard2-fill", + "clipboard2-heart-fill", + "clipboard2-heart", + "clipboard2-minus-fill", + "clipboard2-minus", + "clipboard2-plus-fill", + "clipboard2-plus", + "clipboard2-pulse-fill", + "clipboard2-pulse", + "clipboard2-x-fill", + "clipboard2-x", + "clipboard2", + "emoji-kiss-fill", + "emoji-kiss", + "envelope-heart-fill", + "envelope-heart", + "envelope-open-heart-fill", + "envelope-open-heart", + "envelope-paper-fill", + "envelope-paper-heart-fill", + "envelope-paper-heart", + "envelope-paper", + "filetype-aac", + "filetype-ai", + "filetype-bmp", + "filetype-cs", + "filetype-css", + "filetype-csv", + "filetype-doc", + "filetype-docx", + "filetype-exe", + "filetype-gif", + "filetype-heic", + "filetype-html", + "filetype-java", + "filetype-jpg", + "filetype-js", + "filetype-jsx", + "filetype-key", + "filetype-m4p", + "filetype-md", + "filetype-mdx", + "filetype-mov", + "filetype-mp3", + "filetype-mp4", + "filetype-otf", + "filetype-pdf", + "filetype-php", + "filetype-png", + "filetype-ppt", + "filetype-psd", + "filetype-py", + "filetype-raw", + "filetype-rb", + "filetype-sass", + "filetype-scss", + "filetype-sh", + "filetype-svg", + "filetype-tiff", + "filetype-tsx", + "filetype-ttf", + "filetype-txt", + "filetype-wav", + "filetype-woff", + "filetype-xls", + "filetype-xml", + "filetype-yml", + "heart-arrow", + "heart-pulse-fill", + "heart-pulse", + "heartbreak-fill", + "heartbreak", + "hearts", + "hospital-fill", + "hospital", + "house-heart-fill", + "house-heart", + "incognito", + "magnet-fill", + "magnet", + "person-heart", + "person-hearts", + "phone-flip", + "plugin", + "postage-fill", + "postage-heart-fill", + "postage-heart", + "postage", + "postcard-fill", + "postcard-heart-fill", + "postcard-heart", + "postcard", + "search-heart-fill", + "search-heart", + "sliders2-vertical", + "sliders2", + "trash3-fill", + "trash3", + "valentine", + "valentine2", + "wrench-adjustable-circle-fill", + "wrench-adjustable-circle", + "wrench-adjustable", + "filetype-json", + "filetype-pptx", + "filetype-xlsx", + "1-circle-fill", + "1-circle", + "1-square-fill", + "1-square", + "2-circle-fill", + "2-circle", + "2-square-fill", + "2-square", + "3-circle-fill", + "3-circle", + "3-square-fill", + "3-square", + "4-circle-fill", + "4-circle", + "4-square-fill", + "4-square", + "5-circle-fill", + "5-circle", + "5-square-fill", + "5-square", + "6-circle-fill", + "6-circle", + "6-square-fill", + "6-square", + "7-circle-fill", + "7-circle", + "7-square-fill", + "7-square", + "8-circle-fill", + "8-circle", + "8-square-fill", + "8-square", + "9-circle-fill", + "9-circle", + "9-square-fill", + "9-square", + "airplane-engines-fill", + "airplane-engines", + "airplane-fill", + "airplane", + "alexa", + "alipay", + "android", + "android2", + "box-fill", + "box-seam-fill", + "browser-chrome", + "browser-edge", + "browser-firefox", + "browser-safari", + "c-circle-fill", + "c-circle", + "c-square-fill", + "c-square", + "capsule-pill", + "capsule", + "car-front-fill", + "car-front", + "cassette-fill", + "cassette", + "cc-circle-fill", + "cc-circle", + "cc-square-fill", + "cc-square", + "cup-hot-fill", + "cup-hot", + "currency-rupee", + "dropbox", + "escape", + "fast-forward-btn-fill", + "fast-forward-btn", + "fast-forward-circle-fill", + "fast-forward-circle", + "fast-forward-fill", + "fast-forward", + "filetype-sql", + "fire", + "google-play", + "h-circle-fill", + "h-circle", + "h-square-fill", + "h-square", + "indent", + "lungs-fill", + "lungs", + "microsoft-teams", + "p-circle-fill", + "p-circle", + "p-square-fill", + "p-square", + "pass-fill", + "pass", + "prescription", + "prescription2", + "r-circle-fill", + "r-circle", + "r-square-fill", + "r-square", + "repeat-1", + "repeat", + "rewind-btn-fill", + "rewind-btn", + "rewind-circle-fill", + "rewind-circle", + "rewind-fill", + "rewind", + "train-freight-front-fill", + "train-freight-front", + "train-front-fill", + "train-front", + "train-lightrail-front-fill", + "train-lightrail-front", + "truck-front-fill", + "truck-front", + "ubuntu", + "unindent", + "unity", + "universal-access-circle", + "universal-access", + "virus", + "virus2", + "wechat", + "yelp", + "sign-stop-fill", + "sign-stop-lights-fill", + "sign-stop-lights", + "sign-stop", + "sign-turn-left-fill", + "sign-turn-left", + "sign-turn-right-fill", + "sign-turn-right", + "sign-turn-slight-left-fill", + "sign-turn-slight-left", + "sign-turn-slight-right-fill", + "sign-turn-slight-right", + "sign-yield-fill", + "sign-yield", + "ev-station-fill", + "ev-station", + "fuel-pump-diesel-fill", + "fuel-pump-diesel", + "fuel-pump-fill", + "fuel-pump", + "0-circle-fill", + "0-circle", + "0-square-fill", + "0-square", + "rocket-fill", + "rocket-takeoff-fill", + "rocket-takeoff", + "rocket", + "stripe", + "subscript", + "superscript", + "trello", + "envelope-at-fill", + "envelope-at", + "regex", + "text-wrap", + "sign-dead-end-fill", + "sign-dead-end", + "sign-do-not-enter-fill", + "sign-do-not-enter", + "sign-intersection-fill", + "sign-intersection-side-fill", + "sign-intersection-side", + "sign-intersection-t-fill", + "sign-intersection-t", + "sign-intersection-y-fill", + "sign-intersection-y", + "sign-intersection", + "sign-merge-left-fill", + "sign-merge-left", + "sign-merge-right-fill", + "sign-merge-right", + "sign-no-left-turn-fill", + "sign-no-left-turn", + "sign-no-parking-fill", + "sign-no-parking", + "sign-no-right-turn-fill", + "sign-no-right-turn", + "sign-railroad-fill", + "sign-railroad", + "building-add", + "building-check", + "building-dash", + "building-down", + "building-exclamation", + "building-fill-add", + "building-fill-check", + "building-fill-dash", + "building-fill-down", + "building-fill-exclamation", + "building-fill-gear", + "building-fill-lock", + "building-fill-slash", + "building-fill-up", + "building-fill-x", + "building-fill", + "building-gear", + "building-lock", + "building-slash", + "building-up", + "building-x", + "buildings-fill", + "buildings", + "bus-front-fill", + "bus-front", + "ev-front-fill", + "ev-front", + "globe-americas", + "globe-asia-australia", + "globe-central-south-asia", + "globe-europe-africa", + "house-add-fill", + "house-add", + "house-check-fill", + "house-check", + "house-dash-fill", + "house-dash", + "house-down-fill", + "house-down", + "house-exclamation-fill", + "house-exclamation", + "house-gear-fill", + "house-gear", + "house-lock-fill", + "house-lock", + "house-slash-fill", + "house-slash", + "house-up-fill", + "house-up", + "house-x-fill", + "house-x", + "person-add", + "person-down", + "person-exclamation", + "person-fill-add", + "person-fill-check", + "person-fill-dash", + "person-fill-down", + "person-fill-exclamation", + "person-fill-gear", + "person-fill-lock", + "person-fill-slash", + "person-fill-up", + "person-fill-x", + "person-gear", + "person-lock", + "person-slash", + "person-up", + "scooter", + "taxi-front-fill", + "taxi-front", + "amd", + "database-add", + "database-check", + "database-dash", + "database-down", + "database-exclamation", + "database-fill-add", + "database-fill-check", + "database-fill-dash", + "database-fill-down", + "database-fill-exclamation", + "database-fill-gear", + "database-fill-lock", + "database-fill-slash", + "database-fill-up", + "database-fill-x", + "database-fill", + "database-gear", + "database-lock", + "database-slash", + "database-up", + "database-x", + "database", + "houses-fill", + "houses", + "nvidia", + "person-vcard-fill", + "person-vcard", + "sina-weibo", + "tencent-qq", + "wikipedia", +} + +// IsValid returns true if the input is the name of a Bootstrap icon. +func IsValid(name string) bool { + for i := range icons { + if icons[i] == name { + return true + } + } + return false +} diff --git a/backend/log/log.go b/backend/log/log.go new file mode 100644 index 0000000..5727ace --- /dev/null +++ b/backend/log/log.go @@ -0,0 +1,69 @@ +// Package log contains a global Zap logger. +package log + +import ( + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +var Logger *zap.Logger +var SugaredLogger *zap.SugaredLogger + +func init() { + zcfg := zap.NewProductionConfig() + + zcfg.Level.SetLevel(zap.DebugLevel) + zcfg.Encoding = "console" + zcfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + zcfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder + + logger, err := zcfg.Build() + if err != nil { + panic(err) + } + + zap.RedirectStdLog(logger) + + Logger = logger + SugaredLogger = Logger.WithOptions(zap.AddCallerSkip(1)).Sugar() +} + +func Debug(v ...any) { + SugaredLogger.Debug(v...) +} + +func Info(v ...any) { + SugaredLogger.Info(v...) +} + +func Warn(v ...any) { + SugaredLogger.Warn(v...) +} + +func Error(v ...any) { + SugaredLogger.Error(v...) +} + +func Fatal(v ...any) { + SugaredLogger.Fatal(v...) +} + +func Debugf(tmpl string, v ...any) { + SugaredLogger.Debugf(tmpl, v...) +} + +func Infof(tmpl string, v ...any) { + SugaredLogger.Infof(tmpl, v...) +} + +func Warnf(tmpl string, v ...any) { + SugaredLogger.Warnf(tmpl, v...) +} + +func Errorf(tmpl string, v ...any) { + SugaredLogger.Errorf(tmpl, v...) +} + +func Fatalf(tmpl string, v ...any) { + SugaredLogger.Fatalf(tmpl, v...) +} diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..7c9226a --- /dev/null +++ b/backend/main.go @@ -0,0 +1,94 @@ +package backend + +import ( + "context" + "io" + "net/http" + "os" + "os/signal" + + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + + "github.com/davidbyttow/govips/v2/vips" + "github.com/go-chi/render" + _ "github.com/joho/godotenv/autoload" + "github.com/urfave/cli/v2" +) + +var Command = &cli.Command{ + Name: "web", + Usage: "Run the API server", + Action: run, +} + +func run(c *cli.Context) error { + // set vips log level to WARN, else it will spam logs on info level + vips.LoggingSettings(nil, vips.LogLevelWarning) + + vips.Startup(nil) + defer vips.Shutdown() + + port := ":" + os.Getenv("PORT") + + s, err := server.New() + if err != nil { + log.Fatalf("Error creating server: %v", err) + return nil + } + + // set render.Decode to a custom one that checks content length + render.Decode = decode + + // mount api routes + mountRoutes(s) + + e := make(chan error) + + // run server in another goroutine (for gracefully shutting down, see below) + go func() { + e <- http.ListenAndServe(port, s.Router) + }() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + log.Infof("API server running at %v!", port) + + select { + case <-ctx.Done(): + log.Info("Interrupt signal received, shutting down...") + s.DB.Close() + return nil + case err := <-e: + log.Fatalf("Error running server: %v", err) + } + + return nil +} + +const MaxContentLength = 2 * 1024 * 1024 + +// decode is a custom render.Decode function that makes sure the request doesn't exceed 2 megabytes. +func decode(r *http.Request, v any) error { + if r.ContentLength > MaxContentLength { + return server.APIError{ + Code: server.ErrRequestTooBig, + } + } + + // this is copied from render.Decode to replace r.Body with an io.LimitedReader + var err error + lr := io.LimitReader(r.Body, MaxContentLength) + + switch render.GetRequestContentType(r) { + case render.ContentTypeJSON: + err = render.DecodeJSON(lr, v) + default: + err = server.APIError{ + Code: server.ErrBadRequest, + } + } + + return err +} diff --git a/backend/openapi.html b/backend/openapi.html new file mode 100644 index 0000000..bb77d6f --- /dev/null +++ b/backend/openapi.html @@ -0,0 +1,480 @@ + + + + + + pronouns.cc + + + + + + + + + +

pronouns.cc (1.0.0)

Download OpenAPI specification:Download

License: GNU AGPLv3

The pronouns.cc REST API

+

Get a user

Get a user object. Accepts either ID or username.

+
path Parameters
userRef
required
string

A user reference, either an ID or a username. +IDs are always prioritized, if a user's username is the same as another user's ID, the user with that ID is returned.

+

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "member_title": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "members": [
    ],
  • "fields": [
    ],
  • "custom_preferences": {
    }
}

Get your own user

Get the user object associated with the provided token.

+
Authorizations:
TokenAuth

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "member_title": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "members": [
    ],
  • "fields": [
    ],
  • "custom_preferences": {
    },
  • "max_invites": 0,
  • "is_admin": true,
  • "list_private": true,
  • "discord": "string",
  • "discord_username": "string",
  • "tumblr": "string",
  • "tumblr_username": "string",
  • "google": "string",
  • "google_username": "string",
  • "fediverse": "string",
  • "fediverse_username": "string",
  • "fediverse_instance": "string"
}

Update your own user

Update the current user.

+
Request Body schema: application/json
name
string [ 2 .. 40 ] characters

The user's username, a unique string that identifies them in URLs.

+
display_name
string [ 1 .. 100 ] characters

The user's display name.

+
bio
string [ 1 .. 1000 ] characters

The user's bio/description.

+
member_title
string

Optional text used for the "Members" heading on the user's profile page.

+
avatar
string

A hash of the user's avatar, if set.

+

When editing, a base64-encoded PNG, JPEG, GIF, or WebP image file.

+
links
Array of strings

Links the user has added to their profile.

+
Array of objects (Root Type for FieldEntry)

The user's preferred names.

+
Array of objects (Root Type for FieldEntry)

The user's preferred pronouns.

+
Array of objects (Field)
object (CustomPreferences)

A user's custom preferences.

+
list_private
boolean

Whether your member list is private.

+

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "member_title": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "fields": [
    ],
  • "custom_preferences": {
    },
  • "list_private": true
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "member_title": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "members": [
    ],
  • "fields": [
    ],
  • "custom_preferences": {
    },
  • "max_invites": 0,
  • "is_admin": true,
  • "list_private": true,
  • "discord": "string",
  • "discord_username": "string",
  • "tumblr": "string",
  • "tumblr_username": "string",
  • "google": "string",
  • "google_username": "string",
  • "fediverse": "string",
  • "fediverse_username": "string",
  • "fediverse_instance": "string"
}

Get a user's member list

path Parameters
userRef
required
string

A user ID, username, or @me for yourself.

+

Responses

Response samples

Content type
application/json
[
  • {
    }
]

Create a member

Authorizations:
TokenAuth
Request Body schema: application/json
name
string

The member's unique (per-user) name, used to identify them in URLs. Case insensitive.

+
display_name
string

The member's display name.

+
bio
string

The member's bio/description.

+
avatar
string

A hash of the member's avatar, if set.

+

When editing, a base64-encoded PNG, JPEG, GIF, or WebP image file.

+
links
Array of strings

The member's profile links.

+
Array of objects (Root Type for FieldEntry)

The member's preferred names.

+
Array of objects (PronounEntry)

The member's preferred pronouns.

+
Array of objects (Field)

The member's custom label fields.

+
object (Root Type for PartialUser)

A partial user object as returned from a member endpoint.

+

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "fields": [
    ],
  • "user": {
    }
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "fields": [
    ],
  • "user": {
    }
}

Get a member by ID

path Parameters
memberRef
required
string

The member's unique ID.

+

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "fields": [
    ],
  • "user": {
    }
}

Delete a member

Authorizations:
TokenAuth
path Parameters
memberRef
required
string

The member's unique ID.

+

Responses

Response samples

Content type
application/json
{
  • "code": 2001,
  • "message": "User not found"
}

Update a member

Authorizations:
TokenAuth
path Parameters
memberRef
required
string

The member's unique ID.

+
Request Body schema: application/json
name
string

The member's unique (per-user) name, used to identify them in URLs. Case insensitive.

+
display_name
string

The member's display name.

+
bio
string

The member's bio/description.

+
avatar
string

A hash of the member's avatar, if set.

+

When editing, a base64-encoded PNG, JPEG, GIF, or WebP image file.

+
links
Array of strings

The member's profile links.

+
Array of objects (Root Type for FieldEntry)

The member's preferred names.

+
Array of objects (PronounEntry)

The member's preferred pronouns.

+
Array of objects (Field)

The member's custom label fields.

+
object (Root Type for PartialUser)

A partial user object as returned from a member endpoint.

+

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "fields": [
    ],
  • "user": {
    }
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "fields": [
    ],
  • "user": {
    }
}

Get a member by ID or name

path Parameters
userRef
required
string

A user ID, username, or @me for yourself.

+
memberRef
required
string

A member ID or name.

+

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "display_name": "string",
  • "bio": "string",
  • "avatar": "string",
  • "links": [
    ],
  • "names": [
    ],
  • "pronouns": [
    ],
  • "fields": [
    ],
  • "user": {
    }
}

Get meta info

Responses

Response samples

Content type
application/json
{}
+ + + + diff --git a/backend/prns/main.go b/backend/prns/main.go new file mode 100644 index 0000000..07413ef --- /dev/null +++ b/backend/prns/main.go @@ -0,0 +1,99 @@ +package prns + +import ( + "context" + "net/http" + "os" + "os/signal" + "strings" + + dbpkg "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "github.com/urfave/cli/v2" +) + +var Command = &cli.Command{ + Name: "shortener", + Usage: "URL shortener service", + Action: run, +} + +func run(c *cli.Context) error { + port := ":" + os.Getenv("PRNS_PORT") + baseURL := os.Getenv("BASE_URL") + + db, err := dbpkg.New() + if err != nil { + log.Fatalf("creating database: %v", err) + return err + } + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + defer func() { + if r := recover(); r != nil { + log.Errorf("recovered from panic: %v", err) + } + }() + + id := strings.TrimPrefix(r.URL.Path, "/") + if len(id) == 5 { + u, err := db.UserBySID(r.Context(), id) + if err != nil { + if err != dbpkg.ErrUserNotFound { + log.Errorf("getting user: %v", err) + } + + http.Redirect(w, r, baseURL, http.StatusTemporaryRedirect) + return + } + http.Redirect(w, r, baseURL+"/@"+u.Username, http.StatusTemporaryRedirect) + return + } + + if len(id) == 6 { + m, err := db.MemberBySID(r.Context(), id) + if err != nil { + if err != dbpkg.ErrMemberNotFound { + log.Errorf("getting member: %v", err) + } + + http.Redirect(w, r, baseURL, http.StatusTemporaryRedirect) + return + } + + u, err := db.User(r.Context(), m.UserID) + if err != nil { + log.Errorf("getting user for member %v: %v", m.ID, err) + + http.Redirect(w, r, baseURL, http.StatusTemporaryRedirect) + return + } + + http.Redirect(w, r, baseURL+"/@"+u.Username+"/"+m.Name, http.StatusTemporaryRedirect) + return + } + + http.Redirect(w, r, baseURL, http.StatusTemporaryRedirect) + }) + + e := make(chan error) + go func() { + e <- http.ListenAndServe(port, nil) + }() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + log.Infof("API server running at %v!", port) + + select { + case <-ctx.Done(): + log.Info("Interrupt signal received, shutting down...") + db.Close() + return nil + case err := <-e: + log.Fatalf("Error running server: %v", err) + } + + return nil +} diff --git a/backend/routes.go b/backend/routes.go new file mode 100644 index 0000000..0f9c90c --- /dev/null +++ b/backend/routes.go @@ -0,0 +1,39 @@ +package backend + +import ( + "net/http" + + "codeberg.org/pronounscc/pronouns.cc/backend/routes/auth" + "codeberg.org/pronounscc/pronouns.cc/backend/routes/bot" + "codeberg.org/pronounscc/pronouns.cc/backend/routes/member" + "codeberg.org/pronounscc/pronouns.cc/backend/routes/meta" + "codeberg.org/pronounscc/pronouns.cc/backend/routes/mod" + "codeberg.org/pronounscc/pronouns.cc/backend/routes/user" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + + _ "embed" +) + +//go:embed openapi.html +var openapi string + +// mountRoutes mounts all API routes on the server's router. +// they are all mounted under /v1/ +func mountRoutes(s *server.Server) { + // future-proofing for API versions + s.Router.Route("/v1", func(r chi.Router) { + auth.Mount(s, r) + user.Mount(s, r) + member.Mount(s, r) + bot.Mount(s, r) + meta.Mount(s, r) + mod.Mount(s, r) + }) + + // API docs + s.Router.Get("/", func(w http.ResponseWriter, r *http.Request) { + render.HTML(w, r, openapi) + }) +} diff --git a/backend/routes/auth/captcha.go b/backend/routes/auth/captcha.go new file mode 100644 index 0000000..a9af4bf --- /dev/null +++ b/backend/routes/auth/captcha.go @@ -0,0 +1,57 @@ +package auth + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" +) + +const hcaptchaURL = "https://hcaptcha.com/siteverify" + +type hcaptchaResponse struct { + Success bool `json:"success"` +} + +// verifyCaptcha verifies a captcha response. +func (s *Server) verifyCaptcha(ctx context.Context, response string) (ok bool, err error) { + vals := url.Values{ + "response": []string{response}, + "secret": []string{s.hcaptchaSecret}, + "sitekey": []string{s.hcaptchaSitekey}, + } + + req, err := http.NewRequestWithContext(ctx, "POST", hcaptchaURL, strings.NewReader(vals.Encode())) + if err != nil { + return false, errors.Wrap(err, "creating request") + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false, errors.Wrap(err, "sending request") + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + return false, errors.Sentinel("error status code") + } + b, err := io.ReadAll(resp.Body) + if err != nil { + return false, errors.Wrap(err, "reading body") + } + + var hr hcaptchaResponse + err = json.Unmarshal(b, &hr) + if err != nil { + return false, errors.Wrap(err, "unmarshaling json") + } + + return hr.Success, nil +} diff --git a/backend/routes/auth/discord.go b/backend/routes/auth/discord.go new file mode 100644 index 0000000..f22518c --- /dev/null +++ b/backend/routes/auth/discord.go @@ -0,0 +1,389 @@ +package auth + +import ( + "net/http" + "os" + "time" + + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/bwmarrin/discordgo" + "github.com/go-chi/render" + "github.com/mediocregopher/radix/v4" + "github.com/rs/xid" + "golang.org/x/oauth2" +) + +var discordOAuthConfig = oauth2.Config{ + ClientID: os.Getenv("DISCORD_CLIENT_ID"), + ClientSecret: os.Getenv("DISCORD_CLIENT_SECRET"), + Endpoint: oauth2.Endpoint{ + AuthURL: "https://discord.com/api/oauth2/authorize", + TokenURL: "https://discord.com/api/oauth2/token", + AuthStyle: oauth2.AuthStyleInParams, + }, + Scopes: []string{"identify"}, +} + +type oauthCallbackRequest struct { + CallbackDomain string `json:"callback_domain"` + Code string `json:"code"` + State string `json:"state"` +} + +type discordCallbackResponse struct { + HasAccount bool `json:"has_account"` // if true, Token and User will be set. if false, Ticket and Discord will be set + + Token string `json:"token,omitempty"` + User *userResponse `json:"user,omitempty"` + + Discord string `json:"discord,omitempty"` // username, for UI purposes + Ticket string `json:"ticket,omitempty"` + RequireInvite bool `json:"require_invite"` // require an invite for signing up + RequireCaptcha bool `json:"require_captcha"` + + IsDeleted bool `json:"is_deleted"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + SelfDelete *bool `json:"self_delete,omitempty"` + DeleteReason *string `json:"delete_reason,omitempty"` +} + +func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + decoded, err := Decode[oauthCallbackRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + // if the state can't be validated, return + if valid, err := s.validateCSRFState(ctx, decoded.State); !valid { + if err != nil { + return err + } + + return server.APIError{Code: server.ErrInvalidState} + } + + cfg := discordOAuthConfig + cfg.RedirectURL = decoded.CallbackDomain + "/auth/login/discord" + token, err := cfg.Exchange(r.Context(), decoded.Code) + if err != nil { + log.Errorf("exchanging oauth code: %v", err) + + return server.APIError{Code: server.ErrInvalidOAuthCode} + } + + dg, _ := discordgo.New(token.Type() + " " + token.AccessToken) + du, err := dg.User("@me") + if err != nil { + return err + } + + u, err := s.DB.DiscordUser(ctx, du.ID) + if err == nil { + if u.DeletedAt != nil { + // store cancel delete token + token := undeleteToken() + err = s.saveUndeleteToken(ctx, u.ID, token) + if err != nil { + log.Errorf("saving undelete token: %v", err) + return err + } + + render.JSON(w, r, discordCallbackResponse{ + HasAccount: true, + Token: token, + User: dbUserToUserResponse(u, []db.Field{}), + IsDeleted: true, + DeletedAt: u.DeletedAt, + SelfDelete: u.SelfDelete, + DeleteReason: u.DeleteReason, + }) + return nil + } + + err = u.UpdateFromDiscord(ctx, s.DB, du) + if err != nil { + log.Errorf("updating user %v with Discord info: %v", u.ID, err) + } + + // TODO: implement user + token permissions + tokenID := xid.New() + token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) + if err != nil { + return err + } + + // save token to database + _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) + if err != nil { + return errors.Wrap(err, "saving token to database") + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "querying fields") + } + + render.JSON(w, r, discordCallbackResponse{ + HasAccount: true, + Token: token, + User: dbUserToUserResponse(u, fields), + }) + + return nil + + } else if err != db.ErrUserNotFound { // internal error + return err + } + + // no user found, so save a ticket + save their Discord info in Redis + ticket := RandBase64(32) + err = s.DB.SetJSON(ctx, "discord:"+ticket, du, "EX", "600") + if err != nil { + log.Errorf("setting Discord user for ticket %q: %v", ticket, err) + return err + } + + render.JSON(w, r, discordCallbackResponse{ + HasAccount: false, + Discord: du.String(), + Ticket: ticket, + RequireInvite: s.RequireInvite, + RequireCaptcha: s.hcaptchaSecret != "", + }) + + return nil +} + +type linkRequest struct { + Ticket string `json:"ticket"` +} + +func (s *Server) discordLink(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + claims, _ := server.ClaimsFromContext(ctx) + + // only site tokens can be used for this endpoint + if claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + req, err := Decode[linkRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting user") + } + + if u.Discord != nil { + return server.APIError{Code: server.ErrAlreadyLinked} + } + + du := new(discordgo.User) + err = s.DB.GetJSON(ctx, "discord:"+req.Ticket, &du) + if err != nil { + log.Errorf("getting discord user for ticket: %v", err) + + return server.APIError{Code: server.ErrInvalidTicket} + } + + if du.ID == "" { + log.Errorf("linking user with id %v: discord user ID was empty", claims.UserID) + return server.APIError{Code: server.ErrInternalServerError, Details: "Discord user ID is empty"} + } + + err = u.UpdateFromDiscord(ctx, s.DB, du) + if err != nil { + return errors.Wrap(err, "updating user from discord") + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "getting user fields") + } + + render.JSON(w, r, dbUserToUserResponse(u, fields)) + return nil +} + +func (s *Server) discordUnlink(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + claims, _ := server.ClaimsFromContext(ctx) + + // only site tokens can be used for this endpoint + if claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting user") + } + + if u.Discord == nil { + return server.APIError{Code: server.ErrNotLinked} + } + + // cannot unlink last auth provider + if u.NumProviders() <= 1 { + return server.APIError{Code: server.ErrLastProvider} + } + + err = u.UnlinkDiscord(ctx, s.DB) + if err != nil { + return errors.Wrap(err, "updating user in db") + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "getting user fields") + } + + render.JSON(w, r, dbUserToUserResponse(u, fields)) + return nil +} + +type signupRequest struct { + Ticket string `json:"ticket"` + Username string `json:"username"` + InviteCode string `json:"invite_code"` + CaptchaResponse string `json:"captcha_response"` +} + +type signupResponse struct { + User userResponse `json:"user"` + Token string `json:"token"` +} + +func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + req, err := Decode[signupRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + if s.RequireInvite && req.InviteCode == "" { + return server.APIError{Code: server.ErrInviteRequired} + } + + valid, taken, err := s.DB.UsernameTaken(ctx, req.Username) + if err != nil { + return err + } + if !valid { + return server.APIError{Code: server.ErrInvalidUsername} + } + if taken { + return server.APIError{Code: server.ErrUsernameTaken} + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return errors.Wrap(err, "beginning transaction") + } + defer tx.Rollback(ctx) + + du := new(discordgo.User) + err = s.DB.GetJSON(ctx, "discord:"+req.Ticket, &du) + if err != nil { + log.Errorf("getting discord user for ticket: %v", err) + + return server.APIError{Code: server.ErrInvalidTicket} + } + + // check captcha + if s.hcaptchaSecret != "" { + ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse) + if err != nil { + log.Errorf("verifying captcha: %v", err) + return server.APIError{Code: server.ErrInternalServerError} + } + + if !ok { + return server.APIError{Code: server.ErrInvalidCaptcha} + } + } + + u, err := s.DB.CreateUser(ctx, tx, req.Username) + if err != nil { + if errors.Cause(err) == db.ErrUsernameTaken { + return server.APIError{Code: server.ErrUsernameTaken} + } + + return errors.Wrap(err, "creating user") + } + + if du.ID == "" { + log.Errorf("creating user with name %q: user ID was empty", req.Username) + return server.APIError{Code: server.ErrInternalServerError, Details: "Discord user ID is empty"} + } + + err = u.UpdateFromDiscord(ctx, tx, du) + if err != nil { + return errors.Wrap(err, "updating user from discord") + } + + if s.RequireInvite { + valid, used, err := s.DB.InvalidateInvite(ctx, tx, req.InviteCode) + if err != nil { + return errors.Wrap(err, "checking and invalidating invite") + } + + if !valid { + return server.APIError{Code: server.ErrInviteRequired} + } + + if used { + return server.APIError{Code: server.ErrInviteAlreadyUsed} + } + } + + // delete sign up ticket + err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "discord:"+req.Ticket)) + if err != nil { + return errors.Wrap(err, "deleting signup ticket") + } + + // commit transaction + err = tx.Commit(ctx) + if err != nil { + return errors.Wrap(err, "committing transaction") + } + + // create token + // TODO: implement user + token permissions + tokenID := xid.New() + token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true) + if err != nil { + return errors.Wrap(err, "creating token") + } + + // save token to database + _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) + if err != nil { + return errors.Wrap(err, "saving token to database") + } + + // return user + render.JSON(w, r, signupResponse{ + User: *dbUserToUserResponse(u, nil), + Token: token, + }) + return nil +} + +func Decode[T any](r *http.Request) (T, error) { + decoded := *new(T) + + return decoded, render.Decode(r, &decoded) +} diff --git a/backend/routes/auth/fedi_mastodon.go b/backend/routes/auth/fedi_mastodon.go new file mode 100644 index 0000000..1cae10b --- /dev/null +++ b/backend/routes/auth/fedi_mastodon.go @@ -0,0 +1,411 @@ +package auth + +import ( + "encoding/json" + "io" + "net/http" + "time" + + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/render" + "github.com/mediocregopher/radix/v4" + "github.com/rs/xid" +) + +type fediOauthCallbackRequest struct { + Instance string `json:"instance"` + Code string `json:"code"` + State string `json:"state"` +} + +type fediCallbackResponse struct { + HasAccount bool `json:"has_account"` // if true, Token and User will be set. if false, Ticket and Fediverse will be set + + Token string `json:"token,omitempty"` + User *userResponse `json:"user,omitempty"` + + Fediverse string `json:"fediverse,omitempty"` // username, for UI purposes + Ticket string `json:"ticket,omitempty"` + RequireInvite bool `json:"require_invite"` // require an invite for signing up + RequireCaptcha bool `json:"require_captcha"` + + IsDeleted bool `json:"is_deleted"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + SelfDelete *bool `json:"self_delete,omitempty"` + DeleteReason *string `json:"delete_reason,omitempty"` +} + +type partialMastodonAccount struct { + ID string `json:"id"` + Username string `json:"username"` +} + +func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + decoded, err := Decode[fediOauthCallbackRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + // if the state can't be validated, return + if valid, err := s.validateCSRFState(ctx, decoded.State); !valid { + if err != nil { + return err + } + + return server.APIError{Code: server.ErrInvalidState} + } + + app, err := s.DB.FediverseApp(ctx, decoded.Instance) + if err != nil { + log.Errorf("getting app for instance %q: %v", decoded.Instance, err) + + if err == db.ErrNoInstanceApp { + // can we get here? + return server.APIError{Code: server.ErrNotFound} + } + } + + token, err := app.ClientConfig().Exchange(ctx, decoded.Code) + if err != nil { + log.Errorf("exchanging oauth code: %v", err) + + return server.APIError{Code: server.ErrInvalidOAuthCode} + } + + // make me user request + req, err := http.NewRequestWithContext(ctx, "GET", "https://"+decoded.Instance+"/api/v1/accounts/verify_credentials", nil) + if err != nil { + return errors.Wrap(err, "creating verify_credentials request") + } + req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag) + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", token.Type()+" "+token.AccessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return errors.Wrap(err, "sending verify_credentials request") + } + defer resp.Body.Close() + + jb, err := io.ReadAll(resp.Body) + if err != nil { + return errors.Wrap(err, "reading verify_credentials response") + } + + var mu partialMastodonAccount + err = json.Unmarshal(jb, &mu) + if err != nil { + return errors.Wrap(err, "unmarshaling verify_credentials response") + } + + u, err := s.DB.FediverseUser(ctx, mu.ID, app.ID) + if err == nil { + if u.DeletedAt != nil { + // store cancel delete token + token := undeleteToken() + err = s.saveUndeleteToken(ctx, u.ID, token) + if err != nil { + log.Errorf("saving undelete token: %v", err) + return err + } + + render.JSON(w, r, fediCallbackResponse{ + HasAccount: true, + Token: token, + User: dbUserToUserResponse(u, []db.Field{}), + IsDeleted: true, + DeletedAt: u.DeletedAt, + SelfDelete: u.SelfDelete, + DeleteReason: u.DeleteReason, + }) + return nil + } + + err = u.UpdateFromFedi(ctx, s.DB, mu.ID, mu.Username, app.ID) + if err != nil { + log.Errorf("updating user %v with mastoAPI info: %v", u.ID, err) + } + + // TODO: implement user + token permissions + tokenID := xid.New() + token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) + if err != nil { + return err + } + + // save token to database + _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) + if err != nil { + return errors.Wrap(err, "saving token to database") + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "querying fields") + } + + render.JSON(w, r, fediCallbackResponse{ + HasAccount: true, + Token: token, + User: dbUserToUserResponse(u, fields), + }) + + return nil + + } else if err != db.ErrUserNotFound { // internal error + return err + } + + // no user found, so save a ticket + save their Mastodon info in Redis + ticket := RandBase64(32) + err = s.DB.SetJSON(ctx, "mastodon:"+ticket, mu, "EX", "600") + if err != nil { + log.Errorf("setting mastoAPI user for ticket %q: %v", ticket, err) + return err + } + + render.JSON(w, r, fediCallbackResponse{ + HasAccount: false, + Fediverse: mu.Username, + Ticket: ticket, + RequireInvite: s.RequireInvite, + RequireCaptcha: s.hcaptchaSecret != "", + }) + + return nil +} + +type fediLinkRequest struct { + Instance string `json:"instance"` + Ticket string `json:"ticket"` +} + +func (s *Server) mastodonLink(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + claims, _ := server.ClaimsFromContext(ctx) + + // only site tokens can be used for this endpoint + if claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + req, err := Decode[fediLinkRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + app, err := s.DB.FediverseApp(ctx, req.Instance) + if err != nil { + return errors.Wrap(err, "getting instance application") + } + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting user") + } + + if u.Fediverse != nil { + return server.APIError{Code: server.ErrAlreadyLinked} + } + + mu := new(partialMastodonAccount) + err = s.DB.GetJSON(ctx, "mastodon:"+req.Ticket, &mu) + if err != nil { + log.Errorf("getting mastoAPI user for ticket: %v", err) + + return server.APIError{Code: server.ErrInvalidTicket} + } + + if mu.ID == "" { + log.Errorf("linking user with id %v: user ID was empty", claims.UserID) + return server.APIError{Code: server.ErrInternalServerError, Details: "Mastodon user ID is empty"} + } + + err = u.UpdateFromFedi(ctx, s.DB, mu.ID, mu.Username, app.ID) + if err != nil { + return errors.Wrap(err, "updating user from mastoAPI") + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "getting user fields") + } + + render.JSON(w, r, dbUserToUserResponse(u, fields)) + return nil +} + +func (s *Server) mastodonUnlink(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + claims, _ := server.ClaimsFromContext(ctx) + + // only site tokens can be used for this endpoint + if claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting user") + } + + if u.Fediverse == nil { + return server.APIError{Code: server.ErrNotLinked} + } + + // cannot unlink last auth provider + if u.NumProviders() <= 1 { + return server.APIError{Code: server.ErrLastProvider} + } + + err = u.UnlinkFedi(ctx, s.DB) + if err != nil { + return errors.Wrap(err, "updating user in db") + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "getting user fields") + } + + render.JSON(w, r, dbUserToUserResponse(u, fields)) + return nil +} + +type fediSignupRequest struct { + Instance string `json:"instance"` + Ticket string `json:"ticket"` + Username string `json:"username"` + InviteCode string `json:"invite_code"` + CaptchaResponse string `json:"captcha_response"` +} + +func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + req, err := Decode[fediSignupRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + if s.RequireInvite && req.InviteCode == "" { + return server.APIError{Code: server.ErrInviteRequired} + } + + app, err := s.DB.FediverseApp(ctx, req.Instance) + if err != nil { + return errors.Wrap(err, "getting instance application") + } + + valid, taken, err := s.DB.UsernameTaken(ctx, req.Username) + if err != nil { + return err + } + if !valid { + return server.APIError{Code: server.ErrInvalidUsername} + } + if taken { + return server.APIError{Code: server.ErrUsernameTaken} + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return errors.Wrap(err, "beginning transaction") + } + defer tx.Rollback(ctx) + + mu := new(partialMastodonAccount) + err = s.DB.GetJSON(ctx, "mastodon:"+req.Ticket, &mu) + if err != nil { + log.Errorf("getting mastoAPI user for ticket: %v", err) + + return server.APIError{Code: server.ErrInvalidTicket} + } + + // check captcha + if s.hcaptchaSecret != "" { + ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse) + if err != nil { + log.Errorf("verifying captcha: %v", err) + return server.APIError{Code: server.ErrInternalServerError} + } + + if !ok { + return server.APIError{Code: server.ErrInvalidCaptcha} + } + } + + u, err := s.DB.CreateUser(ctx, tx, req.Username) + if err != nil { + if errors.Cause(err) == db.ErrUsernameTaken { + return server.APIError{Code: server.ErrUsernameTaken} + } + + return errors.Wrap(err, "creating user") + } + + if mu.ID == "" { + log.Errorf("creating user with name %q: user ID was empty", req.Username) + return server.APIError{Code: server.ErrInternalServerError, Details: "Mastodon user ID is empty"} + } + + err = u.UpdateFromFedi(ctx, tx, mu.ID, mu.Username, app.ID) + if err != nil { + return errors.Wrap(err, "updating user from mastoAPI") + } + + if s.RequireInvite { + valid, used, err := s.DB.InvalidateInvite(ctx, tx, req.InviteCode) + if err != nil { + return errors.Wrap(err, "checking and invalidating invite") + } + + if !valid { + return server.APIError{Code: server.ErrInviteRequired} + } + + if used { + return server.APIError{Code: server.ErrInviteAlreadyUsed} + } + } + + // delete sign up ticket + err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "mastodon:"+req.Ticket)) + if err != nil { + return errors.Wrap(err, "deleting signup ticket") + } + + // commit transaction + err = tx.Commit(ctx) + if err != nil { + return errors.Wrap(err, "committing transaction") + } + + // create token + // TODO: implement user + token permissions + tokenID := xid.New() + token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true) + if err != nil { + return errors.Wrap(err, "creating token") + } + + // save token to database + _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) + if err != nil { + return errors.Wrap(err, "saving token to database") + } + + // return user + render.JSON(w, r, signupResponse{ + User: *dbUserToUserResponse(u, nil), + Token: token, + }) + return nil +} diff --git a/backend/routes/auth/fedi_misskey.go b/backend/routes/auth/fedi_misskey.go new file mode 100644 index 0000000..864b852 --- /dev/null +++ b/backend/routes/auth/fedi_misskey.go @@ -0,0 +1,456 @@ +package auth + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/render" + "github.com/mediocregopher/radix/v4" + "github.com/rs/xid" +) + +type partialMisskeyAccount struct { + ID string `json:"id"` + Username string `json:"username"` +} + +func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + decoded, err := Decode[fediOauthCallbackRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + app, err := s.DB.FediverseApp(ctx, decoded.Instance) + if err != nil { + log.Errorf("getting app for instance %q: %v", decoded.Instance, err) + + if err == db.ErrNoInstanceApp { + // can we get here? + return server.APIError{Code: server.ErrNotFound} + } + } + + userkeyReq := struct { + AppSecret string `json:"appSecret"` + Token string `json:"token"` + }{AppSecret: app.ClientSecret, Token: decoded.Code} + + b, err := json.Marshal(userkeyReq) + if err != nil { + return errors.Wrap(err, "marshaling json") + } + + // make me user request + req, err := http.NewRequestWithContext(ctx, "POST", "https://"+decoded.Instance+"/api/auth/session/userkey", bytes.NewReader(b)) + if err != nil { + return errors.Wrap(err, "creating userkey request") + } + req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return errors.Wrap(err, "sending i request") + } + defer resp.Body.Close() + + jb, err := io.ReadAll(resp.Body) + if err != nil { + return errors.Wrap(err, "reading i response") + } + + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + log.Errorf("POST userkey for instance %q (type %v): %v", app.Instance, app.InstanceType, string(jb)) + return errors.Wrap(err, "error on misskey's end") + } + + var mu struct { + User partialMisskeyAccount `json:"user"` + } + err = json.Unmarshal(jb, &mu) + if err != nil { + return errors.Wrap(err, "unmarshaling userkey response") + } + + u, err := s.DB.FediverseUser(ctx, mu.User.ID, app.ID) + if err == nil { + if u.DeletedAt != nil { + // store cancel delete token + token := undeleteToken() + err = s.saveUndeleteToken(ctx, u.ID, token) + if err != nil { + log.Errorf("saving undelete token: %v", err) + return err + } + + render.JSON(w, r, fediCallbackResponse{ + HasAccount: true, + Token: token, + User: dbUserToUserResponse(u, []db.Field{}), + IsDeleted: true, + DeletedAt: u.DeletedAt, + SelfDelete: u.SelfDelete, + DeleteReason: u.DeleteReason, + }) + return nil + } + + err = u.UpdateFromFedi(ctx, s.DB, mu.User.ID, mu.User.Username, app.ID) + if err != nil { + log.Errorf("updating user %v with misskey info: %v", u.ID, err) + } + + // TODO: implement user + token permissions + tokenID := xid.New() + token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) + if err != nil { + return err + } + + // save token to database + _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) + if err != nil { + return errors.Wrap(err, "saving token to database") + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "querying fields") + } + + render.JSON(w, r, fediCallbackResponse{ + HasAccount: true, + Token: token, + User: dbUserToUserResponse(u, fields), + }) + + return nil + + } else if err != db.ErrUserNotFound { // internal error + return err + } + + // no user found, so save a ticket + save their Misskey info in Redis + ticket := RandBase64(32) + err = s.DB.SetJSON(ctx, "misskey:"+ticket, mu.User, "EX", "600") + if err != nil { + log.Errorf("setting misskey user for ticket %q: %v", ticket, err) + return err + } + + render.JSON(w, r, fediCallbackResponse{ + HasAccount: false, + Fediverse: mu.User.Username, + Ticket: ticket, + RequireInvite: s.RequireInvite, + RequireCaptcha: s.hcaptchaSecret != "", + }) + + return nil +} + +func (s *Server) misskeyLink(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + claims, _ := server.ClaimsFromContext(ctx) + + // only site tokens can be used for this endpoint + if claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + req, err := Decode[fediLinkRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + app, err := s.DB.FediverseApp(ctx, req.Instance) + if err != nil { + return errors.Wrap(err, "getting instance application") + } + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting user") + } + + if u.Fediverse != nil { + return server.APIError{Code: server.ErrAlreadyLinked} + } + + mu := new(partialMisskeyAccount) + err = s.DB.GetJSON(ctx, "misskey:"+req.Ticket, &mu) + if err != nil { + log.Errorf("getting misskey user for ticket: %v", err) + + return server.APIError{Code: server.ErrInvalidTicket} + } + + if mu.ID == "" { + log.Errorf("linking user with id %v: user ID was empty", claims.UserID) + return server.APIError{Code: server.ErrInternalServerError, Details: "Misskey user ID is empty"} + } + + err = u.UpdateFromFedi(ctx, s.DB, mu.ID, mu.Username, app.ID) + if err != nil { + return errors.Wrap(err, "updating user from misskey") + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "getting user fields") + } + + render.JSON(w, r, dbUserToUserResponse(u, fields)) + return nil +} + +func (s *Server) misskeySignup(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + req, err := Decode[fediSignupRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + if s.RequireInvite && req.InviteCode == "" { + return server.APIError{Code: server.ErrInviteRequired} + } + + app, err := s.DB.FediverseApp(ctx, req.Instance) + if err != nil { + return errors.Wrap(err, "getting instance application") + } + + valid, taken, err := s.DB.UsernameTaken(ctx, req.Username) + if err != nil { + return err + } + if !valid { + return server.APIError{Code: server.ErrInvalidUsername} + } + if taken { + return server.APIError{Code: server.ErrUsernameTaken} + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return errors.Wrap(err, "beginning transaction") + } + defer tx.Rollback(ctx) + + mu := new(partialMisskeyAccount) + err = s.DB.GetJSON(ctx, "misskey:"+req.Ticket, &mu) + if err != nil { + log.Errorf("getting misskey user for ticket: %v", err) + + return server.APIError{Code: server.ErrInvalidTicket} + } + + // check captcha + if s.hcaptchaSecret != "" { + ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse) + if err != nil { + log.Errorf("verifying captcha: %v", err) + return server.APIError{Code: server.ErrInternalServerError} + } + + if !ok { + return server.APIError{Code: server.ErrInvalidCaptcha} + } + } + + u, err := s.DB.CreateUser(ctx, tx, req.Username) + if err != nil { + if errors.Cause(err) == db.ErrUsernameTaken { + return server.APIError{Code: server.ErrUsernameTaken} + } + + return errors.Wrap(err, "creating user") + } + + if mu.ID == "" { + log.Errorf("creating user with name %q: user ID was empty", req.Username) + return server.APIError{Code: server.ErrInternalServerError, Details: "Misskey user ID is empty"} + } + + err = u.UpdateFromFedi(ctx, tx, mu.ID, mu.Username, app.ID) + if err != nil { + return errors.Wrap(err, "updating user from misskey") + } + + if s.RequireInvite { + valid, used, err := s.DB.InvalidateInvite(ctx, tx, req.InviteCode) + if err != nil { + return errors.Wrap(err, "checking and invalidating invite") + } + + if !valid { + return server.APIError{Code: server.ErrInviteRequired} + } + + if used { + return server.APIError{Code: server.ErrInviteAlreadyUsed} + } + } + + // delete sign up ticket + err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "misskey:"+req.Ticket)) + if err != nil { + return errors.Wrap(err, "deleting signup ticket") + } + + // commit transaction + err = tx.Commit(ctx) + if err != nil { + return errors.Wrap(err, "committing transaction") + } + + // create token + // TODO: implement user + token permissions + tokenID := xid.New() + token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true) + if err != nil { + return errors.Wrap(err, "creating token") + } + + // save token to database + _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) + if err != nil { + return errors.Wrap(err, "saving token to database") + } + + // return user + render.JSON(w, r, signupResponse{ + User: *dbUserToUserResponse(u, nil), + Token: token, + }) + return nil +} + +func (s *Server) noAppMisskeyURL(ctx context.Context, w http.ResponseWriter, r *http.Request, softwareName, instance string) error { + log.Debugf("creating application on misskey-compatible instance %q", instance) + + b, err := json.Marshal(misskeyAppRequest{ + Name: "pronouns.cc (+" + s.BaseURL + ")", + Description: "pronouns.cc on " + s.BaseURL, + CallbackURL: s.BaseURL + "/auth/login/misskey/" + instance, + Permission: []string{"read:account"}, + }) + if err != nil { + log.Errorf("marshaling app json: %v", err) + return errors.Wrap(err, "marshaling json") + } + + req, err := http.NewRequestWithContext(ctx, "POST", "https://"+instance+"/api/app/create", bytes.NewReader(b)) + if err != nil { + log.Errorf("creating POST apps request for %q: %v", instance, err) + return errors.Wrap(err, "creating POST apps request") + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Errorf("sending POST apps request for %q: %v", instance, err) + return errors.Wrap(err, "sending POST apps request") + } + defer resp.Body.Close() + + jb, err := io.ReadAll(resp.Body) + if err != nil { + log.Errorf("reading response for request: %v", err) + return errors.Wrap(err, "reading response") + } + + var ma misskeyApp + err = json.Unmarshal(jb, &ma) + if err != nil { + return errors.Wrap(err, "unmarshaling misskey app") + } + + app, err := s.DB.CreateFediverseApp(ctx, instance, softwareName, ma.ID, ma.Secret) + if err != nil { + log.Errorf("saving app for %q: %v", instance, err) + return errors.Wrap(err, "creating app") + } + + _, url, err := s.misskeyURL(ctx, app) + if err != nil { + log.Errorf("generating URL for misskey %q: %v", instance, err) + return errors.Wrap(err, "generating URL") + } + + render.JSON(w, r, FediResponse{ + URL: url, + }) + return nil +} + +type misskeyAppRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Permission []string `json:"permission"` + CallbackURL string `json:"callbackUrl"` +} + +type misskeyApp struct { + ID string `json:"id"` + Secret string `json:"secret"` +} + +func (s *Server) misskeyURL(ctx context.Context, app db.FediverseApp) (token, url string, err error) { + genSession := struct { + AppSecret string `json:"appSecret"` + }{AppSecret: app.ClientSecret} + + b, err := json.Marshal(genSession) + if err != nil { + return token, url, errors.Wrap(err, "marshaling json") + } + + req, err := http.NewRequestWithContext(ctx, "POST", "https://"+app.Instance+"/api/auth/session/generate", bytes.NewReader(b)) + if err != nil { + log.Errorf("creating POST session request for %q: %v", app.Instance, err) + return token, url, errors.Wrap(err, "creating POST apps request") + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Errorf("sending POST session request for %q: %v", app.Instance, err) + return token, url, errors.Wrap(err, "sending POST apps request") + } + defer resp.Body.Close() + + jb, err := io.ReadAll(resp.Body) + if err != nil { + log.Errorf("reading response for request: %v", err) + return token, url, errors.Wrap(err, "reading response") + } + + var genSessionResp struct { + Token string `json:"token"` + URL string `json:"url"` + } + + err = json.Unmarshal(jb, &genSessionResp) + if err != nil { + return token, url, errors.Wrap(err, "unmarshaling misskey response") + } + + return genSessionResp.Token, genSessionResp.URL, nil +} diff --git a/backend/routes/auth/fedi_nodeinfo.go b/backend/routes/auth/fedi_nodeinfo.go new file mode 100644 index 0000000..fc56c78 --- /dev/null +++ b/backend/routes/auth/fedi_nodeinfo.go @@ -0,0 +1,95 @@ +package auth + +import ( + "context" + "encoding/json" + "io" + "net/http" + + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" +) + +const errNoNodeinfoURL = errors.Sentinel("no valid nodeinfo rel found") + +// nodeinfo queries an instance's nodeinfo and returns the software name. +func nodeinfo(ctx context.Context, instance string) (softwareName string, err error) { + req, err := http.NewRequestWithContext(ctx, "GET", "https://"+instance+"/.well-known/nodeinfo", nil) + if err != nil { + return "", errors.Wrap(err, "creating .well-known/nodeinfo request") + } + req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag) + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", errors.Wrap(err, "sending .well-known/nodeinfo request") + } + defer resp.Body.Close() + + jb, err := io.ReadAll(resp.Body) + if err != nil { + return "", errors.Wrap(err, "reading .well-known/nodeinfo response") + } + + var wkr wellKnownResponse + err = json.Unmarshal(jb, &wkr) + if err != nil { + return "", errors.Wrap(err, "unmarshaling .well-known/nodeinfo response") + } + + var nodeinfoURL string + for _, link := range wkr.Links { + if link.Rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" { + nodeinfoURL = link.Href + break + } + } + + if nodeinfoURL == "" { + return "", errNoNodeinfoURL + } + + req, err = http.NewRequestWithContext(ctx, "GET", nodeinfoURL, nil) + if err != nil { + return "", errors.Wrap(err, "creating nodeinfo request") + } + req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag) + req.Header.Set("Accept", "application/json") + + resp, err = http.DefaultClient.Do(req) + if err != nil { + return "", errors.Wrap(err, "sending nodeinfo request") + } + defer resp.Body.Close() + + jb, err = io.ReadAll(resp.Body) + if err != nil { + return "", errors.Wrap(err, "reading nodeinfo response") + } + + var ni partialNodeinfo + err = json.Unmarshal(jb, &ni) + if err != nil { + return "", errors.Wrap(err, "unmarshaling nodeinfo response") + } + + return ni.Software.Name, nil +} + +type wellKnownResponse struct { + Links []wellKnownLink `json:"links"` +} + +type wellKnownLink struct { + Rel string `json:"rel"` + Href string `json:"href"` +} + +type partialNodeinfo struct { + Software nodeinfoSoftware `json:"software"` +} + +type nodeinfoSoftware struct { + Name string `json:"name"` +} diff --git a/backend/routes/auth/fediverse.go b/backend/routes/auth/fediverse.go new file mode 100644 index 0000000..b3f2f62 --- /dev/null +++ b/backend/routes/auth/fediverse.go @@ -0,0 +1,136 @@ +package auth + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/render" +) + +type FediResponse struct { + URL string `json:"url"` +} + +func (s *Server) getFediverseURL(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + instance := r.FormValue("instance") + if instance == "" { + return server.APIError{Code: server.ErrBadRequest, Details: "Instance URL is empty"} + } + + // Too many people tried using @username@fediverse.example despite the warning + if strings.Contains(instance, "@") { + return server.APIError{Code: server.ErrBadRequest, Details: "Instance URL should only be the base URL, without username"} + } + + app, err := s.DB.FediverseApp(ctx, instance) + if err != nil { + return s.noAppFediverseURL(ctx, w, r, instance) + } + + if app.Misskey() { + _, url, err := s.misskeyURL(ctx, app) + if err != nil { + return errors.Wrap(err, "generating misskey URL") + } + + render.JSON(w, r, FediResponse{ + URL: url, + }) + return nil + } + + state, err := s.setCSRFState(r.Context()) + if err != nil { + return errors.Wrap(err, "setting CSRF state") + } + + render.JSON(w, r, FediResponse{ + URL: app.ClientConfig().AuthCodeURL(state), + }) + return nil +} + +func (s *Server) noAppFediverseURL(ctx context.Context, w http.ResponseWriter, r *http.Request, instance string) error { + softwareName, err := nodeinfo(ctx, instance) + if err != nil { + log.Errorf("querying instance %q nodeinfo: %v", instance, err) + } + + switch softwareName { + case "misskey", "foundkey", "calckey": + return s.noAppMisskeyURL(ctx, w, r, softwareName, instance) + case "mastodon", "pleroma", "akkoma", "pixelfed", "gotosocial": + case "glitchcafe": + // plural.cafe (potentially other instances too?) runs Mastodon but changes the software name + // changing it back to mastodon here for consistency + softwareName = "mastodon" + default: + return server.APIError{Code: server.ErrUnsupportedInstance} + } + + log.Debugf("creating application on mastodon-compatible instance %q", instance) + + formData := url.Values{ + "client_name": {"pronouns.cc (+" + s.BaseURL + ")"}, + "redirect_uris": {s.BaseURL + "/auth/login/mastodon/" + instance}, + "scopes": {"read:accounts"}, + "website": {s.BaseURL}, + } + + req, err := http.NewRequestWithContext(ctx, "POST", "https://"+instance+"/api/v1/apps", strings.NewReader(formData.Encode())) + if err != nil { + log.Errorf("creating POST apps request for %q: %v", instance, err) + return errors.Wrap(err, "creating POST apps request") + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Errorf("sending POST apps request for %q: %v", instance, err) + return errors.Wrap(err, "sending POST apps request") + } + defer resp.Body.Close() + + jb, err := io.ReadAll(resp.Body) + if err != nil { + log.Errorf("reading response for request: %v", err) + return errors.Wrap(err, "reading response") + } + + var ma mastodonApplication + err = json.Unmarshal(jb, &ma) + if err != nil { + return errors.Wrap(err, "unmarshaling mastodon app") + } + + app, err := s.DB.CreateFediverseApp(ctx, instance, softwareName, ma.ClientID, ma.ClientSecret) + if err != nil { + log.Errorf("saving app for %q: %v", instance, err) + return errors.Wrap(err, "creating app") + } + + state, err := s.setCSRFState(r.Context()) + if err != nil { + return errors.Wrap(err, "setting CSRF state") + } + + render.JSON(w, r, FediResponse{ + URL: app.ClientConfig().AuthCodeURL(state), + }) + return nil +} + +type mastodonApplication struct { + Name string `json:"name"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` +} diff --git a/backend/routes/auth/google.go b/backend/routes/auth/google.go new file mode 100644 index 0000000..182c8a6 --- /dev/null +++ b/backend/routes/auth/google.go @@ -0,0 +1,386 @@ +package auth + +import ( + "net/http" + "os" + "time" + + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/render" + "github.com/mediocregopher/radix/v4" + "github.com/rs/xid" + "golang.org/x/oauth2" + "google.golang.org/api/idtoken" +) + +var googleOAuthConfig = oauth2.Config{ + ClientID: os.Getenv("GOOGLE_CLIENT_ID"), + ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), + Endpoint: oauth2.Endpoint{ + AuthURL: "https://accounts.google.com/o/oauth2/auth", + TokenURL: "https://oauth2.googleapis.com/token", + AuthStyle: oauth2.AuthStyleInParams, + }, + Scopes: []string{"openid", "https://www.googleapis.com/auth/userinfo.email"}, +} + +type googleCallbackResponse struct { + HasAccount bool `json:"has_account"` // if true, Token and User will be set. if false, Ticket and Google will be set + + Token string `json:"token,omitempty"` + User *userResponse `json:"user,omitempty"` + + Google string `json:"google,omitempty"` // username, for UI purposes + Ticket string `json:"ticket,omitempty"` + RequireInvite bool `json:"require_invite"` // require an invite for signing up + RequireCaptcha bool `json:"require_captcha"` + + IsDeleted bool `json:"is_deleted"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + SelfDelete *bool `json:"self_delete,omitempty"` + DeleteReason *string `json:"delete_reason,omitempty"` +} + +type partialGoogleUser struct { + ID string `json:"id"` + Email string `json:"email"` +} + +func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + decoded, err := Decode[oauthCallbackRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + // if the state can't be validated, return + if valid, err := s.validateCSRFState(ctx, decoded.State); !valid { + if err != nil { + return err + } + + return server.APIError{Code: server.ErrInvalidState} + } + + cfg := googleOAuthConfig + cfg.RedirectURL = decoded.CallbackDomain + "/auth/login/google" + token, err := cfg.Exchange(r.Context(), decoded.Code) + if err != nil { + log.Errorf("exchanging oauth code: %v", err) + return server.APIError{Code: server.ErrInvalidOAuthCode} + } + rawToken := token.Extra("id_token") + if rawToken == nil { + log.Debug("id_token was nil") + return server.APIError{Code: server.ErrInternalServerError} + } + + idToken, ok := rawToken.(string) + if !ok { + log.Debug("id_token was not a string") + return server.APIError{Code: server.ErrInternalServerError} + } + payload, err := idtoken.Validate(ctx, idToken, "") + if err != nil { + log.Errorf("getting id token payload: %v", err) + return server.APIError{Code: server.ErrInternalServerError} + } + + googleID, ok := payload.Claims["sub"].(string) + if !ok { + log.Debug("id_token.claims.sub was not a string") + return server.APIError{Code: server.ErrInternalServerError} + } + googleUsername, ok := payload.Claims["email"].(string) + if !ok { + log.Debug("id_token.claims.email was not a string") + return server.APIError{Code: server.ErrInternalServerError} + } + + u, err := s.DB.GoogleUser(ctx, googleID) + if err == nil { + if u.DeletedAt != nil { + // store cancel delete token + token := undeleteToken() + err = s.saveUndeleteToken(ctx, u.ID, token) + if err != nil { + log.Errorf("saving undelete token: %v", err) + return err + } + + render.JSON(w, r, googleCallbackResponse{ + HasAccount: true, + Token: token, + User: dbUserToUserResponse(u, []db.Field{}), + IsDeleted: true, + DeletedAt: u.DeletedAt, + SelfDelete: u.SelfDelete, + DeleteReason: u.DeleteReason, + }) + return nil + } + + err = u.UpdateFromGoogle(ctx, s.DB, googleID, googleUsername) + if err != nil { + log.Errorf("updating user %v with Google info: %v", u.ID, err) + } + + // TODO: implement user + token permissions + tokenID := xid.New() + token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) + if err != nil { + return err + } + + // save token to database + _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) + if err != nil { + return errors.Wrap(err, "saving token to database") + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "querying fields") + } + + render.JSON(w, r, googleCallbackResponse{ + HasAccount: true, + Token: token, + User: dbUserToUserResponse(u, fields), + }) + + return nil + + } else if err != db.ErrUserNotFound { // internal error + return err + } + + // no user found, so save a ticket + save their Google info in Redis + ticket := RandBase64(32) + err = s.DB.SetJSON(ctx, "google:"+ticket, partialGoogleUser{ID: googleID, Email: googleUsername}, "EX", "600") + if err != nil { + log.Errorf("setting Google user for ticket %q: %v", ticket, err) + return err + } + + render.JSON(w, r, googleCallbackResponse{ + HasAccount: false, + Google: googleUsername, + Ticket: ticket, + RequireInvite: s.RequireInvite, + RequireCaptcha: s.hcaptchaSecret != "", + }) + + return nil +} + +func (s *Server) googleLink(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + claims, _ := server.ClaimsFromContext(ctx) + + // only site tokens can be used for this endpoint + if claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + req, err := Decode[linkRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting user") + } + + if u.Google != nil { + return server.APIError{Code: server.ErrAlreadyLinked} + } + + gu := new(partialGoogleUser) + err = s.DB.GetJSON(ctx, "google:"+req.Ticket, &gu) + if err != nil { + log.Errorf("getting google user for ticket: %v", err) + + return server.APIError{Code: server.ErrInvalidTicket} + } + + if gu.ID == "" { + log.Errorf("linking user with id %v: user ID was empty", claims.UserID) + return server.APIError{Code: server.ErrInternalServerError, Details: "Google user ID is empty"} + } + + err = u.UpdateFromGoogle(ctx, s.DB, gu.ID, gu.Email) + if err != nil { + return errors.Wrap(err, "updating user from google") + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "getting user fields") + } + + render.JSON(w, r, dbUserToUserResponse(u, fields)) + return nil +} + +func (s *Server) googleUnlink(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + claims, _ := server.ClaimsFromContext(ctx) + + // only site tokens can be used for this endpoint + if claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting user") + } + + if u.Google == nil { + return server.APIError{Code: server.ErrNotLinked} + } + + // cannot unlink last auth provider + if u.NumProviders() <= 1 { + return server.APIError{Code: server.ErrLastProvider} + } + + err = u.UnlinkGoogle(ctx, s.DB) + if err != nil { + return errors.Wrap(err, "updating user in db") + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "getting user fields") + } + + render.JSON(w, r, dbUserToUserResponse(u, fields)) + return nil +} + +func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + req, err := Decode[signupRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + if s.RequireInvite && req.InviteCode == "" { + return server.APIError{Code: server.ErrInviteRequired} + } + + valid, taken, err := s.DB.UsernameTaken(ctx, req.Username) + if err != nil { + return err + } + if !valid { + return server.APIError{Code: server.ErrInvalidUsername} + } + if taken { + return server.APIError{Code: server.ErrUsernameTaken} + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return errors.Wrap(err, "beginning transaction") + } + defer tx.Rollback(ctx) + + gu := new(partialGoogleUser) + err = s.DB.GetJSON(ctx, "google:"+req.Ticket, &gu) + if err != nil { + log.Errorf("getting google user for ticket: %v", err) + + return server.APIError{Code: server.ErrInvalidTicket} + } + + // check captcha + if s.hcaptchaSecret != "" { + ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse) + if err != nil { + log.Errorf("verifying captcha: %v", err) + return server.APIError{Code: server.ErrInternalServerError} + } + + if !ok { + return server.APIError{Code: server.ErrInvalidCaptcha} + } + } + + u, err := s.DB.CreateUser(ctx, tx, req.Username) + if err != nil { + if errors.Cause(err) == db.ErrUsernameTaken { + return server.APIError{Code: server.ErrUsernameTaken} + } + + return errors.Wrap(err, "creating user") + } + + if gu.ID == "" { + log.Errorf("creating user with name %q: user ID was empty", req.Username) + return server.APIError{Code: server.ErrInternalServerError, Details: "Google user ID is empty"} + } + + err = u.UpdateFromGoogle(ctx, tx, gu.ID, gu.Email) + if err != nil { + return errors.Wrap(err, "updating user from google") + } + + if s.RequireInvite { + valid, used, err := s.DB.InvalidateInvite(ctx, tx, req.InviteCode) + if err != nil { + return errors.Wrap(err, "checking and invalidating invite") + } + + if !valid { + return server.APIError{Code: server.ErrInviteRequired} + } + + if used { + return server.APIError{Code: server.ErrInviteAlreadyUsed} + } + } + + // delete sign up ticket + err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "google:"+req.Ticket)) + if err != nil { + return errors.Wrap(err, "deleting signup ticket") + } + + // commit transaction + err = tx.Commit(ctx) + if err != nil { + return errors.Wrap(err, "committing transaction") + } + + // create token + // TODO: implement user + token permissions + tokenID := xid.New() + token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true) + if err != nil { + return errors.Wrap(err, "creating token") + } + + // save token to database + _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) + if err != nil { + return errors.Wrap(err, "saving token to database") + } + + // return user + render.JSON(w, r, signupResponse{ + User: *dbUserToUserResponse(u, nil), + Token: token, + }) + return nil +} diff --git a/backend/routes/auth/invite.go b/backend/routes/auth/invite.go new file mode 100644 index 0000000..44e7f0c --- /dev/null +++ b/backend/routes/auth/invite.go @@ -0,0 +1,76 @@ +package auth + +import ( + "net/http" + "time" + + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/render" +) + +type inviteResponse struct { + Code string `json:"code"` + Created time.Time `json:"created"` + Used bool `json:"used"` +} + +func dbInviteToResponse(i db.Invite) inviteResponse { + return inviteResponse{ + Code: i.Code, + Created: i.Created, + Used: i.Used, + } +} + +func (s *Server) getInvites(w http.ResponseWriter, r *http.Request) error { + if !s.RequireInvite { + return server.APIError{Code: server.ErrInvitesDisabled} + } + + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + if claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + is, err := s.DB.UserInvites(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting user invites") + } + + resps := make([]inviteResponse, len(is)) + for i := range is { + resps[i] = dbInviteToResponse(is[i]) + } + + render.JSON(w, r, resps) + return nil +} + +func (s *Server) createInvite(w http.ResponseWriter, r *http.Request) error { + if !s.RequireInvite { + return server.APIError{Code: server.ErrInvitesDisabled} + } + + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + if claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + inv, err := s.DB.CreateInvite(ctx, claims.UserID) + if err != nil { + if err == db.ErrTooManyInvites { + return server.APIError{Code: server.ErrInviteLimitReached} + } + + return errors.Wrap(err, "creating invite") + } + + render.JSON(w, r, dbInviteToResponse(inv)) + return nil +} diff --git a/backend/routes/auth/oauth.go b/backend/routes/auth/oauth.go new file mode 100644 index 0000000..4232cec --- /dev/null +++ b/backend/routes/auth/oauth.go @@ -0,0 +1,45 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/base64" + + "github.com/mediocregopher/radix/v4" +) + +// numStates is the number of CSRF states stored in Redis at any one time. +// This must be an integer. +const numStates = "1000" + +// setCSRFState generates a random string to use as state, then stores that in Redis. +func (s *Server) setCSRFState(ctx context.Context) (string, error) { + state := RandBase64(32) + + err := s.DB.MultiCmd(ctx, + radix.Cmd(nil, "LPUSH", "csrf", state), + radix.Cmd(nil, "LTRIM", "csrf", "0", numStates), + ) + return state, err +} + +// validateCSRFState checks if the given state exists in Redis. +func (s *Server) validateCSRFState(ctx context.Context, state string) (matched bool, err error) { + var num int + err = s.DB.Redis.Do(ctx, radix.Cmd(&num, "LREM", "csrf", "1", state)) + if err != nil { + return + } + return num > 0, nil +} + +// RandBase64 returns a string of random bytes encoded in raw base 64. +func RandBase64(size int) string { + b := make([]byte, size) + _, err := rand.Read(b) + if err != nil { + panic(err) + } + + return base64.RawURLEncoding.EncodeToString(b) +} diff --git a/backend/routes/auth/routes.go b/backend/routes/auth/routes.go new file mode 100644 index 0000000..7699084 --- /dev/null +++ b/backend/routes/auth/routes.go @@ -0,0 +1,216 @@ +package auth + +import ( + "net/http" + "os" + + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/rs/xid" +) + +type Server struct { + *server.Server + + RequireInvite bool + BaseURL string + + hcaptchaSitekey string + hcaptchaSecret string +} + +type userResponse struct { + ID xid.ID `json:"id"` + Username string `json:"name"` + DisplayName *string `json:"display_name"` + Bio *string `json:"bio"` + Avatar *string `json:"avatar"` + Links []string `json:"links"` + Names []db.FieldEntry `json:"names"` + Pronouns []db.PronounEntry `json:"pronouns"` + Fields []db.Field `json:"fields"` + + Discord *string `json:"discord"` + DiscordUsername *string `json:"discord_username"` + + Tumblr *string `json:"tumblr"` + TumblrUsername *string `json:"tumblr_username"` + + Google *string `json:"google"` + GoogleUsername *string `json:"google_username"` + + Fediverse *string `json:"fediverse"` + FediverseUsername *string `json:"fediverse_username"` + FediverseInstance *string `json:"fediverse_instance"` +} + +func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse { + return &userResponse{ + ID: u.ID, + Username: u.Username, + DisplayName: u.DisplayName, + Bio: u.Bio, + Avatar: u.Avatar, + Links: db.NotNull(u.Links), + Names: db.NotNull(u.Names), + Pronouns: db.NotNull(u.Pronouns), + Fields: db.NotNull(fields), + Discord: u.Discord, + DiscordUsername: u.DiscordUsername, + Tumblr: u.Tumblr, + TumblrUsername: u.TumblrUsername, + Google: u.Google, + GoogleUsername: u.GoogleUsername, + Fediverse: u.Fediverse, + FediverseUsername: u.FediverseUsername, + FediverseInstance: u.FediverseInstance, + } +} + +func Mount(srv *server.Server, r chi.Router) { + s := &Server{ + Server: srv, + RequireInvite: os.Getenv("REQUIRE_INVITE") == "true", + BaseURL: os.Getenv("BASE_URL"), + hcaptchaSitekey: os.Getenv("HCAPTCHA_SITEKEY"), + hcaptchaSecret: os.Getenv("HCAPTCHA_SECRET"), + } + + r.Route("/auth", func(r chi.Router) { + // check if username is taken + r.Get("/username", server.WrapHandler(s.usernameTaken)) + + // generate csrf token, returns all supported OAuth provider URLs + r.Post("/urls", server.WrapHandler(s.oauthURLs)) + r.Get("/urls/fediverse", server.WrapHandler(s.getFediverseURL)) + + r.Route("/discord", func(r chi.Router) { + // takes code + state, validates it, returns token OR discord signup ticket + r.Post("/callback", server.WrapHandler(s.discordCallback)) + // takes discord signup ticket to register account + r.Post("/signup", server.WrapHandler(s.discordSignup)) + // takes discord signup ticket to link to existing account + r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(s.discordLink)) + // removes discord link from existing account + r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.discordUnlink)) + }) + + r.Route("/tumblr", func(r chi.Router) { + r.Post("/callback", server.WrapHandler(s.tumblrCallback)) + r.Post("/signup", server.WrapHandler(s.tumblrSignup)) + r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(s.tumblrLink)) + r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.tumblrUnlink)) + }) + + r.Route("/google", func(r chi.Router) { + r.Post("/callback", server.WrapHandler(s.googleCallback)) + r.Post("/signup", server.WrapHandler(s.googleSignup)) + r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(s.googleLink)) + r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.googleUnlink)) + }) + + r.Route("/mastodon", func(r chi.Router) { + r.Post("/callback", server.WrapHandler(s.mastodonCallback)) + r.Post("/signup", server.WrapHandler(s.mastodonSignup)) + r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(s.mastodonLink)) + r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.mastodonUnlink)) + }) + + r.Route("/misskey", func(r chi.Router) { + r.Post("/callback", server.WrapHandler(s.misskeyCallback)) + r.Post("/signup", server.WrapHandler(s.misskeySignup)) + r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(s.misskeyLink)) + }) + + // invite routes + r.With(server.MustAuth).Get("/invites", server.WrapHandler(s.getInvites)) + r.With(server.MustAuth).Post("/invites", server.WrapHandler(s.createInvite)) + + // tokens + r.With(server.MustAuth).Get("/tokens", server.WrapHandler(s.getTokens)) + r.With(server.MustAuth).Post("/tokens", server.WrapHandler(s.createToken)) + r.With(server.MustAuth).Delete("/tokens", server.WrapHandler(s.deleteToken)) + + // cancel user delete + // uses a special token, so handled in the function itself + r.Get("/cancel-delete", server.WrapHandler(s.cancelDelete)) + // force user delete + // uses a special token (same as above) + r.Get("/force-delete", server.WrapHandler(s.forceDelete)) + }) +} + +type oauthURLsRequest struct { + CallbackDomain string `json:"callback_domain"` +} + +type oauthURLsResponse struct { + Discord string `json:"discord,omitempty"` + Tumblr string `json:"tumblr,omitempty"` + Google string `json:"google,omitempty"` +} + +func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error { + req, err := Decode[oauthURLsRequest](r) + if err != nil { + log.Error(err) + + return server.APIError{Code: server.ErrBadRequest} + } + + // generate CSRF state + state, err := s.setCSRFState(r.Context()) + if err != nil { + return errors.Wrap(err, "setting CSRF state") + } + var resp oauthURLsResponse + + if discordOAuthConfig.ClientID != "" { + discordCfg := discordOAuthConfig + discordCfg.RedirectURL = req.CallbackDomain + "/auth/login/discord" + resp.Discord = discordCfg.AuthCodeURL(state) + "&prompt=none" + } + if tumblrOAuthConfig.ClientID != "" { + tumblrCfg := tumblrOAuthConfig + tumblrCfg.RedirectURL = req.CallbackDomain + "/auth/login/tumblr" + resp.Tumblr = tumblrCfg.AuthCodeURL(state) + } + if googleOAuthConfig.ClientID != "" { + googleCfg := googleOAuthConfig + googleCfg.RedirectURL = req.CallbackDomain + "/auth/login/google" + resp.Google = googleCfg.AuthCodeURL(state) + } + + render.JSON(w, r, resp) + return nil +} + +func (s *Server) usernameTaken(w http.ResponseWriter, r *http.Request) error { + type Response struct { + Valid bool `json:"valid"` + Taken bool `json:"taken"` + } + + name := r.FormValue("username") + if name == "" { + render.JSON(w, r, Response{ + Valid: false, + }) + return nil + } + + valid, taken, err := s.DB.UsernameTaken(r.Context(), name) + if err != nil { + return err + } + + render.JSON(w, r, Response{ + Valid: valid, + Taken: taken, + }) + return nil +} diff --git a/backend/routes/auth/tokens.go b/backend/routes/auth/tokens.go new file mode 100644 index 0000000..e48662d --- /dev/null +++ b/backend/routes/auth/tokens.go @@ -0,0 +1,125 @@ +package auth + +import ( + "net/http" + "time" + + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/render" + "github.com/rs/xid" +) + +type getTokenResponse struct { + TokenID xid.ID `json:"id"` + APIOnly bool `json:"api_only"` + ReadOnly bool `json:"read_only"` + Created time.Time `json:"created"` + Expires time.Time `json:"expires"` +} + +func dbTokenToGetResponse(t db.Token) getTokenResponse { + return getTokenResponse{ + TokenID: t.TokenID, + APIOnly: t.APIOnly, + ReadOnly: t.ReadOnly, + Created: t.Created, + Expires: t.Expires, + } +} + +func (s *Server) getTokens(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + if claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + tokens, err := s.DB.Tokens(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting tokens") + } + + resps := make([]getTokenResponse, len(tokens)) + for i := range tokens { + resps[i] = dbTokenToGetResponse(tokens[i]) + } + + render.JSON(w, r, resps) + return nil +} + +func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + if claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return errors.Wrap(err, "beginning transaction") + } + defer tx.Rollback(ctx) + + err = s.DB.InvalidateAllTokens(ctx, tx, claims.UserID) + if err != nil { + return errors.Wrap(err, "invalidating tokens") + } + + err = tx.Commit(ctx) + if err != nil { + return errors.Wrap(err, "committing transaction") + } + + render.NoContent(w, r) + return nil +} + +type createTokenResponse struct { + Token string `json:"token"` + TokenID xid.ID `json:"id"` + APIOnly bool `json:"api_only"` + ReadOnly bool `json:"read_only"` + Created time.Time `json:"created"` + Expires time.Time `json:"expires"` +} + +func (s *Server) createToken(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + if claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting me user") + } + + readOnly := r.FormValue("read_only") == "true" + tokenID := xid.New() + tokenStr, err := s.Auth.CreateToken(claims.UserID, tokenID, u.IsAdmin, true, !readOnly) + if err != nil { + return errors.Wrap(err, "creating token") + } + + t, err := s.DB.SaveToken(ctx, claims.UserID, tokenID, true, readOnly) + if err != nil { + return errors.Wrap(err, "saving token") + } + + render.JSON(w, r, createTokenResponse{ + Token: tokenStr, + TokenID: t.TokenID, + APIOnly: t.APIOnly, + ReadOnly: t.ReadOnly, + Created: t.Created, + Expires: t.Expires, + }) + return nil +} diff --git a/backend/routes/auth/tumblr.go b/backend/routes/auth/tumblr.go new file mode 100644 index 0000000..d35781a --- /dev/null +++ b/backend/routes/auth/tumblr.go @@ -0,0 +1,419 @@ +package auth + +import ( + "encoding/json" + "io" + "net/http" + "os" + "time" + + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/render" + "github.com/mediocregopher/radix/v4" + "github.com/rs/xid" + "golang.org/x/oauth2" +) + +var tumblrOAuthConfig = oauth2.Config{ + ClientID: os.Getenv("TUMBLR_CLIENT_ID"), + ClientSecret: os.Getenv("TUMBLR_CLIENT_SECRET"), + Endpoint: oauth2.Endpoint{ + AuthURL: "https://www.tumblr.com/oauth2/authorize", + TokenURL: "https://api.tumblr.com/v2/oauth2/token", + AuthStyle: oauth2.AuthStyleInParams, + }, + Scopes: []string{"basic"}, +} + +type partialTumblrResponse struct { + Meta struct { + Status int `json:"status"` + Message string `json:"msg"` + } `json:"meta"` + Response struct { + User struct { + Blogs []struct { + Name string `json:"name"` + Primary bool `json:"primary"` + UUID string `json:"uuid"` + } `json:"blogs"` + } `json:"user"` + } `json:"response"` +} + +type tumblrUserInfo struct { + Name string `json:"name"` + ID string `json:"id"` +} + +type tumblrCallbackResponse struct { + HasAccount bool `json:"has_account"` // if true, Token and User will be set. if false, Ticket and Tumblr will be set + + Token string `json:"token,omitempty"` + User *userResponse `json:"user,omitempty"` + + Tumblr string `json:"tumblr,omitempty"` // username, for UI purposes + Ticket string `json:"ticket,omitempty"` + RequireInvite bool `json:"require_invite"` // require an invite for signing up + RequireCaptcha bool `json:"require_captcha"` + + IsDeleted bool `json:"is_deleted"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + SelfDelete *bool `json:"self_delete,omitempty"` + DeleteReason *string `json:"delete_reason,omitempty"` +} + +func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + decoded, err := Decode[oauthCallbackRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + // if the state can't be validated, return + if valid, err := s.validateCSRFState(ctx, decoded.State); !valid { + if err != nil { + return err + } + + return server.APIError{Code: server.ErrInvalidState} + } + + cfg := tumblrOAuthConfig + cfg.RedirectURL = decoded.CallbackDomain + "/auth/login/tumblr" + token, err := cfg.Exchange(r.Context(), decoded.Code) + if err != nil { + log.Errorf("exchanging oauth code: %v", err) + + return server.APIError{Code: server.ErrInvalidOAuthCode} + } + + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.tumblr.com/v2/user/info", nil) + if err != nil { + return errors.Wrap(err, "creating user/info request") + } + + req.Header.Set("Content-Type", "application/json") + token.SetAuthHeader(req) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return errors.Wrap(err, "sending user/info request") + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + return errors.New("response had status code < 200 or >= 400") + } + + jb, err := io.ReadAll(resp.Body) + if err != nil { + return errors.Wrap(err, "reading user/info response") + } + + var tr partialTumblrResponse + err = json.Unmarshal(jb, &tr) + if err != nil { + return errors.Wrap(err, "unmarshaling user/info response") + } + + var tumblrName, tumblrID string + for _, blog := range tr.Response.User.Blogs { + if blog.Primary { + tumblrName = blog.Name + tumblrID = blog.UUID + break + } + } + + if tumblrID == "" { + return server.APIError{Code: server.ErrInternalServerError, Details: "Your Tumblr account doesn't seem to have a primary blog"} + } + + u, err := s.DB.TumblrUser(ctx, tumblrID) + if err == nil { + if u.DeletedAt != nil { + // store cancel delete token + token := undeleteToken() + err = s.saveUndeleteToken(ctx, u.ID, token) + if err != nil { + log.Errorf("saving undelete token: %v", err) + return err + } + + render.JSON(w, r, tumblrCallbackResponse{ + HasAccount: true, + Token: token, + User: dbUserToUserResponse(u, []db.Field{}), + IsDeleted: true, + DeletedAt: u.DeletedAt, + SelfDelete: u.SelfDelete, + DeleteReason: u.DeleteReason, + }) + return nil + } + + err = u.UpdateFromTumblr(ctx, s.DB, tumblrID, tumblrName) + if err != nil { + log.Errorf("updating user %v with Tumblr info: %v", u.ID, err) + } + + // TODO: implement user + token permissions + tokenID := xid.New() + token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) + if err != nil { + return err + } + + // save token to database + _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) + if err != nil { + return errors.Wrap(err, "saving token to database") + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "querying fields") + } + + render.JSON(w, r, tumblrCallbackResponse{ + HasAccount: true, + Token: token, + User: dbUserToUserResponse(u, fields), + }) + + return nil + + } else if err != db.ErrUserNotFound { // internal error + return err + } + + // no user found, so save a ticket + save their Tumblr info in Redis + ticket := RandBase64(32) + err = s.DB.SetJSON(ctx, "tumblr:"+ticket, tumblrUserInfo{ID: tumblrID, Name: tumblrName}, "EX", "600") + if err != nil { + log.Errorf("setting Tumblr user for ticket %q: %v", ticket, err) + return err + } + + render.JSON(w, r, tumblrCallbackResponse{ + HasAccount: false, + Tumblr: tumblrName, + Ticket: ticket, + RequireInvite: s.RequireInvite, + RequireCaptcha: s.hcaptchaSecret != "", + }) + + return nil +} + +func (s *Server) tumblrLink(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + claims, _ := server.ClaimsFromContext(ctx) + + // only site tokens can be used for this endpoint + if claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + req, err := Decode[linkRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting user") + } + + if u.Tumblr != nil { + return server.APIError{Code: server.ErrAlreadyLinked} + } + + tui := new(tumblrUserInfo) + err = s.DB.GetJSON(ctx, "tumblr:"+req.Ticket, &tui) + if err != nil { + log.Errorf("getting tumblr user for ticket: %v", err) + + return server.APIError{Code: server.ErrInvalidTicket} + } + + if tui.ID == "" { + log.Errorf("linking user with id %v: user ID was empty", claims.UserID) + return server.APIError{Code: server.ErrInternalServerError, Details: "Tumblr user ID is empty"} + } + + err = u.UpdateFromTumblr(ctx, s.DB, tui.ID, tui.Name) + if err != nil { + return errors.Wrap(err, "updating user from tumblr") + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "getting user fields") + } + + render.JSON(w, r, dbUserToUserResponse(u, fields)) + return nil +} + +func (s *Server) tumblrUnlink(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + claims, _ := server.ClaimsFromContext(ctx) + + // only site tokens can be used for this endpoint + if claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting user") + } + + if u.Tumblr == nil { + return server.APIError{Code: server.ErrNotLinked} + } + + // cannot unlink last auth provider + if u.NumProviders() <= 1 { + return server.APIError{Code: server.ErrLastProvider} + } + + err = u.UnlinkTumblr(ctx, s.DB) + if err != nil { + return errors.Wrap(err, "updating user in db") + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "getting user fields") + } + + render.JSON(w, r, dbUserToUserResponse(u, fields)) + return nil +} + +func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + req, err := Decode[signupRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + if s.RequireInvite && req.InviteCode == "" { + return server.APIError{Code: server.ErrInviteRequired} + } + + valid, taken, err := s.DB.UsernameTaken(ctx, req.Username) + if err != nil { + return err + } + if !valid { + return server.APIError{Code: server.ErrInvalidUsername} + } + if taken { + return server.APIError{Code: server.ErrUsernameTaken} + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return errors.Wrap(err, "beginning transaction") + } + defer tx.Rollback(ctx) + + tui := new(tumblrUserInfo) + err = s.DB.GetJSON(ctx, "tumblr:"+req.Ticket, &tui) + if err != nil { + log.Errorf("getting tumblr user for ticket: %v", err) + + return server.APIError{Code: server.ErrInvalidTicket} + } + + // check captcha + if s.hcaptchaSecret != "" { + ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse) + if err != nil { + log.Errorf("verifying captcha: %v", err) + return server.APIError{Code: server.ErrInternalServerError} + } + + if !ok { + return server.APIError{Code: server.ErrInvalidCaptcha} + } + } + + u, err := s.DB.CreateUser(ctx, tx, req.Username) + if err != nil { + if errors.Cause(err) == db.ErrUsernameTaken { + return server.APIError{Code: server.ErrUsernameTaken} + } + + return errors.Wrap(err, "creating user") + } + + if tui.ID == "" { + log.Errorf("creating user with name %q: user ID was empty", req.Username) + return server.APIError{Code: server.ErrInternalServerError, Details: "Tumblr user ID is empty"} + } + + err = u.UpdateFromTumblr(ctx, tx, tui.ID, tui.Name) + if err != nil { + return errors.Wrap(err, "updating user from tumblr") + } + + if s.RequireInvite { + valid, used, err := s.DB.InvalidateInvite(ctx, tx, req.InviteCode) + if err != nil { + return errors.Wrap(err, "checking and invalidating invite") + } + + if !valid { + return server.APIError{Code: server.ErrInviteRequired} + } + + if used { + return server.APIError{Code: server.ErrInviteAlreadyUsed} + } + } + + // delete sign up ticket + err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "tumblr:"+req.Ticket)) + if err != nil { + return errors.Wrap(err, "deleting signup ticket") + } + + // commit transaction + err = tx.Commit(ctx) + if err != nil { + return errors.Wrap(err, "committing transaction") + } + + // create token + // TODO: implement user + token permissions + tokenID := xid.New() + token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true) + if err != nil { + return errors.Wrap(err, "creating token") + } + + // save token to database + _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) + if err != nil { + return errors.Wrap(err, "saving token to database") + } + + // return user + render.JSON(w, r, signupResponse{ + User: *dbUserToUserResponse(u, nil), + Token: token, + }) + return nil +} diff --git a/backend/routes/auth/undelete.go b/backend/routes/auth/undelete.go new file mode 100644 index 0000000..b557d23 --- /dev/null +++ b/backend/routes/auth/undelete.go @@ -0,0 +1,114 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "net/http" + + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/render" + "github.com/mediocregopher/radix/v4" + "github.com/rs/xid" +) + +func (s *Server) cancelDelete(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + token := r.Header.Get("X-Delete-Token") + if token == "" { + return server.APIError{Code: server.ErrForbidden} + } + + id, err := s.getUndeleteToken(ctx, token) + if err != nil { + log.Errorf("getting undelete token: %v", err) + return server.APIError{Code: server.ErrNotFound} // assume invalid token + } + + // only self deleted users can undelete themselves + u, err := s.DB.User(ctx, id) + if err != nil { + log.Errorf("getting user: %v", err) + return errors.Wrap(err, "getting user") + } + if !*u.SelfDelete { + return server.APIError{Code: server.ErrForbidden} + } + + err = s.DB.UndoDeleteUser(ctx, id) + if err != nil { + log.Errorf("executing undelete query: %v", err) + } + + render.NoContent(w, r) + return nil +} + +func undeleteToken() string { + b := make([]byte, 32) + + _, err := rand.Read(b) + if err != nil { + panic(err) + } + + return base64.RawURLEncoding.EncodeToString(b) +} + +func (s *Server) saveUndeleteToken(ctx context.Context, userID xid.ID, token string) error { + err := s.DB.Redis.Do(ctx, radix.Cmd(nil, "SET", "undelete:"+token, userID.String(), "EX", "3600")) + if err != nil { + return errors.Wrap(err, "setting undelete key") + } + return nil +} + +func (s *Server) getUndeleteToken(ctx context.Context, token string) (userID xid.ID, err error) { + var idString string + err = s.DB.Redis.Do(ctx, radix.Cmd(&idString, "GET", "undelete:"+token)) + if err != nil { + return userID, errors.Wrap(err, "getting undelete key") + } + + userID, err = xid.FromString(idString) + if err != nil { + return userID, errors.Wrap(err, "parsing ID") + } + + err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "undelete:"+token)) + if err != nil { + return userID, errors.Wrap(err, "deleting undelete key") + } + return userID, nil +} + +func (s *Server) forceDelete(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + token := r.Header.Get("X-Delete-Token") + if token == "" { + return server.APIError{Code: server.ErrForbidden} + } + + id, err := s.getUndeleteToken(ctx, token) + if err != nil { + log.Errorf("getting delete token: %v", err) + return server.APIError{Code: server.ErrNotFound} // assume invalid token + } + + err = s.DB.CleanUser(ctx, id) + if err != nil { + log.Errorf("cleaning user data: %v", err) + return errors.Wrap(err, "cleaning user") + } + + err = s.DB.ForceDeleteUser(ctx, id) + if err != nil { + log.Errorf("force deleting user: %v", err) + return errors.Wrap(err, "deleting user") + } + + render.NoContent(w, r) + return nil +} diff --git a/backend/routes/bot/bot.go b/backend/routes/bot/bot.go new file mode 100644 index 0000000..1fa8f4c --- /dev/null +++ b/backend/routes/bot/bot.go @@ -0,0 +1,183 @@ +package bot + +import ( + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "os" + + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "github.com/bwmarrin/discordgo" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +type Bot struct { + *server.Server + + publicKey ed25519.PublicKey + baseURL string +} + +func (bot *Bot) UserAvatarURL(u db.User) string { + if u.Avatar == nil { + return "" + } + + return bot.baseURL + "/media/users/" + u.ID.String() + "/" + *u.Avatar + ".webp" +} + +func Mount(srv *server.Server, r chi.Router) { + publicKey, err := hex.DecodeString(os.Getenv("DISCORD_PUBLIC_KEY")) + if err != nil { + return + } + + b := &Bot{ + Server: srv, + publicKey: publicKey, + baseURL: os.Getenv("BASE_URL"), + } + + r.HandleFunc("/interactions", b.handle) +} + +func (bot *Bot) handle(w http.ResponseWriter, r *http.Request) { + if !discordgo.VerifyInteraction(r, bot.publicKey) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + var ev *discordgo.InteractionCreate + + if err := json.NewDecoder(r.Body).Decode(&ev); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + } + + // we can always respond to ping with pong + if ev.Type == discordgo.InteractionPing { + log.Debug("received ping interaction") + render.JSON(w, r, discordgo.InteractionResponse{ + Type: discordgo.InteractionResponsePong, + }) + return + } + + if ev.Type != discordgo.InteractionApplicationCommand { + return + } + data := ev.ApplicationCommandData() + + switch data.Name { + case "Show user's pronouns": + bot.userPronouns(w, r, ev) + case "Show author's pronouns": + } +} + +func (bot *Bot) userPronouns(w http.ResponseWriter, r *http.Request, ev *discordgo.InteractionCreate) { + ctx := r.Context() + + var du *discordgo.User + for _, user := range ev.ApplicationCommandData().Resolved.Users { + du = user + break + } + if du == nil { + return + } + + u, err := bot.DB.DiscordUser(ctx, du.ID) + if err != nil { + if err == db.ErrUserNotFound { + respond(w, r, &discordgo.MessageEmbed{ + Description: du.String() + " does not have any pronouns set.", + }) + return + } + + log.Errorf("getting discord user: %v", err) + return + } + + avatarURL := du.AvatarURL("") + if url := bot.UserAvatarURL(u); url != "" { + avatarURL = url + } + name := u.Username + if u.DisplayName != nil { + name = fmt.Sprintf("%s (%s)", *u.DisplayName, u.Username) + } + url := bot.baseURL + if url != "" { + url += "/@" + u.Username + } + + e := &discordgo.MessageEmbed{ + Author: &discordgo.MessageEmbedAuthor{ + Name: name, + IconURL: avatarURL, + URL: url, + }, + } + + if u.Bio != nil { + e.Fields = append(e.Fields, &discordgo.MessageEmbedField{ + Name: "Bio", + Value: *u.Bio, + }) + } + + fields, err := bot.DB.UserFields(ctx, u.ID) + if err != nil { + respond(w, r, e) + + log.Errorf("getting user fields: %v", err) + return + } + + for _, field := range fields { + var favs []db.FieldEntry + + for _, e := range field.Entries { + if e.Status == "favourite" { + favs = append(favs, e) + } + } + + if len(favs) == 0 { + continue + } + + var value string + for _, fav := range favs { + if len(fav.Value) > 500 { + break + } + + value += fav.Value + "\n" + } + + e.Fields = append(e.Fields, &discordgo.MessageEmbedField{ + Name: field.Name, + Value: value, + Inline: true, + }) + } + + respond(w, r, e) +} + +func respond(w http.ResponseWriter, r *http.Request, embeds ...*discordgo.MessageEmbed) { + render.JSON(w, r, discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: embeds, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} diff --git a/backend/routes/member/create_member.go b/backend/routes/member/create_member.go new file mode 100644 index 0000000..e26ab74 --- /dev/null +++ b/backend/routes/member/create_member.go @@ -0,0 +1,230 @@ +package member + +import ( + "fmt" + "net/http" + "strings" + + "codeberg.org/pronounscc/pronouns.cc/backend/common" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/render" +) + +type CreateMemberRequest struct { + Name string `json:"name"` + DisplayName *string `json:"display_name"` + Bio string `json:"bio"` + Avatar string `json:"avatar"` + Links []string `json:"links"` + Names []db.FieldEntry `json:"names"` + Pronouns []db.PronounEntry `json:"pronouns"` + Fields []db.Field `json:"fields"` +} + +func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error) { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + if !claims.TokenWrite { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"} + } + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting user") + } + + memberCount, err := s.DB.MemberCount(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting member count") + } + if memberCount > db.MaxMemberCount { + return server.APIError{ + Code: server.ErrMemberLimitReached, + } + } + + var cmr CreateMemberRequest + err = render.Decode(r, &cmr) + if err != nil { + if _, ok := err.(server.APIError); ok { + return err + } + + return server.APIError{Code: server.ErrBadRequest} + } + + // remove whitespace from all fields + cmr.Name = strings.TrimSpace(cmr.Name) + cmr.Bio = strings.TrimSpace(cmr.Bio) + if cmr.DisplayName != nil { + *cmr.DisplayName = strings.TrimSpace(*cmr.DisplayName) + } + + // validate everything + if cmr.Name == "" { + return server.APIError{ + Code: server.ErrBadRequest, + Details: "Name may not be empty", + } + } else if len(cmr.Name) > 100 { + return server.APIError{ + Code: server.ErrBadRequest, + Details: "Name may not be longer than 100 characters", + } + } + + if !db.MemberNameValid(cmr.Name) { + return server.APIError{ + Code: server.ErrBadRequest, + Details: "Member name cannot contain any of the following: @, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), {, }, +, <, =, >, ^, |, ~, `, , and cannot be one or two periods.", + } + } + + if common.StringLength(&cmr.Name) > db.MaxMemberNameLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Name name too long (max %d, current %d)", db.MaxMemberNameLength, common.StringLength(&cmr.Name)), + } + } + if common.StringLength(cmr.DisplayName) > db.MaxDisplayNameLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Display name too long (max %d, current %d)", db.MaxDisplayNameLength, common.StringLength(cmr.DisplayName)), + } + } + if common.StringLength(&cmr.Bio) > db.MaxUserBioLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Bio too long (max %d, current %d)", db.MaxUserBioLength, common.StringLength(&cmr.Bio)), + } + } + + if err := validateSlicePtr("name", &cmr.Names, u.CustomPreferences); err != nil { + return *err + } + + if err := validateSlicePtr("pronoun", &cmr.Pronouns, u.CustomPreferences); err != nil { + return *err + } + + if err := validateSlicePtr("field", &cmr.Fields, u.CustomPreferences); err != nil { + return *err + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return errors.Wrap(err, "starting transaction") + } + defer tx.Rollback(ctx) + + m, err := s.DB.CreateMember(ctx, tx, claims.UserID, cmr.Name, cmr.DisplayName, cmr.Bio, cmr.Links) + if err != nil { + if errors.Cause(err) == db.ErrMemberNameInUse { + return server.APIError{Code: server.ErrMemberNameInUse} + } + + return err + } + + // set names, pronouns, fields + err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, db.NotNull(cmr.Names), db.NotNull(cmr.Pronouns)) + if err != nil { + log.Errorf("setting names and pronouns for member %v: %v", m.ID, err) + return err + } + m.Names = cmr.Names + m.Pronouns = cmr.Pronouns + + err = s.DB.SetMemberFields(ctx, tx, m.ID, cmr.Fields) + if err != nil { + log.Errorf("setting fields for member %v: %v", m.ID, err) + return err + } + + if cmr.Avatar != "" { + webp, jpg, err := s.DB.ConvertAvatar(cmr.Avatar) + if err != nil { + if err == db.ErrInvalidDataURI { + return server.APIError{ + Code: server.ErrBadRequest, + Details: "invalid avatar data URI", + } + } else if err == db.ErrInvalidContentType { + return server.APIError{ + Code: server.ErrBadRequest, + Details: "invalid avatar content type", + } + } + + log.Errorf("converting member avatar: %v", err) + return err + } + + hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg) + if err != nil { + log.Errorf("uploading member avatar: %v", err) + return err + } + + err = tx.QueryRow(ctx, "UPDATE members SET avatar = $1 WHERE id = $2", hash, m.ID).Scan(&m.Avatar) + if err != nil { + return errors.Wrap(err, "setting avatar urls in db") + } + } + + // update last active time + err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID) + if err != nil { + log.Errorf("updating last active time for user %v: %v", claims.UserID, err) + return err + } + + err = tx.Commit(ctx) + if err != nil { + return errors.Wrap(err, "committing transaction") + } + + render.JSON(w, r, dbMemberToMember(u, m, cmr.Fields, nil, true)) + return nil +} + +type validator interface { + Validate(custom db.CustomPreferences) string +} + +// validateSlicePtr validates a slice of validators. +// If the slice is nil, a nil error is returned (assuming that the field is not required) +func validateSlicePtr[T validator](typ string, slice *[]T, custom db.CustomPreferences) *server.APIError { + if slice == nil { + return nil + } + + max := db.MaxFields + if typ != "field" { + max = db.FieldEntriesLimit + } + + // max 25 fields + if len(*slice) > max { + return &server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Too many %ss (max %d, current %d)", typ, max, len(*slice)), + } + } + + // validate all fields + for i, pronouns := range *slice { + if s := pronouns.Validate(custom); s != "" { + return &server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("%s %d: %s", typ, i+1, s), + } + } + } + + return nil +} diff --git a/backend/routes/member/delete_member.go b/backend/routes/member/delete_member.go new file mode 100644 index 0000000..5ce49c0 --- /dev/null +++ b/backend/routes/member/delete_member.go @@ -0,0 +1,64 @@ +package member + +import ( + "net/http" + + "emperror.dev/errors" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/rs/xid" + + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" +) + +func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + claims, _ := server.ClaimsFromContext(ctx) + + if !claims.TokenWrite { + return server.APIError{Code: server.ErrMissingPermissions, Details: "this token is read-only"} + } + + id, err := xid.FromString(chi.URLParam(r, "memberRef")) + if err != nil { + return server.APIError{Code: server.ErrMemberNotFound} + } + + m, err := s.DB.Member(ctx, id) + if err != nil { + if err == db.ErrMemberNotFound { + return server.APIError{Code: server.ErrMemberNotFound} + } + + return errors.Wrap(err, "getting member") + } + + if m.UserID != claims.UserID { + return server.APIError{Code: server.ErrNotOwnMember} + } + + err = s.DB.DeleteMember(ctx, m.ID) + if err != nil { + return errors.Wrap(err, "deleting member") + } + + if m.Avatar != nil { + err = s.DB.DeleteMemberAvatar(ctx, m.ID, *m.Avatar) + if err != nil { + return errors.Wrap(err, "deleting member avatar") + } + } + + // update last active time + err = s.DB.UpdateActiveTime(ctx, s.DB, claims.UserID) + if err != nil { + log.Errorf("updating last active time for user %v: %v", claims.UserID, err) + return err + } + + render.NoContent(w, r) + return nil +} diff --git a/backend/routes/member/get_member.go b/backend/routes/member/get_member.go new file mode 100644 index 0000000..ddc94d8 --- /dev/null +++ b/backend/routes/member/get_member.go @@ -0,0 +1,197 @@ +package member + +import ( + "context" + "net/http" + + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/rs/xid" +) + +type GetMemberResponse struct { + ID xid.ID `json:"id"` + SID string `json:"sid"` + Name string `json:"name"` + DisplayName *string `json:"display_name"` + Bio *string `json:"bio"` + Avatar *string `json:"avatar"` + Links []string `json:"links"` + + Names []db.FieldEntry `json:"names"` + Pronouns []db.PronounEntry `json:"pronouns"` + Fields []db.Field `json:"fields"` + Flags []db.MemberFlag `json:"flags"` + + User PartialUser `json:"user"` + + Unlisted *bool `json:"unlisted,omitempty"` +} + +func dbMemberToMember(u db.User, m db.Member, fields []db.Field, flags []db.MemberFlag, isOwnMember bool) GetMemberResponse { + r := GetMemberResponse{ + ID: m.ID, + SID: m.SID, + Name: m.Name, + DisplayName: m.DisplayName, + Bio: m.Bio, + Avatar: m.Avatar, + Links: db.NotNull(m.Links), + + Names: db.NotNull(m.Names), + Pronouns: db.NotNull(m.Pronouns), + Fields: db.NotNull(fields), + Flags: flags, + + User: PartialUser{ + ID: u.ID, + Username: u.Username, + DisplayName: u.DisplayName, + Avatar: u.Avatar, + CustomPreferences: u.CustomPreferences, + }, + } + + if isOwnMember { + r.Unlisted = &m.Unlisted + } + + return r +} + +type PartialUser struct { + ID xid.ID `json:"id"` + Username string `json:"name"` + DisplayName *string `json:"display_name"` + Avatar *string `json:"avatar"` + CustomPreferences db.CustomPreferences `json:"custom_preferences"` +} + +func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + id, err := xid.FromString(chi.URLParam(r, "memberRef")) + if err != nil { + return server.APIError{ + Code: server.ErrMemberNotFound, + } + } + + m, err := s.DB.Member(ctx, id) + if err != nil { + return server.APIError{ + Code: server.ErrMemberNotFound, + } + } + + u, err := s.DB.User(ctx, m.UserID) + if err != nil { + return err + } + + if u.DeletedAt != nil { + return server.APIError{Code: server.ErrMemberNotFound} + } + + isOwnMember := false + if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID { + isOwnMember = true + } + + fields, err := s.DB.MemberFields(ctx, m.ID) + if err != nil { + return err + } + + flags, err := s.DB.MemberFlags(ctx, m.ID) + if err != nil { + return err + } + + render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember)) + return nil +} + +func (s *Server) getUserMember(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + u, err := s.parseUser(ctx, chi.URLParam(r, "userRef")) + if err != nil { + return server.APIError{ + Code: server.ErrUserNotFound, + } + } + + if u.DeletedAt != nil { + return server.APIError{Code: server.ErrUserNotFound} + } + + isOwnMember := false + if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID { + isOwnMember = true + } + + m, err := s.DB.UserMember(ctx, u.ID, chi.URLParam(r, "memberRef")) + if err != nil { + return server.APIError{ + Code: server.ErrMemberNotFound, + } + } + + fields, err := s.DB.MemberFields(ctx, m.ID) + if err != nil { + return err + } + + flags, err := s.DB.MemberFlags(ctx, m.ID) + if err != nil { + return err + } + + render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember)) + return nil +} + +func (s *Server) getMeMember(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting me user") + } + + m, err := s.DB.UserMember(ctx, claims.UserID, chi.URLParam(r, "memberRef")) + if err != nil { + return server.APIError{ + Code: server.ErrMemberNotFound, + } + } + + fields, err := s.DB.MemberFields(ctx, m.ID) + if err != nil { + return err + } + + flags, err := s.DB.MemberFlags(ctx, m.ID) + if err != nil { + return err + } + + render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true)) + return nil +} + +func (s *Server) parseUser(ctx context.Context, userRef string) (u db.User, err error) { + if id, err := xid.FromString(userRef); err != nil { + u, err := s.DB.User(ctx, id) + if err == nil { + return u, nil + } + } + + return s.DB.Username(ctx, userRef) +} diff --git a/backend/routes/member/get_members.go b/backend/routes/member/get_members.go new file mode 100644 index 0000000..6b08239 --- /dev/null +++ b/backend/routes/member/get_members.go @@ -0,0 +1,92 @@ +package member + +import ( + "net/http" + + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/rs/xid" +) + +type memberListResponse struct { + ID xid.ID `json:"id"` + SID string `json:"sid"` + Name string `json:"name"` + DisplayName *string `json:"display_name"` + Bio *string `json:"bio"` + Avatar *string `json:"avatar"` + Links []string `json:"links"` + Names []db.FieldEntry `json:"names"` + Pronouns []db.PronounEntry `json:"pronouns"` + Unlisted bool `json:"unlisted"` +} + +func membersToMemberList(ms []db.Member, isSelf bool) []memberListResponse { + resps := make([]memberListResponse, len(ms)) + for i := range ms { + resps[i] = memberListResponse{ + ID: ms[i].ID, + SID: ms[i].SID, + Name: ms[i].Name, + DisplayName: ms[i].DisplayName, + Bio: ms[i].Bio, + Avatar: ms[i].Avatar, + Links: db.NotNull(ms[i].Links), + Names: db.NotNull(ms[i].Names), + Pronouns: db.NotNull(ms[i].Pronouns), + } + + if isSelf { + resps[i].Unlisted = ms[i].Unlisted + } + } + + return resps +} + +func (s *Server) getUserMembers(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + u, err := s.parseUser(ctx, chi.URLParam(r, "userRef")) + if err != nil { + return server.APIError{ + Code: server.ErrUserNotFound, + } + } + + if u.DeletedAt != nil { + return server.APIError{Code: server.ErrUserNotFound} + } + + isSelf := false + if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID { + isSelf = true + } + + if u.ListPrivate && !isSelf { + return server.APIError{Code: server.ErrMemberListPrivate} + } + + ms, err := s.DB.UserMembers(ctx, u.ID, isSelf) + if err != nil { + return err + } + + render.JSON(w, r, membersToMemberList(ms, isSelf)) + return nil +} + +func (s *Server) getMeMembers(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + ms, err := s.DB.UserMembers(ctx, claims.UserID, true) + if err != nil { + return err + } + + render.JSON(w, r, membersToMemberList(ms, true)) + return nil +} diff --git a/backend/routes/member/patch_member.go b/backend/routes/member/patch_member.go new file mode 100644 index 0000000..5884efe --- /dev/null +++ b/backend/routes/member/patch_member.go @@ -0,0 +1,368 @@ +package member + +import ( + "fmt" + "net/http" + "strings" + "time" + + "codeberg.org/pronounscc/pronouns.cc/backend/common" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/rs/xid" +) + +type PatchMemberRequest struct { + Name *string `json:"name"` + Bio *string `json:"bio"` + DisplayName *string `json:"display_name"` + Links *[]string `json:"links"` + Names *[]db.FieldEntry `json:"names"` + Pronouns *[]db.PronounEntry `json:"pronouns"` + Fields *[]db.Field `json:"fields"` + Avatar *string `json:"avatar"` + Unlisted *bool `json:"unlisted"` + Flags *[]xid.ID `json:"flags"` +} + +func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + claims, _ := server.ClaimsFromContext(ctx) + + if !claims.TokenWrite { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"} + } + + id, err := xid.FromString(chi.URLParam(r, "memberRef")) + if err != nil { + return server.APIError{Code: server.ErrMemberNotFound} + } + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting user") + } + + m, err := s.DB.Member(ctx, id) + if err != nil { + if err == db.ErrMemberNotFound { + return server.APIError{Code: server.ErrMemberNotFound} + } + + return errors.Wrap(err, "getting member") + } + + if m.UserID != claims.UserID { + return server.APIError{Code: server.ErrNotOwnMember} + } + + var req PatchMemberRequest + err = render.Decode(r, &req) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + // validate that *something* is set + if req.DisplayName == nil && + req.Name == nil && + req.Bio == nil && + req.Unlisted == nil && + req.Links == nil && + req.Fields == nil && + req.Names == nil && + req.Pronouns == nil && + req.Avatar == nil && + req.Flags == nil { + return server.APIError{ + Code: server.ErrBadRequest, + Details: "Data must not be empty", + } + } + + // trim whitespace from strings + if req.Name != nil { + *req.Name = strings.TrimSpace(*req.Name) + } + if req.DisplayName != nil { + *req.DisplayName = strings.TrimSpace(*req.DisplayName) + } + if req.Bio != nil { + *req.Bio = strings.TrimSpace(*req.Bio) + } + + if req.Name != nil && *req.Name == "" { + return server.APIError{ + Code: server.ErrBadRequest, + Details: "Name must not be empty", + } + } else if req.Name != nil && len(*req.Name) > 100 { + return server.APIError{ + Code: server.ErrBadRequest, + Details: "Name may not be longer than 100 characters", + } + } + + // validate member name + if req.Name != nil { + if !db.MemberNameValid(*req.Name) { + return server.APIError{ + Code: server.ErrBadRequest, + Details: "Member name cannot contain any of the following: @, \\, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), +, <, =, >, ^, |, ~, `, , and cannot be one or two periods.", + } + } + } + + // validate display name/bio + if common.StringLength(req.Name) > db.MaxMemberNameLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Name name too long (max %d, current %d)", db.MaxMemberNameLength, common.StringLength(req.Name)), + } + } + if common.StringLength(req.DisplayName) > db.MaxDisplayNameLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Display name too long (max %d, current %d)", db.MaxDisplayNameLength, common.StringLength(req.DisplayName)), + } + } + if common.StringLength(req.Bio) > db.MaxUserBioLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Bio too long (max %d, current %d)", db.MaxUserBioLength, common.StringLength(req.Name)), + } + } + + // validate links + if req.Links != nil { + if len(*req.Links) > db.MaxUserLinksLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Too many links (max %d, current %d)", db.MaxUserLinksLength, len(*req.Links)), + } + } + + for i, link := range *req.Links { + if len(link) > db.MaxLinkLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Link %d too long (max %d, current %d)", i, db.MaxLinkLength, len(link)), + } + } + } + } + + // validate flag length + if req.Flags != nil { + if len(*req.Flags) > db.MaxPrideFlags { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Too many flags (max %d, current %d)", len(*req.Flags), db.MaxPrideFlags), + } + } + } + + if err := validateSlicePtr("name", req.Names, u.CustomPreferences); err != nil { + return *err + } + + if err := validateSlicePtr("pronoun", req.Pronouns, u.CustomPreferences); err != nil { + return *err + } + + if err := validateSlicePtr("field", req.Fields, u.CustomPreferences); err != nil { + return *err + } + + // update avatar + var avatarHash *string = nil + if req.Avatar != nil { + if *req.Avatar == "" { + if m.Avatar != nil { + err = s.DB.DeleteMemberAvatar(ctx, m.ID, *m.Avatar) + if err != nil { + log.Errorf("deleting member avatar: %v", err) + return errors.Wrap(err, "deleting avatar") + } + } + avatarHash = req.Avatar + } else { + webp, jpg, err := s.DB.ConvertAvatar(*req.Avatar) + if err != nil { + if err == db.ErrInvalidDataURI { + return server.APIError{ + Code: server.ErrBadRequest, + Details: "invalid avatar data URI", + } + } else if err == db.ErrInvalidContentType { + return server.APIError{ + Code: server.ErrBadRequest, + Details: "invalid avatar content type", + } + } + + log.Errorf("converting member avatar: %v", err) + return err + } + + hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg) + if err != nil { + log.Errorf("uploading member avatar: %v", err) + return err + } + avatarHash = &hash + + // delete current avatar if member has one + if m.Avatar != nil { + err = s.DB.DeleteMemberAvatar(ctx, m.ID, *m.Avatar) + if err != nil { + log.Errorf("deleting existing avatar: %v", err) + } + } + } + } + + // start transaction + tx, err := s.DB.Begin(ctx) + if err != nil { + log.Errorf("creating transaction: %v", err) + return err + } + defer tx.Rollback(ctx) + + m, err = s.DB.UpdateMember(ctx, tx, id, req.Name, req.DisplayName, req.Bio, req.Unlisted, req.Links, avatarHash) + if err != nil { + switch errors.Cause(err) { + case db.ErrNothingToUpdate: + case db.ErrMemberNameInUse: + return server.APIError{Code: server.ErrMemberNameInUse} + default: + log.Errorf("updating member: %v", err) + return errors.Wrap(err, "updating member in db") + } + + } + + if req.Names != nil || req.Pronouns != nil { + names := m.Names + pronouns := m.Pronouns + + if req.Names != nil { + names = *req.Names + } + if req.Pronouns != nil { + pronouns = *req.Pronouns + } + + err = s.DB.SetMemberNamesPronouns(ctx, tx, id, names, pronouns) + if err != nil { + log.Errorf("setting names for member %v: %v", id, err) + return err + } + m.Names = names + m.Pronouns = pronouns + } + + var fields []db.Field + if req.Fields != nil { + err = s.DB.SetMemberFields(ctx, tx, id, *req.Fields) + if err != nil { + log.Errorf("setting fields for member %v: %v", id, err) + return err + } + fields = *req.Fields + } else { + fields, err = s.DB.MemberFields(ctx, id) + if err != nil { + log.Errorf("getting fields for member %v: %v", id, err) + return err + } + } + + // update flags + if req.Flags != nil { + err = s.DB.SetMemberFlags(ctx, tx, m.ID, *req.Flags) + if err != nil { + if err == db.ErrInvalidFlagID { + return server.APIError{Code: server.ErrBadRequest, Details: "One or more flag IDs are unknown"} + } + + log.Errorf("updating flags for member %v: %v", m.ID, err) + return err + } + } + + // update last active time + err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID) + if err != nil { + log.Errorf("updating last active time for user %v: %v", claims.UserID, err) + return err + } + + err = tx.Commit(ctx) + if err != nil { + log.Errorf("committing transaction: %v", err) + return err + } + + // get flags to return (we need to return full flag objects, not the array of IDs in the request body) + flags, err := s.DB.MemberFlags(ctx, m.ID) + if err != nil { + log.Errorf("getting user flags: %v", err) + return err + } + + // echo the updated member back on success + render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true)) + return nil +} + +func (s *Server) rerollMemberSID(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + claims, _ := server.ClaimsFromContext(ctx) + + if !claims.TokenWrite { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"} + } + + id, err := xid.FromString(chi.URLParam(r, "memberRef")) + if err != nil { + return server.APIError{Code: server.ErrMemberNotFound} + } + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting user") + } + + m, err := s.DB.Member(ctx, id) + if err != nil { + if err == db.ErrMemberNotFound { + return server.APIError{Code: server.ErrMemberNotFound} + } + + return errors.Wrap(err, "getting member") + } + + if m.UserID != claims.UserID { + return server.APIError{Code: server.ErrNotOwnMember} + } + + if time.Now().Add(-time.Hour).Before(u.LastSIDReroll) { + return server.APIError{Code: server.ErrRerollingTooQuickly} + } + + newID, err := s.DB.RerollMemberSID(ctx, u.ID, m.ID) + if err != nil { + return errors.Wrap(err, "updating member SID") + } + + m.SID = newID + render.JSON(w, r, dbMemberToMember(u, m, nil, nil, true)) + return nil +} diff --git a/backend/routes/member/routes.go b/backend/routes/member/routes.go new file mode 100644 index 0000000..e7846ed --- /dev/null +++ b/backend/routes/member/routes.go @@ -0,0 +1,36 @@ +package member + +import ( + "github.com/go-chi/chi/v5" + + "codeberg.org/pronounscc/pronouns.cc/backend/server" +) + +type Server struct { + *server.Server +} + +func Mount(srv *server.Server, r chi.Router) { + s := &Server{Server: srv} + + // member list + r.Get("/users/{userRef}/members", server.WrapHandler(s.getUserMembers)) + r.With(server.MustAuth).Get("/users/@me/members", server.WrapHandler(s.getMeMembers)) + + // user-scoped member lookup (including custom urls) + r.Get("/users/{userRef}/members/{memberRef}", server.WrapHandler(s.getUserMember)) + r.With(server.MustAuth).Get("/users/@me/members/{memberRef}", server.WrapHandler(s.getMeMember)) + + r.Route("/members", func(r chi.Router) { + // any member by ID + r.Get("/{memberRef}", server.WrapHandler(s.getMember)) + + // create, edit, and delete members + r.With(server.MustAuth).Post("/", server.WrapHandler(s.createMember)) + r.With(server.MustAuth).Patch("/{memberRef}", server.WrapHandler(s.patchMember)) + r.With(server.MustAuth).Delete("/{memberRef}", server.WrapHandler(s.deleteMember)) + + // reroll member SID + r.With(server.MustAuth).Get("/{memberRef}/reroll", server.WrapHandler(s.rerollMemberSID)) + }) +} diff --git a/backend/routes/meta/meta.go b/backend/routes/meta/meta.go new file mode 100644 index 0000000..ebae428 --- /dev/null +++ b/backend/routes/meta/meta.go @@ -0,0 +1,55 @@ +package meta + +import ( + "net/http" + "os" + + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +type Server struct { + *server.Server +} + +func Mount(srv *server.Server, r chi.Router) { + s := &Server{Server: srv} + + r.Get("/meta", server.WrapHandler(s.meta)) +} + +type MetaResponse struct { + GitRepository string `json:"git_repository"` + GitCommit string `json:"git_commit"` + Users MetaUsers `json:"users"` + Members int64 `json:"members"` + RequireInvite bool `json:"require_invite"` +} + +type MetaUsers struct { + Total int64 `json:"total"` + ActiveMonth int64 `json:"active_month"` + ActiveWeek int64 `json:"active_week"` + ActiveDay int64 `json:"active_day"` +} + +func (s *Server) meta(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + numUsers, numMembers, activeDay, activeWeek, activeMonth := s.DB.Counts(ctx) + + render.JSON(w, r, MetaResponse{ + GitRepository: server.Repository, + GitCommit: server.Revision, + Users: MetaUsers{ + Total: numUsers, + ActiveMonth: activeMonth, + ActiveWeek: activeWeek, + ActiveDay: activeDay, + }, + Members: numMembers, + RequireInvite: os.Getenv("REQUIRE_INVITE") == "true", + }) + return nil +} diff --git a/backend/routes/mod/create_report.go b/backend/routes/mod/create_report.go new file mode 100644 index 0000000..c9b6377 --- /dev/null +++ b/backend/routes/mod/create_report.go @@ -0,0 +1,119 @@ +package mod + +import ( + "net/http" + + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/rs/xid" +) + +const MaxReasonLength = 2000 + +type CreateReportRequest struct { + Reason string `json:"reason"` +} + +func (s *Server) createUserReport(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + if !claims.TokenWrite { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"} + } + + userID, err := xid.FromString(chi.URLParam(r, "id")) + if err != nil { + return server.APIError{Code: server.ErrBadRequest, Details: "Invalid user ID"} + } + + u, err := s.DB.User(ctx, userID) + if err != nil { + if err == db.ErrUserNotFound { + return server.APIError{Code: server.ErrUserNotFound} + } + + log.Errorf("getting user %v: %v", userID, err) + return errors.Wrap(err, "getting user") + } + + if u.DeletedAt != nil { + return server.APIError{Code: server.ErrUserNotFound} + } + + var req CreateReportRequest + err = render.Decode(r, &req) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + if len(req.Reason) > MaxReasonLength { + return server.APIError{Code: server.ErrBadRequest, Details: "Reason cannot exceed 2000 characters"} + } + + _, err = s.DB.CreateReport(ctx, claims.UserID, u.ID, nil, req.Reason) + if err != nil { + log.Errorf("creating report for %v: %v", u.ID, err) + return errors.Wrap(err, "creating report") + } + + render.NoContent(w, r) + return nil +} + +func (s *Server) createMemberReport(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + if !claims.TokenWrite { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"} + } + + memberID, err := xid.FromString(chi.URLParam(r, "id")) + if err != nil { + return server.APIError{Code: server.ErrBadRequest, Details: "Invalid member ID"} + } + + m, err := s.DB.Member(ctx, memberID) + if err != nil { + if err == db.ErrMemberNotFound { + return server.APIError{Code: server.ErrMemberNotFound} + } + + log.Errorf("getting member %v: %v", memberID, err) + return errors.Wrap(err, "getting member") + } + + u, err := s.DB.User(ctx, m.UserID) + if err != nil { + log.Errorf("getting user %v: %v", m.UserID, err) + return errors.Wrap(err, "getting user") + } + + if u.DeletedAt != nil { + return server.APIError{Code: server.ErrMemberNotFound} + } + + var req CreateReportRequest + err = render.Decode(r, &req) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + if len(req.Reason) > MaxReasonLength { + return server.APIError{Code: server.ErrBadRequest, Details: "Reason cannot exceed 2000 characters"} + } + + _, err = s.DB.CreateReport(ctx, claims.UserID, u.ID, &m.ID, req.Reason) + if err != nil { + log.Errorf("creating report for %v: %v", m.ID, err) + return errors.Wrap(err, "creating report") + } + + render.NoContent(w, r) + return nil +} diff --git a/backend/routes/mod/get_reports.go b/backend/routes/mod/get_reports.go new file mode 100644 index 0000000..3a61904 --- /dev/null +++ b/backend/routes/mod/get_reports.go @@ -0,0 +1,84 @@ +package mod + +import ( + "net/http" + "strconv" + + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/rs/xid" +) + +func (s *Server) getReports(w http.ResponseWriter, r *http.Request) (err error) { + ctx := r.Context() + showClosed := r.FormValue("closed") == "true" + var before int + if s := r.FormValue("before"); s != "" { + before, err = strconv.Atoi(s) + if err != nil { + return server.APIError{Code: server.ErrBadRequest, Details: "\"before\": invalid ID"} + } + } + + reports, err := s.DB.Reports(ctx, showClosed, before) + if err != nil { + log.Errorf("getting reports: %v", err) + return errors.Wrap(err, "getting reports from database") + } + + render.JSON(w, r, reports) + return nil +} + +func (s *Server) getReportsByUser(w http.ResponseWriter, r *http.Request) (err error) { + ctx := r.Context() + var before int + if s := r.FormValue("before"); s != "" { + before, err = strconv.Atoi(s) + if err != nil { + return server.APIError{Code: server.ErrBadRequest, Details: "\"before\": invalid ID"} + } + } + + userID, err := xid.FromString(chi.URLParam(r, "id")) + if err != nil { + return server.APIError{Code: server.ErrBadRequest, Details: "Invalid user ID"} + } + + reports, err := s.DB.ReportsByUser(ctx, userID, before) + if err != nil { + log.Errorf("getting reports: %v", err) + return errors.Wrap(err, "getting reports from database") + } + + render.JSON(w, r, reports) + return nil +} + +func (s *Server) getReportsByReporter(w http.ResponseWriter, r *http.Request) (err error) { + ctx := r.Context() + var before int + if s := r.FormValue("before"); s != "" { + before, err = strconv.Atoi(s) + if err != nil { + return server.APIError{Code: server.ErrBadRequest, Details: "\"before\": invalid ID"} + } + } + + userID, err := xid.FromString(chi.URLParam(r, "id")) + if err != nil { + return server.APIError{Code: server.ErrBadRequest, Details: "Invalid user ID"} + } + + reports, err := s.DB.ReportsByReporter(ctx, userID, before) + if err != nil { + log.Errorf("getting reports: %v", err) + return errors.Wrap(err, "getting reports from database") + } + + render.JSON(w, r, reports) + return nil +} diff --git a/backend/routes/mod/resolve_report.go b/backend/routes/mod/resolve_report.go new file mode 100644 index 0000000..77e4b8d --- /dev/null +++ b/backend/routes/mod/resolve_report.go @@ -0,0 +1,113 @@ +package mod + +import ( + "net/http" + "strconv" + + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +type resolveReportRequest struct { + Warn bool `json:"warn"` + Ban bool `json:"ban"` + Delete bool `json:"delete"` + Reason string `json:"reason"` +} + +func (s *Server) resolveReport(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + var req resolveReportRequest + err = render.Decode(r, &req) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + if req.Reason == "" { + return server.APIError{Code: server.ErrBadRequest, Details: "Reason cannot be empty"} + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + log.Errorf("creating transaction: %v", err) + return errors.Wrap(err, "creating transaction") + } + defer tx.Rollback(ctx) + + report, err := s.DB.Report(ctx, tx, id) + if err != nil { + if err == db.ErrReportNotFound { + return server.APIError{Code: server.ErrNotFound} + } + log.Errorf("getting report: %v", err) + return errors.Wrap(err, "getting report") + } + + if report.ResolvedAt != nil { + return server.APIError{Code: server.ErrReportAlreadyHandled} + } + + err = s.DB.ResolveReport(ctx, tx, report.ID, claims.UserID, req.Reason) + if err != nil { + log.Errorf("resolving report: %v", err) + } + + if req.Warn || req.Ban { + _, err = s.DB.CreateWarning(ctx, tx, report.UserID, req.Reason) + if err != nil { + log.Errorf("creating warning: %v", err) + } + } + + if req.Ban { + err = s.DB.DeleteUser(ctx, tx, report.UserID, false, req.Reason) + if err != nil { + log.Errorf("banning user: %v", err) + } + + if req.Delete { + err = s.DB.InvalidateAllTokens(ctx, tx, report.UserID) + if err != nil { + return errors.Wrap(err, "invalidating tokens") + } + + err = s.DB.CleanUser(ctx, report.UserID) + if err != nil { + log.Errorf("cleaning user data: %v", err) + return errors.Wrap(err, "cleaning user") + } + + err = s.DB.DeleteUserMembers(ctx, tx, report.UserID) + if err != nil { + log.Errorf("deleting members: %v", err) + return errors.Wrap(err, "deleting members") + } + + err = s.DB.ResetUser(ctx, tx, report.UserID) + if err != nil { + log.Errorf("resetting user data: %v", err) + return errors.Wrap(err, "resetting user") + } + } + } + + err = tx.Commit(ctx) + if err != nil { + log.Errorf("committing transaction: %v", err) + return errors.Wrap(err, "committing transaction") + } + + render.NoContent(w, r) + return nil +} diff --git a/backend/routes/mod/routes.go b/backend/routes/mod/routes.go new file mode 100644 index 0000000..aaed170 --- /dev/null +++ b/backend/routes/mod/routes.go @@ -0,0 +1,61 @@ +package mod + +import ( + "net/http" + + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +type Server struct { + *server.Server +} + +func Mount(srv *server.Server, r chi.Router) { + s := &Server{Server: srv} + + r.With(MustAdmin).Route("/admin", func(r chi.Router) { + r.Get("/reports", server.WrapHandler(s.getReports)) + r.Get("/reports/by-user/{id}", server.WrapHandler(s.getReportsByUser)) + r.Get("/reports/by-reporter/{id}", server.WrapHandler(s.getReportsByReporter)) + + r.Patch("/reports/{id}", server.WrapHandler(s.resolveReport)) + }) + + r.With(MustAdmin).Handle("/metrics", promhttp.Handler()) + + r.With(server.MustAuth).Post("/users/{id}/reports", server.WrapHandler(s.createUserReport)) + r.With(server.MustAuth).Post("/members/{id}/reports", server.WrapHandler(s.createMemberReport)) + + r.With(server.MustAuth).Get("/auth/warnings", server.WrapHandler(s.getWarnings)) + r.With(server.MustAuth).Post("/auth/warnings/{id}/ack", server.WrapHandler(s.ackWarning)) +} + +func MustAdmin(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + claims, ok := server.ClaimsFromContext(r.Context()) + if !ok { + render.Status(r, http.StatusForbidden) + render.JSON(w, r, server.APIError{ + Code: server.ErrForbidden, + Message: "Forbidden", + }) + return + } + + if !claims.UserIsAdmin { + render.Status(r, http.StatusForbidden) + render.JSON(w, r, server.APIError{ + Code: server.ErrForbidden, + Message: "Forbidden", + }) + return + } + + next.ServeHTTP(w, r) + } + + return http.HandlerFunc(fn) +} diff --git a/backend/routes/mod/warnings.go b/backend/routes/mod/warnings.go new file mode 100644 index 0000000..afab7cb --- /dev/null +++ b/backend/routes/mod/warnings.go @@ -0,0 +1,67 @@ +package mod + +import ( + "net/http" + "strconv" + + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +type warning struct { + db.Warning + Read bool `json:"read"` +} + +func dbWarningsToResponse(ws []db.Warning) []warning { + out := make([]warning, len(ws)) + for i := range ws { + out[i] = warning{ws[i], ws[i].ReadAt != nil} + } + return out +} + +func (s *Server) getWarnings(w http.ResponseWriter, r *http.Request) (err error) { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + showAll := r.FormValue("all") == "true" + + warnings, err := s.DB.Warnings(ctx, claims.UserID, !showAll) + if err != nil { + log.Errorf("getting warnings: %v", err) + return errors.Wrap(err, "getting warnings from database") + } + + render.JSON(w, r, dbWarningsToResponse(warnings)) + return nil +} + +func (s *Server) ackWarning(w http.ResponseWriter, r *http.Request) (err error) { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + if !claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + ok, err := s.DB.AckWarning(ctx, claims.UserID, id) + if err != nil { + log.Errorf("acknowledging warning: %v", err) + return errors.Wrap(err, "acknowledging warning") + } + if !ok { + return server.APIError{Code: server.ErrNotFound} + } + + render.NoContent(w, r) + return nil +} diff --git a/backend/routes/user/delete_user.go b/backend/routes/user/delete_user.go new file mode 100644 index 0000000..1523978 --- /dev/null +++ b/backend/routes/user/delete_user.go @@ -0,0 +1,42 @@ +package user + +import ( + "net/http" + + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/render" +) + +func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + if claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return errors.Wrap(err, "creating transaction") + } + defer tx.Rollback(ctx) + + err = s.DB.DeleteUser(ctx, tx, claims.UserID, true, "") + if err != nil { + return errors.Wrap(err, "setting user as deleted") + } + + err = s.DB.InvalidateAllTokens(ctx, tx, claims.UserID) + if err != nil { + return errors.Wrap(err, "invalidating tokens") + } + + err = tx.Commit(ctx) + if err != nil { + return errors.Wrap(err, "committing transaction") + } + + render.NoContent(w, r) + return nil +} diff --git a/backend/routes/user/export.go b/backend/routes/user/export.go new file mode 100644 index 0000000..2dde011 --- /dev/null +++ b/backend/routes/user/export.go @@ -0,0 +1,82 @@ +package user + +import ( + "net/http" + "time" + + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "github.com/go-chi/render" +) + +func (s *Server) startExport(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + if claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + hasExport, err := s.DB.HasRecentExport(ctx, claims.UserID) + if err != nil { + log.Errorf("checking if user has recent export: %v", err) + return server.APIError{Code: server.ErrInternalServerError} + } + if hasExport { + return server.APIError{Code: server.ErrRecentExport} + } + + req, err := http.NewRequestWithContext(ctx, "GET", s.ExporterPath+"/start/"+claims.UserID.String(), nil) + if err != nil { + log.Errorf("creating start export request: %v", err) + return server.APIError{Code: server.ErrInternalServerError} + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Errorf("executing start export request: %v", err) + return server.APIError{Code: server.ErrInternalServerError} + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusAccepted { + log.Errorf("got non-%v code: %v", http.StatusAccepted, resp.StatusCode) + return server.APIError{ + Code: server.ErrInternalServerError, + } + } + + render.NoContent(w, r) + return nil +} + +type dataExportResponse struct { + Path string `json:"path"` + CreatedAt time.Time `json:"created_at"` +} + +func (s *Server) getExport(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + if claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + de, err := s.DB.UserExport(ctx, claims.UserID) + if err != nil { + if err == db.ErrNoExport { + return server.APIError{Code: server.ErrNotFound} + } + + log.Errorf("getting export for user %v: %v", claims.UserID, err) + return err + } + + render.JSON(w, r, dataExportResponse{ + Path: de.Path(), + CreatedAt: de.CreatedAt, + }) + return nil +} diff --git a/backend/routes/user/flags.go b/backend/routes/user/flags.go new file mode 100644 index 0000000..899a675 --- /dev/null +++ b/backend/routes/user/flags.go @@ -0,0 +1,239 @@ +package user + +import ( + "fmt" + "net/http" + "strings" + + "codeberg.org/pronounscc/pronouns.cc/backend/common" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/rs/xid" +) + +func (s *Server) getUserFlags(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + flags, err := s.DB.AccountFlags(ctx, claims.UserID) + if err != nil { + return errors.Wrapf(err, "getting flags for account %v", claims.UserID) + } + + render.JSON(w, r, flags) + return nil +} + +type postUserFlagRequest struct { + Flag string `json:"flag"` + Name string `json:"name"` + Description string `json:"description"` +} + +func (s *Server) postUserFlag(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + if !claims.TokenWrite { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"} + } + + flags, err := s.DB.AccountFlags(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting current user flags") + } + if len(flags) >= db.MaxPrideFlags { + return server.APIError{ + Code: server.ErrFlagLimitReached, + } + } + + var req postUserFlagRequest + err = render.Decode(r, &req) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + // remove whitespace from all fields + req.Name = strings.TrimSpace(req.Name) + req.Description = strings.TrimSpace(req.Description) + + if s := common.StringLength(&req.Name); s > db.MaxPrideFlagTitleLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("name too long, must be %v characters or less, is %v", db.MaxPrideFlagTitleLength, s), + } + } + if s := common.StringLength(&req.Description); s > db.MaxPrideFlagDescLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("description too long, must be %v characters or less, is %v", db.MaxPrideFlagDescLength, s), + } + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return errors.Wrap(err, "starting transaction") + } + defer tx.Rollback(ctx) + + flag, err := s.DB.CreateFlag(ctx, tx, claims.UserID, req.Name, req.Description) + if err != nil { + log.Errorf("creating flag: %v", err) + return errors.Wrap(err, "creating flag") + } + + webp, err := s.DB.ConvertFlag(req.Flag) + if err != nil { + if err == db.ErrInvalidDataURI { + return server.APIError{Code: server.ErrBadRequest, Message: "invalid data URI"} + } else if err == db.ErrFileTooLarge { + return server.APIError{Code: server.ErrBadRequest, Message: "data URI exceeds 512 KB"} + } + return errors.Wrap(err, "converting flag") + } + + hash, err := s.DB.WriteFlag(ctx, flag.ID, webp) + if err != nil { + return errors.Wrap(err, "writing flag") + } + + flag, err = s.DB.EditFlag(ctx, tx, flag.ID, nil, nil, &hash) + if err != nil { + return errors.Wrap(err, "setting hash for flag") + } + + err = tx.Commit(ctx) + if err != nil { + return errors.Wrap(err, "committing transaction") + } + + render.JSON(w, r, flag) + return nil +} + +type patchUserFlagRequest struct { + Name *string `json:"name"` + Description *string `json:"description"` +} + +func (s *Server) patchUserFlag(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + if !claims.TokenWrite { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"} + } + + flagID, err := xid.FromString(chi.URLParam(r, "flagID")) + if err != nil { + return server.APIError{Code: server.ErrNotFound, Details: "Invalid flag ID"} + } + + flags, err := s.DB.AccountFlags(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting current user flags") + } + if len(flags) >= db.MaxPrideFlags { + return server.APIError{ + Code: server.ErrFlagLimitReached, + } + } + var found bool + for _, flag := range flags { + if flag.ID == flagID { + found = true + break + } + } + if !found { + return server.APIError{Code: server.ErrNotFound, Details: "No flag with that ID found"} + } + + var req patchUserFlagRequest + err = render.Decode(r, &req) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + if req.Name != nil { + *req.Name = strings.TrimSpace(*req.Name) + } + if req.Description != nil { + *req.Description = strings.TrimSpace(*req.Description) + } + + if req.Name == nil && req.Description == nil { + return server.APIError{Code: server.ErrBadRequest, Details: "Request cannot be empty"} + } + + if s := common.StringLength(req.Name); s > db.MaxPrideFlagTitleLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("name too long, must be %v characters or less, is %v", db.MaxPrideFlagTitleLength, s), + } + } + if s := common.StringLength(req.Description); s > db.MaxPrideFlagDescLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("description too long, must be %v characters or less, is %v", db.MaxPrideFlagDescLength, s), + } + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return errors.Wrap(err, "beginning transaction") + } + defer tx.Rollback(ctx) + + flag, err := s.DB.EditFlag(ctx, tx, flagID, req.Name, req.Description, nil) + if err != nil { + return errors.Wrap(err, "updating flag") + } + + err = tx.Commit(ctx) + if err != nil { + return errors.Wrap(err, "committing transaction") + } + + render.JSON(w, r, flag) + return nil +} + +func (s *Server) deleteUserFlag(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + if !claims.TokenWrite { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"} + } + + flagID, err := xid.FromString(chi.URLParam(r, "flagID")) + if err != nil { + return server.APIError{Code: server.ErrNotFound, Details: "Invalid flag ID"} + } + + flag, err := s.DB.UserFlag(ctx, flagID) + if err != nil { + if err == db.ErrFlagNotFound { + return server.APIError{Code: server.ErrNotFound, Details: "Flag not found"} + } + + return errors.Wrap(err, "getting flag object") + } + if flag.UserID != claims.UserID { + return server.APIError{Code: server.ErrNotFound, Details: "Flag not found"} + } + + err = s.DB.DeleteFlag(ctx, flag.ID, flag.Hash) + if err != nil { + return errors.Wrap(err, "deleting flag") + } + + render.NoContent(w, r) + return nil +} diff --git a/backend/routes/user/get_user.go b/backend/routes/user/get_user.go new file mode 100644 index 0000000..f6a9aae --- /dev/null +++ b/backend/routes/user/get_user.go @@ -0,0 +1,213 @@ +package user + +import ( + "net/http" + "time" + + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/rs/xid" +) + +type GetUserResponse struct { + ID xid.ID `json:"id"` + SID string `json:"sid"` + Username string `json:"name"` + DisplayName *string `json:"display_name"` + Bio *string `json:"bio"` + MemberTitle *string `json:"member_title"` + Avatar *string `json:"avatar"` + Links []string `json:"links"` + Names []db.FieldEntry `json:"names"` + Pronouns []db.PronounEntry `json:"pronouns"` + Members []PartialMember `json:"members"` + Fields []db.Field `json:"fields"` + CustomPreferences db.CustomPreferences `json:"custom_preferences"` + Flags []db.UserFlag `json:"flags"` + Badges db.Badge `json:"badges"` +} + +type GetMeResponse struct { + GetUserResponse + + CreatedAt time.Time `json:"created_at"` + + MaxInvites int `json:"max_invites"` + IsAdmin bool `json:"is_admin"` + ListPrivate bool `json:"list_private"` + LastSIDReroll time.Time `json:"last_sid_reroll"` + + Discord *string `json:"discord"` + DiscordUsername *string `json:"discord_username"` + + Tumblr *string `json:"tumblr"` + TumblrUsername *string `json:"tumblr_username"` + + Google *string `json:"google"` + GoogleUsername *string `json:"google_username"` + + Fediverse *string `json:"fediverse"` + FediverseUsername *string `json:"fediverse_username"` + FediverseInstance *string `json:"fediverse_instance"` +} + +type PartialMember struct { + ID xid.ID `json:"id"` + SID string `json:"sid"` + Name string `json:"name"` + DisplayName *string `json:"display_name"` + Bio *string `json:"bio"` + Avatar *string `json:"avatar"` + Links []string `json:"links"` + Names []db.FieldEntry `json:"names"` + Pronouns []db.PronounEntry `json:"pronouns"` +} + +func dbUserToResponse(u db.User, fields []db.Field, members []db.Member, flags []db.UserFlag) GetUserResponse { + resp := GetUserResponse{ + ID: u.ID, + SID: u.SID, + Username: u.Username, + DisplayName: u.DisplayName, + Bio: u.Bio, + MemberTitle: u.MemberTitle, + Avatar: u.Avatar, + Links: db.NotNull(u.Links), + Names: db.NotNull(u.Names), + Pronouns: db.NotNull(u.Pronouns), + Fields: db.NotNull(fields), + CustomPreferences: u.CustomPreferences, + Flags: flags, + } + + if u.IsAdmin { + resp.Badges |= db.BadgeAdmin + } + + resp.Members = make([]PartialMember, len(members)) + for i := range members { + resp.Members[i] = PartialMember{ + ID: members[i].ID, + SID: members[i].SID, + Name: members[i].Name, + DisplayName: members[i].DisplayName, + Bio: members[i].Bio, + Avatar: members[i].Avatar, + Links: db.NotNull(members[i].Links), + Names: db.NotNull(members[i].Names), + Pronouns: db.NotNull(members[i].Pronouns), + } + } + + return resp +} + +func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err error) { + ctx := r.Context() + + userRef := chi.URLParamFromCtx(ctx, "userRef") + + var u db.User + if id, err := xid.FromString(userRef); err == nil { + u, err = s.DB.User(ctx, id) + if err != nil { + log.Errorf("getting user by ID: %v", err) + } + } + + if u.ID.IsNil() { + u, err = s.DB.Username(ctx, userRef) + if err == db.ErrUserNotFound { + return server.APIError{ + Code: server.ErrUserNotFound, + } + } else if err != nil { + log.Errorf("Error getting user by username: %v", err) + return err + } + } + + if u.DeletedAt != nil { + return server.APIError{Code: server.ErrUserNotFound} + } + + isSelf := false + if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID { + isSelf = true + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + log.Errorf("Error getting user fields: %v", err) + return err + } + + flags, err := s.DB.UserFlags(ctx, u.ID) + if err != nil { + log.Errorf("getting user flags: %v", err) + return err + } + + var members []db.Member + if !u.ListPrivate || isSelf { + members, err = s.DB.UserMembers(ctx, u.ID, isSelf) + if err != nil { + log.Errorf("Error getting user members: %v", err) + return err + } + } + + render.JSON(w, r, dbUserToResponse(u, fields, members, flags)) + return nil +} + +func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + log.Errorf("Error getting user: %v", err) + return err + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + log.Errorf("Error getting user fields: %v", err) + return err + } + + members, err := s.DB.UserMembers(ctx, u.ID, true) + if err != nil { + log.Errorf("Error getting user members: %v", err) + return err + } + + flags, err := s.DB.UserFlags(ctx, u.ID) + if err != nil { + log.Errorf("getting user flags: %v", err) + return err + } + + render.JSON(w, r, GetMeResponse{ + GetUserResponse: dbUserToResponse(u, fields, members, flags), + CreatedAt: u.ID.Time(), + MaxInvites: u.MaxInvites, + IsAdmin: u.IsAdmin, + ListPrivate: u.ListPrivate, + LastSIDReroll: u.LastSIDReroll, + Discord: u.Discord, + DiscordUsername: u.DiscordUsername, + Tumblr: u.Tumblr, + TumblrUsername: u.TumblrUsername, + Google: u.Google, + GoogleUsername: u.GoogleUsername, + Fediverse: u.Fediverse, + FediverseUsername: u.FediverseUsername, + FediverseInstance: u.FediverseInstance, + }) + return nil +} diff --git a/backend/routes/user/patch_user.go b/backend/routes/user/patch_user.go new file mode 100644 index 0000000..716dcca --- /dev/null +++ b/backend/routes/user/patch_user.go @@ -0,0 +1,394 @@ +package user + +import ( + "fmt" + "net/http" + "time" + + "codeberg.org/pronounscc/pronouns.cc/backend/common" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/render" + "github.com/google/uuid" + "github.com/rs/xid" +) + +type PatchUserRequest struct { + Username *string `json:"name"` + DisplayName *string `json:"display_name"` + Bio *string `json:"bio"` + MemberTitle *string `json:"member_title"` + Links *[]string `json:"links"` + Names *[]db.FieldEntry `json:"names"` + Pronouns *[]db.PronounEntry `json:"pronouns"` + Fields *[]db.Field `json:"fields"` + Avatar *string `json:"avatar"` + ListPrivate *bool `json:"list_private"` + CustomPreferences *db.CustomPreferences `json:"custom_preferences"` + Flags *[]xid.ID `json:"flags"` +} + +// patchUser parses a PatchUserRequest and updates the user with the given ID. +func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + claims, _ := server.ClaimsFromContext(ctx) + + if !claims.TokenWrite { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"} + } + + var req PatchUserRequest + err := render.Decode(r, &req) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + // get existing user, for comparison later + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting existing user") + } + + // validate that *something* is set + if req.Username == nil && + req.DisplayName == nil && + req.Bio == nil && + req.MemberTitle == nil && + req.ListPrivate == nil && + req.Links == nil && + req.Fields == nil && + req.Names == nil && + req.Pronouns == nil && + req.Avatar == nil && + req.CustomPreferences == nil && + req.Flags == nil { + return server.APIError{ + Code: server.ErrBadRequest, + Details: "Data must not be empty", + } + } + + // validate display name/bio + if common.StringLength(req.Username) > db.MaxUsernameLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Name name too long (max %d, current %d)", db.MaxUsernameLength, common.StringLength(req.Username)), + } + } + if common.StringLength(req.DisplayName) > db.MaxDisplayNameLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Display name too long (max %d, current %d)", db.MaxDisplayNameLength, common.StringLength(req.DisplayName)), + } + } + if common.StringLength(req.Bio) > db.MaxUserBioLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Bio too long (max %d, current %d)", db.MaxUserBioLength, common.StringLength(req.Bio)), + } + } + + // validate links + if req.Links != nil { + if len(*req.Links) > db.MaxUserLinksLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Too many links (max %d, current %d)", db.MaxUserLinksLength, len(*req.Links)), + } + } + + for i, link := range *req.Links { + if len(link) > db.MaxLinkLength { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Link %d too long (max %d, current %d)", i, db.MaxLinkLength, len(link)), + } + } + } + } + + // validate flag length + if req.Flags != nil { + if len(*req.Flags) > db.MaxPrideFlags { + return server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Too many flags (max %d, current %d)", len(*req.Flags), db.MaxPrideFlags), + } + } + } + + // validate custom preferences + if req.CustomPreferences != nil { + if count := len(*req.CustomPreferences); count > db.MaxFields { + return server.APIError{Code: server.ErrBadRequest, Details: fmt.Sprintf("Too many custom preferences (max %d, current %d)", db.MaxFields, count)} + } + + for k, v := range *req.CustomPreferences { + _, err := uuid.Parse(k) + if err != nil { + return server.APIError{Code: server.ErrBadRequest, Details: "One or more custom preference IDs is not a UUID."} + } + if s := v.Validate(); s != "" { + return server.APIError{Code: server.ErrBadRequest, Details: s} + } + } + } + customPreferences := u.CustomPreferences + if req.CustomPreferences != nil { + customPreferences = *req.CustomPreferences + } + + if err := validateSlicePtr("name", req.Names, customPreferences); err != nil { + return *err + } + + if err := validateSlicePtr("pronoun", req.Pronouns, customPreferences); err != nil { + return *err + } + + if err := validateSlicePtr("field", req.Fields, customPreferences); err != nil { + return *err + } + + // update avatar + var avatarHash *string = nil + if req.Avatar != nil { + if *req.Avatar == "" { + if u.Avatar != nil { + err = s.DB.DeleteUserAvatar(ctx, u.ID, *u.Avatar) + if err != nil { + log.Errorf("deleting user avatar: %v", err) + return errors.Wrap(err, "deleting avatar") + } + } + avatarHash = req.Avatar + } else { + webp, jpg, err := s.DB.ConvertAvatar(*req.Avatar) + if err != nil { + if err == db.ErrInvalidDataURI { + return server.APIError{ + Code: server.ErrBadRequest, + Details: "invalid avatar data URI", + } + } else if err == db.ErrInvalidContentType { + return server.APIError{ + Code: server.ErrBadRequest, + Details: "invalid avatar content type", + } + } + + log.Errorf("converting user avatar: %v", err) + return err + } + + hash, err := s.DB.WriteUserAvatar(ctx, claims.UserID, webp, jpg) + if err != nil { + log.Errorf("uploading user avatar: %v", err) + return err + } + avatarHash = &hash + + // delete current avatar if user has one + if u.Avatar != nil { + err = s.DB.DeleteUserAvatar(ctx, claims.UserID, *u.Avatar) + if err != nil { + log.Errorf("deleting existing avatar: %v", err) + } + } + } + } + + // start transaction + tx, err := s.DB.Begin(ctx) + if err != nil { + log.Errorf("creating transaction: %v", err) + return err + } + defer tx.Rollback(ctx) + + // update username + if req.Username != nil && *req.Username != u.Username { + err = s.DB.UpdateUsername(ctx, tx, claims.UserID, *req.Username) + if err != nil { + switch err { + case db.ErrUsernameTaken: + return server.APIError{Code: server.ErrUsernameTaken} + case db.ErrInvalidUsername: + return server.APIError{Code: server.ErrInvalidUsername} + default: + return errors.Wrap(err, "updating username") + } + } + } + + u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.MemberTitle, req.ListPrivate, req.Links, avatarHash, req.CustomPreferences) + if err != nil && errors.Cause(err) != db.ErrNothingToUpdate { + log.Errorf("updating user: %v", err) + return err + } + + if req.Names != nil || req.Pronouns != nil { + names := u.Names + pronouns := u.Pronouns + + if req.Names != nil { + names = *req.Names + } + if req.Pronouns != nil { + pronouns = *req.Pronouns + } + + err = s.DB.SetUserNamesPronouns(ctx, tx, claims.UserID, names, pronouns) + if err != nil { + log.Errorf("setting names for member %v: %v", claims.UserID, err) + return err + } + u.Names = names + u.Pronouns = pronouns + } + + var fields []db.Field + if req.Fields != nil { + err = s.DB.SetUserFields(ctx, tx, claims.UserID, *req.Fields) + if err != nil { + log.Errorf("setting fields for user %v: %v", claims.UserID, err) + return err + } + fields = *req.Fields + } else { + fields, err = s.DB.UserFields(ctx, claims.UserID) + if err != nil { + log.Errorf("getting fields for user %v: %v", claims.UserID, err) + return err + } + } + + // update flags + if req.Flags != nil { + err = s.DB.SetUserFlags(ctx, tx, claims.UserID, *req.Flags) + if err != nil { + if err == db.ErrInvalidFlagID { + return server.APIError{Code: server.ErrBadRequest, Details: "One or more flag IDs are unknown"} + } + + log.Errorf("updating flags for user %v: %v", claims.UserID, err) + return err + } + } + + // update last active time + err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID) + if err != nil { + log.Errorf("updating last active time for user %v: %v", claims.UserID, err) + return err + } + + err = tx.Commit(ctx) + if err != nil { + log.Errorf("committing transaction: %v", err) + return err + } + + // get fedi instance name if the user has a linked fedi account + var fediInstance *string + if u.FediverseAppID != nil { + app, err := s.DB.FediverseAppByID(ctx, *u.FediverseAppID) + if err == nil { + fediInstance = &app.Instance + } + } + + // get flags to return (we need to return full flag objects, not the array of IDs in the request body) + flags, err := s.DB.UserFlags(ctx, u.ID) + if err != nil { + log.Errorf("getting user flags: %v", err) + return err + } + + // echo the updated user back on success + render.JSON(w, r, GetMeResponse{ + GetUserResponse: dbUserToResponse(u, fields, nil, flags), + MaxInvites: u.MaxInvites, + IsAdmin: u.IsAdmin, + ListPrivate: u.ListPrivate, + LastSIDReroll: u.LastSIDReroll, + Discord: u.Discord, + DiscordUsername: u.DiscordUsername, + Tumblr: u.Tumblr, + TumblrUsername: u.TumblrUsername, + Google: u.Google, + GoogleUsername: u.GoogleUsername, + Fediverse: u.Fediverse, + FediverseUsername: u.FediverseUsername, + FediverseInstance: fediInstance, + }) + return nil +} + +type validator interface { + Validate(custom db.CustomPreferences) string +} + +// validateSlicePtr validates a slice of validators. +// If the slice is nil, a nil error is returned (assuming that the field is not required) +func validateSlicePtr[T validator](typ string, slice *[]T, custom db.CustomPreferences) *server.APIError { + if slice == nil { + return nil + } + + max := db.MaxFields + if typ != "field" { + max = db.FieldEntriesLimit + } + + // max 25 fields + if len(*slice) > max { + return &server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("Too many %ss (max %d, current %d)", typ, max, len(*slice)), + } + } + + // validate all fields + for i, pronouns := range *slice { + if s := pronouns.Validate(custom); s != "" { + return &server.APIError{ + Code: server.ErrBadRequest, + Details: fmt.Sprintf("%s %d: %s", typ, i+1, s), + } + } + } + + return nil +} + +func (s *Server) rerollUserSID(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + claims, _ := server.ClaimsFromContext(ctx) + + if !claims.TokenWrite { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"} + } + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting existing user") + } + + if time.Now().Add(-time.Hour).Before(u.LastSIDReroll) { + return server.APIError{Code: server.ErrRerollingTooQuickly} + } + + newID, err := s.DB.RerollUserSID(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "updating user SID") + } + + u.SID = newID + render.JSON(w, r, dbUserToResponse(u, nil, nil, nil)) + return nil +} diff --git a/backend/routes/user/routes.go b/backend/routes/user/routes.go new file mode 100644 index 0000000..a609e9a --- /dev/null +++ b/backend/routes/user/routes.go @@ -0,0 +1,41 @@ +package user + +import ( + "os" + + "codeberg.org/pronounscc/pronouns.cc/backend/server" + "github.com/go-chi/chi/v5" +) + +type Server struct { + *server.Server + + ExporterPath string +} + +func Mount(srv *server.Server, r chi.Router) { + s := &Server{ + Server: srv, + ExporterPath: "http://127.0.0.1:" + os.Getenv("EXPORTER_PORT"), + } + + r.Route("/users", func(r chi.Router) { + r.Get("/{userRef}", server.WrapHandler(s.getUser)) + + r.With(server.MustAuth).Group(func(r chi.Router) { + r.Get("/@me", server.WrapHandler(s.getMeUser)) + r.Patch("/@me", server.WrapHandler(s.patchUser)) + r.Delete("/@me", server.WrapHandler(s.deleteUser)) + + r.Get("/@me/export/start", server.WrapHandler(s.startExport)) + r.Get("/@me/export", server.WrapHandler(s.getExport)) + + r.Get("/@me/flags", server.WrapHandler(s.getUserFlags)) + r.Post("/@me/flags", server.WrapHandler(s.postUserFlag)) + r.Patch("/@me/flags/{flagID}", server.WrapHandler(s.patchUserFlag)) + r.Delete("/@me/flags/{flagID}", server.WrapHandler(s.deleteUserFlag)) + + r.Get("/@me/reroll", server.WrapHandler(s.rerollUserSID)) + }) + }) +} diff --git a/backend/server/auth.go b/backend/server/auth.go new file mode 100644 index 0000000..636b35e --- /dev/null +++ b/backend/server/auth.go @@ -0,0 +1,89 @@ +package server + +import ( + "context" + "net/http" + "strings" + + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "codeberg.org/pronounscc/pronouns.cc/backend/server/auth" + "github.com/go-chi/render" +) + +// maybeAuth is a globally-used middleware. +func (s *Server) maybeAuth(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if token == "" { + next.ServeHTTP(w, r) + return + } + + claims, err := s.Auth.Claims(token) + if err != nil { + render.Status(r, errCodeStatuses[ErrInvalidToken]) + render.JSON(w, r, APIError{ + Code: ErrInvalidToken, + Message: errCodeMessages[ErrInvalidToken], + }) + return + } + + // "valid" here refers to existence and expiry date, not whether the token is known + valid, err := s.DB.TokenValid(r.Context(), claims.UserID, claims.TokenID) + if err != nil { + log.Errorf("validating token for user %v: %v", claims.UserID, err) + render.Status(r, errCodeStatuses[ErrInternalServerError]) + render.JSON(w, r, APIError{ + Code: ErrInternalServerError, + Message: errCodeMessages[ErrInternalServerError], + }) + return + } + + if !valid { + render.Status(r, errCodeStatuses[ErrInvalidToken]) + render.JSON(w, r, APIError{ + Code: ErrInvalidToken, + Message: errCodeMessages[ErrInvalidToken], + }) + return + } + + ctx := context.WithValue(r.Context(), ctxKeyClaims, claims) + + next.ServeHTTP(w, r.WithContext(ctx)) + } + + return http.HandlerFunc(fn) +} + +// MustAuth makes a valid token required +func MustAuth(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + _, ok := ClaimsFromContext(r.Context()) + if !ok { + render.Status(r, errCodeStatuses[ErrForbidden]) + render.JSON(w, r, APIError{ + Code: ErrForbidden, + Message: errCodeMessages[ErrForbidden], + }) + return + } + + next.ServeHTTP(w, r) + } + + return http.HandlerFunc(fn) +} + +// ClaimsFromContext returns the auth.Claims in the context, if any. +func ClaimsFromContext(ctx context.Context) (auth.Claims, bool) { + v := ctx.Value(ctxKeyClaims) + if v == nil { + return auth.Claims{}, false + } + + claims, ok := v.(auth.Claims) + return claims, ok +} diff --git a/backend/server/auth/auth.go b/backend/server/auth/auth.go new file mode 100644 index 0000000..1a124b0 --- /dev/null +++ b/backend/server/auth/auth.go @@ -0,0 +1,93 @@ +package auth + +import ( + "encoding/base64" + "fmt" + "os" + "time" + + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "emperror.dev/errors" + "github.com/golang-jwt/jwt/v4" + "github.com/rs/xid" +) + +// Claims are the claims used in a token. +type Claims struct { + UserID xid.ID `json:"sub"` + TokenID xid.ID `json:"jti"` + UserIsAdmin bool `json:"adm"` + + // APIToken specifies whether this token was generated for the API or for the website. + // API tokens cannot perform some destructive actions, such as DELETE /users/@me. + APIToken bool `json:"atn"` + // TokenWrite specifies whether this token can be used for write actions. + // If set to false, this token can only be used for read actions. + TokenWrite bool `json:"twr"` + + jwt.RegisteredClaims +} + +type Verifier struct { + key []byte +} + +func New() *Verifier { + raw := os.Getenv("HMAC_KEY") + if raw == "" { + log.Fatal("$HMAC_KEY is not set") + } + + key, err := base64.URLEncoding.DecodeString(raw) + if err != nil { + log.Fatal("$HMAC_KEY is not a valid base 64 string") + } + + return &Verifier{key: key} +} + +// CreateToken creates a token for the given user ID. +// It expires after three months. +func (v *Verifier) CreateToken(userID, tokenID xid.ID, isAdmin bool, isAPIToken bool, isWriteToken bool) (token string, err error) { + now := time.Now() + expires := now.Add(db.TokenExpiryTime) + + t := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{ + UserID: userID, + TokenID: tokenID, + UserIsAdmin: isAdmin, + APIToken: isAPIToken, + TokenWrite: isWriteToken, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "pronouns", + ExpiresAt: jwt.NewNumericDate(expires), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + }, + }) + + return t.SignedString(v.key) +} + +// Claims parses the given token and returns its Claims. +// If the token is invalid, returns an error. +func (v *Verifier) Claims(token string) (c Claims, err error) { + parsed, err := jwt.ParseWithClaims(token, &Claims{}, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf(`unexpected signing method "%v"`, t.Header["alg"]) + } + + return v.key, nil + }) + + if err != nil { + return c, errors.Wrap(err, "parsing token") + } + + if c, ok := parsed.Claims.(*Claims); ok && parsed.Valid { + return *c, nil + } + + return c, fmt.Errorf("unknown claims type %T", parsed.Claims) +} diff --git a/backend/server/errors.go b/backend/server/errors.go new file mode 100644 index 0000000..b4b8b07 --- /dev/null +++ b/backend/server/errors.go @@ -0,0 +1,207 @@ +package server + +import ( + "fmt" + "net/http" + + "codeberg.org/pronounscc/pronouns.cc/backend/log" + "github.com/go-chi/render" +) + +// WrapHandler wraps a modified http.HandlerFunc into a stdlib-compatible one. +// The inner HandlerFunc additionally returns an error. +func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + err := hn(w, r) + if err != nil { + // if the function returned an API error, just render that verbatim + // we can assume that it also logged the error (if that was needed) + if apiErr, ok := err.(APIError); ok { + apiErr.prepare() + + render.Status(r, apiErr.Status) + render.JSON(w, r, apiErr) + return + } + + // otherwise, we log the error and return an internal server error message + log.Errorf("error in http handler: %v", err) + + apiErr := APIError{Code: ErrInternalServerError} + apiErr.prepare() + + render.Status(r, apiErr.Status) + render.JSON(w, r, apiErr) + } + } +} + +// APIError is an object returned by the API when an error occurs. +// It implements the error interface and can be returned by handlers. +type APIError struct { + Code int `json:"code"` + Message string `json:"message,omitempty"` + Details string `json:"details,omitempty"` + + RatelimitReset *int `json:"ratelimit_reset,omitempty"` + + // Status is set as the HTTP status code. + Status int `json:"-"` +} + +func (e APIError) Error() string { + if e.Message == "" { + e.Message = errCodeMessages[e.Code] + } + + if e.Details != "" { + return fmt.Sprintf("%s (code: %d) (%s)", e.Message, e.Code, e.Details) + } + + return fmt.Sprintf("%s (code: %d)", e.Message, e.Code) +} + +func (e *APIError) prepare() { + if e.Status == 0 { + e.Status = errCodeStatuses[e.Code] + } + + if e.Message == "" { + e.Message = errCodeMessages[e.Code] + } +} + +// Error code constants +const ( + ErrBadRequest = 400 + ErrForbidden = 403 + ErrNotFound = 404 + ErrMethodNotAllowed = 405 + ErrTooManyRequests = 429 + ErrInternalServerError = 500 // catch-all code for unknown errors + + // Login/authorize error codes + ErrInvalidState = 1001 + ErrInvalidOAuthCode = 1002 + ErrInvalidToken = 1003 // a token was supplied, but it is invalid + ErrInviteRequired = 1004 + ErrInvalidTicket = 1005 // invalid signup ticket + ErrInvalidUsername = 1006 // invalid username (when signing up) + ErrUsernameTaken = 1007 // username taken (when signing up) + ErrInvitesDisabled = 1008 // invites are disabled (unneeded) + ErrInviteLimitReached = 1009 // invite limit reached (when creating invites) + ErrInviteAlreadyUsed = 1010 // invite already used (when signing up) + ErrDeletionPending = 1011 // own user deletion pending, returned with undo code + ErrRecentExport = 1012 // latest export is too recent + ErrUnsupportedInstance = 1013 // unsupported fediverse software + ErrAlreadyLinked = 1014 // user already has linked account of the same type + ErrNotLinked = 1015 // user already doesn't have a linked account + ErrLastProvider = 1016 // unlinking provider would leave account with no authentication method + ErrInvalidCaptcha = 1017 // invalid or missing captcha response + + // User-related error codes + ErrUserNotFound = 2001 + ErrMemberListPrivate = 2002 + ErrFlagLimitReached = 2003 + ErrRerollingTooQuickly = 2004 + + // Member-related error codes + ErrMemberNotFound = 3001 + ErrMemberLimitReached = 3002 + ErrMemberNameInUse = 3003 + ErrNotOwnMember = 3004 + + // General request error codes + ErrRequestTooBig = 4001 + ErrMissingPermissions = 4002 + + // Moderation related error codes + ErrReportAlreadyHandled = 5001 + ErrNotSelfDelete = 5002 +) + +var errCodeMessages = map[int]string{ + ErrBadRequest: "Bad request", + ErrForbidden: "Forbidden", + ErrInternalServerError: "Internal server error", + ErrNotFound: "Not found", + ErrTooManyRequests: "Rate limit reached", + ErrMethodNotAllowed: "Method not allowed", + + ErrInvalidState: "Invalid OAuth state", + ErrInvalidOAuthCode: "Invalid OAuth code", + ErrInvalidToken: "Supplied token was invalid", + ErrInviteRequired: "A valid invite code is required", + ErrInvalidTicket: "Invalid signup ticket", + ErrInvalidUsername: "Invalid username", + ErrUsernameTaken: "Username is already taken", + ErrInvitesDisabled: "Invites are disabled", + ErrInviteLimitReached: "Your account has reached the invite limit", + ErrInviteAlreadyUsed: "That invite code has already been used", + ErrDeletionPending: "Your account is pending deletion", + ErrRecentExport: "Your latest data export is less than 1 day old", + ErrUnsupportedInstance: "Unsupported instance software", + ErrAlreadyLinked: "Your account is already linked to an account of this type", + ErrNotLinked: "Your account is already not linked to an account of this type", + ErrLastProvider: "This is your account's only authentication provider", + ErrInvalidCaptcha: "Invalid or missing captcha response", + + ErrUserNotFound: "User not found", + ErrMemberListPrivate: "This user's member list is private", + ErrFlagLimitReached: "Maximum number of pride flags reached", + ErrRerollingTooQuickly: "You can only reroll one short ID per hour.", + + ErrMemberNotFound: "Member not found", + ErrMemberLimitReached: "Member limit reached", + ErrMemberNameInUse: "Member name already in use", + ErrNotOwnMember: "Not your member", + + ErrRequestTooBig: "Request too big (max 2 MB)", + ErrMissingPermissions: "Your account or current token is missing required permissions for this action", + + ErrReportAlreadyHandled: "Report has already been resolved", + ErrNotSelfDelete: "Cannot cancel deletion for an account deleted by a moderator", +} + +var errCodeStatuses = map[int]int{ + ErrBadRequest: http.StatusBadRequest, + ErrForbidden: http.StatusForbidden, + ErrInternalServerError: http.StatusInternalServerError, + ErrNotFound: http.StatusNotFound, + ErrTooManyRequests: http.StatusTooManyRequests, + ErrMethodNotAllowed: http.StatusMethodNotAllowed, + + ErrInvalidState: http.StatusBadRequest, + ErrInvalidOAuthCode: http.StatusForbidden, + ErrInvalidToken: http.StatusUnauthorized, + ErrInviteRequired: http.StatusBadRequest, + ErrInvalidTicket: http.StatusBadRequest, + ErrInvalidUsername: http.StatusBadRequest, + ErrUsernameTaken: http.StatusBadRequest, + ErrInvitesDisabled: http.StatusForbidden, + ErrInviteLimitReached: http.StatusForbidden, + ErrInviteAlreadyUsed: http.StatusBadRequest, + ErrDeletionPending: http.StatusBadRequest, + ErrRecentExport: http.StatusBadRequest, + ErrUnsupportedInstance: http.StatusBadRequest, + ErrAlreadyLinked: http.StatusBadRequest, + ErrNotLinked: http.StatusBadRequest, + ErrLastProvider: http.StatusBadRequest, + ErrInvalidCaptcha: http.StatusBadRequest, + + ErrUserNotFound: http.StatusNotFound, + ErrMemberListPrivate: http.StatusForbidden, + ErrFlagLimitReached: http.StatusBadRequest, + ErrRerollingTooQuickly: http.StatusForbidden, + + ErrMemberNotFound: http.StatusNotFound, + ErrMemberLimitReached: http.StatusBadRequest, + ErrMemberNameInUse: http.StatusBadRequest, + ErrNotOwnMember: http.StatusForbidden, + + ErrRequestTooBig: http.StatusBadRequest, + ErrMissingPermissions: http.StatusForbidden, + + ErrReportAlreadyHandled: http.StatusBadRequest, + ErrNotSelfDelete: http.StatusForbidden, +} diff --git a/backend/server/rate/rate.go b/backend/server/rate/rate.go new file mode 100644 index 0000000..07f74d7 --- /dev/null +++ b/backend/server/rate/rate.go @@ -0,0 +1,96 @@ +package rate + +import ( + "net/http" + "os" + "sort" + "strings" + "time" + + "github.com/go-chi/httprate" + "github.com/gobwas/glob" +) + +type Limiter struct { + scopes []*scopedLimiter + defaultLimiter func(http.Handler) http.Handler + + windowLength time.Duration + options []httprate.Option + + wildcardScopes []*scopedLimiter + frontendIP string +} + +type scopedLimiter struct { + Method, Pattern string + + glob glob.Glob + handler func(http.Handler) http.Handler +} + +func NewLimiter(defaultLimit int, windowLength time.Duration, options ...httprate.Option) *Limiter { + return &Limiter{ + windowLength: windowLength, + options: options, + defaultLimiter: httprate.Limit(defaultLimit, windowLength, options...), + frontendIP: os.Getenv("FRONTEND_IP"), + } +} + +func (l *Limiter) Scope(method, pattern string, requestLimit int) error { + handler := httprate.Limit(requestLimit, l.windowLength, l.options...) + + g, err := glob.Compile("/v*"+pattern, '/') + if err != nil { + return err + } + + if method == "*" { + l.wildcardScopes = append(l.wildcardScopes, &scopedLimiter{method, pattern, g, handler}) + } else { + l.scopes = append(l.scopes, &scopedLimiter{method, pattern, g, handler}) + } + return nil +} + +func (l *Limiter) Handler() func(http.Handler) http.Handler { + sort.Slice(l.scopes, func(i, j int) bool { + len1 := len(strings.Split(l.scopes[i].Pattern, "/")) + len2 := len(strings.Split(l.scopes[j].Pattern, "/")) + + return len1 > len2 + }) + l.scopes = append(l.scopes, l.wildcardScopes...) + + return l.handle +} + +func (l *Limiter) handle(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if l.frontendIP != "" { + ip, err := httprate.KeyByIP(r) + if err == nil && ip == l.frontendIP { + // frontend gets to bypass ratelimit + next.ServeHTTP(w, r) + return + } + } + + for _, s := range l.scopes { + if (r.Method == s.Method || s.Method == "*") && s.glob.Match(r.URL.Path) { + bucket := s.Pattern + if s.Method != "*" { + bucket = s.Method + " " + s.Pattern + } + w.Header().Set("X-RateLimit-Bucket", bucket) + + s.handler(next).ServeHTTP(w, r) + return + } + } + + w.Header().Set("X-RateLimit-Bucket", "/") + l.defaultLimiter(next).ServeHTTP(w, r) + }) +} diff --git a/backend/server/server.go b/backend/server/server.go new file mode 100644 index 0000000..399ead0 --- /dev/null +++ b/backend/server/server.go @@ -0,0 +1,153 @@ +package server + +import ( + "net/http" + "os" + "strconv" + "time" + + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/server/auth" + "codeberg.org/pronounscc/pronouns.cc/backend/server/rate" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" + "github.com/go-chi/httprate" + "github.com/go-chi/render" + chiprometheus "github.com/toshi0607/chi-prometheus" +) + +// Revision is the git commit, filled at build time +var ( + Revision = "[unknown]" + Tag = "[unknown]" +) + +// Repository is the URL of the git repository +const Repository = "https://codeberg.org/pronounscc/pronouns.cc" + +type Server struct { + Router *chi.Mux + + DB *db.DB + Auth *auth.Verifier +} + +func New() (*Server, error) { + db, err := db.New() + if err != nil { + return nil, err + } + + s := &Server{ + Router: chi.NewMux(), + + DB: db, + Auth: auth.New(), + } + + if os.Getenv("DEBUG") == "true" { + s.Router.Use(middleware.Logger) + } + s.Router.Use(middleware.Recoverer) + // add CORS + s.Router.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{"https://*", "http://*"}, + // Allow all methods normally used by the API + AllowedMethods: []string{"HEAD", "GET", "POST", "PATCH", "DELETE"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, + AllowCredentials: false, + MaxAge: 300, + })) + + // enable request latency tracking + os.Setenv(chiprometheus.EnvChiPrometheusLatencyBuckets, "10,25,50,100,300,500,1000,5000") + prom := chiprometheus.New("pronouns.cc") + s.Router.Use(prom.Handler) + prom.MustRegisterDefault() + + // enable authentication for all routes (but don't require it) + s.Router.Use(s.maybeAuth) + + // rate limit handling + // - base is 120 req/minute (2/s) + // - keyed by Authorization header if valid token is provided, otherwise by IP + // - returns rate limit reset info in error + rateLimiter := rate.NewLimiter(120, time.Minute, + httprate.WithKeyFuncs(func(r *http.Request) (string, error) { + _, ok := ClaimsFromContext(r.Context()) + if token := r.Header.Get("Authorization"); ok && token != "" { + return token, nil + } + + ip, err := httprate.KeyByIP(r) + return ip, err + }), + httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) { + reset, _ := strconv.Atoi(w.Header().Get("X-RateLimit-Reset")) + + render.Status(r, http.StatusTooManyRequests) + render.JSON(w, r, APIError{ + Code: ErrTooManyRequests, + Message: errCodeMessages[ErrTooManyRequests], + RatelimitReset: &reset, + }) + }), + ) + + // set scopes + // users + rateLimiter.Scope("GET", "/users/*", 60) + rateLimiter.Scope("PATCH", "/users/@me", 10) + + // members + rateLimiter.Scope("GET", "/users/*/members", 60) + rateLimiter.Scope("GET", "/users/*/members/*", 60) + + rateLimiter.Scope("POST", "/members", 10) + rateLimiter.Scope("GET", "/members/*", 60) + rateLimiter.Scope("PATCH", "/members/*", 20) + rateLimiter.Scope("DELETE", "/members/*", 5) + + // auth + rateLimiter.Scope("*", "/auth/*", 20) + rateLimiter.Scope("*", "/auth/tokens", 10) + rateLimiter.Scope("*", "/auth/invites", 10) + rateLimiter.Scope("POST", "/auth/discord/*", 10) + + s.Router.Use(rateLimiter.Handler()) + + // increment the total requests counter whenever a request is made + s.Router.Use(func(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + s.DB.TotalRequests.Inc() + next.ServeHTTP(w, r) + } + return http.HandlerFunc(fn) + }) + + // return an API error for not found + method not allowed + s.Router.NotFound(func(w http.ResponseWriter, r *http.Request) { + render.Status(r, errCodeStatuses[ErrNotFound]) + render.JSON(w, r, APIError{ + Code: ErrNotFound, + Message: errCodeMessages[ErrNotFound], + }) + }) + + s.Router.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) { + render.Status(r, errCodeStatuses[ErrMethodNotAllowed]) + render.JSON(w, r, APIError{ + Code: ErrMethodNotAllowed, + Message: errCodeMessages[ErrMethodNotAllowed], + }) + }) + + return s, nil +} + +type ctxKey int + +const ( + ctxKeyClaims ctxKey = 1 +) diff --git a/docs/Caddyfile b/docs/Caddyfile new file mode 100644 index 0000000..d8649e9 --- /dev/null +++ b/docs/Caddyfile @@ -0,0 +1,12 @@ +http://pronouns.local { + handle /media* { + uri path_regexp ^/media /pronouns.cc + reverse_proxy localhost:9000 + } + + handle_path /api* { + reverse_proxy localhost:8080 + } + + reverse_proxy localhost:5173 +} diff --git a/docs/production.md b/docs/production.md new file mode 100644 index 0000000..9afe530 --- /dev/null +++ b/docs/production.md @@ -0,0 +1,68 @@ +# Running pronouns.cc in production + +The configuration files in this directory are the same files used to run pronouns.cc in production. +You might have to change paths and ports, but they should work fine as-is. + +## Building pronouns.cc + +```bash +git clone https://codeberg.org/pronounscc/pronouns.cc.git pronouns +cd pronouns +git checkout stable +make all + +# if running for the first time +./pronouns database migrate +``` + +## Configuration + +pronouns.cc is configured using `.env` files. Note that there are two separate `.env` files, +one in the repository root (for the backend) and one in the frontend directory. + +### Backend keys + +- `HMAC_KEY`: the key used to sign tokens. This should be a base64 string, you can generate one with `go run -v . generate key` (or `./pronouns generate key` after building). +- `DATABASE_URL`: the URL for the PostgreSQL database. +- `REDIS`: the URL for the Redis database. +- `PORT` (int): the port the backend will listen on. +- `EXPORTER_PORT` (int): the port that the exporter service will listen on. +- `DEBUG` (true/false): whether to enable request logging. +- `BASE_URL`: the base URL for the frontend, used to construct some links. +- `MINIO_ENDPOINT`: the S3 endpoint for object storage. +- `MINIO_BUCKET`: the S3 bucket name. +- `MINIO_ACCESS_KEY_ID`: the S3 access key ID. +- `MINIO_ACCESS_KEY_SECRET`: the S3 access key secret. +- `MINIO_SSL`: whether to use SSL for S3. +- `FRONTEND_IP`: the IP for the frontend, which the rate limiter will ignore. +- `REQUIRE_INVITE`: whether to require invites to sign up. +- `DISCORD_CLIENT_ID`: for Discord auth, the client ID. +- `DISCORD_CLIENT_SECRET`: for Discord auth, the client secret. +- `DISCORD_PUBLIC_KEY`: public key for the Discord bot endpoint. + +### Frontend keys + +- `PUBLIC_BASE_URL`: the base URL for the frontend. +- `PRIVATE_SENTRY_DSN`: your Sentry DSN. +- `PUBLIC_MEDIA_URL`: the base URL for media. + If you're proxying your media through nginx as in `pronounscc.nginx`, set this to `$PUBLIC_BASE_URL/media`. + +## Updating + +```bash +make all +systemctl stop pronouns-api pronouns-fe +systemctl stop pronouns-exporter # only if the User, Member, Field, Export tables changed +./pronouns database migrate # only if a new migration was added +systemctl start pronouns-api pronouns-fe +systemctl start pronouns-exporter # if the exporter was stopped +``` + +## Proxy + +Both the backend and frontend are expected to run behind a reverse proxy such as Caddy or nginx. +This directory contains a sample configuration file for nginx. + +Every path should be proxied to the frontend, except for `/api/`: +this should be proxied to the backend, with the URL being rewritten to remove `/api` +(for example, a request to `$DOMAIN/api/v1/users/@me` should be proxied to `localhost:8080/v1/users/@me`) diff --git a/docs/pronouns-api.service b/docs/pronouns-api.service new file mode 100644 index 0000000..3f90b0b --- /dev/null +++ b/docs/pronouns-api.service @@ -0,0 +1,19 @@ +[Unit] +Description=pronouns.cc API +After=syslog.target +After=network.target +Requires=postgresql.service redis.service + +[Service] +RestartSec=2s +Type=simple +User=pronouns +Group=pronouns +AmbientCapabilities= +WorkingDirectory=/home/pronouns/src +ExecStart=/home/pronouns/src/pronouns web +Restart=always +Environment=USER=pronouns HOME=/home/pronouns + +[Install] +WantedBy=multi-user.target diff --git a/docs/pronouns-clean.service b/docs/pronouns-clean.service new file mode 100644 index 0000000..f254ed6 --- /dev/null +++ b/docs/pronouns-clean.service @@ -0,0 +1,19 @@ +[Unit] +Description=Clean pronouns.cc database +After=syslog.target +After=network.target +Requires=postgresql.service redis.service + +[Service] +RestartSec=2s +Type=oneshot +User=pronouns +Group=pronouns +AmbientCapabilities= +WorkingDirectory=/home/pronouns/src +ExecStart=/home/pronouns/src/pronouns database clean +Restart=no +Environment=USER=pronouns HOME=/home/pronouns + +[Install] +WantedBy=multi-user.target diff --git a/docs/pronouns-clean.timer b/docs/pronouns-clean.timer new file mode 100644 index 0000000..d53de1c --- /dev/null +++ b/docs/pronouns-clean.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Clean pronouns.cc database daily + +[Timer] +OnCalendar=daily +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/docs/pronouns-exporter.service b/docs/pronouns-exporter.service new file mode 100644 index 0000000..4e53821 --- /dev/null +++ b/docs/pronouns-exporter.service @@ -0,0 +1,19 @@ +[Unit] +Description=pronouns.cc data exporter service +After=syslog.target +After=network.target +Requires=postgresql.service redis.service + +[Service] +RestartSec=2s +Type=simple +User=pronouns +Group=pronouns +AmbientCapabilities= +WorkingDirectory=/home/pronouns/src +ExecStart=/home/pronouns/src/pronouns exporter +Restart=always +Environment=USER=pronouns HOME=/home/pronouns + +[Install] +WantedBy=multi-user.target diff --git a/docs/pronouns-fe.service b/docs/pronouns-fe.service new file mode 100644 index 0000000..84b6d63 --- /dev/null +++ b/docs/pronouns-fe.service @@ -0,0 +1,19 @@ +[Unit] +Description=pronouns.cc frontend +After=syslog.target +After=network.target +Requires=pronouns-api.service + +[Service] +RestartSec=2s +Type=simple +User=pronouns +Group=pronouns +AmbientCapabilities= +WorkingDirectory=/home/pronouns/src/frontend +ExecStart=node build/index.js +Restart=always +Environment=USER=pronouns HOME=/home/pronouns + +[Install] +WantedBy=multi-user.target diff --git a/docs/pronounscc.nginx b/docs/pronounscc.nginx new file mode 100644 index 0000000..39a1bfc --- /dev/null +++ b/docs/pronounscc.nginx @@ -0,0 +1,82 @@ +server { + server_name example.tld; + + listen 80; + listen [::]:80; + + # For SSL domain validation + root /var/www/html; + location /.well-known/acme-challenge/ { allow all; } + location /.well-known/pki-validation/ { allow all; } + location / { return 301 https://$server_name$request_uri; } +} + +# For media proxy +proxy_cache_path /tmp/pronouns-media-cache levels=1:2 keys_zone=pronouns_media_cache:10m max_size=1g + inactive=720m use_temp_path=off; + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name example.tld; + + ssl_session_timeout 1d; + ssl_session_cache shared:ssl_session_cache:10m; + ssl_session_tickets off; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_stapling on; + ssl_stapling_verify on; + + # To use a Let's Encrypt certificate + ssl_trusted_certificate /etc/letsencrypt/live/example.tld/chain.pem; + ssl_certificate /etc/letsencrypt/live/example.tld/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/example.tld/privkey.pem; + + # To use Debian/Ubuntu's self-signed certificate (For testing or before issuing a certificate) + #ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem; + #ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key; + + client_max_body_size 8m; + + location ~ ^/api { + rewrite ^/api(.*) $1 break; + + proxy_pass http://127.0.0.1:8080; + } + + location ~ ^/media { + proxy_cache pronouns_media_cache; + slice 1m; + proxy_cache_key $host$uri$is_args$args$slice_range; + proxy_set_header Range $slice_range; + proxy_cache_valid 200 206 301 304 1h; + proxy_cache_lock on; + proxy_ignore_client_abort on; + proxy_buffering on; + chunked_transfer_encoding on; + + # Rewrite URL to remove /media/ and add bucket + rewrite ^/media/(.*) /pronouns/$1 break; + + proxy_pass http://127.0.0.1:9000; + } + + location ~ ^/fonts { + expires 1y; + add_header Cache-Control "public, no-transform"; + + proxy_pass http://127.0.0.1:3000; + } + + location / { + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + + proxy_pass http://127.0.0.1:3000; + } +} diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..1463658 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,15 @@ +# Base of frontend URLs +PUBLIC_BASE_URL=http://localhost:5173 + +# Base of media URLs, required for avatars, pride flags, and data exports +# If using the provided nginx reverse proxy config, use `$PUBLIC_BASE_URL/media` +PUBLIC_MEDIA_URL= + +# Base of shortened profile URLs (leave empty to disable) +PUBLIC_SHORT_BASE= + +# hCaptcha configuration (leave empty to disable) +PUBLIC_HCAPTCHA_SITEKEY= + +# Sentry configuration (unused in dev, required in production) +PRIVATE_SENTRY_DSN= diff --git a/frontend/.eslintignore b/frontend/.eslintignore new file mode 100644 index 0000000..3897265 --- /dev/null +++ b/frontend/.eslintignore @@ -0,0 +1,13 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example + +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 0000000..3ccf435 --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -0,0 +1,20 @@ +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], + plugins: ['svelte3', '@typescript-eslint'], + ignorePatterns: ['*.cjs'], + overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }], + settings: { + 'svelte3/typescript': () => require('typescript') + }, + parserOptions: { + sourceType: 'module', + ecmaVersion: 2020 + }, + env: { + browser: true, + es2017: true, + node: true + } +}; diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..3897265 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,13 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example + +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..61c2023 --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,9 @@ +{ + "useTabs": false, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "pluginSearchDirs": ["."], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} diff --git a/frontend/__init__.py b/frontend/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/admin.py b/frontend/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/frontend/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/frontend/apps.py b/frontend/apps.py deleted file mode 100644 index c626efa..0000000 --- a/frontend/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class FrontendConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "frontend" diff --git a/frontend/icons.js b/frontend/icons.js new file mode 100644 index 0000000..6b0e67f --- /dev/null +++ b/frontend/icons.js @@ -0,0 +1,44 @@ +// This script regenerates the list of icons for the frontend (frontend/src/icons.json) +// and the backend (backend/icons/icons.go) from the currently installed version of Bootstrap Icons. +// Run with `pnpm node icons.js` in the frontend directory. + +import { writeFileSync } from "fs"; +import icons from "bootstrap-icons/font/bootstrap-icons.json" assert { type: "json" }; + +const keys = Object.keys(icons); + +console.log(`Found ${keys.length} icons`); +const output = JSON.stringify(keys); +console.log(`Saving file as src/icons.ts`); + +writeFileSync("src/icons.ts", `const icons = ${output};\nexport default icons;`); + +const goCode1 = `// Generated code. DO NOT EDIT +package icons + +var icons = [...]string{ +`; + +const goCode2 = `} + +// IsValid returns true if the input is the name of a Bootstrap icon. +func IsValid(name string) bool { + for i := range icons { + if icons[i] == name { + return true + } + } + return false +} +`; + +let goOutput = goCode1; + +keys.forEach((element) => { + goOutput += ` "${element}",\n`; +}); + +goOutput += goCode2; + +console.log("Writing Go code"); +writeFileSync("../backend/icons/icons.go", goOutput); diff --git a/frontend/migrations/__init__.py b/frontend/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/models.py b/frontend/models.py deleted file mode 100644 index 71a8362..0000000 --- a/frontend/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..80b8720 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,52 @@ +{ + "name": "pronouns-fe", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --plugin-search-dir . --check . && eslint .", + "format": "prettier --plugin-search-dir . --write ." + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^2.0.0", + "@sveltejs/adapter-node": "^1.2.3", + "@sveltejs/kit": "^1.15.0", + "@types/luxon": "^3.2.2", + "@types/markdown-it": "^12.2.3", + "@types/node": "^18.15.11", + "@types/sanitize-html": "^2.9.0", + "@typescript-eslint/eslint-plugin": "^5.57.1", + "@typescript-eslint/parser": "^5.57.1", + "eslint": "^8.37.0", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-svelte3": "^4.0.0", + "prettier": "^2.8.7", + "prettier-plugin-svelte": "^2.10.0", + "svelte": "^3.58.0", + "svelte-check": "^3.1.4", + "svelte-hcaptcha": "^0.1.1", + "sveltestrap": "^5.10.0", + "tslib": "^2.5.0", + "typescript": "^4.9.5", + "vite": "^4.2.1", + "vite-plugin-markdown": "^2.1.0" + }, + "type": "module", + "dependencies": { + "@fontsource/firago": "^4.5.3", + "@popperjs/core": "^2.11.7", + "@sentry/node": "^7.46.0", + "base64-arraybuffer": "^1.0.2", + "bootstrap": "5.3.0-alpha1", + "bootstrap-icons": "^1.10.4", + "jose": "^4.13.1", + "luxon": "^3.3.0", + "markdown-it": "^13.0.1", + "pretty-bytes": "^6.1.0", + "sanitize-html": "^2.10.0" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..67c96b7 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,2288 @@ +lockfileVersion: '6.0' + +dependencies: + '@fontsource/firago': + specifier: ^4.5.3 + version: 4.5.3 + '@popperjs/core': + specifier: ^2.11.7 + version: 2.11.7 + '@sentry/node': + specifier: ^7.46.0 + version: 7.46.0 + base64-arraybuffer: + specifier: ^1.0.2 + version: 1.0.2 + bootstrap: + specifier: 5.3.0-alpha1 + version: 5.3.0-alpha1(@popperjs/core@2.11.7) + bootstrap-icons: + specifier: ^1.10.4 + version: 1.10.4 + jose: + specifier: ^4.13.1 + version: 4.13.1 + luxon: + specifier: ^3.3.0 + version: 3.3.0 + markdown-it: + specifier: ^13.0.1 + version: 13.0.1 + pretty-bytes: + specifier: ^6.1.0 + version: 6.1.0 + sanitize-html: + specifier: ^2.10.0 + version: 2.10.0 + +devDependencies: + '@sveltejs/adapter-auto': + specifier: ^2.0.0 + version: 2.0.0(@sveltejs/kit@1.15.0) + '@sveltejs/adapter-node': + specifier: ^1.2.3 + version: 1.2.3(@sveltejs/kit@1.15.0) + '@sveltejs/kit': + specifier: ^1.15.0 + version: 1.15.0(svelte@3.58.0)(vite@4.2.1) + '@types/luxon': + specifier: ^3.2.2 + version: 3.2.2 + '@types/markdown-it': + specifier: ^12.2.3 + version: 12.2.3 + '@types/node': + specifier: ^18.15.11 + version: 18.15.11 + '@types/sanitize-html': + specifier: ^2.9.0 + version: 2.9.0 + '@typescript-eslint/eslint-plugin': + specifier: ^5.57.1 + version: 5.57.1(@typescript-eslint/parser@5.57.1)(eslint@8.37.0)(typescript@4.9.5) + '@typescript-eslint/parser': + specifier: ^5.57.1 + version: 5.57.1(eslint@8.37.0)(typescript@4.9.5) + eslint: + specifier: ^8.37.0 + version: 8.37.0 + eslint-config-prettier: + specifier: ^8.8.0 + version: 8.8.0(eslint@8.37.0) + eslint-plugin-svelte3: + specifier: ^4.0.0 + version: 4.0.0(eslint@8.37.0)(svelte@3.58.0) + prettier: + specifier: ^2.8.7 + version: 2.8.7 + prettier-plugin-svelte: + specifier: ^2.10.0 + version: 2.10.0(prettier@2.8.7)(svelte@3.58.0) + svelte: + specifier: ^3.58.0 + version: 3.58.0 + svelte-check: + specifier: ^3.1.4 + version: 3.1.4(svelte@3.58.0) + svelte-hcaptcha: + specifier: ^0.1.1 + version: 0.1.1 + sveltestrap: + specifier: ^5.10.0 + version: 5.10.0(svelte@3.58.0) + tslib: + specifier: ^2.5.0 + version: 2.5.0 + typescript: + specifier: ^4.9.5 + version: 4.9.5 + vite: + specifier: ^4.2.1 + version: 4.2.1(@types/node@18.15.11) + vite-plugin-markdown: + specifier: ^2.1.0 + version: 2.1.0(vite@4.2.1) + +packages: + + /@esbuild/android-arm64@0.17.15: + resolution: {integrity: sha512-0kOB6Y7Br3KDVgHeg8PRcvfLkq+AccreK///B4Z6fNZGr/tNHX0z2VywCc7PTeWp+bPvjA5WMvNXltHw5QjAIA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm@0.17.15: + resolution: {integrity: sha512-sRSOVlLawAktpMvDyJIkdLI/c/kdRTOqo8t6ImVxg8yT7LQDUYV5Rp2FKeEosLr6ZCja9UjYAzyRSxGteSJPYg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-x64@0.17.15: + resolution: {integrity: sha512-MzDqnNajQZ63YkaUWVl9uuhcWyEyh69HGpMIrf+acR4otMkfLJ4sUCxqwbCyPGicE9dVlrysI3lMcDBjGiBBcQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-arm64@0.17.15: + resolution: {integrity: sha512-7siLjBc88Z4+6qkMDxPT2juf2e8SJxmsbNVKFY2ifWCDT72v5YJz9arlvBw5oB4W/e61H1+HDB/jnu8nNg0rLA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-x64@0.17.15: + resolution: {integrity: sha512-NbImBas2rXwYI52BOKTW342Tm3LTeVlaOQ4QPZ7XuWNKiO226DisFk/RyPk3T0CKZkKMuU69yOvlapJEmax7cg==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-arm64@0.17.15: + resolution: {integrity: sha512-Xk9xMDjBVG6CfgoqlVczHAdJnCs0/oeFOspFap5NkYAmRCT2qTn1vJWA2f419iMtsHSLm+O8B6SLV/HlY5cYKg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-x64@0.17.15: + resolution: {integrity: sha512-3TWAnnEOdclvb2pnfsTWtdwthPfOz7qAfcwDLcfZyGJwm1SRZIMOeB5FODVhnM93mFSPsHB9b/PmxNNbSnd0RQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm64@0.17.15: + resolution: {integrity: sha512-T0MVnYw9KT6b83/SqyznTs/3Jg2ODWrZfNccg11XjDehIved2oQfrX/wVuev9N936BpMRaTR9I1J0tdGgUgpJA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm@0.17.15: + resolution: {integrity: sha512-MLTgiXWEMAMr8nmS9Gigx43zPRmEfeBfGCwxFQEMgJ5MC53QKajaclW6XDPjwJvhbebv+RzK05TQjvH3/aM4Xw==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ia32@0.17.15: + resolution: {integrity: sha512-wp02sHs015T23zsQtU4Cj57WiteiuASHlD7rXjKUyAGYzlOKDAjqK6bk5dMi2QEl/KVOcsjwL36kD+WW7vJt8Q==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-loong64@0.17.15: + resolution: {integrity: sha512-k7FsUJjGGSxwnBmMh8d7IbObWu+sF/qbwc+xKZkBe/lTAF16RqxRCnNHA7QTd3oS2AfGBAnHlXL67shV5bBThQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-mips64el@0.17.15: + resolution: {integrity: sha512-ZLWk6czDdog+Q9kE/Jfbilu24vEe/iW/Sj2d8EVsmiixQ1rM2RKH2n36qfxK4e8tVcaXkvuV3mU5zTZviE+NVQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ppc64@0.17.15: + resolution: {integrity: sha512-mY6dPkIRAiFHRsGfOYZC8Q9rmr8vOBZBme0/j15zFUKM99d4ILY4WpOC7i/LqoY+RE7KaMaSfvY8CqjJtuO4xg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-riscv64@0.17.15: + resolution: {integrity: sha512-EcyUtxffdDtWjjwIH8sKzpDRLcVtqANooMNASO59y+xmqqRYBBM7xVLQhqF7nksIbm2yHABptoioS9RAbVMWVA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-s390x@0.17.15: + resolution: {integrity: sha512-BuS6Jx/ezxFuHxgsfvz7T4g4YlVrmCmg7UAwboeyNNg0OzNzKsIZXpr3Sb/ZREDXWgt48RO4UQRDBxJN3B9Rbg==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-x64@0.17.15: + resolution: {integrity: sha512-JsdS0EgEViwuKsw5tiJQo9UdQdUJYuB+Mf6HxtJSPN35vez1hlrNb1KajvKWF5Sa35j17+rW1ECEO9iNrIXbNg==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-x64@0.17.15: + resolution: {integrity: sha512-R6fKjtUysYGym6uXf6qyNephVUQAGtf3n2RCsOST/neIwPqRWcnc3ogcielOd6pT+J0RDR1RGcy0ZY7d3uHVLA==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-x64@0.17.15: + resolution: {integrity: sha512-mVD4PGc26b8PI60QaPUltYKeSX0wxuy0AltC+WCTFwvKCq2+OgLP4+fFd+hZXzO2xW1HPKcytZBdjqL6FQFa7w==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/sunos-x64@0.17.15: + resolution: {integrity: sha512-U6tYPovOkw3459t2CBwGcFYfFRjivcJJc1WC8Q3funIwX8x4fP+R6xL/QuTPNGOblbq/EUDxj9GU+dWKX0oWlQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-arm64@0.17.15: + resolution: {integrity: sha512-W+Z5F++wgKAleDABemiyXVnzXgvRFs+GVKThSI+mGgleLWluv0D7Diz4oQpgdpNzh4i2nNDzQtWbjJiqutRp6Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-ia32@0.17.15: + resolution: {integrity: sha512-Muz/+uGgheShKGqSVS1KsHtCyEzcdOn/W/Xbh6H91Etm+wiIfwZaBn1W58MeGtfI8WA961YMHFYTthBdQs4t+w==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-x64@0.17.15: + resolution: {integrity: sha512-DjDa9ywLUUmjhV2Y9wUTIF+1XsmuFGvZoCmOWkli1XcNAh5t25cc7fgsCx4Zi/Uurep3TTLyDiKATgGEg61pkA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@eslint-community/eslint-utils@4.4.0(eslint@8.37.0): + resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + dependencies: + eslint: 8.37.0 + eslint-visitor-keys: 3.4.0 + dev: true + + /@eslint-community/regexpp@4.5.0: + resolution: {integrity: sha512-vITaYzIcNmjn5tF5uxcZ/ft7/RXGrMUIS9HalWckEOF6ESiwXKoMzAQf2UW0aVd6rnOeExTJVd5hmWXucBKGXQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + dev: true + + /@eslint/eslintrc@2.0.2: + resolution: {integrity: sha512-3W4f5tDUra+pA+FzgugqL2pRimUTDJWKr7BINqOpkZrC0uYI0NIc0/JFgBROCU07HR6GieA5m3/rsPIhDmCXTQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + ajv: 6.12.6 + debug: 4.3.4 + espree: 9.5.1 + globals: 13.20.0 + ignore: 5.2.4 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@eslint/js@8.37.0: + resolution: {integrity: sha512-x5vzdtOOGgFVDCUs81QRB2+liax8rFg3+7hqM+QhBG0/G3F1ZsoYl97UrqgHgQ9KKT7G6c4V+aTUCgu/n22v1A==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + + /@fontsource/firago@4.5.3: + resolution: {integrity: sha512-Kn6FBi6MGNPYwzVKUtgyWGUj0zV18El263ieZi8A7TM2LPKsfzON5ENeKfjyTKfYErkjg9ktwiNP3SLsEOSBNQ==} + dev: false + + /@humanwhocodes/config-array@0.11.8: + resolution: {integrity: sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==} + engines: {node: '>=10.10.0'} + dependencies: + '@humanwhocodes/object-schema': 1.2.1 + debug: 4.3.4 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@humanwhocodes/module-importer@1.0.1: + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + dev: true + + /@humanwhocodes/object-schema@1.2.1: + resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==} + dev: true + + /@jridgewell/resolve-uri@3.1.0: + resolution: {integrity: sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==} + engines: {node: '>=6.0.0'} + dev: true + + /@jridgewell/sourcemap-codec@1.4.14: + resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==} + dev: true + + /@jridgewell/trace-mapping@0.3.17: + resolution: {integrity: sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==} + dependencies: + '@jridgewell/resolve-uri': 3.1.0 + '@jridgewell/sourcemap-codec': 1.4.14 + dev: true + + /@nodelib/fs.scandir@2.1.5: + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + dev: true + + /@nodelib/fs.stat@2.0.5: + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + dev: true + + /@nodelib/fs.walk@1.2.8: + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.15.0 + dev: true + + /@polka/url@1.0.0-next.21: + resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==} + dev: true + + /@popperjs/core@2.11.7: + resolution: {integrity: sha512-Cr4OjIkipTtcXKjAsm8agyleBuDHvxzeBoa1v543lbv1YaIwQjESsVcmjiWiPEbC1FIeHOG/Op9kdCmAmiS3Kw==} + + /@rollup/plugin-commonjs@24.0.1(rollup@3.20.2): + resolution: {integrity: sha512-15LsiWRZk4eOGqvrJyu3z3DaBu5BhXIMeWnijSRvd8irrrg9SHpQ1pH+BUK4H6Z9wL9yOxZJMTLU+Au86XHxow==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.68.0||^3.0.0 + peerDependenciesMeta: + rollup: + optional: true + dependencies: + '@rollup/pluginutils': 5.0.2(rollup@3.20.2) + commondir: 1.0.1 + estree-walker: 2.0.2 + glob: 8.1.0 + is-reference: 1.2.1 + magic-string: 0.27.0 + rollup: 3.20.2 + dev: true + + /@rollup/plugin-json@6.0.0(rollup@3.20.2): + resolution: {integrity: sha512-i/4C5Jrdr1XUarRhVu27EEwjt4GObltD7c+MkCIpO2QIbojw8MUs+CCTqOphQi3Qtg1FLmYt+l+6YeoIf51J7w==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0 + peerDependenciesMeta: + rollup: + optional: true + dependencies: + '@rollup/pluginutils': 5.0.2(rollup@3.20.2) + rollup: 3.20.2 + dev: true + + /@rollup/plugin-node-resolve@15.0.1(rollup@3.20.2): + resolution: {integrity: sha512-ReY88T7JhJjeRVbfCyNj+NXAG3IIsVMsX9b5/9jC98dRP8/yxlZdz7mHZbHk5zHr24wZZICS5AcXsFZAXYUQEg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0 + peerDependenciesMeta: + rollup: + optional: true + dependencies: + '@rollup/pluginutils': 5.0.2(rollup@3.20.2) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-builtin-module: 3.2.1 + is-module: 1.0.0 + resolve: 1.22.1 + rollup: 3.20.2 + dev: true + + /@rollup/pluginutils@5.0.2(rollup@3.20.2): + resolution: {integrity: sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0 + peerDependenciesMeta: + rollup: + optional: true + dependencies: + '@types/estree': 1.0.0 + estree-walker: 2.0.2 + picomatch: 2.3.1 + rollup: 3.20.2 + dev: true + + /@sentry-internal/tracing@7.46.0: + resolution: {integrity: sha512-KYoppa7PPL8Er7bdPoxTNUfIY804JL7hhOEomQHYD22rLynwQ4AaLm3YEY75QWwcGb0B7ZDMV+tSumW7Rxuwuw==} + engines: {node: '>=8'} + dependencies: + '@sentry/core': 7.46.0 + '@sentry/types': 7.46.0 + '@sentry/utils': 7.46.0 + tslib: 1.14.1 + dev: false + + /@sentry/core@7.46.0: + resolution: {integrity: sha512-BnNHGh/ZTztqQedFko7vb2u6yLs/kWesOQNivav32ZbsEpVCjcmG1gOJXh2YmGIvj3jXOC9a4xfIuh+lYFcA6A==} + engines: {node: '>=8'} + dependencies: + '@sentry/types': 7.46.0 + '@sentry/utils': 7.46.0 + tslib: 1.14.1 + dev: false + + /@sentry/node@7.46.0: + resolution: {integrity: sha512-+GrgJMCye2WXGarRiU5IJHCK27xg7xbPc2XjGojBKbBoZfqxVAWbXEK4bnBQgRGP1pCmrU/M6ZhVgR3dP580xA==} + engines: {node: '>=8'} + dependencies: + '@sentry-internal/tracing': 7.46.0 + '@sentry/core': 7.46.0 + '@sentry/types': 7.46.0 + '@sentry/utils': 7.46.0 + cookie: 0.4.2 + https-proxy-agent: 5.0.1 + lru_map: 0.3.3 + tslib: 1.14.1 + transitivePeerDependencies: + - supports-color + dev: false + + /@sentry/types@7.46.0: + resolution: {integrity: sha512-2FMEMgt2h6u7AoELhNhu9L54GAh67KKfK2pJ1kEXJHmWxM9FSCkizjLs/t+49xtY7jEXr8qYq8bV967VfDPQ9g==} + engines: {node: '>=8'} + dev: false + + /@sentry/utils@7.46.0: + resolution: {integrity: sha512-elRezDAF84guMG0OVIIZEWm6wUpgbda4HGks98CFnPsrnMm3N1bdBI9XdlxYLtf+ir5KsGR5YlEIf/a0kRUwAQ==} + engines: {node: '>=8'} + dependencies: + '@sentry/types': 7.46.0 + tslib: 1.14.1 + dev: false + + /@sveltejs/adapter-auto@2.0.0(@sveltejs/kit@1.15.0): + resolution: {integrity: sha512-b+gkHFZgD771kgV3aO4avHFd7y1zhmMYy9i6xOK7m/rwmwaRO8gnF5zBc0Rgca80B2PMU1bKNxyBTHA14OzUAQ==} + peerDependencies: + '@sveltejs/kit': ^1.0.0 + dependencies: + '@sveltejs/kit': 1.15.0(svelte@3.58.0)(vite@4.2.1) + import-meta-resolve: 2.2.2 + dev: true + + /@sveltejs/adapter-node@1.2.3(@sveltejs/kit@1.15.0): + resolution: {integrity: sha512-Fv6NyVpVWYA63KRaV6dDjcU8ytcWFiUr0siJOoHl+oWy5WHNEuRiJOUdiZzYbZo8MmvFaCoxHkTgPrVQhpqaRA==} + peerDependencies: + '@sveltejs/kit': ^1.0.0 + dependencies: + '@rollup/plugin-commonjs': 24.0.1(rollup@3.20.2) + '@rollup/plugin-json': 6.0.0(rollup@3.20.2) + '@rollup/plugin-node-resolve': 15.0.1(rollup@3.20.2) + '@sveltejs/kit': 1.15.0(svelte@3.58.0)(vite@4.2.1) + rollup: 3.20.2 + dev: true + + /@sveltejs/kit@1.15.0(svelte@3.58.0)(vite@4.2.1): + resolution: {integrity: sha512-fvDsW9msxWjDU/j9wwLlxEZ6cpXQYcmcQHq7neJMqibMEl39gI1ztVymGnYqM8KLqZXwNmhKtLu8EPheukKtXQ==} + engines: {node: ^16.14 || >=18} + hasBin: true + requiresBuild: true + peerDependencies: + svelte: ^3.54.0 + vite: ^4.0.0 + dependencies: + '@sveltejs/vite-plugin-svelte': 2.0.4(svelte@3.58.0)(vite@4.2.1) + '@types/cookie': 0.5.1 + cookie: 0.5.0 + devalue: 4.3.0 + esm-env: 1.0.0 + kleur: 4.1.5 + magic-string: 0.30.0 + mime: 3.0.0 + sade: 1.8.1 + set-cookie-parser: 2.6.0 + sirv: 2.0.2 + svelte: 3.58.0 + tiny-glob: 0.2.9 + undici: 5.21.0 + vite: 4.2.1(@types/node@18.15.11) + transitivePeerDependencies: + - supports-color + dev: true + + /@sveltejs/vite-plugin-svelte@2.0.4(svelte@3.58.0)(vite@4.2.1): + resolution: {integrity: sha512-pjqhW00KwK2uzDGEr+yJBwut+D+4XfJO/+bHHdHzPRXn9+1Jeq5JcFHyrUiYaXgHtyhX0RsllCTm4ssAx4ZY7Q==} + engines: {node: ^14.18.0 || >= 16} + peerDependencies: + svelte: ^3.54.0 + vite: ^4.0.0 + dependencies: + debug: 4.3.4 + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.30.0 + svelte: 3.58.0 + svelte-hmr: 0.15.1(svelte@3.58.0) + vite: 4.2.1(@types/node@18.15.11) + vitefu: 0.2.4(vite@4.2.1) + transitivePeerDependencies: + - supports-color + dev: true + + /@types/cookie@0.5.1: + resolution: {integrity: sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==} + dev: true + + /@types/estree@1.0.0: + resolution: {integrity: sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==} + dev: true + + /@types/json-schema@7.0.11: + resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==} + dev: true + + /@types/linkify-it@3.0.2: + resolution: {integrity: sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA==} + dev: true + + /@types/luxon@3.2.2: + resolution: {integrity: sha512-CuF9hIlsxGpJO4EztrW23/q1L9ctQfb5JM9mnLCJhhA8z81K2b4LTVjQYySXWhFV5SMyUsPYH/IcvvXDCKwa2g==} + dev: true + + /@types/markdown-it@12.2.3: + resolution: {integrity: sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==} + dependencies: + '@types/linkify-it': 3.0.2 + '@types/mdurl': 1.0.2 + dev: true + + /@types/mdurl@1.0.2: + resolution: {integrity: sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==} + dev: true + + /@types/node@18.15.11: + resolution: {integrity: sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==} + dev: true + + /@types/pug@2.0.6: + resolution: {integrity: sha512-SnHmG9wN1UVmagJOnyo/qkk0Z7gejYxOYYmaAwr5u2yFYfsupN3sg10kyzN8Hep/2zbHxCnsumxOoRIRMBwKCg==} + dev: true + + /@types/resolve@1.20.2: + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + dev: true + + /@types/sanitize-html@2.9.0: + resolution: {integrity: sha512-4fP/kEcKNj2u39IzrxWYuf/FnCCwwQCpif6wwY6ROUS1EPRIfWJjGkY3HIowY1EX/VbX5e86yq8AAE7UPMgATg==} + dependencies: + htmlparser2: 8.0.2 + dev: true + + /@types/semver@7.3.13: + resolution: {integrity: sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==} + dev: true + + /@typescript-eslint/eslint-plugin@5.57.1(@typescript-eslint/parser@5.57.1)(eslint@8.37.0)(typescript@4.9.5): + resolution: {integrity: sha512-1MeobQkQ9tztuleT3v72XmY0XuKXVXusAhryoLuU5YZ+mXoYKZP9SQ7Flulh1NX4DTjpGTc2b/eMu4u7M7dhnQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + '@typescript-eslint/parser': ^5.0.0 + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@eslint-community/regexpp': 4.5.0 + '@typescript-eslint/parser': 5.57.1(eslint@8.37.0)(typescript@4.9.5) + '@typescript-eslint/scope-manager': 5.57.1 + '@typescript-eslint/type-utils': 5.57.1(eslint@8.37.0)(typescript@4.9.5) + '@typescript-eslint/utils': 5.57.1(eslint@8.37.0)(typescript@4.9.5) + debug: 4.3.4 + eslint: 8.37.0 + grapheme-splitter: 1.0.4 + ignore: 5.2.4 + natural-compare-lite: 1.4.0 + semver: 7.3.8 + tsutils: 3.21.0(typescript@4.9.5) + typescript: 4.9.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/parser@5.57.1(eslint@8.37.0)(typescript@4.9.5): + resolution: {integrity: sha512-hlA0BLeVSA/wBPKdPGxoVr9Pp6GutGoY380FEhbVi0Ph4WNe8kLvqIRx76RSQt1lynZKfrXKs0/XeEk4zZycuA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/scope-manager': 5.57.1 + '@typescript-eslint/types': 5.57.1 + '@typescript-eslint/typescript-estree': 5.57.1(typescript@4.9.5) + debug: 4.3.4 + eslint: 8.37.0 + typescript: 4.9.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/scope-manager@5.57.1: + resolution: {integrity: sha512-N/RrBwEUKMIYxSKl0oDK5sFVHd6VI7p9K5MyUlVYAY6dyNb/wHUqndkTd3XhpGlXgnQsBkRZuu4f9kAHghvgPw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + '@typescript-eslint/types': 5.57.1 + '@typescript-eslint/visitor-keys': 5.57.1 + dev: true + + /@typescript-eslint/type-utils@5.57.1(eslint@8.37.0)(typescript@4.9.5): + resolution: {integrity: sha512-/RIPQyx60Pt6ga86hKXesXkJ2WOS4UemFrmmq/7eOyiYjYv/MUSHPlkhU6k9T9W1ytnTJueqASW+wOmW4KrViw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: '*' + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/typescript-estree': 5.57.1(typescript@4.9.5) + '@typescript-eslint/utils': 5.57.1(eslint@8.37.0)(typescript@4.9.5) + debug: 4.3.4 + eslint: 8.37.0 + tsutils: 3.21.0(typescript@4.9.5) + typescript: 4.9.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/types@5.57.1: + resolution: {integrity: sha512-bSs4LOgyV3bJ08F5RDqO2KXqg3WAdwHCu06zOqcQ6vqbTJizyBhuh1o1ImC69X4bV2g1OJxbH71PJqiO7Y1RuA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + + /@typescript-eslint/typescript-estree@5.57.1(typescript@4.9.5): + resolution: {integrity: sha512-A2MZqD8gNT0qHKbk2wRspg7cHbCDCk2tcqt6ScCFLr5Ru8cn+TCfM786DjPhqwseiS+PrYwcXht5ztpEQ6TFTw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/types': 5.57.1 + '@typescript-eslint/visitor-keys': 5.57.1 + debug: 4.3.4 + globby: 11.1.0 + is-glob: 4.0.3 + semver: 7.3.8 + tsutils: 3.21.0(typescript@4.9.5) + typescript: 4.9.5 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/utils@5.57.1(eslint@8.37.0)(typescript@4.9.5): + resolution: {integrity: sha512-kN6vzzf9NkEtawECqze6v99LtmDiUJCVpvieTFA1uL7/jDghiJGubGZ5csicYHU1Xoqb3oH/R5cN5df6W41Nfg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.37.0) + '@types/json-schema': 7.0.11 + '@types/semver': 7.3.13 + '@typescript-eslint/scope-manager': 5.57.1 + '@typescript-eslint/types': 5.57.1 + '@typescript-eslint/typescript-estree': 5.57.1(typescript@4.9.5) + eslint: 8.37.0 + eslint-scope: 5.1.1 + semver: 7.3.8 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + + /@typescript-eslint/visitor-keys@5.57.1: + resolution: {integrity: sha512-RjQrAniDU0CEk5r7iphkm731zKlFiUjvcBS2yHAg8WWqFMCaCrD0rKEVOMUyMMcbGPZ0bPp56srkGWrgfZqLRA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + '@typescript-eslint/types': 5.57.1 + eslint-visitor-keys: 3.4.0 + dev: true + + /acorn-jsx@5.3.2(acorn@8.8.2): + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + acorn: 8.8.2 + dev: true + + /acorn@8.8.2: + resolution: {integrity: sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + + /agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + dependencies: + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: false + + /ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + dev: true + + /ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + dev: true + + /ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + dependencies: + color-convert: 2.0.1 + dev: true + + /anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + dev: true + + /argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + dependencies: + sprintf-js: 1.0.3 + dev: true + + /argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + /array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + dev: true + + /balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + dev: true + + /base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + dev: false + + /binary-extensions@2.2.0: + resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} + engines: {node: '>=8'} + dev: true + + /bootstrap-icons@1.10.4: + resolution: {integrity: sha512-eI3HyIUmpGKRiRv15FCZccV+2sreGE2NnmH8mtxV/nPOzQVu0sPEj8HhF1MwjJ31IhjF0rgMvtYOX5VqIzcb/A==} + dev: false + + /bootstrap@5.3.0-alpha1(@popperjs/core@2.11.7): + resolution: {integrity: sha512-ABZpKK4ObS3kKlIqH+ZVDqoy5t/bhFG0oHTAzByUdon7YIom0lpCeTqRniDzJmbtcWkNe800VVPBiJgxSYTYew==} + peerDependencies: + '@popperjs/core': ^2.11.6 + dependencies: + '@popperjs/core': 2.11.7 + dev: false + + /brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + dev: true + + /brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + dependencies: + balanced-match: 1.0.2 + dev: true + + /braces@3.0.2: + resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} + engines: {node: '>=8'} + dependencies: + fill-range: 7.0.1 + dev: true + + /buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + dev: true + + /builtin-modules@3.3.0: + resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} + engines: {node: '>=6'} + dev: true + + /busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + dependencies: + streamsearch: 1.1.0 + dev: true + + /callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + dev: true + + /chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + dev: true + + /chokidar@3.5.3: + resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} + engines: {node: '>= 8.10.0'} + dependencies: + anymatch: 3.1.3 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + dependencies: + color-name: 1.1.4 + dev: true + + /color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + dev: true + + /commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + dev: true + + /concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + dev: true + + /cookie@0.4.2: + resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} + engines: {node: '>= 0.6'} + dev: false + + /cookie@0.5.0: + resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} + engines: {node: '>= 0.6'} + dev: true + + /cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + dev: true + + /debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + + /deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dev: true + + /deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + /detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + dev: true + + /devalue@4.3.0: + resolution: {integrity: sha512-n94yQo4LI3w7erwf84mhRUkUJfhLoCZiLyoOZ/QFsDbcWNZePrLwbQpvZBUG2TNxwV3VjCKPxkiiQA6pe3TrTA==} + dev: true + + /dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + dependencies: + path-type: 4.0.0 + dev: true + + /doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + dependencies: + esutils: 2.0.3 + dev: true + + /dom-serializer@1.4.1: + resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + entities: 2.2.0 + dev: true + + /dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.4.0 + + /domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + /domhandler@4.3.1: + resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} + engines: {node: '>= 4'} + dependencies: + domelementtype: 2.3.0 + dev: true + + /domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + dependencies: + domelementtype: 2.3.0 + + /domutils@2.8.0: + resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} + dependencies: + dom-serializer: 1.4.1 + domelementtype: 2.3.0 + domhandler: 4.3.1 + dev: true + + /domutils@3.0.1: + resolution: {integrity: sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==} + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + /entities@2.1.0: + resolution: {integrity: sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==} + dev: true + + /entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + dev: true + + /entities@3.0.1: + resolution: {integrity: sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==} + engines: {node: '>=0.12'} + dev: false + + /entities@4.4.0: + resolution: {integrity: sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==} + engines: {node: '>=0.12'} + + /es6-promise@3.3.1: + resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} + dev: true + + /esbuild@0.17.15: + resolution: {integrity: sha512-LBUV2VsUIc/iD9ME75qhT4aJj0r75abCVS0jakhFzOtR7TQsqQA5w0tZ+KTKnwl3kXE0MhskNdHDh/I5aCR1Zw==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/android-arm': 0.17.15 + '@esbuild/android-arm64': 0.17.15 + '@esbuild/android-x64': 0.17.15 + '@esbuild/darwin-arm64': 0.17.15 + '@esbuild/darwin-x64': 0.17.15 + '@esbuild/freebsd-arm64': 0.17.15 + '@esbuild/freebsd-x64': 0.17.15 + '@esbuild/linux-arm': 0.17.15 + '@esbuild/linux-arm64': 0.17.15 + '@esbuild/linux-ia32': 0.17.15 + '@esbuild/linux-loong64': 0.17.15 + '@esbuild/linux-mips64el': 0.17.15 + '@esbuild/linux-ppc64': 0.17.15 + '@esbuild/linux-riscv64': 0.17.15 + '@esbuild/linux-s390x': 0.17.15 + '@esbuild/linux-x64': 0.17.15 + '@esbuild/netbsd-x64': 0.17.15 + '@esbuild/openbsd-x64': 0.17.15 + '@esbuild/sunos-x64': 0.17.15 + '@esbuild/win32-arm64': 0.17.15 + '@esbuild/win32-ia32': 0.17.15 + '@esbuild/win32-x64': 0.17.15 + dev: true + + /escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + /eslint-config-prettier@8.8.0(eslint@8.37.0): + resolution: {integrity: sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + dependencies: + eslint: 8.37.0 + dev: true + + /eslint-plugin-svelte3@4.0.0(eslint@8.37.0)(svelte@3.58.0): + resolution: {integrity: sha512-OIx9lgaNzD02+MDFNLw0GEUbuovNcglg+wnd/UY0fbZmlQSz7GlQiQ1f+yX0XvC07XPcDOnFcichqI3xCwp71g==} + peerDependencies: + eslint: '>=8.0.0' + svelte: ^3.2.0 + dependencies: + eslint: 8.37.0 + svelte: 3.58.0 + dev: true + + /eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + dev: true + + /eslint-scope@7.1.1: + resolution: {integrity: sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + dev: true + + /eslint-visitor-keys@3.4.0: + resolution: {integrity: sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + + /eslint@8.37.0: + resolution: {integrity: sha512-NU3Ps9nI05GUoVMxcZx1J8CNR6xOvUT4jAUMH5+z8lpp3aEdPVCImKw6PWG4PY+Vfkpr+jvMpxs/qoE7wq0sPw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + hasBin: true + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.37.0) + '@eslint-community/regexpp': 4.5.0 + '@eslint/eslintrc': 2.0.2 + '@eslint/js': 8.37.0 + '@humanwhocodes/config-array': 0.11.8 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.3 + debug: 4.3.4 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.1.1 + eslint-visitor-keys: 3.4.0 + espree: 9.5.1 + esquery: 1.5.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.20.0 + grapheme-splitter: 1.0.4 + ignore: 5.2.4 + import-fresh: 3.3.0 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-sdsl: 4.4.0 + js-yaml: 4.1.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.1 + strip-ansi: 6.0.1 + strip-json-comments: 3.1.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + dev: true + + /esm-env@1.0.0: + resolution: {integrity: sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==} + dev: true + + /espree@9.5.1: + resolution: {integrity: sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + acorn: 8.8.2 + acorn-jsx: 5.3.2(acorn@8.8.2) + eslint-visitor-keys: 3.4.0 + dev: true + + /esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + dev: true + + /esquery@1.5.0: + resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} + engines: {node: '>=0.10'} + dependencies: + estraverse: 5.3.0 + dev: true + + /esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + dependencies: + estraverse: 5.3.0 + dev: true + + /estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + dev: true + + /estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + dev: true + + /estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + dev: true + + /esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + dev: true + + /fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + dev: true + + /fast-glob@3.2.12: + resolution: {integrity: sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==} + engines: {node: '>=8.6.0'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.5 + dev: true + + /fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + dev: true + + /fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + dev: true + + /fastq@1.15.0: + resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} + dependencies: + reusify: 1.0.4 + dev: true + + /file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + dependencies: + flat-cache: 3.0.4 + dev: true + + /fill-range@7.0.1: + resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + engines: {node: '>=8'} + dependencies: + to-regex-range: 5.0.1 + dev: true + + /find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + dev: true + + /flat-cache@3.0.4: + resolution: {integrity: sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==} + engines: {node: ^10.12.0 || >=12.0.0} + dependencies: + flatted: 3.2.7 + rimraf: 3.0.2 + dev: true + + /flatted@3.2.7: + resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==} + dev: true + + /front-matter@4.0.2: + resolution: {integrity: sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg==} + dependencies: + js-yaml: 3.14.1 + dev: true + + /fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + dev: true + + /fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /function-bind@1.1.1: + resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} + dev: true + + /glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + dependencies: + is-glob: 4.0.3 + dev: true + + /glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + dependencies: + is-glob: 4.0.3 + dev: true + + /glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + dev: true + + /glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.6 + once: 1.4.0 + dev: true + + /globals@13.20.0: + resolution: {integrity: sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==} + engines: {node: '>=8'} + dependencies: + type-fest: 0.20.2 + dev: true + + /globalyzer@0.1.0: + resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==} + dev: true + + /globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.2.12 + ignore: 5.2.4 + merge2: 1.4.1 + slash: 3.0.0 + dev: true + + /globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + dev: true + + /graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + dev: true + + /grapheme-splitter@1.0.4: + resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} + dev: true + + /has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + dev: true + + /has@1.0.3: + resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} + engines: {node: '>= 0.4.0'} + dependencies: + function-bind: 1.1.1 + dev: true + + /htmlparser2@6.1.0: + resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==} + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + domutils: 2.8.0 + entities: 2.2.0 + dev: true + + /htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.0.1 + entities: 4.4.0 + + /https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + dependencies: + agent-base: 6.0.2 + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: false + + /ignore@5.2.4: + resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} + engines: {node: '>= 4'} + dev: true + + /import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + dev: true + + /import-meta-resolve@2.2.2: + resolution: {integrity: sha512-f8KcQ1D80V7RnqVm+/lirO9zkOxjGxhaTC1IPrBGd3MEfNgmNG67tSUO9gTi2F3Blr2Az6g1vocaxzkVnWl9MA==} + dev: true + + /imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + dev: true + + /inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + dev: true + + /inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + dev: true + + /is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + dependencies: + binary-extensions: 2.2.0 + dev: true + + /is-builtin-module@3.2.1: + resolution: {integrity: sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==} + engines: {node: '>=6'} + dependencies: + builtin-modules: 3.3.0 + dev: true + + /is-core-module@2.11.0: + resolution: {integrity: sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==} + dependencies: + has: 1.0.3 + dev: true + + /is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + dev: true + + /is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + dependencies: + is-extglob: 2.1.1 + dev: true + + /is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + dev: true + + /is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + dev: true + + /is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + dev: true + + /is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + dev: false + + /is-reference@1.2.1: + resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} + dependencies: + '@types/estree': 1.0.0 + dev: true + + /isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + dev: true + + /jose@4.13.1: + resolution: {integrity: sha512-MSJQC5vXco5Br38mzaQKiq9mwt7lwj2eXpgpRyQYNHYt2lq1PjkWa7DLXX0WVcQLE9HhMh3jPiufS7fhJf+CLQ==} + dev: false + + /js-sdsl@4.4.0: + resolution: {integrity: sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==} + dev: true + + /js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + dev: true + + /js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + dependencies: + argparse: 2.0.1 + dev: true + + /json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + dev: true + + /json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + dev: true + + /kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + dev: true + + /levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + dev: true + + /linkify-it@3.0.3: + resolution: {integrity: sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==} + dependencies: + uc.micro: 1.0.6 + dev: true + + /linkify-it@4.0.1: + resolution: {integrity: sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==} + dependencies: + uc.micro: 1.0.6 + dev: false + + /locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + dependencies: + p-locate: 5.0.0 + dev: true + + /lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + dev: true + + /lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + dependencies: + yallist: 4.0.0 + dev: true + + /lru_map@0.3.3: + resolution: {integrity: sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ==} + dev: false + + /luxon@3.3.0: + resolution: {integrity: sha512-An0UCfG/rSiqtAIiBPO0Y9/zAnHUZxAMiCpTd5h2smgsj7GGmcenvrvww2cqNA8/4A5ZrD1gJpHN2mIHZQF+Mg==} + engines: {node: '>=12'} + dev: false + + /magic-string@0.27.0: + resolution: {integrity: sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/sourcemap-codec': 1.4.14 + dev: true + + /magic-string@0.30.0: + resolution: {integrity: sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/sourcemap-codec': 1.4.14 + dev: true + + /markdown-it@12.3.2: + resolution: {integrity: sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==} + hasBin: true + dependencies: + argparse: 2.0.1 + entities: 2.1.0 + linkify-it: 3.0.3 + mdurl: 1.0.1 + uc.micro: 1.0.6 + dev: true + + /markdown-it@13.0.1: + resolution: {integrity: sha512-lTlxriVoy2criHP0JKRhO2VDG9c2ypWCsT237eDiLqi09rmbKoUetyGHq2uOIRoRS//kfoJckS0eUzzkDR+k2Q==} + hasBin: true + dependencies: + argparse: 2.0.1 + entities: 3.0.1 + linkify-it: 4.0.1 + mdurl: 1.0.1 + uc.micro: 1.0.6 + dev: false + + /mdurl@1.0.1: + resolution: {integrity: sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==} + + /merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + dev: true + + /micromatch@4.0.5: + resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} + engines: {node: '>=8.6'} + dependencies: + braces: 3.0.2 + picomatch: 2.3.1 + dev: true + + /mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + dev: true + + /min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + dev: true + + /minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + dependencies: + brace-expansion: 1.1.11 + dev: true + + /minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + dependencies: + brace-expansion: 2.0.1 + dev: true + + /minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + dev: true + + /mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + dependencies: + minimist: 1.2.8 + dev: true + + /mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + dev: true + + /mrmime@1.0.1: + resolution: {integrity: sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==} + engines: {node: '>=10'} + dev: true + + /ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + + /nanoid@3.3.6: + resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + /natural-compare-lite@1.4.0: + resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} + dev: true + + /natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + dev: true + + /normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + dev: true + + /once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + dependencies: + wrappy: 1.0.2 + dev: true + + /optionator@0.9.1: + resolution: {integrity: sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==} + engines: {node: '>= 0.8.0'} + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.3 + dev: true + + /p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + dependencies: + yocto-queue: 0.1.0 + dev: true + + /p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + dependencies: + p-limit: 3.1.0 + dev: true + + /parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + dependencies: + callsites: 3.1.0 + dev: true + + /parse-srcset@1.0.2: + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + dev: false + + /path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + dev: true + + /path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + dev: true + + /path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + dev: true + + /path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + dev: true + + /path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + dev: true + + /picocolors@1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + + /picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + dev: true + + /postcss@8.4.21: + resolution: {integrity: sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.6 + picocolors: 1.0.0 + source-map-js: 1.0.2 + + /prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + dev: true + + /prettier-plugin-svelte@2.10.0(prettier@2.8.7)(svelte@3.58.0): + resolution: {integrity: sha512-GXMY6t86thctyCvQq+jqElO+MKdB09BkL3hexyGP3Oi8XLKRFaJP1ud/xlWCZ9ZIa2BxHka32zhHfcuU+XsRQg==} + peerDependencies: + prettier: ^1.16.4 || ^2.0.0 + svelte: ^3.2.0 + dependencies: + prettier: 2.8.7 + svelte: 3.58.0 + dev: true + + /prettier@2.8.7: + resolution: {integrity: sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==} + engines: {node: '>=10.13.0'} + hasBin: true + dev: true + + /pretty-bytes@6.1.0: + resolution: {integrity: sha512-Rk753HI8f4uivXi4ZCIYdhmG1V+WKzvRMg/X+M42a6t7D07RcmopXJMDNk6N++7Bl75URRGsb40ruvg7Hcp2wQ==} + engines: {node: ^14.13.1 || >=16.0.0} + dev: false + + /punycode@2.3.0: + resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} + engines: {node: '>=6'} + dev: true + + /queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + dev: true + + /readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + dependencies: + picomatch: 2.3.1 + dev: true + + /resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + dev: true + + /resolve@1.22.1: + resolution: {integrity: sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==} + hasBin: true + dependencies: + is-core-module: 2.11.0 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + dev: true + + /reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + dev: true + + /rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + hasBin: true + dependencies: + glob: 7.2.3 + dev: true + + /rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + hasBin: true + dependencies: + glob: 7.2.3 + dev: true + + /rollup@3.20.2: + resolution: {integrity: sha512-3zwkBQl7Ai7MFYQE0y1MeQ15+9jsi7XxfrqwTb/9EK8D9C9+//EBR4M+CuA1KODRaNbFez/lWxA5vhEGZp4MUg==} + engines: {node: '>=14.18.0', npm: '>=8.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + dependencies: + queue-microtask: 1.2.3 + dev: true + + /sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + dependencies: + mri: 1.2.0 + dev: true + + /sander@0.5.1: + resolution: {integrity: sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==} + dependencies: + es6-promise: 3.3.1 + graceful-fs: 4.2.11 + mkdirp: 0.5.6 + rimraf: 2.7.1 + dev: true + + /sanitize-html@2.10.0: + resolution: {integrity: sha512-JqdovUd81dG4k87vZt6uA6YhDfWkUGruUu/aPmXLxXi45gZExnt9Bnw/qeQU8oGf82vPyaE0vO4aH0PbobB9JQ==} + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 8.0.2 + is-plain-object: 5.0.0 + parse-srcset: 1.0.2 + postcss: 8.4.21 + dev: false + + /semver@7.3.8: + resolution: {integrity: sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==} + engines: {node: '>=10'} + hasBin: true + dependencies: + lru-cache: 6.0.0 + dev: true + + /set-cookie-parser@2.6.0: + resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==} + dev: true + + /shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + dependencies: + shebang-regex: 3.0.0 + dev: true + + /shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + dev: true + + /sirv@2.0.2: + resolution: {integrity: sha512-4Qog6aE29nIjAOKe/wowFTxOdmbEZKb+3tsLljaBRzJwtqto0BChD2zzH0LhgCSXiI+V7X+Y45v14wBZQ1TK3w==} + engines: {node: '>= 10'} + dependencies: + '@polka/url': 1.0.0-next.21 + mrmime: 1.0.1 + totalist: 3.0.1 + dev: true + + /slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + dev: true + + /sorcery@0.11.0: + resolution: {integrity: sha512-J69LQ22xrQB1cIFJhPfgtLuI6BpWRiWu1Y3vSsIwK/eAScqJxd/+CJlUuHQRdX2C9NGFamq+KqNywGgaThwfHw==} + hasBin: true + dependencies: + '@jridgewell/sourcemap-codec': 1.4.14 + buffer-crc32: 0.2.13 + minimist: 1.2.8 + sander: 0.5.1 + dev: true + + /source-map-js@1.0.2: + resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + engines: {node: '>=0.10.0'} + + /sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + dev: true + + /streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + dev: true + + /strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + dependencies: + ansi-regex: 5.0.1 + dev: true + + /strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + dependencies: + min-indent: 1.0.1 + dev: true + + /strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + dev: true + + /supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + dependencies: + has-flag: 4.0.0 + dev: true + + /supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + dev: true + + /svelte-check@3.1.4(svelte@3.58.0): + resolution: {integrity: sha512-25Lb46ZS4IK/XpBMe4IBMrtYf23V8alqBX+szXoccb7uM0D2Wqq5rMRzYBONZnFVuU1bQG3R50lyIT5eRewv2g==} + hasBin: true + peerDependencies: + svelte: ^3.55.0 + dependencies: + '@jridgewell/trace-mapping': 0.3.17 + chokidar: 3.5.3 + fast-glob: 3.2.12 + import-fresh: 3.3.0 + picocolors: 1.0.0 + sade: 1.8.1 + svelte: 3.58.0 + svelte-preprocess: 5.0.3(svelte@3.58.0)(typescript@4.9.5) + typescript: 4.9.5 + transitivePeerDependencies: + - '@babel/core' + - coffeescript + - less + - postcss + - postcss-load-config + - pug + - sass + - stylus + - sugarss + dev: true + + /svelte-hcaptcha@0.1.1: + resolution: {integrity: sha512-iFF3HwfrCRciJnDs4Y9/rpP/BM2U/5zt+vh+9d4tALPAHVkcANiJIKqYuS835pIaTm6gt+xOzjfFI3cgiRI29A==} + dev: true + + /svelte-hmr@0.15.1(svelte@3.58.0): + resolution: {integrity: sha512-BiKB4RZ8YSwRKCNVdNxK/GfY+r4Kjgp9jCLEy0DuqAKfmQtpL38cQK3afdpjw4sqSs4PLi3jIPJIFp259NkZtA==} + engines: {node: ^12.20 || ^14.13.1 || >= 16} + peerDependencies: + svelte: '>=3.19.0' + dependencies: + svelte: 3.58.0 + dev: true + + /svelte-preprocess@5.0.3(svelte@3.58.0)(typescript@4.9.5): + resolution: {integrity: sha512-GrHF1rusdJVbOZOwgPWtpqmaexkydznKzy5qIC2FabgpFyKN57bjMUUUqPRfbBXK5igiEWn1uO/DXsa2vJ5VHA==} + engines: {node: '>= 14.10.0'} + requiresBuild: true + peerDependencies: + '@babel/core': ^7.10.2 + coffeescript: ^2.5.1 + less: ^3.11.3 || ^4.0.0 + postcss: ^7 || ^8 + postcss-load-config: ^2.1.0 || ^3.0.0 || ^4.0.0 + pug: ^3.0.0 + sass: ^1.26.8 + stylus: ^0.55.0 + sugarss: ^2.0.0 || ^3.0.0 || ^4.0.0 + svelte: ^3.23.0 + typescript: '>=3.9.5 || ^4.0.0 || ^5.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + coffeescript: + optional: true + less: + optional: true + postcss: + optional: true + postcss-load-config: + optional: true + pug: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + typescript: + optional: true + dependencies: + '@types/pug': 2.0.6 + detect-indent: 6.1.0 + magic-string: 0.27.0 + sorcery: 0.11.0 + strip-indent: 3.0.0 + svelte: 3.58.0 + typescript: 4.9.5 + dev: true + + /svelte@3.58.0: + resolution: {integrity: sha512-brIBNNB76mXFmU/Kerm4wFnkskBbluBDCjx/8TcpYRb298Yh2dztS2kQ6bhtjMcvUhd5ynClfwpz5h2gnzdQ1A==} + engines: {node: '>= 8'} + dev: true + + /sveltestrap@5.10.0(svelte@3.58.0): + resolution: {integrity: sha512-k6Ob+6G2AMYvBidXHBKM9W28fJqFHbmosqCe/NC8pv6TV7K+v47Yw+zmnLWkjqCzzmjkSLkL48SrHZrlWc9mYQ==} + peerDependencies: + svelte: ^3.29.0 + dependencies: + '@popperjs/core': 2.11.7 + svelte: 3.58.0 + dev: true + + /text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + dev: true + + /tiny-glob@0.2.9: + resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} + dependencies: + globalyzer: 0.1.0 + globrex: 0.1.2 + dev: true + + /to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + dependencies: + is-number: 7.0.0 + dev: true + + /totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + dev: true + + /tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + /tslib@2.5.0: + resolution: {integrity: sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==} + dev: true + + /tsutils@3.21.0(typescript@4.9.5): + resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} + engines: {node: '>= 6'} + peerDependencies: + typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + dependencies: + tslib: 1.14.1 + typescript: 4.9.5 + dev: true + + /type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + dependencies: + prelude-ls: 1.2.1 + dev: true + + /type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + dev: true + + /typescript@4.9.5: + resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} + engines: {node: '>=4.2.0'} + hasBin: true + dev: true + + /uc.micro@1.0.6: + resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==} + + /undici@5.21.0: + resolution: {integrity: sha512-HOjK8l6a57b2ZGXOcUsI5NLfoTrfmbOl90ixJDl0AEFG4wgHNDQxtZy15/ZQp7HhjkpaGlp/eneMgtsu1dIlUA==} + engines: {node: '>=12.18'} + dependencies: + busboy: 1.6.0 + dev: true + + /uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + dependencies: + punycode: 2.3.0 + dev: true + + /vite-plugin-markdown@2.1.0(vite@4.2.1): + resolution: {integrity: sha512-eWLlrWzYZXEX3/HaXZo/KLjRpO72IUhbgaoFrbwB07ueXi6QfwqrgdZQfUcXTSofJCkN7GhErMC1K1RTAE0gGQ==} + peerDependencies: + vite: ^2.0.0 || ^3.0.0 + dependencies: + front-matter: 4.0.2 + htmlparser2: 6.1.0 + markdown-it: 12.3.2 + vite: 4.2.1(@types/node@18.15.11) + dev: true + + /vite@4.2.1(@types/node@18.15.11): + resolution: {integrity: sha512-7MKhqdy0ISo4wnvwtqZkjke6XN4taqQ2TBaTccLIpOKv7Vp2h4Y+NpmWCnGDeSvvn45KxvWgGyb0MkHvY1vgbg==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + '@types/node': 18.15.11 + esbuild: 0.17.15 + postcss: 8.4.21 + resolve: 1.22.1 + rollup: 3.20.2 + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /vitefu@0.2.4(vite@4.2.1): + resolution: {integrity: sha512-fanAXjSaf9xXtOOeno8wZXIhgia+CZury481LsDaV++lSvcU2R9Ch2bPh3PYFyoHW+w9LqAeYRISVQjUIew14g==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 + peerDependenciesMeta: + vite: + optional: true + dependencies: + vite: 4.2.1(@types/node@18.15.11) + dev: true + + /which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + dependencies: + isexe: 2.0.0 + dev: true + + /word-wrap@1.2.3: + resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==} + engines: {node: '>=0.10.0'} + dev: true + + /wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + dev: true + + /yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + dev: true + + /yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + dev: true diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts new file mode 100644 index 0000000..9176095 --- /dev/null +++ b/frontend/src/app.d.ts @@ -0,0 +1,31 @@ +// See https://kit.svelte.dev/docs/types#app +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface Platform {} + } +} + +declare module "svelte-hcaptcha" { + import type { SvelteComponent } from "svelte"; + + export interface HCaptchaProps { + sitekey?: string; + apihost?: string; + hl?: string; + reCaptchaCompat?: boolean; + theme?: CaptchaTheme; + size?: string; + } + + declare class HCaptcha extends SvelteComponent { + $$prop_def: HCaptchaProps; + } + + export default HCaptcha; +} + +export {}; diff --git a/frontend/src/app.html b/frontend/src/app.html new file mode 100644 index 0000000..0f9a71c --- /dev/null +++ b/frontend/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/frontend/src/error.html b/frontend/src/error.html new file mode 100644 index 0000000..40fc538 --- /dev/null +++ b/frontend/src/error.html @@ -0,0 +1,73 @@ + + + + + + + Internal error occurred + + + +
+ +

Internal error occurred

+

An internal error has occurred. Don't worry, it's (probably) not your fault.

+

+ If this is the first time this is happening, try reloading the page. Otherwise, check the + status page for updates. +

+
+

+ Status: %sveltekit.status%
+ Error message: %sveltekit.error.message% +

+ + diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts new file mode 100644 index 0000000..e073554 --- /dev/null +++ b/frontend/src/hooks.server.ts @@ -0,0 +1,17 @@ +import { PRIVATE_SENTRY_DSN } from "$env/static/private"; +import * as Sentry from "@sentry/node"; +import type { HandleServerError } from "@sveltejs/kit"; + +Sentry.init({ dsn: PRIVATE_SENTRY_DSN }); + +export const handleError = (({ error, event }) => { + console.log(error); + console.log(event); + // const id = Sentry.captureException(error); + + return { + message: "Internal server error", + code: 500, + //id, + }; +}) satisfies HandleServerError; diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts new file mode 100644 index 0000000..5df52c1 --- /dev/null +++ b/frontend/src/icons.ts @@ -0,0 +1,2 @@ +const icons = ["123","alarm-fill","alarm","align-bottom","align-center","align-end","align-middle","align-start","align-top","alt","app-indicator","app","archive-fill","archive","arrow-90deg-down","arrow-90deg-left","arrow-90deg-right","arrow-90deg-up","arrow-bar-down","arrow-bar-left","arrow-bar-right","arrow-bar-up","arrow-clockwise","arrow-counterclockwise","arrow-down-circle-fill","arrow-down-circle","arrow-down-left-circle-fill","arrow-down-left-circle","arrow-down-left-square-fill","arrow-down-left-square","arrow-down-left","arrow-down-right-circle-fill","arrow-down-right-circle","arrow-down-right-square-fill","arrow-down-right-square","arrow-down-right","arrow-down-short","arrow-down-square-fill","arrow-down-square","arrow-down-up","arrow-down","arrow-left-circle-fill","arrow-left-circle","arrow-left-right","arrow-left-short","arrow-left-square-fill","arrow-left-square","arrow-left","arrow-repeat","arrow-return-left","arrow-return-right","arrow-right-circle-fill","arrow-right-circle","arrow-right-short","arrow-right-square-fill","arrow-right-square","arrow-right","arrow-up-circle-fill","arrow-up-circle","arrow-up-left-circle-fill","arrow-up-left-circle","arrow-up-left-square-fill","arrow-up-left-square","arrow-up-left","arrow-up-right-circle-fill","arrow-up-right-circle","arrow-up-right-square-fill","arrow-up-right-square","arrow-up-right","arrow-up-short","arrow-up-square-fill","arrow-up-square","arrow-up","arrows-angle-contract","arrows-angle-expand","arrows-collapse","arrows-expand","arrows-fullscreen","arrows-move","aspect-ratio-fill","aspect-ratio","asterisk","at","award-fill","award","back","backspace-fill","backspace-reverse-fill","backspace-reverse","backspace","badge-3d-fill","badge-3d","badge-4k-fill","badge-4k","badge-8k-fill","badge-8k","badge-ad-fill","badge-ad","badge-ar-fill","badge-ar","badge-cc-fill","badge-cc","badge-hd-fill","badge-hd","badge-tm-fill","badge-tm","badge-vo-fill","badge-vo","badge-vr-fill","badge-vr","badge-wc-fill","badge-wc","bag-check-fill","bag-check","bag-dash-fill","bag-dash","bag-fill","bag-plus-fill","bag-plus","bag-x-fill","bag-x","bag","bar-chart-fill","bar-chart-line-fill","bar-chart-line","bar-chart-steps","bar-chart","basket-fill","basket","basket2-fill","basket2","basket3-fill","basket3","battery-charging","battery-full","battery-half","battery","bell-fill","bell","bezier","bezier2","bicycle","binoculars-fill","binoculars","blockquote-left","blockquote-right","book-fill","book-half","book","bookmark-check-fill","bookmark-check","bookmark-dash-fill","bookmark-dash","bookmark-fill","bookmark-heart-fill","bookmark-heart","bookmark-plus-fill","bookmark-plus","bookmark-star-fill","bookmark-star","bookmark-x-fill","bookmark-x","bookmark","bookmarks-fill","bookmarks","bookshelf","bootstrap-fill","bootstrap-reboot","bootstrap","border-all","border-bottom","border-center","border-inner","border-left","border-middle","border-outer","border-right","border-style","border-top","border-width","border","bounding-box-circles","bounding-box","box-arrow-down-left","box-arrow-down-right","box-arrow-down","box-arrow-in-down-left","box-arrow-in-down-right","box-arrow-in-down","box-arrow-in-left","box-arrow-in-right","box-arrow-in-up-left","box-arrow-in-up-right","box-arrow-in-up","box-arrow-left","box-arrow-right","box-arrow-up-left","box-arrow-up-right","box-arrow-up","box-seam","box","braces","bricks","briefcase-fill","briefcase","brightness-alt-high-fill","brightness-alt-high","brightness-alt-low-fill","brightness-alt-low","brightness-high-fill","brightness-high","brightness-low-fill","brightness-low","broadcast-pin","broadcast","brush-fill","brush","bucket-fill","bucket","bug-fill","bug","building","bullseye","calculator-fill","calculator","calendar-check-fill","calendar-check","calendar-date-fill","calendar-date","calendar-day-fill","calendar-day","calendar-event-fill","calendar-event","calendar-fill","calendar-minus-fill","calendar-minus","calendar-month-fill","calendar-month","calendar-plus-fill","calendar-plus","calendar-range-fill","calendar-range","calendar-week-fill","calendar-week","calendar-x-fill","calendar-x","calendar","calendar2-check-fill","calendar2-check","calendar2-date-fill","calendar2-date","calendar2-day-fill","calendar2-day","calendar2-event-fill","calendar2-event","calendar2-fill","calendar2-minus-fill","calendar2-minus","calendar2-month-fill","calendar2-month","calendar2-plus-fill","calendar2-plus","calendar2-range-fill","calendar2-range","calendar2-week-fill","calendar2-week","calendar2-x-fill","calendar2-x","calendar2","calendar3-event-fill","calendar3-event","calendar3-fill","calendar3-range-fill","calendar3-range","calendar3-week-fill","calendar3-week","calendar3","calendar4-event","calendar4-range","calendar4-week","calendar4","camera-fill","camera-reels-fill","camera-reels","camera-video-fill","camera-video-off-fill","camera-video-off","camera-video","camera","camera2","capslock-fill","capslock","card-checklist","card-heading","card-image","card-list","card-text","caret-down-fill","caret-down-square-fill","caret-down-square","caret-down","caret-left-fill","caret-left-square-fill","caret-left-square","caret-left","caret-right-fill","caret-right-square-fill","caret-right-square","caret-right","caret-up-fill","caret-up-square-fill","caret-up-square","caret-up","cart-check-fill","cart-check","cart-dash-fill","cart-dash","cart-fill","cart-plus-fill","cart-plus","cart-x-fill","cart-x","cart","cart2","cart3","cart4","cash-stack","cash","cast","chat-dots-fill","chat-dots","chat-fill","chat-left-dots-fill","chat-left-dots","chat-left-fill","chat-left-quote-fill","chat-left-quote","chat-left-text-fill","chat-left-text","chat-left","chat-quote-fill","chat-quote","chat-right-dots-fill","chat-right-dots","chat-right-fill","chat-right-quote-fill","chat-right-quote","chat-right-text-fill","chat-right-text","chat-right","chat-square-dots-fill","chat-square-dots","chat-square-fill","chat-square-quote-fill","chat-square-quote","chat-square-text-fill","chat-square-text","chat-square","chat-text-fill","chat-text","chat","check-all","check-circle-fill","check-circle","check-square-fill","check-square","check","check2-all","check2-circle","check2-square","check2","chevron-bar-contract","chevron-bar-down","chevron-bar-expand","chevron-bar-left","chevron-bar-right","chevron-bar-up","chevron-compact-down","chevron-compact-left","chevron-compact-right","chevron-compact-up","chevron-contract","chevron-double-down","chevron-double-left","chevron-double-right","chevron-double-up","chevron-down","chevron-expand","chevron-left","chevron-right","chevron-up","circle-fill","circle-half","circle-square","circle","clipboard-check","clipboard-data","clipboard-minus","clipboard-plus","clipboard-x","clipboard","clock-fill","clock-history","clock","cloud-arrow-down-fill","cloud-arrow-down","cloud-arrow-up-fill","cloud-arrow-up","cloud-check-fill","cloud-check","cloud-download-fill","cloud-download","cloud-drizzle-fill","cloud-drizzle","cloud-fill","cloud-fog-fill","cloud-fog","cloud-fog2-fill","cloud-fog2","cloud-hail-fill","cloud-hail","cloud-haze-fill","cloud-haze","cloud-haze2-fill","cloud-lightning-fill","cloud-lightning-rain-fill","cloud-lightning-rain","cloud-lightning","cloud-minus-fill","cloud-minus","cloud-moon-fill","cloud-moon","cloud-plus-fill","cloud-plus","cloud-rain-fill","cloud-rain-heavy-fill","cloud-rain-heavy","cloud-rain","cloud-slash-fill","cloud-slash","cloud-sleet-fill","cloud-sleet","cloud-snow-fill","cloud-snow","cloud-sun-fill","cloud-sun","cloud-upload-fill","cloud-upload","cloud","clouds-fill","clouds","cloudy-fill","cloudy","code-slash","code-square","code","collection-fill","collection-play-fill","collection-play","collection","columns-gap","columns","command","compass-fill","compass","cone-striped","cone","controller","cpu-fill","cpu","credit-card-2-back-fill","credit-card-2-back","credit-card-2-front-fill","credit-card-2-front","credit-card-fill","credit-card","crop","cup-fill","cup-straw","cup","cursor-fill","cursor-text","cursor","dash-circle-dotted","dash-circle-fill","dash-circle","dash-square-dotted","dash-square-fill","dash-square","dash","diagram-2-fill","diagram-2","diagram-3-fill","diagram-3","diamond-fill","diamond-half","diamond","dice-1-fill","dice-1","dice-2-fill","dice-2","dice-3-fill","dice-3","dice-4-fill","dice-4","dice-5-fill","dice-5","dice-6-fill","dice-6","disc-fill","disc","discord","display-fill","display","distribute-horizontal","distribute-vertical","door-closed-fill","door-closed","door-open-fill","door-open","dot","download","droplet-fill","droplet-half","droplet","earbuds","easel-fill","easel","egg-fill","egg-fried","egg","eject-fill","eject","emoji-angry-fill","emoji-angry","emoji-dizzy-fill","emoji-dizzy","emoji-expressionless-fill","emoji-expressionless","emoji-frown-fill","emoji-frown","emoji-heart-eyes-fill","emoji-heart-eyes","emoji-laughing-fill","emoji-laughing","emoji-neutral-fill","emoji-neutral","emoji-smile-fill","emoji-smile-upside-down-fill","emoji-smile-upside-down","emoji-smile","emoji-sunglasses-fill","emoji-sunglasses","emoji-wink-fill","emoji-wink","envelope-fill","envelope-open-fill","envelope-open","envelope","eraser-fill","eraser","exclamation-circle-fill","exclamation-circle","exclamation-diamond-fill","exclamation-diamond","exclamation-octagon-fill","exclamation-octagon","exclamation-square-fill","exclamation-square","exclamation-triangle-fill","exclamation-triangle","exclamation","exclude","eye-fill","eye-slash-fill","eye-slash","eye","eyedropper","eyeglasses","facebook","file-arrow-down-fill","file-arrow-down","file-arrow-up-fill","file-arrow-up","file-bar-graph-fill","file-bar-graph","file-binary-fill","file-binary","file-break-fill","file-break","file-check-fill","file-check","file-code-fill","file-code","file-diff-fill","file-diff","file-earmark-arrow-down-fill","file-earmark-arrow-down","file-earmark-arrow-up-fill","file-earmark-arrow-up","file-earmark-bar-graph-fill","file-earmark-bar-graph","file-earmark-binary-fill","file-earmark-binary","file-earmark-break-fill","file-earmark-break","file-earmark-check-fill","file-earmark-check","file-earmark-code-fill","file-earmark-code","file-earmark-diff-fill","file-earmark-diff","file-earmark-easel-fill","file-earmark-easel","file-earmark-excel-fill","file-earmark-excel","file-earmark-fill","file-earmark-font-fill","file-earmark-font","file-earmark-image-fill","file-earmark-image","file-earmark-lock-fill","file-earmark-lock","file-earmark-lock2-fill","file-earmark-lock2","file-earmark-medical-fill","file-earmark-medical","file-earmark-minus-fill","file-earmark-minus","file-earmark-music-fill","file-earmark-music","file-earmark-person-fill","file-earmark-person","file-earmark-play-fill","file-earmark-play","file-earmark-plus-fill","file-earmark-plus","file-earmark-post-fill","file-earmark-post","file-earmark-ppt-fill","file-earmark-ppt","file-earmark-richtext-fill","file-earmark-richtext","file-earmark-ruled-fill","file-earmark-ruled","file-earmark-slides-fill","file-earmark-slides","file-earmark-spreadsheet-fill","file-earmark-spreadsheet","file-earmark-text-fill","file-earmark-text","file-earmark-word-fill","file-earmark-word","file-earmark-x-fill","file-earmark-x","file-earmark-zip-fill","file-earmark-zip","file-earmark","file-easel-fill","file-easel","file-excel-fill","file-excel","file-fill","file-font-fill","file-font","file-image-fill","file-image","file-lock-fill","file-lock","file-lock2-fill","file-lock2","file-medical-fill","file-medical","file-minus-fill","file-minus","file-music-fill","file-music","file-person-fill","file-person","file-play-fill","file-play","file-plus-fill","file-plus","file-post-fill","file-post","file-ppt-fill","file-ppt","file-richtext-fill","file-richtext","file-ruled-fill","file-ruled","file-slides-fill","file-slides","file-spreadsheet-fill","file-spreadsheet","file-text-fill","file-text","file-word-fill","file-word","file-x-fill","file-x","file-zip-fill","file-zip","file","files-alt","files","film","filter-circle-fill","filter-circle","filter-left","filter-right","filter-square-fill","filter-square","filter","flag-fill","flag","flower1","flower2","flower3","folder-check","folder-fill","folder-minus","folder-plus","folder-symlink-fill","folder-symlink","folder-x","folder","folder2-open","folder2","fonts","forward-fill","forward","front","fullscreen-exit","fullscreen","funnel-fill","funnel","gear-fill","gear-wide-connected","gear-wide","gear","gem","geo-alt-fill","geo-alt","geo-fill","geo","gift-fill","gift","github","globe","globe2","google","graph-down","graph-up","grid-1x2-fill","grid-1x2","grid-3x2-gap-fill","grid-3x2-gap","grid-3x2","grid-3x3-gap-fill","grid-3x3-gap","grid-3x3","grid-fill","grid","grip-horizontal","grip-vertical","hammer","hand-index-fill","hand-index-thumb-fill","hand-index-thumb","hand-index","hand-thumbs-down-fill","hand-thumbs-down","hand-thumbs-up-fill","hand-thumbs-up","handbag-fill","handbag","hash","hdd-fill","hdd-network-fill","hdd-network","hdd-rack-fill","hdd-rack","hdd-stack-fill","hdd-stack","hdd","headphones","headset","heart-fill","heart-half","heart","heptagon-fill","heptagon-half","heptagon","hexagon-fill","hexagon-half","hexagon","hourglass-bottom","hourglass-split","hourglass-top","hourglass","house-door-fill","house-door","house-fill","house","hr","hurricane","image-alt","image-fill","image","images","inbox-fill","inbox","inboxes-fill","inboxes","info-circle-fill","info-circle","info-square-fill","info-square","info","input-cursor-text","input-cursor","instagram","intersect","journal-album","journal-arrow-down","journal-arrow-up","journal-bookmark-fill","journal-bookmark","journal-check","journal-code","journal-medical","journal-minus","journal-plus","journal-richtext","journal-text","journal-x","journal","journals","joystick","justify-left","justify-right","justify","kanban-fill","kanban","key-fill","key","keyboard-fill","keyboard","ladder","lamp-fill","lamp","laptop-fill","laptop","layer-backward","layer-forward","layers-fill","layers-half","layers","layout-sidebar-inset-reverse","layout-sidebar-inset","layout-sidebar-reverse","layout-sidebar","layout-split","layout-text-sidebar-reverse","layout-text-sidebar","layout-text-window-reverse","layout-text-window","layout-three-columns","layout-wtf","life-preserver","lightbulb-fill","lightbulb-off-fill","lightbulb-off","lightbulb","lightning-charge-fill","lightning-charge","lightning-fill","lightning","link-45deg","link","linkedin","list-check","list-nested","list-ol","list-stars","list-task","list-ul","list","lock-fill","lock","mailbox","mailbox2","map-fill","map","markdown-fill","markdown","mask","megaphone-fill","megaphone","menu-app-fill","menu-app","menu-button-fill","menu-button-wide-fill","menu-button-wide","menu-button","menu-down","menu-up","mic-fill","mic-mute-fill","mic-mute","mic","minecart-loaded","minecart","moisture","moon-fill","moon-stars-fill","moon-stars","moon","mouse-fill","mouse","mouse2-fill","mouse2","mouse3-fill","mouse3","music-note-beamed","music-note-list","music-note","music-player-fill","music-player","newspaper","node-minus-fill","node-minus","node-plus-fill","node-plus","nut-fill","nut","octagon-fill","octagon-half","octagon","option","outlet","paint-bucket","palette-fill","palette","palette2","paperclip","paragraph","patch-check-fill","patch-check","patch-exclamation-fill","patch-exclamation","patch-minus-fill","patch-minus","patch-plus-fill","patch-plus","patch-question-fill","patch-question","pause-btn-fill","pause-btn","pause-circle-fill","pause-circle","pause-fill","pause","peace-fill","peace","pen-fill","pen","pencil-fill","pencil-square","pencil","pentagon-fill","pentagon-half","pentagon","people-fill","people","percent","person-badge-fill","person-badge","person-bounding-box","person-check-fill","person-check","person-circle","person-dash-fill","person-dash","person-fill","person-lines-fill","person-plus-fill","person-plus","person-square","person-x-fill","person-x","person","phone-fill","phone-landscape-fill","phone-landscape","phone-vibrate-fill","phone-vibrate","phone","pie-chart-fill","pie-chart","pin-angle-fill","pin-angle","pin-fill","pin","pip-fill","pip","play-btn-fill","play-btn","play-circle-fill","play-circle","play-fill","play","plug-fill","plug","plus-circle-dotted","plus-circle-fill","plus-circle","plus-square-dotted","plus-square-fill","plus-square","plus","power","printer-fill","printer","puzzle-fill","puzzle","question-circle-fill","question-circle","question-diamond-fill","question-diamond","question-octagon-fill","question-octagon","question-square-fill","question-square","question","rainbow","receipt-cutoff","receipt","reception-0","reception-1","reception-2","reception-3","reception-4","record-btn-fill","record-btn","record-circle-fill","record-circle","record-fill","record","record2-fill","record2","reply-all-fill","reply-all","reply-fill","reply","rss-fill","rss","rulers","save-fill","save","save2-fill","save2","scissors","screwdriver","search","segmented-nav","server","share-fill","share","shield-check","shield-exclamation","shield-fill-check","shield-fill-exclamation","shield-fill-minus","shield-fill-plus","shield-fill-x","shield-fill","shield-lock-fill","shield-lock","shield-minus","shield-plus","shield-shaded","shield-slash-fill","shield-slash","shield-x","shield","shift-fill","shift","shop-window","shop","shuffle","signpost-2-fill","signpost-2","signpost-fill","signpost-split-fill","signpost-split","signpost","sim-fill","sim","skip-backward-btn-fill","skip-backward-btn","skip-backward-circle-fill","skip-backward-circle","skip-backward-fill","skip-backward","skip-end-btn-fill","skip-end-btn","skip-end-circle-fill","skip-end-circle","skip-end-fill","skip-end","skip-forward-btn-fill","skip-forward-btn","skip-forward-circle-fill","skip-forward-circle","skip-forward-fill","skip-forward","skip-start-btn-fill","skip-start-btn","skip-start-circle-fill","skip-start-circle","skip-start-fill","skip-start","slack","slash-circle-fill","slash-circle","slash-square-fill","slash-square","slash","sliders","smartwatch","snow","snow2","snow3","sort-alpha-down-alt","sort-alpha-down","sort-alpha-up-alt","sort-alpha-up","sort-down-alt","sort-down","sort-numeric-down-alt","sort-numeric-down","sort-numeric-up-alt","sort-numeric-up","sort-up-alt","sort-up","soundwave","speaker-fill","speaker","speedometer","speedometer2","spellcheck","square-fill","square-half","square","stack","star-fill","star-half","star","stars","stickies-fill","stickies","sticky-fill","sticky","stop-btn-fill","stop-btn","stop-circle-fill","stop-circle","stop-fill","stop","stoplights-fill","stoplights","stopwatch-fill","stopwatch","subtract","suit-club-fill","suit-club","suit-diamond-fill","suit-diamond","suit-heart-fill","suit-heart","suit-spade-fill","suit-spade","sun-fill","sun","sunglasses","sunrise-fill","sunrise","sunset-fill","sunset","symmetry-horizontal","symmetry-vertical","table","tablet-fill","tablet-landscape-fill","tablet-landscape","tablet","tag-fill","tag","tags-fill","tags","telegram","telephone-fill","telephone-forward-fill","telephone-forward","telephone-inbound-fill","telephone-inbound","telephone-minus-fill","telephone-minus","telephone-outbound-fill","telephone-outbound","telephone-plus-fill","telephone-plus","telephone-x-fill","telephone-x","telephone","terminal-fill","terminal","text-center","text-indent-left","text-indent-right","text-left","text-paragraph","text-right","textarea-resize","textarea-t","textarea","thermometer-half","thermometer-high","thermometer-low","thermometer-snow","thermometer-sun","thermometer","three-dots-vertical","three-dots","toggle-off","toggle-on","toggle2-off","toggle2-on","toggles","toggles2","tools","tornado","trash-fill","trash","trash2-fill","trash2","tree-fill","tree","triangle-fill","triangle-half","triangle","trophy-fill","trophy","tropical-storm","truck-flatbed","truck","tsunami","tv-fill","tv","twitch","twitter","type-bold","type-h1","type-h2","type-h3","type-italic","type-strikethrough","type-underline","type","ui-checks-grid","ui-checks","ui-radios-grid","ui-radios","umbrella-fill","umbrella","union","unlock-fill","unlock","upc-scan","upc","upload","vector-pen","view-list","view-stacked","vinyl-fill","vinyl","voicemail","volume-down-fill","volume-down","volume-mute-fill","volume-mute","volume-off-fill","volume-off","volume-up-fill","volume-up","vr","wallet-fill","wallet","wallet2","watch","water","whatsapp","wifi-1","wifi-2","wifi-off","wifi","wind","window-dock","window-sidebar","window","wrench","x-circle-fill","x-circle","x-diamond-fill","x-diamond","x-octagon-fill","x-octagon","x-square-fill","x-square","x","youtube","zoom-in","zoom-out","bank","bank2","bell-slash-fill","bell-slash","cash-coin","check-lg","coin","currency-bitcoin","currency-dollar","currency-euro","currency-exchange","currency-pound","currency-yen","dash-lg","exclamation-lg","file-earmark-pdf-fill","file-earmark-pdf","file-pdf-fill","file-pdf","gender-ambiguous","gender-female","gender-male","gender-trans","headset-vr","info-lg","mastodon","messenger","piggy-bank-fill","piggy-bank","pin-map-fill","pin-map","plus-lg","question-lg","recycle","reddit","safe-fill","safe2-fill","safe2","sd-card-fill","sd-card","skype","slash-lg","translate","x-lg","safe","apple","microsoft","windows","behance","dribbble","line","medium","paypal","pinterest","signal","snapchat","spotify","stack-overflow","strava","wordpress","vimeo","activity","easel2-fill","easel2","easel3-fill","easel3","fan","fingerprint","graph-down-arrow","graph-up-arrow","hypnotize","magic","person-rolodex","person-video","person-video2","person-video3","person-workspace","radioactive","webcam-fill","webcam","yin-yang","bandaid-fill","bandaid","bluetooth","body-text","boombox","boxes","dpad-fill","dpad","ear-fill","ear","envelope-check-fill","envelope-check","envelope-dash-fill","envelope-dash","envelope-exclamation-fill","envelope-exclamation","envelope-plus-fill","envelope-plus","envelope-slash-fill","envelope-slash","envelope-x-fill","envelope-x","explicit-fill","explicit","git","infinity","list-columns-reverse","list-columns","meta","nintendo-switch","pc-display-horizontal","pc-display","pc-horizontal","pc","playstation","plus-slash-minus","projector-fill","projector","qr-code-scan","qr-code","quora","quote","robot","send-check-fill","send-check","send-dash-fill","send-dash","send-exclamation-fill","send-exclamation","send-fill","send-plus-fill","send-plus","send-slash-fill","send-slash","send-x-fill","send-x","send","steam","terminal-dash","terminal-plus","terminal-split","ticket-detailed-fill","ticket-detailed","ticket-fill","ticket-perforated-fill","ticket-perforated","ticket","tiktok","window-dash","window-desktop","window-fullscreen","window-plus","window-split","window-stack","window-x","xbox","ethernet","hdmi-fill","hdmi","usb-c-fill","usb-c","usb-fill","usb-plug-fill","usb-plug","usb-symbol","usb","boombox-fill","displayport","gpu-card","memory","modem-fill","modem","motherboard-fill","motherboard","optical-audio-fill","optical-audio","pci-card","router-fill","router","thunderbolt-fill","thunderbolt","usb-drive-fill","usb-drive","usb-micro-fill","usb-micro","usb-mini-fill","usb-mini","cloud-haze2","device-hdd-fill","device-hdd","device-ssd-fill","device-ssd","displayport-fill","mortarboard-fill","mortarboard","terminal-x","arrow-through-heart-fill","arrow-through-heart","badge-sd-fill","badge-sd","bag-heart-fill","bag-heart","balloon-fill","balloon-heart-fill","balloon-heart","balloon","box2-fill","box2-heart-fill","box2-heart","box2","braces-asterisk","calendar-heart-fill","calendar-heart","calendar2-heart-fill","calendar2-heart","chat-heart-fill","chat-heart","chat-left-heart-fill","chat-left-heart","chat-right-heart-fill","chat-right-heart","chat-square-heart-fill","chat-square-heart","clipboard-check-fill","clipboard-data-fill","clipboard-fill","clipboard-heart-fill","clipboard-heart","clipboard-minus-fill","clipboard-plus-fill","clipboard-pulse","clipboard-x-fill","clipboard2-check-fill","clipboard2-check","clipboard2-data-fill","clipboard2-data","clipboard2-fill","clipboard2-heart-fill","clipboard2-heart","clipboard2-minus-fill","clipboard2-minus","clipboard2-plus-fill","clipboard2-plus","clipboard2-pulse-fill","clipboard2-pulse","clipboard2-x-fill","clipboard2-x","clipboard2","emoji-kiss-fill","emoji-kiss","envelope-heart-fill","envelope-heart","envelope-open-heart-fill","envelope-open-heart","envelope-paper-fill","envelope-paper-heart-fill","envelope-paper-heart","envelope-paper","filetype-aac","filetype-ai","filetype-bmp","filetype-cs","filetype-css","filetype-csv","filetype-doc","filetype-docx","filetype-exe","filetype-gif","filetype-heic","filetype-html","filetype-java","filetype-jpg","filetype-js","filetype-jsx","filetype-key","filetype-m4p","filetype-md","filetype-mdx","filetype-mov","filetype-mp3","filetype-mp4","filetype-otf","filetype-pdf","filetype-php","filetype-png","filetype-ppt","filetype-psd","filetype-py","filetype-raw","filetype-rb","filetype-sass","filetype-scss","filetype-sh","filetype-svg","filetype-tiff","filetype-tsx","filetype-ttf","filetype-txt","filetype-wav","filetype-woff","filetype-xls","filetype-xml","filetype-yml","heart-arrow","heart-pulse-fill","heart-pulse","heartbreak-fill","heartbreak","hearts","hospital-fill","hospital","house-heart-fill","house-heart","incognito","magnet-fill","magnet","person-heart","person-hearts","phone-flip","plugin","postage-fill","postage-heart-fill","postage-heart","postage","postcard-fill","postcard-heart-fill","postcard-heart","postcard","search-heart-fill","search-heart","sliders2-vertical","sliders2","trash3-fill","trash3","valentine","valentine2","wrench-adjustable-circle-fill","wrench-adjustable-circle","wrench-adjustable","filetype-json","filetype-pptx","filetype-xlsx","1-circle-fill","1-circle","1-square-fill","1-square","2-circle-fill","2-circle","2-square-fill","2-square","3-circle-fill","3-circle","3-square-fill","3-square","4-circle-fill","4-circle","4-square-fill","4-square","5-circle-fill","5-circle","5-square-fill","5-square","6-circle-fill","6-circle","6-square-fill","6-square","7-circle-fill","7-circle","7-square-fill","7-square","8-circle-fill","8-circle","8-square-fill","8-square","9-circle-fill","9-circle","9-square-fill","9-square","airplane-engines-fill","airplane-engines","airplane-fill","airplane","alexa","alipay","android","android2","box-fill","box-seam-fill","browser-chrome","browser-edge","browser-firefox","browser-safari","c-circle-fill","c-circle","c-square-fill","c-square","capsule-pill","capsule","car-front-fill","car-front","cassette-fill","cassette","cc-circle-fill","cc-circle","cc-square-fill","cc-square","cup-hot-fill","cup-hot","currency-rupee","dropbox","escape","fast-forward-btn-fill","fast-forward-btn","fast-forward-circle-fill","fast-forward-circle","fast-forward-fill","fast-forward","filetype-sql","fire","google-play","h-circle-fill","h-circle","h-square-fill","h-square","indent","lungs-fill","lungs","microsoft-teams","p-circle-fill","p-circle","p-square-fill","p-square","pass-fill","pass","prescription","prescription2","r-circle-fill","r-circle","r-square-fill","r-square","repeat-1","repeat","rewind-btn-fill","rewind-btn","rewind-circle-fill","rewind-circle","rewind-fill","rewind","train-freight-front-fill","train-freight-front","train-front-fill","train-front","train-lightrail-front-fill","train-lightrail-front","truck-front-fill","truck-front","ubuntu","unindent","unity","universal-access-circle","universal-access","virus","virus2","wechat","yelp","sign-stop-fill","sign-stop-lights-fill","sign-stop-lights","sign-stop","sign-turn-left-fill","sign-turn-left","sign-turn-right-fill","sign-turn-right","sign-turn-slight-left-fill","sign-turn-slight-left","sign-turn-slight-right-fill","sign-turn-slight-right","sign-yield-fill","sign-yield","ev-station-fill","ev-station","fuel-pump-diesel-fill","fuel-pump-diesel","fuel-pump-fill","fuel-pump","0-circle-fill","0-circle","0-square-fill","0-square","rocket-fill","rocket-takeoff-fill","rocket-takeoff","rocket","stripe","subscript","superscript","trello","envelope-at-fill","envelope-at","regex","text-wrap","sign-dead-end-fill","sign-dead-end","sign-do-not-enter-fill","sign-do-not-enter","sign-intersection-fill","sign-intersection-side-fill","sign-intersection-side","sign-intersection-t-fill","sign-intersection-t","sign-intersection-y-fill","sign-intersection-y","sign-intersection","sign-merge-left-fill","sign-merge-left","sign-merge-right-fill","sign-merge-right","sign-no-left-turn-fill","sign-no-left-turn","sign-no-parking-fill","sign-no-parking","sign-no-right-turn-fill","sign-no-right-turn","sign-railroad-fill","sign-railroad","building-add","building-check","building-dash","building-down","building-exclamation","building-fill-add","building-fill-check","building-fill-dash","building-fill-down","building-fill-exclamation","building-fill-gear","building-fill-lock","building-fill-slash","building-fill-up","building-fill-x","building-fill","building-gear","building-lock","building-slash","building-up","building-x","buildings-fill","buildings","bus-front-fill","bus-front","ev-front-fill","ev-front","globe-americas","globe-asia-australia","globe-central-south-asia","globe-europe-africa","house-add-fill","house-add","house-check-fill","house-check","house-dash-fill","house-dash","house-down-fill","house-down","house-exclamation-fill","house-exclamation","house-gear-fill","house-gear","house-lock-fill","house-lock","house-slash-fill","house-slash","house-up-fill","house-up","house-x-fill","house-x","person-add","person-down","person-exclamation","person-fill-add","person-fill-check","person-fill-dash","person-fill-down","person-fill-exclamation","person-fill-gear","person-fill-lock","person-fill-slash","person-fill-up","person-fill-x","person-gear","person-lock","person-slash","person-up","scooter","taxi-front-fill","taxi-front","amd","database-add","database-check","database-dash","database-down","database-exclamation","database-fill-add","database-fill-check","database-fill-dash","database-fill-down","database-fill-exclamation","database-fill-gear","database-fill-lock","database-fill-slash","database-fill-up","database-fill-x","database-fill","database-gear","database-lock","database-slash","database-up","database-x","database","houses-fill","houses","nvidia","person-vcard-fill","person-vcard","sina-weibo","tencent-qq","wikipedia"]; +export default icons; \ No newline at end of file diff --git a/frontend/src/lib/api/default_preferences.ts b/frontend/src/lib/api/default_preferences.ts new file mode 100644 index 0000000..0d564d2 --- /dev/null +++ b/frontend/src/lib/api/default_preferences.ts @@ -0,0 +1,48 @@ +import { type CustomPreferences, PreferenceSize } from "./entities"; + +const defaultPreferences: CustomPreferences = { + favourite: { + icon: "heart-fill", + tooltip: "Favourite", + size: PreferenceSize.Large, + muted: false, + favourite: true, + }, + okay: { + icon: "hand-thumbs-up", + tooltip: "Okay", + size: PreferenceSize.Normal, + muted: false, + favourite: false, + }, + jokingly: { + icon: "emoji-laughing", + tooltip: "Jokingly", + size: PreferenceSize.Normal, + muted: false, + favourite: false, + }, + friends_only: { + icon: "people", + tooltip: "Friends only", + size: PreferenceSize.Normal, + muted: false, + favourite: false, + }, + avoid: { + icon: "hand-thumbs-down", + tooltip: "Avoid", + size: PreferenceSize.Small, + muted: true, + favourite: false, + }, + missing: { + icon: "question-lg", + tooltip: "Unknown (missing)", + size: PreferenceSize.Normal, + muted: false, + favourite: false, + }, +}; + +export default defaultPreferences; diff --git a/frontend/src/lib/api/entities.ts b/frontend/src/lib/api/entities.ts new file mode 100644 index 0000000..61c5c92 --- /dev/null +++ b/frontend/src/lib/api/entities.ts @@ -0,0 +1,222 @@ +import { PUBLIC_BASE_URL, PUBLIC_MEDIA_URL } from "$env/static/public"; + +export const MAX_MEMBERS = 500; +export const MAX_FIELDS = 25; +export const MAX_DESCRIPTION_LENGTH = 1000; + +export interface User { + id: string; + sid: string; + name: string; + display_name: string | null; + bio: string | null; + avatar: string | null; + links: string[]; + member_title: string | null; + badges: number; + + names: FieldEntry[]; + pronouns: Pronoun[]; + members: PartialMember[]; + fields: Field[]; + flags: PrideFlag[]; + custom_preferences: CustomPreferences; +} + +export interface CustomPreferences { + [key: string]: CustomPreference; +} + +export interface CustomPreference { + icon: string; + tooltip: string; + size: PreferenceSize; + muted: boolean; + favourite: boolean; +} + +export enum PreferenceSize { + Large = "large", + Normal = "normal", + Small = "small", +} + +export interface MeUser extends User { + created_at: string; + max_invites: number; + is_admin: boolean; + discord: string | null; + discord_username: string | null; + tumblr: string | null; + tumblr_username: string | null; + google: string | null; + google_username: string | null; + fediverse: string | null; + fediverse_username: string | null; + fediverse_instance: string | null; + list_private: boolean; + last_sid_reroll: string; +} + +export interface Field { + name: string; + entries: FieldEntry[]; +} + +export interface FieldEntry { + value: string; + status: string; +} + +export interface Pronoun { + pronouns: string; + display_text: string | null; + status: string; +} + +export interface PartialMember { + id: string; + sid: string; + name: string; + display_name: string | null; + bio: string | null; + avatar: string | null; + links: string[]; + names: FieldEntry[]; + pronouns: Pronoun[]; +} + +export interface Member extends PartialMember { + fields: Field[]; + flags: PrideFlag[]; + + user: MemberPartialUser; + unlisted?: boolean; +} + +export interface MemberPartialUser { + id: string; + name: string; + display_name: string | null; + avatar: string | null; + custom_preferences: CustomPreferences; +} + +export interface PrideFlag { + id: string; + hash: string; + name: string; + description: string | null; +} + +export interface Invite { + code: string; + created: string; + used: boolean; +} + +export interface Report { + id: string; + user_id: string; + user_name: string; + member_id: string | null; + member_name: string | null; + reason: string; + reporter_id: string; + + created_at: string; + resolved_at: string | null; + admin_id: string | null; + admin_comment: string | null; +} + +export interface Warning { + id: number; + reason: string; + created_at: string; + read: boolean; +} + +export interface APIError { + code: ErrorCode; + message?: string; + details?: string; +} + +export enum ErrorCode { + BadRequest = 400, + Forbidden = 403, + NotFound = 404, + MethodNotAllowed = 405, + TooManyRequests = 429, + InternalServerError = 500, + + InvalidState = 1001, + InvalidOAuthCode = 1002, + InvalidToken = 1003, + InviteRequired = 1004, + InvalidTicket = 1005, + InvalidUsername = 1006, + UsernameTaken = 1007, + InvitesDisabled = 1008, + InviteLimitReached = 1009, + InviteAlreadyUsed = 1010, + RecentExport = 1012, + UnsupportedInstance = 1013, + AlreadyLinked = 1014, + NotLinked = 1015, + LastProvider = 1016, + InvalidCaptcha = 1017, + + UserNotFound = 2001, + + MemberNotFound = 3001, + MemberLimitReached = 3002, + MemberNameInUse = 3003, + NotOwnMember = 3004, + + RequestTooBig = 4001, + MissingPermissions = 4002, +} + +export const pronounDisplay = (entry: Pronoun) => { + if (entry.display_text) return entry.display_text; + + const split = entry.pronouns.split("/"); + if (split.length < 2) return split.join("/"); + else return split.slice(0, 2).join("/"); +}; + +export const userAvatars = (user: User | MeUser | MemberPartialUser) => { + if (!user.avatar) return defaultAvatars; + + return [ + `${PUBLIC_MEDIA_URL}/users/${user.id}/${user.avatar}.webp`, + `${PUBLIC_MEDIA_URL}/users/${user.id}/${user.avatar}.jpg`, + ]; +}; + +export const memberAvatars = (member: Member | PartialMember) => { + if (!member.avatar) return defaultAvatars; + + return [ + `${PUBLIC_MEDIA_URL}/members/${member.id}/${member.avatar}.webp`, + `${PUBLIC_MEDIA_URL}/members/${member.id}/${member.avatar}.jpg`, + ]; +}; + +export const flagURL = ({ hash }: PrideFlag) => `${PUBLIC_MEDIA_URL}/flags/${hash}.webp`; + +export const defaultAvatars = [ + `${PUBLIC_BASE_URL}/default/512.webp`, + `${PUBLIC_BASE_URL}/default/512.jpg`, +]; + +export interface PronounsJson { + pages: Pronouns; + autocomplete: Pronouns; +} + +interface Pronouns { + [key: string]: { pronouns: string[]; display?: string }; +} diff --git a/frontend/src/lib/api/fetch.ts b/frontend/src/lib/api/fetch.ts new file mode 100644 index 0000000..8448c75 --- /dev/null +++ b/frontend/src/lib/api/fetch.ts @@ -0,0 +1,90 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ErrorCode, type APIError } from "./entities"; +import { PUBLIC_BASE_URL } from "$env/static/public"; +import { addToast } from "$lib/toast"; +import { userStore } from "$lib/store"; + +export async function apiFetch( + path: string, + { + method, + body, + token, + headers, + }: { method?: string; body?: any; token?: string; headers?: Record }, +) { + const resp = await fetch(`${PUBLIC_BASE_URL}/api/v1${path}`, { + method: method || "GET", + headers: { + ...(token ? { Authorization: token } : {}), + ...(headers ? headers : {}), + "Content-Type": "application/json", + }, + body: body ? JSON.stringify(body) : null, + }); + + const data = await resp.json(); + if (resp.status < 200 || resp.status >= 400) throw data as APIError; + return data as T; +} + +export const apiFetchClient = async (path: string, method = "GET", body: any = null) => { + try { + const data = await apiFetch(path, { + method, + body, + token: localStorage.getItem("pronouns-token") || undefined, + }); + return data; + } catch (e) { + if ((e as APIError).code === ErrorCode.InvalidToken) { + addToast({ header: "Token expired", body: "Your token has expired, please log in again." }); + userStore.set(null); + localStorage.removeItem("pronouns-token"); + localStorage.removeItem("pronouns-user"); + } + throw e; + } +}; + +/** Fetches the specified path without parsing the response body. */ +export async function fastFetch( + path: string, + { + method, + body, + token, + headers, + }: { method?: string; body?: any; token?: string; headers?: Record }, +) { + const resp = await fetch(`${PUBLIC_BASE_URL}/api/v1${path}`, { + method: method || "GET", + headers: { + ...(token ? { Authorization: token } : {}), + ...(headers ? headers : {}), + "Content-Type": "application/json", + }, + body: body ? JSON.stringify(body) : null, + }); + + if (resp.status < 200 || resp.status >= 400) throw (await resp.json()) as APIError; +} + +/** Fetches the specified path without parsing the response body. */ +export const fastFetchClient = async (path: string, method = "GET", body: any = null) => { + try { + await fastFetch(path, { + method, + body, + token: localStorage.getItem("pronouns-token") || undefined, + }); + } catch (e) { + if ((e as APIError).code === ErrorCode.InvalidToken) { + addToast({ header: "Token expired", body: "Your token has expired, please log in again." }); + userStore.set(null); + localStorage.removeItem("pronouns-token"); + localStorage.removeItem("pronouns-user"); + } + throw e; + } +}; diff --git a/frontend/src/lib/api/regex.ts b/frontend/src/lib/api/regex.ts new file mode 100644 index 0000000..45db77c --- /dev/null +++ b/frontend/src/lib/api/regex.ts @@ -0,0 +1,2 @@ +export const memberNameRegex = /^[^@\\?!#/\\\\[\]"\\{\\}'$%&()+<=>^|~`,]{1,100}$/; +export const usernameRegex = /^[\w-.]{2,40}$/; diff --git a/frontend/src/lib/api/responses.ts b/frontend/src/lib/api/responses.ts new file mode 100644 index 0000000..5a343f8 --- /dev/null +++ b/frontend/src/lib/api/responses.ts @@ -0,0 +1,32 @@ +import type { MeUser } from "./entities"; + +export interface SignupResponse { + user: MeUser; + token: string; +} + +export interface MetaResponse { + git_repository: string; + git_commit: string; + users: MetaUsers; + members: number; + require_invite: boolean; +} + +export interface MetaUsers { + total: number; + active_month: number; + active_week: number; + active_day: number; +} + +export interface UrlsResponse { + discord?: string; + tumblr?: string; + google?: string; +} + +export interface ExportResponse { + path: string; + created_at: string; +} diff --git a/frontend/src/lib/components/ErrorAlert.svelte b/frontend/src/lib/components/ErrorAlert.svelte new file mode 100644 index 0000000..c9cbe2c --- /dev/null +++ b/frontend/src/lib/components/ErrorAlert.svelte @@ -0,0 +1,15 @@ + + + +

An error occurred

+ {error.code}: + {error.message} + {#if error.details} + ({error.details}) + {/if} +
diff --git a/frontend/src/lib/components/FallbackImage.svelte b/frontend/src/lib/components/FallbackImage.svelte new file mode 100644 index 0000000..9572e46 --- /dev/null +++ b/frontend/src/lib/components/FallbackImage.svelte @@ -0,0 +1,38 @@ + + +{#if urls.length > 0} + + {#each urls as url} + + {/each} + + +{:else} + +{/if} diff --git a/frontend/src/lib/components/FieldCard.svelte b/frontend/src/lib/components/FieldCard.svelte new file mode 100644 index 0000000..8abaffd --- /dev/null +++ b/frontend/src/lib/components/FieldCard.svelte @@ -0,0 +1,17 @@ + + +
+

{field.name}

+
    + {#each field.entries as entry} +
  • {entry.value}
  • + {/each} +
+
diff --git a/frontend/src/lib/components/IconButton.svelte b/frontend/src/lib/components/IconButton.svelte new file mode 100644 index 0000000..c131a4d --- /dev/null +++ b/frontend/src/lib/components/IconButton.svelte @@ -0,0 +1,35 @@ + + +{tooltip} + diff --git a/frontend/src/lib/components/PartialMemberCard.svelte b/frontend/src/lib/components/PartialMemberCard.svelte new file mode 100644 index 0000000..fc2b3e4 --- /dev/null +++ b/frontend/src/lib/components/PartialMemberCard.svelte @@ -0,0 +1,64 @@ + + + diff --git a/frontend/src/lib/components/PronounLink.svelte b/frontend/src/lib/components/PronounLink.svelte new file mode 100644 index 0000000..d295a3a --- /dev/null +++ b/frontend/src/lib/components/PronounLink.svelte @@ -0,0 +1,42 @@ + + +{#if shouldLink} + {pronounText} +{:else} + {pronounText} +{/if} diff --git a/frontend/src/lib/components/StatusIcon.svelte b/frontend/src/lib/components/StatusIcon.svelte new file mode 100644 index 0000000..be4977c --- /dev/null +++ b/frontend/src/lib/components/StatusIcon.svelte @@ -0,0 +1,24 @@ + + + +{currentPreference.tooltip} diff --git a/frontend/src/lib/components/StatusLine.svelte b/frontend/src/lib/components/StatusLine.svelte new file mode 100644 index 0000000..021326c --- /dev/null +++ b/frontend/src/lib/components/StatusLine.svelte @@ -0,0 +1,44 @@ + + +{#if currentPreference.size === PreferenceSize.Large} + +{:else} + +{/if} diff --git a/frontend/src/lib/components/Toast.svelte b/frontend/src/lib/components/Toast.svelte new file mode 100644 index 0000000..aae6233 --- /dev/null +++ b/frontend/src/lib/components/Toast.svelte @@ -0,0 +1,13 @@ + + + + {body} + diff --git a/frontend/src/lib/pronouns.json b/frontend/src/lib/pronouns.json new file mode 100644 index 0000000..a06df3b --- /dev/null +++ b/frontend/src/lib/pronouns.json @@ -0,0 +1,22 @@ +{ + "pages": { + "they": { "pronouns": ["they", "them", "their", "theirs", "themself"] }, + "he": { "pronouns": ["he", "him", "his", "his", "himself"] }, + "she": { "pronouns": ["she", "her", "her", "hers", "herself"] }, + "it": { "pronouns": ["it", "it", "its", "its", "itself"], "display": "it/its" } + }, + "autocomplete": { + "they/them": { "pronouns": ["they", "them", "their", "theirs", "themself"] }, + "they/them (singular)": { + "pronouns": ["they", "them", "their", "theirs", "themself"], + "display": "they/them (singular)" + }, + "they/them (plural)": { + "pronouns": ["they", "them", "their", "theirs", "themselves"], + "display": "they/them (plural)" + }, + "he/him": { "pronouns": ["he", "him", "his", "his", "himself"] }, + "she/her": { "pronouns": ["she", "her", "her", "hers", "herself"] }, + "it/its": { "pronouns": ["it", "it", "its", "its", "itself"], "display": "it/its" } + } +} diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts new file mode 100644 index 0000000..2f88900 --- /dev/null +++ b/frontend/src/lib/store.ts @@ -0,0 +1,16 @@ +import { writable } from "svelte/store"; +import { browser } from "$app/environment"; + +import type { MeUser } from "./api/entities"; + +const initialUserValue = null; +export const userStore = writable(initialUserValue); + +const defaultThemeValue = "dark"; +const initialThemeValue = browser + ? window.localStorage.getItem("pronouns-theme") ?? defaultThemeValue + : defaultThemeValue; + +export const themeStore = writable(initialThemeValue); + +export const CURRENT_CHANGELOG = "0.5.3"; diff --git a/frontend/src/lib/toast.ts b/frontend/src/lib/toast.ts new file mode 100644 index 0000000..4c72adf --- /dev/null +++ b/frontend/src/lib/toast.ts @@ -0,0 +1,33 @@ +import { writable } from "svelte/store"; + +export interface ToastData { + header?: string; + body: string; + duration?: number; +} + +interface IdToastData extends ToastData { + id: number; +} + +export const toastStore = writable([]); + +let maxId = 0; + +export const addToast = (data: ToastData) => { + const id = maxId++; + + toastStore.update((toasts) => (toasts = [...toasts, { ...data, id }])); + + if (data.duration !== -1) { + setTimeout(() => { + toastStore.update((toasts) => (toasts = toasts.filter((toast) => toast.id !== id))); + }, data.duration ?? 5000); + } + + return id; +}; + +export const delToast = (id: number) => { + toastStore.update((toasts) => (toasts = toasts.filter((toast) => toast.id !== id))); +}; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..276d98a --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,14 @@ +import MarkdownIt from "markdown-it"; +import sanitize from "sanitize-html"; + +const md = new MarkdownIt({ + html: false, + breaks: true, + linkify: true, +}).disable(["heading", "lheading", "link", "table", "blockquote"]); + +export function renderMarkdown(src: string | null) { + return src ? sanitize(md.render(src)) : null; +} + +export const charCount = (str: string) => [...str].length; diff --git a/frontend/src/routes/+error.svelte b/frontend/src/routes/+error.svelte new file mode 100644 index 0000000..21dc4d0 --- /dev/null +++ b/frontend/src/routes/+error.svelte @@ -0,0 +1,25 @@ + + +

An error occurred ({$page.status})

+ +{#if $page.status === 404} +

+ The page you were looking for was not found. If you're sure the page exists, check for any typos + in the address. +

+{:else if $page.status === 429} +

You've exceeded a rate limit, please try again later.

+{:else if $page.status === 500} +

An internal error occurred. Please try again later.

+

+ If this error keeps happening, please file a bug report with an explanation of what you did to cause the error. +

+{/if} + +

Error message: {$page.error?.message}

diff --git a/frontend/src/routes/+layout.server.ts b/frontend/src/routes/+layout.server.ts new file mode 100644 index 0000000..1af3ac9 --- /dev/null +++ b/frontend/src/routes/+layout.server.ts @@ -0,0 +1,30 @@ +import { building } from "$app/environment"; +import type { LayoutServerLoad } from "./$types"; +import { apiFetch } from "$lib/api/fetch"; +import type { MetaResponse } from "$lib/api/responses"; + +export const load = (async () => { + try { + return await apiFetch("/meta", {}); + } catch (e) { + console.warn("error fetching meta endpoint:", e); + + if (building) { + // just return an empty object--this only affects the three static pages, nothing else, so it's fine + return { + git_repository: "", + git_commit: "", + users: { + total: 0, + active_month: 0, + active_week: 0, + active_day: 0, + }, + members: 0, + require_invite: false, + }; + } else { + throw e; + } + } +}) satisfies LayoutServerLoad; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..e3040c1 --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,72 @@ + + + + + + + + + +
+
+ +
+ +
+ {#each $toastStore as toast} + + {/each} +
+
+
+ + +
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte new file mode 100644 index 0000000..f5e1d10 --- /dev/null +++ b/frontend/src/routes/+page.svelte @@ -0,0 +1,91 @@ + + + + pronouns.cc + + + + + + +
+

pronouns.cc

+ +

Create and share all your pronouns

+ +

+ {#if $userStore} + + {:else} + + {/if} +

+
+ +
+
+

+ Everybody uses pronouns to refer to others. Whether it be he/him, she/her, they/them, or + anything else (or nothing at all!), everybody has preferences. But how do you know which + pronouns you should use? +

+

+ pronouns.cc is a service where you can create and share a list of your preferred names, + pronouns, and terms to share with other people! But not just one set: you can create + multiple + lists and have all of them linked on your main profile. +

+

This is useful for plural systems, people who kin, and anyone else too!

+
+
+ +
+
+

In beta

+

+ pronouns.cc is currently in beta. There might be issues and some + functionality is not available or unfinished. Issue reports and pull requests + in the repository are welcome! +

+
+
+

Isn't this similar to Pronouns.page?

+

+ Yes! Pronouns.page, along + with pronouny.xyz, was a + direct inspiration for pronouns.cc. If those sites are working fine for you, that's great, + they're good projects! +

+
+
+

Open source

+

+ pronouns.cc is + open source, and licensed under the GNU Affero General Public License. Feel free to contribute! +

+
+
+

This is useful!

+

thank you :3

+
+
+

This website sucks!

+

oh no :(

+
+
+

Support pronouns.cc

+

+ If you like pronouns.cc and want to support me financially, check out the about page! +

+
+
diff --git a/frontend/src/routes/@[username]/+error.svelte b/frontend/src/routes/@[username]/+error.svelte new file mode 100644 index 0000000..8e0cbbc --- /dev/null +++ b/frontend/src/routes/@[username]/+error.svelte @@ -0,0 +1,22 @@ + + +

An error occurred ({$page.status})

+ +{#if $page.status === 404} +

The user you were looking for couldn't be found. Please check for any typos.

+{:else if $page.status === 429} +

You've exceeded a rate limit, please try again later.

+{:else if $page.status === 500} +

An internal error occurred. Please try again later.

+

+ If this error keeps happening, please file a bug report with an explanation of what you did to cause the error. +

+{/if} + +

Error message: {$page.error?.message}

diff --git a/frontend/src/routes/@[username]/+page.server.ts b/frontend/src/routes/@[username]/+page.server.ts new file mode 100644 index 0000000..9a4c26f --- /dev/null +++ b/frontend/src/routes/@[username]/+page.server.ts @@ -0,0 +1,19 @@ +import { apiFetch } from "$lib/api/fetch"; +import { ErrorCode, type APIError, type User } from "$lib/api/entities"; +import { error } from "@sveltejs/kit"; + +export const load = async ({ params }) => { + try { + const resp = await apiFetch(`/users/${params.username}`, { + method: "GET", + }); + + return resp; + } catch (e) { + if ((e as APIError).code === ErrorCode.UserNotFound) { + throw error(404, (e as APIError).message); + } + + throw e; + } +}; diff --git a/frontend/src/routes/@[username]/+page.svelte b/frontend/src/routes/@[username]/+page.svelte new file mode 100644 index 0000000..65230c7 --- /dev/null +++ b/frontend/src/routes/@[username]/+page.svelte @@ -0,0 +1,384 @@ + + +
+ {#if $userStore && $userStore.id === data.id} + + You are currently viewing your public profile. +
Edit your profile +
+ {/if} +
+
+
+ + {#if data.flags && data.bio} +
+ {#each data.flags as flag} + + {/each} +
+ {/if} +
+
+ {#if data.display_name} +
+

{data.display_name}

+

@{data.name}

+
+ {:else} +

@{data.name}

+ {/if} + {#if profileEmpty && $userStore?.id === data.id} +
+

+ + Your profile is empty! You can customize it by going to the edit profile page. (only you can see this) +

+ {:else if bio} +
+

{@html bio}

+ {/if} +
+ {#if data.links.length > 0} +
+
    + {#each data.links as link} + + {/each} +
+
+ {/if} +
+ {#if data.flags && !data.bio} +
+ {#each data.flags as flag} + + {/each} +
+ {/if} +
+ {#if data.names.length > 0} +
+

Names

+
    + {#each data.names as name} +
  • + {name.value} +
  • + {/each} +
+
+ {/if} + {#if data.pronouns.length > 0} +
+

Pronouns

+
    + {#each data.pronouns as pronouns} +
  • + +
  • + {/each} +
+
+ {/if} + {#each data.fields as field} +
+ +
+ {/each} +
+
+
+ + + {#if PUBLIC_SHORT_BASE} + + {/if} + {#if $userStore && $userStore.id !== data.id} + + {/if} + +
+
+
+ {#if data.members.length > 0 || ($userStore && $userStore.id === data.id)} +
+
+
+

+ {data.member_title || "Members"} + {#if $userStore && $userStore.id === data.id} + + {/if} + {#if totalPages > 1} + + + + + + {/if} +

+
+
+ {#if data.members.length > 0} +
+ {#each memberSlice as member} + + {/each} +
+ {#if totalPages > 1} +
+ + + + + +
+ {/if} + {:else} +
+

+ You don't have any members yet. +
+ Members are sub-profiles that can have their own avatar, names, pronouns, and preferred terms. +
+ If you were expecting to see members here, check your + list of hidden members. + (only you can see this) +

+
+ {/if} + {/if} + + + +

+ Your members must have distinct names. Member + names must be 100 characters long at most, and cannot contain the following characters: @ ? + ! # / \ [ ] " ' $ % & ( ) { } + < = > ^ | ~ ` and , +

+ {#if newMemberError} + + {/if} +
+ + {#if !memberNameValid && newMemberName.length > 0} + That member name is not valid. + {/if} + + + +
+
+
+ + + @{data.name} - pronouns.cc + + + + + + + {#if data.avatar} + + {/if} + + {#if favNames.length !== 0 && favPronouns.length !== 0} + + {:else if favNames.length !== 0} + + {:else if favPronouns.length !== 0} + + {:else if data.bio && data.bio !== ""} + + {:else} + + {/if} + diff --git a/frontend/src/routes/@[username]/ProfileFlag.svelte b/frontend/src/routes/@[username]/ProfileFlag.svelte new file mode 100644 index 0000000..3ef4c63 --- /dev/null +++ b/frontend/src/routes/@[username]/ProfileFlag.svelte @@ -0,0 +1,22 @@ + + + + {flag.description ?? flag.name} + {flag.description + {flag.name} + + + diff --git a/frontend/src/routes/@[username]/ProfileLink.svelte b/frontend/src/routes/@[username]/ProfileLink.svelte new file mode 100644 index 0000000..175003c --- /dev/null +++ b/frontend/src/routes/@[username]/ProfileLink.svelte @@ -0,0 +1,35 @@ + + +{#if isLink} + +
  • + + {displayLink} +
  • +
    +{:else} +
  • + + {displayLink} +
  • +{/if} diff --git a/frontend/src/routes/@[username]/ReportButton.svelte b/frontend/src/routes/@[username]/ReportButton.svelte new file mode 100644 index 0000000..79dbc82 --- /dev/null +++ b/frontend/src/routes/@[username]/ReportButton.svelte @@ -0,0 +1,61 @@ + + + + + + + {#if error} + + {/if} + +