commit a5cafdddcafe1a2f68c62034448592be09e1aec8 Author: Bruno Bernardino Date: Sat Mar 16 08:40:24 2024 +0000 Make it public! diff --git a/.dvmrc b/.dvmrc new file mode 100644 index 0000000..7d47e59 --- /dev/null +++ b/.dvmrc @@ -0,0 +1 @@ +1.41.0 diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..31e48f8 --- /dev/null +++ b/.env.sample @@ -0,0 +1,16 @@ +PORT=8000 + +POSTGRESQL_HOST="localhost" +POSTGRESQL_USER="postgres" +POSTGRESQL_PASSWORD="fake" +POSTGRESQL_DBNAME="apollo" +POSTGRESQL_PORT=5432 +POSTGRESQL_CAFILE="" + +JWT_SECRET="fake" +PASSWORD_SALT="fake" + +BREVO_API_KEY="fake" + +CONFIG_ALLOW_SIGNUPS="false" +CONFIG_ENABLED_APPS="dashboard,news,contacts,calendar,tasks,files,notes,photos" diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..30b98b4 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,6 @@ +github: [BrunoBernardino] +custom: + [ + 'https://paypal.me/brunobernardino', + 'https://gist.github.com/BrunoBernardino/ff5b54c13dd96ac7f9fee6fbfd825b09', + ] diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..ccd19b8 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,33 @@ +name: Deploy + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Configure SSH + run: | + mkdir -p ~/.ssh/ + echo "$SSH_KEY" | tr -d '\r' > ~/.ssh/server.key + chmod 600 ~/.ssh/server.key + cat >>~/.ssh/config < + 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..cd10544 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +.PHONY: start +start: + deno task start + +.PHONY: format +format: + deno fmt + +.PHONY: test +test: + deno task check + deno task test + +.PHONY: build +build: + deno task build + +.PHONY: migrate-db +migrate-db: + deno run --allow-net --allow-read --allow-env migrate-db.ts + +.PHONY: crons/cleanup +crons/cleanup: + deno run --allow-net --allow-read --allow-env crons/cleanup.ts + +.PHONY: exec-db +exec-db: + docker exec -it -u postgres $(shell basename $(CURDIR))-postgresql-1 psql diff --git a/README.md b/README.md new file mode 100644 index 0000000..69bf65f --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# bewCloud + +[![](https://github.com/bewcloud/bewcloud/workflows/Run%20Tests/badge.svg)](https://github.com/bewcloud/bewcloud/actions?workflow=Run+Tests) + +This is the [bewCloud app](https://bewcloud.com) built using [Fresh](https://fresh.deno.dev) and deployed using [docker compose](https://docs.docker.com/compose/). + +## Self-host it! + +Check the [Development section below](#development). + +> **NOTE:** You don't need to have emails (Brevo) setup to have the app work. Those are only setup and used for email verification and future needs. You can simply make any `user.status = 'active'` and `user.subscription.expires_at = new Date('2100-01-01')` to "never" expire, in the database, directly. + +> **NOTE 2:** Even with signups disabled (`CONFIG_ALLOW_SIGNUPS="false"`), the first signup will work and become an admin. + +## Requirements + +This was tested with [`Deno`](https://deno.land)'s version stated in the `.dvmrc` file, though other versions may work. + +For the postgres dependency (used when running locally or in CI), you should have `Docker` and `docker compose` installed. + +Don't forget to set up your `.env` file based on `.env.sample`. + +## Development + +```sh +$ docker compose up # (optional) runs docker with postgres, locally +$ make migrate-db # runs any missing database migrations +$ make start # runs the app +$ make format # formats the code +$ make test # runs tests +``` + +## Other less-used commands + +```sh +$ make exec-db # runs psql inside the postgres container, useful for running direct development queries like `DROP DATABASE "bewcloud"; CREATE DATABASE "bewcloud";` +$ make build # generates all static files for production deploy +``` + +## Structure + +- Routes defined at `routes/`. +- Static files are defined at `static/`. +- Static frontent components are defined at `components/`. +- Interactive frontend components are defined at `islands/`. +- Cron jobs are defined at `crons/`. +- Reusable bits of code are defined at `lib/`. +- Database migrations are defined at `db-migrations/`. + +## Deployment + +Just push to the `main` branch. + +## Tentative Roadmap: + +- [x] Dashboard with URLs and Notes +- [x] News +- [x] Contacts / CardDav +- [ ] Calendar / CalDav +- [ ] Tasks / CalDav +- [ ] Files / WebDav +- [ ] Notes / WebDav +- [ ] Photos / WebDav +- [ ] Desktop app for selective file sync (or potentially just `rclone`) +- [ ] Mobile app for offline file sync +- [ ] Add notes support for mobile app +- [ ] Add photos/sync support for mobile client +- [ ] Address `TODO:`s in code diff --git a/components/Header.tsx b/components/Header.tsx new file mode 100644 index 0000000..0fc9995 --- /dev/null +++ b/components/Header.tsx @@ -0,0 +1,174 @@ +import { Head } from 'fresh/runtime.ts'; + +import { User } from '/lib/types.ts'; + +interface Data { + route: string; + user?: User; +} + +interface MenuItem { + url: string; + label: string; +} + +export default function Header({ route, user }: Data) { + const activeClass = 'bg-slate-800 text-white rounded-md px-3 py-2 text-sm font-medium'; + const defaultClass = 'text-slate-300 hover:bg-slate-700 hover:text-white rounded-md px-3 py-2 text-sm font-medium'; + + const mobileActiveClass = 'bg-slate-800 text-white block rounded-md px-3 py-2 text-base font-medium'; + const mobileDefaultClass = + 'text-slate-300 hover:bg-slate-700 hover:text-white block rounded-md px-3 py-2 text-base font-medium'; + + const iconWidthAndHeightInPixels = 20; + + const menuItems: MenuItem[] = [ + { + url: '/dashboard', + label: 'Dashboard', + }, + { + url: '/news', + label: 'News', + }, + { + url: '/contacts', + label: 'Contacts', + }, + { + url: '/calendar', + label: 'Calendar', + }, + { + url: '/tasks', + label: 'Tasks', + }, + { + url: '/files', + label: 'Files', + }, + { + url: '/notes', + label: 'Notes', + }, + { + url: '/photos', + label: 'Photos', + }, + ]; + + if (user) { + const activeMenu = menuItems.find((menu) => route.startsWith(menu.url)); + + let pageLabel = activeMenu?.label || '404 - Page not found'; + + if (route.startsWith('/news/feeds')) { + pageLabel = 'News feeds'; + } + + if (route.startsWith('/settings')) { + pageLabel = 'Settings'; + } + + return ( + <> + + {pageLabel} - bewCloud + + + +
+
+

+ {pageLabel} +

