commit fe48b73de38c6bea0422040e03f8e7f8a6a35e38 Author: Ryan Stafford Date: Fri Jun 30 15:41:35 2023 -0400 init diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1bd6c58 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM golang:1.20-bullseye as builder +RUN git config --global --add safe.directory /app +WORKDIR /app +COPY go.* ./ +RUN go mod download +COPY . ./ +RUN go build -v -o mlmym + +FROM debian:bullseye-slim +WORKDIR /app +RUN set -x && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \ + ca-certificates && \ + rm -rf /var/lib/apt/lists/* +COPY --from=builder /app/mlmym /app/mlmym +COPY --from=builder /app/templates /app/templates +COPY --from=builder /app/public /app/public +CMD ["./mlmym", "--addr", "0.0.0.0:8080"] 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..ca918f2 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +.PHONY: dev reload serve style + +all: + $(MAKE) -j3 --no-print-directory dev + +dev: reload serve style + +reload: + #websocketd --port=8080 watchexec -w public echo reload &>/dev/null + websocketd --loglevel=fatal --port=8009 watchexec --no-vcs-ignore -e html,css,js -d 500 -w public 'echo "$$WATCHEXEC_WRITTEN_PATH"' + +serve: + #python -m http.server --directory ./public 8081 &>/dev/null + watchexec -e go -r "go run . --addr 0.0.0.0:8008 -w" + +style: + npm run watchcss > /dev/null 2>&1 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..88f3104 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module mlmym + +go 1.19 + +require ( + github.com/cenkalti/backoff/v4 v4.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/gorilla/securecookie v1.1.1 // indirect + github.com/gorilla/sessions v1.2.1 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + github.com/julienschmidt/httprouter v1.3.0 // indirect + github.com/rystaf/go-lemmy v0.0.0-20230623191350-f39e3c8bdcb5 // indirect + github.com/yuin/goldmark v1.5.4 // indirect + go.elara.ws/go-lemmy v0.17.3 // indirect + golang.org/x/text v0.10.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..66acff2 --- /dev/null +++ b/go.sum @@ -0,0 +1,38 @@ +github.com/cenkalti/backoff/v4 v4.2.0 h1:HN5dHm3WBOgndBH6E8V0q2jIYIR3s9yglV8k/+MN3u4= +github.com/cenkalti/backoff/v4 v4.2.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/rystaf/go-lemmy v0.0.0-20230622213726-c394de37235c h1:VxOcsDMWaqoBKbhoiSBxPl1zZ62YZ/VAW2nxlBRJiow= +github.com/rystaf/go-lemmy v0.0.0-20230622213726-c394de37235c/go.mod h1:nRSkTD+ARAHXtqlSPdf5q3hjHLP1ALsS1m5D3o86o+4= +github.com/rystaf/go-lemmy v0.0.0-20230622214853-5f2ab0756865 h1:xitFpcTOSP8RlZWR569yY75B2/7WX08rQQVG+0Mi4SA= +github.com/rystaf/go-lemmy v0.0.0-20230622214853-5f2ab0756865/go.mod h1:nRSkTD+ARAHXtqlSPdf5q3hjHLP1ALsS1m5D3o86o+4= +github.com/rystaf/go-lemmy v0.0.0-20230622215253-d38b61ec174f h1:EueAC5v+8oX9xK9bT36Tpgbz+c66wUZx5zmyxePurbw= +github.com/rystaf/go-lemmy v0.0.0-20230622215253-d38b61ec174f/go.mod h1:nRSkTD+ARAHXtqlSPdf5q3hjHLP1ALsS1m5D3o86o+4= +github.com/rystaf/go-lemmy v0.0.0-20230622222647-983d49e1d285 h1:tihBOF3ejTXzYVftaflwqRAXnaY4W9q3iNiE3YMF+D8= +github.com/rystaf/go-lemmy v0.0.0-20230622222647-983d49e1d285/go.mod h1:nRSkTD+ARAHXtqlSPdf5q3hjHLP1ALsS1m5D3o86o+4= +github.com/rystaf/go-lemmy v0.0.0-20230622230518-ee2cfdf288a4 h1:++T5SoZzghtfNJprWlXiRSpPPdnMSSZgIWWAnPoGx/w= +github.com/rystaf/go-lemmy v0.0.0-20230622230518-ee2cfdf288a4/go.mod h1:nRSkTD+ARAHXtqlSPdf5q3hjHLP1ALsS1m5D3o86o+4= +github.com/rystaf/go-lemmy v0.0.0-20230623185656-962f9bf8359d h1:ORS2KIBuT+wBn4wJncF1SoLDCVCAUPHASHpQ+Y3TnRI= +github.com/rystaf/go-lemmy v0.0.0-20230623185656-962f9bf8359d/go.mod h1:nRSkTD+ARAHXtqlSPdf5q3hjHLP1ALsS1m5D3o86o+4= +github.com/rystaf/go-lemmy v0.0.0-20230623191111-7ff8c74b1935 h1:zmzUz6PGRB8yQTT6BRaZNTgNlrk6L7e72dzTnWJTw+I= +github.com/rystaf/go-lemmy v0.0.0-20230623191111-7ff8c74b1935/go.mod h1:nRSkTD+ARAHXtqlSPdf5q3hjHLP1ALsS1m5D3o86o+4= +github.com/rystaf/go-lemmy v0.0.0-20230623191350-f39e3c8bdcb5 h1:MoI87uid2KqpLdUMZGK2HBOuxJMnPOJaar/4Og2PshM= +github.com/rystaf/go-lemmy v0.0.0-20230623191350-f39e3c8bdcb5/go.mod h1:nRSkTD+ARAHXtqlSPdf5q3hjHLP1ALsS1m5D3o86o+4= +github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU= +github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.elara.ws/go-lemmy v0.17.3 h1:644k23BS2xqKJHJ9cHd8eyt1INpb5myqsBQQL2chBiA= +go.elara.ws/go-lemmy v0.17.3/go.mod h1:rurQND/HT3yWfX/T4w+hb6vEwRAeAlV+9bSGFkkx5rA= +golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58= +golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/main.go b/main.go new file mode 100644 index 0000000..b1feb74 --- /dev/null +++ b/main.go @@ -0,0 +1,115 @@ +package main + +import ( + "flag" + "fmt" + "html/template" + "log" + "net" + "net/http" + "os" + + "github.com/gorilla/sessions" + "github.com/julienschmidt/httprouter" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" +) + +var watch = flag.Bool("w", false, "watch for file changes") +var addr = flag.String("addr", ":80", "http service address") +var md goldmark.Markdown +var templates map[string]*template.Template +var store = sessions.NewCookieStore([]byte(os.Getenv("SESSIONSECRET"))) + +type AddHeaderTransport struct { + T http.RoundTripper + ForwardFor string +} + +func (adt *AddHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Add("User-Agent", "Mlmym") + if adt.ForwardFor != "" { + req.Header.Add("X-Forwarded-For", adt.ForwardFor) + req.Header.Add("X-Real-IP", adt.ForwardFor) + } + return adt.T.RoundTrip(req) +} + +func NewAddHeaderTransport(remoteAddr string) *AddHeaderTransport { + var forwardFor string + if host, _, err := net.SplitHostPort(remoteAddr); err == nil { + if ip := net.ParseIP(host); ip != nil { + if !ip.IsPrivate() { + forwardFor = ip.String() + } + } + } + return &AddHeaderTransport{ + T: http.DefaultTransport, + ForwardFor: forwardFor, + } +} + +func init() { + md = goldmark.New(goldmark.WithExtensions(extension.Linkify)) + templates = make(map[string]*template.Template) + if !*watch { + for _, name := range []string{"index.html", "login.html", "frontpage.html", "root.html"} { + t := template.New(name).Funcs(funcMap) + glob, err := t.ParseGlob("templates/*") + if err != nil { + fmt.Println(err) + continue + } + templates[name] = glob + } + } +} +func middleware(n httprouter.Handle) httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + if ps.ByName("host") != "" && !IsLemmy(ps.ByName("host")) { + http.Redirect(w, r, "/", 301) + return + } + n(w, r, ps) + } +} +func main() { + flag.Parse() + log.Println("serve", *addr) + router := httprouter.New() + router.ServeFiles("/:host/static/*filepath", http.Dir("public")) + router.GET("/", middleware(GetRoot)) + router.POST("/", middleware(PostRoot)) + router.GET("/:host/", middleware(GetFrontpage)) + router.GET("/:host/search", middleware(Search)) + router.POST("/:host/search", middleware(UserOp)) + router.GET("/:host/inbox", middleware(Inbox)) + router.GET("/:host/login", middleware(GetLogin)) + router.POST("/:host/login", middleware(SignUpOrLogin)) + router.POST("/:host/", middleware(UserOp)) + router.GET("/:host/icon.jpg", middleware(GetIcon)) + router.GET("/:host/c/:community", middleware(GetFrontpage)) + router.POST("/:host/c/:community", middleware(UserOp)) + router.GET("/:host/c/:community/search", middleware(Search)) + router.GET("/:host/post/:postid", middleware(GetPost)) + router.POST("/:host/post/:postid", middleware(UserOp)) + router.GET("/:host/comment/:commentid", middleware(GetComment)) + router.GET("/:host/comment/:commentid/:op", middleware(GetComment)) + router.POST("/:host/comment/:commentid", middleware(UserOp)) + router.GET("/:host/u/:username", middleware(GetUser)) + router.GET("/:host/u/:username/message", middleware(GetMessageForm)) + router.POST("/:host/u/:username/message", middleware(SendMessage)) + router.POST("/:host/u/:username", middleware(UserOp)) + router.GET("/:host/u/:username/search", middleware(Search)) + router.GET("/:host/create_post", middleware(GetCreatePost)) + router.POST("/:host/create_post", middleware(UserOp)) + router.GET("/:host/create_community", middleware(GetCreateCommunity)) + router.POST("/:host/create_community", middleware(UserOp)) + + err := http.ListenAndServe(*addr, router) + if err != nil { + log.Fatal("ListenAndServe: ", err) + } + +} diff --git a/routes.go b/routes.go new file mode 100644 index 0000000..c041a1c --- /dev/null +++ b/routes.go @@ -0,0 +1,863 @@ +package main + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "html/template" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/dustin/go-humanize" + "github.com/julienschmidt/httprouter" + "github.com/rystaf/go-lemmy" + "github.com/rystaf/go-lemmy/types" + "golang.org/x/text/language" + "golang.org/x/text/message" +) + +var funcMap = template.FuncMap{ + "proxy": func(s string) string { + u, err := url.Parse(s) + if err != nil { + return s + } + return "/" + u.Host + u.Path + }, + "printer": func(n any) string { + p := message.NewPrinter(language.English) + return p.Sprintf("%d", n) + }, + "likedPerc": func(c types.PostAggregates) string { + return fmt.Sprintf("%.1f", (float64(c.Upvotes)/float64(c.Upvotes+c.Downvotes))*100) + }, + "fullname": func(person types.PersonSafe) string { + if person.Local { + return person.Name + } + l, err := url.Parse(person.ActorID) + if err != nil { + fmt.Println(err) + return person.Name + } + return person.Name + "@" + l.Host + }, + "fullcname": func(c types.CommunitySafe) string { + if c.Local { + return c.Name + } + l, err := url.Parse(c.ActorID) + if err != nil { + fmt.Println(err) + return c.Name + } + return c.Name + "@" + l.Host + }, + "isMod": func(c *types.GetCommunityResponse, username string) bool { + for _, mod := range c.Moderators { + if mod.Moderator.Local && username == mod.Moderator.Name { + return true + } + } + return false + }, + "host": func(p Post) string { + if p.Post.URL.IsValid() { + l, err := url.Parse(p.Post.URL.String()) + if err != nil { + return "" + } + return l.Host + } + if p.Post.Local { + return "self." + p.Community.Name + } + l, err := url.Parse(p.Post.ApID) + if err != nil { + return "" + } + return l.Host + }, + "membership": func(s types.SubscribedType) string { + switch s { + case types.SubscribedTypeSubscribed: + return "leave" + case types.SubscribedTypeNotSubscribed: + return "join" + case types.SubscribedTypePending: + return "pending" + } + return "" + }, + "isImage": func(url string) bool { + ext := url[len(url)-4:] + if ext == "jpeg" || ext == ".jpg" || ext == ".png" || ext == "webp" || ext == ".gif" { + return true + } + return false + }, + "humanize": humanize.Time, + "markdown": func(host string, body string) template.HTML { + var buf bytes.Buffer + if err := md.Convert([]byte(body), &buf); err != nil { + panic(err) + } + converted := strings.Replace(buf.String(), `href="https://`+host, `href="/`+host, -1) + return template.HTML(converted) + }, + "contains": strings.Contains, + "sub": func(a int, b int) int { + return int(a) - b + }, +} + +func Initialize(Host string, r *http.Request) (State, error) { + state := State{ + Host: Host, + Sort: "Hot", + Page: 1, + Status: http.StatusOK, + } + state.ParseQuery(r.URL.RawQuery) + client := http.Client{Transport: NewAddHeaderTransport(r.RemoteAddr)} + c, err := lemmy.NewWithClient("https://"+state.Host, &client) + if err != nil { + fmt.Println(err) + state.Status = http.StatusInternalServerError + return state, err + } + state.HTTPClient = &client + state.Client = c + session, err := store.Get(r, state.Host) + if err == nil { + token, ok1 := session.Values["token"].(string) + username, ok2 := session.Values["username"].(string) + userid, ok3 := session.Values["id"].(int) + if ok1 && ok2 && ok3 { + state.Client.Token = token + sess := Session{ + UserName: username, + UserID: userid, + } + state.Session = &sess + if state.Listing == "" { + state.Listing = "Subscribed" + } + } + } + if state.Listing == "" { + state.Listing = "All" + } + return state, nil +} +func GetTemplate(name string) (*template.Template, error) { + if *watch { + t := template.New(name).Funcs(funcMap) + glob, err := t.ParseGlob("templates/*") + if err != nil { + return nil, err + } + return glob, nil + } + t, ok := templates[name] + if !ok { + return nil, errors.New("template not found") + } + return t, nil +} +func Render(w http.ResponseWriter, templateName string, state State) { + tmpl, err := GetTemplate(templateName) + if err != nil { + w.Write([]byte("500 - Server Error")) + return + } + if len(state.TopCommunities) == 0 { + state.GetCommunities() + } + if state.Session != nil { + state.GetUnreadCount() + } + if state.Status != http.StatusOK { + w.WriteHeader(state.Status) + } + err = tmpl.Execute(w, state) + if err != nil { + fmt.Println("execute fail", err) + w.Write([]byte("500 - Server Error")) + return + } +} +func GetRoot(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + data := make(map[string]any) + tmpl, err := GetTemplate("root.html") + if err != nil { + fmt.Println("execute fail", err) + w.Write([]byte("500 - Server Error")) + return + } + tmpl.Execute(w, data) +} + +type NodeSoftware struct { + Name string `json:"name"` + Version string `json:"version"` +} +type NodeInfo struct { + Software NodeSoftware `json:"software"` +} + +func IsLemmy(domain string) bool { + var nodeInfo NodeInfo + res, err := http.Get("https://" + domain + "/nodeinfo/2.0.json") + if err != nil { + return false + } + err = json.NewDecoder(res.Body).Decode(&nodeInfo) + if err != nil { + return false + } + if nodeInfo.Software.Name == "lemmy" { + return true + } + return false +} + +func PostRoot(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + data := make(map[string]any) + tmpl, err := GetTemplate("root.html") + if err != nil { + fmt.Println("execute fail", err) + w.Write([]byte("500 - Server Error")) + return + } + + var dest url.URL + re := regexp.MustCompile(`^(([a-zA-Z]{1})|([a-zA-Z]{1}[a-zA-Z]{1})|([a-zA-Z]{1}[0-9]{1})|([0-9]{1}[a-zA-Z]{1})|([a-zA-Z0-9][a-zA-Z0-9-_]{1,61}[a-zA-Z0-9]))\.([a-zA-Z]{2,6}|[a-zA-Z0-9-]{2,30}\.[a-zA-Z + ]{2,3})$`) + if re.MatchString(r.FormValue("destination")) { + dest.Host = r.FormValue("destination") + } else if u, err := url.Parse(r.FormValue("destination")); err == nil && u.Host != "" { + dest.Parse(u.String()) + } + if dest.Host != "" && IsLemmy(dest.Host) { + redirectUrl := "/" + dest.Host + dest.Path + if dest.RawQuery != "" { + redirectUrl = redirectUrl + "?" + dest.RawQuery + } + http.Redirect(w, r, redirectUrl, 301) + return + } + data["Error"] = "Invalid destination" + tmpl.Execute(w, data) +} +func GetIcon(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + if ps.ByName("host") == "favicon.ico" { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("404 - Not Found")) + } + state, err := Initialize(ps.ByName("host"), r) + state.Client.Token = "" + resp, err := state.Client.Site(context.Background(), types.GetSite{}) + if err != nil { + fmt.Println(err) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("500 - Server Error")) + return + } + if !resp.SiteView.Site.Icon.IsValid() { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("404 - Not Found")) + return + } + iresp, err := state.HTTPClient.Get(resp.SiteView.Site.Icon.String()) + if err != nil { + fmt.Println(err) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("500 - Server Error")) + return + } + defer iresp.Body.Close() + w.Header().Set("Content-Type", "image/jpeg") + w.Header().Set("Cache-Control", "max-age=2592000") + io.Copy(w, iresp.Body) + return + +} +func GetFrontpage(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + state, err := Initialize(ps.ByName("host"), r) + if err != nil { + Render(w, "index.html", state) + return + } + m, _ := url.ParseQuery(r.URL.RawQuery) + if len(m["edit"]) > 0 { + state.Op = "edit_community" + } + if ps.ByName("community") == "" || state.Op == "edit_community" { + state.GetSite() + } + state.GetCommunity(ps.ByName("community")) + if state.Op == "" { + state.GetPosts() + } + Render(w, "frontpage.html", state) +} + +func GetPost(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + state, err := Initialize(ps.ByName("host"), r) + if err != nil { + Render(w, "index.html", state) + return + } + m, _ := url.ParseQuery(r.URL.RawQuery) + if len(m["edit"]) > 0 { + state.Op = "edit_post" + state.GetSite() + } + postid, _ := strconv.Atoi(ps.ByName("postid")) + state.GetPost(postid) + state.GetComments() + Render(w, "index.html", state) +} +func GetComment(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + state, err := Initialize(ps.ByName("host"), r) + if err != nil { + Render(w, "index.html", state) + return + } + m, _ := url.ParseQuery(r.URL.RawQuery) + if len(m["reply"]) > 0 { + state.Op = "reply" + } + if len(m["edit"]) > 0 { + state.Op = "edit" + } + if len(m["source"]) > 0 { + state.Op = "source" + } + commentid, _ := strconv.Atoi(ps.ByName("commentid")) + state.GetComment(commentid) + state.GetPost(state.PostID) + Render(w, "index.html", state) +} +func GetUser(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + state, err := Initialize(ps.ByName("host"), r) + if err != nil { + Render(w, "index.html", state) + return + } + state.GetUser(ps.ByName("username")) + Render(w, "index.html", state) +} +func GetMessageForm(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + state, err := Initialize(ps.ByName("host"), r) + if err != nil { + Render(w, "index.html", state) + return + } + state.Op = "send_message" + state.GetUser(ps.ByName("username")) + Render(w, "index.html", state) +} +func SendMessage(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + state, err := Initialize(ps.ByName("host"), r) + if err != nil { + Render(w, "index.html", state) + return + } + userid, _ := strconv.Atoi(r.FormValue("userid")) + _, err = state.Client.CreatePrivateMessage(context.Background(), types.CreatePrivateMessage{ + Content: r.FormValue("content"), + RecipientID: userid, + }) + if err != nil { + state.Error = err + Render(w, "index.html", state) + return + } + r.URL.Path = "/" + state.Host + "/inbox" + http.Redirect(w, r, r.URL.String(), 301) +} +func GetCreatePost(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + state, err := Initialize(ps.ByName("host"), r) + if err != nil { + Render(w, "index.html", state) + return + } + state.GetSite() + state.GetCommunity("") + state.Op = "create_post" + Render(w, "index.html", state) +} +func GetCreateCommunity(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + state, err := Initialize(ps.ByName("host"), r) + if err != nil { + Render(w, "index.html", state) + return + } + state.GetSite() + state.Op = "create_community" + Render(w, "index.html", state) +} + +func Inbox(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + state, err := Initialize(ps.ByName("host"), r) + if err != nil { + Render(w, "index.html", state) + return + } + state.GetMessages() + Render(w, "index.html", state) + state.MarkAllAsRead() +} + +func SignUpOrLogin(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + state, err := Initialize(ps.ByName("host"), r) + if err != nil { + Render(w, "index.html", state) + return + } + var token string + switch r.FormValue("submit") { + case "log in": + resp, err := state.Client.Login(context.Background(), types.Login{ + UsernameOrEmail: r.FormValue("username"), + Password: r.FormValue("password"), + }) + if err != nil { + state.Error = err + state.GetSite() + state.GetCaptcha() + Render(w, "login.html", state) + return + } + if resp.JWT.IsValid() { + token = resp.JWT.String() + } + case "sign up": + register := types.Register{ + Username: r.FormValue("username"), + Password: r.FormValue("password"), + PasswordVerify: r.FormValue("passwordverify"), + ShowNSFW: r.FormValue("nsfw") != "", + } + if r.FormValue("email") != "" { + register.Email = types.NewOptional(r.FormValue("email")) + } + if r.FormValue("answer") != "" { + register.Answer = types.NewOptional(r.FormValue("answer")) + } + if r.FormValue("captchauuid") != "" { + register.CaptchaUuid = types.NewOptional(r.FormValue("captchauuid")) + } + if r.FormValue("captchaanswer") != "" { + register.CaptchaAnswer = types.NewOptional(r.FormValue("captchaanswer")) + } + resp, err := state.Client.Register(context.Background(), register) + if err != nil { + state.Error = err + state.GetSite() + state.GetCaptcha() + Render(w, "login.html", state) + return + } + if resp.JWT.IsValid() { + token = resp.JWT.String() + } else { + var alert string + if resp.RegistrationCreated { + alert = "Registration application submitted. " + } + if resp.VerifyEmailSent { + alert = alert + "Email verification sent. " + } + q := r.URL.Query() + q.Add("alert", alert) + r.URL.RawQuery = q.Encode() + http.Redirect(w, r, r.URL.String(), 301) + } + } + if token != "" { + session, err := store.Get(r, state.Host) + if err != nil { + state.Error = err + state.GetSite() + state.GetCaptcha() + Render(w, "login.html", state) + return + } + if resp, err := state.Client.Site(context.Background(), types.GetSite{ + Auth: types.NewOptional(token), + }); err != nil { + fmt.Println(err) + return + } else if myUser, err := resp.MyUser.Value(); err == nil { + // Error is nil when value is nil? + return + } else { + session.Values["username"] = myUser.LocalUserView.Person.Name + session.Values["id"] = myUser.LocalUserView.Person.ID + } + session.Values["token"] = token + session.Save(r, w) + r.URL.Path = "/" + state.Host + http.Redirect(w, r, r.URL.String(), 301) + return + } +} +func GetLogin(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + state, err := Initialize(ps.ByName("host"), r) + if err != nil { + Render(w, "index.html", state) + return + } + state.GetSite() + state.GetCaptcha() + m, _ := url.ParseQuery(r.URL.RawQuery) + if len(m["alert"]) > 0 { + state.Alert = m["alert"][0] + } + Render(w, "login.html", state) +} +func Search(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + state, err := Initialize(ps.ByName("host"), r) + if err != nil { + Render(w, "index.html", state) + return + } + if state.CommunityName != "" { + state.GetCommunity(ps.ByName("community")) + } + if state.UserName != "" { + state.GetUser(state.UserName) + } else if state.Community == nil { + state.GetSite() + } + m, _ := url.ParseQuery(r.URL.RawQuery) + state.SearchType = "Posts" + if len(m["searchtype"]) > 0 { + switch m["searchtype"][0] { + case "Comments": + state.SearchType = "Comments" + case "Communities": + state.SearchType = "Communities" + state.Listing = "All" + } + } + state.Search(state.SearchType) + Render(w, "index.html", state) +} + +type PictrsFile struct { + Filename string `json:"file"` + DeleteToken string `json:"delete_token"` +} + +type PictrsResponse struct { + Message string `json:"msg"` + Files []PictrsFile `json:"files"` +} + +func UserOp(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + state, err := Initialize(ps.ByName("host"), r) + if err != nil { + Render(w, "index.html", state) + return + } + fmt.Println("user op ", r.FormValue("op")) + switch r.FormValue("op") { + case "leave": + communityid, _ := strconv.Atoi(r.FormValue("communityid")) + state.Client.FollowCommunity(context.Background(), types.FollowCommunity{ + CommunityID: communityid, + Follow: false, + }) + case "join": + communityid, _ := strconv.Atoi(r.FormValue("communityid")) + state.Client.FollowCommunity(context.Background(), types.FollowCommunity{ + CommunityID: communityid, + Follow: true, + }) + case "logout": + if session, err := store.Get(r, state.Host); err == nil { + session.Options.MaxAge = -1 + session.Save(r, w) + } + case "login": + resp, err := state.Client.Login(context.Background(), types.Login{ + UsernameOrEmail: r.FormValue("user"), + Password: r.FormValue("pass"), + }) + if err != nil { + state.Status = http.StatusUnauthorized + } + if resp.JWT.IsValid() { + session, err := store.Get(r, state.Host) + if err == nil { + state.GetUser(r.FormValue("user")) + session.Values["token"] = resp.JWT.String() + session.Values["username"] = state.User.PersonView.Person.Name + session.Values["id"] = state.User.PersonView.Person.ID + session.Save(r, w) + } + } + case "create_community": + state.GetSite() + community := types.CreateCommunity{ + Name: r.FormValue("name"), + Title: r.FormValue("title"), + } + if r.FormValue("description") != "" { + community.Description = types.NewOptional(r.FormValue("description")) + } + if file, handler, err := r.FormFile("icon"); err == nil { + pres, err := state.UploadImage(file, handler) + if err != nil { + state.Error = err + Render(w, "index.html", state) + return + } + community.Icon = types.NewOptional("https://" + state.Host + "/pictrs/image/" + pres.Files[0].Filename) + } + if file, handler, err := r.FormFile("banner"); err == nil { + pres, err := state.UploadImage(file, handler) + if err != nil { + state.Error = err + Render(w, "index.html", state) + return + } + community.Banner = types.NewOptional("https://" + state.Host + "/pictrs/image/" + pres.Files[0].Filename) + } + resp, err := state.Client.CreateCommunity(context.Background(), community) + if err == nil { + r.URL.Path = "/" + state.Host + "/c/" + resp.CommunityView.Community.Name + } else { + fmt.Println(err) + } + case "edit_community": + state.CommunityName = ps.ByName("community") + state.GetCommunity("") + if state.Community == nil { + Render(w, "index.html", state) + return + } + state.GetSite() + community := types.EditCommunity{ + CommunityID: state.Community.CommunityView.Community.ID, + } + if r.FormValue("title") != "" { + community.Title = types.NewOptional(r.FormValue("title")) + } + if r.FormValue("description") != "" { + community.Description = types.NewOptional(r.FormValue("description")) + } + if file, handler, err := r.FormFile("icon"); err == nil { + pres, err := state.UploadImage(file, handler) + if err != nil { + state.Error = err + Render(w, "index.html", state) + return + } + community.Icon = types.NewOptional("https://" + state.Host + "/pictrs/image/" + pres.Files[0].Filename) + } + if file, handler, err := r.FormFile("banner"); err == nil { + pres, err := state.UploadImage(file, handler) + if err != nil { + state.Error = err + Render(w, "index.html", state) + return + } + community.Banner = types.NewOptional("https://" + state.Host + "/pictrs/image/" + pres.Files[0].Filename) + } + resp, err := state.Client.EditCommunity(context.Background(), community) + if err == nil { + r.URL.Path = "/" + state.Host + "/c/" + resp.CommunityView.Community.Name + } else { + fmt.Println(err) + } + case "create_post": + state.CommunityName = r.FormValue("communityname") + state.GetCommunity("") + state.GetSite() + if state.Community == nil { + state.Status = http.StatusBadRequest + state.Op = "create_post" + Render(w, "index.html", state) + return + } + post := types.CreatePost{ + Name: r.FormValue("name"), + CommunityID: state.Community.CommunityView.Community.ID, + } + if r.FormValue("url") != "" { + post.URL = types.NewOptional(r.FormValue("url")) + } + file, handler, err := r.FormFile("file") + if err == nil { + pres, err := state.UploadImage(file, handler) + if err != nil { + state.Error = err + Render(w, "index.html", state) + return + } + post.URL = types.NewOptional("https://" + state.Host + "/pictrs/image/" + pres.Files[0].Filename) + } + if r.FormValue("body") != "" { + post.Body = types.NewOptional(r.FormValue("body")) + } + if r.FormValue("language") != "" { + languageid, _ := strconv.Atoi(r.FormValue("language")) + post.LanguageID = types.NewOptional(languageid) + } + resp, err := state.Client.CreatePost(context.Background(), post) + if err == nil { + postid := strconv.Itoa(resp.PostView.Post.ID) + r.URL.Path = "/" + state.Host + "/post/" + postid + } else { + fmt.Println(err) + } + case "edit_post": + r.ParseMultipartForm(10 << 20) + state.GetSite() + postid, _ := strconv.Atoi(ps.ByName("postid")) + post := types.EditPost{ + PostID: postid, + Body: types.NewOptional(r.FormValue("body")), + URL: types.NewOptional(r.FormValue("url")), + } + if r.FormValue("url") == "" { + post.URL = types.Optional[string]{} + } + if r.FormValue("language") != "" { + languageid, _ := strconv.Atoi(r.FormValue("language")) + post.LanguageID = types.NewOptional(languageid) + } + file, handler, err := r.FormFile("file") + if err == nil { + pres, err := state.UploadImage(file, handler) + if err != nil { + state.Error = err + Render(w, "index.html", state) + return + } + post.URL = types.NewOptional("https://" + state.Host + "/pictrs/image/" + pres.Files[0].Filename) + } + + resp, err := state.Client.EditPost(context.Background(), post) + if err == nil { + postid := strconv.Itoa(resp.PostView.Post.ID) + r.URL.Path = "/" + state.Host + "/post/" + postid + r.URL.RawQuery = "" + } else { + state.Status = http.StatusBadRequest + state.Error = err + fmt.Println(err) + } + case "delete_post": + postid, _ := strconv.Atoi(r.FormValue("postid")) + fmt.Println("delete " + r.FormValue("postid")) + post := types.DeletePost{ + PostID: postid, + Deleted: true, + } + if r.FormValue("undo") != "" { + post.Deleted = false + } + resp, err := state.Client.DeletePost(context.Background(), post) + if err != nil { + fmt.Println(err) + } else { + r.URL.Path = "/" + state.Host + "/c/" + resp.PostView.Community.Name + r.URL.RawQuery = "" + } + case "vote_post": + var score int16 + score = 1 + if r.FormValue("vote") != "▲" { + score = -1 + } + if r.FormValue("undo") == strconv.Itoa(int(score)) { + score = 0 + } + postid, _ := strconv.Atoi(r.FormValue("postid")) + post := types.CreatePostLike{ + PostID: postid, + Score: score, + } + state.Client.CreatePostLike(context.Background(), post) + case "vote_comment": + var score int16 + score = 1 + if r.FormValue("vote") != "▲" { + score = -1 + } + if r.FormValue("undo") == strconv.Itoa(int(score)) { + score = 0 + } + commentid, _ := strconv.Atoi(r.FormValue("commentid")) + post := types.CreateCommentLike{ + CommentID: commentid, + Score: score, + } + state.Client.CreateCommentLike(context.Background(), post) + case "create_comment": + if ps.ByName("postid") != "" { + postid, _ := strconv.Atoi(ps.ByName("postid")) + state.PostID = postid + } + if r.FormValue("parentid") != "" { + parentid, _ := strconv.Atoi(r.FormValue("parentid")) + state.GetComment(parentid) + } + createComment := types.CreateComment{ + Content: r.FormValue("content"), + PostID: state.PostID, + } + if state.CommentID > 0 { + createComment.ParentID = types.NewOptional(state.CommentID) + } + resp, err := state.Client.CreateComment(context.Background(), createComment) + if err == nil { + postid := strconv.Itoa(state.PostID) + commentid := strconv.Itoa(resp.CommentView.Comment.ID) + r.URL.Path = "/" + state.Host + "/post/" + postid + r.URL.Fragment = "c" + commentid + } else { + fmt.Println(err) + } + case "edit_comment": + commentid, _ := strconv.Atoi(r.FormValue("commentid")) + resp, err := state.Client.EditComment(context.Background(), types.EditComment{ + CommentID: commentid, + Content: types.NewOptional(r.FormValue("content")), + }) + if err != nil { + fmt.Println(err) + } else { + commentid := strconv.Itoa(resp.CommentView.Comment.ID) + r.URL.Fragment = "c" + commentid + r.URL.RawQuery = "" + } + case "delete_comment": + commentid, _ := strconv.Atoi(r.FormValue("commentid")) + resp, err := state.Client.DeleteComment(context.Background(), types.DeleteComment{ + CommentID: commentid, + Deleted: true, + }) + if err != nil { + fmt.Println(err) + } else { + commentid := strconv.Itoa(resp.CommentView.Comment.ID) + r.URL.Fragment = "c" + commentid + r.URL.RawQuery = "" + } + } + http.Redirect(w, r, r.URL.String(), 301) +} diff --git a/state.go b/state.go new file mode 100644 index 0000000..29b0960 --- /dev/null +++ b/state.go @@ -0,0 +1,616 @@ +package main + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "github.com/rystaf/go-lemmy" + "github.com/rystaf/go-lemmy/types" +) + +type Comment struct { + P types.CommentView + C []Comment + Selected bool + State *State + Op string + ChildCount int +} + +func (c *Comment) Submitter() bool { + return c.P.Comment.CreatorID == c.P.Post.CreatorID +} + +type Person struct { + types.PersonViewSafe +} + +type Activity struct { + Timestamp time.Time + Comment *Comment + Post *Post + Message *types.PrivateMessageView +} + +type Post struct { + types.PostView + Rank int + State *State +} + +type Session struct { + UserName string + UserID int +} + +type State struct { + Client *lemmy.Client + HTTPClient *http.Client + Session *Session + Status int + Error error + Alert string + Host string + CommunityName string + Community *types.GetCommunityResponse + TopCommunities []types.CommunityView + Communities []types.CommunityView + UnreadCount int64 + Sort string + Listing string + Page int + Parts []string + Posts []Post + Comments []Comment + Activities []Activity + CommentCount int + PostID int + CommentID int + UserName string + User *types.GetPersonDetailsResponse + Now int64 + XHR bool + Op string + Site *types.GetSiteResponse + Query string + SearchType string + Captcha *types.CaptchaResponse +} + +func (p State) SortBy(v string) string { + var q string + if p.Query != "" || p.SearchType == "Communities" { + q = "q=" + p.Query + "&communityname=" + p.CommunityName + "&username=" + p.UserName + "&searchtype=" + p.SearchType + "&" + } + return "?" + q + "sort=" + v + "&listingType=" + p.Listing +} +func (p State) ListBy(v string) string { + var q string + if p.Query != "" || p.SearchType == "Communities" { + q = "q=" + p.Query + "&communityname=" + p.CommunityName + "&username=" + p.UserName + "&searchtype=" + p.SearchType + "&" + } + return "?" + q + "sort=" + p.Sort + "&listingType=" + v +} + +func (p State) PrevPage() string { + var listing string + if p.Listing != "All" { + listing = "&listingType=" + p.Listing + } + var q string + if p.Query != "" || p.SearchType == "Communities" { + q = "q=" + p.Query + "&communityname=" + p.CommunityName + "&username=" + p.UserName + "&searchtype=" + p.SearchType + "&" + } + page := strconv.Itoa(p.Page - 1) + return "?" + q + "sort=" + p.Sort + listing + "&page=" + page +} +func (p State) NextPage() string { + var listing string + if p.Listing != "All" { + listing = "&listingType=" + p.Listing + } + var q string + if p.Query != "" || p.SearchType == "Communities" { + q = "q=" + p.Query + "&communityname=" + p.CommunityName + "&username=" + p.UserName + "&searchtype=" + p.SearchType + "&" + } + page := strconv.Itoa(p.Page + 1) + return "?" + q + "sort=" + p.Sort + listing + "&page=" + page +} +func (p State) Rank(v int) int { + return ((p.Page - 1) * 25) + v + 1 +} + +func (u *Person) FullUserName() string { + if u.Person.Local { + return u.Person.Name + } + l, err := url.Parse(u.Person.ActorID) + if err != nil { + fmt.Println(err) + return u.Person.Name + } + return u.Person.Name + "@" + l.Host +} + +func (state *State) ParseQuery(RawQuery string) { + if RawQuery == "" { + return + } + m, _ := url.ParseQuery(RawQuery) + if len(m["listingType"]) > 0 { + state.Listing = m["listingType"][0] + } + if len(m["sort"]) > 0 { + state.Sort = m["sort"][0] + } + if len(m["communityname"]) > 0 { + state.CommunityName = m["communityname"][0] + } + if len(m["username"]) > 0 { + state.UserName = m["username"][0] + } + if len(m["q"]) > 0 { + state.Query = m["q"][0] + } + if len(m["xhr"]) > 0 { + state.XHR = true + } + //if len(m["op"]) > 0 { + // state.Op = m["op"][0] + //} + if len(m["page"]) > 0 { + i, _ := strconv.Atoi(m["page"][0]) + state.Page = i + } +} + +//func (state *State) Build() { +// if state.Listing == "" { +// state.Listing = "All" +// } +// if state.Op == "create_post" { +// if state.CommunityName != "" { +// state.GetCommunity() +// } +// return +// } +// if state.CommentID > 0 { +// state.GetComment() +// state.GetPost() +// state.GetCommunity() +// return +// } +// +// if state.UserName != "" { +// state.GetUser() +// return +// } +// +// if state.PostID == 0 { +// state.GetPosts() +// } else { +// state.GetPost() +// state.GetComments() +// } +// +// if state.CommunityName != "" { +// state.GetCommunity() +// } +// +//} + +func (state *State) GetCaptcha() { + resp, err := state.Client.Captcha(context.Background(), types.GetCaptcha{}) + if err != nil { + fmt.Printf("Get %v %v", err, resp) + } else { + captcha, _ := resp.Ok.Value() + if resp.Ok.IsValid() { + state.Captcha = &captcha + } + } +} +func (state *State) GetSite() { + token := state.Client.Token + state.Client.Token = "" + resp, err := state.Client.Site(context.Background(), types.GetSite{}) + if err != nil { + fmt.Println(err) + state.Status = http.StatusInternalServerError + return + } + state.Client.Token = token + state.Site = resp +} + +func (state *State) GetComment(commentid int) { + state.CommentID = commentid + cresp, err := state.Client.Comments(context.Background(), types.GetComments{ + ParentID: types.NewOptional(state.CommentID), + Sort: types.NewOptional(types.CommentSortType(state.Sort)), + Type: types.NewOptional(types.ListingType("All")), + Limit: types.NewOptional(int64(200)), + }) + if err != nil { + fmt.Println(err) + state.Status = http.StatusInternalServerError + return + } + state.CommentCount = len(cresp.Comments) + for _, c := range cresp.Comments { + if c.Comment.ID == state.CommentID { + state.PostID = c.Comment.PostID + //if state.Session != nil && state.Session.UserID + comment := Comment{ + P: c, + Selected: !state.XHR, + State: state, + Op: state.Op, + } + getChildren(&comment, cresp.Comments, c.Post.CreatorID) + state.Comments = append(state.Comments, comment) + } + } +} +func (state *State) GetComments() { + cresp, err := state.Client.Comments(context.Background(), types.GetComments{ + PostID: types.NewOptional(state.PostID), + Sort: types.NewOptional(types.CommentSortType(state.Sort)), + Type: types.NewOptional(types.ListingType("All")), + Limit: types.NewOptional(int64(200)), + Page: types.NewOptional(int64(state.Page)), + }) + if err != nil { + state.Status = http.StatusInternalServerError + return + } + state.CommentCount = len(cresp.Comments) + for _, c := range cresp.Comments { + levels := strings.Split(c.Comment.Path, ".") + if len(levels) != 2 { + continue + } + comment := Comment{P: c, State: state} + var postCreatorID int + if len(state.Posts) > 0 { + postCreatorID = state.Posts[0].Post.CreatorID + } + getChildren(&comment, cresp.Comments, postCreatorID) + state.Comments = append(state.Comments, comment) + } +} + +func (state *State) GetMessages() { + if resp, err := state.Client.PrivateMessages(context.Background(), types.GetPrivateMessages{ + Page: types.NewOptional(int64(state.Page)), + }); err != nil { + fmt.Println(err) + state.Status = http.StatusInternalServerError + return + } else { + for _, m := range resp.PrivateMessages { + message := m + state.Activities = append(state.Activities, Activity{ + Timestamp: m.PrivateMessage.Published.Time, + Message: &message, + }) + } + } + if resp, err := state.Client.PersonMentions(context.Background(), types.GetPersonMentions{ + Page: types.NewOptional(int64(state.Page)), + }); err != nil { + fmt.Println(err) + state.Status = http.StatusInternalServerError + return + } else { + for _, m := range resp.Mentions { + var unread string + if !m.PersonMention.Read { + unread = "unread" + } + comment := Comment{ + P: types.CommentView{ + Comment: m.Comment, + }, + Op: unread, + State: state, + } + state.Activities = append(state.Activities, Activity{ + Timestamp: m.Comment.Published.Time, + Comment: &comment, + }) + } + } + if resp, err := state.Client.Replies(context.Background(), types.GetReplies{ + Page: types.NewOptional(int64(state.Page)), + }); err != nil { + fmt.Println(err) + state.Status = http.StatusInternalServerError + return + } else { + for _, m := range resp.Replies { + var unread string + if !m.CommentReply.Read { + unread = "unread" + } + comment := Comment{ + P: types.CommentView{ + Comment: m.Comment, + Post: m.Post, + Creator: m.Creator, + Community: m.Community, + }, + Op: unread, + State: state, + } + state.Activities = append(state.Activities, Activity{ + Timestamp: m.Comment.Published.Time, + Comment: &comment, + }) + } + } +} + +func (state *State) GetUser(username string) { + state.UserName = username + limit := 12 + if state.Op == "send_message" { + limit = 1 + } + resp, err := state.Client.PersonDetails(context.Background(), types.GetPersonDetails{ + Username: types.NewOptional(state.UserName), + Page: types.NewOptional(int64(state.Page)), + Limit: types.NewOptional(int64(limit)), + }) + if err != nil { + fmt.Println(err) + state.Status = http.StatusInternalServerError + return + } + state.User = resp + if state.Query != "" { + return + } + for i, p := range resp.Posts { + post := Post{ + PostView: resp.Posts[i], + Rank: -1, + State: state, + } + state.Activities = append(state.Activities, Activity{ + Timestamp: p.Post.Published.Time, + Post: &post, + }) + } + for _, c := range resp.Comments { + comment := Comment{P: c, State: state} + state.Activities = append(state.Activities, Activity{ + Timestamp: c.Comment.Published.Time, + Comment: &comment, + }) + } + sort.Slice(state.Activities, func(i, j int) bool { + return state.Activities[i].Timestamp.After(state.Activities[j].Timestamp) + }) +} + +func (state *State) GetUnreadCount() { + resp, err := state.Client.UnreadCount(context.Background(), types.GetUnreadCount{}) + if err != nil { + fmt.Println(err) + return + } + state.UnreadCount = resp.PrivateMessages + resp.Mentions + resp.Replies +} +func (state *State) GetCommunities() { + resp, err := state.Client.Communities(context.Background(), types.ListCommunities{ + Sort: types.NewOptional(types.SortType("TopAll")), + Limit: types.NewOptional(int64(20)), + }) + if err != nil { + fmt.Println(err) + return + } + state.TopCommunities = resp.Communities +} +func (state *State) MarkAllAsRead() { + _, err := state.Client.MarkAllAsRead(context.Background(), types.MarkAllAsRead{}) + if err != nil { + fmt.Println(err) + return + } +} + +func (state *State) GetPosts() { + resp, err := state.Client.Posts(context.Background(), types.GetPosts{ + Sort: types.NewOptional(types.SortType(state.Sort)), + Type: types.NewOptional(types.ListingType(state.Listing)), + CommunityName: types.NewOptional(state.CommunityName), + Limit: types.NewOptional(int64(25)), + Page: types.NewOptional(int64(state.Page)), + }) + if err != nil { + fmt.Println(err) + state.Status = http.StatusInternalServerError + return + } else { + for i, p := range resp.Posts { + state.Posts = append(state.Posts, Post{ + PostView: p, + Rank: (state.Page-1)*25 + i + 1, + State: state, + }) + } + } +} + +func (state *State) Search(searchtype string) { + if state.Query == "" && searchtype == "Communities" { + resp, err := state.Client.Communities(context.Background(), types.ListCommunities{ + Sort: types.NewOptional(types.SortType(state.Sort)), + Limit: types.NewOptional(int64(25)), + Page: types.NewOptional(int64(state.Page)), + }) + if err != nil { + fmt.Println(err) + return + } + state.Communities = resp.Communities + return + } + search := types.Search{ + Q: state.Query, + Sort: types.NewOptional(types.SortType(state.Sort)), + ListingType: types.NewOptional(types.ListingType(state.Listing)), + Type: types.NewOptional(types.SearchType(searchtype)), + Limit: types.NewOptional(int64(25)), + Page: types.NewOptional(int64(state.Page)), + } + + if state.CommunityName != "" { + search.CommunityName = types.NewOptional(state.CommunityName) + } + + if state.User != nil { + search.CreatorID = types.NewOptional(state.User.PersonView.Person.ID) + } + + resp, err := state.Client.Search(context.Background(), search) + if err != nil { + fmt.Println(err) + state.Status = http.StatusInternalServerError + return + } else { + for i, p := range resp.Posts { + state.Posts = append(state.Posts, Post{ + PostView: p, + Rank: (state.Page-1)*25 + i + 1, + State: state, + }) + } + for _, c := range resp.Comments { + state.Comments = append(state.Comments, Comment{ + P: c, + State: state, + }) + } + state.Communities = resp.Communities + } +} + +func (state *State) GetPost(postid int) { + if postid == 0 { + return + } + state.PostID = postid + // get post + resp, err := state.Client.Post(context.Background(), types.GetPost{ + ID: types.NewOptional(state.PostID), + }) + if err != nil { + state.Status = http.StatusInternalServerError + return + } else { + state.Posts = []Post{Post{ + PostView: resp.PostView, + State: state, + }} + if state.CommentID > 0 && len(state.Posts) > 0 { + state.Posts[0].Rank = -1 + } + state.CommunityName = resp.PostView.Community.Name + cresp := types.GetCommunityResponse{ + CommunityView: resp.CommunityView, + Moderators: resp.Moderators, + } + state.Community = &cresp + } +} + +func (state *State) GetCommunity(communityName string) { + if communityName != "" { + state.CommunityName = communityName + } + if state.CommunityName == "" { + return + } + fmt.Println("Get community " + state.CommunityName) + resp, err := state.Client.Community(context.Background(), types.GetCommunity{ + Name: types.NewOptional(state.CommunityName), + }) + if err != nil { + state.Error = err + } else { + state.Community = resp + } +} + +func (state *State) UploadImage(file multipart.File, header *multipart.FileHeader) (*PictrsResponse, error) { + defer file.Close() + body := new(bytes.Buffer) + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("images[]", header.Filename) + if err != nil { + return nil, err + } + io.Copy(part, file) + writer.Close() + req, err := http.NewRequest("POST", "https://"+state.Host+"/pictrs/image", body) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Cookie", "jwt="+state.Client.Token) + res, err := state.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + var pres PictrsResponse + if err := json.NewDecoder(res.Body).Decode(&pres); err != nil { + return nil, err + } + if pres.Message != "ok" { + return &pres, errors.New(pres.Message) + } + return &pres, nil +} + +func getChildren(parent *Comment, pool []types.CommentView, postCreatorID int) { + var children []Comment + total := -1 + for _, c := range pool { + levels := strings.Split(c.Comment.Path, ".") + for i, l := range levels { + id, _ := strconv.Atoi(l) + if id == parent.P.Comment.ID { + total = total + 1 + if i == (len(levels) - 2) { + children = append(children, Comment{ + P: c, + C: children, + State: parent.State, + }) + } + } + + } + } + for i, _ := range children { + getChildren(&children[i], pool, postCreatorID) + } + parent.C = children + parent.ChildCount = total +} diff --git a/templates/activities.html b/templates/activities.html new file mode 100644 index 0000000..f05c5a4 --- /dev/null +++ b/templates/activities.html @@ -0,0 +1,37 @@ +{{ $state := . }} + {{ range $i, $activity := .Activities}} +
+ {{ if $activity.Comment }} +
+ {{ if not $state.User }} + comment on + {{ end }} + {{ $activity.Comment.P.Post.Name}} + {{ if $state.User}} + by + {{$state.User.PersonView.Person.Name }} + {{ end }} + in + /c/{{ $activity.Comment.P.Community.Name }} +
+ {{ template "comment.html" $activity.Comment }} + {{ else if $activity.Post }} + {{ template "post.html" $activity.Post }} + {{ else if $activity.Message }} +
+ + message + {{ if eq $activity.Message.Creator.ID $state.Session.UserID }} + to + {{ $activity.Message.Recipient.Name }} + {{ else }} + from + {{ $activity.Message.Creator.Name }} + {{end}} + sent {{ humanize $activity.Message.PrivateMessage.Published.Time }} + +
{{ markdown "" $activity.Message.PrivateMessage.Content }}
+
+ {{ end }} +
+ {{ end }} diff --git a/templates/comment.html b/templates/comment.html new file mode 100644 index 0000000..f646bb4 --- /dev/null +++ b/templates/comment.html @@ -0,0 +1,83 @@ +
+
+ {{ if .State.Session }} +
+ +
+ {{ end }} + + {{if or (lt .P.Counts.Score -5) .P.Comment.Deleted }} + [+] + {{ else }} + [-] + {{ end }} + + {{fullname .P.Creator}} + {{.P.Counts.Score}} points {{ humanize .P.Comment.Published.Time }} + {{- if gt .P.Comment.Updated.Time.Unix .P.Comment.Published.Time.Unix -}} + * (last edited {{ humanize .P.Comment.Updated.Time }}) + {{ end }} +
+ {{ if eq .Op "edit" }} +
+
+ +
+ + + +
+ {{ else }} +
{{if .P.Comment.Deleted}}[removed]{{else}}{{ markdown .State.Host .P.Comment.Content }}{{end}}
+ {{ if eq .Op "source" }}
{{end}} + {{ end }} +
    +
  • permalink
  • + {{ if ne .Op "source"}} +
  • source
  • + {{ else }} +
  • hide source
  • + {{ end }} + {{ if and .State.Session (eq .P.Comment.CreatorID .State.Session.UserID) (ne .Op "edit")}} +
  • edit
  • +
  • +
    + + + +
    +
  • + {{ if ne .Op "reply"}} +
  • reply
  • + {{ end }} + {{ end }} +
+
+ {{ if eq .State.Op "reply" }} +
+
+ +
+ + + +
+ {{ end}} + {{ range $ci, $child := .C }}{{ template "comment.html" $child }}{{end}} +
+ {{ if ne .P.Counts.ChildCount .ChildCount}} +
+ load more comments + ({{ sub .P.Counts.ChildCount .ChildCount}} replies) +
+ {{end}} +
diff --git a/templates/community.html b/templates/community.html new file mode 100644 index 0000000..f1af3c6 --- /dev/null +++ b/templates/community.html @@ -0,0 +1,18 @@ +
+
+ + +
+ c/{{fullcname .Community}}: {{.Community.Title}} +
+{{ if .Community.Description.IsValid }} +
+ {{markdown "poop" .Community.Description.String}} +
+{{ end }} +
+ {{printer .Counts.Subscribers}} subscribers, + a community founded {{ humanize .Community.Published.Time }} +
+
+
diff --git a/templates/create_community.html b/templates/create_community.html new file mode 100644 index 0000000..f24c009 --- /dev/null +++ b/templates/create_community.html @@ -0,0 +1,52 @@ +{{ $c := .Community }} +
+
+ + +
+
+ + +
+
+ +{{ if and $c $c.CommunityView.Community.Icon.IsValid }} + +{{ end }} + +
+
+ +{{ if and $c $c.CommunityView.Community.Banner.IsValid }} + +{{ end }} + +
+
+ + +
+
+ + warning: if you deselect Undetermined, you will not see most content.
+ +
+
+ +
+ {{ if eq .Op "create_community" }} + + + {{ else }} + + + {{ end }} +
diff --git a/templates/create_post.html b/templates/create_post.html new file mode 100644 index 0000000..86302df --- /dev/null +++ b/templates/create_post.html @@ -0,0 +1,42 @@ +{{ $p := .Posts }} +
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ {{ if not $p }} +
+ + +
+ {{ end }} + {{ if eq .Op "create_post" }} + + + {{ else }} + + + {{ end }} +
diff --git a/templates/frontpage.html b/templates/frontpage.html new file mode 100644 index 0000000..775a680 --- /dev/null +++ b/templates/frontpage.html @@ -0,0 +1,52 @@ + + + {{ if and .Community (ne .Community.CommunityView.Community.Title "")}}{{.Community.CommunityView.Community.Title}}{{else if ne .CommunityName ""}}/c/{{.CommunityName}}{{ else if .User}}overview for {{.User.PersonView.Person.Name}}{{else}}{{ .Host }}{{end}} + + + + + + + {{ template "nav.html" . -}} + {{ template "sidebar.html" . }} +{{ if or (contains .Sort "Top") (and (not .PostID) (not .User) (not .Community) (not .Activities) (eq .Op ""))}} + {{ template "menu.html" . }} +{{ end}} +
+ +{{ if .Error }} +
{{.Error}}
+{{ end }} + +{{ range .Posts }} + {{ template "post.html" . }} +{{ end }} + +{{ if and .Session (not .Posts) (not .Community) (and .Listing "Subscribed") }} +

This is your home

+

When you find a community that you like, click the join button

+

Click here to find communities or check out what's popular

+{{ else if or (and (not .Op) (not .Activities) (not .Comments) (not .Posts) (not .Communities)) (and (not .Comments) .PostID) (and (not .Activities) (not .Query) .User) }} +
there doesn't seem to be anything here
+{{ end }} + + +{{ if or .Query (eq .SearchType "Communities") (eq (len .Posts) 25) (and .Comments (and (eq .CommentCount 200) (gt (index .Posts 0).Counts.Comments .CommentCount))) (and .User (or (gt .User.PersonView.Counts.CommentCount 10) (gt .User.PersonView.Counts.PostCount 10))) }} +
+ view more: {{if gt .Page 1 }}‹ prev{{ end }} next › +
+{{ end }} + + +
+ + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..f108df2 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,5 @@ +{{ if .XHR }} + {{ template "xhr.html" . }} +{{ else }} + {{ template "main.html" . }} +{{ end}} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..f59d3af --- /dev/null +++ b/templates/login.html @@ -0,0 +1,100 @@ + + + {{ .Host }}: sign up or log in + + + + + +{{ if .Alert }} +
+ {{ .Alert }} +
+{{ end }} +{{ if .Error }} +
{{.Error}}
+{{ end }} + + + diff --git a/templates/main.html b/templates/main.html new file mode 100644 index 0000000..1733749 --- /dev/null +++ b/templates/main.html @@ -0,0 +1,123 @@ + + + {{if ne .PostID 0}}{{ (index .Posts 0).Post.Name}} : {{.CommunityName}}{{else if and .Community (ne .Community.CommunityView.Community.Title "")}}{{.Community.CommunityView.Community.Title}}{{else if ne .CommunityName ""}}/c/{{.CommunityName}}{{ else if .User}}overview for {{.User.PersonView.Person.Name}}{{else}}{{ .Host }}{{end}} + + + + + + + {{ template "nav.html" . -}} + {{ template "sidebar.html" . }} +{{ if or (contains .Sort "Top") (and (not .PostID) (not .User) (not .Community) (not .Activities) (eq .Op ""))}} + {{ template "menu.html" . }} +{{ end}} +
+ +{{ if or (ne .Query "") .Communities }} + +{{ end}} + +{{ if .Error }} +
{{.Error}}
+{{ end }} + +{{ range .Communities }} + {{ template "community.html" . }} +{{ end }} + +{{ if eq .Op "create_community" "edit_community" }} + {{ template "create_community.html" . }} +{{ end }} + +{{ range .Posts }} + {{ template "post.html" . }} +{{ end }} + +{{ if eq .Op "create_post" "edit_post" }} + {{ template "create_post.html" . }} +{{ end }} + +{{ if .PostID }} + {{ if .CommentID}} +
you are viewing a single comment's thread
+ view the rest of the comments +
+ {{ else }} +
+ {{if .Comments}}{{if gt .Page 1}}next{{else if or (lt .CommentCount 200) (lt (index .Posts 0).Counts.Comments .CommentCount) }}all{{else}}top{{end}} {{.CommentCount}} comments{{else}} no comments (yet){{end}} +
+ sorted by: + hot + top + new + old +
+
+ {{ if and .Session (ne .Op "edit_post") }} +
+
+ +
+ + +
+ {{ end }} + {{ end }} +{{ end}} + + +{{ range $i, $comment := .Comments }} + {{ template "comment.html" $comment }} +{{ end }} + +{{ if eq .Op "send_message" }} +{{ template "send_message.html" . }} +{{ else }} +{{ template "activities.html" . }} +{{ end }} + +{{ if or (and (not .Op) (not .Activities) (not .Comments) (not .Posts) (not .Communities)) (and (not .Comments) .PostID) (and (not .Activities) (not .Query) .User) }} +
there doesn't seem to be anything here
+{{ end }} + + +{{ if or .Query (eq .SearchType "Communities") (eq (len .Posts) 25) (and .Comments (and (eq .CommentCount 200) (gt (index .Posts 0).Counts.Comments .CommentCount))) (and .User (or (gt .User.PersonView.Counts.CommentCount 10) (gt .User.PersonView.Counts.PostCount 10))) }} +
+ view more: {{if gt .Page 1 }}‹ prev{{ end }} next › +
+{{ end }} + + +
+ + diff --git a/templates/menu.html b/templates/menu.html new file mode 100644 index 0000000..83a935a --- /dev/null +++ b/templates/menu.html @@ -0,0 +1,21 @@ + diff --git a/templates/nav.html b/templates/nav.html new file mode 100644 index 0000000..7fcca7a --- /dev/null +++ b/templates/nav.html @@ -0,0 +1,64 @@ + diff --git a/templates/post.html b/templates/post.html new file mode 100644 index 0000000..542dcb6 --- /dev/null +++ b/templates/post.html @@ -0,0 +1,68 @@ +
+{{ if gt .Rank 0 }} +
{{ .Rank }}
+{{ end }} +
+{{ if .State.Session }} + +{{ else }} +
{{ .Counts.Score }}
+{{ end }} +
+
+
+
+ {{ .Post.Name }} + ({{ host . }}) +
+{{ if or (and .Post.Body.IsValid (ne .Post.Body.String "")) (isImage .Post.URL.String) }} +
+{{ end }} +
+ submitted + {{ humanize .Post.Published.Time -}} + {{- if gt .Post.Updated.Time.Unix .Post.Published.Time.Unix -}} + * (last edited {{ humanize .Post.Updated.Time }}) + {{ end }} + by + {{ fullname .Creator }} + to + c/{{ fullcname .Community}} +
+
+ {{ .Counts.Comments }} comments + {{ if and .State.Session (eq .State.Session.UserID .Post.CreatorID) }} + {{ if not .Post.Deleted }}edit{{end}} + + {{ end}} +
+
+{{ if (and .Post.Body.IsValid (ne .Post.Body.String "")) }} +
{{ markdown .State.Host .Post.Body.String }}
+{{ end }} +{{ if isImage .Post.URL.String}} + +{{ end }} +
+
+
+
+
diff --git a/templates/root.html b/templates/root.html new file mode 100644 index 0000000..f111a90 --- /dev/null +++ b/templates/root.html @@ -0,0 +1,24 @@ + + + mlmym + + + + + +
+ +{{ if .Error }} +
{{.Error}}
+{{ end }} +
+ + diff --git a/templates/send_message.html b/templates/send_message.html new file mode 100644 index 0000000..c336e37 --- /dev/null +++ b/templates/send_message.html @@ -0,0 +1,13 @@ +

send a private message

+
+
+ + +
+
+ + +
+ + +
diff --git a/templates/sidebar.html b/templates/sidebar.html new file mode 100644 index 0000000..41279c2 --- /dev/null +++ b/templates/sidebar.html @@ -0,0 +1,120 @@ +{{ $host := .Host }} +
+ +{{ if eq .Query "" }} +
+ + {{ if .User }} + + {{ else if .Community }} + + {{ end }} +
+{{ end }} + +{{ if .User }} +

{{ .User.PersonView.Person.Name }}

+ {{ .User.PersonView.Counts.PostScore}} post score
+ {{ .User.PersonView.Counts.CommentScore}} comment score
+
+ {{ if .Session }}send a message{{end}} + joined {{ humanize .User.PersonView.Person.Published.Time }} +
+ {{ if .User.Moderates }} + MODERATOR OF +
+ {{ range $i, $mod := .User.Moderates }} + + {{ end }} +
+ {{ end }} +{{ end }} + +{{ if not .Session -}} + +{{ end }} + +{{ if .PostID }} +
+ this post was submitted on {{ (index .Posts 0).Post.Published.Time.Format "01 Jan 2006" }} +
{{ (index .Posts 0).Counts.Score }} points ({{likedPerc (index .Posts 0).Counts}}% liked)
+
+{{ end }} + +{{ if .Session }} +
+
Create a post »
+ {{ if not .Community }} +
Create a community »
+ {{ end }} +
+{{ end }} + +{{ if and .Site (not .Community) }} + {{ if .Site.SiteView.Site.Banner.IsValid }} + + {{ else if .Site.SiteView.Site.Icon.IsValid }} + + {{ end }} +

{{ .Site.SiteView.Site.Name }}

+ {{ printer .Site.SiteView.Counts.Users }} readers
+ + {{ printer .Site.SiteView.Counts.UsersActiveDay }} users here now +

{{ markdown .Host .Site.SiteView.Site.Sidebar.String }}

+
founded {{ humanize .Site.SiteView.Site.Published.Time }}
+ {{ if .Site.Admins }} + ADMINS +
+ {{ range $i, $mod := .Site.Admins }} + + {{ end }} +
+ {{ end }} +{{ end }} + + +{{ if .Community }} + {{ if .Community.CommunityView.Community.Banner.IsValid }} + + {{ else if .Community.CommunityView.Community.Icon.IsValid }} + + {{ end }} +

{{ if ne .Community.CommunityView.Community.Title ""}}{{ .Community.CommunityView.Community.Title }}{{ else }}{{ .Community.CommunityView.Community.Name }}{{end}}

+ {{ if .Session }} +
+ + +
+ {{ end }} + {{ .Community.CommunityView.Counts.Subscribers }} readers
+ + {{ .Community.CommunityView.Counts.UsersActiveDay }} users here now + {{ if and .Session (isMod .Community .Session.UserName) }} +

you are a moderator of this community

+ {{ end }} +

{{ markdown .Host .Community.CommunityView.Community.Description.String }}

+
+ founded {{ humanize .Community.CommunityView.Counts.Published.Time }} +
+ {{ if and .Session (isMod .Community .Session.UserName) }} + MODERATOR TOOLS + + {{ end }} + {{ if .Community.Moderators }} + MODERATORS +
+ {{ range $i, $mod := .Community.Moderators }} + + {{ end }} +
+ {{ end }} +{{ end }} +
diff --git a/templates/xhr.html b/templates/xhr.html new file mode 100644 index 0000000..fde7e0a --- /dev/null +++ b/templates/xhr.html @@ -0,0 +1,10 @@ +{{ if .CommentID }} + {{ range $i, $comment := .Comments }} + {{ template "comment.html" $comment }} + {{ end }} +{{ else }} + {{ range $post := .Posts }} + + {{ template "post.html" $post }} + {{ end }} +{{ end }}