commit eb08b8ec5d1bf2b28412313580f30e1428275654 Author: hyzen Date: Sun Jun 14 11:40:27 2026 +0200 Init diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is 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. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + 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. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + 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 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. Use with the GNU Affero General Public License. + + 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 Affero 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 special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU 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 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 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 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 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + 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 GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/admin.php b/admin.php new file mode 100644 index 0000000..7201713 --- /dev/null +++ b/admin.php @@ -0,0 +1,208 @@ + false, 'message' => 'Server configuration error.']); + exit; +} +$env = []; +foreach (file($env_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { + if (str_starts_with(trim($line), '#') || !str_contains($line, '=')) continue; + [$k, $v] = explode('=', $line, 2); + $env[trim($k)] = trim($v); +} + +define('DB_HOST', $env['DB_HOST'] ?? '127.0.0.1'); +define('DB_PORT', $env['DB_PORT'] ?? '5432'); +define('DB_NAME', $env['DB_NAME'] ?? 'freedoms4'); +define('DB_USER', $env['DB_USER'] ?? 'freedoms4_user'); +define('DB_PASS', $env['DB_PASS'] ?? ''); + +define('SESSION_NAME', 'f4_session'); +define('SESSION_SECURE', true); +define('SESSION_SAMESITE', 'None'); +define('MAX_BODY_BYTES', 4096); +define('ADMIN_USER', 'hyzen'); + +// ── CORS ── +$origin = $_SERVER['HTTP_ORIGIN'] ?? ''; +$allowed_origins = ['https://freedoms4.org', 'https://www.freedoms4.org']; + +if ($origin && !in_array($origin, $allowed_origins, true)) { + http_response_code(403); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['success' => false, 'message' => 'Forbidden.']); + exit; +} +if ($origin) { + header('Access-Control-Allow-Origin: ' . $origin); + header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); + header('Access-Control-Allow-Headers: Content-Type'); + header('Access-Control-Allow-Credentials: true'); +} +if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { + http_response_code(204); + exit; +} + +// ── Helpers ── +function json_out(array $data, int $status = 200): never { + http_response_code($status); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($data); + exit; +} + +function db_connect(): PDO { + static $pdo = null; + if ($pdo !== null) return $pdo; + $dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', DB_HOST, DB_PORT, DB_NAME); + try { + $pdo = new PDO($dsn, DB_USER, DB_PASS, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } catch (PDOException $e) { + error_log('admin.php DB error: ' . $e->getMessage()); + json_out(['success' => false, 'message' => 'Database unavailable.'], 503); + } + return $pdo; +} + +// ── Session + admin check ── +if (session_status() === PHP_SESSION_NONE) { + session_name(SESSION_NAME); + session_set_cookie_params([ + 'lifetime' => 0, + 'path' => '/', + 'secure' => SESSION_SECURE, + 'httponly' => true, + 'samesite' => SESSION_SAMESITE, + ]); + session_start(); +} + +if (empty($_SESSION['username']) || $_SESSION['username'] !== ADMIN_USER) { + json_out(['success' => false, 'message' => 'Unauthorized.'], 403); +} + +// ════════════════════════════════════════════════════════════════════════════ +// GET: list users +// ════════════════════════════════════════════════════════════════════════════ +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + $pdo = db_connect(); + $stmt = $pdo->prepare( + "SELECT id, username, email, blocked, created_at + FROM users + ORDER BY CASE WHEN username = :admin THEN 0 ELSE 1 END, + LOWER(username) ASC, + username ASC" + ); + $stmt->execute([':admin' => ADMIN_USER]); + json_out(['success' => true, 'users' => $stmt->fetchAll()]); +} + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + json_out(['success' => false, 'message' => 'Method not allowed.'], 405); +} + +$content_length = (int)($_SERVER['CONTENT_LENGTH'] ?? 0); +if ($content_length > MAX_BODY_BYTES) { + json_out(['success' => false, 'message' => 'Request too large.'], 413); +} +$body = json_decode(fread(fopen('php://input', 'r'), MAX_BODY_BYTES), true); +if (!is_array($body)) { + json_out(['success' => false, 'message' => 'Invalid request body.'], 400); +} + +$action = $body['action'] ?? ''; +$user_id = (int)($body['user_id'] ?? 0); + +if ($user_id === 0) { + json_out(['success' => false, 'message' => 'user_id is required.']); +} + +$pdo = db_connect(); + +// Prevent admin from acting on themselves +$stmt = $pdo->prepare('SELECT username FROM users WHERE id = :id LIMIT 1'); +$stmt->execute([':id' => $user_id]); +$target = $stmt->fetch(); +if (!$target) { + json_out(['success' => false, 'message' => 'User not found.'], 404); +} +if ($target['username'] === ADMIN_USER) { + json_out(['success' => false, 'message' => 'Cannot modify the admin account.'], 403); +} + +// ════════════════════════════════════════════════════════════════════════════ +// Block user +// ════════════════════════════════════════════════════════════════════════════ +if ($action === 'block_user') { + $pdo->prepare('UPDATE users SET blocked = TRUE WHERE id = :id') + ->execute([':id' => $user_id]); + + // Backup virtual mail entry so user can no longer receive mail + $safe_user = escapeshellarg($target['username']); + shell_exec("sudo /usr/local/bin/email-block block {$safe_user} 2>&1"); + + json_out(['success' => true]); +} + +// ════════════════════════════════════════════════════════════════════════════ +// Unblock user +// ════════════════════════════════════════════════════════════════════════════ +if ($action === 'unblock_user') { + $pdo->prepare('UPDATE users SET blocked = FALSE WHERE id = :id') + ->execute([':id' => $user_id]); + + // Restore virtual mail entry + $safe_user = escapeshellarg($target['username']); + shell_exec("sudo /usr/local/bin/email-block unblock {$safe_user} 2>&1"); + + json_out(['success' => true]); +} + +// ════════════════════════════════════════════════════════════════════════════ +// Delete user +// ════════════════════════════════════════════════════════════════════════════ +if ($action === 'delete_user') { + $safe_user = escapeshellarg($target['username']); + + // Run the same message cleanup as the block action before removing the + // mailbox and account configuration permanently. + $block_output = []; + $block_status = 0; + exec("sudo /usr/local/bin/email-block block {$safe_user} 2>&1", $block_output, $block_status); + if ($block_status !== 0) { + error_log("email-block failed during delete for {$target['username']}: " . implode("\n", $block_output)); + json_out(['success' => false, 'message' => 'Failed to clear the user email history.'], 500); + } + + $delete_output = []; + $delete_status = 0; + exec("sudo /usr/local/bin/email-delete {$safe_user} 2>&1", $delete_output, $delete_status); + if ($delete_status !== 0) { + error_log("email-delete failed for {$target['username']}: " . implode("\n", $delete_output)); + } + + $pdo->prepare('DELETE FROM users WHERE id = :id') + ->execute([':id' => $user_id]); + json_out(['success' => true]); +} + +json_out(['success' => false, 'message' => 'Unknown action.'], 400); diff --git a/auth-passwdfile.conf.ext b/auth-passwdfile.conf.ext new file mode 100644 index 0000000..ed7950e --- /dev/null +++ b/auth-passwdfile.conf.ext @@ -0,0 +1,9 @@ +passdb { + driver = passwd-file + args = scheme=SHA512-CRYPT username_format=%n /var/dovecot/users +} +userdb { + driver = passwd-file + args = username_format=%n /var/dovecot/users + default_fields = uid=vmail gid=mail home=/var/vmail/%n@freedoms4.org/maildir +} diff --git a/auth.php b/auth.php new file mode 100644 index 0000000..d1612c7 --- /dev/null +++ b/auth.php @@ -0,0 +1,600 @@ + false, 'message' => 'Server configuration error.']); + exit; +} +$env = []; +foreach (file($env_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { + if (str_starts_with(trim($line), '#') || !str_contains($line, '=')) continue; + [$k, $v] = explode('=', $line, 2); + $env[trim($k)] = trim($v); +} + +define('DB_HOST', $env['DB_HOST'] ?? '127.0.0.1'); +define('DB_PORT', $env['DB_PORT'] ?? '5432'); +define('DB_NAME', $env['DB_NAME'] ?? 'freedoms4'); +define('DB_USER', $env['DB_USER'] ?? 'freedoms4_user'); +define('DB_PASS', $env['DB_PASS'] ?? ''); +define('PROSODY_DB_NAME', $env['PROSODY_DB_NAME'] ?? 'prosody'); +define('PROSODY_DB_USER', $env['PROSODY_DB_USER'] ?? 'prosody'); +define('PROSODY_DB_PASS', $env['PROSODY_DB_PASS'] ?? ''); +define('PROSODY_HOST', $env['PROSODY_HOST'] ?? 'freedoms4.org'); + +// ── Constants ── +define('SESSION_NAME', 'f4_session'); +define('SESSION_SECURE', true); +define('SESSION_SAMESITE', 'None'); +define('SESSION_TTL', 86400); // 24 hours + +define('OTP_FROM', 'no-reply@freedoms4.org'); +define('OTP_TTL', 600); // 10 minutes +define('OTP_MAX_DAY', 5); // max OTPs per email per 24 h +define('OTP_MAX_FAILS', 10); // max failed OTP attempts per IP before lockout +define('MAX_BODY_BYTES', 4096); + +// ── CORS ── +$origin = $_SERVER['HTTP_ORIGIN'] ?? ''; +$allowed_origins = ['https://freedoms4.org', 'https://www.freedoms4.org']; + +if ($origin && !in_array($origin, $allowed_origins, true)) { + http_response_code(403); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['success' => false, 'message' => 'Forbidden.']); + exit; +} + +if ($origin) { + header('Access-Control-Allow-Origin: ' . $origin); + header('Access-Control-Allow-Methods: POST, OPTIONS'); + header('Access-Control-Allow-Headers: Content-Type'); + header('Access-Control-Allow-Credentials: true'); +} + +if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { + http_response_code(204); + exit; +} + +// ── Helpers ── +function json_out(array $data, int $status = 200): never { + http_response_code($status); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($data); + exit; +} + +function start_session(): void { + if (session_status() === PHP_SESSION_NONE) { + session_name(SESSION_NAME); + session_set_cookie_params([ + 'lifetime' => 0, + 'path' => '/', + 'secure' => SESSION_SECURE, + 'httponly' => true, + 'samesite' => SESSION_SAMESITE, + ]); + session_start(); + } +} + +function db_connect(): PDO { + static $pdo = null; + if ($pdo !== null) return $pdo; + $dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', DB_HOST, DB_PORT, DB_NAME); + try { + $pdo = new PDO($dsn, DB_USER, DB_PASS, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } catch (PDOException $e) { + error_log('DB connection failed: ' . $e->getMessage()); + json_out(['success' => false, 'message' => 'Database unavailable.'], 503); + } + return $pdo; +} + +function prosody_db_connect(): PDO { + static $pdo = null; + if ($pdo !== null) return $pdo; + $dsn = sprintf('pgsql:host=127.0.0.1;port=5432;dbname=%s', PROSODY_DB_NAME); + // Throws on failure — caller must catch and handle + $pdo = new PDO($dsn, PROSODY_DB_USER, PROSODY_DB_PASS, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + return $pdo; +} + +// Rate limiting via APCu (per-IP, persistent across requests within the window) +// Falls back to session-based if APCu is unavailable. +function rate_limit(string $ip, int $max, int $window): bool { + $key = 'rl_' . hash('sha256', $ip); + if (function_exists('apcu_fetch')) { + $count = apcu_fetch($key, $ok); + if (!$ok) { + apcu_store($key, 1, $window); + return true; + } + if ($count >= $max) return false; + apcu_inc($key); + return true; + } + // Session fallback + $now = time(); + $rl = $_SESSION[$key] ?? ['count' => 0, 'window_start' => $now]; + if ($now - $rl['window_start'] > $window) { + $rl = ['count' => 0, 'window_start' => $now]; + } + $rl['count']++; + $_SESSION[$key] = $rl; + return $rl['count'] <= $max; +} + +// OTP failure tracking via APCu (per-IP lockout after OTP_MAX_FAILS attempts) +function otp_fail_count(string $ip): int { + $key = 'otpfail_' . hash('sha256', $ip); + if (function_exists('apcu_fetch')) { + $count = apcu_fetch($key, $ok); + return $ok ? (int)$count : 0; + } + return $_SESSION[$key] ?? 0; +} + +function otp_fail_increment(string $ip): void { + $key = 'otpfail_' . hash('sha256', $ip); + if (function_exists('apcu_inc')) { + if (!apcu_fetch($key)) { + apcu_store($key, 1, 3600); + } else { + apcu_inc($key); + } + return; + } + $_SESSION[$key] = ($_SESSION[$key] ?? 0) + 1; +} + +function otp_fail_reset(string $ip): void { + $key = 'otpfail_' . hash('sha256', $ip); + if (function_exists('apcu_delete')) { + apcu_delete($key); + return; + } + unset($_SESSION[$key]); +} + +function create_xmpp_account(string $username, string $password): bool { + try { + $pdo = prosody_db_connect(); + $host = PROSODY_HOST; + $now = time(); + + // Never overwrite an existing account + $stmt = $pdo->prepare( + "SELECT 1 FROM prosody WHERE host = :h AND \"user\" = :u AND store = 'accounts' LIMIT 1" + ); + $stmt->execute([':h' => $host, ':u' => $username]); + if ($stmt->fetch()) { + return true; + } + + // Derive SCRAM-SHA-1 keys + $salt = sprintf( + '%08x-%04x-%04x-%04x-%012x', + random_int(0, 0xffffffff), + random_int(0, 0xffff), + random_int(0x4000, 0x4fff), + random_int(0x8000, 0xbfff), + random_int(0, 0xffffffffffff) + ); + $iterations = 10000; + $salted_pw = hash_pbkdf2('sha1', $password, $salt, $iterations, 0, true); + $client_key = hash_hmac('sha1', 'Client Key', $salted_pw, true); + $stored_key = sha1($client_key); + $server_key = hash_hmac('sha1', 'Server Key', $salted_pw, false); + + $insert = $pdo->prepare( + "INSERT INTO prosody (host, \"user\", store, key, type, value) + VALUES (:h, :u, 'accounts', :k, :t, :v) + ON CONFLICT (host, \"user\", store, key) DO UPDATE SET type = EXCLUDED.type, value = EXCLUDED.value" + ); + + $rows = [ + ['salt', 'string', $salt], + ['iteration_count', 'number', (string)$iterations], + ['stored_key', 'string', $stored_key], + ['server_key', 'string', $server_key], + ['created', 'number', (string)$now], + ['updated', 'number', (string)$now], + ]; + + $pdo->beginTransaction(); + foreach ($rows as [$key, $type, $value]) { + $insert->execute([':h' => $host, ':u' => $username, ':k' => $key, ':t' => $type, ':v' => $value]); + } + $pdo->commit(); + + return true; + } catch (Exception $e) { + error_log("create_xmpp_account failed for {$username}: " . $e->getMessage()); + if (isset($pdo) && $pdo->inTransaction()) { + $pdo->rollBack(); + } + return false; + } +} + +// ── Only accept POST ── +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + json_out(['success' => false, 'message' => 'Method not allowed.'], 405); +} + +// ── Request body size cap ── +$content_length = (int)($_SERVER['CONTENT_LENGTH'] ?? 0); +if ($content_length > MAX_BODY_BYTES) { + json_out(['success' => false, 'message' => 'Request too large.'], 413); +} + +$raw = fread(fopen('php://input', 'r'), MAX_BODY_BYTES + 1); +if (strlen($raw) > MAX_BODY_BYTES) { + json_out(['success' => false, 'message' => 'Request too large.'], 413); +} + +$body = json_decode($raw, true); +if (!is_array($body)) { + json_out(['success' => false, 'message' => 'Invalid request body.'], 400); +} +$action = $body['action'] ?? ''; + +// ── Session + rate limiting ── +start_session(); +$now = time(); +$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; + +if (!rate_limit($ip, 20, 900)) { + json_out(['success' => false, 'message' => 'Too many requests. Please wait a few minutes.'], 429); +} + +// ── 24 h session expiry ── +if (!empty($_SESSION['user_id'])) { + $last_seen = $_SESSION['last_seen'] ?? 0; + if ($now - $last_seen > SESSION_TTL) { + if (!empty($_SESSION['db_session_id'])) { + try { + db_connect()->prepare( + "UPDATE user_sessions SET logged_out_at = NOW() WHERE id = :sid AND logged_out_at IS NULL" + )->execute([':sid' => $_SESSION['db_session_id']]); + } catch (Exception $e) {} + } + session_destroy(); + start_session(); + json_out(['success' => false, 'message' => 'Session expired. Please log in again.'], 401); + } + if ($now - $last_seen > 60) { + $_SESSION['last_seen'] = $now; + if (!empty($_SESSION['db_session_id'])) { + try { + db_connect()->prepare( + "UPDATE user_sessions SET last_seen_at = NOW() WHERE id = :sid" + )->execute([':sid' => $_SESSION['db_session_id']]); + } catch (Exception $e) {} + } + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// Send OTP +// ════════════════════════════════════════════════════════════════════════════ +if ($action === 'send_otp') { + $username = trim($body['username'] ?? ''); + $email = trim($body['email'] ?? ''); + if ($username !== '' && !preg_match('/^[a-zA-Z0-9_\-]{3,32}$/', $username)) { + json_out(['success' => false, 'message' => 'Username must be 3-32 characters: letters, numbers, _ or -.']); + } + if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + json_out(['success' => false, 'message' => 'A valid email address is required.']); + } + + $pdo = db_connect(); + + $stmt = $pdo->prepare('SELECT blocked FROM users WHERE username = :u OR email = :e ORDER BY blocked DESC LIMIT 1'); + $stmt->execute([':u' => $username, ':e' => $email]); + $user = $stmt->fetch(); + if ($user && ($user['blocked'] === true || $user['blocked'] === 't')) { + json_out(['success' => false, 'message' => 'This account has been blocked.']); + } + if ($user) { + json_out(['success' => false, 'message' => 'Username or email is already taken.']); + } + + $stmt = $pdo->prepare( + "SELECT COUNT(*) FROM email_otps WHERE email = :e AND created_at > NOW() - INTERVAL '24 hours'" + ); + $stmt->execute([':e' => $email]); + if ((int)$stmt->fetchColumn() >= OTP_MAX_DAY) { + json_out(['success' => false, 'message' => 'Too many OTP requests for this email. Please try again tomorrow.'], 429); + } + + $pdo->prepare("DELETE FROM email_otps WHERE email = :e AND used = FALSE")->execute([':e' => $email]); + + $otp = str_pad((string)random_int(0, 999999), 6, '0', STR_PAD_LEFT); + $otp_hash = password_hash($otp, PASSWORD_BCRYPT, ['cost' => 10]); + + $pdo->prepare( + "INSERT INTO email_otps (email, otp_hash, expires_at, used) + VALUES (:e, :h, NOW() + INTERVAL '10 minutes', FALSE)" + )->execute([':e' => $email, ':h' => $otp_hash]); + + $subject = 'Freedoms4 sign up OTP'; + $message = + "Hello,\n\n" . + "Your OTP to create a freedoms4.org account is:\n\n" . + "{$otp}\n\n" . + "This code expires in 10 minutes. Do not share it with anyone.\n\n" . + "If you did not request this, you can safely ignore this email.\n\n" . + "freedoms4.org"; + + $headers = implode("\r\n", [ + 'From: freedoms4.org <' . OTP_FROM . '>', + 'Reply-To: ' . OTP_FROM, + 'Cc: hyzen@freedoms4.org', + 'X-Mailer: PHP/' . PHP_VERSION, + 'MIME-Version: 1.0', + 'Content-Type: text/plain; charset=UTF-8', + ]); + + if (!mail($email, $subject, $message, $headers)) { + error_log("OTP mail() failed for: {$email}"); + json_out(['success' => false, 'message' => 'Failed to send OTP email. Please try again.'], 500); + } + + json_out(['success' => true, 'message' => 'OTP sent. Please check your inbox (and spam folder).']); +} + +// ════════════════════════════════════════════════════════════════════════════ +// Login +// ════════════════════════════════════════════════════════════════════════════ +if ($action === 'login') { + $username = trim($body['username'] ?? ''); + $password = $body['password'] ?? ''; + + if ($username === '' || $password === '') { + json_out(['success' => false, 'message' => 'Username and password are required.']); + } + + $pdo = db_connect(); + $stmt = $pdo->prepare('SELECT id, username, password_hash, blocked FROM users WHERE username = :u LIMIT 1'); + $stmt->execute([':u' => $username]); + $user = $stmt->fetch(); + + $hash = $user['password_hash'] ?? '$2y$12$invalidhashpadding000000000000000000000000000000000000000'; + if (!$user || !password_verify($password, $hash)) { + json_out(['success' => false, 'message' => 'Invalid username or password.']); + } + + if ($user && ($user['blocked'] === true || $user['blocked'] === 't')) { + json_out(['success' => false, 'message' => 'This account has been blocked.']); + } + + + session_regenerate_id(true); + $_SESSION['user_id'] = $user['id']; + $_SESSION['username'] = $user['username']; + $_SESSION['last_seen'] = $now; + + $ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512); + $session_hash = hash('sha256', session_id()); + $stmt = $pdo->prepare( + "INSERT INTO user_sessions (user_id, session_id, logged_in_at, last_seen_at, ip_address, user_agent) + VALUES (:uid, :sid, NOW(), NOW(), :ip, :ua)" + ); + $stmt->execute([':uid' => $user['id'], ':sid' => $session_hash, ':ip' => $ip, ':ua' => $ua]); + $_SESSION['db_session_id'] = $pdo->lastInsertId(); + + json_out(['success' => true, 'redirect' => '/']); +} + +// ════════════════════════════════════════════════════════════════════════════ +// Signup + XMPP + Email accounts creation +// ════════════════════════════════════════════════════════════════════════════ +if ($action === 'signup') { + $username = trim($body['username'] ?? ''); + $email = trim($body['email'] ?? ''); + $password = $body['password'] ?? ''; + $otp = trim($body['otp'] ?? ''); + $terms_agreed = !empty($body['terms_agreed']); + + if ($username === '') { + json_out(['success' => false, 'message' => 'Username is required.']); + } + if (!preg_match('/^[a-zA-Z0-9_\-]{3,32}$/', $username)) { + json_out(['success' => false, 'message' => 'Username must be 3-32 characters: letters, numbers, _ or -.']); + } + if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + json_out(['success' => false, 'message' => 'A valid email address is required.']); + } + if (strlen($password) < 8) { + json_out(['success' => false, 'message' => 'Password must be at least 8 characters.']); + } + if ($otp === '' || !preg_match('/^\d{6}$/', $otp)) { + json_out(['success' => false, 'message' => 'A 6-digit verification code is required.']); + } + if (!$terms_agreed) { + json_out(['success' => false, 'message' => 'You must agree to the terms and conditions.']); + } + + // OTP brute-force lockout + if (otp_fail_count($ip) >= OTP_MAX_FAILS) { + json_out(['success' => false, 'message' => 'Too many failed attempts. Please request a new code.'], 429); + } + + $pdo = db_connect(); + + $stmt = $pdo->prepare('SELECT blocked FROM users WHERE username = :u OR email = :e ORDER BY blocked DESC LIMIT 1'); + $stmt->execute([':u' => $username, ':e' => $email]); + $user = $stmt->fetch(); + if ($user && ($user['blocked'] === true || $user['blocked'] === 't')) { + json_out(['success' => false, 'message' => 'This account has been blocked.']); + } + if ($user) { + json_out(['success' => false, 'message' => 'Username or email is already taken.']); + } + + // Verify OTP + $stmt = $pdo->prepare( + "SELECT id, otp_hash FROM email_otps + WHERE email = :e AND used = FALSE AND expires_at > NOW() + ORDER BY created_at DESC LIMIT 1" + ); + $stmt->execute([':e' => $email]); + $otp_row = $stmt->fetch(); + + if (!$otp_row || !password_verify($otp, $otp_row['otp_hash'])) { + otp_fail_increment($ip); + json_out(['success' => false, 'message' => 'Invalid or expired verification code.']); + } + + // Valid OTP — reset failure counter + otp_fail_reset($ip); + + // Check uniqueness + $stmt = $pdo->prepare('SELECT 1 FROM users WHERE username = :u OR email = :e LIMIT 1'); + $stmt->execute([':u' => $username, ':e' => $email]); + if ($stmt->fetch()) { + json_out(['success' => false, 'message' => 'Username or email is already taken.']); + } + + // Create freedoms4 account + mark OTP used + $pdo->beginTransaction(); + try { + $hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]); + $pdo->prepare( + 'INSERT INTO users (username, email, password_hash, terms_agreed, created_at) VALUES (:u, :e, :h, :t, NOW())' + )->execute([':u' => $username, ':e' => $email, ':h' => $hash, ':t' => $terms_agreed ? 'true' : 'false']); + + $pdo->prepare("UPDATE email_otps SET used = TRUE WHERE id = :id") + ->execute([':id' => $otp_row['id']]); + + $pdo->commit(); + } catch (PDOException $e) { + $pdo->rollBack(); + error_log('signup transaction failed: ' . $e->getMessage()); + json_out(['success' => false, 'message' => 'Account creation failed. Please try again.'], 500); + } + + // Regenerate session after successful signup (#5) + session_regenerate_id(true); + + // Create XMPP account + if (!create_xmpp_account($username, $password)) { + error_log("XMPP account creation failed for new user: {$username}"); + // Don't fail the signup — user account exists, XMPP can be fixed manually + } + + // ── Create Dovecot virtual email account ── + // Sudoers: www-data ALL=(root) NOPASSWD: /usr/local/bin/email-account-create + $safe_user = escapeshellarg($username); + $safe_pw = escapeshellarg($password); + $email_out = shell_exec("sudo /usr/local/bin/email-account-create {$safe_user} {$safe_pw} 2>&1"); + if (!in_array(trim($email_out ?? ''), ['created', 'exists', 'system-user'])) { + error_log("email-account-create failed for {$username}: " . ($email_out ?? 'null')); + // Don't fail the signup — email can be fixed manually + } + + // Welcome email + $welcome_headers = implode("\r\n", [ + 'From: hyzen ', + 'Reply-To: hyzen@freedoms4.org', + 'Cc: hyzen@freedoms4.org', + 'X-Mailer: PHP/' . PHP_VERSION, + 'MIME-Version: 1.0', + 'Content-Type: text/plain; charset=UTF-8', + ]); + + $welcome_message = + "Hi {$username},\n\n" . + "Welcome aboard!\n\n" . + "Thank you for signing up.\n\n" . + "Your credentials:\n\n" . + "XMPP JID: {$username}@freedoms4.org\n" . + "Email ID: {$username}@freedoms4.org\n\n" . + "Passwords: Use the same password that you used during registration.\n\n" . + "If you have any questions, I'm here to help:\n" . + "Email and XMPP : hyzen@freedoms4.org\n" . + "IRC/Liberachat: hyzen, #freedoms4\n\n" . + "Best regards,\n" . + "hyzen, freedoms4.org."; + + if (!mail($email, "Welcome to freedoms4.org", $welcome_message, $welcome_headers)) { + error_log("Welcome mail() failed for: {$email}"); + } + + json_out(['success' => true]); +} + +// ════════════════════════════════════════════════════════════════════════════ +// Check session +// ════════════════════════════════════════════════════════════════════════════ +if ($action === 'check_session') { + if (empty($_SESSION['user_id'])) { + json_out(['valid' => false]); + } + + // Verify user still exists in DB + try { + $pdo = db_connect(); + $stmt = $pdo->prepare('SELECT 1 FROM users WHERE id = :id LIMIT 1'); + $stmt->execute([':id' => $_SESSION['user_id']]); + if (!$stmt->fetch()) { + // User deleted — destroy session + $_SESSION = []; + session_destroy(); + json_out(['valid' => false]); + } + } catch (Exception $e) { + // DB unavailable — don't force logout, just report invalid so frontend can retry + json_out(['valid' => false, 'db_error' => true]); + } + + json_out(['valid' => true, 'username' => $_SESSION['username']]); +} + +// ════════════════════════════════════════════════════════════════════════════ +// Logout +// ════════════════════════════════════════════════════════════════════════════ +// Logout +// ════════════════════════════════════════════════════════════════════════════ +if ($action === 'logout') { + if (!empty($_SESSION['db_session_id'])) { + try { + db_connect()->prepare( + "UPDATE user_sessions SET logged_out_at = NOW() WHERE id = :sid AND logged_out_at IS NULL" + )->execute([':sid' => $_SESSION['db_session_id']]); + } catch (Exception $e) {} + } + + $_SESSION = []; + if (ini_get('session.use_cookies')) { + $p = session_get_cookie_params(); + setcookie(session_name(), '', time() - 42000, + $p['path'], $p['domain'], $p['secure'], $p['httponly']); + } + session_destroy(); + json_out(['success' => true, 'redirect' => '/']); +} + +json_out(['success' => false, 'message' => 'Unknown action.'], 400); diff --git a/backend.freedoms4.org b/backend.freedoms4.org new file mode 100644 index 0000000..aa55529 --- /dev/null +++ b/backend.freedoms4.org @@ -0,0 +1,58 @@ +server { + listen 80; + listen [::]:80; + server_name backend.freedoms4.org; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name backend.freedoms4.org; + + ssl_certificate /etc/letsencrypt/live/backend.freedoms4.org/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/backend.freedoms4.org/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + root /var/www/freedoms4/api; + + location = /auth.php { + limit_except POST OPTIONS { + deny all; + } + fastcgi_pass unix:/run/php/php8.2-fpm.sock; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + } + + location = /comments.php { + limit_except GET POST OPTIONS { + deny all; + } + fastcgi_pass unix:/run/php/php8.2-fpm.sock; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + } + + location = /admin.php { + limit_except GET POST OPTIONS { + deny all; + } + fastcgi_pass unix:/run/php/php8.2-fpm.sock; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + } + + + location / { + deny all; + } +} diff --git a/comments.php b/comments.php new file mode 100644 index 0000000..a7643f0 --- /dev/null +++ b/comments.php @@ -0,0 +1,378 @@ + false, 'message' => 'Server configuration error.']); + exit; +} +$env = []; +foreach (file($env_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { + if (str_starts_with(trim($line), '#') || !str_contains($line, '=')) continue; + [$k, $v] = explode('=', $line, 2); + $env[trim($k)] = trim($v); +} + +define('DB_HOST', $env['DB_HOST'] ?? '127.0.0.1'); +define('DB_PORT', $env['DB_PORT'] ?? '5432'); +define('DB_NAME', $env['DB_NAME'] ?? 'freedoms4'); +define('DB_USER', $env['DB_USER'] ?? 'freedoms4_user'); +define('DB_PASS', $env['DB_PASS'] ?? ''); + +define('SESSION_NAME', 'f4_session'); +define('SESSION_SECURE', true); +define('SESSION_SAMESITE', 'None'); +define('MAX_BODY_BYTES', 8192); +define('MAX_COMMENT_LEN', 2000); + +// ── CORS ── +$origin = $_SERVER['HTTP_ORIGIN'] ?? ''; +$allowed_origins = ['https://freedoms4.org', 'https://www.freedoms4.org']; + +if ($origin && !in_array($origin, $allowed_origins, true)) { + http_response_code(403); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['success' => false, 'message' => 'Forbidden.']); + exit; +} +if ($origin) { + header('Access-Control-Allow-Origin: ' . $origin); + header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); + header('Access-Control-Allow-Headers: Content-Type'); + header('Access-Control-Allow-Credentials: true'); +} +if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { + http_response_code(204); + exit; +} + +// ── Helpers ── +function json_out(array $data, int $status = 200): never { + http_response_code($status); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($data); + exit; +} + +function db_connect(): PDO { + static $pdo = null; + if ($pdo !== null) return $pdo; + $dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', DB_HOST, DB_PORT, DB_NAME); + try { + $pdo = new PDO($dsn, DB_USER, DB_PASS, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } catch (PDOException $e) { + error_log('comments.php DB error: ' . $e->getMessage()); + json_out(['success' => false, 'message' => 'Database unavailable.'], 503); + } + return $pdo; +} + +function start_session(): void { + if (session_status() === PHP_SESSION_NONE) { + session_name(SESSION_NAME); + session_set_cookie_params([ + 'lifetime' => 0, + 'path' => '/', + 'secure' => SESSION_SECURE, + 'httponly' => true, + 'samesite' => SESSION_SAMESITE, + ]); + session_start(); + } +} + +/** + * Send comment notification emails. + * + * $type 'new_comment' | 'new_reply' + * $actor username of the person who wrote the comment/reply + * $body the comment/reply text + * $post_id the post slug/path (used as human-readable context) + * $notify_user ['username' => ..., 'email' => ...] | null — commenter being replied to + */ +function send_notification(string $type, string $actor, string $body, string $post_id, ?array $notify_user): void { + $from = 'no-reply@freedoms4.org'; + $headers = implode("\r\n", [ + 'From: freedoms4.org <' . $from . '>', + 'Reply-To: ' . $from, + 'X-Mailer: PHP/' . PHP_VERSION, + 'MIME-Version: 1.0', + 'Content-Type: text/plain; charset=UTF-8', + ]); + + $post_url = 'https://freedoms4.org' . trim($post_id, '"\''); + + if ($type === 'new_reply') { + // ── Reply notification ── + // Always notify hyzen (unless hyzen is the one replying) + $reply_subject = "You have a new reply from {$actor}"; + $reply_body = + "You have a new reply from {$actor}:\n\n" . + "{$body}\n\n" . + "Post: {$post_url}\n\n" . + "freedoms4.org"; + + // Notify the commenter being replied to (their registered email + @freedoms4.org), + // unless they are hyzen (handled separately below) + if ($notify_user && $notify_user['username'] !== 'hyzen') { + // Send to registered email + if (!empty($notify_user['email'])) { + @mail($notify_user['email'], $reply_subject, $reply_body, $headers); + } + // Send to their @freedoms4.org address + $site_email = $notify_user['username'] . '@freedoms4.org'; + @mail($site_email, $reply_subject, $reply_body, $headers); + } + + // Notify hyzen for all replies (unless hyzen is the replier) + if ($actor !== 'hyzen') { + // If hyzen is being replied to, use the reply subject; otherwise use new-comment subject + if ($notify_user && $notify_user['username'] === 'hyzen') { + @mail('hyzen@freedoms4.org', $reply_subject, $reply_body, $headers); + } else { + // hyzen gets a "new reply" notice even when it's not on their own comment + $hyzen_subject = "A new comment from {$actor}"; + $hyzen_body = + "A new comment from {$actor}:\n\n" . + "{$body}\n\n" . + "Post: {$post_url}\n\n" . + "freedoms4.org"; + @mail('hyzen@freedoms4.org', $hyzen_subject, $hyzen_body, $headers); + } + } + + } else { + // ── New top-level comment notification (hyzen only) ── + if ($actor === 'hyzen') return; // hyzen commenting on their own site — skip + $subject = "A new comment from {$actor}"; + $msg = + "A new comment from {$actor}:\n\n" . + "{$body}\n\n" . + "Post: {$post_url}\n\n" . + "freedoms4.org"; + @mail('hyzen@freedoms4.org', $subject, $msg, $headers); + } +} + +function logged_in_user(): ?array { + if (empty($_SESSION['user_id']) || empty($_SESSION['username'])) return null; + // Verify the user still exists in the DB (handles deleted accounts / wiped DB) + try { + $pdo = db_connect(); + $stmt = $pdo->prepare('SELECT blocked FROM users WHERE id = :id LIMIT 1'); + $stmt->execute([':id' => (int)$_SESSION['user_id']]); + $row = $stmt->fetch(); + if (!$row) { + $_SESSION = []; + session_destroy(); + return null; + } + if ($row['blocked'] === true || $row['blocked'] === 't') { + return null; + } + } catch (Exception $e) { + // DB unavailable — treat as logged-out to be safe + return null; + } + return ['id' => (int)$_SESSION['user_id'], 'username' => $_SESSION['username']]; +} + +// ── GET: fetch comments ── +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + $post_id = trim($_GET['post_id'] ?? ''); + if ($post_id === '') { + json_out(['success' => false, 'message' => 'post_id is required.'], 400); + } + + start_session(); + $viewer = logged_in_user(); + $is_admin = $viewer && $viewer['username'] === 'hyzen'; + + $pdo = db_connect(); + $stmt = $pdo->prepare( + "SELECT c.id, c.post_id, c.parent_id, c.user_id, COALESCE(u.username, c.username, '[deleted user]') AS username, + c.body, c.created_at, c.deleted, c.deleted_by + FROM comments c + LEFT JOIN users u ON u.id = c.user_id + WHERE c.post_id = :pid + ORDER BY c.created_at ASC" + ); + $stmt->execute([':pid' => $post_id]); + $rows = $stmt->fetchAll(); + + // Build tree: top-level comments with nested replies + $top = []; + $index = []; + foreach ($rows as $row) { + if ($row['deleted']) { + $row['body'] = null; + $row['deleted_label'] = $row['deleted_by'] === 'admin' ? 'deleted by admin' : 'deleted by user'; + } else { + $row['deleted_label'] = null; + } + $row['replies'] = []; + $row['is_own'] = $viewer && (int)$row['user_id'] === $viewer['id']; + $index[$row['id']] = $row; + } + foreach ($index as $id => &$node) { + if ($node['parent_id'] === null) { + $top[$id] = &$node; + } else { + $index[$node['parent_id']]['replies'][$id] = &$node; + } + } + unset($node); + + json_out(['success' => true, 'comments' => array_values($top), 'logged_in' => $viewer !== null, 'username' => $viewer['username'] ?? null, 'is_admin' => $is_admin]); +} + +// ── POST actions ── +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + json_out(['success' => false, 'message' => 'Method not allowed.'], 405); +} + +$content_length = (int)($_SERVER['CONTENT_LENGTH'] ?? 0); +if ($content_length > MAX_BODY_BYTES) { + json_out(['success' => false, 'message' => 'Request too large.'], 413); +} +$raw = fread(fopen('php://input', 'r'), MAX_BODY_BYTES + 1); +$body = json_decode($raw, true); +if (!is_array($body)) { + json_out(['success' => false, 'message' => 'Invalid request body.'], 400); +} + +start_session(); +$user = logged_in_user(); +if (!$user) { + json_out(['success' => false, 'message' => 'You must be logged in to comment.'], 401); +} + +$action = $body['action'] ?? ''; + +// ── POST: add comment or reply ── +if ($action === 'post' || $action === 'reply') { + $post_id = trim($body['post_id'] ?? ''); + $text = trim($body['body'] ?? ''); + $parent_id = isset($body['parent_id']) ? (int)$body['parent_id'] : null; + + if ($post_id === '') { + json_out(['success' => false, 'message' => 'post_id is required.']); + } + if ($text === '') { + json_out(['success' => false, 'message' => 'Comment cannot be empty.']); + } + if (strlen($text) > MAX_COMMENT_LEN) { + json_out(['success' => false, 'message' => 'Comment is too long (max 2000 characters).']); + } + + $pdo = db_connect(); + + // Rate limit: max 1 comment per user per minute (hyzen is exempt) + if ($user['username'] !== 'hyzen') { + $rate_stmt = $pdo->prepare( + "SELECT COUNT(*) FROM comments + WHERE user_id = :uid AND created_at > NOW() - INTERVAL '1 minute'" + ); + $rate_stmt->execute([':uid' => $user['id']]); + if ((int)$rate_stmt->fetchColumn() >= 1) { + json_out(['success' => false, 'message' => 'You are posting too fast. Please wait a moment.'], 429); + } + } + + // Validate parent exists and belongs to same post + if ($parent_id !== null) { + $stmt = $pdo->prepare("SELECT id FROM comments WHERE id = :pid AND post_id = :post AND deleted = FALSE LIMIT 1"); + $stmt->execute([':pid' => $parent_id, ':post' => $post_id]); + if (!$stmt->fetch()) { + json_out(['success' => false, 'message' => 'Parent comment not found.'], 404); + } + } + + $stmt = $pdo->prepare( + "INSERT INTO comments (post_id, parent_id, user_id, username, body, created_at, deleted) + VALUES (:post, :parent, :uid, :username, :body, NOW(), FALSE) + RETURNING id, created_at" + ); + $stmt->execute([ + ':post' => $post_id, + ':parent' => $parent_id, + ':uid' => $user['id'], + ':username' => $user['username'], + ':body' => $text, + ]); + $row = $stmt->fetch(); + + // ── Email notifications ── + if ($parent_id !== null) { + // It's a reply — find the parent comment's author for notification + $parent_stmt = $pdo->prepare( + "SELECT c.user_id, u.username, u.email + FROM comments c JOIN users u ON u.id = c.user_id + WHERE c.id = :pid LIMIT 1" + ); + $parent_stmt->execute([':pid' => $parent_id]); + $parent_author = $parent_stmt->fetch() ?: null; + send_notification('new_reply', $user['username'], $text, $post_id, $parent_author); + } else { + // Top-level comment + send_notification('new_comment', $user['username'], $text, $post_id, null); + } + + json_out(['success' => true, 'id' => $row['id'], 'created_at' => $row['created_at']]); +} + +// ── POST: delete comment (own) or any comment (admin) ── +if ($action === 'delete') { + $comment_id = (int)($body['comment_id'] ?? 0); + if ($comment_id === 0) { + json_out(['success' => false, 'message' => 'comment_id is required.']); + } + + $is_admin = $user['username'] === 'hyzen'; + + $pdo = db_connect(); + + // Check if the comment belongs to the deleter + $owner_stmt = $pdo->prepare("SELECT user_id FROM comments WHERE id = :id LIMIT 1"); + $owner_stmt->execute([':id' => $comment_id]); + $comment_row = $owner_stmt->fetch(PDO::FETCH_ASSOC); + $is_own = $comment_row && (int)$comment_row['user_id'] === (int)$user['id']; + + $deleted_by = $is_own ? 'user' : 'admin'; + + if ($is_admin) { + $stmt = $pdo->prepare( + "UPDATE comments SET deleted = TRUE, body = NULL, deleted_by = :by + WHERE id = :id AND deleted = FALSE" + ); + $stmt->execute([':id' => $comment_id, ':by' => $deleted_by]); + } else { + $stmt = $pdo->prepare( + "UPDATE comments SET deleted = TRUE, body = NULL, deleted_by = :by + WHERE id = :id AND user_id = :uid AND deleted = FALSE" + ); + $stmt->execute([':id' => $comment_id, ':uid' => $user['id'], ':by' => $deleted_by]); + } + + if ($stmt->rowCount() === 0) { + json_out(['success' => false, 'message' => 'Comment not found or not yours.'], 403); + } + json_out(['success' => true]); +} + +json_out(['success' => false, 'message' => 'Unknown action.'], 400); diff --git a/email-account-create.sh b/email-account-create.sh new file mode 100755 index 0000000..e7bb237 --- /dev/null +++ b/email-account-create.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# /usr/local/bin/email-account-create +# Called by www-data via sudo to add a virtual mail user to /var/dovecot/users +# Usage: sudo /usr/local/bin/email-account-create + +set -e + +USERNAME="$1" +PASSWORD="$2" +USERS_FILE="/var/dovecot/users" +VMAIL_UID=$(id -u vmail) +VMAIL_GID=$(id -g vmail) +MAILDIR="/var/vmail/${USERNAME}@freedoms4.org/maildir" + +if [[ -z "$USERNAME" || -z "$PASSWORD" ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +# Validate username +if ! [[ "$USERNAME" =~ ^[a-zA-Z0-9_-]{1,32}$ ]]; then + echo "Invalid username" >&2 + exit 2 +fi + +# Check if already exists in passwd-file +if grep -q "^${USERNAME}:" "${USERS_FILE}" 2>/dev/null; then + echo "exists" + exit 0 +fi + +# Skip if this is an existing system user — they have their own mailbox +if id "${USERNAME}" &>/dev/null; then + echo "system-user" + exit 0 +fi + +# Hash the password using SHA512-CRYPT (Dovecot compatible) +HASHED=$(doveadm pw -s SHA512-CRYPT -p "$PASSWORD") + +# Append to users file +echo "${USERNAME}:${HASHED}:${VMAIL_UID}:${VMAIL_GID}::${MAILDIR}::" >> "${USERS_FILE}" + +# Create maildir structure +mkdir -p "${MAILDIR}" +chown -R vmail:mail "/var/vmail/${USERNAME}@freedoms4.org" +chmod -R 700 "/var/vmail/${USERNAME}@freedoms4.org" + +# Register this user in the per-user transport map so Postfix routes +# inbound mail to the Dovecot LDA (system users are not in this map +# and continue to receive via normal local delivery). +VTRANSPORT_FILE="/etc/postfix/virtual_transport" +if ! grep -q "^${USERNAME}@freedoms4.org" "${VTRANSPORT_FILE}" 2>/dev/null; then + echo "${USERNAME}@freedoms4.org dovecot" >> "${VTRANSPORT_FILE}" + postmap "${VTRANSPORT_FILE}" +fi + +echo "created" diff --git a/email-block.sh b/email-block.sh new file mode 100755 index 0000000..dc16bc6 --- /dev/null +++ b/email-block.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# /usr/local/bin/email-block +# Block or unblock a virtual mail user by moving their entry in/out of the users file. +# Usage: sudo /usr/local/bin/email-block + +ACTION="$1" +USERNAME="$2" +USERS_FILE="/var/dovecot/users" +BACKUP_FILE="/var/dovecot/users.blocked" +MAIL_ROOT="/var/vmail/${USERNAME}@freedoms4.org" +VTRANSPORT_FILE="/etc/postfix/virtual_transport" +ADDRESS="${USERNAME}@freedoms4.org" + +if [[ -z "$ACTION" || -z "$USERNAME" ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +if ! [[ "$USERNAME" =~ ^[a-zA-Z0-9_-]{1,32}$ ]]; then + echo "Invalid username" >&2 + exit 2 +fi + +# Skip system users — they authenticate via PAM, not passwd-file +if id "${USERNAME}" &>/dev/null; then + echo "system-user" + exit 0 +fi + +if [[ "$ACTION" == "block" ]]; then + # Check if entry exists in users file + if ! grep -q "^${USERNAME}:" "${USERS_FILE}" 2>/dev/null; then + echo "not-found" + exit 0 + fi + # Move entry to backup file + grep "^${USERNAME}:" "${USERS_FILE}" >> "${BACKUP_FILE}" + sed -i "/^${USERNAME}:/d" "${USERS_FILE}" + # Remove Postfix transport entry so new mail is no longer delivered + if [[ -f "${VTRANSPORT_FILE}" ]]; then + sed -i "/^${ADDRESS}[[:space:]]/d" "${VTRANSPORT_FILE}" + postmap "${VTRANSPORT_FILE}" 2>/dev/null || true + fi + # Delete existing messages while keeping mailbox folders intact + if [[ -d "${MAIL_ROOT}" ]]; then + find "${MAIL_ROOT}" -type d \( -name cur -o -name new -o -name tmp \) -print0 | + while IFS= read -r -d '' maildir_part; do + find "${maildir_part}" -mindepth 1 -maxdepth 1 -type f -delete + done + fi + echo "blocked" + +elif [[ "$ACTION" == "unblock" ]]; then + # Check if entry exists in backup file + if ! grep -q "^${USERNAME}:" "${BACKUP_FILE}" 2>/dev/null; then + echo "not-found" + exit 0 + fi + # Restore entry to users file + grep "^${USERNAME}:" "${BACKUP_FILE}" >> "${USERS_FILE}" + sed -i "/^${USERNAME}:/d" "${BACKUP_FILE}" + # Restore Postfix transport entry so new mail is delivered again + if [[ -f "${VTRANSPORT_FILE}" ]] && ! grep -q "^${ADDRESS}[[:space:]]" "${VTRANSPORT_FILE}" 2>/dev/null; then + echo "${ADDRESS} dovecot" >> "${VTRANSPORT_FILE}" + postmap "${VTRANSPORT_FILE}" 2>/dev/null || true + fi + echo "unblocked" + +else + echo "Invalid action: $ACTION" >&2 + exit 3 +fi diff --git a/email-delete.sh b/email-delete.sh new file mode 100755 index 0000000..f5ae39d --- /dev/null +++ b/email-delete.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# /usr/local/bin/email-delete +# Permanently delete a virtual mail user and their mailbox. +# Usage: sudo /usr/local/bin/email-delete + +set -e + +USERNAME="$1" +USERS_FILE="/var/dovecot/users" +BACKUP_FILE="/var/dovecot/users.blocked" +MAIL_ROOT="/var/vmail/${USERNAME}@freedoms4.org" +VTRANSPORT_FILE="/etc/postfix/virtual_transport" + +if [[ -z "$USERNAME" ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +if ! [[ "$USERNAME" =~ ^[a-zA-Z0-9_-]{1,32}$ ]]; then + echo "Invalid username" >&2 + exit 2 +fi + +# Skip system users — they authenticate via PAM, not passwd-file +if id "${USERNAME}" &>/dev/null; then + echo "system-user" + exit 0 +fi + +# Remove active/blocked Dovecot passwd-file entries +sed -i "/^${USERNAME}:/d" "${USERS_FILE}" 2>/dev/null || true +sed -i "/^${USERNAME}:/d" "${BACKUP_FILE}" 2>/dev/null || true + +# Remove per-user Postfix transport route +if [[ -f "${VTRANSPORT_FILE}" ]]; then + sed -i "/^${USERNAME}@freedoms4\.org[[:space:]]/d" "${VTRANSPORT_FILE}" + postmap "${VTRANSPORT_FILE}" 2>/dev/null || true +fi + +# Delete existing messages using the same Maildir cleanup as the block action +if [[ -d "${MAIL_ROOT}" ]]; then + find "${MAIL_ROOT}" -type d \( -name cur -o -name new -o -name tmp \) -print0 | + while IFS= read -r -d '' maildir_part; do + find "${maildir_part}" -mindepth 1 -maxdepth 1 -type f -delete + done +fi + +# Permanently remove the virtual mailbox directory +rm -rf "${MAIL_ROOT}" + +echo "deleted" diff --git a/full-setup.sh b/full-setup.sh new file mode 100755 index 0000000..43ef648 --- /dev/null +++ b/full-setup.sh @@ -0,0 +1,587 @@ +#!/bin/bash +# full-setup.sh — freedoms4 backend automation script + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +info() { echo -e "${BLUE}[INFO]${NC} $1"; } +success() { echo -e "${GREEN}[OK]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; } + +# Config (edit these before running) +DB_NAME="" +DB_USER="" +DB_PASS="" +PROSODY_DB_USER="" +PROSODY_DB_PASS="" # must match /etc/prosody/prosody.cfg.lua +DOMAIN="" +CERTBOT_EMAIL="" +API_DIR="" +ENV_FILE="" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +OTP_FROM="" # for example: no-reply@freedoms4.org + +# Must run as root +if [[ $EUID -ne 0 ]]; then + error "Please run as root: sudo bash full-setup.sh" +fi + +echo "" +echo -e "${BLUE}================================================${NC}" +echo -e "${BLUE} freedoms4 backend setup${NC}" +echo -e "${BLUE}================================================${NC}" +echo "" + +# ── STEP 1 PostgreSQL ── +info "Checking PostgreSQL..." + +if command -v psql &>/dev/null; then + success "PostgreSQL is already installed." +else + info "Installing PostgreSQL..." + apt update -qq + apt install -y postgresql postgresql-contrib + systemctl enable --now postgresql + success "PostgreSQL installed and started." +fi + +if ! systemctl is-active --quiet postgresql; then + systemctl start postgresql +fi +success "PostgreSQL is running." + +# ── STEP 2 Database & user ── +info "Setting up database and user..." + +if (cd /tmp && sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='${DB_USER}'" | grep -q 1); then + warn "DB user '${DB_USER}' already exists — resetting password to ensure it matches." + (cd /tmp && sudo -u postgres psql -c "ALTER USER ${DB_USER} WITH PASSWORD '${DB_PASS}';") +else + (cd /tmp && sudo -u postgres psql -c "CREATE USER ${DB_USER} WITH PASSWORD '${DB_PASS}';") + success "Created DB user '${DB_USER}'." +fi + +if (cd /tmp && sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" | grep -q 1); then + warn "Database '${DB_NAME}' already exists, skipping." +else + (cd /tmp && sudo -u postgres psql -c "CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};") + success "Created database '${DB_NAME}'." +fi + +(cd /tmp && sudo -u postgres psql -d "${DB_NAME}") << SQL +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(32) NOT NULL UNIQUE, + email VARCHAR(254) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + blocked BOOLEAN NOT NULL DEFAULT FALSE, + terms_agreed BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Migration: add terms_agreed for existing installs +ALTER TABLE users ADD COLUMN IF NOT EXISTS terms_agreed BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE INDEX IF NOT EXISTS idx_users_username ON users (username); +CREATE INDEX IF NOT EXISTS idx_users_email ON users (email); + +REVOKE ALL ON TABLE users FROM PUBLIC; +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE users TO ${DB_USER}; +GRANT USAGE, SELECT ON SEQUENCE users_id_seq TO ${DB_USER}; + +-- OTP table for email verification during sign-up +CREATE TABLE IF NOT EXISTS email_otps ( + id BIGSERIAL PRIMARY KEY, + email VARCHAR(254) NOT NULL, + otp_hash VARCHAR(255) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used BOOLEAN NOT NULL DEFAULT FALSE, + otp_token VARCHAR(64), + token_expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_email_otps_email ON email_otps (email); +CREATE INDEX IF NOT EXISTS idx_email_otps_token ON email_otps (otp_token); + +REVOKE ALL ON TABLE email_otps FROM PUBLIC; +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE email_otps TO ${DB_USER}; +GRANT USAGE, SELECT ON SEQUENCE email_otps_id_seq TO ${DB_USER}; + +-- Session tracking table +CREATE TABLE IF NOT EXISTS user_sessions ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_id VARCHAR(128) NOT NULL UNIQUE, + logged_in_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + logged_out_at TIMESTAMPTZ, + ip_address VARCHAR(45), + user_agent VARCHAR(512) +); + +CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions (user_id); +CREATE INDEX IF NOT EXISTS idx_user_sessions_session_id ON user_sessions (session_id); + +REVOKE ALL ON TABLE user_sessions FROM PUBLIC; +GRANT SELECT, INSERT, UPDATE ON TABLE user_sessions TO ${DB_USER}; +GRANT USAGE, SELECT ON SEQUENCE user_sessions_id_seq TO ${DB_USER}; + +-- Comments table +CREATE TABLE IF NOT EXISTS comments ( + id BIGSERIAL PRIMARY KEY, + post_id VARCHAR(512) NOT NULL, + parent_id BIGINT REFERENCES comments(id) ON DELETE SET NULL, + user_id BIGINT REFERENCES users(id) ON DELETE SET NULL, + username VARCHAR(32), + body TEXT, + deleted_by VARCHAR(16), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted BOOLEAN NOT NULL DEFAULT FALSE +); + +ALTER TABLE comments ADD COLUMN IF NOT EXISTS username VARCHAR(32); +UPDATE comments c +SET username = u.username +FROM users u +WHERE c.user_id = u.id AND c.username IS NULL; + +CREATE INDEX IF NOT EXISTS idx_comments_post_id ON comments (post_id); +CREATE INDEX IF NOT EXISTS idx_comments_parent_id ON comments (parent_id); + +REVOKE ALL ON TABLE comments FROM PUBLIC; +GRANT SELECT, INSERT, UPDATE ON TABLE comments TO ${DB_USER}; +GRANT USAGE, SELECT ON SEQUENCE comments_id_seq TO ${DB_USER}; +SQL + + +# ── STEP 2b Grant freedoms4_user access to Prosody DB ── +info "Granting freedoms4_user access to prosody database..." + +(cd /tmp && sudo -u postgres psql -d prosody -c "GRANT CONNECT ON DATABASE prosody TO ${DB_USER};") +(cd /tmp && sudo -u postgres psql -d prosody -c "GRANT SELECT, INSERT, UPDATE ON TABLE prosody TO ${DB_USER};") +success "freedoms4_user can read/write prosody table." + + +# ── STEP 3 Block port 5432 via ufw ── +info "Blocking port 5432 externally via ufw..." + +if command -v ufw &>/dev/null; then + if ufw status | head -1 | grep -q "active"; then + ufw deny 5432/tcp + success "ufw: port 5432 blocked — PostgreSQL is localhost-only." + else + ufw --force enable + ufw deny 5432/tcp + success "ufw enabled and port 5432 blocked." + fi +else + warn "ufw not found — please manually ensure port 5432 is not publicly exposed." +fi + +# ── STEP 4 PHP 8.2 fpm + pgsql ── +info "Installing PHP 8.2 fpm and pgsql extension..." + +apt install -y php8.2-fpm php8.2-pgsql php8.2-apcu + +phpenmod -v 8.2 pgsql 2>/dev/null || true +phpenmod -v 8.2 pdo_pgsql 2>/dev/null || true +phpenmod -v 8.2 apcu 2>/dev/null || true + +systemctl enable --now php8.2-fpm +systemctl restart php8.2-fpm + +if [[ ! -S /run/php/php8.2-fpm.sock ]]; then + error "fpm socket not found at /run/php/php8.2-fpm.sock" +fi +success "php8.2-fpm ready." + +# ── STEP 5 Mail server check ── +info "Checking mail server (Postfix) for OTP delivery..." + +if ! command -v postfix &>/dev/null; then + warn "Postfix binary not found. Installing..." + apt install -y postfix + # Ensure it is configured as 'Internet Site' for freedoms4.org + debconf-set-selections <<< "postfix postfix/main_mailer_type select Internet Site" + debconf-set-selections <<< "postfix postfix/mailname string freedoms4.org" + dpkg-reconfigure -f noninteractive postfix + success "Postfix installed." +fi + +if ! systemctl is-active --quiet postfix; then + systemctl start postfix + success "Postfix started." +else + success "Postfix is running." +fi + +# Ensure PHP's sendmail_path points to Postfix's sendmail binary +PHP_INI_FPM="/etc/php/8.2/fpm/php.ini" +SENDMAIL_PATH=$(php8.2 -r "echo ini_get('sendmail_path');" 2>/dev/null || true) +if [[ "$SENDMAIL_PATH" != *"sendmail"* ]]; then + warn "sendmail_path may not be set — adding to ${PHP_INI_FPM}" + if ! grep -q "^sendmail_path" "${PHP_INI_FPM}" 2>/dev/null; then + echo 'sendmail_path = "/usr/sbin/sendmail -t -i"' >> "${PHP_INI_FPM}" + systemctl restart php8.2-fpm + success "sendmail_path set and php8.2-fpm restarted." + fi +else + success "sendmail_path is configured: ${SENDMAIL_PATH}" +fi + +# Configure Postfix myhostname / myorigin if not already set +POSTFIX_MAIN="/etc/postfix/main.cf" +if ! grep -q "^myhostname\s*=\s*freedoms4.org" "${POSTFIX_MAIN}" 2>/dev/null; then + info "Setting Postfix myhostname = freedoms4.org ..." + postconf -e "myhostname = freedoms4.org" + postconf -e "myorigin = freedoms4.org" + systemctl reload postfix + success "Postfix myhostname/myorigin updated." +fi + +# ── Configure Postfix to use Dovecot SASL for SMTP AUTH (so virtual users can send) ── +info "Configuring Postfix to use Dovecot SASL for SMTP submission..." + +# Install libsasl2 if needed +apt install -y libsasl2-modules 2>/dev/null || true + +postconf -e "smtpd_sasl_type = dovecot" +postconf -e "smtpd_sasl_path = private/auth" +postconf -e "smtpd_sasl_auth_enable = yes" +postconf -e "smtpd_sasl_security_options = noanonymous" +postconf -e "smtpd_sasl_local_domain = \$myhostname" +postconf -e "broken_sasl_auth_clients = yes" +postconf -e "smtpd_recipient_restrictions = permit_sasl_authenticated, permit_mynetworks, reject_unauth_destination" + +# ── Configure Postfix virtual mailbox delivery (Dovecot LDA) ── +info "Configuring Postfix virtual mailbox delivery via transport_maps..." + +# freedoms4.org stays in mydestination so system users (hyzen etc.) keep +# working via local delivery. For site-created virtual users, we use +# transport_maps on a per-address basis to route them to Dovecot LDA. +# +# The critical piece: by default, Postfix checks local_recipient_maps for +# any domain in mydestination and rejects unknown recipients before ever +# consulting transport_maps. We override local_recipient_maps to only list +# actual Unix system accounts (passwd file). Virtual users are not in passwd, +# so Postfix finds no match, skips the reject, and falls through to +# transport_maps where their dovecot entry routes them correctly. +postconf -e "local_recipient_maps =" + +postconf -e "dovecot_destination_recipient_limit = 1" + +# Initialise the per-user transport map (entries added by email-account-create) +VTRANSPORT_FILE="/etc/postfix/virtual_transport" +if [[ ! -f "${VTRANSPORT_FILE}" ]]; then + touch "${VTRANSPORT_FILE}" + success "Created empty ${VTRANSPORT_FILE}." +fi +postmap "${VTRANSPORT_FILE}" +postconf -e "transport_maps = hash:${VTRANSPORT_FILE}" +success "transport_maps initialised." + +# Add dovecot LDA transport to master.cf if not already there. +# Use dovecot-lda (the correct binary name on Debian/Ubuntu). +MASTER_CF="/etc/postfix/master.cf" +if ! grep -q "^dovecot" "${MASTER_CF}"; then + cat >> "${MASTER_CF}" << 'MASTER' +dovecot unix - n n - - pipe + flags=DRhu user=vmail:mail argv=/usr/lib/dovecot/dovecot-lda -f ${sender} -d ${recipient} +MASTER + success "Dovecot LDA transport added to master.cf." +fi + +# Enable Dovecot auth-userdb socket for Postfix SASL. +# Use a separate drop-in file instead of injecting into 10-master.conf +# to avoid sed portability issues and keep changes isolated. +DOVECOT_POSTFIX_CONF="/etc/dovecot/conf.d/99-postfix-auth.conf" +if [[ ! -f "${DOVECOT_POSTFIX_CONF}" ]]; then + cat > "${DOVECOT_POSTFIX_CONF}" << 'DOVECOTCONF' +# Dovecot SASL socket for Postfix SMTP AUTH +# Added by full-setup.sh — remove this file to undo +service auth { + unix_listener /var/spool/postfix/private/auth { + mode = 0660 + user = postfix + group = postfix + } +} +DOVECOTCONF + success "Created Dovecot Postfix-auth drop-in at ${DOVECOT_POSTFIX_CONF}." +else + success "Dovecot Postfix-auth drop-in already exists." +fi + +systemctl reload postfix +systemctl reload dovecot +success "Postfix SASL + virtual mailbox delivery configured." + +# Quick test: send a mail from no-reply@freedoms4.org to itself (appears in /var/mail or mail queue) +info "Sending a Postfix self-test email..." +echo "Postfix OTP relay self-test from full-setup.sh" \ + | mail -s "freedoms4 mail test" \ + -a "From: ${OTP_FROM}" \ + root 2>/dev/null \ + && success "Test email queued (check /var/mail/root or 'mailq')." \ + || warn "mail command not available for self-test — install mailutils if needed." + +# ── STEP 6 Generate env file + deploy auth.php ── +info "Creating credentials env file at ${ENV_FILE}..." + +# Write env file +mkdir -p "$(dirname ${ENV_FILE})" +cat > "${ENV_FILE}" << ENV +# /etc/freedoms4/auth.env — generated by full-setup.sh on $(date) +# Do NOT commit to version control. +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_NAME=${DB_NAME} +DB_USER=${DB_USER} +DB_PASS=${DB_PASS} +PROSODY_DB_NAME=prosody +PROSODY_DB_USER=${PROSODY_DB_USER} +PROSODY_DB_PASS=${PROSODY_DB_PASS} +PROSODY_HOST=freedoms4.org +ENV + +chown root:www-data "${ENV_FILE}" +chmod 640 "${ENV_FILE}" +success "Env file written and secured (root:www-data 640)." + +info "Deploying auth.php to ${API_DIR}..." + +if [[ ! -f "${SCRIPT_DIR}/auth.php" ]]; then + error "auth.php not found in ${SCRIPT_DIR}." +fi + +mkdir -p "${API_DIR}" +cp "${SCRIPT_DIR}/auth.php" "${API_DIR}/auth.php" +chown -R www-data:www-data "${API_DIR}" +chmod 640 "${API_DIR}/auth.php" +success "auth.php deployed." + +if [[ ! -f "${SCRIPT_DIR}/comments.php" ]]; then + error "comments.php not found in ${SCRIPT_DIR}." +fi +cp "${SCRIPT_DIR}/comments.php" "${API_DIR}/comments.php" +chown www-data:www-data "${API_DIR}/comments.php" +chmod 640 "${API_DIR}/comments.php" +success "comments.php deployed." + +if [[ ! -f "${SCRIPT_DIR}/admin.php" ]]; then + error "admin.php not found in ${SCRIPT_DIR}." +fi +cp "${SCRIPT_DIR}/admin.php" "${API_DIR}/admin.php" +chown www-data:www-data "${API_DIR}/admin.php" +chmod 640 "${API_DIR}/admin.php" +success "admin.php deployed." + + + +# ── STEP 6b Virtual mail setup (Dovecot + vmail) ── +info "Setting up virtual mail user and Dovecot passwd-file..." + +# Create vmail system user if not exists +if ! id vmail &>/dev/null; then + useradd -r -u 5000 -g mail -d /var/vmail -s /sbin/nologin vmail + success "Created vmail system user (uid 5000)." +else + success "vmail user already exists." +fi + +# Create mailbox base directory +mkdir -p /var/vmail +mkdir -p /var/dovecot +chown vmail:mail /var/vmail +chmod 770 /var/vmail +success "/var/vmail ready." + +# Create /var/dovecot/users if not exists +if [[ ! -f /var/dovecot/users ]]; then + touch /var/dovecot/users + chown root:root /var/dovecot/users + chmod 644 /var/dovecot/users + success "Created /var/dovecot/users." +else + success "/var/dovecot/users already exists." +fi + +# Enable auth-passwdfile in 10-auth.conf (add include if not already there) +if ! grep -q "auth-passwdfile" /etc/dovecot/conf.d/10-auth.conf; then + echo '!include auth-passwdfile.conf.ext' >> /etc/dovecot/conf.d/10-auth.conf + success "Enabled auth-passwdfile.conf.ext in 10-auth.conf." +fi + +# Enable auth-passwdfile.conf.ext — uncomment the passdb/userdb blocks +PASSWDFILE="/etc/dovecot/conf.d/auth-passwdfile.conf.ext" +if grep -q "^#passdb" "${PASSWDFILE}" 2>/dev/null || ! grep -q "^passdb" "${PASSWDFILE}" 2>/dev/null; then + cat > "${PASSWDFILE}" << 'DOVECOT' +passdb { + driver = passwd-file + args = scheme=SHA512-CRYPT username_format=%n /var/dovecot/users +} +userdb { + driver = passwd-file + args = username_format=%n /var/dovecot/users + default_fields = uid=vmail gid=mail home=/var/vmail/%n@freedoms4.org/maildir +} +DOVECOT + success "auth-passwdfile.conf.ext configured." +fi + +systemctl reload dovecot +success "Dovecot reloaded." + +# ── STEP 6c Deploy email-account-create wrapper + sudoers ── +info "Deploying email-account-create wrapper script..." + +if [[ ! -f "${SCRIPT_DIR}/email-account-create.sh" ]]; then + error "email-account-create.sh not found in ${SCRIPT_DIR}." +fi + +cp "${SCRIPT_DIR}/email-account-create.sh" /usr/local/bin/email-account-create +chown root:root /usr/local/bin/email-account-create +chmod 755 /usr/local/bin/email-account-create +success "email-account-create deployed to /usr/local/bin/" + +SUDOERS_EMAIL="/etc/sudoers.d/email-account-create" +echo "www-data ALL=(root) NOPASSWD: /usr/local/bin/email-account-create" > "${SUDOERS_EMAIL}" +chmod 440 "${SUDOERS_EMAIL}" +visudo -cf "${SUDOERS_EMAIL}" && success "sudoers rule for email-account-create installed." \ + || error "sudoers syntax check failed — check ${SUDOERS_EMAIL}" + + +# ── STEP 6d Deploy email-block wrapper + sudoers ── +info "Deploying email-block wrapper script..." + +if [[ ! -f "${SCRIPT_DIR}/email-block.sh" ]]; then + error "email-block.sh not found in ${SCRIPT_DIR}." +fi + +cp "${SCRIPT_DIR}/email-block.sh" /usr/local/bin/email-block +chown root:root /usr/local/bin/email-block +chmod 755 /usr/local/bin/email-block +success "email-block deployed to /usr/local/bin/" + +# Create backup file if not exists +touch /var/dovecot/users.blocked +chown root:root /var/dovecot/users.blocked +chmod 644 /var/dovecot/users.blocked +success "/var/dovecot/users.blocked ready." + +SUDOERS_BLOCK="/etc/sudoers.d/email-block" +echo "www-data ALL=(root) NOPASSWD: /usr/local/bin/email-block" > "${SUDOERS_BLOCK}" +chmod 440 "${SUDOERS_BLOCK}" +visudo -cf "${SUDOERS_BLOCK}" && success "sudoers rule for email-block installed." \ + || error "sudoers syntax check failed — check ${SUDOERS_BLOCK}" + + +# ── STEP 6e Deploy email-delete wrapper + sudoers ── +info "Deploying email-delete wrapper script..." + +if [[ ! -f "${SCRIPT_DIR}/email-delete.sh" ]]; then + error "email-delete.sh not found in ${SCRIPT_DIR}." +fi + +cp "${SCRIPT_DIR}/email-delete.sh" /usr/local/bin/email-delete +chown root:root /usr/local/bin/email-delete +chmod 755 /usr/local/bin/email-delete +success "email-delete deployed to /usr/local/bin/" + +SUDOERS_DELETE="/etc/sudoers.d/email-delete" +echo "www-data ALL=(root) NOPASSWD: /usr/local/bin/email-delete" > "${SUDOERS_DELETE}" +chmod 440 "${SUDOERS_DELETE}" +visudo -cf "${SUDOERS_DELETE}" && success "sudoers rule for email-delete installed." \ + || error "sudoers syntax check failed — check ${SUDOERS_DELETE}" + + +# ── STEP 7 Nginx config ── +info "Deploying nginx config for ${DOMAIN}..." + +if [[ ! -f "${SCRIPT_DIR}/backend.freedoms4.org" ]]; then + error "backend.freedoms4.org not found in ${SCRIPT_DIR}." +fi + +cp "${SCRIPT_DIR}/backend.freedoms4.org" "/etc/nginx/sites-available/${DOMAIN}" +ln -sf "/etc/nginx/sites-available/${DOMAIN}" "/etc/nginx/sites-enabled/${DOMAIN}" + +nginx -t || error "Nginx config test failed." +systemctl restart nginx +success "Nginx restarted." + +# ── STEP 8 Certbot / SSL ── +info "Checking SSL certificate..." + +if ! command -v certbot &>/dev/null; then + apt install -y certbot python3-certbot-nginx + success "Certbot installed." +fi + +if [[ -f "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" ]]; then + warn "Certificate for ${DOMAIN} already exists, skipping issuance." +else + info "Obtaining SSL certificate for ${DOMAIN}..." + certbot --nginx -d "${DOMAIN}" --non-interactive --agree-tos -m "${CERTBOT_EMAIL}" + success "Certificate obtained." +fi + +if systemctl list-timers 2>/dev/null | grep -q certbot; then + success "Certbot auto-renewal timer is active." +else + systemctl enable --now certbot.timer 2>/dev/null || true + if ! crontab -l 2>/dev/null | grep -q certbot; then + (crontab -l 2>/dev/null; echo "0 3 * * * certbot renew --quiet --post-hook 'systemctl reload nginx'") | crontab - + success "Added certbot renewal cron job." + fi +fi + +# ── STEP 9 OTP cleanup cron ── +info "Installing OTP cleanup cron (purges rows older than 24 h)..." + +OTP_CRON="0 4 * * * psql postgresql://${DB_USER}:${DB_PASS}@127.0.0.1/${DB_NAME} -c \"DELETE FROM email_otps WHERE created_at < NOW() - INTERVAL '24 hours';\" >/dev/null 2>&1" + +if ! crontab -l 2>/dev/null | grep -q "email_otps"; then + (crontab -l 2>/dev/null; echo "${OTP_CRON}") | crontab - + success "OTP cleanup cron installed (runs daily at 04:00)." +else + success "OTP cleanup cron already present." +fi + +# ── STEP 10 Smoke test ── +info "Running smoke test..." + +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ + "https://${DOMAIN}/auth.php" \ + -H "Content-Type: application/json" \ + -H "Origin: https://freedoms4.org" \ + -d '{"action":"login","username":"__probe__","password":"__probe__"}' \ + --max-time 10 || true) + +if [[ "$HTTP_CODE" == "200" ]]; then + success "Smoke test passed (HTTP 200)." +elif [[ "$HTTP_CODE" == "000" ]]; then + error "Smoke test failed (000) — endpoint unreachable. Run: curl -sv https://${DOMAIN}/auth.php" +else + success "Smoke test: HTTP ${HTTP_CODE} — endpoint is reachable." +fi + +# ── Done ── +echo "" +echo -e "${GREEN}================================================${NC}" +echo -e "${GREEN} Setup complete!${NC}" +echo -e "${GREEN}================================================${NC}" +echo "" +echo -e " API endpoint : ${BLUE}https://${DOMAIN}/auth.php${NC}" +echo -e " Auth file : ${BLUE}${API_DIR}/auth.php${NC}" +echo -e " OTP sender : ${BLUE}${OTP_FROM}${NC}" +echo "" +echo "" diff --git a/rollback-everything.sh b/rollback-everything.sh new file mode 100755 index 0000000..916f01f --- /dev/null +++ b/rollback-everything.sh @@ -0,0 +1,144 @@ +#!/bin/bash +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +info() { echo -e "${BLUE}[INFO]${NC} $1"; } +success() { echo -e "${GREEN}[OK]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } + +# Must run as root +if [[ $EUID -ne 0 ]]; then + echo -e "${RED}[ERROR]${NC} Please run as root: sudo bash rollback-everything.sh" + exit 1 +fi + +echo "" +echo -e "${RED}WARNING:${NC} This will remove the database, email accounts, mailboxes, and all config." +echo -n "Are you sure you want to proceed? (yes/no) [no]: " +read -r CONFIRM +if [[ "${CONFIRM}" != "yes" ]]; then + echo "Aborted." + exit 0 +fi +echo "" +info "Stopping php8.2-fpm..." +systemctl stop php8.2-fpm + +# ── 2. Terminate any remaining freedoms4 DB connections ── +info "Terminating freedoms4 DB connections..." +(cd /tmp && sudo -u postgres psql -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='freedoms4';" 2>/dev/null || true) + +# ── 3. Revoke freedoms4_user access to prosody DB ── +info "Revoking freedoms4_user from prosody DB..." +(cd /tmp && sudo -u postgres psql -d prosody -c "REVOKE ALL ON TABLE prosody FROM freedoms4_user;" 2>/dev/null || true) +(cd /tmp && sudo -u postgres psql -c "REVOKE CONNECT ON DATABASE prosody FROM freedoms4_user;" 2>/dev/null || true) + +# ── 4. Drop the freedoms4 database and user ── +info "Dropping freedoms4 database and user..." +(cd /tmp && sudo -u postgres psql -c "DROP DATABASE IF EXISTS freedoms4;") +(cd /tmp && sudo -u postgres psql -c "DROP USER IF EXISTS freedoms4_user;") +success "Database and user dropped." + +# ── 5. Remove deployed API dir and env file ── +info "Removing API dir and env file..." +rm -rf /var/www/freedoms4 +rm -rf /etc/freedoms4 +success "API dir and env file removed." + +# ── 6. Remove nginx site ── +info "Removing nginx site config..." +rm -f /etc/nginx/sites-enabled/backend.freedoms4.org +rm -f /etc/nginx/sites-available/backend.freedoms4.org +systemctl restart nginx +success "Nginx site removed and restarted." + +# ── 7. Remove email account wrapper and sudoers rule ── +info "Removing email-account-create script and sudoers rule..." +rm -f /usr/local/bin/email-account-create +rm -f /etc/sudoers.d/email-account-create +rm -f /usr/local/bin/email-block +rm -f /etc/sudoers.d/email-block +rm -f /usr/local/bin/email-delete +rm -f /etc/sudoers.d/email-delete +success "email-account-create removed." + +# ── 8. Remove ONLY site-created virtual users from /var/dovecot/users ── +# System users (like hyzen) are NOT in /var/dovecot/users — they authenticate +# via PAM/system auth — so we can safely remove the entire file. +# Their mailboxes in /var/mail/ or ~/Maildir are untouched. +info "Removing virtual users file (/var/dovecot/users)..." +rm -f /var/dovecot/users +rm -f /var/dovecot/users.blocked +success "Virtual users file removed (system users unaffected)." + +# Remove vmail mailbox data (only virtual users live here; system users use /var/mail) +info "Removing /var/vmail (virtual mailboxes only)..." +rm -rf /var/vmail +success "/var/vmail removed." + +# Remove vmail system user (not a login account, safe to remove) +if id vmail &>/dev/null; then + userdel vmail 2>/dev/null || true + success "vmail system user removed." +fi + +# ── 9. Undo Dovecot passwd-file auth config ── +info "Reverting Dovecot auth config..." +sed -i '/auth-passwdfile/d' /etc/dovecot/conf.d/10-auth.conf +cat > /etc/dovecot/conf.d/auth-passwdfile.conf.ext << 'DOVECOT' +# passdb and userdb for virtual users — managed by full-setup.sh +# (currently inactive; run full-setup.sh to re-enable) +DOVECOT +# Remove the Postfix-auth drop-in (do NOT touch 10-master.conf itself) +rm -f /etc/dovecot/conf.d/99-postfix-auth.conf +systemctl reload dovecot +success "Dovecot config reverted." + +# ── 10. Undo Postfix SASL and virtual mailbox config ── +info "Reverting Postfix SASL and virtual mailbox config..." + +# mydestination was never changed by full-setup, so nothing to restore there. + +# Use postconf -X to fully REMOVE parameters (not set them empty). +# postconf -e "param =" writes "param =" to main.cf which Postfix rejects +# for parameters like virtual_transport that cannot be blank. +postconf -X transport_maps +postconf -X dovecot_destination_recipient_limit +postconf -X local_recipient_maps +postconf -X smtpd_sasl_type +postconf -X smtpd_sasl_path +postconf -e "smtpd_sasl_auth_enable = no" +postconf -X smtpd_sasl_security_options +postconf -X smtpd_sasl_local_domain +postconf -e "broken_sasl_auth_clients = no" + +# Restore default recipient restrictions +postconf -e "smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination" + +# Remove the dovecot pipe transport block from master.cf +sed -i '/^dovecot[[:space:]]*unix.*pipe/,/argv=\/usr\/lib\/dovecot\//d' /etc/postfix/master.cf 2>/dev/null || true + +# Remove transport map files +rm -f /etc/postfix/virtual_transport /etc/postfix/virtual_transport.db + +systemctl reload postfix +success "Postfix config reverted — system users (hyzen etc.) restored." + +# ── 11. Restart php-fpm ── +info "Restarting php8.2-fpm..." +systemctl start php8.2-fpm +success "php8.2-fpm restarted." + +echo "" +echo -e "${GREEN}================================================${NC}" +echo -e "${GREEN} Rollback complete.${NC}" +echo -e "${GREEN}================================================${NC}" +echo "" +echo " Run full-setup.sh again to restore the backend." +echo "" diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..48dff89 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,108 @@ +#!/bin/bash +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +info() { echo -e "${BLUE}[INFO]${NC} $1"; } +success() { echo -e "${GREEN}[OK]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } + +# Must run as root +if [[ $EUID -ne 0 ]]; then + echo -e "${RED}[ERROR]${NC} Please run as root: sudo bash uninstall.sh" + exit 1 +fi + +echo "" +echo -e "${BLUE}================================================${NC}" +echo -e "${BLUE} freedoms4 uninstall (keeps DB + email accounts)${NC}" +echo -e "${BLUE}================================================${NC}" +echo "" +warn "This removes deployed files and config only." +warn "Database, email accounts, and mailboxes are preserved." +echo "" + +# ── 1. Stop php-fpm to release DB connections ── +info "Stopping php8.2-fpm..." +systemctl stop php8.2-fpm + +# ── 2. Remove deployed API dir and env file ── +info "Removing API dir and env file..." +rm -rf /var/www/freedoms4 +rm -rf /etc/freedoms4 +success "API dir and env file removed." + +# ── 3. Remove nginx site ── +info "Removing nginx site config..." +rm -f /etc/nginx/sites-enabled/backend.freedoms4.org +rm -f /etc/nginx/sites-available/backend.freedoms4.org +systemctl restart nginx +success "Nginx site removed and restarted." + +# ── 4. Remove email account wrapper and sudoers rule ── +info "Removing email-account-create script and sudoers rule..." +rm -f /usr/local/bin/email-account-create +rm -f /etc/sudoers.d/email-account-create +success "email-account-create removed." + +# ── 5. Undo Dovecot passwd-file auth config ── +# NOTE: /var/dovecot/users and /var/vmail are intentionally preserved. +info "Reverting Dovecot auth config (preserving user accounts and mailboxes)..." +sed -i '/auth-passwdfile/d' /etc/dovecot/conf.d/10-auth.conf +cat > /etc/dovecot/conf.d/auth-passwdfile.conf.ext << 'DOVECOT' +# passdb and userdb for virtual users — managed by full-setup.sh +# (currently inactive; run full-setup.sh to re-enable) +DOVECOT +rm -f /etc/dovecot/conf.d/99-postfix-auth.conf +systemctl reload dovecot +success "Dovecot config reverted (accounts and mailboxes untouched)." + +# ── 6. Undo Postfix SASL and virtual mailbox config ── +info "Reverting Postfix config..." + +# Use postconf -X to fully remove parameters rather than set them empty. +postconf -X transport_maps +postconf -X dovecot_destination_recipient_limit +postconf -X local_recipient_maps +postconf -X smtpd_sasl_type +postconf -X smtpd_sasl_path +postconf -e "smtpd_sasl_auth_enable = no" +postconf -X smtpd_sasl_security_options +postconf -X smtpd_sasl_local_domain +postconf -e "broken_sasl_auth_clients = no" +postconf -e "smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination" + +# Remove the dovecot pipe transport block from master.cf +sed -i '/^dovecot[[:space:]]*unix.*pipe/,/argv=\/usr\/lib\/dovecot\//d' /etc/postfix/master.cf 2>/dev/null || true + +# NOTE: /etc/postfix/virtual_transport and its .db are preserved so that +# existing site-created email accounts retain their routing entries when +# full-setup.sh re-enables transport_maps. +success "Postfix config reverted — system users (hyzen etc.) unaffected." + +systemctl reload postfix + +# ── 7. Restart php-fpm ── +info "Restarting php8.2-fpm..." +systemctl start php8.2-fpm +success "php8.2-fpm restarted." + +echo "" +echo -e "${GREEN}================================================${NC}" +echo -e "${GREEN} Uninstall complete.${NC}" +echo -e "${GREEN}================================================${NC}" +echo "" +echo " Preserved:" +echo " - PostgreSQL database 'freedoms4' and all data" +echo " - /var/dovecot/users (virtual email accounts)" +echo " - /var/vmail (mailboxes)" +echo " - /etc/postfix/virtual_transport (routing entries)" +echo " - vmail system user" +echo "" +echo " Run full-setup.sh again to redeploy without losing any data." +echo ""