+
+
+ + ); + } + + return ( +
+ + the bewCloud logo: a stylized logo + +
+ ); +} diff --git a/crons/cleanup.ts b/crons/cleanup.ts new file mode 100644 index 0000000..d13ab11 --- /dev/null +++ b/crons/cleanup.ts @@ -0,0 +1,23 @@ +import Database, { sql } from '/lib/interfaces/database.ts'; + +const db = new Database(); + +export async function cleanupSessions() { + const yesterday = new Date(new Date().setUTCDate(new Date().getUTCDate() - 1)); + + try { + const result = await db.query<{ count: number }>( + sql`WITH "deleted" AS ( + DELETE FROM "bewcloud_user_sessions" WHERE "expires_at" <= $1 RETURNING * + ) + SELECT COUNT(*) FROM "deleted"`, + [ + yesterday.toISOString().substring(0, 10), + ], + ); + + console.log('Deleted', result[0].count, 'user sessions'); + } catch (error) { + console.log(error); + } +} diff --git a/crons/index.ts b/crons/index.ts new file mode 100644 index 0000000..d414da8 --- /dev/null +++ b/crons/index.ts @@ -0,0 +1,32 @@ +import { Cron } from 'https://deno.land/x/croner@7.0.1/dist/croner.js'; + +import { cleanupSessions } from './cleanup.ts'; +import { fetchNewArticles } from './news.ts'; + +export function startCrons() { + new Cron( + // At 03:06 every day. + '6 3 * * *', + { + name: 'cleanup', + protect: true, + }, + async () => { + await cleanupSessions(); + }, + ); + + new Cron( + // Every 30 minutes. + '*/30 * * * *', + { + name: 'news', + protect: true, + }, + async () => { + await fetchNewArticles(); + }, + ); + + console.log('Crons starting...'); +} diff --git a/crons/news.ts b/crons/news.ts new file mode 100644 index 0000000..6c985d5 --- /dev/null +++ b/crons/news.ts @@ -0,0 +1,25 @@ +import Database, { sql } from '/lib/interfaces/database.ts'; +import { NewsFeed } from '/lib/types.ts'; +import { concurrentPromises } from '/lib/utils.ts'; +import { crawlNewsFeed } from '/lib/data/news.ts'; + +const db = new Database(); + +export async function fetchNewArticles(forceFetch = false) { + const fourHoursAgo = forceFetch ? new Date() : new Date(new Date().setUTCHours(new Date().getUTCHours() - 4)); + + try { + const feedsToCrawl = await db.query( + sql`SELECT * FROM "bewcloud_news_feeds" WHERE "last_crawled_at" IS NULL OR "last_crawled_at" <= $1`, + [ + fourHoursAgo.toISOString().substring(0, 10), + ], + ); + + await concurrentPromises(feedsToCrawl.map((newsFeed) => () => crawlNewsFeed(newsFeed)), 3); + + console.log('Crawled', feedsToCrawl.length, 'news feeds'); + } catch (error) { + console.log(error); + } +} diff --git a/db-migrations/001-base.pgsql b/db-migrations/001-base.pgsql new file mode 100644 index 0000000..c9e4341 --- /dev/null +++ b/db-migrations/001-base.pgsql @@ -0,0 +1,209 @@ +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + + +-- +-- Name: bewcloud_user_sessions; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.bewcloud_user_sessions ( + id uuid DEFAULT gen_random_uuid(), + user_id uuid DEFAULT gen_random_uuid(), + expires_at timestamp with time zone NOT NULL, + last_seen_at timestamp with time zone DEFAULT now(), + created_at timestamp with time zone DEFAULT now() +); + + +ALTER TABLE public.bewcloud_user_sessions OWNER TO postgres; + + +-- +-- Name: bewcloud_users; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.bewcloud_users ( + id uuid DEFAULT gen_random_uuid(), + email character varying NOT NULL, + hashed_password text NOT NULL, + subscription jsonb NOT NULL, + status character varying NOT NULL, + extra jsonb NOT NULL, + created_at timestamp with time zone DEFAULT now() +); + + +ALTER TABLE public.bewcloud_users OWNER TO postgres; + +-- +-- Name: bewcloud_verification_codes; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.bewcloud_verification_codes ( + id uuid DEFAULT gen_random_uuid(), + user_id uuid DEFAULT gen_random_uuid(), + code character varying NOT NULL, + verification jsonb NOT NULL, + expires_at timestamp with time zone NOT NULL, + created_at timestamp with time zone DEFAULT now() +); + + +ALTER TABLE public.bewcloud_verification_codes OWNER TO postgres; + + +-- +-- Name: bewcloud_dashboards; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.bewcloud_dashboards ( + id uuid DEFAULT gen_random_uuid(), + user_id uuid DEFAULT gen_random_uuid(), + data jsonb NOT NULL, + created_at timestamp with time zone DEFAULT now() +); + + +ALTER TABLE public.bewcloud_dashboards OWNER TO postgres; + +CREATE UNIQUE INDEX bewcloud_dashboards_unique_index ON public.bewcloud_dashboards ( user_id ); + + +-- +-- Name: bewcloud_migrations; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.bewcloud_migrations ( + id uuid DEFAULT gen_random_uuid(), + name character varying(100) NOT NULL, + executed_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP +); + +ALTER TABLE public.bewcloud_migrations OWNER TO postgres; + + +-- +-- Name: bewcloud_user_sessions bewcloud_user_sessions_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.bewcloud_user_sessions + ADD CONSTRAINT bewcloud_user_sessions_pkey PRIMARY KEY (id); + + +-- +-- Name: bewcloud_verification_codes bewcloud_verification_codes_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.bewcloud_verification_codes + ADD CONSTRAINT bewcloud_verification_codes_pkey PRIMARY KEY (id); + + +-- +-- Name: bewcloud_users bewcloud_users_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.bewcloud_users + ADD CONSTRAINT bewcloud_users_pkey PRIMARY KEY (id); + + +-- +-- Name: bewcloud_dashboards bewcloud_dashboards_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.bewcloud_dashboards + ADD CONSTRAINT bewcloud_dashboards_pkey PRIMARY KEY (id); + + +-- +-- Name: bewcloud_dashboards bewcloud_dashboards_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.bewcloud_dashboards + ADD CONSTRAINT bewcloud_dashboards_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id); + + +-- +-- Name: bewcloud_migrations bewcloud_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.bewcloud_migrations + ADD CONSTRAINT bewcloud_migrations_pkey PRIMARY KEY (id); + + +-- +-- Name: bewcloud_user_sessions bewcloud_user_sessions_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.bewcloud_user_sessions + ADD CONSTRAINT bewcloud_user_sessions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id); + + +-- +-- Name: bewcloud_verification_codes bewcloud_verification_codes_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.bewcloud_verification_codes + ADD CONSTRAINT bewcloud_verification_codes_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id); + + +-- +-- Name: TABLE bewcloud_user_sessions; Type: ACL; Schema: public; Owner: postgres +-- + +GRANT ALL ON TABLE public.bewcloud_user_sessions TO postgres; + + +-- +-- Name: TABLE bewcloud_users; Type: ACL; Schema: public; Owner: postgres +-- + +GRANT ALL ON TABLE public.bewcloud_users TO postgres; + + +-- +-- Name: TABLE bewcloud_verification_codes; Type: ACL; Schema: public; Owner: postgres +-- + +GRANT ALL ON TABLE public.bewcloud_verification_codes TO postgres; + + +-- +-- Name: TABLE bewcloud_dashboards; Type: ACL; Schema: public; Owner: postgres +-- + +GRANT ALL ON TABLE public.bewcloud_dashboards TO postgres; + +-- +-- Name: TABLE bewcloud_migrations; Type: ACL; Schema: public; Owner: postgres +-- + +GRANT ALL ON TABLE public.bewcloud_migrations TO postgres; + + +-- +-- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: public; Owner: postgres +-- + +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres; + + +-- +-- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: public; Owner: postgres +-- + +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres; + + +-- +-- Name: DEFAULT PRIVILEGES FOR TABLES; Type: DEFAULT ACL; Schema: public; Owner: postgres +-- + +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres; diff --git a/db-migrations/002-news-app.pgsql b/db-migrations/002-news-app.pgsql new file mode 100644 index 0000000..929bf1a --- /dev/null +++ b/db-migrations/002-news-app.pgsql @@ -0,0 +1,106 @@ +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + + +-- +-- Name: bewcloud_news_feeds; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.bewcloud_news_feeds ( + id uuid DEFAULT gen_random_uuid(), + user_id uuid DEFAULT gen_random_uuid(), + feed_url text NOT NULL, + last_crawled_at timestamp with time zone DEFAULT NULL, + extra jsonb NOT NULL, + created_at timestamp with time zone DEFAULT now() +); + + +ALTER TABLE public.bewcloud_news_feeds OWNER TO postgres; + +CREATE UNIQUE INDEX bewcloud_news_feeds_unique_index ON public.bewcloud_news_feeds ( user_id, feed_url ); + + +-- +-- Name: bewcloud_news_feeds bewcloud_news_feeds_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.bewcloud_news_feeds + ADD CONSTRAINT bewcloud_news_feeds_pkey PRIMARY KEY (id); + + +-- +-- Name: bewcloud_news_feeds bewcloud_news_feeds_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.bewcloud_news_feeds + ADD CONSTRAINT bewcloud_news_feeds_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id); + + +-- +-- Name: TABLE bewcloud_news_feeds; Type: ACL; Schema: public; Owner: postgres +-- + +GRANT ALL ON TABLE public.bewcloud_news_feeds TO postgres; + + +-- +-- Name: bewcloud_news_feed_articles; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.bewcloud_news_feed_articles ( + id uuid DEFAULT gen_random_uuid(), + user_id uuid DEFAULT gen_random_uuid(), + feed_id uuid DEFAULT gen_random_uuid(), + article_url text NOT NULL, + article_title text NOT NULL, + article_summary text NOT NULL, + article_date timestamp with time zone NOT NULL, + is_read boolean DEFAULT FALSE, + extra jsonb NOT NULL, + created_at timestamp with time zone DEFAULT now() +); + + +ALTER TABLE public.bewcloud_news_feed_articles OWNER TO postgres; + +CREATE UNIQUE INDEX bewcloud_news_feed_articles_unique_index ON public.bewcloud_news_feed_articles ( user_id, feed_id, article_url ); + + +-- +-- Name: bewcloud_news_feed_articles bewcloud_news_feed_articles_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.bewcloud_news_feed_articles + ADD CONSTRAINT bewcloud_news_feed_articles_pkey PRIMARY KEY (id); + + +-- +-- Name: bewcloud_news_feed_articles bewcloud_news_feed_articles_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.bewcloud_news_feed_articles + ADD CONSTRAINT bewcloud_news_feed_articles_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id); + + +-- +-- Name: bewcloud_news_feed_articles bewcloud_news_feed_articles_feed_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.bewcloud_news_feed_articles + ADD CONSTRAINT bewcloud_news_feed_articles_feed_id_fkey FOREIGN KEY (feed_id) REFERENCES public.bewcloud_news_feeds(id); + + +-- +-- Name: TABLE bewcloud_news_feed_articles; Type: ACL; Schema: public; Owner: postgres +-- + +GRANT ALL ON TABLE public.bewcloud_news_feed_articles TO postgres; diff --git a/db-migrations/003-contacts-app.pgsql b/db-migrations/003-contacts-app.pgsql new file mode 100644 index 0000000..fc08abb --- /dev/null +++ b/db-migrations/003-contacts-app.pgsql @@ -0,0 +1,52 @@ +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + + +-- +-- Name: bewcloud_contacts; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.bewcloud_contacts ( + id uuid DEFAULT gen_random_uuid(), + user_id uuid DEFAULT gen_random_uuid(), + revision text NOT NULL, + first_name text NOT NULL, + last_name text NOT NULL, + extra jsonb NOT NULL, + updated_at timestamp with time zone DEFAULT now(), + created_at timestamp with time zone DEFAULT now() +); + + +ALTER TABLE public.bewcloud_contacts OWNER TO postgres; + + +-- +-- Name: bewcloud_contacts bewcloud_contacts_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.bewcloud_contacts + ADD CONSTRAINT bewcloud_contacts_pkey PRIMARY KEY (id); + + +-- +-- Name: bewcloud_contacts bewcloud_contacts_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.bewcloud_contacts + ADD CONSTRAINT bewcloud_contacts_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id); + + +-- +-- Name: TABLE bewcloud_contacts; Type: ACL; Schema: public; Owner: postgres +-- + +GRANT ALL ON TABLE public.bewcloud_contacts TO postgres; diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..a03b525 --- /dev/null +++ b/deno.json @@ -0,0 +1,39 @@ +{ + "lock": false, + "tasks": { + "check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx", + "cli": "echo \"import '\\$fresh/src/dev/cli.ts'\" | deno run --unstable -A -", + "manifest": "deno task cli manifest $(pwd)", + "start": "deno run -A --watch=static/,routes/,lib/,components/,islands/ dev.ts", + "build": "deno run -A dev.ts build", + "preview": "deno run -A main.ts", + "update": "deno run -A -r https://fresh.deno.dev/update .", + "test": "deno test -A --check" + }, + "fmt": { + "useTabs": false, + "lineWidth": 120, + "indentWidth": 2, + "singleQuote": true, + "proseWrap": "preserve" + }, + "lint": { + "rules": { + "tags": [ + "fresh", + "recommended" + ], + "exclude": ["no-explicit-any", "no-empty-interface", "ban-types"] + } + }, + "exclude": [ + "./_fresh/*", + "./node_modules/*" + ], + "importMap": "./import_map.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact" + }, + "nodeModulesDir": true +} diff --git a/dev.ts b/dev.ts new file mode 100755 index 0000000..43f077b --- /dev/null +++ b/dev.ts @@ -0,0 +1,8 @@ +#!/usr/bin/env -S deno run -A --watch=static/,routes/,lib/,components/,islands/ + +import dev from 'fresh/dev.ts'; +import config from './fresh.config.ts'; + +import 'std/dotenv/load.ts'; + +await dev(import.meta.url, './main.ts', config); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dd7f967 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + postgresql: + image: postgres:15 + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=fake + - POSTGRES_DB=bewcloud + restart: on-failure + volumes: + - pgdata:/var/lib/postgresql/data + ports: + - 5432:5432 + ulimits: + memlock: + soft: -1 + hard: -1 + +volumes: + pgdata: + driver: local diff --git a/fresh.config.ts b/fresh.config.ts new file mode 100644 index 0000000..d3300da --- /dev/null +++ b/fresh.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'fresh/server.ts'; +import tailwind from 'fresh/plugins/tailwind.ts'; + +import { startCrons } from '/crons/index.ts'; + +const isBuildMode = Deno.args.includes('build'); + +if (!isBuildMode) { + startCrons(); +} + +export default defineConfig({ + plugins: [tailwind()], +}); diff --git a/fresh.gen.ts b/fresh.gen.ts new file mode 100644 index 0000000..6d209c2 --- /dev/null +++ b/fresh.gen.ts @@ -0,0 +1,93 @@ +// DO NOT EDIT. This file is generated by Fresh. +// This file SHOULD be checked into source version control. +// This file is automatically updated during development when running `dev.ts`. + +import * as $_well_known_carddav from './routes/.well-known/carddav.tsx'; +import * as $_404 from './routes/_404.tsx'; +import * as $_app from './routes/_app.tsx'; +import * as $_middleware from './routes/_middleware.tsx'; +import * as $api_contacts_add from './routes/api/contacts/add.tsx'; +import * as $api_contacts_delete from './routes/api/contacts/delete.tsx'; +import * as $api_contacts_get from './routes/api/contacts/get.tsx'; +import * as $api_contacts_import from './routes/api/contacts/import.tsx'; +import * as $api_dashboard_save_links from './routes/api/dashboard/save-links.tsx'; +import * as $api_dashboard_save_notes from './routes/api/dashboard/save-notes.tsx'; +import * as $api_news_add_feed from './routes/api/news/add-feed.tsx'; +import * as $api_news_delete_feed from './routes/api/news/delete-feed.tsx'; +import * as $api_news_import_feeds from './routes/api/news/import-feeds.tsx'; +import * as $api_news_mark_read from './routes/api/news/mark-read.tsx'; +import * as $api_news_refresh_articles from './routes/api/news/refresh-articles.tsx'; +import * as $contacts from './routes/contacts.tsx'; +import * as $contacts_contactId_ from './routes/contacts/[contactId].tsx'; +import * as $dashboard from './routes/dashboard.tsx'; +import * as $dav_addressbooks from './routes/dav/addressbooks.tsx'; +import * as $dav_addressbooks_contacts from './routes/dav/addressbooks/contacts.tsx'; +import * as $dav_addressbooks_contacts_contactId_vcf from './routes/dav/addressbooks/contacts/[contactId].vcf.tsx'; +import * as $dav_files from './routes/dav/files.tsx'; +import * as $dav_index from './routes/dav/index.tsx'; +import * as $dav_principals from './routes/dav/principals.tsx'; +import * as $index from './routes/index.tsx'; +import * as $login from './routes/login.tsx'; +import * as $logout from './routes/logout.tsx'; +import * as $news from './routes/news.tsx'; +import * as $news_feeds from './routes/news/feeds.tsx'; +import * as $remote_php_davRoute_ from './routes/remote.php/[davRoute].tsx'; +import * as $settings from './routes/settings.tsx'; +import * as $signup from './routes/signup.tsx'; +import * as $Settings from './islands/Settings.tsx'; +import * as $contacts_Contacts from './islands/contacts/Contacts.tsx'; +import * as $contacts_ViewContact from './islands/contacts/ViewContact.tsx'; +import * as $dashboard_Links from './islands/dashboard/Links.tsx'; +import * as $dashboard_Notes from './islands/dashboard/Notes.tsx'; +import * as $news_Articles from './islands/news/Articles.tsx'; +import * as $news_Feeds from './islands/news/Feeds.tsx'; +import { type Manifest } from '$fresh/server.ts'; + +const manifest = { + routes: { + './routes/.well-known/carddav.tsx': $_well_known_carddav, + './routes/_404.tsx': $_404, + './routes/_app.tsx': $_app, + './routes/_middleware.tsx': $_middleware, + './routes/api/contacts/add.tsx': $api_contacts_add, + './routes/api/contacts/delete.tsx': $api_contacts_delete, + './routes/api/contacts/get.tsx': $api_contacts_get, + './routes/api/contacts/import.tsx': $api_contacts_import, + './routes/api/dashboard/save-links.tsx': $api_dashboard_save_links, + './routes/api/dashboard/save-notes.tsx': $api_dashboard_save_notes, + './routes/api/news/add-feed.tsx': $api_news_add_feed, + './routes/api/news/delete-feed.tsx': $api_news_delete_feed, + './routes/api/news/import-feeds.tsx': $api_news_import_feeds, + './routes/api/news/mark-read.tsx': $api_news_mark_read, + './routes/api/news/refresh-articles.tsx': $api_news_refresh_articles, + './routes/contacts.tsx': $contacts, + './routes/contacts/[contactId].tsx': $contacts_contactId_, + './routes/dashboard.tsx': $dashboard, + './routes/dav/addressbooks.tsx': $dav_addressbooks, + './routes/dav/addressbooks/contacts.tsx': $dav_addressbooks_contacts, + './routes/dav/addressbooks/contacts/[contactId].vcf.tsx': $dav_addressbooks_contacts_contactId_vcf, + './routes/dav/files.tsx': $dav_files, + './routes/dav/index.tsx': $dav_index, + './routes/dav/principals.tsx': $dav_principals, + './routes/index.tsx': $index, + './routes/login.tsx': $login, + './routes/logout.tsx': $logout, + './routes/news.tsx': $news, + './routes/news/feeds.tsx': $news_feeds, + './routes/remote.php/[davRoute].tsx': $remote_php_davRoute_, + './routes/settings.tsx': $settings, + './routes/signup.tsx': $signup, + }, + islands: { + './islands/Settings.tsx': $Settings, + './islands/contacts/Contacts.tsx': $contacts_Contacts, + './islands/contacts/ViewContact.tsx': $contacts_ViewContact, + './islands/dashboard/Links.tsx': $dashboard_Links, + './islands/dashboard/Notes.tsx': $dashboard_Notes, + './islands/news/Articles.tsx': $news_Articles, + './islands/news/Feeds.tsx': $news_Feeds, + }, + baseUrl: import.meta.url, +} satisfies Manifest; + +export default manifest; diff --git a/import_map.json b/import_map.json new file mode 100644 index 0000000..1c6e2f4 --- /dev/null +++ b/import_map.json @@ -0,0 +1,19 @@ +{ + "imports": { + "/": "./", + "./": "./", + "xml/": "https://deno.land/x/xml@2.1.3/", + + "fresh/": "https://deno.land/x/fresh@1.6.5/", + "$fresh/": "https://deno.land/x/fresh@1.6.5/", + "preact": "https://esm.sh/preact@10.19.2", + "preact/": "https://esm.sh/preact@10.19.2/", + "@preact/signals": "https://esm.sh/*@preact/signals@1.2.1", + "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.0", + "tailwindcss": "npm:tailwindcss@3.4.1", + "tailwindcss/": "npm:/tailwindcss@3.4.1/", + "tailwindcss/plugin": "npm:/tailwindcss@3.4.1/plugin.js", + "std/": "https://deno.land/std@0.217.0/", + "$std/": "https://deno.land/std@0.217.0/" + } +} diff --git a/islands/Settings.tsx b/islands/Settings.tsx new file mode 100644 index 0000000..f2a69b0 --- /dev/null +++ b/islands/Settings.tsx @@ -0,0 +1,175 @@ +import { convertObjectToFormData, helpEmail } from '/lib/utils.ts'; +import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx'; + +interface SettingsProps { + formData: Record; + error?: { + title: string; + message: string; + }; + notice?: { + title: string; + message: string; + }; +} + +export type Action = + | 'change-email' + | 'verify-change-email' + | 'change-password' + | 'change-dav-password' + | 'delete-account'; + +export const actionWords = new Map([ + ['change-email', 'change email'], + ['verify-change-email', 'change email'], + ['change-password', 'change password'], + ['change-dav-password', 'change DAV password'], + ['delete-account', 'delete account'], +]); + +function formFields(action: Action, formData: FormData) { + const fields: FormField[] = [ + { + name: 'action', + label: '', + type: 'hidden', + value: action, + overrideValue: action, + required: true, + readOnly: true, + }, + ]; + + if (action === 'change-email') { + fields.push({ + name: 'email', + label: 'Email', + type: 'email', + placeholder: 'jane.doe@example.com', + value: getFormDataField(formData, 'email'), + required: true, + }); + } else if (action === 'verify-change-email') { + fields.push({ + name: 'email', + label: 'Email', + type: 'email', + placeholder: 'jane.doe@example.com', + value: getFormDataField(formData, 'email'), + required: true, + }, { + name: 'verification-code', + label: 'Verification Code', + description: `The verification code to validate your new email.`, + type: 'text', + placeholder: '000000', + required: true, + }); + } else if (action === 'change-password') { + fields.push({ + name: 'current-password', + label: 'Current Password', + type: 'password', + placeholder: 'super-SECRET-passphrase', + required: true, + }, { + name: 'new-password', + label: 'New Password', + type: 'password', + placeholder: 'super-SECRET-passphrase', + required: true, + }); + } else if (action === 'change-dav-password') { + fields.push({ + name: 'new-dav-password', + label: 'New DAV Password', + type: 'password', + placeholder: 'super-SECRET-passphrase', + required: true, + description: 'Alternative password used for DAV access and/or HTTP Basic Auth.', + }); + } else if (action === 'delete-account') { + fields.push({ + name: 'current-password', + label: 'Password', + type: 'password', + placeholder: 'super-SECRET-passphrase', + description: 'You need to input your password in order to delete your account.', + required: true, + }); + } + return fields; +} + +export default function Settings({ formData: formDataObject, error, notice }: SettingsProps) { + const formData = convertObjectToFormData(formDataObject); + + const action = getFormDataField(formData, 'action') as Action; + + return ( + <> +
+ {error + ? ( +
+

{error.title}

+

{error.message}

+
+ ) + : null} + {notice + ? ( +
+

{notice.title}

+

{notice.message}

+
+ ) + : null} + +

Change your email

+ +
+ {formFields( + action === 'change-email' && notice?.message.includes('verify') ? 'verify-change-email' : 'change-email', + formData, + ).map((field) => generateFieldHtml(field, formData))} +
+ +
+
+ +

Change your password

+ +
+ {formFields('change-password', formData).map((field) => generateFieldHtml(field, formData))} +
+ +
+
+ +

Change your DAV password

+ +
+ {formFields('change-dav-password', formData).map((field) => generateFieldHtml(field, formData))} +
+ +
+
+ +

Delete your account

+

+ Deleting your account is instant and deletes all your data. If you need help, please{' '} + reach out. +

+ +
+ {formFields('delete-account', formData).map((field) => generateFieldHtml(field, formData))} +
+ +
+
+
+ + ); +} diff --git a/islands/contacts/Contacts.tsx b/islands/contacts/Contacts.tsx new file mode 100644 index 0000000..4b26c6a --- /dev/null +++ b/islands/contacts/Contacts.tsx @@ -0,0 +1,407 @@ +import { useSignal } from '@preact/signals'; + +import { Contact } from '/lib/types.ts'; +import { baseUrl, CONTACTS_PER_PAGE_COUNT, formatContactToVCard, parseVCardFromTextContents } from '/lib/utils.ts'; +import { RequestBody as GetRequestBody, ResponseBody as GetResponseBody } from '/routes/api/contacts/get.tsx'; +import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/contacts/add.tsx'; +import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/contacts/delete.tsx'; +import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/routes/api/contacts/import.tsx'; + +interface ContactsProps { + initialContacts: Pick[]; + page: number; + contactsCount: number; + search?: string; +} + +export default function Contacts({ initialContacts, page, contactsCount, search }: ContactsProps) { + const isAdding = useSignal(false); + const isDeleting = useSignal(false); + const isExporting = useSignal(false); + const isImporting = useSignal(false); + const contacts = useSignal[]>(initialContacts); + const isOptionsDropdownOpen = useSignal(false); + + async function onClickAddContact() { + if (isAdding.value) { + return; + } + + const firstName = (prompt(`What's the **first name** for the new contact?`) || '').trim(); + + if (!firstName) { + alert('A first name is required for a new contact!'); + return; + } + + const lastName = (prompt(`What's the **last name** for the new contact?`) || '').trim(); + + isAdding.value = true; + + try { + const requestBody: AddRequestBody = { firstName, lastName, page }; + const response = await fetch(`/api/contacts/add`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as AddResponseBody; + + if (!result.success) { + throw new Error('Failed to add contact!'); + } + + contacts.value = [...result.contacts]; + } catch (error) { + console.error(error); + } + + isAdding.value = false; + } + + function toggleOptionsDropdown() { + isOptionsDropdownOpen.value = !isOptionsDropdownOpen.value; + } + + async function onClickDeleteContact(contactId: string) { + if (confirm('Are you sure you want to delete this contact?')) { + if (isDeleting.value) { + return; + } + + isDeleting.value = true; + + try { + const requestBody: DeleteRequestBody = { contactId, page }; + const response = await fetch(`/api/contacts/delete`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as DeleteResponseBody; + + if (!result.success) { + throw new Error('Failed to delete contact!'); + } + + contacts.value = [...result.contacts]; + } catch (error) { + console.error(error); + } + + isDeleting.value = false; + } + } + + function onClickImportVCard() { + isOptionsDropdownOpen.value = false; + + if (isImporting.value) { + return; + } + + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.click(); + + fileInput.onchange = (event) => { + const files = (event.target as HTMLInputElement)?.files!; + const file = files[0]; + + if (!file) { + return; + } + + const reader = new FileReader(); + reader.onload = async (fileRead) => { + const importFileContents = fileRead.target?.result; + + if (!importFileContents || isImporting.value) { + return; + } + + isImporting.value = true; + + try { + const partialContacts = parseVCardFromTextContents(importFileContents!.toString()); + + const requestBody: ImportRequestBody = { partialContacts, page }; + const response = await fetch(`/api/contacts/import`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as ImportResponseBody; + + if (!result.success) { + throw new Error('Failed to import contact!'); + } + + contacts.value = [...result.contacts]; + } catch (error) { + console.error(error); + } + + isImporting.value = false; + }; + + reader.readAsText(file, 'UTF-8'); + }; + } + + async function onClickExportVCard() { + isOptionsDropdownOpen.value = false; + + if (isExporting.value) { + return; + } + + isExporting.value = true; + + const fileName = ['contacts-', new Date().toISOString().substring(0, 19).replace(/:/g, '-'), '.vcf'] + .join(''); + + try { + const requestBody: GetRequestBody = {}; + const response = await fetch(`/api/contacts/get`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as GetResponseBody; + + if (!result.success) { + throw new Error('Failed to get contact!'); + } + + const exportContents = formatContactToVCard([...result.contacts]); + + // Add content-type + const vCardContent = ['data:text/vcard; charset=utf-8,', encodeURIComponent(exportContents)].join(''); + + // Download the file + const data = vCardContent; + const link = document.createElement('a'); + link.setAttribute('href', data); + link.setAttribute('download', fileName); + link.click(); + link.remove(); + } catch (error) { + console.error(error); + } + + isExporting.value = false; + } + + const pagesCount = Math.ceil(contactsCount / CONTACTS_PER_PAGE_COUNT); + const pages = Array.from({ length: pagesCount }).map((_value, index) => index + 1); + + return ( + <> +
+
+
+ +
+
+
+
+
+ +
+ + +
+ +
+
+ +
+ + + + + + + + + + {contacts.value.map((contact) => ( + + + + + + ))} + {contacts.value.length === 0 + ? ( + + + + ) + : null} + +
First NameLast Name
+ {contact.first_name} + + {contact.last_name} + + +
+
+
No contacts to show
+
+
+ + + {isDeleting.value + ? ( + <> + Deleting... + + ) + : null} + {isExporting.value + ? ( + <> + Exporting... + + ) + : null} + {isImporting.value + ? ( + <> + Importing... + + ) + : null} + {!isDeleting.value && !isExporting.value && !isImporting.value ? <>  : null} + +
+ + {pagesCount > 0 + ? ( +
+ +
+ ) + : null} + +
+ CardDAV URLs:{' '} + {baseUrl}/dav/principals/{' '} + {baseUrl}/dav/addressbooks/ +
+ + ); +} diff --git a/islands/contacts/ViewContact.tsx b/islands/contacts/ViewContact.tsx new file mode 100644 index 0000000..f67189e --- /dev/null +++ b/islands/contacts/ViewContact.tsx @@ -0,0 +1,471 @@ +import { useSignal } from '@preact/signals'; + +import { Contact } from '/lib/types.ts'; +import { convertObjectToFormData } from '/lib/utils.ts'; +import { FormField, generateFieldHtml } from '/lib/form-utils.tsx'; +import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/contacts/delete.tsx'; + +interface ViewContactProps { + initialContact: Contact; + formData: Record; + error?: string; + notice?: string; +} + +export function formFields(contact: Contact) { + const fields: FormField[] = [ + { + name: 'name_title', + label: 'Honorary title/prefix', + type: 'text', + placeholder: 'Dr.', + value: contact.extra.name_title, + required: false, + }, + { + name: 'first_name', + label: 'First name', + type: 'text', + placeholder: 'John', + value: contact.first_name, + required: true, + }, + { + name: 'middle_names', + label: 'Middle name(s)', + type: 'text', + placeholder: '', + value: contact.extra.middle_names?.map((name) => (name || '').trim()).filter(Boolean).join(' '), + required: false, + }, + { + name: 'last_name', + label: 'Last name', + type: 'text', + placeholder: 'Doe', + value: contact.last_name, + required: false, + }, + { + name: 'birthday', + label: 'Birthday', + type: 'text', + placeholder: 'YYYYMMDD', + value: contact.extra.birthday, + required: false, + }, + { + name: 'nickname', + label: 'Nickname', + type: 'text', + placeholder: 'Johnny', + value: contact.extra.nickname, + required: false, + }, + { + name: 'organization', + label: 'Company/Organization', + type: 'text', + placeholder: 'Acme Corporation', + value: contact.extra.organization, + required: false, + }, + { + name: 'role', + label: 'Job/Role', + type: 'text', + placeholder: '(Super) Genius', + value: contact.extra.role, + required: false, + }, + { + name: 'photo_url', + label: 'Photo URL', + type: 'url', + placeholder: 'https://example.com/image.jpg', + value: contact.extra.photo_url, + required: false, + }, + ]; + + // Phones + const phones = contact.extra.fields?.filter((field) => field.type === 'phone') || []; + for (const [index, phone] of phones.entries()) { + fields.push({ + name: 'phone_numbers', + label: `Phone number #${index + 1}`, + type: 'tel', + placeholder: '+44 0000 111 2222', + value: phone.value, + required: false, + }); + + fields.push({ + name: 'phone_labels', + label: `Phone label #${index + 1}`, + type: 'text', + placeholder: 'Home, Work, etc.', + value: phone.name, + required: false, + }); + } + + fields.push({ + name: 'phone_numbers', + label: `Phone number #${phones.length + 1}`, + type: 'tel', + placeholder: '+44 0000 111 2222', + value: '', + required: false, + }, { + name: 'phone_labels', + label: `Phone label #${phones.length + 1}`, + type: 'text', + placeholder: 'Home, Work, etc.', + value: '', + required: false, + }); + + // Emails + const emails = contact.extra.fields?.filter((field) => field.type === 'email') || []; + for (const [index, email] of emails.entries()) { + fields.push({ + name: 'email_addresses', + label: `Email #${index + 1}`, + type: 'email', + placeholder: 'user@example.com', + value: email.value, + required: false, + }); + + fields.push({ + name: 'email_labels', + label: `Email label #${index + 1}`, + type: 'text', + placeholder: 'Home, Work, etc.', + value: email.name, + required: false, + }); + } + + fields.push({ + name: 'email_addresses', + label: `Email #${emails.length + 1}`, + type: 'email', + placeholder: 'user@example.com', + value: '', + required: false, + }, { + name: 'email_labels', + label: `Email label #${emails.length + 1}`, + type: 'text', + placeholder: 'Home, Work, etc.', + value: '', + required: false, + }); + + // URLs + const urls = contact.extra.fields?.filter((field) => field.type === 'url') || []; + for (const [index, url] of urls.entries()) { + fields.push({ + name: 'url_addresses', + label: `URL #${index + 1}`, + type: 'url', + placeholder: 'https://example.com', + value: url.value, + required: false, + }); + + fields.push({ + name: 'url_labels', + label: `URL label #${index + 1}`, + type: 'text', + placeholder: 'Home, Work, etc.', + value: url.name, + required: false, + }); + } + + fields.push({ + name: 'url_addresses', + label: `URL #${urls.length + 1}`, + type: 'url', + placeholder: 'https://example.com', + value: '', + required: false, + }, { + name: 'url_labels', + label: `URL label #${urls.length + 1}`, + type: 'text', + placeholder: 'Home, Work, etc.', + value: '', + required: false, + }); + + // Others + const others = contact.extra.fields?.filter((field) => field.type === 'other') || []; + for (const [index, other] of others.entries()) { + fields.push({ + name: 'other_values', + label: `Other contact #${index + 1}`, + type: 'text', + placeholder: '@acme', + value: other.value, + required: false, + }); + + fields.push({ + name: 'other_labels', + label: `Other label #${index + 1}`, + type: 'text', + placeholder: 'Home, Work, etc.', + value: other.name, + required: false, + }); + } + + fields.push({ + name: 'other_values', + label: `Other contact #${others.length + 1}`, + type: 'text', + placeholder: '@acme', + value: '', + required: false, + }, { + name: 'other_labels', + label: `Other label #${others.length + 1}`, + type: 'text', + placeholder: 'Home, Work, etc.', + value: '', + required: false, + }); + + // Addresses + const addresses = contact.extra.addresses || []; + for (const [index, address] of addresses.entries()) { + fields.push({ + name: 'address_line_1s', + label: `Address line 1 #${index + 1}`, + type: 'text', + placeholder: '992 Tyburn Rd', + value: address.line_1, + required: false, + }); + + fields.push({ + name: 'address_line_2s', + label: `Address line 2 #${index + 1}`, + type: 'text', + placeholder: 'Apt 2', + value: address.line_2, + required: false, + }); + + fields.push({ + name: 'address_cities', + label: `Address city #${index + 1}`, + type: 'text', + placeholder: 'Birmingham', + value: address.city, + required: false, + }); + + fields.push({ + name: 'address_postal_codes', + label: `Address postal code #${index + 1}`, + type: 'text', + placeholder: 'B24 0TL', + value: address.postal_code, + required: false, + }); + + fields.push({ + name: 'address_states', + label: `Address state #${index + 1}`, + type: 'text', + placeholder: 'West Midlands', + value: address.state, + required: false, + }); + + fields.push({ + name: 'address_countries', + label: `Address country #${index + 1}`, + type: 'text', + placeholder: 'United Kingdom', + value: address.country, + required: false, + }); + + fields.push({ + name: 'address_labels', + label: `Address label #${index + 1}`, + type: 'text', + placeholder: 'Home, Work, etc.', + value: address.label, + required: false, + }); + } + + fields.push({ + name: 'address_line_1s', + label: `Address line 1 #${addresses.length + 1}`, + type: 'text', + placeholder: '992 Tyburn Rd', + value: '', + required: false, + }, { + name: 'address_line_2s', + label: `Address line 2 #${addresses.length + 1}`, + type: 'text', + placeholder: 'Apt 2', + value: '', + required: false, + }, { + name: 'address_cities', + label: `Address city #${addresses.length + 1}`, + type: 'text', + placeholder: 'Birmingham', + value: '', + required: false, + }, { + name: 'address_postal_codes', + label: `Address postal code #${addresses.length + 1}`, + type: 'text', + placeholder: 'B24 0TL', + value: '', + required: false, + }, { + name: 'address_states', + label: `Address state #${addresses.length + 1}`, + type: 'text', + placeholder: 'West Midlands', + value: '', + required: false, + }, { + name: 'address_countries', + label: `Address country #${addresses.length + 1}`, + type: 'text', + placeholder: 'United Kingdom', + value: '', + required: false, + }, { + name: 'address_labels', + label: `Address label #${addresses.length + 1}`, + type: 'text', + placeholder: 'Home, Work, etc.', + value: '', + required: false, + }); + + fields.push({ + name: 'notes', + label: 'Notes', + type: 'textarea', + placeholder: 'Some notes...', + value: contact.extra.notes, + required: false, + }); + + return fields; +} + +export default function ViewContact({ initialContact, formData: formDataObject, error, notice }: ViewContactProps) { + const isDeleting = useSignal(false); + const contact = useSignal(initialContact); + + const formData = convertObjectToFormData(formDataObject); + + async function onClickDeleteContact() { + if (confirm('Are you sure you want to delete this contact?')) { + if (isDeleting.value) { + return; + } + + isDeleting.value = true; + + try { + const requestBody: DeleteRequestBody = { contactId: contact.value.id, page: 1 }; + const response = await fetch(`/api/contacts/delete`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as DeleteResponseBody; + + if (!result.success) { + throw new Error('Failed to delete contact!'); + } + + window.location.href = '/contacts'; + } catch (error) { + console.error(error); + } + + isDeleting.value = false; + } + } + + return ( + <> +
+ View contacts +
+ +
+
+ +
+ {error + ? ( +
+

Failed to update!

+

{error}

+
+ ) + : null} + {notice + ? ( +
+

Success!

+

{notice}

+
+ ) + : null} + +
+ {formFields(contact.peek()).map((field) => generateFieldHtml(field, formData))} + +
+ +
+
+ + + {isDeleting.value + ? ( + <> + Deleting... + + ) + : null} + {!isDeleting.value ? <>  : null} + +
+ + ); +} diff --git a/islands/dashboard/Links.tsx b/islands/dashboard/Links.tsx new file mode 100644 index 0000000..8b2a5bd --- /dev/null +++ b/islands/dashboard/Links.tsx @@ -0,0 +1,199 @@ +import { useSignal } from '@preact/signals'; +import { useEffect } from 'preact/hooks'; + +import { DashboardLink } from '/lib/types.ts'; +import { validateUrl } from '/lib/utils.ts'; +import { RequestBody, ResponseBody } from '/routes/api/dashboard/save-links.tsx'; + +interface LinksProps { + initialLinks: DashboardLink[]; +} + +export default function Links({ initialLinks }: LinksProps) { + const hasSavedTimeout = useSignal>(0); + const isSaving = useSignal(false); + const hasSaved = useSignal(false); + const links = useSignal(initialLinks); + + async function saveLinks(newLinks: DashboardLink[]) { + if (isSaving.value) { + return; + } + + hasSaved.value = false; + isSaving.value = true; + + const oldLinks = [...links.value]; + + links.value = newLinks; + + try { + const requestBody: RequestBody = { links: newLinks }; + const response = await fetch(`/api/dashboard/save-links`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as ResponseBody; + + if (!result.success) { + throw new Error('Failed to save notes!'); + } + } catch (error) { + console.error(error); + links.value = [...oldLinks]; + } + + isSaving.value = false; + hasSaved.value = true; + + if (hasSavedTimeout.value) { + clearTimeout(hasSavedTimeout.value); + } + + hasSavedTimeout.value = setTimeout(() => { + hasSaved.value = false; + }, 3000); + } + + useEffect(() => { + return () => { + if (hasSavedTimeout.value) { + clearTimeout(hasSavedTimeout.value); + } + }; + }); + + function onClickAddLink() { + const name = (prompt(`What's the **name** for the new link?`) || '').trim(); + const url = (prompt(`What's the **URL** for the new link?`) || '').trim(); + + if (!name || !url) { + alert('A name and URL are required for a new link!'); + return; + } + + if (!validateUrl(url)) { + alert('Invalid URL!'); + return; + } + + const newLinks = [...links.value, { name, url }]; + + saveLinks(newLinks); + } + + function onClickDeleteLink(indexToDelete: number) { + if (confirm('Are you sure you want to delete this link?')) { + const newLinks = [...links.value]; + + newLinks.splice(indexToDelete, 1); + + saveLinks(newLinks); + } + } + + function onClickMoveLeftLink(indexToMoveLeft: number) { + if (indexToMoveLeft <= 0) { + return; + } + + if (confirm('Are you sure you want to move this link left?')) { + const newLinks = [...links.value]; + + const linkToMove = newLinks.splice(indexToMoveLeft, 1); + + newLinks.splice(indexToMoveLeft - 1, 0, linkToMove[0]); + + saveLinks(newLinks); + } + } + + return ( + <> +
+
+ +
+
+ +
+
+ {links.value.map((link, index) => ( +
+ + + {index > 0 + ? ( + + ) + : null} +
+ ))} +
+ + + {isSaving.value + ? ( + <> + Saving... + + ) + : null} + {hasSaved.value + ? ( + <> + Saved! + + ) + : null} + {!isSaving.value && !hasSaved.value ? <>  : null} + +
+ + ); +} diff --git a/islands/dashboard/Notes.tsx b/islands/dashboard/Notes.tsx new file mode 100644 index 0000000..822f60c --- /dev/null +++ b/islands/dashboard/Notes.tsx @@ -0,0 +1,98 @@ +import { useSignal, useSignalEffect } from '@preact/signals'; +import { useEffect } from 'preact/hooks'; + +import { RequestBody, ResponseBody } from '/routes/api/dashboard/save-notes.tsx'; + +interface NotesProps { + initialNotes: string; +} + +export default function Notes({ initialNotes }: NotesProps) { + const saveTimeout = useSignal>(0); + const hasSavedTimeout = useSignal>(0); + const isSaving = useSignal(false); + const hasSaved = useSignal(false); + + function saveNotes(newNotes: string) { + if (saveTimeout.value) { + clearTimeout(saveTimeout.value); + } + + saveTimeout.value = setTimeout(async () => { + hasSaved.value = false; + isSaving.value = true; + + try { + const requestBody: RequestBody = { notes: newNotes }; + const response = await fetch(`/api/dashboard/save-notes`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as ResponseBody; + + if (!result.success) { + throw new Error('Failed to save notes!'); + } + } catch (error) { + console.error(error); + } + + isSaving.value = false; + hasSaved.value = true; + }, 1000); + } + + useSignalEffect(() => { + if (hasSaved.value && !hasSavedTimeout.value) { + hasSavedTimeout.value = setTimeout(() => { + hasSaved.value = false; + }, 3000); + } + }); + + useEffect(() => { + return () => { + if (saveTimeout.value) { + clearTimeout(saveTimeout.value); + } + + if (hasSavedTimeout.value) { + clearTimeout(hasSavedTimeout.value); + } + }; + }, []); + + return ( +
+ + + + {isSaving.value + ? ( + <> + Saving... + + ) + : null} + {hasSaved.value + ? ( + <> + Saved! + + ) + : null} + {!isSaving.value && !hasSaved.value ? <>  : null} + +
+ ); +} diff --git a/islands/news/Articles.tsx b/islands/news/Articles.tsx new file mode 100644 index 0000000..1f4412a --- /dev/null +++ b/islands/news/Articles.tsx @@ -0,0 +1,265 @@ +import { useSignal } from '@preact/signals'; + +import { NewsFeedArticle } from '/lib/types.ts'; +import { + RequestBody as RefreshRequestBody, + ResponseBody as RefreshResponseBody, +} from '/routes/api/news/refresh-articles.tsx'; +import { RequestBody as ReadRequestBody, ResponseBody as ReadResponseBody } from '/routes/api/news/mark-read.tsx'; + +interface ArticlesProps { + initialArticles: NewsFeedArticle[]; +} + +interface Filter { + status: 'all' | 'unread'; +} + +export default function Articles({ initialArticles }: ArticlesProps) { + const isRefreshing = useSignal(false); + const articles = useSignal(initialArticles); + const filter = useSignal({ status: 'unread' }); + const sessionReadArticleIds = useSignal>(new Set()); + const isFilterDropdownOpen = useSignal(false); + + const dateFormat = new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium' }); + + async function refreshArticles() { + if (isRefreshing.value) { + return; + } + + isRefreshing.value = true; + + try { + const requestBody: RefreshRequestBody = {}; + const response = await fetch(`/api/news/refresh-articles`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as RefreshResponseBody; + + if (!result.success) { + throw new Error('Failed to refresh articles!'); + } + + articles.value = [...result.newArticles]; + } catch (error) { + console.error(error); + } + + isRefreshing.value = false; + } + + const filteredArticles = articles.value.filter((article) => { + if (filter.value.status === 'unread') { + if (article.is_read && !sessionReadArticleIds.value.has(article.id)) { + return false; + } + + return true; + } + + return true; + }); + + async function onClickView(articleId: string) { + const newArticles = [...articles.value]; + + const matchingArticle = newArticles.find((article) => article.id === articleId); + if (matchingArticle) { + if (matchingArticle.is_read) { + return; + } + + matchingArticle.is_read = true; + } else { + return; + } + + sessionReadArticleIds.value.add(articleId); + + articles.value = [...newArticles]; + + try { + const requestBody: ReadRequestBody = { articleId }; + const response = await fetch(`/api/news/mark-read`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as ReadResponseBody; + + if (!result.success) { + throw new Error('Failed to mark article as read!'); + } + } catch (error) { + console.error(error); + } + } + + async function onClickMarkAllRead() { + const newArticles = [...articles.value].map((article) => { + article.is_read = true; + + sessionReadArticleIds.value.add(article.id); + + return article; + }); + + articles.value = [...newArticles]; + + try { + const requestBody: ReadRequestBody = { articleId: 'all' }; + const response = await fetch(`/api/news/mark-read`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as ReadResponseBody; + + if (!result.success) { + throw new Error('Failed to mark all articles as read!'); + } + } catch (error) { + console.error(error); + } + } + + function toggleFilterDropdown() { + isFilterDropdownOpen.value = !isFilterDropdownOpen.value; + } + + function setNewFilter(newFilter: Partial) { + filter.value = { ...filter.value, ...newFilter }; + + isFilterDropdownOpen.value = false; + } + + return ( + <> +
+ Manage feeds +
+
+
+ +
+ + +
+ + + + +
+
+ +
+ {filteredArticles.length === 0 + ?

There are no new articles to show.

+ : ( +
+ {filteredArticles.map((article) => ( +
+ onClickView(article.id)} + > + {article.article_title} + + {dateFormat.format(new Date(article.article_date))} + + +
+ {article.article_summary} +
+ onClickView(article.id)} + > + {article.article_url} + View article + +
+ ))} +
+ )} +
+ + ); +} diff --git a/islands/news/Feeds.tsx b/islands/news/Feeds.tsx new file mode 100644 index 0000000..973cfc0 --- /dev/null +++ b/islands/news/Feeds.tsx @@ -0,0 +1,372 @@ +import { useSignal } from '@preact/signals'; + +import { NewsFeed } from '/lib/types.ts'; +import { escapeHtml, validateUrl } from '/lib/utils.ts'; +import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/news/add-feed.tsx'; +import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/news/delete-feed.tsx'; +import { + RequestBody as ImportRequestBody, + ResponseBody as ImportResponseBody, +} from '/routes/api/news/import-feeds.tsx'; + +interface FeedsProps { + initialFeeds: NewsFeed[]; +} + +function formatNewsFeedsToOpml(feeds: NewsFeed[]) { + return ` + + + Subscriptions + + + ${ + feeds.map((feed) => + `` + ).join('\n ') + } + +`; +} + +function parseOpmlFromTextContents(html: string): string[] { + const feedUrls: string[] = []; + + const document = new DOMParser().parseFromString(html, 'text/html'); + + const feeds = Array.from(document.getElementsByTagName('outline')); + + for (const feed of feeds) { + const url = (feed.getAttribute('xmlUrl') || feed.getAttribute('htmlUrl') || '').trim(); + + if (validateUrl(url)) { + feedUrls.push(url); + } + } + + return feedUrls; +} + +export default function Feeds({ initialFeeds }: FeedsProps) { + const isAdding = useSignal(false); + const isDeleting = useSignal(false); + const isExporting = useSignal(false); + const isImporting = useSignal(false); + const feeds = useSignal(initialFeeds); + const isOptionsDropdownOpen = useSignal(false); + + const dateFormat = new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium', timeStyle: 'short' }); + + async function onClickAddFeed() { + if (isAdding.value) { + return; + } + + const url = (prompt(`What's the **URL** for the new feed?`) || '').trim(); + + if (!url) { + alert('A URL is required for a new feed!'); + return; + } + + if (!validateUrl(url)) { + alert('Invalid URL!'); + return; + } + + isAdding.value = true; + + try { + const requestBody: AddRequestBody = { feedUrl: url }; + const response = await fetch(`/api/news/add-feed`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as AddResponseBody; + + if (!result.success) { + throw new Error('Failed to add feed!'); + } + + feeds.value = [...result.newFeeds]; + } catch (error) { + console.error(error); + } + + isAdding.value = false; + } + + function toggleOptionsDropdown() { + isOptionsDropdownOpen.value = !isOptionsDropdownOpen.value; + } + + async function onClickDeleteFeed(feedId: string) { + if (confirm('Are you sure you want to delete this feed and all its articles?')) { + if (isDeleting.value) { + return; + } + + isDeleting.value = true; + + try { + const requestBody: DeleteRequestBody = { feedId }; + const response = await fetch(`/api/news/delete-feed`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as DeleteResponseBody; + + if (!result.success) { + throw new Error('Failed to delete feed!'); + } + + feeds.value = [...result.newFeeds]; + } catch (error) { + console.error(error); + } + + isDeleting.value = false; + } + } + + function onClickImportOpml() { + isOptionsDropdownOpen.value = false; + + if (isImporting.value) { + return; + } + + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.click(); + + fileInput.onchange = (event) => { + const files = (event.target as HTMLInputElement)?.files!; + const file = files[0]; + + if (!file) { + return; + } + + const reader = new FileReader(); + reader.onload = async (fileRead) => { + const importFileContents = fileRead.target?.result; + + if (!importFileContents || isImporting.value) { + return; + } + + isImporting.value = true; + + try { + const feedUrls = parseOpmlFromTextContents(importFileContents!.toString()); + + const requestBody: ImportRequestBody = { feedUrls }; + const response = await fetch(`/api/news/import-feeds`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as ImportResponseBody; + + if (!result.success) { + throw new Error('Failed to import feeds!'); + } + + feeds.value = [...result.newFeeds]; + } catch (error) { + console.error(error); + } + + isImporting.value = false; + }; + + reader.readAsText(file, 'UTF-8'); + }; + } + + function onClickExportOpml() { + isOptionsDropdownOpen.value = false; + + if (isExporting.value) { + return; + } + + isExporting.value = true; + + const fileName = ['feeds-', new Date().toISOString().substring(0, 19).replace(/:/g, '-'), '.opml'] + .join(''); + + const exportContents = formatNewsFeedsToOpml([...feeds.peek()]); + + // Add content-type + const xmlContent = ['data:application/xml; charset=utf-8,', exportContents].join(''); + + // Download the file + const data = encodeURI(xmlContent); + const link = document.createElement('a'); + link.setAttribute('href', data); + link.setAttribute('download', fileName); + link.click(); + link.remove(); + + isExporting.value = false; + } + + return ( + <> +
+ View articles +
+
+
+ +
+ + +
+ +
+
+ +
+ + + + + + + + + + + {feeds.value.map((newsFeed) => ( + + + + + + + ))} + {feeds.value.length === 0 + ? ( + + + + ) + : null} + +
Title & URLLast CrawlType
+
+
{newsFeed.extra.title || 'N/A'}
+
{newsFeed.feed_url}
+
+
+ {newsFeed.last_crawled_at ? dateFormat.format(new Date(newsFeed.last_crawled_at)) : 'N/A'} + +
+ {newsFeed.extra.feed_type?.split('').map((character) => character.toUpperCase()).join('') || 'N/A'} +
+
+ +
+
+
No feeds to show
+
+
+ + + {isDeleting.value + ? ( + <> + Deleting... + + ) + : null} + {isExporting.value + ? ( + <> + Exporting... + + ) + : null} + {isImporting.value + ? ( + <> + Importing... + + ) + : null} + {!isDeleting.value && !isExporting.value && !isImporting.value ? <>  : null} + +
+ + ); +} diff --git a/lib/auth.ts b/lib/auth.ts new file mode 100644 index 0000000..ea8463d --- /dev/null +++ b/lib/auth.ts @@ -0,0 +1,238 @@ +import { decodeBase64Url, encodeBase64Url } from 'std/encoding/base64url.ts'; +import { decodeBase64 } from 'std/encoding/base64.ts'; +import { Cookie, getCookies, setCookie } from 'std/http/cookie.ts'; +import 'std/dotenv/load.ts'; + +import { baseUrl, generateHash, isRunningLocally } from './utils.ts'; +import { User, UserSession } from './types.ts'; +import { createUserSession, deleteUserSession, getUserByEmail, validateUserAndSession } from './data/user.ts'; + +const JWT_SECRET = Deno.env.get('JWT_SECRET') || ''; +export const PASSWORD_SALT = Deno.env.get('PASSWORD_SALT') || ''; +export const COOKIE_NAME = 'bewcloud-app-v1'; + +export interface JwtData { + data: { + user_id: string; + session_id: string; + }; +} + +const textToData = (text: string) => new TextEncoder().encode(text); + +export const dataToText = (data: Uint8Array) => new TextDecoder().decode(data); + +const generateKey = async (key: string) => + await crypto.subtle.importKey('raw', textToData(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']); + +async function signAuthJwt(key: CryptoKey, data: JwtData) { + const payload = encodeBase64Url(textToData(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))) + '.' + + encodeBase64Url(textToData(JSON.stringify(data) || '')); + const signature = encodeBase64Url( + new Uint8Array(await crypto.subtle.sign({ name: 'HMAC' }, key, textToData(payload))), + ); + return `${payload}.${signature}`; +} + +async function verifyAuthJwt(key: CryptoKey, jwt: string) { + const jwtParts = jwt.split('.'); + if (jwtParts.length !== 3) { + throw new Error('Malformed JWT'); + } + + const data = textToData(jwtParts[0] + '.' + jwtParts[1]); + if (await crypto.subtle.verify({ name: 'HMAC' }, key, decodeBase64Url(jwtParts[2]), data) === true) { + return JSON.parse(dataToText(decodeBase64Url(jwtParts[1]))) as JwtData; + } + + throw new Error('Invalid JWT'); +} + +export async function getDataFromRequest(request: Request) { + const cookies = getCookies(request.headers); + const authorizationHeader = request.headers.get('authorization'); + + if (cookies[COOKIE_NAME]) { + const result = await getDataFromCookie(cookies[COOKIE_NAME]); + + if (result) { + return result; + } + } + + if (authorizationHeader) { + const result = await getDataFromAuthorizationHeader(authorizationHeader); + + if (result) { + return result; + } + } + + return null; +} + +async function getDataFromAuthorizationHeader(authorizationHeader: string) { + if (!authorizationHeader) { + return null; + } + + // Only basic auth is supported for now + if (!authorizationHeader.startsWith('Basic ')) { + return null; + } + + try { + const basicAuthHash = authorizationHeader.split('Basic ')[1] || ''; + + const [basicAuthUsername, basicAuthPassword] = dataToText(decodeBase64(basicAuthHash)).split(':'); + + const hashedPassword = await generateHash(`${basicAuthPassword}:${PASSWORD_SALT}`, 'SHA-256'); + + const user = await getUserByEmail(basicAuthUsername); + + if (!user || (user.hashed_password !== hashedPassword && user.extra.dav_hashed_password !== hashedPassword)) { + throw new Error('Email not found or invalid password.'); + } + + return { user, session: undefined }; + } catch (error) { + console.error(error); + } + + return null; +} + +async function getDataFromCookie(cookieValue: string) { + if (!cookieValue) { + return null; + } + + const key = await generateKey(JWT_SECRET); + + try { + const token = await verifyAuthJwt(key, cookieValue) as JwtData; + + const { user, session } = await validateUserAndSession(token.data.user_id, token.data.session_id); + + return { user, session, tokenData: token.data }; + } catch (error) { + console.error(error); + } + + return null; +} + +export async function generateToken(tokenData: JwtData['data']) { + const key = await generateKey(JWT_SECRET); + + const token = await signAuthJwt(key, { data: tokenData }); + + return token; +} + +export async function logoutUser(request: Request) { + const tomorrow = new Date(new Date().setDate(new Date().getDate() + 1)); + + const cookies = getCookies(request.headers); + + const result = await getDataFromCookie(cookies[COOKIE_NAME]); + + if (!result || !result.tokenData?.session_id || !result.user) { + throw new Error('Invalid session'); + } + + const { tokenData } = result; + const { session_id } = tokenData; + + // Delete user session + await deleteUserSession(session_id); + + // Generate response with empty and expiring cookie + const cookie: Cookie = { + name: COOKIE_NAME, + value: '', + expires: tomorrow, + domain: isRunningLocally(request) ? 'localhost' : baseUrl.replace('https://', ''), + path: '/', + secure: isRunningLocally(request) ? false : true, + httpOnly: true, + sameSite: 'Lax', + }; + + const response = new Response('Logged Out', { + status: 303, + headers: { 'Location': '/', 'Content-Type': 'text/html; charset=utf-8' }, + }); + + setCookie(response.headers, cookie); + + return response; +} + +export async function createSessionResponse( + request: Request, + user: User, + { urlToRedirectTo = '/' }: { + urlToRedirectTo?: string; + } = {}, +) { + const response = new Response('Logged In', { + status: 303, + headers: { 'Location': urlToRedirectTo, 'Content-Type': 'text/html; charset=utf-8' }, + }); + + const responseWithCookie = await createSessionCookie(request, user, response); + + return responseWithCookie; +} + +export async function createSessionCookie( + request: Request, + user: User, + response: Response, + isShortLived = false, +) { + const newSession = await createUserSession(user, isShortLived); + + // Generate response with session cookie + const token = await generateToken({ user_id: user.id, session_id: newSession.id }); + + const cookie: Cookie = { + name: COOKIE_NAME, + value: token, + expires: newSession.expires_at, + domain: isRunningLocally(request) ? 'localhost' : baseUrl.replace('https://', ''), + path: '/', + secure: isRunningLocally(request) ? false : true, + httpOnly: true, + sameSite: 'Lax', + }; + + setCookie(response.headers, cookie); + + return response; +} + +export async function updateSessionCookie( + response: Response, + request: Request, + userSession: UserSession, + newSessionData: JwtData['data'], +) { + const token = await generateToken(newSessionData); + + const cookie: Cookie = { + name: COOKIE_NAME, + value: token, + expires: userSession.expires_at, + domain: isRunningLocally(request) ? 'localhost' : baseUrl.replace('https://', ''), + path: '/', + secure: isRunningLocally(request) ? false : true, + httpOnly: true, + sameSite: 'Lax', + }; + + setCookie(response.headers, cookie); + + return response; +} diff --git a/lib/config.ts b/lib/config.ts new file mode 100644 index 0000000..fba98ff --- /dev/null +++ b/lib/config.ts @@ -0,0 +1,15 @@ +import 'std/dotenv/load.ts'; + +import { isThereAnAdmin } from './data/user.ts'; + +export async function isSignupAllowed() { + const areSignupsAllowed = Deno.env.get('CONFIG_ALLOW_SIGNUPS') === 'true'; + + const areThereAdmins = await isThereAnAdmin(); + + if (areSignupsAllowed || !areThereAdmins) { + return true; + } + + return false; +} diff --git a/lib/data/contacts.ts b/lib/data/contacts.ts new file mode 100644 index 0000000..51234df --- /dev/null +++ b/lib/data/contacts.ts @@ -0,0 +1,138 @@ +import Database, { sql } from '/lib/interfaces/database.ts'; +import { Contact } from '/lib/types.ts'; +import { CONTACTS_PER_PAGE_COUNT } from '/lib/utils.ts'; +import { updateUserContactRevision } from './user.ts'; + +const db = new Database(); + +export async function getContacts(userId: string, pageIndex: number) { + const contacts = await db.query>( + sql`SELECT "id", "first_name", "last_name" FROM "bewcloud_contacts" WHERE "user_id" = $1 ORDER BY "first_name" ASC, "last_name" ASC LIMIT ${CONTACTS_PER_PAGE_COUNT} OFFSET $2`, + [ + userId, + pageIndex * CONTACTS_PER_PAGE_COUNT, + ], + ); + + return contacts; +} + +export async function getContactsCount(userId: string) { + const results = await db.query<{ count: number }>( + sql`SELECT COUNT("id") AS "count" FROM "bewcloud_contacts" WHERE "user_id" = $1`, + [ + userId, + ], + ); + + return Number(results[0]?.count || 0); +} + +export async function searchContacts(search: string, userId: string, pageIndex: number) { + const contacts = await db.query>( + sql`SELECT "id", "first_name", "last_name" FROM "bewcloud_contacts" WHERE "user_id" = $1 AND ("first_name" ILIKE $3 OR "last_name" ILIKE $3 OR "extra"::text ILIKE $3) ORDER BY "first_name" ASC, "last_name" ASC LIMIT ${CONTACTS_PER_PAGE_COUNT} OFFSET $2`, + [ + userId, + pageIndex * CONTACTS_PER_PAGE_COUNT, + `%${search}%`, + ], + ); + + return contacts; +} + +export async function searchContactsCount(search: string, userId: string) { + const results = await db.query<{ count: number }>( + sql`SELECT COUNT("id") AS "count" FROM "bewcloud_contacts" WHERE "user_id" = $1 AND ("first_name" ILIKE $2 OR "last_name" ILIKE $2 OR "extra"::text ILIKE $2)`, + [ + userId, + `%${search}%`, + ], + ); + + return Number(results[0]?.count || 0); +} + +export async function getAllContacts(userId: string) { + const contacts = await db.query(sql`SELECT * FROM "bewcloud_contacts" WHERE "user_id" = $1`, [ + userId, + ]); + + return contacts; +} + +export async function getContact(id: string, userId: string) { + const contacts = await db.query( + sql`SELECT * FROM "bewcloud_contacts" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`, + [ + id, + userId, + ], + ); + + return contacts[0]; +} + +export async function createContact(userId: string, firstName: string, lastName: string) { + const extra: Contact['extra'] = {}; + + const revision = crypto.randomUUID(); + + const newContact = (await db.query( + sql`INSERT INTO "bewcloud_contacts" ( + "user_id", + "revision", + "first_name", + "last_name", + "extra" + ) VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [ + userId, + revision, + firstName, + lastName, + JSON.stringify(extra), + ], + ))[0]; + + await updateUserContactRevision(userId); + + return newContact; +} + +export async function updateContact(contact: Contact) { + const revision = crypto.randomUUID(); + + await db.query( + sql`UPDATE "bewcloud_contacts" SET + "revision" = $3, + "first_name" = $4, + "last_name" = $5, + "extra" = $6, + "updated_at" = now() + WHERE "id" = $1 AND "revision" = $2`, + [ + contact.id, + contact.revision, + revision, + contact.first_name, + contact.last_name, + JSON.stringify(contact.extra), + ], + ); + + await updateUserContactRevision(contact.user_id); +} + +export async function deleteContact(id: string, userId: string) { + await db.query( + sql`DELETE FROM "bewcloud_contacts" WHERE "id" = $1 AND "user_id" = $2`, + [ + id, + userId, + ], + ); + + await updateUserContactRevision(userId); +} diff --git a/lib/data/dashboard.ts b/lib/data/dashboard.ts new file mode 100644 index 0000000..55e1993 --- /dev/null +++ b/lib/data/dashboard.ts @@ -0,0 +1,42 @@ +import Database, { sql } from '/lib/interfaces/database.ts'; +import { Dashboard } from '/lib/types.ts'; + +const db = new Database(); + +export async function getDashboardByUserId(userId: string) { + const dashboard = (await db.query(sql`SELECT * FROM "bewcloud_dashboards" WHERE "user_id" = $1 LIMIT 1`, [ + userId, + ]))[0]; + + return dashboard; +} + +export async function createDashboard(userId: string) { + const data: Dashboard['data'] = { links: [], notes: '' }; + + const newDashboard = (await db.query( + sql`INSERT INTO "bewcloud_dashboards" ( + "user_id", + "data" + ) VALUES ($1, $2) + RETURNING *`, + [ + userId, + JSON.stringify(data), + ], + ))[0]; + + return newDashboard; +} + +export async function updateDashboard(dashboard: Dashboard) { + await db.query( + sql`UPDATE "bewcloud_dashboards" SET + "data" = $2 + WHERE "id" = $1`, + [ + dashboard.id, + JSON.stringify(dashboard.data), + ], + ); +} diff --git a/lib/data/news.ts b/lib/data/news.ts new file mode 100644 index 0000000..99e14fe --- /dev/null +++ b/lib/data/news.ts @@ -0,0 +1,298 @@ +import { Feed } from 'https://deno.land/x/rss@1.0.0/mod.ts'; + +import Database, { sql } from '/lib/interfaces/database.ts'; +import { NewsFeed, NewsFeedArticle } from '/lib/types.ts'; +import { + findFeedInUrl, + getArticleUrl, + getFeedInfo, + JsonFeed, + parseTextFromHtml, + parseUrl, + parseUrlAsGooglebot, + parseUrlWithProxy, +} from '/lib/feed.ts'; + +const db = new Database(); + +export async function getNewsFeeds(userId: string) { + const newsFeeds = await db.query(sql`SELECT * FROM "bewcloud_news_feeds" WHERE "user_id" = $1`, [ + userId, + ]); + + return newsFeeds; +} + +export async function getNewsFeed(id: string, userId: string) { + const newsFeeds = await db.query( + sql`SELECT * FROM "bewcloud_news_feeds" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`, + [ + id, + userId, + ], + ); + + return newsFeeds[0]; +} + +export async function getNewsArticles(userId: string) { + const articles = await db.query( + sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "user_id" = $1 ORDER BY "article_date" DESC`, + [ + userId, + ], + ); + + return articles; +} + +export async function getNewsArticlesByFeedId(feedId: string) { + const articles = await db.query( + sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "feed_id" = $1 ORDER BY "article_date" DESC`, + [ + feedId, + ], + ); + + return articles; +} + +export async function getNewsArticle(id: string, userId: string) { + const articles = await db.query( + sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`, + [ + id, + userId, + ], + ); + + return articles[0]; +} + +export async function createNewsFeed(userId: string, feedUrl: string) { + const extra: NewsFeed['extra'] = {}; + + const newNewsFeed = (await db.query( + sql`INSERT INTO "bewcloud_news_feeds" ( + "user_id", + "feed_url", + "extra" + ) VALUES ($1, $2, $3) + RETURNING *`, + [ + userId, + feedUrl, + JSON.stringify(extra), + ], + ))[0]; + + return newNewsFeed; +} + +export async function updateNewsFeed(newsFeed: NewsFeed) { + await db.query( + sql`UPDATE "bewcloud_news_feeds" SET + "feed_url" = $2, + "last_crawled_at" = $3, + "extra" = $4 + WHERE "id" = $1`, + [ + newsFeed.id, + newsFeed.feed_url, + newsFeed.last_crawled_at, + JSON.stringify(newsFeed.extra), + ], + ); +} + +export async function deleteNewsFeed(id: string) { + await db.query( + sql`DELETE FROM "bewcloud_news_feed_articles" WHERE "feed_id" = $1`, + [ + id, + ], + ); + + await db.query( + sql`DELETE FROM "bewcloud_news_feeds" WHERE "id" = $1`, + [ + id, + ], + ); +} + +export async function createsNewsArticle( + userId: string, + feedId: string, + article: Omit, +) { + const extra: NewsFeedArticle['extra'] = {}; + + const newNewsArticle = (await db.query( + sql`INSERT INTO "bewcloud_news_feed_articles" ( + "user_id", + "feed_id", + "article_url", + "article_title", + "article_summary", + "article_date", + "extra" + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [ + userId, + feedId, + article.article_url, + article.article_title, + article.article_summary, + article.article_date, + JSON.stringify(extra), + ], + ))[0]; + + return newNewsArticle; +} + +export async function updateNewsArticle(article: NewsFeedArticle) { + await db.query( + sql`UPDATE "bewcloud_news_feed_articles" SET + "is_read" = $2, + "extra" = $3 + WHERE "id" = $1`, + [ + article.id, + article.is_read, + JSON.stringify(article.extra), + ], + ); +} + +export async function markAllArticlesRead(userId: string) { + await db.query( + sql`UPDATE "bewcloud_news_feed_articles" SET + "is_read" = TRUE + WHERE "user_id" = $1`, + [ + userId, + ], + ); +} + +async function fetchNewsArticles(newsFeed: NewsFeed): Promise { + try { + if (!newsFeed.extra.title || !newsFeed.extra.feed_type || !newsFeed.extra.crawl_type) { + throw new Error('Invalid News Feed!'); + } + + let feed: JsonFeed | Feed | null = null; + + if (newsFeed.extra.crawl_type === 'direct') { + feed = await parseUrl(newsFeed.feed_url); + } else if (newsFeed.extra.crawl_type === 'googlebot') { + feed = await parseUrlAsGooglebot(newsFeed.feed_url); + } else if (newsFeed.extra.crawl_type === 'proxy') { + feed = await parseUrlWithProxy(newsFeed.feed_url); + } + + return (feed as Feed)?.entries || (feed as JsonFeed)?.items || []; + } catch (error) { + console.log('Failed parsing feed to get articles', newsFeed.feed_url); + console.log(error); + } + + return []; +} + +type FeedArticle = Feed['entries'][number]; +type JsonFeedArticle = JsonFeed['items'][number]; + +const MAX_ARTICLES_CRAWLED_PER_RUN = 10; + +export async function crawlNewsFeed(newsFeed: NewsFeed) { + // TODO: Lock this per feedId, so no two processes run this at the same time + + if (!newsFeed.extra.title || !newsFeed.extra.feed_type || !newsFeed.extra.crawl_type) { + const feedUrl = await findFeedInUrl(newsFeed.feed_url); + + if (!feedUrl) { + throw new Error( + `Invalid URL for feed: "${feedUrl}"`, + ); + } + + if (feedUrl !== newsFeed.feed_url) { + newsFeed.feed_url = feedUrl; + } + + const feedInfo = await getFeedInfo(newsFeed.feed_url); + + newsFeed.extra.title = feedInfo.title; + newsFeed.extra.feed_type = feedInfo.feed_type; + newsFeed.extra.crawl_type = feedInfo.crawl_type; + } + + const feedArticles = await fetchNewsArticles(newsFeed); + + const articles: Omit[] = []; + + for (const feedArticle of feedArticles) { + // Don't add too many articles per run + if (articles.length >= MAX_ARTICLES_CRAWLED_PER_RUN) { + continue; + } + + const url = (feedArticle as JsonFeedArticle).url || getArticleUrl((feedArticle as FeedArticle).links) || + feedArticle.id; + + const articleIsoDate = (feedArticle as JsonFeedArticle).date_published || + (feedArticle as FeedArticle).published?.toISOString() || (feedArticle as JsonFeedArticle).date_modified || + (feedArticle as FeedArticle).updated?.toISOString(); + + const articleDate = articleIsoDate ? new Date(articleIsoDate) : new Date(); + + const summary = await parseTextFromHtml( + (feedArticle as FeedArticle).description?.value || (feedArticle as FeedArticle).content?.value || + (feedArticle as JsonFeedArticle).content_text || (feedArticle as JsonFeedArticle).content_html || + (feedArticle as JsonFeedArticle).summary || '', + ); + + if (url) { + articles.push({ + article_title: (feedArticle as FeedArticle).title?.value || (feedArticle as JsonFeedArticle).title || + url.replace('http://', '').replace('https://', ''), + article_url: url, + article_summary: summary, + article_date: articleDate, + }); + } + } + + const existingArticles = await getNewsArticlesByFeedId(newsFeed.id); + const existingArticleUrls = new Set(existingArticles.map((article) => article.article_url)); + const previousLatestArticleUrl = existingArticles[0]?.article_url; + let seenPreviousLatestArticleUrl = false; + let addedArticlesCount = 0; + + for (const article of articles) { + // Stop looking after seeing the previous latest article + if (article.article_url === previousLatestArticleUrl) { + seenPreviousLatestArticleUrl = true; + } + + if (!seenPreviousLatestArticleUrl && !existingArticleUrls.has(article.article_url)) { + try { + await createsNewsArticle(newsFeed.user_id, newsFeed.id, article); + ++addedArticlesCount; + } catch (error) { + console.error(error); + console.error(`Failed to add new article: "${article.article_url}"`); + } + } + } + + console.log('Added', addedArticlesCount, 'new articles'); + + newsFeed.last_crawled_at = new Date(); + + await updateNewsFeed(newsFeed); +} diff --git a/lib/data/user.ts b/lib/data/user.ts new file mode 100644 index 0000000..b169e70 --- /dev/null +++ b/lib/data/user.ts @@ -0,0 +1,296 @@ +import Database, { sql } from '/lib/interfaces/database.ts'; +import { User, UserSession, VerificationCode } from '/lib/types.ts'; +import { generateRandomCode } from '/lib/utils.ts'; + +const db = new Database(); + +export async function isThereAnAdmin() { + const user = + (await db.query(sql`SELECT * FROM "bewcloud_users" WHERE ("extra" ->> 'is_admin')::boolean IS TRUE LIMIT 1`))[ + 0 + ]; + + return Boolean(user); +} + +export async function getUserByEmail(email: string) { + const lowercaseEmail = email.toLowerCase().trim(); + + const user = (await db.query(sql`SELECT * FROM "bewcloud_users" WHERE "email" = $1 LIMIT 1`, [ + lowercaseEmail, + ]))[0]; + + return user; +} + +export async function getUserById(id: string) { + const user = (await db.query(sql`SELECT * FROM "bewcloud_users" WHERE "id" = $1 LIMIT 1`, [ + id, + ]))[0]; + + return user; +} + +export async function createUser(email: User['email'], hashedPassword: User['hashed_password']) { + const trialDays = 30; + const now = new Date(); + const trialEndDate = new Date(new Date().setUTCDate(new Date().getUTCDate() + trialDays)); + + const subscription: User['subscription'] = { + external: {}, + expires_at: trialEndDate.toISOString(), + updated_at: now.toISOString(), + }; + + const extra: User['extra'] = { is_email_verified: false }; + + // First signup will be an admin "forever" + if (!(await isThereAnAdmin())) { + extra.is_admin = true; + subscription.expires_at = new Date('2100-12-31').toISOString(); + } + + const newUser = (await db.query( + sql`INSERT INTO "bewcloud_users" ( + "email", + "subscription", + "status", + "hashed_password", + "extra" + ) VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [ + email, + JSON.stringify(subscription), + extra.is_admin ? 'active' : 'trial', + hashedPassword, + JSON.stringify(extra), + ], + ))[0]; + + return newUser; +} + +export async function updateUser(user: User) { + await db.query( + sql`UPDATE "bewcloud_users" SET + "email" = $2, + "subscription" = $3, + "status" = $4, + "hashed_password" = $5, + "extra" = $6 + WHERE "id" = $1`, + [ + user.id, + user.email, + JSON.stringify(user.subscription), + user.status, + user.hashed_password, + JSON.stringify(user.extra), + ], + ); +} + +export async function deleteUser(userId: string) { + await db.query( + sql`DELETE FROM "bewcloud_user_sessions" WHERE "user_id" = $1`, + [ + userId, + ], + ); + + await db.query( + sql`DELETE FROM "bewcloud_verification_codes" WHERE "user_id" = $1`, + [ + userId, + ], + ); + + await db.query( + sql`DELETE FROM "bewcloud_news_feed_articles" WHERE "user_id" = $1`, + [ + userId, + ], + ); + + await db.query( + sql`DELETE FROM "bewcloud_news_feeds" WHERE "user_id" = $1`, + [ + userId, + ], + ); + + await db.query( + sql`DELETE FROM "bewcloud_users" WHERE "id" = $1`, + [ + userId, + ], + ); +} + +export async function getSessionById(id: string) { + const session = (await db.query( + sql`SELECT * FROM "bewcloud_user_sessions" WHERE "id" = $1 AND "expires_at" > now() LIMIT 1`, + [ + id, + ], + ))[0]; + + return session; +} + +export async function createUserSession(user: User, isShortLived = false) { + const oneMonthFromToday = new Date(new Date().setUTCMonth(new Date().getUTCMonth() + 1)); + const oneWeekFromToday = new Date(new Date().setUTCDate(new Date().getUTCDate() + 7)); + + const newSession: Omit = { + user_id: user.id, + expires_at: isShortLived ? oneWeekFromToday : oneMonthFromToday, + last_seen_at: new Date(), + }; + + const newUserSessionResult = (await db.query( + sql`INSERT INTO "bewcloud_user_sessions" ( + "user_id", + "expires_at", + "last_seen_at" + ) VALUES ($1, $2, $3) + RETURNING *`, + [ + newSession.user_id, + newSession.expires_at, + newSession.last_seen_at, + ], + ))[0]; + + return newUserSessionResult; +} + +export async function updateSession(session: UserSession) { + await db.query( + sql`UPDATE "bewcloud_user_sessions" SET + "expires_at" = $2, + "last_seen_at" = $3 + WHERE "id" = $1`, + [ + session.id, + session.expires_at, + session.last_seen_at, + ], + ); +} + +export async function deleteUserSession(sessionId: string) { + await db.query( + sql`DELETE FROM "bewcloud_user_sessions" WHERE "id" = $1`, + [ + sessionId, + ], + ); +} + +export async function validateUserAndSession(userId: string, sessionId: string) { + const user = await getUserById(userId); + + if (!user) { + throw new Error('Not Found'); + } + + const session = await getSessionById(sessionId); + + if (!session || session.user_id !== user.id) { + throw new Error('Not Found'); + } + + const oneMonthFromToday = new Date(new Date().setUTCMonth(new Date().getUTCMonth() + 1)); + + session.last_seen_at = new Date(); + session.expires_at = oneMonthFromToday; + + await updateSession(session); + + return { user, session }; +} + +export async function createVerificationCode( + user: User, + verificationId: string, + type: VerificationCode['verification']['type'], +) { + const inThirtyMinutes = new Date(new Date().setUTCMinutes(new Date().getUTCMinutes() + 30)); + + const code = generateRandomCode(); + + const newVerificationCode: Omit = { + user_id: user.id, + code, + expires_at: inThirtyMinutes, + verification: { + id: verificationId, + type, + }, + }; + + await db.query( + sql`INSERT INTO "bewcloud_verification_codes" ( + "user_id", + "code", + "expires_at", + "verification" + ) VALUES ($1, $2, $3, $4) + RETURNING "id"`, + [ + newVerificationCode.user_id, + newVerificationCode.code, + newVerificationCode.expires_at, + JSON.stringify(newVerificationCode.verification), + ], + ); + + return code; +} + +export async function validateVerificationCode( + user: User, + verificationId: string, + code: string, + type: VerificationCode['verification']['type'], +) { + const verificationCode = (await db.query( + sql`SELECT * FROM "bewcloud_verification_codes" + WHERE "user_id" = $1 AND + "code" = $2 AND + "verification" ->> 'type' = $3 AND + "verification" ->> 'id' = $4 AND + "expires_at" > now() + LIMIT 1`, + [ + user.id, + code, + type, + verificationId, + ], + ))[0]; + + if (verificationCode) { + await db.query( + sql`DELETE FROM "bewcloud_verification_codes" WHERE "id" = $1`, + [ + verificationCode.id, + ], + ); + } else { + throw new Error('Not Found'); + } +} + +export async function updateUserContactRevision(id: string) { + const user = await getUserById(id); + + const revision = crypto.randomUUID(); + + user.extra.contacts_revision = revision; + user.extra.contacts_updated_at = new Date().toISOString(); + + await updateUser(user); +} diff --git a/lib/feed.ts b/lib/feed.ts new file mode 100644 index 0000000..a44e796 --- /dev/null +++ b/lib/feed.ts @@ -0,0 +1,233 @@ +import { DOMParser, initParser } from 'https://deno.land/x/deno_dom@v0.1.45/deno-dom-wasm-noinit.ts'; +import { Feed, parseFeed } from 'https://deno.land/x/rss@1.0.0/mod.ts'; +import { fetchUrl, fetchUrlAsGooglebot, fetchUrlWithProxy, fetchUrlWithRetries } from './utils.ts'; +import { NewsFeed, NewsFeedCrawlType, NewsFeedType } from './types.ts'; + +export interface JsonFeedItem { + id: string; + url: string; + title: string; + content_text?: string; + content_html?: string; + summary?: string; + date_modified?: string; + date_published: string; +} + +export interface JsonFeed { + version: string; + title: string; + home_page_url?: string; + description?: string; + authors?: { name: string; url?: string }[]; + language?: string; + items: JsonFeedItem[]; +} + +async function getFeedFromUrlContents(urlContents: string) { + try { + const jsonFeed = JSON.parse(urlContents) as JsonFeed; + return jsonFeed; + } catch (_error) { + const feed = await parseFeed(urlContents); + return feed; + } +} + +export async function parseUrl(feedUrl: string) { + const urlContents = await fetchUrl(feedUrl); + const feed = await getFeedFromUrlContents(urlContents); + return feed; +} + +export async function parseUrlAsGooglebot(feedUrl: string) { + const urlContents = await fetchUrlAsGooglebot(feedUrl); + const feed = await getFeedFromUrlContents(urlContents); + return feed; +} + +export async function parseUrlWithProxy(feedUrl: string) { + const urlContents = await fetchUrlWithProxy(feedUrl); + const feed = await getFeedFromUrlContents(urlContents); + return feed; +} + +async function parseUrlWithRetries(feedUrl: string): Promise<{ feed: JsonFeed | Feed; crawlType: NewsFeedCrawlType }> { + try { + const feed = await parseUrl(feedUrl); + return { feed, crawlType: 'direct' }; + } catch (_error) { + try { + const feed = await parseUrlAsGooglebot(feedUrl); + return { feed, crawlType: 'googlebot' }; + } catch (_error) { + const feed = await parseUrlWithProxy(feedUrl); + return { feed, crawlType: 'proxy' }; + } + } +} + +export async function isValid(feedUrl: string, fastFail = false) { + try { + console.log('Checking if URL is a valid feed URL', feedUrl); + const { feed } = fastFail ? { feed: await parseUrl(feedUrl) } : await parseUrlWithRetries(feedUrl); + return Boolean( + (feed as Feed).title?.value || (feed as JsonFeed).title || (feed as JsonFeed).items?.length || + (feed as Feed).links?.length > 0 || feed.description, + ); + } catch (error) { + console.log('Failed parsing feed to check validity', feedUrl); + console.log(error); + } + + return false; +} + +export async function getFeedInfo(feedUrl: string, fastFail = false): Promise { + try { + console.log('Getting Feed URL info', feedUrl); + + const { feed, crawlType } = fastFail + ? { feed: await parseUrl(feedUrl), crawlType: 'direct' as const } + : await parseUrlWithRetries(feedUrl); + let feedType: NewsFeedType = 'rss'; + + if ((feed as JsonFeed).version) { + feedType = 'json'; + } else if ((feed as Feed).type === 'ATOM') { + feedType = 'atom'; + } + + return { + title: (feed as Feed).title?.value || (feed as JsonFeed).title || '', + feed_type: feedType, + crawl_type: crawlType, + }; + } catch (error) { + console.log('Failed parsing feed to check validity', feedUrl); + console.log(error); + } + + return {}; +} + +export async function findFeedInUrl(url: string) { + let urlContents = ''; + try { + urlContents = await fetchUrl(url); + } catch (error) { + console.log('Failed to fetch URL to find feed', url); + console.log(error); + return null; + } + + await initParser(); + + try { + const document = new DOMParser().parseFromString(urlContents, 'text/html'); + + const urlOptions = [ + url, + document!.querySelector('link[type="application/rss+xml"]')?.getAttribute('href'), + document!.querySelector('link[type="application/atom+xml"]')?.getAttribute('href'), + document!.querySelector('link[rel="alternate"]')?.getAttribute('href'), + // Try some common URL paths + 'feed', + 'rss', + 'rss.xml', + 'feed.xml', + 'atom.xml', + 'atom', + 'feeds/posts/default', + ].filter(Boolean); + + for (const urlOption of urlOptions) { + const optionalSlash = urlOption!.startsWith('/') || url.endsWith('/') ? '' : '/'; + const potentialFeedUrl = urlOption!.startsWith('http') ? urlOption : `${url}${optionalSlash}${urlOption}`; + + try { + const isValidFeed = await isValid(potentialFeedUrl!, true); + + if (isValidFeed) { + return potentialFeedUrl; + } + } catch (_error) { + // Do nothing. + } + } + } catch (error) { + // This error can happen for huge responses, but that usually means the URL works + if (error.toString().includes('RangeError: Maximum call stack size exceeded')) { + return url; + } else { + console.error(error); + } + } + + return null; +} + +export function getArticleUrl(links: Feed['entries'][0]['links']) { + try { + for (const link of links) { + if (link.rel === 'alternate' && link.type?.startsWith('text/html')) { + return link.href || ''; + } + } + + return links[0]?.href || ''; + } catch (_error) { + return ''; + } +} + +export async function getUrlInfo(url: string): Promise<{ title: string; htmlBody: string; textBody: string } | null> { + let urlContents = ''; + try { + urlContents = await fetchUrlWithRetries(url); + } catch (error) { + console.log('Failed to fetch URL to get info', url); + console.log(error); + return null; + } + + await initParser(); + + const document = new DOMParser().parseFromString(urlContents, 'text/html'); + + const title = document!.querySelector('title')?.textContent; + let htmlBody = document!.querySelector('body')?.innerHTML; + let textBody = document!.querySelector('body')?.textContent; + + const mainHtml = document!.querySelector('main')?.innerHTML; + const mainText = document!.querySelector('main')?.textContent; + + const articleHtml = document!.querySelector('article')?.innerHTML; + const articleText = document!.querySelector('article')?.textContent; + + if (mainHtml && mainText) { + htmlBody = mainHtml; + textBody = mainText; + } else if (articleHtml && articleText) { + htmlBody = articleHtml; + textBody = articleText; + } + + if (!title || !htmlBody || !textBody) { + return null; + } + + return { title, htmlBody, textBody }; +} + +export async function parseTextFromHtml(html: string): Promise { + let text = ''; + + await initParser(); + + const document = new DOMParser().parseFromString(html, 'text/html'); + + text = document!.textContent; + + return text; +} diff --git a/lib/form-utils.tsx b/lib/form-utils.tsx new file mode 100644 index 0000000..504f94c --- /dev/null +++ b/lib/form-utils.tsx @@ -0,0 +1,185 @@ +export interface FormField { + name: string; + label: string; + value?: string | null; + overrideValue?: string; + description?: string; + placeholder?: string; + type: + | 'text' + | 'email' + | 'tel' + | 'url' + | 'date' + | 'number' + | 'range' + | 'select' + | 'textarea' + | 'checkbox' + | 'hidden' + | 'password'; + step?: string; + max?: string; + min?: string; + rows?: string; + options?: { + label: string; + value: string; + }[]; + checked?: boolean; + multiple?: boolean; + required?: boolean; + disabled?: boolean; + readOnly?: boolean; + extraClasses?: string; +} + +export function getFormDataField(formData: FormData, field: string) { + return ((formData.get(field) || '') as string).trim(); +} + +export function getFormDataFieldArray(formData: FormData, field: string) { + return ((formData.getAll(field) || []) as string[]).map((value) => value.trim()); +} + +export function generateFieldHtml( + field: FormField, + formData: FormData, +) { + let value = field.overrideValue || + (field.multiple ? getFormDataFieldArray(formData, field.name) : getFormDataField(formData, field.name)) || + field.value; + + if (typeof field.overrideValue !== 'undefined') { + value = field.overrideValue; + } + + if (field.type === 'hidden') { + return generateInputHtml(field, value); + } + + return ( +
+ + {generateInputHtml(field, value)} + {field.description + ? ( + + ) + : null} +
+ ); +} + +function generateInputHtml( + { + name, + placeholder, + type, + options, + step, + max, + min, + rows, + checked, + multiple, + disabled, + required, + readOnly, + }: FormField, + value?: string | string[] | null, +) { + const additionalAttributes: Record = {}; + + if (typeof step !== 'undefined') { + additionalAttributes.step = parseInt(step, 10); + } + if (typeof max !== 'undefined') { + additionalAttributes.max = parseInt(max, 10); + } + if (typeof min !== 'undefined') { + additionalAttributes.min = parseInt(min, 10); + } + if (typeof rows !== 'undefined') { + additionalAttributes.rows = parseInt(rows, 10); + } + if (checked === true && type === 'checkbox' && value) { + additionalAttributes.checked = true; + } + if (multiple === true) { + additionalAttributes.multiple = true; + } + if (required === true) { + additionalAttributes.required = true; + } + if (disabled === true) { + additionalAttributes.disabled = true; + } + if (readOnly === true) { + additionalAttributes.readonly = true; + } + + if (type === 'select') { + return ( + + ); + } + + if (type === 'textarea') { + return ( + + ); + } + + if (type === 'checkbox') { + return ( + + ); + } + + if (type === 'password') { + return ( + + ); + } + + return ( + + ); +} diff --git a/lib/interfaces/database.ts b/lib/interfaces/database.ts new file mode 100644 index 0000000..6eccd8e --- /dev/null +++ b/lib/interfaces/database.ts @@ -0,0 +1,76 @@ +import { Client } from 'https://deno.land/x/postgres@v0.19.2/mod.ts'; +import 'std/dotenv/load.ts'; + +const POSTGRESQL_HOST = Deno.env.get('POSTGRESQL_HOST') || ''; +const POSTGRESQL_USER = Deno.env.get('POSTGRESQL_USER') || ''; +const POSTGRESQL_PASSWORD = Deno.env.get('POSTGRESQL_PASSWORD') || ''; +const POSTGRESQL_DBNAME = Deno.env.get('POSTGRESQL_DBNAME') || ''; +const POSTGRESQL_PORT = Deno.env.get('POSTGRESQL_PORT') || ''; +const POSTGRESQL_CAFILE = Deno.env.get('POSTGRESQL_CAFILE') || ''; + +const tls = POSTGRESQL_CAFILE + ? { + enabled: true, + enforce: false, + caCertificates: [await Deno.readTextFile(POSTGRESQL_CAFILE)], + } + : { + enabled: true, + enforce: false, + }; + +export default class Database { + protected db?: Client; + + constructor(connectNow = false) { + if (connectNow) { + this.connectToPostgres(); + } + } + + protected async connectToPostgres() { + if (this.db) { + return this.db; + } + + const postgresClient = new Client({ + user: POSTGRESQL_USER, + password: POSTGRESQL_PASSWORD, + database: POSTGRESQL_DBNAME, + hostname: POSTGRESQL_HOST, + port: POSTGRESQL_PORT, + tls, + }); + + await postgresClient.connect(); + + this.db = postgresClient; + } + + protected async disconnectFromPostgres() { + if (!this.db) { + return; + } + + await this.db.end(); + + this.db = undefined; + } + + public close() { + this.disconnectFromPostgres(); + } + + public async query(sql: string, args?: any[]) { + if (!this.db) { + await this.connectToPostgres(); + } + + const result = await this.db!.queryObject(sql, args); + + return result.rows; + } +} + +// This allows us to have nice SQL syntax highlighting in template literals +export const sql = String.raw; diff --git a/lib/providers/brevo.ts b/lib/providers/brevo.ts new file mode 100644 index 0000000..f53a7ea --- /dev/null +++ b/lib/providers/brevo.ts @@ -0,0 +1,83 @@ +import 'std/dotenv/load.ts'; + +import { helpEmail } from '/lib/utils.ts'; + +const BREVO_API_KEY = Deno.env.get('BREVO_API_KEY') || ''; + +enum BrevoTemplateId { + BEWCLOUD_VERIFY_EMAIL = 20, +} + +interface BrevoResponse { + messageId?: string; + code?: string; + message?: string; +} + +function getApiRequestHeaders() { + return { + 'Api-Key': BREVO_API_KEY, + 'Accept': 'application/json; charset=utf-8', + 'Content-Type': 'application/json; charset=utf-8', + }; +} + +interface BrevoRequestBody { + templateId?: number; + params: Record | null; + to: { email: string; name?: string }[]; + cc?: { email: string; name?: string }[]; + bcc?: { email: string; name?: string }[]; + htmlContent?: string; + textContent?: string; + subject?: string; + replyTo: { email: string; name?: string }; + tags?: string[]; + attachment?: { name: string; content: string; url: string }[]; +} + +async function sendEmailWithTemplate( + to: string, + templateId: BrevoTemplateId, + data: BrevoRequestBody['params'], + attachments: BrevoRequestBody['attachment'] = [], + cc?: string, +) { + const email: BrevoRequestBody = { + templateId, + params: data, + to: [{ email: to }], + replyTo: { email: helpEmail }, + }; + + if (attachments?.length) { + email.attachment = attachments; + } + + if (cc) { + email.cc = [{ email: cc }]; + } + + const brevoResponse = await fetch('https://api.brevo.com/v3/smtp/email', { + method: 'POST', + headers: getApiRequestHeaders(), + body: JSON.stringify(email), + }); + const brevoResult = (await brevoResponse.json()) as BrevoResponse; + + if (brevoResult.code || brevoResult.message) { + console.log(JSON.stringify({ brevoResult }, null, 2)); + throw new Error(`Failed to send email "${templateId}"`); + } +} + +export async function sendVerifyEmailEmail( + email: string, + verificationCode: string, +) { + const data = { + verificationCode, + }; + + await sendEmailWithTemplate(email, BrevoTemplateId.BEWCLOUD_VERIFY_EMAIL, data); +} diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..fc5e801 --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,131 @@ +export interface User { + id: string; + email: string; + hashed_password: string; + subscription: { + external: Record; + expires_at: string; + updated_at: string; + }; + status: 'trial' | 'active' | 'inactive'; + extra: { + is_email_verified: boolean; + is_admin?: boolean; + dav_hashed_password?: string; + contacts_revision?: string; + contacts_updated_at?: string; + }; + created_at: Date; +} + +export interface UserSession { + id: string; + user_id: string; + expires_at: Date; + last_seen_at: Date; + created_at: Date; +} + +export interface FreshContextState { + user?: User; + session?: UserSession; +} + +export interface VerificationCode { + id: string; + user_id: string; + code: string; + verification: { + type: 'email'; + id: string; + }; + expires_at: Date; + created_at: Date; +} + +export interface DashboardLink { + url: string; + name: string; +} + +export interface Dashboard { + id: string; + user_id: string; + data: { + links: DashboardLink[]; + notes: string; + }; + created_at: Date; +} + +export type NewsFeedType = 'rss' | 'atom' | 'json'; +export type NewsFeedCrawlType = 'direct' | 'googlebot' | 'proxy'; + +export interface NewsFeed { + id: string; + user_id: string; + feed_url: string; + last_crawled_at: Date | null; + extra: { + title?: string; + feed_type?: NewsFeedType; + crawl_type?: NewsFeedCrawlType; + }; + created_at: Date; +} + +export interface NewsFeedArticle { + id: string; + user_id: string; + feed_id: string; + article_url: string; + article_title: string; + article_summary: string; + article_date: Date; + is_read: boolean; + extra: Record; + created_at: Date; +} + +// NOTE: I don't really organize contacts by groups or address books, so I don't think I'll need that complexity +export interface Contact { + id: string; + user_id: string; + revision: string; + first_name: string; + last_name: string; + extra: { + name_title?: string; + middle_names?: string[]; + organization?: string; + role?: string; + photo_url?: string; + photo_mediatype?: string; + addresses?: ContactAddress[]; + fields?: ContactField[]; + notes?: string; + uid?: string; + nickname?: string; + birthday?: string; + }; + updated_at: Date; + created_at: Date; +} + +export interface ContactAddress { + label?: string; + line_1?: string; + line_2?: string; + city?: string; + state?: string; + postal_code?: string; + country?: string; +} + +export type ContactFieldType = 'email' | 'phone' | 'url' | 'other'; + +export interface ContactField { + name: string; + value: string; + type: ContactFieldType; +} diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 0000000..3464f85 --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,616 @@ +import { Contact, ContactAddress, ContactField } from './types.ts'; + +export const baseUrl = 'https://app.bewcloud.com'; +export const defaultTitle = 'bewCloud is a modern and simpler alternative to Nextcloud and ownCloud'; +export const defaultDescription = `Have your calendar, contacts, tasks, and files under your own control.`; +export const helpEmail = 'help@bewcloud.com'; + +export const CONTACTS_PER_PAGE_COUNT = 20; + +export const DAV_RESPONSE_HEADER = '1, 3, 4, addressbook'; +// '1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, oc-resource-sharing, addressbook, nextcloud-checksum-update, nc-calendar-search, nc-enable-birthday-calendar' +// '1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, calendar-access, calendar-proxy, calendar-auto-schedule, calendar-availability, nc-calendar-trashbin, nc-calendar-webcal-cache, calendarserver-subscribed, oc-resource-sharing, oc-calendar-publishing, calendarserver-sharing, addressbook, nextcloud-checksum-update, nc-calendar-search, nc-enable-birthday-calendar + +export function isRunningLocally(request: Request) { + return request.url.includes('localhost'); +} + +export function escapeHtml(unsafe: string) { + return unsafe.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"') + .replaceAll("'", '''); +} + +export function escapeXml(unsafe: string) { + return escapeHtml(unsafe).replaceAll('\r', ' '); +} + +export function generateRandomCode(length = 6) { + const getRandomDigit = () => Math.floor(Math.random() * (10)); // 0-9 + + const codeDigits = Array.from({ length }).map(getRandomDigit); + + return codeDigits.join(''); +} + +export async function generateHash(value: string, algorithm: AlgorithmIdentifier) { + const hashedValueData = await crypto.subtle.digest( + algorithm, + new TextEncoder().encode(value), + ); + + const hashedValue = Array.from(new Uint8Array(hashedValueData)).map( + (byte) => byte.toString(16).padStart(2, '0'), + ).join(''); + + return hashedValue; +} + +export function splitArrayInChunks(array: T[], chunkLength: number) { + const chunks = []; + let chunkIndex = 0; + const arrayLength = array.length; + + while (chunkIndex < arrayLength) { + chunks.push(array.slice(chunkIndex, chunkIndex += chunkLength)); + } + + return chunks; +} + +export function validateEmail(email: string) { + const trimmedEmail = (email || '').trim().toLocaleLowerCase(); + if (!trimmedEmail) { + return false; + } + + const requiredCharsNotInEdges = ['@', '.']; + return requiredCharsNotInEdges.every((char) => + trimmedEmail.includes(char) && !trimmedEmail.startsWith(char) && !trimmedEmail.endsWith(char) + ); +} + +export function validateUrl(url: string) { + const trimmedUrl = (url || '').trim().toLocaleLowerCase(); + if (!trimmedUrl) { + return false; + } + + if (!trimmedUrl.includes('://')) { + return false; + } + + const protocolIndex = trimmedUrl.indexOf('://'); + const urlAfterProtocol = trimmedUrl.substring(protocolIndex + 3); + + if (!urlAfterProtocol) { + return false; + } + + return true; +} + +// Adapted from https://gist.github.com/fasiha/7f20043a12ce93401d8473aee037d90a +export async function concurrentPromises( + generators: (() => Promise)[], + maxConcurrency: number, +): Promise { + const iterator = generators.entries(); + + const results: T[] = []; + + let hasFailed = false; + + await Promise.all( + Array.from(Array(maxConcurrency), async () => { + for (const [index, promiseToExecute] of iterator) { + if (hasFailed) { + break; + } + try { + results[index] = await promiseToExecute(); + } catch (error) { + hasFailed = true; + throw error; + } + } + }), + ); + + return results; +} + +const MAX_RESPONSE_TIME_IN_MS = 10 * 1000; + +export async function fetchUrl(url: string) { + const abortController = new AbortController(); + const requestCancelTimeout = setTimeout(() => { + abortController.abort(); + }, MAX_RESPONSE_TIME_IN_MS); + + const response = await fetch(url, { + signal: abortController.signal, + }); + + if (requestCancelTimeout) { + clearTimeout(requestCancelTimeout); + } + + const urlContents = await response.text(); + return urlContents; +} + +export async function fetchUrlAsGooglebot(url: string) { + const abortController = new AbortController(); + const requestCancelTimeout = setTimeout(() => { + abortController.abort(); + }, MAX_RESPONSE_TIME_IN_MS); + + const response = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', + }, + signal: abortController.signal, + }); + + if (requestCancelTimeout) { + clearTimeout(requestCancelTimeout); + } + + const urlContents = await response.text(); + return urlContents; +} + +export async function fetchUrlWithProxy(url: string) { + const abortController = new AbortController(); + const requestCancelTimeout = setTimeout(() => { + abortController.abort(); + }, MAX_RESPONSE_TIME_IN_MS); + + const response = await fetch(`https://api.allorigins.win/raw?url=${url}`, { + signal: abortController.signal, + }); + + if (requestCancelTimeout) { + clearTimeout(requestCancelTimeout); + } + + const urlContents = await response.text(); + return urlContents; +} + +export async function fetchUrlWithRetries(url: string) { + try { + const text = await fetchUrl(url); + return text; + } catch (_error) { + try { + const text = await fetchUrlAsGooglebot(url); + return text; + } catch (_error) { + const text = await fetchUrlWithProxy(url); + return text; + } + } +} + +export function convertFormDataToObject(formData: FormData): Record { + return JSON.parse(JSON.stringify(Object.fromEntries(formData))); +} + +export function convertObjectToFormData(formDataObject: Record): FormData { + const formData = new FormData(); + + for (const key of Object.keys(formDataObject || {})) { + if (Array.isArray(formDataObject[key])) { + formData.append(key, formDataObject[key].join(',')); + } else { + formData.append(key, formDataObject[key]); + } + } + + return formData; +} + +function writeXmlTag(tagName: string, value: any, attributes?: Record) { + const attributesXml = attributes + ? Object.keys(attributes || {}).map((attributeKey) => `${attributeKey}="${escapeHtml(attributes[attributeKey])}"`) + .join(' ') + : ''; + + if (Array.isArray(value)) { + if (value.length === 0) { + return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''} />`; + } + + const xmlLines: string[] = []; + + for (const valueItem of value) { + xmlLines.push(writeXmlTag(tagName, valueItem)); + } + + return xmlLines.join('\n'); + } + + if (typeof value === 'object') { + if (Object.keys(value).length === 0) { + return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''} />`; + } + + return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''}>${convertObjectToDavXml(value)}`; + } + + return `<${tagName}${attributesXml ? ` ${attributesXml}` : ''}>${value}`; +} + +export function convertObjectToDavXml(davObject: Record, isInitial = false): string { + const xmlLines: string[] = []; + + if (isInitial) { + xmlLines.push(``); + } + + for (const key of Object.keys(davObject)) { + if (key.endsWith('_attributes')) { + continue; + } + + xmlLines.push(writeXmlTag(key, davObject[key], davObject[`${key}_attributes`])); + } + + return xmlLines.join('\n'); +} + +function addLeadingZero(number: number) { + if (number < 10) { + return `0${number}`; + } + + return number.toString(); +} + +export function buildRFC822Date(dateString: string) { + const dayStrings = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const monthStrings = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + + const timeStamp = Date.parse(dateString); + const date = new Date(timeStamp); + + const day = dayStrings[date.getDay()]; + const dayNumber = addLeadingZero(date.getUTCDate()); + const month = monthStrings[date.getUTCMonth()]; + const year = date.getUTCFullYear(); + const time = `${addLeadingZero(date.getUTCHours())}:${addLeadingZero(date.getUTCMinutes())}:00`; + + return `${day}, ${dayNumber} ${month} ${year} ${time} +0000`; +} + +export function formatContactToVCard(contacts: Contact[]): string { + const vCardText = contacts.map((contact) => + `BEGIN:VCARD +VERSION:4.0 +N:${contact.last_name};${contact.first_name};${ + contact.extra.middle_names ? contact.extra.middle_names?.map((name) => name.trim()).filter(Boolean).join(' ') : '' + };${contact.extra.name_title || ''}; +FN:${contact.extra.name_title ? `${contact.extra.name_title || ''} ` : ''}${contact.first_name} ${contact.last_name} +${contact.extra.organization ? `ORG:${contact.extra.organization.replaceAll(',', '\\,')}` : ''} +${contact.extra.role ? `TITLE:${contact.extra.role}` : ''} +${contact.extra.birthday ? `BDAY:${contact.extra.birthday}` : ''} +${contact.extra.nickname ? `NICKNAME:${contact.extra.nickname}` : ''} +${contact.extra.photo_url ? `PHOTO;MEDIATYPE=${contact.extra.photo_mediatype}:${contact.extra.photo_url}` : ''} +${ + contact.extra.fields?.filter((field) => field.type === 'phone').map((phone) => + `TEL;TYPE=${phone.name}:${phone.value}` + ).join('\n') || '' + } +${ + contact.extra.addresses?.map((address) => + `ADR;TYPE=${address.label}:${(address.line_2 || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')};${ + (address.line_1 || '').replaceAll('\n', '\\n').replaceAll(',', '\\,') + };${(address.city || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')};${ + (address.state || '').replaceAll('\n', '\\n').replaceAll(',', '\\,') + };${(address.postal_code || '').replaceAll('\n', '\\n').replaceAll(',', '\\,')};${ + (address.country || '').replaceAll('\n', '\\n').replaceAll(',', '\\,') + }` + ).join('\n') || '' + } +${ + contact.extra.fields?.filter((field) => field.type === 'email').map((email) => + `EMAIL;TYPE=${email.name}:${email.value}` + ).join('\n') || '' + } +REV:${new Date(contact.updated_at).toISOString()} +${ + contact.extra.fields?.filter((field) => field.type === 'other').map((other) => `x-${other.name}:${other.value}`) + .join('\n') || '' + } +${ + contact.extra.notes + ? `NOTE:${contact.extra.notes.replaceAll('\r', '').replaceAll('\n', '\\n').replaceAll(',', '\\,')}` + : '' + } +${contact.extra.uid ? `UID:${contact.extra.uid}` : ''} +END:VCARD` + ).join('\n'); + + return vCardText.split('\n').map((line) => line.trim()).filter(Boolean).join('\n'); +} + +type VCardVersion = '2.1' | '3.0' | '4.0'; + +export function parseVCardFromTextContents(text: string): Partial[] { + const lines = text.split('\n').map((line) => line.trim()).filter(Boolean); + + const partialContacts: Partial[] = []; + + let partialContact: Partial = {}; + let vCardVersion: VCardVersion = '2.1'; + + // Loop through every line + for (const line of lines) { + // Start new contact and vCard version + if (line.startsWith('BEGIN:VCARD')) { + partialContact = {}; + vCardVersion = '2.1'; + continue; + } + + // Finish contact + if (line.startsWith('END:VCARD')) { + partialContacts.push(partialContact); + continue; + } + + // Select proper vCard version + if (line.startsWith('VERSION:')) { + if (line.startsWith('VERSION:2.1')) { + vCardVersion = '2.1'; + } else if (line.startsWith('VERSION:3.0')) { + vCardVersion = '3.0'; + } else if (line.startsWith('VERSION:4.0')) { + vCardVersion = '4.0'; + } else { + // Default to 2.1, log warning + vCardVersion = '2.1'; + console.warn(`Invalid vCard version found: "${line}". Defaulting to 2.1 parser.`); + } + + continue; + } + + if (vCardVersion !== '2.1' && vCardVersion !== '3.0' && vCardVersion !== '4.0') { + vCardVersion = '2.1'; + console.warn(`Invalid vCard version found: "${vCardVersion}". Defaulting to 2.1 parser.`); + } + + if (line.startsWith('UID:')) { + const uid = line.replace('UID:', ''); + + if (!uid) { + continue; + } + + partialContact.extra = { + ...(partialContact.extra || {}), + uid, + }; + + continue; + } + + if (line.startsWith('N:')) { + const names = line.split('N:')[1].split(';'); + + const lastName = names[0] || ''; + const firstName = names[1] || ''; + const middleNames = names.slice(2, -1).filter(Boolean); + const title = names.slice(-1).join(' ') || ''; + + if (!firstName) { + continue; + } + + partialContact.first_name = firstName; + partialContact.last_name = lastName; + partialContact.extra = { + ...(partialContact.extra || {}), + middle_names: middleNames, + name_title: title, + }; + + continue; + } + + if (line.startsWith('ORG:')) { + const organization = ((line.split('ORG:')[1] || '').split(';').join(' ') || '').replaceAll('\\,', ','); + + if (!organization) { + continue; + } + + partialContact.extra = { + ...(partialContact.extra || {}), + organization, + }; + + continue; + } + + if (line.startsWith('BDAY:')) { + const birthday = line.split('BDAY:')[1] || ''; + + partialContact.extra = { + ...(partialContact.extra || {}), + birthday, + }; + + continue; + } + + if (line.startsWith('NICKNAME:')) { + const nickname = (line.split('NICKNAME:')[1] || '').split(';').join(' ') || ''; + + if (!nickname) { + continue; + } + + partialContact.extra = { + ...(partialContact.extra || {}), + nickname, + }; + + continue; + } + + if (line.startsWith('TITLE:')) { + const role = line.split('TITLE:')[1] || ''; + + partialContact.extra = { + ...(partialContact.extra || {}), + role, + }; + + continue; + } + + if (line.startsWith('NOTE:')) { + const notes = (line.split('NOTE:')[1] || '').replaceAll('\\n', '\n').replaceAll('\\,', ','); + + partialContact.extra = { + ...(partialContact.extra || {}), + notes, + }; + + continue; + } + + if (line.includes('ADR;')) { + const addressInfo = line.split('ADR;')[1] || ''; + const addressParts = (addressInfo.split(':')[1] || '').split(';'); + const country = addressParts.slice(-1, addressParts.length).join(' ').replaceAll('\\n', '\n').replaceAll( + '\\,', + ',', + ); + const postalCode = addressParts.slice(-2, addressParts.length - 1).join(' ').replaceAll('\\n', '\n').replaceAll( + '\\,', + ',', + ); + const state = addressParts.slice(-3, addressParts.length - 2).join(' ').replaceAll('\\n', '\n').replaceAll( + '\\,', + ',', + ); + const city = addressParts.slice(-4, addressParts.length - 3).join(' ').replaceAll('\\n', '\n').replaceAll( + '\\,', + ',', + ); + const line1 = addressParts.slice(-5, addressParts.length - 4).join(' ').replaceAll('\\n', '\n').replaceAll( + '\\,', + ',', + ); + const line2 = addressParts.slice(-6, addressParts.length - 5).join(' ').replaceAll('\\n', '\n').replaceAll( + '\\,', + ',', + ); + + const label = ((addressInfo.split(':')[0] || '').split('TYPE=')[1] || 'home').replaceAll(';', '').replaceAll( + '\\n', + '\n', + ).replaceAll('\\,', ','); + + if (!country && !postalCode && !state && !city && !line2 && !line1) { + continue; + } + + const address: ContactAddress = { + label, + line_1: line1, + line_2: line2, + city, + state, + postal_code: postalCode, + country, + }; + + partialContact.extra = { + ...(partialContact.extra || {}), + addresses: [...(partialContact.extra?.addresses || []), address], + }; + + continue; + } + + if (line.includes('PHOTO;')) { + const photoInfo = line.split('PHOTO;')[1] || ''; + const photoUrl = photoInfo.split(':')[1]; + const photoMediaTypeInfo = photoInfo.split(':')[0]; + let photoMediaType = photoMediaTypeInfo.split('TYPE=')[1] || ''; + + if (!photoMediaType) { + photoMediaType = 'image/jpeg'; + } + + if (!photoMediaType.startsWith('image/')) { + photoMediaType = `image/${photoMediaType.toLowerCase()}`; + } + + if (!photoUrl) { + continue; + } + + partialContact.extra = { + ...(partialContact.extra || {}), + photo_mediatype: photoMediaType, + photo_url: photoUrl, + }; + + continue; + } + + if (line.includes('TEL;')) { + const phoneInfo = line.split('TEL;')[1] || ''; + const phoneNumber = phoneInfo.split(':')[1] || ''; + const name = (phoneInfo.split(':')[0].split('TYPE=')[1] || 'home').replaceAll(';', ''); + + if (!phoneNumber) { + continue; + } + + const field: ContactField = { + name, + value: phoneNumber, + type: 'phone', + }; + + partialContact.extra = { + ...(partialContact.extra || {}), + fields: [...(partialContact.extra?.fields || []), field], + }; + + continue; + } + + if (line.includes('EMAIL;')) { + const emailInfo = line.split('EMAIL;')[1] || ''; + const emailAddress = emailInfo.split(':')[1] || ''; + const name = (emailInfo.split(':')[0].split('TYPE=')[1] || 'home').replaceAll(';', ''); + + if (!emailAddress) { + continue; + } + + const field: ContactField = { + name, + value: emailAddress, + type: 'email', + }; + + partialContact.extra = { + ...(partialContact.extra || {}), + fields: [...(partialContact.extra?.fields || []), field], + }; + + continue; + } + } + + return partialContacts; +} diff --git a/lib/utils_test.ts b/lib/utils_test.ts new file mode 100644 index 0000000..b6f6615 --- /dev/null +++ b/lib/utils_test.ts @@ -0,0 +1,294 @@ +import { assertEquals } from 'std/assert/assert_equals.ts'; +import { + convertFormDataToObject, + convertObjectToDavXml, + convertObjectToFormData, + escapeHtml, + generateHash, + generateRandomCode, + splitArrayInChunks, + validateEmail, + validateUrl, +} from './utils.ts'; + +Deno.test('that escapeHtml works', () => { + const tests: { input: string; expected: string }[] = [ + { + input: 'URL', + expected: '<a href="https://brunobernardino.com">URL</a>', + }, + { + input: "\">", + expected: '"><img onerror='alert(1)' />', + }, + ]; + + for (const test of tests) { + const output = escapeHtml(test.input); + assertEquals(output, test.expected); + } +}); + +Deno.test('that generateRandomCode works', () => { + const tests: { length: number }[] = [ + { + length: 6, + }, + { + length: 7, + }, + { + length: 8, + }, + ]; + + for (const test of tests) { + const output = generateRandomCode(test.length); + assertEquals(output.length, test.length); + } +}); + +Deno.test('that splitArrayInChunks works', () => { + const tests: { input: { array: { number: number }[]; chunkLength: number }; expected: { number: number }[][] }[] = [ + { + input: { + array: [ + { number: 1 }, + { number: 2 }, + { number: 3 }, + { number: 4 }, + { number: 5 }, + { number: 6 }, + ], + chunkLength: 2, + }, + expected: [ + [{ number: 1 }, { number: 2 }], + [{ number: 3 }, { number: 4 }], + [{ number: 5 }, { number: 6 }], + ], + }, + { + input: { + array: [ + { number: 1 }, + { number: 2 }, + { number: 3 }, + { number: 4 }, + { number: 5 }, + ], + chunkLength: 2, + }, + expected: [ + [{ number: 1 }, { number: 2 }], + [{ number: 3 }, { number: 4 }], + [{ number: 5 }], + ], + }, + { + input: { + array: [ + { number: 1 }, + { number: 2 }, + { number: 3 }, + { number: 4 }, + { number: 5 }, + { number: 6 }, + ], + chunkLength: 3, + }, + expected: [ + [{ number: 1 }, { number: 2 }, { number: 3 }], + [{ number: 4 }, { number: 5 }, { number: 6 }], + ], + }, + ]; + + for (const test of tests) { + const output = splitArrayInChunks( + test.input.array, + test.input.chunkLength, + ); + assertEquals(output, test.expected); + } +}); + +Deno.test('that generateHash works', async () => { + const tests: { input: { value: string; algorithm: string }; expected: string }[] = [ + { + input: { + value: 'password', + algorithm: 'SHA-256', + }, + expected: '5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8', + }, + { + input: { + value: '123456', + algorithm: 'SHA-256', + }, + expected: '8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92', + }, + ]; + + for (const test of tests) { + const output = await generateHash(test.input.value, test.input.algorithm); + assertEquals(output, test.expected); + } +}); + +Deno.test('that validateEmail works', () => { + const tests: { email: string; expected: boolean }[] = [ + { email: 'user@example.com', expected: true }, + { email: 'u@e.c', expected: true }, + { email: 'user@example.', expected: false }, + { email: '@example.com', expected: false }, + { email: 'user@example.', expected: false }, + { email: 'ABC', expected: false }, + ]; + + for (const test of tests) { + const result = validateEmail(test.email); + assertEquals(result, test.expected); + } +}); + +Deno.test('that validateUrl works', () => { + const tests: { url: string; expected: boolean }[] = [ + { url: 'https://bewcloud.com', expected: true }, + { url: 'ftp://something', expected: true }, + { url: 'http', expected: false }, + { url: 'https://', expected: false }, + { url: 'http://a', expected: true }, + { url: 'ABC', expected: false }, + ]; + + for (const test of tests) { + const result = validateUrl(test.url); + assertEquals(result, test.expected); + } +}); + +Deno.test('that convertFormDataToObject works', () => { + const formData1 = new FormData(); + formData1.append('user', '1'); + formData1.append('is_real', 'false'); + formData1.append('tags', 'one'); + formData1.append('tags', 'two'); + + const formData2 = new FormData(); + formData2.append('user', '2'); + formData2.append('is_real', 'true'); + formData2.append('tags', 'one'); + formData2.append('empty', ''); + + const tests: { input: FormData; expected: Record }[] = [ + { + input: formData1, + expected: { + user: '1', + is_real: 'false', + // tags: ['one', 'two'], + tags: 'two', // NOTE: This is a limitation of the simple logic, but it should ideally be the array above + }, + }, + { + input: formData2, + expected: { + user: '2', + is_real: 'true', + tags: 'one', + empty: '', + }, + }, + ]; + + for (const test of tests) { + const output = convertFormDataToObject(test.input); + assertEquals(output, test.expected); + } +}); + +Deno.test('that convertObjectToFormData works', () => { + const formData1 = new FormData(); + formData1.append('user', '1'); + formData1.append('is_real', 'false'); + formData1.append('tags', 'one'); + // formData1.append('tags', 'two');// NOTE: This is a limitation of the simple logic, but it should ideally be an array below + + const formData2 = new FormData(); + formData2.append('user', '2'); + formData2.append('is_real', 'true'); + formData2.append('tags', 'one'); + formData2.append('empty', ''); + + const tests: { input: Record; expected: FormData }[] = [ + { + input: { + user: '1', + is_real: 'false', + tags: 'one', + }, + expected: formData1, + }, + { + input: { + user: '2', + is_real: 'true', + tags: 'one', + empty: '', + }, + expected: formData2, + }, + ]; + + for (const test of tests) { + const output = convertObjectToFormData(test.input); + assertEquals(convertFormDataToObject(output), convertFormDataToObject(test.expected)); + } +}); + +Deno.test('that convertObjectToDavXml works', () => { + const tests: { input: Record; expected: string }[] = [ + { + input: { + url: 'https://bewcloud.com', + }, + expected: `https://bewcloud.com`, + }, + { + input: { + a: 'Website', + a_attributes: { + href: 'https://bewcloud.com', + target: '_blank', + }, + }, + expected: `Website`, + }, + { + input: { + article: { + p: [ + { + strong: 'Indeed', + }, + { + i: {}, + }, + 'Mighty!', + ], + }, + article_attributes: { + class: 'center', + }, + }, + expected: `

Indeed

\n

\n

Mighty!

`, + }, + ]; + + for (const test of tests) { + const result = convertObjectToDavXml(test.input); + assertEquals(result, test.expected); + } +}); diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..074b8d4 --- /dev/null +++ b/main.ts @@ -0,0 +1,13 @@ +/// +/// +/// +/// +/// + +import 'std/dotenv/load.ts'; + +import { start } from 'fresh/server.ts'; +import manifest from './fresh.gen.ts'; +import config from './fresh.config.ts'; + +await start(manifest, config); diff --git a/main_test.ts b/main_test.ts new file mode 100644 index 0000000..f523ea8 --- /dev/null +++ b/main_test.ts @@ -0,0 +1,44 @@ +import { assert } from 'std/assert/assert.ts'; +import { assertEquals } from 'std/assert/assert_equals.ts'; +import { createHandler, ServeHandlerInfo } from 'fresh/server.ts'; + +import manifest from './fresh.gen.ts'; +import config from './fresh.config.ts'; + +const CONN_INFO: ServeHandlerInfo = { + remoteAddr: { hostname: '127.0.0.1', port: 53496, transport: 'tcp' }, +}; + +Deno.test('Basic routes', async (testContext) => { + const handler = await createHandler(manifest, config); + + await testContext.step('#1 GET /', async () => { + const response = await handler(new Request('http://127.0.0.1/'), CONN_INFO); + assertEquals(response.status, 303); + }); + + await testContext.step('#2 GET /login', async () => { + const response = await handler(new Request('http://127.0.0.1/login'), CONN_INFO); + const text = await response.text(); + assert(text.includes('bewCloud')); + assertEquals(response.status, 200); + }); + + await testContext.step('#3 GET /blah', async () => { + const response = await handler(new Request('http://127.0.0.1/blah'), CONN_INFO); + const text = await response.text(); + assert(text.includes('404 - Page not found')); + assertEquals(response.status, 404); + }); + + // await testContext.step('#4 POST /', async () => { + // const formData = new FormData(); + // formData.append('text', 'Deno!'); + // const request = new Request('http://127.0.0.1/', { + // method: 'POST', + // body: formData, + // }); + // const response = await handler(request, CONN_INFO); + // assertEquals(response.status, 303); + // }); +}); diff --git a/migrate-db.ts b/migrate-db.ts new file mode 100644 index 0000000..0e0d401 --- /dev/null +++ b/migrate-db.ts @@ -0,0 +1,90 @@ +import 'std/dotenv/load.ts'; + +import Database, { sql } from '/lib/interfaces/database.ts'; + +const migrationsDirectoryPath = `${Deno.cwd()}/db-migrations`; + +const migrationsDirectory = Deno.readDir(migrationsDirectoryPath); + +const db = new Database(); + +interface Migration { + id: string; + name: string; + executed_at: Date; +} + +async function getExecutedMigrations() { + const executedMigrations = new Set( + Array.from( + (await db.query(sql`SELECT * FROM "bewcloud_migrations" ORDER BY "name" ASC`)).map((migration) => + migration.name + ), + ), + ); + + return executedMigrations; +} + +async function getMissingMigrations() { + const existingMigrations: Set = new Set(); + + for await (const migrationFile of migrationsDirectory) { + // Skip non-files + if (!migrationFile.isFile) { + continue; + } + + // Skip files not in the "001-blah.pgsql" format + if (!migrationFile.name.match(/^\d+-.*(\.pgsql)$/)) { + continue; + } + + existingMigrations.add(migrationFile.name); + } + + // Add everything to run, by default + const migrationsToExecute = new Set([...existingMigrations]); + + try { + const executedMigrations = await getExecutedMigrations(); + + // Remove any existing migrations that were executed, from the list of migrations to execute + for (const executedMigration of executedMigrations) { + migrationsToExecute.delete(executedMigration); + } + } catch (_error) { + // The table likely doesn't exist, so run everything. + } + + return migrationsToExecute; +} + +async function runMigrations(missingMigrations: Set) { + for (const missingMigration of missingMigrations) { + console.log(`Running "${missingMigration}"...`); + + try { + const migrationSql = await Deno.readTextFile(`${migrationsDirectoryPath}/${missingMigration}`); + + await db.query(migrationSql); + + await db.query(sql`INSERT INTO "public"."bewcloud_migrations" ("name", "executed_at") VALUES ($1, NOW())`, [ + missingMigration, + ]); + + console.log('Success!'); + } catch (error) { + console.log('Failed!'); + console.error(error); + } + } +} + +const missingMigrations = await getMissingMigrations(); + +await runMigrations(missingMigrations); + +if (missingMigrations.size === 0) { + console.log('No migrations to run!'); +} diff --git a/routes/.well-known/carddav.tsx b/routes/.well-known/carddav.tsx new file mode 100644 index 0000000..e479c7a --- /dev/null +++ b/routes/.well-known/carddav.tsx @@ -0,0 +1,7 @@ +// Nextcloud/ownCloud mimicry +export function handler(): Response { + return new Response('Redirecting...', { + status: 307, + headers: { Location: '/dav' }, + }); +} diff --git a/routes/_404.tsx b/routes/_404.tsx new file mode 100644 index 0000000..0fdf170 --- /dev/null +++ b/routes/_404.tsx @@ -0,0 +1,38 @@ +import { Head } from 'fresh/runtime.ts'; +import { PageProps } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; + +interface Data {} + +export default function Error404({ state }: PageProps) { + return ( + <> + + 404 - Page not found + +
+
+ {!state.user + ? ( + <> + the bewCloud logo: a stylized logo +

404 - Page not found

+ + ) + : null} +

+ The page you were looking for doesn"t exist. +

+ Go back home +
+
+ + ); +} diff --git a/routes/_app.tsx b/routes/_app.tsx new file mode 100644 index 0000000..5496833 --- /dev/null +++ b/routes/_app.tsx @@ -0,0 +1,29 @@ +import { PageProps } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { defaultDescription, defaultTitle } from '/lib/utils.ts'; +import Header from '/components/Header.tsx'; + +interface Data {} + +export default function App({ route, Component, state }: PageProps) { + return ( + + + + + {defaultTitle} + + + + + + + + +
+ + + + ); +} diff --git a/routes/_middleware.tsx b/routes/_middleware.tsx new file mode 100644 index 0000000..90191ef --- /dev/null +++ b/routes/_middleware.tsx @@ -0,0 +1,73 @@ +import { FreshContext } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { getDataFromRequest } from '/lib/auth.ts'; + +export const handler = [ + async function handleCors(request: Request, context: FreshContext) { + if (request.method == 'OPTIONS') { + const response = new Response(null, { + status: 204, + }); + const origin = request.headers.get('Origin') || '*'; + const headers = response.headers; + headers.set('Access-Control-Allow-Origin', origin); + headers.set('Access-Control-Allow-Methods', 'DELETE'); + return response; + } + + const origin = request.headers.get('Origin') || '*'; + const response = await context.next(); + const headers = response.headers; + + headers.set('Access-Control-Allow-Origin', origin); + headers.set('Access-Control-Allow-Credentials', 'true'); + headers.set( + 'Access-Control-Allow-Headers', + 'Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With', + ); + headers.set( + 'Access-Control-Allow-Methods', + 'POST, OPTIONS, GET, PUT, DELETE', + ); + headers.set( + 'Content-Security-Policy', + "default-src 'self'; child-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'", + ); + headers.set('X-Frame-Options', 'DENY'); + headers.set('X-Content-Type-Options', 'nosniff'); + headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); + + return response; + }, + + async function handleContextState(request: Request, context: FreshContext) { + const { user, session } = (await getDataFromRequest(request)) || {}; + + if (user) { + context.state.user = user; + } + + if (session) { + context.state.session = session; + } + + const response = await context.next(); + + return response; + }, + + async function handleLogging(request: Request, context: FreshContext) { + const response = await context.next(); + + console.info(`${new Date().toISOString()} - ${request.method} ${request.url} [${response.status}]`); + if (request.url.includes('/dav/')) { + console.info(`Request`, request.headers); + console.info((await request.clone().text()) || ''); + console.info(`Response`, response.headers); + console.info(`Status`, response.status); + } + + return response; + }, +]; diff --git a/routes/api/contacts/add.tsx b/routes/api/contacts/add.tsx new file mode 100644 index 0000000..edc1369 --- /dev/null +++ b/routes/api/contacts/add.tsx @@ -0,0 +1,41 @@ +import { Handlers } from 'fresh/server.ts'; + +import { Contact, FreshContextState } from '/lib/types.ts'; +import { createContact, getContacts } from '/lib/data/contacts.ts'; + +interface Data {} + +export interface RequestBody { + firstName: string; + lastName: string; + page: number; +} + +export interface ResponseBody { + success: boolean; + contacts: Pick[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if (requestBody.firstName) { + const contact = await createContact(context.state.user.id, requestBody.firstName, requestBody.lastName); + + if (!contact) { + return new Response('Not found', { status: 404 }); + } + } + + const contacts = await getContacts(context.state.user.id, requestBody.page - 1); + + const responseBody: ResponseBody = { success: true, contacts }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/contacts/delete.tsx b/routes/api/contacts/delete.tsx new file mode 100644 index 0000000..36acfeb --- /dev/null +++ b/routes/api/contacts/delete.tsx @@ -0,0 +1,42 @@ +import { Handlers } from 'fresh/server.ts'; + +import { Contact, FreshContextState } from '/lib/types.ts'; +import { deleteContact, getContact, getContacts } from '/lib/data/contacts.ts'; + +interface Data {} + +export interface RequestBody { + contactId: string; + page: number; +} + +export interface ResponseBody { + success: boolean; + contacts: Pick[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if (requestBody.contactId) { + const contact = await getContact(requestBody.contactId, context.state.user.id); + + if (!contact) { + return new Response('Not found', { status: 404 }); + } + + await deleteContact(requestBody.contactId, context.state.user.id); + } + + const contacts = await getContacts(context.state.user.id, requestBody.page - 1); + + const responseBody: ResponseBody = { success: true, contacts }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/contacts/get.tsx b/routes/api/contacts/get.tsx new file mode 100644 index 0000000..0ac27c4 --- /dev/null +++ b/routes/api/contacts/get.tsx @@ -0,0 +1,27 @@ +import { Handlers } from 'fresh/server.ts'; + +import { Contact, FreshContextState } from '/lib/types.ts'; +import { getAllContacts } from '/lib/data/contacts.ts'; + +interface Data {} + +export interface RequestBody {} + +export interface ResponseBody { + success: boolean; + contacts: Contact[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const contacts = await getAllContacts(context.state.user.id); + + const responseBody: ResponseBody = { success: true, contacts }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/contacts/import.tsx b/routes/api/contacts/import.tsx new file mode 100644 index 0000000..7a3db30 --- /dev/null +++ b/routes/api/contacts/import.tsx @@ -0,0 +1,60 @@ +import { Handlers } from 'fresh/server.ts'; + +import { Contact, FreshContextState } from '/lib/types.ts'; +import { concurrentPromises } from '/lib/utils.ts'; +import { createContact, getContacts, updateContact } from '/lib/data/contacts.ts'; + +interface Data {} + +export interface RequestBody { + partialContacts: Partial[]; + page: number; +} + +export interface ResponseBody { + success: boolean; + contacts: Pick[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if (requestBody.partialContacts) { + if (requestBody.partialContacts.length === 0) { + return new Response('Not found', { status: 404 }); + } + + await concurrentPromises( + requestBody.partialContacts.map((partialContact) => async () => { + if (partialContact.first_name) { + const contact = await createContact( + context.state.user!.id, + partialContact.first_name, + partialContact.last_name || '', + ); + + const parsedExtra = JSON.stringify(partialContact.extra || {}); + + if (parsedExtra !== '{}') { + contact.extra = partialContact.extra!; + + await updateContact(contact); + } + } + }), + 5, + ); + } + + const contacts = await getContacts(context.state.user.id, requestBody.page - 1); + + const responseBody: ResponseBody = { success: true, contacts }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/dashboard/save-links.tsx b/routes/api/dashboard/save-links.tsx new file mode 100644 index 0000000..a836ea8 --- /dev/null +++ b/routes/api/dashboard/save-links.tsx @@ -0,0 +1,40 @@ +import { Handlers } from 'fresh/server.ts'; + +import { DashboardLink, FreshContextState } from '/lib/types.ts'; +import { getDashboardByUserId, updateDashboard } from '/lib/data/dashboard.ts'; + +interface Data {} + +export interface RequestBody { + links: DashboardLink[]; +} + +export interface ResponseBody { + success: boolean; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const userDashboard = await getDashboardByUserId(context.state.user.id); + + if (!userDashboard) { + return new Response('Not found', { status: 404 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if (typeof requestBody.links !== 'undefined') { + userDashboard.data.links = requestBody.links; + + await updateDashboard(userDashboard); + } + + const responseBody: ResponseBody = { success: true }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/dashboard/save-notes.tsx b/routes/api/dashboard/save-notes.tsx new file mode 100644 index 0000000..cc79d98 --- /dev/null +++ b/routes/api/dashboard/save-notes.tsx @@ -0,0 +1,40 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { getDashboardByUserId, updateDashboard } from '/lib/data/dashboard.ts'; + +interface Data {} + +export interface RequestBody { + notes: string; +} + +export interface ResponseBody { + success: boolean; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const userDashboard = await getDashboardByUserId(context.state.user.id); + + if (!userDashboard) { + return new Response('Not found', { status: 404 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if (typeof requestBody.notes !== 'undefined' && userDashboard.data.notes !== requestBody.notes) { + userDashboard.data.notes = requestBody.notes; + + await updateDashboard(userDashboard); + } + + const responseBody: ResponseBody = { success: true }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/news/add-feed.tsx b/routes/api/news/add-feed.tsx new file mode 100644 index 0000000..c3bbd26 --- /dev/null +++ b/routes/api/news/add-feed.tsx @@ -0,0 +1,42 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState, NewsFeed } from '/lib/types.ts'; +import { createNewsFeed, getNewsFeeds } from '/lib/data/news.ts'; +import { fetchNewArticles } from '/crons/news.ts'; + +interface Data {} + +export interface RequestBody { + feedUrl: string; +} + +export interface ResponseBody { + success: boolean; + newFeeds: NewsFeed[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if (requestBody.feedUrl) { + const newFeed = await createNewsFeed(context.state.user.id, requestBody.feedUrl); + + if (!newFeed) { + return new Response('Not found', { status: 404 }); + } + } + + await fetchNewArticles(); + + const newFeeds = await getNewsFeeds(context.state.user.id); + + const responseBody: ResponseBody = { success: true, newFeeds }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/news/delete-feed.tsx b/routes/api/news/delete-feed.tsx new file mode 100644 index 0000000..3eb82ea --- /dev/null +++ b/routes/api/news/delete-feed.tsx @@ -0,0 +1,41 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState, NewsFeed } from '/lib/types.ts'; +import { deleteNewsFeed, getNewsFeed, getNewsFeeds } from '/lib/data/news.ts'; + +interface Data {} + +export interface RequestBody { + feedId: string; +} + +export interface ResponseBody { + success: boolean; + newFeeds: NewsFeed[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if (requestBody.feedId) { + const newsFeed = await getNewsFeed(requestBody.feedId, context.state.user.id); + + if (!newsFeed) { + return new Response('Not found', { status: 404 }); + } + + await deleteNewsFeed(requestBody.feedId); + } + + const newFeeds = await getNewsFeeds(context.state.user.id); + + const responseBody: ResponseBody = { success: true, newFeeds }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/news/import-feeds.tsx b/routes/api/news/import-feeds.tsx new file mode 100644 index 0000000..02f8949 --- /dev/null +++ b/routes/api/news/import-feeds.tsx @@ -0,0 +1,46 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState, NewsFeed } from '/lib/types.ts'; +import { concurrentPromises } from '/lib/utils.ts'; +import { createNewsFeed, getNewsFeeds } from '/lib/data/news.ts'; +import { fetchNewArticles } from '/crons/news.ts'; + +interface Data {} + +export interface RequestBody { + feedUrls: string[]; +} + +export interface ResponseBody { + success: boolean; + newFeeds: NewsFeed[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if (requestBody.feedUrls) { + if (requestBody.feedUrls.length === 0) { + return new Response('Not found', { status: 404 }); + } + + await concurrentPromises( + requestBody.feedUrls.map((feedUrl) => () => createNewsFeed(context.state.user!.id, feedUrl)), + 5, + ); + } + + await fetchNewArticles(); + + const newFeeds = await getNewsFeeds(context.state.user.id); + + const responseBody: ResponseBody = { success: true, newFeeds }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/news/mark-read.tsx b/routes/api/news/mark-read.tsx new file mode 100644 index 0000000..a618b28 --- /dev/null +++ b/routes/api/news/mark-read.tsx @@ -0,0 +1,44 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { getNewsArticle, markAllArticlesRead, updateNewsArticle } from '/lib/data/news.ts'; + +interface Data {} + +export interface RequestBody { + articleId: string; +} + +export interface ResponseBody { + success: boolean; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if (requestBody.articleId) { + if (requestBody.articleId === 'all') { + await markAllArticlesRead(context.state.user.id); + } else { + const article = await getNewsArticle(requestBody.articleId, context.state.user.id); + + if (!article) { + return new Response('Not found', { status: 404 }); + } + + article.is_read = true; + + await updateNewsArticle(article); + } + } + + const responseBody: ResponseBody = { success: true }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/news/refresh-articles.tsx b/routes/api/news/refresh-articles.tsx new file mode 100644 index 0000000..d5b0acd --- /dev/null +++ b/routes/api/news/refresh-articles.tsx @@ -0,0 +1,36 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState, NewsFeedArticle } from '/lib/types.ts'; +import { getNewsArticles, getNewsFeeds } from '/lib/data/news.ts'; +import { fetchNewArticles } from '/crons/news.ts'; + +interface Data {} + +export interface RequestBody {} + +export interface ResponseBody { + success: boolean; + newArticles: NewsFeedArticle[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const newsFeeds = await getNewsFeeds(context.state.user.id); + + if (!newsFeeds.length) { + return new Response('Not found', { status: 404 }); + } + + await fetchNewArticles(true); + + const newArticles = await getNewsArticles(context.state.user.id); + + const responseBody: ResponseBody = { success: true, newArticles }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/contacts.tsx b/routes/contacts.tsx new file mode 100644 index 0000000..6c5e9c6 --- /dev/null +++ b/routes/contacts.tsx @@ -0,0 +1,48 @@ +import { Handlers, PageProps } from 'fresh/server.ts'; + +import { Contact, FreshContextState } from '/lib/types.ts'; +import { getContacts, getContactsCount, searchContacts, searchContactsCount } from '/lib/data/contacts.ts'; +import Contacts from '/islands/contacts/Contacts.tsx'; + +interface Data { + userContacts: Pick[]; + page: number; + contactsCount: number; + search?: string; +} + +export const handler: Handlers = { + async GET(request, context) { + if (!context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); + } + + const searchParams = new URL(request.url).searchParams; + + const page = parseInt(searchParams.get('page') || '1', 10); + const search = searchParams.get('search') || undefined; + + const userContacts = search + ? await searchContacts(search, context.state.user.id, page - 1) + : await getContacts(context.state.user.id, page - 1); + + const contactsCount = search + ? await searchContactsCount(search, context.state.user.id) + : await getContactsCount(context.state.user.id); + + return await context.render({ userContacts, page, contactsCount, search }); + }, +}; + +export default function ContactsPage({ data }: PageProps) { + return ( +
+ +
+ ); +} diff --git a/routes/contacts/[contactId].tsx b/routes/contacts/[contactId].tsx new file mode 100644 index 0000000..1b5c467 --- /dev/null +++ b/routes/contacts/[contactId].tsx @@ -0,0 +1,204 @@ +import { Handlers, PageProps } from 'fresh/server.ts'; + +import { Contact, ContactAddress, ContactField, FreshContextState } from '/lib/types.ts'; +import { convertFormDataToObject } from '/lib/utils.ts'; +import { getContact, updateContact } from '/lib/data/contacts.ts'; +import { getFormDataField, getFormDataFieldArray } from '/lib/form-utils.tsx'; +import ViewContact, { formFields } from '/islands/contacts/ViewContact.tsx'; + +interface Data { + contact: Contact; + error?: string; + notice?: string; + formData: Record; +} + +export const handler: Handlers = { + async GET(request, context) { + if (!context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); + } + + const { contactId } = context.params; + + const contact = await getContact(contactId, context.state.user.id); + + if (!contact) { + return new Response('Not found', { status: 404 }); + } + + return await context.render({ contact, formData: {} }); + }, + async POST(request, context) { + if (!context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); + } + + const { contactId } = context.params; + + const contact = await getContact(contactId, context.state.user.id); + + if (!contact) { + return new Response('Not found', { status: 404 }); + } + + const formData = await request.formData(); + + contact.extra.name_title = getFormDataField(formData, 'name_title') || undefined; + contact.first_name = getFormDataField(formData, 'first_name'); + contact.last_name = getFormDataField(formData, 'last_name'); + contact.extra.middle_names = getFormDataField(formData, 'middle_names').split(' ').map((name) => + (name || '').trim() + ).filter(Boolean); + if (contact.extra.middle_names.length === 0) { + contact.extra.middle_names = undefined; + } + contact.extra.birthday = getFormDataField(formData, 'birthday') || undefined; + contact.extra.nickname = getFormDataField(formData, 'nickname') || undefined; + contact.extra.organization = getFormDataField(formData, 'organization') || undefined; + contact.extra.role = getFormDataField(formData, 'role') || undefined; + contact.extra.photo_url = getFormDataField(formData, 'photo_url') || undefined; + contact.extra.photo_mediatype = contact.extra.photo_url + ? `image/${contact.extra.photo_url.split('.').slice(-1, 1).join('').toLowerCase()}` + : undefined; + contact.extra.notes = getFormDataField(formData, 'notes') || undefined; + + contact.extra.fields = []; + + // Phones + const phoneNumbers = getFormDataFieldArray(formData, 'phone_numbers'); + const phoneLabels = getFormDataFieldArray(formData, 'phone_labels'); + + for (const [index, phoneNumber] of phoneNumbers.entries()) { + if (phoneNumber.trim().length === 0) { + continue; + } + + const field: ContactField = { + name: phoneLabels[index] || 'Home', + value: phoneNumber.trim(), + type: 'phone', + }; + + contact.extra.fields.push(field); + } + + // Emails + const emailAddresses = getFormDataFieldArray(formData, 'email_addresses'); + const emailLabels = getFormDataFieldArray(formData, 'email_labels'); + + for (const [index, emailAddress] of emailAddresses.entries()) { + if (emailAddress.trim().length === 0) { + continue; + } + + const field: ContactField = { + name: emailLabels[index] || 'Home', + value: emailAddress.trim(), + type: 'email', + }; + + contact.extra.fields.push(field); + } + + // URLs + const urlAddresses = getFormDataFieldArray(formData, 'url_addresses'); + const urlLabels = getFormDataFieldArray(formData, 'url_labels'); + + for (const [index, urlAddress] of urlAddresses.entries()) { + if (urlAddress.trim().length === 0) { + continue; + } + + const field: ContactField = { + name: urlLabels[index] || 'Home', + value: urlAddress.trim(), + type: 'url', + }; + + contact.extra.fields.push(field); + } + + // Others + const otherValues = getFormDataFieldArray(formData, 'other_values'); + const otherLabels = getFormDataFieldArray(formData, 'other_labels'); + + for (const [index, otherValue] of otherValues.entries()) { + if (otherValue.trim().length === 0) { + continue; + } + + const field: ContactField = { + name: otherLabels[index] || 'Home', + value: otherValue.trim(), + type: 'other', + }; + + contact.extra.fields.push(field); + } + + contact.extra.addresses = []; + + // Addresses + const addressLine1s = getFormDataFieldArray(formData, 'address_line_1s'); + const addressLine2s = getFormDataFieldArray(formData, 'address_line_2s'); + const addressCities = getFormDataFieldArray(formData, 'address_cities'); + const addressPostalCodes = getFormDataFieldArray(formData, 'address_postal_codes'); + const addressStates = getFormDataFieldArray(formData, 'address_states'); + const addressCountries = getFormDataFieldArray(formData, 'address_countries'); + const addressLabels = getFormDataFieldArray(formData, 'address_labels'); + + for (const [index, addressLine1] of addressLine1s.entries()) { + if (addressLine1.trim().length === 0) { + continue; + } + + const address: ContactAddress = { + label: addressLabels[index] || 'Home', + line_1: addressLine1.trim(), + line_2: addressLine2s[index] || undefined, + city: addressCities[index] || undefined, + postal_code: addressPostalCodes[index] || undefined, + state: addressStates[index] || undefined, + country: addressCountries[index] || undefined, + }; + + contact.extra.addresses.push(address); + } + + try { + if (!contact.first_name) { + throw new Error(`First name is required.`); + } + + formFields(contact).forEach((field) => { + if (field.required) { + const value = formData.get(field.name); + + if (!value) { + throw new Error(`${field.label} is required`); + } + } + }); + + await updateContact(contact); + + return await context.render({ + contact, + notice: 'Contact updated successfully!', + formData: convertFormDataToObject(formData), + }); + } catch (error) { + console.error(error); + return await context.render({ contact, error: error.toString(), formData: convertFormDataToObject(formData) }); + } + }, +}; + +export default function ContactsPage({ data }: PageProps) { + return ( +
+ +
+ ); +} diff --git a/routes/dashboard.tsx b/routes/dashboard.tsx new file mode 100644 index 0000000..d10d82d --- /dev/null +++ b/routes/dashboard.tsx @@ -0,0 +1,38 @@ +import { Handlers, PageProps } from 'fresh/server.ts'; + +import { Dashboard, FreshContextState } from '/lib/types.ts'; +import { createDashboard, getDashboardByUserId } from '/lib/data/dashboard.ts'; +import Notes from '/islands/dashboard/Notes.tsx'; +import Links from '/islands/dashboard/Links.tsx'; + +interface Data { + userDashboard: Dashboard; +} + +export const handler: Handlers = { + async GET(request, context) { + if (!context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); + } + + let userDashboard = await getDashboardByUserId(context.state.user.id); + + if (!userDashboard) { + userDashboard = await createDashboard(context.state.user.id); + } + + return await context.render({ userDashboard }); + }, +}; + +export default function Dashboard({ data }: PageProps) { + const initialNotes = data?.userDashboard?.data?.notes || 'Jot down some notes here.'; + + return ( +
+ + + +
+ ); +} diff --git a/routes/dav/addressbooks.tsx b/routes/dav/addressbooks.tsx new file mode 100644 index 0000000..dee10f6 --- /dev/null +++ b/routes/dav/addressbooks.tsx @@ -0,0 +1,124 @@ +import { Handler } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils.ts'; +import { createSessionCookie } from '/lib/auth.ts'; + +interface Data {} + +interface DavResponse { + 'd:href': string; + 'd:propstat': { + 'd:prop': { + 'd:resourcetype'?: { + 'd:collection': {}; + 'card:addressbook'?: {}; + }; + 'd:displayname'?: string; + 'd:getetag'?: string; + 'd:current-user-principal'?: { + 'd:href': string; + }; + 'd:principal-URL'?: {}; + 'card:addressbook-home-set'?: {}; + }; + 'd:status': string; + }[]; +} + +interface DavMultiStatusResponse { + 'd:multistatus': { + 'd:response': DavResponse[]; + }; + 'd:multistatus_attributes': { + 'xmlns:d': string; + 'xmlns:s': string; + 'xmlns:card': string; + 'xmlns:oc': string; + 'xmlns:nc': string; + }; +} + +interface ResponseBody extends DavMultiStatusResponse {} + +export const handler: Handler = (request, context) => { + if (!context.state.user) { + return new Response('Unauthorized', { + status: 401, + headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' }, + }); + } + + if (request.method === 'GET') { + return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.'); + } + + if (request.method !== 'PROPFIND' && request.method !== 'REPORT') { + return new Response('Bad Request', { status: 400 }); + } + + const responseBody: ResponseBody = { + 'd:multistatus': { + 'd:response': [ + { + 'd:href': '/dav/addressbooks/', + 'd:propstat': [{ + 'd:prop': { + 'd:resourcetype': { + 'd:collection': {}, + }, + 'd:current-user-principal': { + 'd:href': '/dav/principals/', + }, + }, + 'd:status': 'HTTP/1.1 200 OK', + }, { + 'd:prop': { 'd:principal-URL': {}, 'card:addressbook-home-set': {} }, + 'd:status': 'HTTP/1.1 404 Not Found', + }], + }, + { + 'd:href': '/dav/addressbooks/contacts/', + 'd:propstat': [{ + 'd:prop': { + 'd:resourcetype': { + 'd:collection': {}, + 'card:addressbook': {}, + }, + 'd:displayname': 'Contacts', + 'd:getetag': escapeHtml(`"${context.state.user.extra.contacts_revision || 'new'}"`), + 'd:current-user-principal': { + 'd:href': '/dav/principals/', + }, + }, + 'd:status': 'HTTP/1.1 200 OK', + }, { + 'd:prop': { 'd:principal-URL': {}, 'card:addressbook-home-set': {} }, + 'd:status': 'HTTP/1.1 404 Not Found', + }], + }, + ], + }, + 'd:multistatus_attributes': { + 'xmlns:d': 'DAV:', + 'xmlns:s': 'http://sabredav.org/ns', + 'xmlns:card': 'urn:ietf:params:xml:ns:carddav', + 'xmlns:oc': 'http://owncloud.org/ns', + 'xmlns:nc': 'http://nextcloud.org/ns', + }, + }; + + const response = new Response(convertObjectToDavXml(responseBody, true), { + headers: { + 'content-type': 'application/xml; charset=utf-8', + 'dav': DAV_RESPONSE_HEADER, + }, + status: 207, + }); + + if (context.state.session) { + return response; + } + + return createSessionCookie(request, context.state.user, response, true); +}; diff --git a/routes/dav/addressbooks/contacts.tsx b/routes/dav/addressbooks/contacts.tsx new file mode 100644 index 0000000..e34c062 --- /dev/null +++ b/routes/dav/addressbooks/contacts.tsx @@ -0,0 +1,249 @@ +import { Handler } from 'fresh/server.ts'; +import { parse } from 'xml/mod.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { + buildRFC822Date, + convertObjectToDavXml, + DAV_RESPONSE_HEADER, + escapeHtml, + escapeXml, + formatContactToVCard, +} from '/lib/utils.ts'; +import { getAllContacts } from '/lib/data/contacts.ts'; +import { createSessionCookie } from '/lib/auth.ts'; + +interface Data {} + +interface DavResponse { + 'd:href': string; + 'd:propstat': { + 'd:prop': { + 'd:resourcetype'?: { + 'd:collection'?: {}; + 'card:addressbook'?: {}; + }; + 'd:displayname'?: string | {}; + 'card:address-data'?: string; + 'd:getlastmodified'?: string | {}; + 'd:getetag'?: string | {}; + 'd:getcontenttype'?: string | {}; + 'd:getcontentlength'?: number | {}; + 'd:creationdate'?: string | {}; + 'card:addressbook-description'?: string | {}; + 'cs:getctag'?: {}; + 'd:current-user-privilege-set'?: { + 'd:privilege': { + 'd:write-properties'?: {}; + 'd:write'?: {}; + 'd:write-content'?: {}; + 'd:unlock'?: {}; + 'd:bind'?: {}; + 'd:unbind'?: {}; + 'd:write-acl'?: {}; + 'd:read'?: {}; + 'd:read-acl'?: {}; + 'd:read-current-user-privilege-set'?: {}; + }[]; + }; + }; + 'd:status': string; + }[]; +} + +interface DavMultiStatusResponse { + 'd:multistatus': { + 'd:response': DavResponse[]; + }; + 'd:multistatus_attributes': { + 'xmlns:d': string; + 'xmlns:s': string; + 'xmlns:card': string; + 'xmlns:oc': string; + 'xmlns:nc': string; + 'xmlns:cs': string; + }; +} + +interface ResponseBody extends DavMultiStatusResponse {} + +export const handler: Handler = async (request, context) => { + if (!context.state.user) { + return new Response('Unauthorized', { + status: 401, + headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' }, + }); + } + + if (request.method === 'GET') { + return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.'); + } + + if (request.method !== 'PROPFIND' && request.method !== 'REPORT') { + return new Response('Bad Request', { status: 400 }); + } + + const contacts = await getAllContacts(context.state.user.id); + + const requestBody = (await request.clone().text()).toLowerCase(); + + let includeVCard = false; + let includeCollection = true; + const includePrivileges = requestBody.includes('current-user-privilege-set'); + + const filterContactIds = new Set(); + + try { + const parsedDocument = parse(requestBody); + + const multiGetRequest = (parsedDocument['addressbook-multiget'] || parsedDocument['r:addressbook-multiget'] || + parsedDocument['f:addressbook-multiget'] || parsedDocument['d:addressbook-multiget'] || + parsedDocument['r:addressbook-query'] || + parsedDocument['card:addressbook-multiget'] || parsedDocument['card:addressbook-query']) as + | Record + | undefined; + + includeVCard = Boolean(multiGetRequest); + + const requestedHrefs: string[] = (multiGetRequest && (multiGetRequest['href'] || multiGetRequest['d:href'])) || []; + + includeCollection = requestedHrefs.length === 0; + + for (const requestedHref of requestedHrefs) { + const userVCard = requestedHref.split('/').slice(-1).join(''); + const [userId] = userVCard.split('.vcf'); + + if (userId) { + filterContactIds.add(userId); + } + } + } catch (error) { + console.error(`Failed to parse XML`, error); + } + + const filteredContacts = filterContactIds.size > 0 + ? contacts.filter((contact) => filterContactIds.has(contact.id)) + : contacts; + + const parsedContacts = filteredContacts.map((contact) => { + const parsedContact: DavResponse = { + 'd:href': `/dav/addressbooks/contacts/${contact.id}.vcf`, + 'd:propstat': [{ + 'd:prop': { + 'd:getlastmodified': buildRFC822Date(contact.updated_at.toISOString()), + 'd:getetag': escapeHtml(`"${contact.revision}"`), + 'd:getcontenttype': 'text/vcard; charset=utf-8', + 'd:resourcetype': {}, + }, + 'd:status': 'HTTP/1.1 200 OK', + }, { + 'd:prop': { + 'd:displayname': {}, + 'd:getcontentlength': {}, + 'd:creationdate': {}, + 'card:addressbook-description': {}, + 'cs:getctag': {}, + }, + 'd:status': 'HTTP/1.1 404 Not Found', + }], + }; + + if (includeVCard) { + parsedContact['d:propstat'][0]['d:prop']['card:address-data'] = escapeXml(formatContactToVCard([contact])); + } + + if (includePrivileges) { + parsedContact['d:propstat'][0]['d:prop']['d:current-user-privilege-set'] = { + 'd:privilege': [ + { 'd:write-properties': {} }, + { 'd:write': {} }, + { 'd:write-content': {} }, + { 'd:unlock': {} }, + { 'd:bind': {} }, + { 'd:unbind': {} }, + { 'd:write-acl': {} }, + { 'd:read': {} }, + { 'd:read-acl': {} }, + { 'd:read-current-user-privilege-set': {} }, + ], + }; + } + + return parsedContact; + }); + + const responseBody: ResponseBody = { + 'd:multistatus': { + 'd:response': [ + ...parsedContacts, + ], + }, + 'd:multistatus_attributes': { + 'xmlns:d': 'DAV:', + 'xmlns:s': 'http://sabredav.org/ns', + 'xmlns:card': 'urn:ietf:params:xml:ns:carddav', + 'xmlns:oc': 'http://owncloud.org/ns', + 'xmlns:nc': 'http://nextcloud.org/ns', + 'xmlns:cs': 'http://calendarserver.org/ns/', + }, + }; + + if (includeCollection) { + const collectionResponse: DavResponse = { + 'd:href': '/dav/addressbooks/contacts/', + 'd:propstat': [{ + 'd:prop': { + 'd:resourcetype': { + 'd:collection': {}, + 'card:addressbook': {}, + }, + 'd:displayname': 'Contacts', + 'd:getetag': escapeHtml(`"${context.state.user.extra.contacts_revision || 'new'}"`), + }, + 'd:status': 'HTTP/1.1 200 OK', + }, { + 'd:prop': { + 'd:getlastmodified': {}, + 'd:getcontenttype': {}, + 'd:getcontentlength': {}, + 'd:creationdate': {}, + 'card:addressbook-description': {}, + 'cs:getctag': {}, + }, + 'd:status': 'HTTP/1.1 404 Not Found', + }], + }; + + if (includePrivileges) { + collectionResponse['d:propstat'][0]['d:prop']['d:current-user-privilege-set'] = { + 'd:privilege': [ + { 'd:write-properties': {} }, + { 'd:write': {} }, + { 'd:write-content': {} }, + { 'd:unlock': {} }, + { 'd:bind': {} }, + { 'd:unbind': {} }, + { 'd:write-acl': {} }, + { 'd:read': {} }, + { 'd:read-acl': {} }, + { 'd:read-current-user-privilege-set': {} }, + ], + }; + } + responseBody['d:multistatus']['d:response'].unshift(collectionResponse); + } + + const response = new Response(convertObjectToDavXml(responseBody, true), { + headers: { + 'content-type': 'application/xml; charset=utf-8', + 'dav': DAV_RESPONSE_HEADER, + }, + status: 207, + }); + + if (context.state.session) { + return response; + } + + return createSessionCookie(request, context.state.user, response, true); +}; diff --git a/routes/dav/addressbooks/contacts/[contactId].vcf.tsx b/routes/dav/addressbooks/contacts/[contactId].vcf.tsx new file mode 100644 index 0000000..8897137 --- /dev/null +++ b/routes/dav/addressbooks/contacts/[contactId].vcf.tsx @@ -0,0 +1,228 @@ +import { Handler } from 'fresh/server.ts'; +import { parse } from 'xml/mod.ts'; + +import { Contact, FreshContextState } from '/lib/types.ts'; +import { + buildRFC822Date, + convertObjectToDavXml, + DAV_RESPONSE_HEADER, + escapeHtml, + escapeXml, + formatContactToVCard, + parseVCardFromTextContents, +} from '/lib/utils.ts'; +import { createContact, deleteContact, getContact, updateContact } from '/lib/data/contacts.ts'; +import { createSessionCookie } from '/lib/auth.ts'; + +interface Data {} + +interface DavResponse { + 'd:href': string; + 'd:propstat': { + 'd:prop': { + 'd:resourcetype': { + 'd:collection'?: {}; + 'card:addressbook'?: {}; + }; + 'card:address-data'?: string; + 'd:getlastmodified'?: string; + 'd:getetag'?: string; + 'd:getcontenttype'?: string; + }; + 'd:status': string; + }; +} + +interface DavMultiStatusResponse { + 'd:multistatus': { + 'd:response': DavResponse[]; + }; + 'd:multistatus_attributes': { + 'xmlns:d': string; + 'xmlns:s': string; + 'xmlns:card': string; + 'xmlns:oc': string; + 'xmlns:nc': string; + }; +} + +interface ResponseBody extends DavMultiStatusResponse {} + +export const handler: Handler = async (request, context) => { + if (!context.state.user) { + return new Response('Unauthorized', { + status: 401, + headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' }, + }); + } + + if ( + request.method !== 'PROPFIND' && request.method !== 'REPORT' && request.method !== 'GET' && + request.method !== 'PUT' && request.method !== 'DELETE' + ) { + return new Response('Bad Request', { status: 400 }); + } + + const { contactId } = context.params; + + let contact: Contact | null = null; + + try { + contact = await getContact(contactId, context.state.user.id); + } catch (error) { + console.error(error); + } + + if (!contact) { + if (request.method === 'PUT') { + const requestBody = await request.clone().text(); + + const [partialContact] = parseVCardFromTextContents(requestBody); + + if (partialContact.first_name) { + const newContact = await createContact( + context.state.user.id, + partialContact.first_name, + partialContact.last_name || '', + ); + + // Use the sent id for the UID + if (!partialContact.extra?.uid) { + partialContact.extra = { + ...(partialContact.extra || {}), + uid: contactId, + }; + } + + newContact.extra = partialContact.extra!; + + await updateContact(newContact); + + contact = await getContact(newContact.id, context.state.user.id); + + return new Response('Created', { status: 201, headers: { 'etag': `"${contact.revision}"` } }); + } + } + + return new Response('Not found', { status: 404 }); + } + + if (request.method === 'DELETE') { + const clientRevision = request.headers.get('if-match') || request.headers.get('etag'); + + // Don't update outdated data + if (clientRevision && clientRevision !== `"${contact.revision}"`) { + return new Response(null, { status: 204, headers: { 'etag': `"${contact.revision}"` } }); + } + + await deleteContact(contactId, context.state.user.id); + + return new Response(null, { status: 202 }); + } + + if (request.method === 'PUT') { + const clientRevision = request.headers.get('if-match') || request.headers.get('etag'); + + // Don't update outdated data + if (clientRevision && clientRevision !== `"${contact.revision}"`) { + return new Response(null, { status: 204, headers: { 'etag': `"${contact.revision}"` } }); + } + + const requestBody = await request.clone().text(); + + const [partialContact] = parseVCardFromTextContents(requestBody); + + contact = { + ...contact, + ...partialContact, + }; + + await updateContact(contact); + + contact = await getContact(contactId, context.state.user.id); + + return new Response(null, { status: 204, headers: { 'etag': `"${contact.revision}"` } }); + } + + if (request.method === 'GET') { + // Set a UID if there isn't one + if (!contact.extra.uid) { + contact.extra.uid = crypto.randomUUID(); + await updateContact(contact); + + contact = await getContact(contactId, context.state.user.id); + } + + const response = new Response(formatContactToVCard([contact]), { + status: 200, + headers: { 'etag': `"${contact.revision}"` }, + }); + + if (context.state.session) { + return response; + } + + return createSessionCookie(request, context.state.user, response, true); + } + + const requestBody = (await request.clone().text()).toLowerCase(); + + let includeVCard = false; + + try { + const parsedDocument = parse(requestBody); + + const multiGetRequest = (parsedDocument['r:addressbook-multiget'] || parsedDocument['r:addressbook-query'] || + parsedDocument['card:addressbook-multiget'] || parsedDocument['card:addressbook-query']) as + | Record + | undefined; + + includeVCard = Boolean(multiGetRequest); + } catch (error) { + console.error(`Failed to parse XML`, error); + } + + const parsedContact: DavResponse = { + 'd:href': `/dav/addressbooks/contacts/${contact.id}.vcf`, + 'd:propstat': { + 'd:prop': { + 'd:getlastmodified': buildRFC822Date(contact.updated_at.toISOString()), + 'd:getetag': escapeHtml(`"${contact.revision}"`), + 'd:getcontenttype': 'text/vcard; charset=utf-8', + 'd:resourcetype': {}, + }, + 'd:status': 'HTTP/1.1 200 OK', + }, + }; + + if (includeVCard) { + parsedContact['d:propstat']['d:prop']['card:address-data'] = escapeXml(formatContactToVCard([contact])); + } + + const responseBody: ResponseBody = { + 'd:multistatus': { + 'd:response': [parsedContact], + }, + 'd:multistatus_attributes': { + 'xmlns:d': 'DAV:', + 'xmlns:s': 'http://sabredav.org/ns', + 'xmlns:card': 'urn:ietf:params:xml:ns:carddav', + 'xmlns:oc': 'http://owncloud.org/ns', + 'xmlns:nc': 'http://nextcloud.org/ns', + }, + }; + + const response = new Response(convertObjectToDavXml(responseBody, true), { + headers: { + 'content-type': 'application/xml; charset=utf-8', + 'dav': DAV_RESPONSE_HEADER, + }, + status: 207, + }); + + if (context.state.session) { + return response; + } + + return createSessionCookie(request, context.state.user, response, true); +}; diff --git a/routes/dav/files.tsx b/routes/dav/files.tsx new file mode 100644 index 0000000..9f3872e --- /dev/null +++ b/routes/dav/files.tsx @@ -0,0 +1,91 @@ +import { Handler } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils.ts'; +import { createSessionCookie } from '/lib/auth.ts'; + +interface Data {} + +interface DavResponse { + 'd:href': string; + 'd:propstat': { + 'd:prop': { + 'd:resourcetype': { + 'd:collection'?: {}; + }; + 'd:getlastmodified': string; + 'd:getetag': string; + 'd:getcontentlength'?: number; + 'd:getcontenttype'?: string; + }; + 'd:status': string; + }; +} + +interface DavMultiStatusResponse { + 'd:multistatus': { + 'd:response': DavResponse[]; + }; + 'd:multistatus_attributes': { 'xmlns:d': string; 'xmlns:s': string; 'xmlns:oc': string; 'xmlns:nc': string }; +} + +interface ResponseBody extends DavMultiStatusResponse {} + +export const handler: Handler = (request, context) => { + if (!context.state.user) { + return new Response('Unauthorized', { + status: 401, + headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' }, + }); + } + + if (request.method === 'GET') { + return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.'); + } + + if (request.method !== 'PROPFIND') { + return new Response('Bad Request', { status: 400 }); + } + + // TODO: List directories and files in root + + const responseBody: ResponseBody = { + 'd:multistatus': { + 'd:response': [ + { + 'd:href': '/dav/files/', + 'd:propstat': { + 'd:prop': { + 'd:resourcetype': { + 'd:collection': {}, + }, + 'd:getlastmodified': buildRFC822Date('2020-01-01'), + 'd:getetag': escapeHtml(`"fake"`), + }, + 'd:status': 'HTTP/1.1 200 OK', + }, + }, + ], + }, + 'd:multistatus_attributes': { + 'xmlns:d': 'DAV:', + 'xmlns:s': 'http://sabredav.org/ns', + 'xmlns:oc': 'http://owncloud.org/ns', + 'xmlns:nc': 'http://nextcloud.org/ns', + }, + }; + + const response = new Response(convertObjectToDavXml(responseBody, true), { + headers: { + 'content-type': 'application/xml; charset=utf-8', + 'dav': DAV_RESPONSE_HEADER, + }, + status: 207, + }); + + if (context.state.session) { + return response; + } + + return createSessionCookie(request, context.state.user, response, true); +}; diff --git a/routes/dav/index.tsx b/routes/dav/index.tsx new file mode 100644 index 0000000..33366ac --- /dev/null +++ b/routes/dav/index.tsx @@ -0,0 +1,118 @@ +import { Handler } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils.ts'; +import { createSessionCookie } from '/lib/auth.ts'; + +interface Data {} + +interface DavResponse { + 'd:href': string; + 'd:propstat': { + 'd:prop': { + 'd:resourcetype': { + 'd:collection': {}; + }; + 'd:current-user-principal'?: { + 'd:href': string; + }; + 'd:getlastmodified'?: string; + 'd:getetag'?: string; + }; + 'd:status': string; + }; +} + +interface DavMultiStatusResponse { + 'd:multistatus': { + 'd:response': DavResponse[]; + }; + 'd:multistatus_attributes': { 'xmlns:d': string; 'xmlns:s': string; 'xmlns:oc': string; 'xmlns:nc': string }; +} + +interface ResponseBody extends DavMultiStatusResponse {} + +export const handler: Handler = (request, context) => { + if (!context.state.user) { + return new Response('Unauthorized', { + status: 401, + headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' }, + }); + } + + if (request.method === 'GET') { + return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.'); + } + + if (request.method !== 'PROPFIND' && request.method !== 'REPORT') { + return new Response('Bad Request', { status: 400 }); + } + + const responseBody: ResponseBody = { + 'd:multistatus': { + 'd:response': [ + { + 'd:href': '/dav/', + 'd:propstat': { + 'd:prop': { + 'd:resourcetype': { + 'd:collection': {}, + }, + }, + 'd:status': 'HTTP/1.1 200 OK', + }, + }, + { + 'd:href': '/dav/addressbooks/', + 'd:propstat': { + 'd:prop': { + 'd:resourcetype': { + 'd:collection': {}, + }, + 'd:current-user-principal': { + 'd:href': '/dav/principals/', + }, + }, + 'd:status': 'HTTP/1.1 200 OK', + }, + }, + { + 'd:href': '/dav/files/', + 'd:propstat': { + 'd:prop': { + 'd:resourcetype': { + 'd:collection': {}, + }, + 'd:current-user-principal': { + 'd:href': '/dav/principals/', + }, + 'd:getlastmodified': buildRFC822Date('2020-01-01'), + 'd:getetag': escapeHtml(`"fake"`), + }, + 'd:status': 'HTTP/1.1 200 OK', + }, + }, + ], + }, + 'd:multistatus_attributes': { + 'xmlns:d': 'DAV:', + 'xmlns:s': 'http://sabredav.org/ns', + 'xmlns:oc': 'http://owncloud.org/ns', + 'xmlns:nc': 'http://nextcloud.org/ns', + }, + }; + + const response = new Response(convertObjectToDavXml(responseBody, true), { + headers: { + 'content-type': 'application/xml; charset=utf-8', + 'dav': DAV_RESPONSE_HEADER, + }, + status: 207, + }); + + if (context.state.session) { + return response; + } + + return createSessionCookie(request, context.state.user, response, true); +}; diff --git a/routes/dav/principals.tsx b/routes/dav/principals.tsx new file mode 100644 index 0000000..a384a3b --- /dev/null +++ b/routes/dav/principals.tsx @@ -0,0 +1,128 @@ +import { Handler } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { convertObjectToDavXml, DAV_RESPONSE_HEADER } from '/lib/utils.ts'; +import { createSessionCookie } from '/lib/auth.ts'; + +interface Data {} + +interface DavResponse { + 'd:href': string; + 'd:propstat': { + 'd:prop': { + 'd:resourcetype': { + 'd:collection'?: {}; + 'd:principal': {}; + }; + 'd:displayname'?: string; + 'card:addressbook-home-set'?: { + 'd:href': string; + }; + 'd:current-user-principal'?: { + 'd:href': string; + }; + 'd:principal-URL'?: { + 'd:href': string; + }; + }; + 'd:status': string; + }; +} + +interface DavMultiStatusResponse { + 'd:multistatus': { + 'd:response': DavResponse[]; + }; + 'd:multistatus_attributes': { + 'xmlns:d': string; + 'xmlns:s': string; + 'xmlns:cal': string; + 'xmlns:cs': string; + 'xmlns:card': string; + 'xmlns:oc': string; + 'xmlns:nc': string; + }; +} + +interface ResponseBody extends DavMultiStatusResponse {} + +export const handler: Handler = async (request, context) => { + if (!context.state.user) { + return new Response('Unauthorized', { + status: 401, + headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' }, + }); + } + + if (request.method === 'GET') { + return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.'); + } + + if (request.method !== 'PROPFIND' && request.method !== 'REPORT') { + return new Response('Bad Request', { status: 400 }); + } + + const responseBody: ResponseBody = { + 'd:multistatus': { + 'd:response': [], + }, + 'd:multistatus_attributes': { + 'xmlns:d': 'DAV:', + 'xmlns:s': 'http://sabredav.org/ns', + 'xmlns:cal': 'urn:ietf:params:xml:ns:caldav', + 'xmlns:cs': 'http://calendarserver.org/ns/', + 'xmlns:card': 'urn:ietf:params:xml:ns:carddav', + 'xmlns:oc': 'http://owncloud.org/ns', + 'xmlns:nc': 'http://nextcloud.org/ns', + }, + }; + + if (request.method === 'PROPFIND') { + const propResponse: DavResponse = { + 'd:href': '/dav/principals/', + 'd:propstat': { + 'd:prop': { + 'd:resourcetype': { + 'd:collection': {}, + 'd:principal': {}, + }, + 'd:current-user-principal': { + 'd:href': '/dav/principals/', + }, + 'd:principal-URL': { + 'd:href': '/dav/principals/', + }, + }, + 'd:status': 'HTTP/1.1 200 OK', + }, + }; + + const requestBody = (await request.clone().text()).toLowerCase(); + + if (requestBody.includes('displayname')) { + propResponse['d:propstat']['d:prop']['d:displayname'] = `${context.state.user.email}`; + } + + if (requestBody.includes('addressbook-home-set')) { + propResponse['d:propstat']['d:prop']['card:addressbook-home-set'] = { + 'd:href': `/dav/addressbooks/`, + }; + } + + responseBody['d:multistatus']['d:response'].push(propResponse); + } + + const response = new Response(convertObjectToDavXml(responseBody, true), { + headers: { + 'content-type': 'application/xml; charset=utf-8', + 'dav': DAV_RESPONSE_HEADER, + }, + status: 207, + }); + + if (context.state.session) { + return response; + } + + return createSessionCookie(request, context.state.user, response, true); +}; diff --git a/routes/index.tsx b/routes/index.tsx new file mode 100644 index 0000000..ca06a93 --- /dev/null +++ b/routes/index.tsx @@ -0,0 +1,15 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; + +interface Data {} + +export const handler: Handlers = { + GET(request, context) { + if (context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/dashboard` } }); + } + + return new Response('Redirect', { status: 303, headers: { 'Location': '/login' } }); + }, +}; diff --git a/routes/login.tsx b/routes/login.tsx new file mode 100644 index 0000000..d8b25dc --- /dev/null +++ b/routes/login.tsx @@ -0,0 +1,175 @@ +import { Handlers, PageProps } from 'fresh/server.ts'; + +import { generateHash, helpEmail, validateEmail } from '/lib/utils.ts'; +import { createSessionResponse, PASSWORD_SALT } from '/lib/auth.ts'; +import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx'; +import { createVerificationCode, getUserByEmail, updateUser, validateVerificationCode } from '/lib/data/user.ts'; +import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts'; +import { FreshContextState } from '/lib/types.ts'; + +interface Data { + error?: string; + notice?: string; + email?: string; + formData?: FormData; +} + +export const handler: Handlers = { + async GET(request, context) { + if (context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/` } }); + } + + const searchParams = new URL(request.url).searchParams; + + const formData = new FormData(); + let notice = ''; + let email = ''; + + if (searchParams.get('success') === 'signup') { + email = searchParams.get('email') || ''; + formData.set('email', email); + + notice = `You have received a code in your email. Use it to verify your email and login.`; + } + + return await context.render({ notice, email, formData }); + }, + async POST(request, context) { + if (context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/` } }); + } + + const formData = await request.clone().formData(); + const email = getFormDataField(formData, 'email'); + + try { + if (!validateEmail(email)) { + throw new Error(`Invalid email.`); + } + + const password = getFormDataField(formData, 'password'); + + if (password.length < 6) { + throw new Error(`Password is too short.`); + } + + const hashedPassword = await generateHash(`${password}:${PASSWORD_SALT}`, 'SHA-256'); + + const user = await getUserByEmail(email); + + if (!user || user.hashed_password !== hashedPassword) { + throw new Error('Email not found or invalid password.'); + } + + if (!user.extra.is_email_verified) { + const code = getFormDataField(formData, 'verification-code'); + + if (!code) { + const verificationCode = await createVerificationCode(user, user.email, 'email'); + + await sendVerifyEmailEmail(user.email, verificationCode); + + throw new Error('Email not verified. New code sent to verify your email.'); + } else { + await validateVerificationCode(user, user.email, code, 'email'); + + user.extra.is_email_verified = true; + + await updateUser(user); + } + } + + return createSessionResponse(request, user, { urlToRedirectTo: `/` }); + } catch (error) { + console.error(error); + return await context.render({ error: error.toString(), email, formData }); + } + }, +}; + +function formFields(email?: string, showVerificationCode = false) { + const fields: FormField[] = [ + { + name: 'email', + label: 'Email', + type: 'email', + placeholder: 'jane.doe@example.com', + value: email || '', + required: true, + }, + { + name: 'password', + label: 'Password', + type: 'password', + placeholder: 'super-SECRET-passphrase', + required: true, + }, + ]; + + if (showVerificationCode) { + fields.push({ + name: 'verification-code', + label: 'Verification Code', + description: `The verification code to validate your email.`, + type: 'text', + placeholder: '000000', + required: true, + }); + } + + return fields; +} + +export default function Login({ data }: PageProps) { + return ( +
+
+

+ Login +

+ {data?.error + ? ( +
+

Failed to login!

+

{data?.error}

+
+ ) + : null} + {data?.notice + ? ( +
+

Verify your email!

+

{data?.notice}

+
+ ) + : null} + +
+ {formFields(data?.email, data?.notice?.includes('verify your email')).map((field) => + generateFieldHtml(field, data?.formData || new FormData()) + )} +
+ +
+
+ +

Need an account?

+

+ If you still don't have an account,{' '} + + signup + . +

+ +

Need help?

+

+ If you're having any issues or have any questions,{' '} + + please reach out + . +

+
+
+ ); +} diff --git a/routes/logout.tsx b/routes/logout.tsx new file mode 100644 index 0000000..db10747 --- /dev/null +++ b/routes/logout.tsx @@ -0,0 +1,16 @@ +import { Handlers } from 'fresh/server.ts'; + +import { logoutUser } from '/lib/auth.ts'; +import { FreshContextState } from '/lib/types.ts'; + +interface Data {} + +export const handler: Handlers = { + async GET(request, context) { + if (!context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); + } + + return await logoutUser(request); + }, +}; diff --git a/routes/news.tsx b/routes/news.tsx new file mode 100644 index 0000000..9711580 --- /dev/null +++ b/routes/news.tsx @@ -0,0 +1,29 @@ +import { Handlers, PageProps } from 'fresh/server.ts'; + +import { FreshContextState, NewsFeedArticle } from '/lib/types.ts'; +import { getNewsArticles } from '/lib/data/news.ts'; +import Articles from '/islands/news/Articles.tsx'; + +interface Data { + userArticles: NewsFeedArticle[]; +} + +export const handler: Handlers = { + async GET(request, context) { + if (!context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); + } + + const userArticles = await getNewsArticles(context.state.user.id); + + return await context.render({ userArticles }); + }, +}; + +export default function News({ data }: PageProps) { + return ( +
+ +
+ ); +} diff --git a/routes/news/feeds.tsx b/routes/news/feeds.tsx new file mode 100644 index 0000000..c32888b --- /dev/null +++ b/routes/news/feeds.tsx @@ -0,0 +1,29 @@ +import { Handlers, PageProps } from 'fresh/server.ts'; + +import { FreshContextState, NewsFeed } from '/lib/types.ts'; +import { getNewsFeeds } from '/lib/data/news.ts'; +import Feeds from '/islands/news/Feeds.tsx'; + +interface Data { + userFeeds: NewsFeed[]; +} + +export const handler: Handlers = { + async GET(request, context) { + if (!context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); + } + + const userFeeds = await getNewsFeeds(context.state.user.id); + + return await context.render({ userFeeds }); + }, +}; + +export default function FeedsPage({ data }: PageProps) { + return ( +
+ +
+ ); +} diff --git a/routes/remote.php/[davRoute].tsx b/routes/remote.php/[davRoute].tsx new file mode 100644 index 0000000..e479c7a --- /dev/null +++ b/routes/remote.php/[davRoute].tsx @@ -0,0 +1,7 @@ +// Nextcloud/ownCloud mimicry +export function handler(): Response { + return new Response('Redirecting...', { + status: 307, + headers: { Location: '/dav' }, + }); +} diff --git a/routes/settings.tsx b/routes/settings.tsx new file mode 100644 index 0000000..4b7ff69 --- /dev/null +++ b/routes/settings.tsx @@ -0,0 +1,186 @@ +import { Handlers, PageProps } from 'fresh/server.ts'; + +import { Dashboard, FreshContextState } from '/lib/types.ts'; +import { PASSWORD_SALT } from '/lib/auth.ts'; +import { + createVerificationCode, + deleteUser, + getUserByEmail, + updateUser, + validateVerificationCode, +} from '/lib/data/user.ts'; +import { convertFormDataToObject, generateHash, validateEmail } from '/lib/utils.ts'; +import { getFormDataField } from '/lib/form-utils.tsx'; +import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts'; +import Settings, { Action, actionWords } from '/islands/Settings.tsx'; + +interface Data { + error?: { + title: string; + message: string; + }; + notice?: { + title: string; + message: string; + }; + formData: Record; +} + +export const handler: Handlers = { + async GET(request, context) { + if (!context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); + } + + return await context.render(); + }, + async POST(request, context) { + if (!context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); + } + + let action: Action = 'change-email'; + let errorTitle = ''; + let errorMessage = ''; + let successTitle = ''; + let successMessage = ''; + + const formData = await request.clone().formData(); + + try { + const { user } = context.state; + + action = getFormDataField(formData, 'action') as Action; + + if (action !== 'change-email' && action !== 'verify-change-email') { + formData.set('email', user.email); + } + + if ((action === 'change-email' || action === 'verify-change-email')) { + const email = getFormDataField(formData, 'email'); + + if (!validateEmail(email)) { + throw new Error(`Invalid email.`); + } + + if (email === user.email) { + throw new Error(`New email is the same as the current email.`); + } + + const matchingUser = await getUserByEmail(email); + + if (matchingUser) { + throw new Error('Email is already in use.'); + } + + if (action === 'change-email') { + const verificationCode = await createVerificationCode(user, email, 'email'); + + await sendVerifyEmailEmail(email, verificationCode); + + successTitle = 'Verify your email!'; + successMessage = 'You have received a code in your new email. Use it to verify it here.'; + } else { + const code = getFormDataField(formData, 'verification-code'); + + await validateVerificationCode(user, email, code, 'email'); + + user.email = email; + + await updateUser(user); + + successTitle = 'Email updated!'; + successMessage = 'Email updated successfully.'; + } + } else if (action === 'change-password') { + const currentPassword = getFormDataField(formData, 'current-password'); + const newPassword = getFormDataField(formData, 'new-password'); + + if (newPassword.length < 6) { + throw new Error(`New password is too short`); + } + + const hashedCurrentPassword = await generateHash(`${currentPassword}:${PASSWORD_SALT}`, 'SHA-256'); + const hashedNewPassword = await generateHash(`${newPassword}:${PASSWORD_SALT}`, 'SHA-256'); + + if (user.hashed_password !== hashedCurrentPassword) { + throw new Error('Invalid current password.'); + } + + if (hashedCurrentPassword === hashedNewPassword) { + throw new Error(`New password is the same as the current password.`); + } + + user.hashed_password = hashedNewPassword; + + await updateUser(user); + + successTitle = 'Password changed!'; + successMessage = 'Password changed successfully.'; + } else if (action === 'change-dav-password') { + const newDavPassword = getFormDataField(formData, 'new-dav-password'); + + if (newDavPassword.length < 6) { + throw new Error(`New DAV password is too short`); + } + + const hashedNewDavPassword = await generateHash(`${newDavPassword}:${PASSWORD_SALT}`, 'SHA-256'); + + if (user.extra.dav_hashed_password === hashedNewDavPassword) { + throw new Error(`New DAV password is the same as the current password.`); + } + + user.extra.dav_hashed_password = hashedNewDavPassword; + + await updateUser(user); + + successTitle = 'DAV Password changed!'; + successMessage = 'DAV Password changed successfully.'; + } else if (action === 'delete-account') { + const currentPassword = getFormDataField(formData, 'current-password'); + + const hashedCurrentPassword = await generateHash(`${currentPassword}:${PASSWORD_SALT}`, 'SHA-256'); + + if (user.hashed_password !== hashedCurrentPassword) { + throw new Error('Invalid current password.'); + } + + await deleteUser(user.id); + + return new Response('Account deleted successfully', { + status: 303, + headers: { 'location': `/signup?success=delete` }, + }); + } + + const notice = successTitle + ? { + title: successTitle, + message: successMessage, + } + : undefined; + + return await context.render({ + notice, + formData: convertFormDataToObject(formData), + }); + } catch (error) { + console.error(error); + errorMessage = error.toString(); + errorTitle = `Failed to ${actionWords.get(action) || action}!`; + + return await context.render({ + error: { title: errorTitle, message: errorMessage }, + formData: convertFormDataToObject(formData), + }); + } + }, +}; + +export default function Dashboard({ data }: PageProps) { + return ( +
+ +
+ ); +} diff --git a/routes/signup.tsx b/routes/signup.tsx new file mode 100644 index 0000000..d416e78 --- /dev/null +++ b/routes/signup.tsx @@ -0,0 +1,155 @@ +import { Handlers, PageProps } from 'fresh/server.ts'; + +import { generateHash, helpEmail, validateEmail } from '/lib/utils.ts'; +import { PASSWORD_SALT } from '/lib/auth.ts'; +import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx'; +import { createUser, createVerificationCode, getUserByEmail } from '/lib/data/user.ts'; +import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts'; +import { isSignupAllowed } from '/lib/config.ts'; +import { FreshContextState } from '/lib/types.ts'; + +interface Data { + error?: string; + notice?: string; + email?: string; + formData?: FormData; +} + +export const handler: Handlers = { + async GET(request, context) { + if (context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/` } }); + } + + const searchParams = new URL(request.url).searchParams; + + let notice = ''; + + if (searchParams.get('success') === 'delete') { + notice = `Your account and all its data has been deleted.`; + } + + return await context.render({ notice }); + }, + async POST(request, context) { + if (context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/` } }); + } + + const formData = await request.clone().formData(); + const email = getFormDataField(formData, 'email'); + + try { + if (!(await isSignupAllowed())) { + throw new Error(`Signups are not allowed.`); + } + + if (!validateEmail(email)) { + throw new Error(`Invalid email.`); + } + + const password = getFormDataField(formData, 'password'); + + if (password.length < 6) { + throw new Error(`Password is too short.`); + } + + const existingUser = await getUserByEmail(email); + + if (existingUser) { + throw new Error('Email is already in use. Perhaps you want to login instead?'); + } + + const hashedPassword = await generateHash(`${password}:${PASSWORD_SALT}`, 'SHA-256'); + + const user = await createUser(email, hashedPassword); + + const verificationCode = await createVerificationCode(user, user.email, 'email'); + + await sendVerifyEmailEmail(user.email, verificationCode); + + return new Response('Signup successful', { + status: 303, + headers: { 'location': `/login?success=signup&email=${encodeURIComponent(user.email)}` }, + }); + } catch (error) { + console.error(error); + return await context.render({ error: error.toString(), email, formData }); + } + }, +}; + +function formFields(email?: string) { + const fields: FormField[] = [ + { + name: 'email', + label: 'Email', + description: `The email that will be used to login. A code will be sent to it.`, + type: 'email', + placeholder: 'jane.doe@example.com', + value: email || '', + required: true, + }, + { + name: 'password', + label: 'Password', + description: `The password that will be used to login.`, + type: 'password', + placeholder: 'super-SECRET-passphrase', + required: true, + }, + ]; + + return fields; +} + +export default function Signup({ data }: PageProps) { + return ( +
+
+

+ Signup +

+ {data?.error + ? ( +
+

Failed to signup!

+

{data?.error}

+
+ ) + : null} + {data?.notice + ? ( +
+

Success!

+

{data?.notice}

+
+ ) + : null} + +
+ {formFields(data?.email).map((field) => generateFieldHtml(field, data?.formData || new FormData()))} +
+ +
+
+ +

Already have an account?

+

+ If you already have an account,{' '} + + login + . +

+ +

Need help?

+

+ If you're having any issues or have any questions,{' '} + + please reach out + . +

+
+
+ ); +} diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..fba0cfb Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/images/add.svg b/static/images/add.svg new file mode 100644 index 0000000..f4ec049 --- /dev/null +++ b/static/images/add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/calendar.svg b/static/images/calendar.svg new file mode 100644 index 0000000..e875497 --- /dev/null +++ b/static/images/calendar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/check-all.svg b/static/images/check-all.svg new file mode 100644 index 0000000..d2772a7 --- /dev/null +++ b/static/images/check-all.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/check.svg b/static/images/check.svg new file mode 100644 index 0000000..cb75631 --- /dev/null +++ b/static/images/check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/contacts.svg b/static/images/contacts.svg new file mode 100644 index 0000000..4b95e29 --- /dev/null +++ b/static/images/contacts.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/dashboard.svg b/static/images/dashboard.svg new file mode 100644 index 0000000..ddd0456 --- /dev/null +++ b/static/images/dashboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/default.png b/static/images/default.png new file mode 100644 index 0000000..ef3da75 Binary files /dev/null and b/static/images/default.png differ diff --git a/static/images/delete.svg b/static/images/delete.svg new file mode 100644 index 0000000..6ec1cb6 --- /dev/null +++ b/static/images/delete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/favicon.png b/static/images/favicon.png new file mode 100644 index 0000000..f5092b3 Binary files /dev/null and b/static/images/favicon.png differ diff --git a/static/images/favicon.svg b/static/images/favicon.svg new file mode 100644 index 0000000..e1a12c4 --- /dev/null +++ b/static/images/favicon.svg @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/images/files.svg b/static/images/files.svg new file mode 100644 index 0000000..2a52f8e --- /dev/null +++ b/static/images/files.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/left-circle.svg b/static/images/left-circle.svg new file mode 100644 index 0000000..bdf7f3f --- /dev/null +++ b/static/images/left-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/left.svg b/static/images/left.svg new file mode 100644 index 0000000..4f63e2f --- /dev/null +++ b/static/images/left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/loading.svg b/static/images/loading.svg new file mode 100644 index 0000000..3757161 --- /dev/null +++ b/static/images/loading.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/images/logo-white.png b/static/images/logo-white.png new file mode 100644 index 0000000..d33b0e5 Binary files /dev/null and b/static/images/logo-white.png differ diff --git a/static/images/logo-white.svg b/static/images/logo-white.svg new file mode 100644 index 0000000..6d096f1 --- /dev/null +++ b/static/images/logo-white.svg @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/images/logo.png b/static/images/logo.png new file mode 100644 index 0000000..dfe6b60 Binary files /dev/null and b/static/images/logo.png differ diff --git a/static/images/logo.svg b/static/images/logo.svg new file mode 100644 index 0000000..31a66ce --- /dev/null +++ b/static/images/logo.svg @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/images/logomark.svg b/static/images/logomark.svg new file mode 100644 index 0000000..2764c14 --- /dev/null +++ b/static/images/logomark.svg @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/images/logout.svg b/static/images/logout.svg new file mode 100644 index 0000000..749a68d --- /dev/null +++ b/static/images/logout.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/news.svg b/static/images/news.svg new file mode 100644 index 0000000..928fcd3 --- /dev/null +++ b/static/images/news.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/notes.svg b/static/images/notes.svg new file mode 100644 index 0000000..61008e3 --- /dev/null +++ b/static/images/notes.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/photos.svg b/static/images/photos.svg new file mode 100644 index 0000000..99876c8 --- /dev/null +++ b/static/images/photos.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/refresh.svg b/static/images/refresh.svg new file mode 100644 index 0000000..fa63e64 --- /dev/null +++ b/static/images/refresh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/right-circle.svg b/static/images/right-circle.svg new file mode 100644 index 0000000..3a18831 --- /dev/null +++ b/static/images/right-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/right.svg b/static/images/right.svg new file mode 100644 index 0000000..64e93db --- /dev/null +++ b/static/images/right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/save.svg b/static/images/save.svg new file mode 100644 index 0000000..1480fec --- /dev/null +++ b/static/images/save.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/settings.svg b/static/images/settings.svg new file mode 100644 index 0000000..c19df1a --- /dev/null +++ b/static/images/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/tasks.svg b/static/images/tasks.svg new file mode 100644 index 0000000..920d729 --- /dev/null +++ b/static/images/tasks.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000..78a0cca --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,4 @@ +# See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file + +User-agent: * +Disallow: / diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..1b08992 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,75 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +h1 { + @apply text-4xl font-bold; +} + +a { + @apply text-[#51A4FB] no-underline hover:underline font-semibold; +} + +main { + @apply mx-auto max-w-7xl py-6 sm:px-6 lg:px-8 text-white; +} + +form { + @apply px-4 py-8 max-w-screen-md mx-auto lg:min-w-96; +} + +.button { + @apply inline-block rounded text-white bg-[#51A4FB] hover:bg-sky-400 hover:shadow-sm px-4 py-2 !important; +} + +.button-secondary { + @apply inline-block rounded text-white bg-slate-600 hover:text-slate-900 hover:bg-slate-400 hover:shadow-sm px-4 py-2 !important; +} + +.button-danger { + @apply inline-block rounded text-slate-50 bg-red-600 hover:text-slate-900 hover:bg-red-400 hover:shadow-md px-4 py-2 !important; +} + +.input-field { + @apply block w-full p-2 rounded-md text-white bg-slate-700 border-transparent border focus:border-[#51A4FB] focus:bg-slate-700 outline-none focus:ring-0 shadow-sm !important; +} + +.input-field[type="range"] { + @apply w-auto !important; +} + +.input-field:required:invalid:not(:placeholder-shown) { + @apply bg-red-100 !important; +} + +.notification-error { + @apply bg-red-700 rounded text-white px-6 py-8 shadow-md mt-2 mb-6 w-full hover:shadow-lg; +} + +.notification-error h3 { + @apply text-4xl text-white mb-6; +} + +.notification-success { + @apply bg-green-600 rounded text-white px-6 py-8 shadow-md mt-2 mb-6 w-full hover:shadow-lg; +} + +.notification-success h3 { + @apply text-4xl text-white mb-6; +} + +img.white { + filter: invert(100%) sepia(0%) saturate(7500%) hue-rotate(42deg) brightness(99%) contrast(112%); +} + +img.green { + filter: invert(47%) sepia(19%) saturate(6698%) hue-rotate(114deg) brightness(91%) contrast(83%); +} + +img.red { + filter: invert(18%) sepia(45%) saturate(3518%) hue-rotate(337deg) brightness(110%) contrast(101%); +} + +img.gray { + filter: invert(30%) sepia(46%) saturate(356%) hue-rotate(174deg) brightness(90%) contrast(82%); +} diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..4e810be --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,7 @@ +import { Config } from 'tailwindcss'; + +export default { + content: [ + '{routes,islands,components,lib}/**/*.{ts,tsx}', + ], +} satisfies Config;