diff options
| -rw-r--r-- | .gitignore | 6 | ||||
| -rw-r--r-- | CMakeLists.txt | 65 | ||||
| -rw-r--r-- | LICENSE | 661 | ||||
| -rw-r--r-- | README.md | 176 | ||||
| -rw-r--r-- | astrftime.c | 58 | ||||
| -rw-r--r-- | auth.c | 327 | ||||
| -rw-r--r-- | auth.h | 45 | ||||
| -rw-r--r-- | cmake/FindGraphicsMagick.cmake | 310 | ||||
| -rw-r--r-- | cmake/Findlibsodium.cmake | 29 | ||||
| -rw-r--r-- | cmake/Findweb.cmake | 24 | ||||
| -rwxr-xr-x | configure | 262 | ||||
| -rw-r--r-- | db.c | 188 | ||||
| -rw-r--r-- | db.h | 69 | ||||
| -rw-r--r-- | db_post.c | 63 | ||||
| -rw-r--r-- | db_section.c | 59 | ||||
| -rw-r--r-- | db_topic.c | 57 | ||||
| -rw-r--r-- | default.h | 10 | ||||
| -rw-r--r-- | default_prv_policy.c | 30 | ||||
| -rw-r--r-- | default_style.c | 63 | ||||
| -rw-r--r-- | default_terms.c | 13 | ||||
| -rw-r--r-- | defs.h | 43 | ||||
| -rw-r--r-- | endpoints.h | 21 | ||||
| -rw-r--r-- | ep_create.c | 695 | ||||
| -rw-r--r-- | ep_index.c | 432 | ||||
| -rw-r--r-- | ep_login.c | 306 | ||||
| -rw-r--r-- | ep_logout.c | 85 | ||||
| -rw-r--r-- | ep_passwd.c | 400 | ||||
| -rw-r--r-- | ep_signup.c | 651 | ||||
| -rw-r--r-- | ep_style.c | 89 | ||||
| -rw-r--r-- | ep_ucp.c | 241 | ||||
| -rw-r--r-- | ep_view.c | 1180 | ||||
| -rw-r--r-- | form.h | 38 | ||||
| -rw-r--r-- | form_badreq.c | 29 | ||||
| -rw-r--r-- | form_category.c | 40 | ||||
| -rw-r--r-- | form_footer.c | 105 | ||||
| -rw-r--r-- | form_head.c | 45 | ||||
| -rw-r--r-- | form_login.c | 105 | ||||
| -rw-r--r-- | form_post.c | 54 | ||||
| -rw-r--r-- | form_section.c | 64 | ||||
| -rw-r--r-- | form_shortpwd.c | 21 | ||||
| -rw-r--r-- | form_topic.c | 63 | ||||
| -rw-r--r-- | form_unauthorized.c | 29 | ||||
| -rw-r--r-- | gencookie.c | 72 | ||||
| -rw-r--r-- | getul.c | 52 | ||||
| -rw-r--r-- | getul_n.c | 33 | ||||
| -rw-r--r-- | jwt.c | 229 | ||||
| -rw-r--r-- | jwt.h | 28 | ||||
| -rw-r--r-- | login_get.c | 49 | ||||
| -rw-r--r-- | main.c | 648 | ||||
| -rw-r--r-- | op.c | 183 | ||||
| -rw-r--r-- | op.h | 40 | ||||
| -rw-r--r-- | sanitize.c | 54 | ||||
| -rw-r--r-- | tokengen.c | 155 | ||||
| -rw-r--r-- | utils.h | 30 |
54 files changed, 8824 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb36d8e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +build/ +/nanobbs +/tokengen +*.o +*.d +/Makefile diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..6aa295b --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,65 @@ +cmake_minimum_required(VERSION 3.13) +project(nanobbs C) +add_executable(${PROJECT_NAME} + auth.c + astrftime.c + db.c + db_post.c + db_section.c + db_topic.c + default_prv_policy.c + default_style.c + default_terms.c + ep_create.c + ep_index.c + ep_login.c + ep_logout.c + ep_passwd.c + ep_signup.c + ep_style.c + ep_ucp.c + ep_view.c + form_badreq.c + form_category.c + form_footer.c + form_head.c + form_login.c + form_post.c + form_section.c + form_shortpwd.c + form_topic.c + form_unauthorized.c + gencookie.c + getul.c + getul_n.c + jwt.c + login_get.c + main.c + op.c + sanitize.c +) +add_executable(tokengen + jwt.c + tokengen.c +) +set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} ${CMAKE_CURRENT_LIST_DIR}/cmake) +find_package(libsodium REQUIRED) +find_package(cJSON REQUIRED) +find_package(SQLite3 3.6.19 REQUIRED) +find_package(web 0.3.0) + +if(WEB_FOUND) + find_package(dynstr 0.1.0 REQUIRED) +else() + message(STATUS "Using in-tree libweb") + set(BUILD_EXAMPLES OFF) + add_subdirectory(libweb) + # dynstr is already provided by libweb. +endif() + +set(deps dynstr sqlite3 libsodium cjson) +target_link_libraries(${PROJECT_NAME} PRIVATE web ${deps}) +target_compile_options(${PROJECT_NAME} PRIVATE -Wall) +target_link_libraries(tokengen PRIVATE ${deps}) +target_compile_options(tokengen PRIVATE -Wall) +install(TARGETS ${PROJECT_NAME} tokengen) @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +<https://www.gnu.org/licenses/>. diff --git a/README.md b/README.md new file mode 100644 index 0000000..43fb259 --- /dev/null +++ b/README.md @@ -0,0 +1,176 @@ +# nanobbs, a tiny forums software + +`nanobbs` is a simple and lightweight implementation of a forums software, +commonly known as "bulletin board software" or simply "BBS", written in C99 +plus POSIX.1-2008 extensions. + +## Disclaimer + +Intentionally, `nanobbs` does not share some of the philosophical views from the +[suckless project](https://suckless.org). However, it still strives towards +portability, minimalism, simplicity and efficiency. + +## Features + +- No JavaScript. +- Typical forums software features: categories, sections, topics and posts. +- Create/delete users from the web interface. +- Uses [`libweb`](https://gitea.privatedns.org/xavi/libweb), a tiny web framework. +- Uses [`libsodium`](https://www.libsodium.org/) as its cryptography backend. +- Uses [SQLite](https://sqlite.org/) as its database backend. + +### TLS + +In order to maintain simplicity and reduce the risk for security bugs, `nanobbs` +does **not** implement TLS support. Instead, this should be provided by a +reverse proxy, such as [`caddy`](https://caddyserver.com/). + +### Root permissions + +`nanobbs` is expected to listen to connections from any port number so that `root` +access is not required. So, in order to avoid the risk for security bugs, +**please do not run `nanobbs` as `root`**. + +## Requirements + +- A POSIX environment. +- [`libsodium`](https://www.libsodium.org/). +- [SQLite](https://sqlite.org/) >= 3.0.0 (tentative). +- [`dynstr`](https://gitea.privatedns.org/xavi/dynstr) +(provided as a `git` submodule by `libweb`). +- [`libweb`](https://gitea.privatedns.org/xavi/libweb) +(provided as a `git` submodule). +- CMake (optional). + +### Ubuntu / Debian + +#### Mandatory packages + +```sh +sudo apt install build-essential libsqlite3-dev libsodium-dev +``` + +#### Optional packages + +```sh +sudo apt install cmake +``` + +## How to use +### Build + +Two build environments are provided for `nanobbs` - feel free to choose any of +them: + +- A [`configure`](configure) POSIX shell and mostly POSIX-compliant +[`Makefile`](Makefile). +- A [`CMakeLists.txt`](CMakeLists.txt). + +`nanobbs` can be built using the standard build process: + +#### Make + +```sh +$ ./configure +$ make +``` + +#### CMake + +```sh +$ cmake -B build +$ cmake --build build/ +``` + +### Setting up + +`nanobbs` consumes a path to a directory with the following tree structure: + +``` +. +├── nanobbs.db +├── style.css +└── TERMS +``` + +Where: + +- `nanobbs.db` is the SQLite database. + - **Note:** `nanobbs` creates a database with one administrator account, + namely `admin`, if not found, with file mode bits set to `0600`. +- `style.css` is the site stylesheet. It can be freely modified. + - **Note:** `nanobbs` creates a default stylesheet file, if not found. +- `TERMS` is a text file containing the terms of service for the instance. +It can be freely modified. + - **Note:** `nanobbs` creates a default terms file, if not found. + +**Note:** `nanobbs` creates the given directory if it does not exist. + +### Running + +To run `nanobbs`, simply run the executable with the path to a directory including +the files listed above. By default, `nanobbs` will listen to incoming connections +on a random TCP port number. To set a specific port number, use the `-p` +command line option. For example: + +```sh +nanobbs -p 7822 ~/my-db/ +``` + +`nanobbs` requires a temporary directory where files uploaded by users are +temporarily stored until moved to the user directory. By default, `nanobbs` +attempts to retrieve the path to the temporary directory by inspecting the +`TMPDIR` environment variable, and falls back to `/tmp` if undefined. + +If a custom temporary directory is required, it can be defined via command +line option `-t`. For example: + +```sh +nanobbs -t ~/my-tmp -p 7822 ~/my-db +``` + +**Note:** system-level temporary directories such as `/tmp` might reside +on a filesytem different than the one where the database resides. This +would force `nanobbs` to copy the contents from uploaded files from the +temporary directory to the database, which might be an expensive operation. +Therefore, in order to avoid expensive copies, define a custom temporary +directory that resides on the same filesystem. + +## Why this project? + +When looking up a forums software for +[Speed Dreams](https://www.speed-dreams.net/), traditional forums software +solutions had been considered, such as [phpBB](https://www.phpbb.com/), +[myBB](https://mybb.com/) or [Discourse](https://www.discourse.org/), +yet raised concerns related to perceived software bloat and/or excessive +use of client-side JavaScript. + +As of the time of this writing, there was no forums software written in C +other than `nanobbs`, +[according to Wikipedia](https://en.wikipedia.org/wiki/Comparison_of_Internet_forum_software). +Since [`slcl`](https://gitea.privatedns.org/xavi/slcl), another project with +the same philosophy, was motivated by similar goals back then, `nanobbs` was seen +as a good opportunity to show how a solution written in C can be both simple, +efficient and safe. + +## License + +``` +nanobbs, a tiny forums software. +Copyright (C) 2025-2026 Xavier Del Campo Romero + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see <https://www.gnu.org/licenses/>. +``` + +Also, see [`LICENSE`](LICENSE). diff --git a/astrftime.c b/astrftime.c new file mode 100644 index 0000000..4c10654 --- /dev/null +++ b/astrftime.c @@ -0,0 +1,58 @@ +#define _POSIX_C_SOURCE 200809L + +#include "utils.h" +#include <errno.h> +#include <locale.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> + +char *astrftime(const char *const fmt, const struct tm *const tm) +{ + char *ret = NULL, *s = NULL; + size_t n = sizeof "."; + const locale_t l = newlocale(LC_TIME_MASK, "POSIX", (locale_t)0); + + if (l == (locale_t)0) + { + fprintf(stderr, "%s: newlocale(3): %s\n", __func__, strerror(errno)); + goto end; + } + else if (!(s = malloc(n))) + { + fprintf(stderr, "%s: malloc(3): %s\n", __func__, strerror(errno)); + goto end; + } + + for (;;) + { + const int res = strftime_l(s, n, fmt, tm, l); + + if (!res) + { + char *const ss = realloc(s, ++n); + + if (!s) + { + fprintf(stderr, "%s: realloc(3): %s\n", __func__, + strerror(errno)); + goto end; + } + + s = ss; + } + else + break; + } + + ret = s; + +end: + + if (!ret) + free(s); + + freelocale(l); + return ret; +} @@ -0,0 +1,327 @@ +/* + * nanobbs, a tiny forums software. + * Copyright (C) 2025-2026 Xavier Del Campo Romero + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#define _POSIX_C_SOURCE 200809L + +#include "auth.h" +#include "db.h" +#include "defs.h" +#include "endpoints.h" +#include "jwt.h" +#include "op.h" +#include <dynstr.h> +#include <libweb/http.h> +#include <sodium.h> +#include <sqlite3.h> +#include <errno.h> +#include <stdbool.h> +#include <stdlib.h> +#include <string.h> + +struct validate +{ + bool valid; + unsigned long id; + enum auth_role role; + sqlite3 *db; + auth_fn fn; + void *user; +}; + +static void free_validate(void *const p) +{ + int error; + struct validate *const v = p; + + if (!v) + return; + else if ((error = sqlite3_close(v->db)) != SQLITE_OK) + fprintf(stderr, "%s: sqlite3_close: %s\n", __func__, + sqlite3_errstr(error)); + + free(v); +} + +static int check_role(const char *const s, enum auth_role *const out) +{ + static const struct r + { + const char *s; + enum auth_role role; + } roles[] = + { + {"admin", AUTH_ROLE_ADMIN}, + {"mod", AUTH_ROLE_MOD}, + {"user", AUTH_ROLE_USER}, + {"banned", AUTH_ROLE_BANNED} + }; + + for (size_t i = 0; i < sizeof roles / sizeof *roles; i++) + { + const struct r *const r = &roles[i]; + + if (!strcmp(s, r->s)) + { + *out = r->role; + return 0; + } + } + + fprintf(stderr, "%s: invalid role: %s\n", __func__, s); + return -1; +} + +static int end(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + int ret = -1; + struct validate *const v = args; + + if (!v->valid) + ret = v->fn(p, r, v->user, v->db, NULL); + else + { + const struct http_cookie *const c = &p->cookie; + const char *const username = c->field; + const struct auth_user u = + { + .username = username, + .role = v->role, + .id = v->id + }; + + ret = v->fn(p, r, v->user, v->db, &u); + } + + free(v); + return ret; +} + +static int signkey(const struct http_cookie *const c, const char *const key, + struct validate *const v) +{ + int ret = -1; + cJSON *j = NULL; + unsigned char dkey[crypto_auth_hmacsha256_KEYBYTES]; + const size_t len = strlen(key), explen = sizeof dkey * 2; + const cJSON *u; + const char *user; + + if (len != explen) + { + fprintf(stderr, "%s: key size mismatch, got %zu, expected %zu\n", + __func__, len, explen); + goto end; + } + else if (sodium_hex2bin(dkey, sizeof dkey, key, len, NULL, NULL, NULL)) + { + fprintf(stderr, "%s: sodium_hex2bin failed\n", __func__); + goto end; + } + else if ((ret = jwt_decode(c->value, dkey, sizeof dkey, &j))) + { + if (ret < 0) + fprintf(stderr, "%s: jwt_check failed\n", __func__); + + goto end; + } + else if (!(u = cJSON_GetObjectItem(j, "name")) + || !(user = cJSON_GetStringValue(u)) + || strcmp(user, c->field)) + { + ret = 1; + goto end; + } + + ret = 0; + +end: + cJSON_Delete(j); + return ret; +} + +static int row(sqlite3_stmt *const stmt, const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + int ret = -1, error; + struct validate *const v = args; + sqlite3 *const db = v->db; + char *const key = db_str(db, stmt, "signkey"), *name = NULL; + enum auth_role role; + unsigned id; + + if (!key) + { + fprintf(stderr, "%s: db_str %s failed\n", __func__, "signkey"); + goto end; + } + else if ((ret = db_uint(db, stmt, "id", &id))) + { + fprintf(stderr, "%s: db_uint %s failed\n", __func__, "id"); + goto end; + } + else if (!(name = db_str(db, stmt, "name"))) + { + fprintf(stderr, "%s: db_str %s failed\n", __func__, "name"); + goto end; + } + else if (check_role(name, &role)) + { + fprintf(stderr, "%s: role failed\n", __func__); + goto end; + } + else if ((error = signkey(&p->cookie, key, v))) + { + if (error < 0) + { + fprintf(stderr, "%s: signkey failed\n", __func__); + goto end; + } + } + else if (!error) + { + v->valid = true; + v->id = id; + v->role = role; + } + + ret = 0; + +end: + free(name); + free(key); + return ret; +} + +static int check_username(const char *const name) +{ + return strspn(name, USER_SYMS) != strlen(name); +} + +static int setup(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + int ret = -1; + const struct http_cookie *const c = &p->cookie; + const char *const username = c->field; + struct validate *const v = args; + struct dynstr d; + + dynstr_init(&d); + + if (dynstr_append(&d, "SELECT users.id, users.signkey, roles.name " + "FROM users JOIN roles ON users.roleid = roles.id " + "WHERE users.name = '%s';", username)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + + const struct op_cfg op = + { + .error = free_validate, + .end = end, + .row = row, + .args = v + }; + + if (!op_run(v->db, d.str, &op, r)) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + goto end; + } + + ret = 0; + +end: + + if (ret) + free_validate(v); + + dynstr_free(&d); + return ret; +} + +static int open_db(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + struct validate *const v = args; + const struct cfg *const cfg = user; + const int error = db_open(cfg->dir, &v->db); + + if (error != SQLITE_OK) + { + if (error != SQLITE_BUSY) + { + fprintf(stderr, "%s: db_open: %s\n", __func__, + sqlite3_errstr(error)); + goto failure; + } + } + else + *r = (const struct http_response) + { + .step.payload = setup, + .args = v + }; + + return 0; + +failure: + free_validate(v); + return -1; +} + +int auth_validate(const struct http_payload *const p, + struct http_response *const r, void *const user, const auth_fn fn) +{ + int ret = -1; + struct validate *v = NULL; + const struct http_cookie *const c = &p->cookie; + const char *const username = c->field; + + if (!username || !c->value || check_username(username)) + { + ret = 1; + goto failure; + } + else if (!(v = malloc(sizeof *v))) + { + fprintf(stderr, "%s: malloc(3): %s\n", __func__, strerror(errno)); + goto failure; + } + + *r = (const struct http_response) + { + .step.payload = open_db, + .free = free_validate, + .args = v + }; + + *v = (const struct validate) + { + .fn = fn, + .user = user + }; + + return 0; + +failure: + free(v); + return ret; +} @@ -0,0 +1,45 @@ +/* + * nanobbs, a tiny forums software. + * Copyright (C) 2025-2026 Xavier Del Campo Romero + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#ifndef AUTH_H +#define AUTH_H + +#include <sqlite3.h> +#include <libweb/http.h> + +struct auth_user +{ + const char *username; + unsigned long id; + + enum auth_role + { + AUTH_ROLE_BANNED, + AUTH_ROLE_USER, + AUTH_ROLE_MOD, + AUTH_ROLE_ADMIN + } role; +}; + +typedef int (*auth_fn)(const struct http_payload *p, struct http_response *r, + void *, sqlite3 *, const struct auth_user *); + +int auth_validate(const struct http_payload *p, struct http_response *r, + void *user, auth_fn fn); + +#endif diff --git a/cmake/FindGraphicsMagick.cmake b/cmake/FindGraphicsMagick.cmake new file mode 100644 index 0000000..2182849 --- /dev/null +++ b/cmake/FindGraphicsMagick.cmake @@ -0,0 +1,310 @@ +# Distributed under the OSI-approved BSD 3-Clause License. See accompanying +# file Copyright.txt or https://cmake.org/licensing for details. + +#[=======================================================================[.rst: +FindGraphicsMagick +--------------- + +Find GraphicsMagick binary suite. + +.. versionadded:: 3.9 + Added support for GraphicsMagick 7. + +This module will search for a set of GraphicsMagick tools specified as +components in the :command:`find_package` call. Typical components include, +but are not limited to (future versions of GraphicsMagick might have +additional components not listed here): + +:: + + animate + compare + composite + conjure + convert + display + identify + import + mogrify + montage + stream + + + +If no component is specified in the :command:`find_package` call, then it only +searches for the GraphicsMagick executable directory. This code defines +the following variables: + +:: + + GraphicsMagick_FOUND - TRUE if all components are found. + GraphicsMagick_EXECUTABLE_DIR - Full path to executables directory. + GraphicsMagick_<component>_FOUND - TRUE if <component> is found. + GraphicsMagick_<component>_EXECUTABLE - Full path to <component> executable. + GraphicsMagick_VERSION_STRING - the version of GraphicsMagick found + (since CMake 2.8.8) + + + +``GraphicsMagick_VERSION_STRING`` will not work for old versions like 5.2.3. + +There are also components for the following GraphicsMagick APIs: + +:: + + Magick++ + MagickWand + MagickCore + + + +For these components the following variables are set: + +:: + + GraphicsMagick_FOUND - TRUE if all components are found. + GraphicsMagick_INCLUDE_DIRS - Full paths to all include dirs. + GraphicsMagick_LIBRARIES - Full paths to all libraries. + GraphicsMagick_<component>_FOUND - TRUE if <component> is found. + GraphicsMagick_<component>_INCLUDE_DIRS - Full path to <component> include dirs. + GraphicsMagick_<component>_LIBRARIES - Full path to <component> libraries. + + + +Example Usages: + +:: + + find_package(GraphicsMagick) + find_package(GraphicsMagick COMPONENTS convert) + find_package(GraphicsMagick COMPONENTS convert mogrify display) + find_package(GraphicsMagick COMPONENTS Magick++) + find_package(GraphicsMagick COMPONENTS Magick++ convert) + + + +Note that the standard :command:`find_package` features are supported (i.e., +``QUIET``, ``REQUIRED``, etc.). +#]=======================================================================] + +find_package(PkgConfig QUIET) + +#--------------------------------------------------------------------- +# Helper functions +#--------------------------------------------------------------------- +function(FIND_GRAPHICSMAGICK_API component header) + set(GraphicsMagick_${component}_FOUND FALSE PARENT_SCOPE) + + pkg_check_modules(PC_${component} QUIET ${component}) + + find_path(GraphicsMagick_${component}_INCLUDE_DIR + NAMES ${header} + HINTS + ${PC_${component}_INCLUDEDIR} + ${PC_${component}_INCLUDE_DIRS} + PATHS + ${GraphicsMagick_INCLUDE_DIRS} + "[HKEY_LOCAL_MACHINE\\SOFTWARE\\GraphicsMagick\\Current;BinPath]/include" + PATH_SUFFIXES + GraphicsMagick GraphicsMagick-6 GraphicsMagick-7 + DOC "Path to the GraphicsMagick arch-independent include dir." + NO_DEFAULT_PATH + ) + find_path(GraphicsMagick_${component}_ARCH_INCLUDE_DIR + NAMES magick/magick-baseconfig.h + HINTS + ${PC_${component}_INCLUDEDIR} + ${PC_${component}_INCLUDE_DIRS} + PATHS + ${GraphicsMagick_INCLUDE_DIRS} + "[HKEY_LOCAL_MACHINE\\SOFTWARE\\GraphicsMagick\\Current;BinPath]/include" + PATH_SUFFIXES + GraphicsMagick GraphicsMagick-6 GraphicsMagick-7 + DOC "Path to the GraphicsMagick arch-specific include dir." + NO_DEFAULT_PATH + ) + find_library(GraphicsMagick_${component}_LIBRARY + NAMES ${ARGN} + HINTS + ${PC_${component}_LIBDIR} + ${PC_${component}_LIB_DIRS} + PATHS + "[HKEY_LOCAL_MACHINE\\SOFTWARE\\GraphicsMagick\\Current;BinPath]/lib" + DOC "Path to the GraphicsMagick Magick++ library." + NO_DEFAULT_PATH + ) + + # old version have only indep dir + if(GraphicsMagick_${component}_INCLUDE_DIR AND GraphicsMagick_${component}_LIBRARY) + set(GraphicsMagick_${component}_FOUND TRUE PARENT_SCOPE) + + # Construct per-component include directories. + set(GraphicsMagick_${component}_INCLUDE_DIRS + ${GraphicsMagick_${component}_INCLUDE_DIR} + ) + if(GraphicsMagick_${component}_ARCH_INCLUDE_DIR) + list(APPEND GraphicsMagick_${component}_INCLUDE_DIRS + ${GraphicsMagick_${component}_ARCH_INCLUDE_DIR}) + endif() + list(REMOVE_DUPLICATES GraphicsMagick_${component}_INCLUDE_DIRS) + set(GraphicsMagick_${component}_INCLUDE_DIRS + ${GraphicsMagick_${component}_INCLUDE_DIRS} PARENT_SCOPE) + + # Add the per-component include directories to the full include dirs. + list(APPEND GraphicsMagick_INCLUDE_DIRS ${GraphicsMagick_${component}_INCLUDE_DIRS}) + list(REMOVE_DUPLICATES GraphicsMagick_INCLUDE_DIRS) + set(GraphicsMagick_INCLUDE_DIRS ${GraphicsMagick_INCLUDE_DIRS} PARENT_SCOPE) + + list(APPEND GraphicsMagick_LIBRARIES + ${GraphicsMagick_${component}_LIBRARY} + ) + set(GraphicsMagick_LIBRARIES ${GraphicsMagick_LIBRARIES} PARENT_SCOPE) + endif() +endfunction() + +function(FIND_GRAPHICSMAGICK_EXE component) + set(_GRAPHICSMAGICK_EXECUTABLE + ${GraphicsMagick_EXECUTABLE_DIR}/${component}${CMAKE_EXECUTABLE_SUFFIX}) + if(EXISTS ${_GRAPHICSMAGICK_EXECUTABLE}) + set(GraphicsMagick_${component}_EXECUTABLE + ${_GRAPHICSMAGICK_EXECUTABLE} + PARENT_SCOPE + ) + set(GraphicsMagick_${component}_FOUND TRUE PARENT_SCOPE) + else() + set(GraphicsMagick_${component}_FOUND FALSE PARENT_SCOPE) + endif() +endfunction() + +#--------------------------------------------------------------------- +# Start Actual Work +#--------------------------------------------------------------------- +# Try to find a GraphicsMagick installation binary path. +find_path(GraphicsMagick_EXECUTABLE_DIR + NAMES mogrify${CMAKE_EXECUTABLE_SUFFIX} + PATHS + "[HKEY_LOCAL_MACHINE\\SOFTWARE\\GraphicsMagick\\Current;BinPath]" + DOC "Path to the GraphicsMagick binary directory." + NO_DEFAULT_PATH + ) +find_path(GraphicsMagick_EXECUTABLE_DIR + NAMES mogrify${CMAKE_EXECUTABLE_SUFFIX} + ) + +# Find each component. Search for all tools in same dir +# <GraphicsMagick_EXECUTABLE_DIR>; otherwise they should be found +# independently and not in a cohesive module such as this one. +unset(GraphicsMagick_REQUIRED_VARS) +unset(GraphicsMagick_DEFAULT_EXECUTABLES) +foreach(component ${GraphicsMagick_FIND_COMPONENTS} + # DEPRECATED: forced components for backward compatibility + gm + ) + if(component STREQUAL "Magick++") + FIND_GRAPHICSMAGICK_API(Magick++ Magick++.h + Magick++ CORE_RL_Magick++_ + Magick++-6 Magick++-7 + Magick++-Q8 Magick++-Q16 Magick++-Q16HDRI Magick++-Q8HDRI + Magick++-6.Q64 Magick++-6.Q32 Magick++-6.Q64HDRI Magick++-6.Q32HDRI + Magick++-6.Q16 Magick++-6.Q8 Magick++-6.Q16HDRI Magick++-6.Q8HDRI + Magick++-7.Q64 Magick++-7.Q32 Magick++-7.Q64HDRI Magick++-7.Q32HDRI + Magick++-7.Q16 Magick++-7.Q8 Magick++-7.Q16HDRI Magick++-7.Q8HDRI + ) + list(APPEND GraphicsMagick_REQUIRED_VARS GraphicsMagick_Magick++_LIBRARY) + elseif(component STREQUAL "MagickWand") + FIND_GRAPHICSMAGICK_API(MagickWand "wand/MagickWand.h;MagickWand/MagickWand.h" + Wand MagickWand CORE_RL_wand_ CORE_RL_MagickWand_ + MagickWand-6 MagickWand-7 + MagickWand-Q16 MagickWand-Q8 MagickWand-Q16HDRI MagickWand-Q8HDRI + MagickWand-6.Q64 MagickWand-6.Q32 MagickWand-6.Q64HDRI MagickWand-6.Q32HDRI + MagickWand-6.Q16 MagickWand-6.Q8 MagickWand-6.Q16HDRI MagickWand-6.Q8HDRI + MagickWand-7.Q64 MagickWand-7.Q32 MagickWand-7.Q64HDRI MagickWand-7.Q32HDRI + MagickWand-7.Q16 MagickWand-7.Q8 MagickWand-7.Q16HDRI MagickWand-7.Q8HDRI + ) + list(APPEND GraphicsMagick_REQUIRED_VARS GraphicsMagick_MagickWand_LIBRARY) + elseif(component STREQUAL "MagickCore") + FIND_GRAPHICSMAGICK_API(MagickCore "magick/MagickCore.h;MagickCore/MagickCore.h" + Magick MagickCore CORE_RL_magick_ CORE_RL_MagickCore_ + MagickCore-6 MagickCore-7 + MagickCore-Q16 MagickCore-Q8 MagickCore-Q16HDRI MagickCore-Q8HDRI + MagickCore-6.Q64 MagickCore-6.Q32 MagickCore-6.Q64HDRI MagickCore-6.Q32HDRI + MagickCore-6.Q16 MagickCore-6.Q8 MagickCore-6.Q16HDRI MagickCore-6.Q8HDRI + MagickCore-7.Q64 MagickCore-7.Q32 MagickCore-7.Q64HDRI MagickCore-7.Q32HDRI + MagickCore-7.Q16 MagickCore-7.Q8 MagickCore-7.Q16HDRI MagickCore-7.Q8HDRI + ) + list(APPEND GraphicsMagick_REQUIRED_VARS GraphicsMagick_MagickCore_LIBRARY) + else() + if(GraphicsMagick_EXECUTABLE_DIR) + FIND_GRAPHICSMAGICK_EXE(${component}) + endif() + + if(GraphicsMagick_FIND_COMPONENTS) + list(FIND GraphicsMagick_FIND_COMPONENTS ${component} is_requested) + if(is_requested GREATER -1) + list(APPEND GraphicsMagick_REQUIRED_VARS GraphicsMagick_${component}_EXECUTABLE) + endif() + elseif(GraphicsMagick_${component}_EXECUTABLE) + # if no components were requested explicitly put all (default) executables + # in the list + list(APPEND GraphicsMagick_DEFAULT_EXECUTABLES GraphicsMagick_${component}_EXECUTABLE) + endif() + endif() +endforeach() + +if(NOT GraphicsMagick_FIND_COMPONENTS AND NOT GraphicsMagick_DEFAULT_EXECUTABLES) + # No components were requested, and none of the default components were + # found. Just insert mogrify into the list of the default components to + # find so FPHSA below has something to check + list(APPEND GraphicsMagick_REQUIRED_VARS GraphicsMagick_mogrify_EXECUTABLE) +elseif(GraphicsMagick_DEFAULT_EXECUTABLES) + list(APPEND GraphicsMagick_REQUIRED_VARS ${GraphicsMagick_DEFAULT_EXECUTABLES}) +endif() + +set(GraphicsMagick_INCLUDE_DIRS ${GraphicsMagick_INCLUDE_DIRS}) +set(GraphicsMagick_LIBRARIES ${GraphicsMagick_LIBRARIES}) + +if(GraphicsMagick_mogrify_EXECUTABLE) + execute_process(COMMAND ${GraphicsMagick_mogrify_EXECUTABLE} -version + OUTPUT_VARIABLE graphicsmagick_version + ERROR_QUIET + OUTPUT_STRIP_TRAILING_WHITESPACE) + if(graphicsmagick_version MATCHES "^Version: GraphicsMagick ([-0-9\\.]+)") + set(GraphicsMagick_VERSION_STRING "${CMAKE_MATCH_1}") + endif() + unset(graphicsmagick_version) +endif() + +#--------------------------------------------------------------------- +# Standard Package Output +#--------------------------------------------------------------------- +include(FindPackageHandleStandardArgs) +FIND_PACKAGE_HANDLE_STANDARD_ARGS(GraphicsMagick + REQUIRED_VARS ${GraphicsMagick_REQUIRED_VARS} + VERSION_VAR GraphicsMagick_VERSION_STRING + ) +# Maintain consistency with all other variables. +set(GraphicsMagick_FOUND ${GRAPHICSMAGICK_FOUND}) + +#--------------------------------------------------------------------- +# DEPRECATED: Setting variables for backward compatibility. +#--------------------------------------------------------------------- +set(GRAPHICSMAGICK_BINARY_PATH ${GraphicsMagick_EXECUTABLE_DIR} + CACHE PATH "Path to the GraphicsMagick binary directory.") +set(GRAPHICSMAGICK_CONVERT_EXECUTABLE ${GraphicsMagick_convert_EXECUTABLE} + CACHE FILEPATH "Path to GraphicsMagick's convert executable.") +set(GRAPHICSMAGICK_MOGRIFY_EXECUTABLE ${GraphicsMagick_mogrify_EXECUTABLE} + CACHE FILEPATH "Path to GraphicsMagick's mogrify executable.") +set(GRAPHICSMAGICK_IMPORT_EXECUTABLE ${GraphicsMagick_import_EXECUTABLE} + CACHE FILEPATH "Path to GraphicsMagick's import executable.") +set(GRAPHICSMAGICK_MONTAGE_EXECUTABLE ${GraphicsMagick_montage_EXECUTABLE} + CACHE FILEPATH "Path to GraphicsMagick's montage executable.") +set(GRAPHICSMAGICK_COMPOSITE_EXECUTABLE ${GraphicsMagick_composite_EXECUTABLE} + CACHE FILEPATH "Path to GraphicsMagick's composite executable.") +mark_as_advanced( + GRAPHICSMAGICK_BINARY_PATH + GRAPHICSMAGICK_CONVERT_EXECUTABLE + GRAPHICSMAGICK_MOGRIFY_EXECUTABLE + GRAPHICSMAGICK_IMPORT_EXECUTABLE + GRAPHICSMAGICK_MONTAGE_EXECUTABLE + GRAPHICSMAGICK_COMPOSITE_EXECUTABLE + ) diff --git a/cmake/Findlibsodium.cmake b/cmake/Findlibsodium.cmake new file mode 100644 index 0000000..5bf917a --- /dev/null +++ b/cmake/Findlibsodium.cmake @@ -0,0 +1,29 @@ +find_package(PkgConfig) + +if(PKG_CONFIG_FOUND) + pkg_check_modules(libsodium libsodium) +endif() + +if(NOT libsodium_FOUND) + find_library(libsodium_LIBRARIES name sodium) + find_file(libsodium_INCLUDE_DIRS NAMES + sodium.h + sodium/core.h + PATH_SUFFIXES include/ include/sodium) + + if (libsodium_LIBRARIES STREQUAL "libsodium_LIBRARIES-NOTFOUND") + message(FATAL_ERROR "libsodium not found") + elseif (libsodium_INCLUDE_DIRS STREQUAL "libsodium_INCLUDE_DIRS-NOTFOUND") + message(FATAL_ERROR "libsodium headers not found") + endif() +endif() + +message("libsodium_LINK_LIBRARIES=${libsodium_LINK_LIBRARIES}") +message("libsodium_INCLUDE_DIRS=${libsodium_INCLUDE_DIRS}") + +if(NOT TARGET libsodium) + add_library(libsodium INTERFACE IMPORTED) + set_target_properties(libsodium PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES ${libsodium_INCLUDE_DIRS} + INTERFACE_LINK_LIBRARIES ${libsodium_LINK_LIBRARIES}) +endif() diff --git a/cmake/Findweb.cmake b/cmake/Findweb.cmake new file mode 100644 index 0000000..a5d1d68 --- /dev/null +++ b/cmake/Findweb.cmake @@ -0,0 +1,24 @@ +mark_as_advanced(WEB_LIBRARY WEB_INCLUDE_DIR) +find_library(WEB_LIBRARY NAMES libweb web) + +find_path(WEB_INCLUDE_DIR + NAMES + handler.h + html.h + http.h + server.h + wildcard_cmp.h + PATH_SUFFIXES libweb include/libweb) + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(web + DEFAULT_MSG WEB_LIBRARY WEB_INCLUDE_DIR) + +if(WEB_FOUND) + if(NOT TARGET web) + add_library(web UNKNOWN IMPORTED) + set_target_properties(web PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES "${WEB_INCLUDE_DIR}" + IMPORTED_LOCATION "${WEB_LIBRARY}") + endif() +endif() diff --git a/configure b/configure new file mode 100755 index 0000000..1fff315 --- /dev/null +++ b/configure @@ -0,0 +1,262 @@ +#! /bin/sh + +set -e + +default_prefix=/usr/local +prefix=$default_prefix +default_CC='c99' +# FILE_OFFSET_BITS=64 is required for large file support on 32-bit platforms. +default_CFLAGS='-O1 -Wall -MD' +default_LDFLAGS="" + +CC=${CC:-$default_CC} + +help() +{ + cat <<-EOF +$0 [OPTION ...] + +--prefix Set installation directory [$default_prefix] + +Some influential environment variables: + CC C compiler [$default_CC] + CFLAGS C compiler flags [$default_CFLAGS] + LDFLAGS Link-time flags [$default_LDFLAGS] +EOF +} + +while true; do + split_arg=0 + + if printf "%s" "$1" | grep -e '=' > /dev/null + then + key="$(printf "%s" "$1" | cut -d '=' -f1)" + value="$(printf "%s" "$1" | cut -d '=' -f2)" + split_arg=1 + else + key="$1" + value="$2" + fi + + case "$key" in + --prefix ) prefix="$value"; shift; test $split_arg -eq 0 && shift ;; + -h | --help ) help; exit 0 ;; + * ) test "$1" != "" && help && exit 1 || break ;; + esac +done + +if pkg-config sqlite3 +then + proj_CFLAGS="$proj_CFLAGS $(pkg-config --cflags sqlite3)" + proj_LDFLAGS="$proj_LDFLAGS $(pkg-config --libs sqlite3)" + tk_LDFLAGS="$tk_LDFLAGS $(pkg-config --libs sqlite3)" +else + echo "Error: sqlite3 not found" >&2 + exit 1 +fi + +if pkg-config libcjson +then + proj_CFLAGS="$proj_CFLAGS $(pkg-config --cflags libcjson)" + proj_LDFLAGS="$proj_LDFLAGS $(pkg-config --libs libcjson)" + tk_LDFLAGS="$tk_LDFLAGS $(pkg-config --libs libcjson)" +else + echo "Error: libcjson not found." >&2 + exit 1 +fi + +if pkg-config libsodium +then + proj_CFLAGS="$proj_CFLAGS $(pkg-config --cflags libsodium)" + proj_LDFLAGS="$proj_LDFLAGS $(pkg-config --libs libsodium)" + tk_LDFLAGS="$tk_LDFLAGS $(pkg-config --libs libsodium)" +else + echo "Error: libsodium not found" >&2 + exit 1 +fi + +if pkg-config dynstr +then + in_tree_dynstr=0 + proj_CFLAGS="$proj_CFLAGS $(pkg-config --cflags dynstr)" + proj_LDFLAGS="$proj_LDFLAGS $(pkg-config --libs dynstr)" + tk_LDFLAGS="$tk_LDFLAGS $(pkg-config --libs dynstr)" +else + echo "Info: dynstr not found. Using in-tree copy" >&2 + in_tree_dynstr=1 + proj_CFLAGS="$proj_CFLAGS -Ilibweb/dynstr/include" + proj_LDFLAGS="$proj_LDFLAGS -Llibweb/dynstr -ldynstr" + tk_LDFLAGS="$tk_LDFLAGS -Llibweb/dynstr -ldynstr" +fi + +if pkg-config libweb +then + in_tree_libweb=0 + proj_CFLAGS="$proj_CFLAGS $(pkg-config --cflags libweb)" + proj_LDFLAGS="$proj_LDFLAGS $(pkg-config --libs libweb)" +else + echo "Info: libweb not found. Using in-tree copy" >&2 + in_tree_libweb=1 + proj_CFLAGS="$proj_CFLAGS -Ilibweb/include" + proj_LDFLAGS="$proj_LDFLAGS -Llibweb -lweb" + + if [ -f libweb/Makefile ] + then + echo "Info: Re-configuring libweb" >&2 + (cd libweb && CFLAGS="$CFLAGS" LDFLAGS="$LDFLAGS" ./configure \ + --prefix="$prefix") + fi +fi + +proj_CFLAGS="$proj_CFLAGS $default_CFLAGS $CFLAGS" +proj_LDFLAGS="$proj_LDFLAGS $default_LDFLAGS $LDFLAGS" +tk_LDFLAGS="$tk_LDFLAGS $LDFLAGS" + +cleanup() +{ + rm -f $F +} + +F=/tmp/Makefile.nanobbs +trap cleanup EXIT + +cat <<EOF > $F +.POSIX: + +CC = $CC +PREFIX = $prefix +DST = $prefix/bin +CFLAGS = $CFLAGS +LDFLAGS = $LDFLAGS +PROJ_CFLAGS = $proj_CFLAGS +PROJ_LDFLAGS = $proj_LDFLAGS +TK_LDFLAGS = $tk_LDFLAGS +EOF + +cat <<"EOF" >> $F +PROJECT = nanobbs +DEPS = $(OBJECTS:.o=.d) +OBJECTS = \ + auth.o \ + astrftime.o \ + db.o \ + db_post.o \ + db_section.o \ + db_topic.o \ + default_prv_policy.o \ + default_style.o \ + default_terms.o \ + ep_create.o \ + ep_index.o \ + ep_login.o \ + ep_logout.o \ + ep_passwd.o \ + ep_signup.o \ + ep_style.o \ + ep_ucp.o \ + ep_view.o \ + form_badreq.o \ + form_category.o \ + form_footer.o \ + form_head.o \ + form_login.o \ + form_post.o \ + form_section.o \ + form_shortpwd.o \ + form_topic.o \ + form_unauthorized.o \ + gencookie.o \ + getul.o \ + getul_n.o \ + jwt.o \ + login_get.o \ + main.o \ + op.o \ + sanitize.o + +TK_OBJECTS = \ + jwt.o \ + tokengen.o + +all: $(PROJECT) tokengen + +.c.o: + $(CC) $(PROJ_CFLAGS) -c $< -o $@ + +install: all + mkdir -p $(DST) + install nanobbs tokengen $(DST) + +cd doc && $(MAKE) PREFIX=$(PREFIX) install + +FORCE: +$(PROJECT): $(OBJECTS) + $(CC) $(OBJECTS) $(PROJ_LDFLAGS) -o $@ + +tokengen: $(TK_OBJECTS) + $(CC) $(TK_OBJECTS) $(TK_LDFLAGS) -o $@ +EOF + +if [ $in_tree_dynstr -ne 0 ] +then +cat <<"EOF" >> $F +DYNSTR = libweb/dynstr/libdynstr.a +$(PROJECT) tokengen: $(DYNSTR) +$(DYNSTR): FORCE + +cd libweb/dynstr && $(MAKE) CC=$(CC) +EOF +fi + +if [ $in_tree_libweb -ne 0 ] +then +cat <<"EOF" >> $F +LIBWEB_MK = libweb/Makefile +$(LIBWEB_MK): + cd libweb && CFLAGS="$(CFLAGS)" LDFLAGS="$(LDFLAGS)" \ + ./configure --prefix=$(PREFIX) +LIBWEB = libweb/libweb.a +$(PROJECT): $(LIBWEB) +$(LIBWEB): $(LIBWEB_MK) FORCE + +cd libweb && $(MAKE) CC=$(CC) +libweb: $(LIBWEB) +EOF +fi + +cat <<"EOF" >> $F +clean: + rm -f $(OBJECTS) $(TK_OBJECTS) $(DEPS) +EOF + +if [ $in_tree_dynstr -ne 0 ] +then +cat <<"EOF" >> $F + +cd libweb/dynstr && $(MAKE) clean +EOF +fi + +if [ $in_tree_libweb -ne 0 ] +then +cat <<"EOF" >> $F + +test -f $(LIBWEB_MK) && cd libweb && $(MAKE) clean || : +EOF +fi + +cat <<"EOF" >> $F +distclean: clean + rm -f nanobbs tokengen + rm Makefile +EOF + +# dynstr has no distclean target as of the time of this writing. + +if [ $in_tree_libweb -ne 0 ] +then +cat <<"EOF" >> $F + +test -f $(LIBWEB_MK) && cd libweb && $(MAKE) distclean || : +EOF +fi + +cat <<"EOF" >> $F +-include $(DEPS) +EOF + +mv $F Makefile @@ -0,0 +1,188 @@ +/* + * nanobbs, a tiny forums software. + * Copyright (C) 2025-2026 Xavier Del Campo Romero + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#define _POSIX_C_SOURCE 200809L + +#include "db.h" +#include "defs.h" +#include <dynstr.h> +#include <sqlite3.h> +#include <sys/stat.h> +#include <errno.h> +#include <stddef.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +static int column(sqlite3 *const db, sqlite3_stmt *const stmt, + const char *const name) +{ + const int n = sqlite3_data_count(stmt); + + if (!n) + { + fprintf(stderr, "%s: sqlite3_data_count: %s\n", __func__, + sqlite3_errmsg(db)); + return -1; + } + + for (int i = 0; i < n; i++) + { + const char *const col = sqlite3_column_name(stmt, i); + + if (!col) + { + fprintf(stderr, "%s: sqlite3_column_name: %s\n", __func__, + sqlite3_errmsg(db)); + return -1; + } + else if (!strcmp(col, name)) + return i; + } + + fprintf(stderr, "%s: could not find column \"%s\"\n", __func__, name); + return -1; +} + +int db_int(sqlite3 *const db, sqlite3_stmt *const stmt, const char *const name, + int *const out) +{ + const int col = column(db, stmt, name); + + if (col < 0) + return -1; + + *out = sqlite3_column_int(stmt, col); + return 0; +} + +int db_uint(sqlite3 *const db, sqlite3_stmt *const stmt, const char *const name, + unsigned *const out) +{ + int v; + + if (db_int(db, stmt, name, &v)) + return -1; + else if (v < 0) + { + fprintf(stderr, "%s: unexpected negative value for %s: %d\n", __func__, + name, v); + return -1; + } + + *out = v; + return 0; +} + +int db_bigint(sqlite3 *const db, sqlite3_stmt *const stmt, + const char *const name, long long *const out) +{ + const int col = column(db, stmt, name); + + if (col < 0) + return -1; + + *out = sqlite3_column_int64(stmt, col); + return 0; +} + +int db_biguint(sqlite3 *const db, sqlite3_stmt *const stmt, + const char *const name, unsigned long long *const out) +{ + const int col = column(db, stmt, name); + sqlite3_int64 v; + + if (col < 0 || (v = sqlite3_column_int64(stmt, col)) < 0) + return -1; + + *out = v; + return 0; +} + +char *db_str(sqlite3 *const db, sqlite3_stmt *const stmt, + const char *const name) +{ + char *ret = NULL; + const int col = column(db, stmt, name); + + if (col < 0) + return NULL; + + const unsigned char *const s = sqlite3_column_text(stmt, col); + + if (!s) + { + fprintf(stderr, "%s: sqlite3_column_text: %s\n", __func__, + sqlite3_errmsg(db)); + goto failure; + } + else if (!(ret = strndup((const char *)s, sqlite3_column_bytes(stmt, col)))) + { + fprintf(stderr, "%s: strndup(3): %s\n", __func__, strerror(errno)); + goto failure; + } + + return ret; + +failure: + free(ret); + return NULL; +} + +int db_rollback(sqlite3 *const db) +{ + char *msg = NULL; + const int e = sqlite3_exec(db, "ROLLBACK;", NULL, NULL, &msg); + + if (e != SQLITE_OK) + fprintf(stderr, "%s: sqlite3_exec: %s, msg=%s\n", __func__, + sqlite3_errstr(e), msg); + + sqlite3_free(msg); + return e != SQLITE_OK; +} + +int db_open(const char *const dir, sqlite3 **const out) +{ + int ret = SQLITE_ERROR; + sqlite3 *db; + struct dynstr d; + const mode_t m = umask(S_IWGRP | S_IWOTH | S_IROTH); + + dynstr_init(&d); + + if (dynstr_append(&d, "%s/" PROJECT_NAME ".db", dir)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + else if ((ret = sqlite3_open(d.str, &db)) != SQLITE_OK) + { + fprintf(stderr, "%s: sqlite3_open %s: %s\n", __func__, d.str, + sqlite3_errstr(ret)); + goto end; + } + + *out = db; + ret = 0; + +end: + umask(m); + dynstr_free(&d); + return ret; +} @@ -0,0 +1,69 @@ +/* + * nanobbs, a tiny forums software. + * Copyright (C) 2025-2026 Xavier Del Campo Romero + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#ifndef DB_H +#define DB_H + +#include <sqlite3.h> +#include <time.h> + +struct db_section +{ + unsigned long id, catid; + char *desc, *name; +}; + +struct db_topic +{ + unsigned long id, secid; + char *title; + time_t creat; +}; + +struct db_post +{ + unsigned long id, topid, uid; + char *text; + time_t creat; +}; + +struct db_user +{ + unsigned long id, roleid; + char *name; + time_t creat; +}; + +int db_open(const char *dir, sqlite3 **db); +char *db_str(sqlite3 *db, sqlite3_stmt *stmt, const char *name); +int db_id(sqlite3 *db, sqlite3_stmt *stmt, const char *name, unsigned *out); +int db_int(sqlite3 *db, sqlite3_stmt *stmt, const char *name, int *out); +int db_uint(sqlite3 *db, sqlite3_stmt *stmt, const char *name, unsigned *out); +int db_bigint(sqlite3 *db, sqlite3_stmt *stmt, const char *name, + long long *out); +int db_biguint(sqlite3 *db, sqlite3_stmt *stmt, const char *name, + unsigned long long *out); +int db_section(sqlite3 *db, sqlite3_stmt *stmt, struct db_section *s); +int db_topic(sqlite3 *db, sqlite3_stmt *stmt, struct db_topic *t); +int db_post(sqlite3 *db, sqlite3_stmt *stmt, struct db_post *p); +void db_section_free(struct db_section *s); +void db_topic_free(struct db_topic *t); +void db_post_free(struct db_post *p); +int db_rollback(sqlite3 *db); + +#endif diff --git a/db_post.c b/db_post.c new file mode 100644 index 0000000..2f25ea2 --- /dev/null +++ b/db_post.c @@ -0,0 +1,63 @@ +#define _POSIX_C_SOURCE 200809L + +#include "db.h" +#include <sqlite3.h> +#include <stddef.h> +#include <stdio.h> +#include <stdlib.h> + +void db_post_free(struct db_post *const p) +{ + if (!p) + return; + + free(p->text); +} + +int db_post(sqlite3 *const db, sqlite3_stmt *const stmt, struct db_post *const p) +{ + char *text = NULL; + unsigned long long id, uid, topid; + long long creat; + + if (db_biguint(db, stmt, "id", &id)) + { + fprintf(stderr, "%s: failed to get id\n", __func__); + goto failure; + } + else if (db_biguint(db, stmt, "topid", &topid)) + { + fprintf(stderr, "%s: failed to get topic id\n", __func__); + goto failure; + } + else if (db_biguint(db, stmt, "uid", &uid)) + { + fprintf(stderr, "%s: failed to user id\n", __func__); + goto failure; + } + else if (db_bigint(db, stmt, "creat", &creat)) + { + fprintf(stderr, "%s: failed to get creation time\n", __func__); + goto failure; + } + else if (!(text = db_str(db, stmt, "text"))) + { + fprintf(stderr, "%s: failed to get text\n", __func__); + goto failure; + } + + *p = (const struct db_post) + { + .topid = topid, + .creat = creat, + .text = text, + .uid = uid, + .id = id + }; + + return 0; + +failure: + free(text); + return -1; +} diff --git a/db_section.c b/db_section.c new file mode 100644 index 0000000..72aa227 --- /dev/null +++ b/db_section.c @@ -0,0 +1,59 @@ +#define _POSIX_C_SOURCE 200809L + +#include "db.h" +#include <sqlite3.h> +#include <stddef.h> +#include <stdio.h> +#include <stdlib.h> + +void db_section_free(struct db_section *const s) +{ + if (!s) + return; + + free(s->name); + free(s->desc); +} + +int db_section(sqlite3 *const db, sqlite3_stmt *const stmt, + struct db_section *const s) +{ + unsigned long long id, catid; + char *name = NULL, *desc = NULL; + + if (db_biguint(db, stmt, "id", &id)) + { + fprintf(stderr, "%s: failed to get id\n", __func__); + goto failure; + } + else if (db_biguint(db, stmt, "catid", &catid)) + { + fprintf(stderr, "%s: failed to get category id\n", __func__); + goto failure; + } + else if (!(name = db_str(db, stmt, "name"))) + { + fprintf(stderr, "%s: failed to get name\n", __func__); + goto failure; + } + else if (!(desc = db_str(db, stmt, "description"))) + { + fprintf(stderr, "%s: failed to get name\n", __func__); + goto failure; + } + + *s = (const struct db_section) + { + .catid = catid, + .name = name, + .desc = desc, + .id = id + }; + + return 0; + +failure: + free(desc); + free(name); + return -1; +} diff --git a/db_topic.c b/db_topic.c new file mode 100644 index 0000000..c07d5ee --- /dev/null +++ b/db_topic.c @@ -0,0 +1,57 @@ +#define _POSIX_C_SOURCE 200809L + +#include "db.h" +#include <sqlite3.h> +#include <stddef.h> +#include <stdio.h> +#include <stdlib.h> + +void db_topic_free(struct db_topic *const t) +{ + if (!t) + return; + + free(t->title); +} + +int db_topic(sqlite3 *const db, sqlite3_stmt *const stmt, struct db_topic *const t) +{ + char *title = NULL; + unsigned long long id, secid; + long long creat; + + if (db_biguint(db, stmt, "id", &id)) + { + fprintf(stderr, "%s: failed to get id\n", __func__); + goto failure; + } + else if (db_biguint(db, stmt, "secid", &secid)) + { + fprintf(stderr, "%s: failed to get section id\n", __func__); + goto failure; + } + else if (db_bigint(db, stmt, "creat", &creat)) + { + fprintf(stderr, "%s: failed to get creation time\n", __func__); + goto failure; + } + else if (!(title = db_str(db, stmt, "title"))) + { + fprintf(stderr, "%s: failed to get name\n", __func__); + goto failure; + } + + *t = (const struct db_topic) + { + .secid = secid, + .title = title, + .creat = creat, + .id = id + }; + + return 0; + +failure: + free(title); + return -1; +} diff --git a/default.h b/default.h new file mode 100644 index 0000000..ded67d7 --- /dev/null +++ b/default.h @@ -0,0 +1,10 @@ +#ifndef DEFAULT_H +#define DEFAULT_H + +#include <stddef.h> + +extern const char default_style[], default_terms[], default_prv_policy[]; +extern const size_t default_style_len, default_terms_len, + default_prv_policy_len; + +#endif diff --git a/default_prv_policy.c b/default_prv_policy.c new file mode 100644 index 0000000..e9b674a --- /dev/null +++ b/default_prv_policy.c @@ -0,0 +1,30 @@ +/* + * nanobbs, a tiny forums software. + * Copyright (C) 2025-2026 Xavier Del Campo Romero + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#include "default.h" +#include "defs.h" +#include <stddef.h> + +const char default_prv_policy[] = + "Please enter your privacy policy here.\n" + "By default, " PROJECT_NAME + " collects usernames, passwords and thumbnails.\n" + "A cookie is sent to the user when logged in.\n" + ; + +const size_t default_prv_policy_len = sizeof default_prv_policy - 1; diff --git a/default_style.c b/default_style.c new file mode 100644 index 0000000..e94301e --- /dev/null +++ b/default_style.c @@ -0,0 +1,63 @@ +#include "default.h" +#include <stddef.h> + +const char default_style[] = + "body\n" + "{\n" + " font-family: 'Courier New', Courier, monospace;\n" + "}\n" + "td\n" + "{\n" + " font-size: 14px;\n" + "}\n" + "a\n" + "{\n" + " text-decoration: none;\n" + "}\n" + ".userform\n" + "{\n" + " padding: 4px;\n" + "}\n" + ".loginform\n" + "{\n" + " display: grid;\n" + "}\n" + "form, label, table\n" + "{\n" + " margin: auto;\n" + "}\n" + "div\n" + "{\n" + " align-items: center;\n" + " display: grid;\n" + "}\n" + "input, .abutton\n" + "{\n" + " margin: auto;\n" + " border: 1px solid;\n" + " border-radius: 8px;\n" + "}\n" + "header, footer\n" + "{\n" + " display: flex;\n" + " justify-content: center;\n" + " text-decoration: auto;\n" + "}\n" + "table\n" + "{\n" + " max-width: 50%;\n" + "}\n" + "tr:nth-child(even)\n" + "{\n" + " background-color: lightgray;\n" + "}\n" + ".page\n" + "{\n" + " margin: 0.2rem;\n" + "}\n" + ".pages\n" + "{\n" + " display: flex;\n" + "}\n"; + +const size_t default_style_len = sizeof default_style - 1; diff --git a/default_terms.c b/default_terms.c new file mode 100644 index 0000000..f532cc9 --- /dev/null +++ b/default_terms.c @@ -0,0 +1,13 @@ +#include "default.h" +#include <stddef.h> + +const char default_terms[] = + "Please enter your terms of service.\n" + "They can be modified from the TERMS file inside the prefix directory.\n" + "For example, a bullet list:\n" + "- Be nice to others.\n" + "- Do not upload illegal or harmful content.\n" + "A token can be generated by the administrator with tokengen(1).\n" + ; + +const size_t default_terms_len = sizeof default_terms - 1; @@ -0,0 +1,43 @@ +/* + * nanobbs, a tiny forums software. + * Copyright (C) 2025-2026 Xavier Del Campo Romero + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#ifndef DEFS_H +#define DEFS_H + +#define MINPWDLEN 8 +#define PAGE_LIMIT 15 +#define PROJECT_NAME "nanobbs" +#define PROJECT_DESC "a tiny forums software" +#define PROJECT_TITLE PROJECT_NAME ", " PROJECT_DESC +#define PROJECT_TAG "<title>" PROJECT_TITLE "</title>" +#define PROJECT_URL "https://gitea.privatedns.org/xavi/" PROJECT_NAME +#define COMMON_HEAD \ + " <meta charset=\"UTF-8\">\n" \ + " <meta name=\"viewport\"\n" \ + " content=\"width=device-width, initial-scale=1,\n" \ + " maximum-scale=1\">\n" \ + PROJECT_TAG "\n" +#define DOCTYPE_TAG "<!DOCTYPE html>\n" +#define TERMS_PATH "TERMS" +#define PRV_PATH "PRIVACY" +#define STYLE_PATH "style.css" +#define STYLE_A "<link href=\"/" STYLE_PATH "\" rel=\"stylesheet\">" +#define USER_SYMS "abcdefghijklmnopqrstuvwxyz" \ + "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +#endif diff --git a/endpoints.h b/endpoints.h new file mode 100644 index 0000000..b41ef17 --- /dev/null +++ b/endpoints.h @@ -0,0 +1,21 @@ +#ifndef ENDPOINTS_H +#define ENDPOINTS_H + +#include <libweb/http.h> + +struct cfg +{ + const char *dir; +}; + +int ep_index(const struct http_payload *p, struct http_response *r, void *user); +int ep_view(const struct http_payload *p, struct http_response *r, void *user); +int ep_style(const struct http_payload *p, struct http_response *r, void *user); +int ep_login(const struct http_payload *p, struct http_response *r, void *user); +int ep_logout(const struct http_payload *p, struct http_response *r, void *user); +int ep_signup(const struct http_payload *p, struct http_response *r, void *user); +int ep_passwd(const struct http_payload *p, struct http_response *r, void *user); +int ep_create(const struct http_payload *p, struct http_response *r, void *user); +int ep_ucp(const struct http_payload *p, struct http_response *r, void *user); + +#endif diff --git a/ep_create.c b/ep_create.c new file mode 100644 index 0000000..dfe0d6f --- /dev/null +++ b/ep_create.c @@ -0,0 +1,695 @@ +/* + * nanobbs, a tiny forums software. + * Copyright (C) 2025-2026 Xavier Del Campo Romero + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#define _POSIX_C_SOURCE 200809L + +#include "endpoints.h" +#include "auth.h" +#include "db.h" +#include "form.h" +#include "op.h" +#include "utils.h" +#include <dynstr.h> +#include <libweb/form.h> +#include <libweb/http.h> +#include <libweb/html.h> +#include <sqlite3.h> +#include <errno.h> +#include <stdbool.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> + +struct create +{ + unsigned long uid, category, section, topic; + enum auth_role role; + time_t creat; + char *name, *post; + sqlite3 *db; +}; + +static int teardown(struct create *const c) +{ + int ret = 0, error; + + if (!c) + return 0; + else if ((error = sqlite3_close(c->db)) != SQLITE_OK) + { + fprintf(stderr, "%s: sqlite3_close: %s\n", __func__, + sqlite3_errstr(error)); + ret = -1; + } + + free(c->name); + free(c->post); + free(c); + return ret; +} + +static void free_create(void *const p) +{ + teardown(p); +} + +static int end_post(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + int ret = -1; + struct create *const c = args; + struct dynstr d; + const int error = sqlite3_close(c->db); + + c->db = NULL; + dynstr_init(&d); + + if (error != SQLITE_OK) + { + fprintf(stderr, "%s: sqlite3_close: %s\n", __func__, + sqlite3_errstr(error)); + goto end; + } + else if (dynstr_append(&d, "/view/%lu/%lu/%jd", c->category, c->section, + c->topic)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + + *r = (const struct http_response){.status = HTTP_STATUS_SEE_OTHER}; + + if (http_response_add_header(r, "Location", d.str)) + { + fprintf(stderr, "%s: http_response_add_header failed\n", __func__); + goto end; + } + + free_create(c); + ret = 0; + +end: + dynstr_free(&d); + return ret; +} + +static int create_post(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + int ret = -1, error; + struct form *f = NULL; + char *spost = NULL; + struct dynstr d; + const char *post; + struct create *const c = args; + const time_t t = time(NULL); + + dynstr_init(&d); + + if (c->role < AUTH_ROLE_USER) + { + ret = form_unauthorized("User rights or higher required", r); + goto end; + } + else if (t == (time_t)-1) + { + fprintf(stderr, "%s: time(2): %s\n", __func__, strerror(errno)); + goto end; + } + else if ((error = form_alloc(p->u.post.data, &f))) + { + if ((ret = error) < 0) + fprintf(stderr, "%s: form_alloc failed\n", __func__); + + goto end; + } + else if (!(post = form_value(f, "post"))) + { + ret = form_badreq("Missing post", r); + goto end; + } + else if (!(spost = sanitize(post))) + { + fprintf(stderr, "%s: sanitize failed\n", __func__); + goto end; + } + else if (dynstr_append(&d, "INSERT INTO posts(text, creat, uid, topid) " + "VALUES ('%s', '%jd', %lu, %lu)", spost, (intmax_t)t, c->uid, + c->topic)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + + const struct op_cfg op = + { + .error = free_create, + .end = end_post, + .args = c + }; + + if (!op_run(c->db, d.str, &op, r)) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + goto end; + } + + ret = 0; + +end: + free(spost); + form_free(f); + dynstr_free(&d); + return ret; +} + +static int topic(sqlite3_stmt *const stmt, const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + struct create *const c = args; + unsigned t; + + if (db_uint(c->db, stmt, "topid", &t)) + { + fprintf(stderr, "%s: db_uint failed\n", __func__); + return -1; + } + + c->topic = t; + return 0; +} + +static void rollback(void *const args) +{ + struct create *const c = args; + + if (!c) + return; + + db_rollback(c->db); + free_create(c); +} + +static int commit(const struct http_payload *const p, + struct http_response *r, void *const user, void *const args) +{ + struct create *const c = args; + + const struct op_cfg op = + { + .error = rollback, + .end = end_post, + .args = c + }; + + if (!op_run(c->db, "COMMIT;", &op, r)) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + return -1; + } + + return 0; +} + +static int post_inserted(const struct http_payload *const p, + struct http_response *r, void *const user, void *const args) +{ + static const char query[] = "SELECT topid FROM posts " + "WHERE id = last_insert_rowid();"; + struct create *const c = args; + + const struct op_cfg op = + { + .error = rollback, + .end = commit, + .row = topic, + .args = c + }; + + if (!op_run(c->db, query, &op, r)) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + return -1; + } + + return 0; +} + +static int topic_inserted(const struct http_payload *const p, + struct http_response *r, void *const user, void *const args) +{ + int ret = -1; + struct create *const c = args; + struct dynstr d; + + dynstr_init(&d); + + if (dynstr_append(&d, "INSERT INTO posts(text, creat, uid, topid) " + "VALUES ('%s', %jd, %lu, last_insert_rowid());", c->post, c->creat, + c->uid)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + + const struct op_cfg op = + { + .end = post_inserted, + .error = rollback, + .args = c + }; + + if (!op_run(c->db, d.str, &op, r)) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + goto end; + } + + ret = 0; + +end: + dynstr_free(&d); + return ret; +} + +static int begin_tr(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + int ret = -1; + struct create *const c = args; + struct dynstr d; + + dynstr_init(&d); + + if (dynstr_append(&d, "INSERT INTO topics(title, creat, secid, uid) " + "VALUES ('%s', %jd, %lu, %lu);", c->name, c->creat, c->section, c->uid)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + + const struct op_cfg op = + { + .end = topic_inserted, + .error = rollback, + .args = c + }; + + if (!op_run(c->db, d.str, &op, r)) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + goto end; + } + + ret = 0; + +end: + dynstr_free(&d); + return ret; +} + +static int create_topic(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + int ret = -1, error; + char *sname = NULL, *spost = NULL; + struct form *f = NULL; + struct create *const c = args; + const char *name, *post; + const time_t t = time(NULL); + + if (c->role < AUTH_ROLE_USER) + { + ret = form_unauthorized("User rights or higher required", r); + goto end; + } + else if (t == (time_t)-1) + { + fprintf(stderr, "%s: time(2): %s\n", __func__, strerror(errno)); + goto end; + } + else if ((error = form_alloc(p->u.post.data, &f))) + { + if ((ret = error) < 0) + fprintf(stderr, "%s: form_alloc failed\n", __func__); + else + ret = form_badreq("Missing topic name and/or post", r); + + goto end; + } + else if (!(name = form_value(f, "name"))) + { + ret = form_badreq("Missing topic name", r); + goto end; + } + else if (!(post = form_value(f, "post"))) + { + ret = form_badreq("Missing post", r); + goto end; + } + else if (!(sname = sanitize(name)) || !(spost = sanitize(post))) + { + fprintf(stderr, "%s: sanitize failed\n", __func__); + goto end; + } + + const struct op_cfg op = + { + .error = free_create, + .end = begin_tr, + .args = c + }; + + if (!op_run(c->db, "BEGIN TRANSACTION;", &op, r)) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + goto end; + } + + c->name = sname; + c->post = spost; + c->creat = t; + ret = 0; + +end: + + if (ret) + { + free(spost); + free(sname); + } + + form_free(f); + return ret; +} + +static int end_section(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + int ret = -1; + struct create *const c = args; + struct dynstr d; + const int error = sqlite3_close(c->db); + + c->db = NULL; + dynstr_init(&d); + + if (error != SQLITE_OK) + { + fprintf(stderr, "%s: sqlite3_close: %s\n", __func__, + sqlite3_errstr(error)); + goto end; + } + else if (dynstr_append(&d, "/view/%lu", c->category)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + + *r = (const struct http_response){.status = HTTP_STATUS_SEE_OTHER}; + + if (http_response_add_header(r, "Location", d.str)) + { + fprintf(stderr, "%s: http_response_add_header failed\n", __func__); + goto end; + } + + free_create(c); + ret = 0; + +end: + dynstr_free(&d); + return ret; +} + +static int create_section(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + int ret = -1, error; + struct dynstr d; + char *sname = NULL, *sdesc = NULL; + struct form *f = NULL; + struct create *const c = args; + const char *name, *description; + + dynstr_init(&d); + + if (c->role < AUTH_ROLE_MOD) + { + ret = form_unauthorized("Moderator rights required", r); + goto end; + } + else if ((error = form_alloc(p->u.post.data, &f))) + { + if ((ret = error < 0)) + fprintf(stderr, "%s: form_alloc failed\n", __func__); + else + ret = form_badreq("Missing section name and/or title", r); + + goto end; + } + else if (!(name = form_value(f, "name"))) + { + ret = form_badreq("Missing section name", r); + goto end; + } + else if (!(description = form_value(f, "description"))) + { + ret = form_badreq("Missing section description", r); + goto end; + } + else if (!(sname = sanitize(name)) + || !(sdesc = sanitize(description))) + { + fprintf(stderr, "%s: sanitize failed\n", __func__); + goto end; + } + else if (dynstr_append(&d, "INSERT INTO sections(name, description, catid)" + "VALUES('%s', '%s', %lu);", sname, sdesc, c->category)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + + const struct op_cfg op = + { + .error = free_create, + .end = end_section, + .args = c + }; + + if (!op_run(c->db, d.str, &op, r)) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + goto end; + } + + ret = 0; + +end: + free(sdesc); + free(sname); + form_free(f); + dynstr_free(&d); + return ret; +} + +static int end_category(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + int ret = -1; + struct create *const c = args; + const int error = sqlite3_close(c->db); + + c->db = NULL; + + if (error != SQLITE_OK) + { + fprintf(stderr, "%s: sqlite3_close: %s\n", __func__, + sqlite3_errstr(error)); + goto end; + } + + *r = (const struct http_response){.status = HTTP_STATUS_SEE_OTHER}; + + if (http_response_add_header(r, "Location", "/")) + { + fprintf(stderr, "%s: http_response_add_header failed\n", __func__); + goto end; + } + + ret = 0; + +end: + free_create(c); + return ret; +} + +static int create_category(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + int ret = -1, error; + char *sname = NULL; + struct create *const c = args; + struct form *f = NULL; + struct dynstr d; + const char *name; + + dynstr_init(&d); + + if (c->role < AUTH_ROLE_MOD) + { + ret = form_unauthorized("Moderator rights or higher required", r); + goto end; + } + else if ((error = form_alloc(p->u.post.data, &f))) + { + if ((ret = error) < 0) + fprintf(stderr, "%s: form_alloc failed\n", __func__); + else + ret = form_badreq("Missing category name", r); + + goto end; + } + else if (!(name = form_value(f, "name"))) + { + ret = form_badreq("Expected category name", r); + goto end; + } + else if (!(sname = sanitize(name))) + { + fprintf(stderr, "%s: sanitize failed\n", __func__); + goto end; + } + else if (dynstr_append(&d, "INSERT INTO categories(name) VALUES" + "('%s');", sname)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + + const struct op_cfg op = + { + .error = free_create, + .end = end_category, + .args = c + }; + + if (!op_run(c->db, d.str, &op, r)) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + goto end; + } + + ret = 0; + +end: + free(sname); + dynstr_free(&d); + form_free(f); + return ret; +} + +static int set_path(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + int ret = 0; + struct create *const c = args; + const char *tree = p->resource + strlen("/create/"); + + if (!*tree) + r->step.payload = create_category; + else if (getul(&tree, &c->category)) + ret = form_badreq("Invalid category", r); + else if (!*tree) + r->step.payload = create_section; + else if (getul(&tree, &c->section)) + ret = form_badreq("Invalid section", r); + else if (!*tree) + r->step.payload = create_topic; + else if (getul(&tree, &c->topic)) + ret = form_badreq("Invalid topic", r); + else + r->step.payload = create_post; + + return ret; +} + +static int setup(const struct http_payload *const p, + struct http_response *const r, void *const user, + sqlite3 *const db, const struct auth_user *const u) +{ + int ret = -1, error; + struct create *c = NULL; + + if (!u) + { + ret = form_unauthorized("Authentication required", r); + goto failure; + } + else if (u->role <= AUTH_ROLE_BANNED) + { + ret = form_unauthorized("Banned account", r); + goto failure; + } + else if (!(c = malloc(sizeof *c))) + { + fprintf(stderr, "%s: malloc(3): %s\n", __func__, strerror(errno)); + goto failure; + } + + *c = (const struct create) + { + .uid = u->id, + .role = u->role, + .db = db + }; + + *r = (const struct http_response) + { + .step.payload = set_path, + .free = free_create, + .args = c + }; + + return 0; + +failure: + + if ((error = sqlite3_close(db)) != SQLITE_OK) + fprintf(stderr, "%s: sqlite3_close: %s\n", __func__, + sqlite3_errstr(error)); + + free(c); + return ret; +} + +int ep_create(const struct http_payload *const p, + struct http_response *const r, void *const user) +{ + int ret = auth_validate(p, r, user, setup); + + if (ret < 0) + fprintf(stderr, "%s: auth_validate failed\n", __func__); + else if (ret) + ret = form_unauthorized("Authentication required", r); + + return ret; +} diff --git a/ep_index.c b/ep_index.c new file mode 100644 index 0000000..d533220 --- /dev/null +++ b/ep_index.c @@ -0,0 +1,432 @@ +/* + * nanobbs, a tiny forums software. + * Copyright (C) 2025-2026 Xavier Del Campo Romero + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#define _POSIX_C_SOURCE 200809L + +#include "endpoints.h" +#include "auth.h" +#include "defs.h" +#include "db.h" +#include "form.h" +#include "op.h" +#include <dynstr.h> +#include <libweb/http.h> +#include <libweb/html.h> +#include <sqlite3.h> +#include <errno.h> +#include <stdlib.h> +#include <string.h> + +struct category +{ + sqlite3 *db; + struct op *op; + struct html_node *root, *body, *section_ul, *div; +}; + +static void free_category(struct category *const c) +{ + int error; + + if (!c) + return; + else if ((error = sqlite3_close(c->db)) != SQLITE_OK) + fprintf(stderr, "%s: sqlite3_close: %s\n", __func__, + sqlite3_errstr(error)); + + html_node_free(c->root); + free(c); +} + +static int section(sqlite3_stmt *const stmt, + const struct http_payload *const pl, struct http_response *const r, + void *const user, void *const args) +{ + int ret = -1; + struct db_section dbs = {0}; + struct html_node *div, *ul, *a, *p; + struct dynstr d; + struct category *const c = args; + + dynstr_init(&d); + + if (db_section(c->db, stmt, &dbs)) + { + fprintf(stderr, "%s: db_section failed\n", __func__); + goto end; + } + else if (dynstr_append(&d, "/view/%d/%d", dbs.catid, dbs.id)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + else if (!(ul = html_node_add_child(c->div, "ul"))) + { + fprintf(stderr, "%s: html_node_add_child %s failed\n", __func__, "ul"); + goto end; + } + else if (!(div = html_node_add_child(ul, "div"))) + { + fprintf(stderr, "%s: html_node_add_child %s failed\n", __func__, "ul"); + goto end; + } + else if (!(a = html_node_add_child(div, "a"))) + { + fprintf(stderr, "%s: html_node_add_child %s failed\n", __func__, "a"); + goto end; + } + else if (html_node_add_attr(a, "href", d.str)) + { + fprintf(stderr, "%s: html_node_add_attr failed\n", __func__); + goto end; + } + else if (html_node_set_value(a, dbs.name)) + { + fprintf(stderr, "%s: html_node_set_value failed\n", __func__); + goto end; + } + else if (!(p = html_node_add_child(div, "p"))) + { + fprintf(stderr, "%s: html_node_add_child %s failed\n", __func__, "p"); + goto end; + } + else if (html_node_set_value(p, dbs.desc)) + { + fprintf(stderr, "%s: html_node_set_value failed\n", __func__); + goto end; + } + + ret = 0; + +end: + db_section_free(&dbs); + dynstr_free(&d); + return ret; +} + +static int end_section(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + struct category *const c = args; + + op_resume(c->op, r); + return 0; +} + +static void rollback(void *const args) +{ + struct category *const c = args; + + if (!c) + return; + + db_rollback(c->db); + free_category(c); +} + +static int category( sqlite3_stmt *const stmt, + const struct http_payload *const p, struct http_response *const r, + void *const user, void *const args) +{ + int ret = -1; + unsigned long long id; + char *name = NULL; + struct html_node *div, *ul, *a; + struct dynstr d, expr; + struct category *const c = args; + sqlite3 *const db = c->db; + + dynstr_init(&d); + dynstr_init(&expr); + + if (db_biguint(db, stmt, "id", &id)) + { + fprintf(stderr, "%s: failed to get id\n", __func__); + goto end; + } + else if (!(name = db_str(db, stmt, "name"))) + { + fprintf(stderr, "%s: failed to get name\n", __func__); + goto end; + } + else if (dynstr_append(&d, "/view/%d", id)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + else if (!(div = html_node_add_child(c->body, "div"))) + { + fprintf(stderr, "%s: html_node_add_child %s failed\n", __func__, "div"); + goto end; + } + else if (!(ul = html_node_add_child(div, "ul"))) + { + fprintf(stderr, "%s: html_node_add_child %s failed\n", __func__, "ul"); + goto end; + } + else if (!(a = html_node_add_child(ul, "a"))) + { + fprintf(stderr, "%s: html_node_add_child %s failed\n", __func__, "a"); + goto end; + } + else if (html_node_add_attr(a, "href", d.str)) + { + fprintf(stderr, "%s: html_node_add_attr failed\n", __func__); + goto end; + } + else if (html_node_set_value(a, name)) + { + fprintf(stderr, "%s: html_node_set_value failed\n", __func__); + goto end; + } + else if (dynstr_append(&expr, "SELECT * FROM sections WHERE catid = %d " + "ORDER BY id ASC;", id)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + + const struct op_cfg op = + { + .end = end_section, + .row = section, + .error = rollback, + .args = c + }; + + if (!op_run(db, expr.str, &op, r)) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + goto end; + } + + c->div = div; + ret = 0; + +end: + free(name); + dynstr_free(&expr); + dynstr_free(&d); + return ret; +} + +static int end_category(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + struct category *const c = args; + struct dynstr d; + const int error = sqlite3_close(c->db); + + c->db = NULL; + dynstr_init(&d); + + if (error != SQLITE_OK) + { + fprintf(stderr, "%s: sqlite3_close: %s\n", __func__, + sqlite3_errstr(error)); + goto failure; + } + else if (form_footer(c->body, p->resource)) + { + fprintf(stderr, "%s: form_footer failed\n", __func__); + goto failure; + } + else if (dynstr_append(&d, "%s", DOCTYPE_TAG)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto failure; + } + else if (html_serialize(c->root, &d)) + { + fprintf(stderr, "%s: html_serialize failed\n", __func__); + goto failure; + } + + *r = (const struct http_response) + { + .status = HTTP_STATUS_OK, + .buf.rw = d.str, + .free = free, + .n = d.len + }; + + free_category(c); + return 0; + +failure: + dynstr_free(&d); + return -1; +} + +static int commit(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + struct category *const c = args; + const struct op_cfg ocfg = + { + .end = end_category, + .error = rollback, + .args = c + }; + + if (!op_run(c->db, "COMMIT;", &ocfg, r)) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + return -1; + } + + return 0; +} + +static int begin_tr(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + static const char query[] = "SELECT * FROM categories ORDER BY id ASC;"; + struct category *const c = args; + const struct op_cfg ocfg = + { + .row = category, + .end = commit, + .args = c + }; + + if (!(c->op = op_run(c->db, query, &ocfg, r))) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + return -1; + } + + return 0; +} + +static int setup(const struct http_payload *const pl, + struct http_response *const r, void *const user, sqlite3 *const db, + const struct auth_user *const u) +{ + int ret = -1, error; + char *userdup = NULL; + const char *const username = u ? u->username : NULL; + struct html_node *root = NULL, *body, *div, *h2, *ul; + struct category *c = NULL; + + if (u && u->role <= AUTH_ROLE_BANNED) + { + ret = form_unauthorized("Banned account", r); + goto failure; + } + else if (!(root = html_node_alloc("html"))) + { + fprintf(stderr, "%s: html_node_alloc failed\n", __func__); + goto failure; + } + else if (form_head(root)) + { + fprintf(stderr, "%s: form_head failed\n", __func__); + goto failure; + } + else if (!(body = html_node_add_child(root, "body"))) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + goto failure; + } + else if (form_login(body, username)) + { + fprintf(stderr, "%s: form_login failed\n", __func__); + goto failure; + } + else if (u && u->role >= AUTH_ROLE_MOD && form_category(body)) + { + fprintf(stderr, "%s: form_category failed\n", __func__); + goto failure; + } + else if (!(div = html_node_add_child(body, "div")) + || !(h2 = html_node_add_child(div, "h2")) + || !(ul = html_node_add_child(body, "ul"))) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + goto failure; + } + else if (html_node_set_value(h2, "Categories")) + { + fprintf(stderr, "%s: html_node_set_value failed\n", __func__); + goto failure; + } + else if (!(c = malloc(sizeof *c))) + { + fprintf(stderr, "%s: malloc(3): %s\n", __func__, strerror(errno)); + goto failure; + } + + const struct op_cfg ocfg = + { + .error = rollback, + .end = begin_tr, + .args = c + }; + + if (!op_run(db, "BEGIN TRANSACTION", &ocfg, r)) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + goto failure; + } + + *c = (const struct category) + { + .section_ul = ul, + .body = body, + .root = root, + .db = db + }; + + return 0; + +failure: + + if ((error = sqlite3_close(db)) != SQLITE_OK) + fprintf(stderr, "%s: sqlite3_close: %s\n", __func__, + sqlite3_errstr(error)); + + free(c); + free(userdup); + html_node_free(root); + return ret; +} + +int ep_index(const struct http_payload *const p, struct http_response *const r, + void *const user) +{ + const int n = auth_validate(p, r, user, setup); + + if (n < 0) + fprintf(stderr, "%s: auth_validate failed\n", __func__); + else if (n) + { + const struct cfg *const cfg = user; + sqlite3 *db; + + if (db_open(cfg->dir, &db)) + { + fprintf(stderr, "%s: db_open failed\n", __func__); + return -1; + } + + return setup(p, r, user, db, NULL); + } + + return n; +} diff --git a/ep_login.c b/ep_login.c new file mode 100644 index 0000000..0247ce4 --- /dev/null +++ b/ep_login.c @@ -0,0 +1,306 @@ +/* + * nanobbs, a tiny forums software. + * Copyright (C) 2025-2026 Xavier Del Campo Romero + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#define _POSIX_C_SOURCE 200809L + +#include "endpoints.h" +#include "db.h" +#include "defs.h" +#include "form.h" +#include "op.h" +#include "utils.h" +#include <cjson/cJSON.h> +#include <dynstr.h> +#include <libweb/form.h> +#include <sodium.h> +#include <sqlite3.h> +#include <errno.h> +#include <stdbool.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +struct login +{ + bool valid; + char *username, *password, *key; + sqlite3 *db; +}; + +static void free_login(void *const p) +{ + int error; + struct login *const l = p; + + if (!l) + return; + else if ((error = sqlite3_close(l->db)) != SQLITE_OK) + fprintf(stderr, "%s: sqlite3_close: %s\n", __func__, + sqlite3_errstr(error)); + + free(l->username); + free(l->password); + free(l->key); + free(l); +} + +static int row(sqlite3_stmt *const stmt, + const struct http_payload *const p, struct http_response *const r, + void *const user, void *const args) +{ + int ret = -1; + struct login *const l = args; + sqlite3 *const db = l->db; + const size_t len = strlen(l->password); + char *const hashpwd = db_str(db, stmt, "password"), + *key = db_str(db, stmt, "signkey"); + + if (!hashpwd) + { + fprintf(stderr, "%s: missing password\n", __func__); + goto end; + } + else if (!key) + { + fprintf(stderr, "%s: missing signkey\n", __func__); + goto end; + } + else if (!crypto_pwhash_str_verify(hashpwd, l->password, len)) + { + l->valid = true; + l->key = key; + key = NULL; + } + + ret = 0; + +end: + free(key); + free(hashpwd); + return ret; +} + +static int authorize(const struct login *const l, struct http_response *const r) +{ + int ret = -1; + char *const cookie = gencookie(l->username, l->key); + + if (!cookie) + { + fprintf(stderr, "%s: gencookie failed\n", __func__); + goto end; + } + + *r = (const struct http_response){.status = HTTP_STATUS_SEE_OTHER}; + + if (http_response_add_header(r, "Location", "/") + || http_response_add_header(r, "Set-Cookie", cookie)) + { + fprintf(stderr, "%s: http_response_add_header failed\n", __func__); + goto end; + } + + ret = 0; + +end: + free(cookie); + return ret; +} + +static int end(const struct http_payload *const pl, + struct http_response *const r, void *const user, void *const args) +{ + struct login *const l = args; + const int error = sqlite3_close(l->db); + + l->db = NULL; + + if (error != SQLITE_OK) + { + fprintf(stderr, "%s: sqlite3_close: %s\n", __func__, + sqlite3_errstr(error)); + return -1; + } + else if (l->valid) + { + if (authorize(l, r)) + { + fprintf(stderr, "%s: authorize failed\n", __func__); + return -1; + } + } + else if (form_unauthorized("Invalid username or password", r)) + { + fprintf(stderr, "%s: form_unauthorized failed\n", __func__); + return -1; + } + + free_login(l); + return 0; +} + +static int query_creds(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + int ret = -1; + struct login *const l = args; + struct dynstr d; + + dynstr_init(&d); + + if (dynstr_append(&d, + "SELECT signkey, password FROM users WHERE name = '%s'", l->username)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + + const struct op_cfg op = + { + .error = free_login, + .end = end, + .row = row, + .args = l + }; + + if (!op_run(l->db, d.str, &op, r)) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + goto end; + } + + ret = 0; + +end: + + if (ret) + free_login(l); + + dynstr_free(&d); + return ret; +} + +static int open_db(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + const struct cfg *const cfg = user; + struct login *const l = args; + const int error = db_open(cfg->dir, &l->db); + + if (error != SQLITE_OK) + { + if (error != SQLITE_BUSY) + { + fprintf(stderr, "%s: db_open: %s\n", __func__, + sqlite3_errstr(error)); + goto failure; + } + } + else + *r = (const struct http_response) + { + .step.payload = query_creds, + .args = l + }; + + return 0; + +failure: + free(l); + return -1; +} + +static int check_user(const char *const username) +{ + return strspn(username, USER_SYMS) != strlen(username); +} + +int ep_login(const struct http_payload *const p, struct http_response *const r, + void *const user) +{ + int error, ret = -1; + char *userdup = NULL, *passdup = NULL; + struct form *f = NULL; + struct login *l = NULL; + const char *username, *password; + + if ((error = form_alloc(p->u.post.data, &f))) + { + if ((ret = error) < 0) + fprintf(stderr, "%s: form_alloc failed\n", __func__); + else + ret = form_badreq("Invalid request", r); + + goto end; + } + else if (!(username = form_value(f, "username"))) + { + fprintf(stderr, "%s: missing username\n", __func__); + ret = form_badreq("Missing username", r); + goto end; + } + else if (!(password = form_value(f, "password"))) + { + fprintf(stderr, "%s: missing password\n", __func__); + ret = form_badreq("Missing password", r); + goto end; + } + else if (check_user(username)) + { + fprintf(stderr, "%s: check_user failed\n", __func__); + ret = form_unauthorized("Invalid username", r); + goto end; + } + else if (!(userdup = strdup(username)) + || !(passdup = strdup(password))) + { + fprintf(stderr, "%s: strdup(3): %s\n", __func__, strerror(errno)); + goto end; + } + else if (!(l = malloc(sizeof *l))) + { + fprintf(stderr, "%s: malloc(3): %s\n", __func__, strerror(errno)); + goto end; + } + + *l = (const struct login) + { + .username = userdup, + .password = passdup, + }; + + *r = (const struct http_response) + { + .step.payload = open_db, + .args = l + }; + + ret = 0; + +end: + + if (ret) + { + free(userdup); + free(passdup); + free(l); + } + + form_free(f); + return 0; +} diff --git a/ep_logout.c b/ep_logout.c new file mode 100644 index 0000000..6f9bc1d --- /dev/null +++ b/ep_logout.c @@ -0,0 +1,85 @@ +/* + * nanobbs, a tiny forums software. + * Copyright (C) 2025-2026 Xavier Del Campo Romero + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#define _POSIX_C_SOURCE 200809L + +#include "endpoints.h" +#include "auth.h" +#include "form.h" +#include <libweb/http.h> +#include <stdio.h> + +static const char errmsg[] = "Invalid or missing cookie"; + +static int setup(const struct http_payload *const p, + struct http_response *const r, void *const user, sqlite3 *const db, + const struct auth_user *const u) +{ + static const char date[] = "Thu, 1 Jan 1970 00:00:00 GMT"; + int ret = -1, error; + struct dynstr d; + + dynstr_init(&d); + + if (!u) + { + ret = form_unauthorized("Authentication required", r); + goto end; + } + else if (dynstr_append(&d, "%s=expired; Expires=%s", u->username, date)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + + *r = (const struct http_response){.status = HTTP_STATUS_SEE_OTHER}; + + if (http_response_add_header(r, "Set-Cookie", d.str) + || http_response_add_header(r, "Location", "/")) + { + fprintf(stderr, "%s: http_response_add_header failed\n", __func__); + goto end; + } + + ret = 0; + +end: + + if ((error = sqlite3_close(db)) != SQLITE_OK) + { + fprintf(stderr, "%s: sqlite3_close: %s\n", __func__, + sqlite3_errstr(error)); + ret = -1; + } + + dynstr_free(&d); + return ret; +} + +int ep_logout(const struct http_payload *const p, + struct http_response *const r, void *const user) +{ + int ret = auth_validate(p, r, user, setup); + + if (ret < 0) + fprintf(stderr, "%s: auth_validate failed\n", __func__); + else if (ret) + ret = form_badreq(errmsg, r); + + return ret; +} diff --git a/ep_passwd.c b/ep_passwd.c new file mode 100644 index 0000000..c3dab9a --- /dev/null +++ b/ep_passwd.c @@ -0,0 +1,400 @@ +/* + * nanobbs, a tiny forums software. + * Copyright (C) 2025-2026 Xavier Del Campo Romero + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#define _POSIX_C_SOURCE 200809L + +#include "endpoints.h" +#include "auth.h" +#include "db.h" +#include "defs.h" +#include "form.h" +#include <dynstr.h> +#include <libweb/form.h> +#include <libweb/http.h> +#include <libweb/html.h> +#include <sodium.h> +#include <sqlite3.h> +#include <errno.h> +#include <stdbool.h> +#include <stddef.h> +#include <stdlib.h> +#include <string.h> + +struct passwd +{ + bool valid; + sqlite3 *db; + sqlite3_stmt *stmt; + char *username, *old, *new; +}; + +static void free_passwd(struct passwd *const p) +{ + int error; + + if (!p) + return; + + sqlite3_finalize(p->stmt); + + if ((error = sqlite3_close(p->db) != SQLITE_OK)) + fprintf(stderr, "%s: sqlite3_close: %s\n", __func__, + sqlite3_errstr(error)); + + free(p->username); + free(p->old); + free(p->new); + free(p); +} + +static int row(struct passwd *const p) +{ + char *const hashpwd = db_str(p->db, p->stmt, "password"); + + if (!hashpwd) + { + fprintf(stderr, "%s: missing password\n", __func__); + return -1; + } + else if (!crypto_pwhash_str_verify(hashpwd, p->old, strlen(p->old))) + p->valid = true; + + free(hashpwd); + return 0; +} + +static int finalize_update(struct passwd *const p, + struct http_response *const r) +{ + *r = (const struct http_response){.status = HTTP_STATUS_SEE_OTHER}; + + if (http_response_add_header(r, "Location", "/")) + { + fprintf(stderr, "%s: http_response_add_header failed\n", __func__); + return -1; + } + + free_passwd(p); + return 0; +} + +static int update(const struct http_payload *const pl, + struct http_response *const r, void *const user, void *const args) +{ + struct passwd *const p = args; + sqlite3_stmt *const stmt = p->stmt; + const int error = sqlite3_step(stmt); + + switch (error) + { + case SQLITE_BUSY: + break; + + case SQLITE_DONE: + if (finalize_update(p, r)) + goto failure; + + break; + + default: + fprintf(stderr, "%s: sqlite3_step: %s\n", __func__, + sqlite3_errstr(error)); + sqlite3_reset(stmt); + goto failure; + } + + return 0; + +failure: + free_passwd(p); + return -1; +} + +static int prepare_change(struct passwd *const p, struct http_response *const r) +{ + int ret = -1, error; + struct dynstr d; + sqlite3_stmt *stmt = NULL; + char hashpwd[crypto_pwhash_STRBYTES]; + + dynstr_init(&d); + + if (crypto_pwhash_str(hashpwd, p->new, strlen(p->new), + crypto_pwhash_OPSLIMIT_INTERACTIVE, crypto_pwhash_MEMLIMIT_INTERACTIVE)) + { + fprintf(stderr, "%s: crypto_pwhash_str failed\n", __func__); + goto end; + } + else if (dynstr_append(&d, + "UPDATE users SET password='%s' WHERE name = '%s';", hashpwd, + p->username)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + else if ((error = sqlite3_prepare_v2(p->db, d.str, d.len, &stmt, NULL)) + != SQLITE_OK) + { + fprintf(stderr, "%s: sqlite3_prepare_v2 \"%s\": %s\n", __func__, + d.str, sqlite3_errstr(error)); + goto end; + } + + p->stmt = stmt; + + *r = (const struct http_response) + { + .step.payload = update, + .args = p + }; + + ret = 0; + +end: + dynstr_free(&d); + return ret; +} + +static int finalize(struct passwd *const p, struct http_response *const r) +{ + const int error = sqlite3_finalize(p->stmt); + + p->stmt = NULL; + + if (error != SQLITE_OK) + { + fprintf(stderr, "%s: sqlite3_finalize: %s\n", __func__, + sqlite3_errstr(error)); + return -1; + } + else if (!p->valid) + { + const int ret = form_unauthorized("Invalid current password", r); + + free_passwd(p); + return ret; + } + else if (prepare_change(p, r)) + { + fprintf(stderr, "%s: prepare_change failed\n", __func__); + return -1; + } + + return 0; +} + +static int run_query(const struct http_payload *const pl, + struct http_response *const r, void *const user, void *const args) +{ + struct passwd *const p = args; + sqlite3_stmt *const stmt = p->stmt; + const int error = sqlite3_step(stmt); + + switch (error) + { + case SQLITE_BUSY: + break; + + case SQLITE_DONE: + if (finalize(p, r)) + goto failure; + + break; + + case SQLITE_ROW: + if (row(p)) + goto failure; + + break; + + default: + fprintf(stderr, "%s: sqlite3_step: %s\n", __func__, + sqlite3_errstr(error)); + sqlite3_reset(stmt); + goto failure; + } + + return 0; + +failure: + free_passwd(p); + return -1; +} + +static int passwd(const struct cfg *const cfg, const char *const username, + const char *const old, const char *const new, sqlite3 *const db, + struct http_response *const r) +{ + int ret = -1, error; + struct dynstr d; + sqlite3_stmt *stmt = NULL; + char *userdup = NULL, *olddup = NULL, *newdup = NULL; + struct passwd *p = NULL; + + dynstr_init(&d); + + if (!(userdup = strdup(username)) + || !(olddup = strdup(old)) + || !(newdup = strdup(new))) + { + fprintf(stderr, "%s: strdup(3): %s\n", __func__, strerror(errno)); + goto end; + } + else if (dynstr_append(&d, + "SELECT password from users WHERE name = '%s'", username)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + else if ((error = sqlite3_prepare_v2(db, d.str, d.len, &stmt, + NULL)) != SQLITE_OK) + { + fprintf(stderr, "%s: sqlite3_prepare_v2 \"%s\": %s\n", __func__, + d.str, sqlite3_errstr(error)); + goto end; + } + else if (!(p = malloc(sizeof *p))) + { + fprintf(stderr, "%s: malloc(3): %s\n", __func__, strerror(errno)); + goto end; + } + + *p = (const struct passwd) + { + .username = userdup, + .old = olddup, + .new = newdup, + .stmt = stmt, + .db = db + }; + + *r = (const struct http_response) + { + .step.payload = run_query, + .args = p + }; + + ret = 0; + +end: + + if (ret) + { + free(p); + free(userdup); + free(olddup); + free(newdup); + sqlite3_finalize(stmt); + } + + dynstr_free(&d); + return ret; +} + +static int setup(const struct http_payload *const p, + struct http_response *const r, void *const user, sqlite3 *const db, + const struct auth_user *const u) +{ + int ret = -1, error; + struct form *f = NULL; + const char *old, *new, *cnew; + const struct cfg *const cfg = user; + + *r = (const struct http_response){0}; + + if (!u) + { + ret = form_unauthorized("Authentication required", r); + goto failure; + } + else if ((error = form_alloc(p->u.post.data, &f))) + { + if ((ret = error) < 0) + fprintf(stderr, "%s: form_alloc failed\n", __func__); + else + ret = form_badreq("Invalid request", r); + + goto failure; + } + else if (!(old = form_value(f, "old"))) + { + ret = form_badreq("Missing old password", r); + goto failure; + } + else if (!(new = form_value(f, "new"))) + { + ret = form_badreq("Missing new password", r); + goto failure; + } + else if (!(cnew = form_value(f, "cnew"))) + { + ret = form_badreq("Missing confirmation password", r); + goto failure; + } + else if (strcmp(new, cnew)) + { + ret = form_badreq("New password mismatch", r); + goto failure; + } + else if (strlen(new) < MINPWDLEN) + { + ret = form_shortpwd(r); + goto failure; + } + else if ((ret = passwd(cfg, u->username, old, new, db, r))) + { + fprintf(stderr, "%s: passwd failed\n", __func__); + goto failure; + } + + form_free(f); + return 0; + +failure: + + if ((error = sqlite3_close(db)) != SQLITE_OK) + fprintf(stderr, "%s: sqlite3_close: %s\n", __func__, + sqlite3_errstr(error)); + + form_free(f); + return ret; +} + +int ep_passwd(const struct http_payload *const p, + struct http_response *const r, void *const user) +{ + const int n = auth_validate(p, r, user, setup); + + if (n < 0) + fprintf(stderr, "%s: auth_validate failed\n", __func__); + else if (n) + { + const struct cfg *const cfg = user; + sqlite3 *db; + + if (db_open(cfg->dir, &db)) + { + fprintf(stderr, "%s: db_open failed\n", __func__); + return -1; + } + + return setup(p, r, user, db, NULL); + } + + return n; +} diff --git a/ep_signup.c b/ep_signup.c new file mode 100644 index 0000000..fddf96e --- /dev/null +++ b/ep_signup.c @@ -0,0 +1,651 @@ +/* + * nanobbs, a tiny forums software. + * Copyright (C) 2025-2026 Xavier Del Campo Romero + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#define _POSIX_C_SOURCE 200809L + +#include "endpoints.h" +#include "db.h" +#include "defs.h" +#include "form.h" +#include "jwt.h" +#include "op.h" +#include "utils.h" +#include <cjson/cJSON.h> +#include <dynstr.h> +#include <libweb/form.h> +#include <libweb/html.h> +#include <libweb/http.h> +#include <sodium.h> +#include <sqlite3.h> +#include <fcntl.h> +#include <sys/stat.h> +#include <unistd.h> +#include <errno.h> +#include <stdbool.h> +#include <stdint.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> + +struct signup +{ + bool exists; + char *cookie; + sqlite3 *db; + unsigned char key[32]; +}; + +static void free_signup(void *const p) +{ + int error; + struct signup *const s = p; + + if (!s) + return; + else if ((error = sqlite3_close(s->db)) != SQLITE_OK) + fprintf(stderr, "%s: sqlite3_close: %s\n", __func__, + sqlite3_errstr(error)); + + free(s->cookie); + free(s); +} + +static char *dump_terms(const char *const dir) +{ + char *ret = NULL, *s = NULL, *encs = NULL; + int fd = -1; + struct dynstr d; + struct stat sb; + + dynstr_init(&d); + + if (dynstr_append(&d, "%s/%s", dir, TERMS_PATH)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + else if ((fd = open(d.str, O_RDONLY)) < 0) + { + fprintf(stderr, "%s: open(2) %s: %s\n", __func__, d.str, + strerror(errno)); + goto end; + } + else if (fstat(fd, &sb)) + { + fprintf(stderr, "%s: fstat(2) %s: %s\n", __func__, d.str, + strerror(errno)); + goto end; + } + else if (!(s = malloc(sb.st_size + 1))) + { + fprintf(stderr, "%s: malloc(3): %s\n", __func__, strerror(errno)); + goto end; + } + + off_t rem = sb.st_size; + char *p = s; + + while (rem) + { + const ssize_t r = read(fd, p, rem); + + if (r < 0) + { + fprintf(stderr, "%s: read(2): %s\n", __func__, strerror(errno)); + goto end; + } + + rem -= r; + p += r; + } + + s[sb.st_size] = '\0'; + + if (!(encs = html_encode(s))) + { + fprintf(stderr, "%s: html_encode failed\n", __func__); + goto end; + } + + ret = encs; + +end: + + if (fd >= 0 && close(fd)) + { + fprintf(stderr, "%s: close(2) %s: %s\n", __func__, d.str, + strerror(errno)); + ret = NULL; + } + + if (!ret) + free(encs); + + free(s); + dynstr_free(&d); + return ret; +} + +static int get(const struct http_payload *const p, + struct http_response *const r, void *const user) +{ + int ret = -1; + char *terms = NULL; + const struct cfg *const cfg = user; + struct dynstr d; + struct html_node *root = NULL, *body, *form, *luser, *iuser, *lpass, + *ipass, *ltoken, *itoken, *submit, *pterms; + + dynstr_init(&d); + + if (!(terms = dump_terms(cfg->dir))) + { + fprintf(stderr, "%s: dump_terms failed\n", __func__); + goto end; + } + else if (!(root = html_node_alloc("html"))) + { + fprintf(stderr, "%s: html_node_alloc failed\n", __func__); + goto end; + } + else if (form_head(root)) + { + fprintf(stderr, "%s: form_head failed\n", __func__); + goto end; + } + else if (!(body = html_node_add_child(root, "body")) + || !(form = html_node_add_child(body, "form")) + || !(luser = html_node_add_child(form, "label")) + || !(iuser = html_node_add_child(form, "input")) + || !(lpass = html_node_add_child(form, "label")) + || !(ipass = html_node_add_child(form, "input")) + || !(ltoken = html_node_add_child(form, "label")) + || !(itoken = html_node_add_child(form, "input")) + || !(submit = html_node_add_child(form, "input")) + || !(pterms = html_node_add_child(body, "p"))) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + goto end; + } + else if (html_node_add_attr(form, "action", "/signup") + || html_node_add_attr(form, "form", "loginform") + || html_node_add_attr(form, "method", "post") + || html_node_add_attr(luser, "for", "username") + || html_node_add_attr(lpass, "for", "password") + || html_node_add_attr(ltoken, "for", "token") + || html_node_add_attr(iuser, "type", "text") + || html_node_add_attr(iuser, "id", "username") + || html_node_add_attr(iuser, "name", "username") + || html_node_add_attr(ipass, "type", "password") + || html_node_add_attr(ipass, "id", "password") + || html_node_add_attr(ipass, "name", "password") + || html_node_add_attr(itoken, "type", "text") + || html_node_add_attr(itoken, "id", "token") + || html_node_add_attr(itoken, "name", "token") + || html_node_add_attr(submit, "type", "submit") + || html_node_add_attr(submit, "value", "Sign up")) + { + fprintf(stderr, "%s: html_node_add_attr failed\n", __func__); + goto end; + } + else if (html_node_set_value(luser, "Username:") + || html_node_set_value(lpass, "Password:") + || html_node_set_value(ltoken, "Token:") + || html_node_set_value_unescaped(pterms, terms)) + { + fprintf(stderr, "%s: html_node_set_value failed\n", __func__); + goto end; + } + else if (form_footer(body, p->resource)) + { + fprintf(stderr, "%s: form_footer failed\n", __func__); + goto end; + } + else if (dynstr_append(&d, "%s", DOCTYPE_TAG)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + else if (html_serialize(root, &d)) + { + fprintf(stderr, "%s: html_serialize failed\n", __func__); + goto end; + } + + *r = (const struct http_response) + { + .status = HTTP_STATUS_OK, + .buf.rw = d.str, + .n = d.len, + .free = free + }; + + ret = 0; + +end: + if (ret) + dynstr_free(&d); + + free(terms); + html_node_free(root); + return ret; +} + +static int check_user(const char *const username) +{ + return strspn(username, USER_SYMS) != strlen(username); +} + +static int check_token(const char *const token, const struct signup *const s, + const char *const expuser) +{ + int ret = -1; + cJSON *j = NULL; + const cJSON *u, *e; + const char *user; + + if ((ret = jwt_decode(token, s->key, sizeof s->key, &j))) + { + if (ret < 0) + fprintf(stderr, "%s: jwt_decode failed\n", __func__); + + goto end; + } + else if (!(u = cJSON_GetObjectItem(j, "name")) + || !(e = cJSON_GetObjectItem(j, "exp")) + || !(user = cJSON_GetStringValue(u)) + || !cJSON_IsNumber(e) + || strcmp(user, expuser)) + { + ret = 1; + goto end; + } + + const time_t t = time(NULL); + + if (t == (time_t)-1) + { + fprintf(stderr, "%s: time(2): %s\n", __func__, strerror(errno)); + ret = -1; + goto end; + } + else if (t >= (intmax_t)cJSON_GetNumberValue(e)) + { + ret = 1; + goto end; + } + + ret = 0; + +end: + cJSON_Delete(j); + return ret; +} + +static int report_usersyms(struct http_response *const r) +{ + int ret = -1; + char *enc = NULL; + struct dynstr d; + + dynstr_init(&d); + + if (dynstr_append(&d, "Invalid username. Accepted symbols: %s", + USER_SYMS)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + else if (!(enc = html_encode(d.str))) + { + fprintf(stderr, "%s: html_encode failed\n", __func__); + goto end; + } + + ret = form_badreq(d.str, r); + +end: + free(enc); + dynstr_free(&d); + return ret; +} + +static int end_signup(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + struct signup *const s = args; + const int error = sqlite3_close(s->db); + + s->db = NULL; + + if (error != SQLITE_OK) + { + fprintf(stderr, "%s: sqlite3_close: %s\n", __func__, + sqlite3_errstr(error)); + return -1; + } + else if (s->exists) + { + if (form_badreq("Username already exists", r)) + { + fprintf(stderr, "%s: form_badreq failed\n", __func__); + return -1; + } + } + else + { + *r = (const struct http_response){.status = HTTP_STATUS_SEE_OTHER}; + + if (http_response_add_header(r, "Location", "/") + || http_response_add_header(r, "Set-Cookie", s->cookie)) + { + fprintf(stderr, "%s: http_response_add_header failed\n", __func__); + return -1; + } + } + + free_signup(s); + return 0; +} + +static int constraint(void *const args) +{ + struct signup *const s = args; + + s->exists = true; + return 0; +} + +static int end_tr(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + struct signup *const s = args; + const struct op_cfg op = + { + .error = free_signup, + .end = end_signup, + .args = s + }; + + if (!op_run(s->db, "END TRANSACTION;", &op, r)) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + goto failure; + } + + return 0; + +failure: + free_signup(s); + return -1; +} + +static int end_tokenkey(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + int ret = -1, success = 0, error; + struct form *f = NULL; + char *cookie = NULL; + struct dynstr d; + unsigned char key[crypto_auth_hmacsha256_KEYBYTES]; + char hashpwd[crypto_pwhash_STRBYTES], enckey[sizeof key * 2 + 1]; + const char *username, *password, *token; + struct signup *const s = args; + const time_t t = time(NULL); + + dynstr_init(&d); + crypto_auth_hmacsha256_keygen(key); + + if (t == (time_t)-1) + { + fprintf(stderr, "%s: time(2): %s\n", __func__, strerror(errno)); + goto end; + } + else if ((error = form_alloc(p->u.post.data, &f))) + { + if ((ret = error) < 0) + fprintf(stderr, "%s: form_alloc failed\n", __func__); + else + ret = form_badreq("Invalid request", r); + + goto end; + } + else if (!(username = form_value(f, "username"))) + { + fprintf(stderr, "%s: missing username\n", __func__); + ret = form_badreq("Missing username", r); + goto end; + } + else if (!(password = form_value(f, "password"))) + { + fprintf(stderr, "%s: missing password\n", __func__); + ret = form_badreq("Missing password", r); + goto end; + } + else if (!(token = form_value(f, "token"))) + { + fprintf(stderr, "%s: missing token\n", __func__); + ret = form_badreq("Missing token", r); + goto end; + } + else if (check_user(username)) + { + if ((ret = report_usersyms(r))) + fprintf(stderr, "%s: report_usersyms failed\n", __func__); + + goto end; + } + else if (strlen(password) < MINPWDLEN) + { + if ((ret = form_shortpwd(r))) + fprintf(stderr, "%s: form_shortpwd failed\n", __func__); + + goto end; + } + else if ((ret = check_token(token, s, username))) + { + if (ret < 0) + fprintf(stderr, "%s: check_token failed\n", __func__); + else if ((ret = form_badreq("Invalid registration token", r))) + fprintf(stderr, "%s: form_badreq failed\n", __func__); + + goto end; + } + else if (!sodium_bin2hex(enckey, sizeof enckey, key, sizeof key)) + { + fprintf(stderr, "%s: sodium_bin2hex failed\n", __func__); + goto end; + } + else if (!(cookie = gencookie(username, enckey))) + { + fprintf(stderr, "%s: gencookie failed\n", __func__); + goto end; + } + else if (crypto_pwhash_str(hashpwd, password, strlen(password), + crypto_pwhash_OPSLIMIT_INTERACTIVE, crypto_pwhash_MEMLIMIT_INTERACTIVE)) + { + fprintf(stderr, "%s: crypto_pwhash_str failed\n", __func__); + goto end; + } + else if (dynstr_append(&d, "INSERT INTO " + "users(name, password, signkey, creat, thumbnail, roleid) VALUES" + "('%s', '%s', '%s', %jd, NULL, " + "(SELECT id FROM roles WHERE name = 'user'));", + username, hashpwd, enckey, (intmax_t)t)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + + const struct op_cfg op = + { + .constraint = constraint, + .error = free_signup, + .end = end_tr, + .args = s + }; + + if (!op_run(s->db, d.str, &op, r)) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + goto end; + } + + s->cookie = cookie; + ret = 0; + success = 1; + +end: + + if (!success) + free_signup(s); + + if (ret) + free(cookie); + + dynstr_free(&d); + form_free(f); + return ret; +} + +static int tokenkey(sqlite3_stmt *const stmt, + const struct http_payload *const p, struct http_response *const r, + void *const user, void *const args) +{ + int ret = -1; + struct signup *const s = args; + char *const key = db_str(s->db, stmt, "value"); + + if (!key) + { + fprintf(stderr, "%s: missing tokenkey\n", __func__); + goto end; + } + else if (strlen(key) != sizeof s->key * 2) + { + fprintf(stderr, "%s: unexpected key length: %zu\n", __func__, + strlen(key)); + goto end; + } + else if (sodium_hex2bin(s->key, sizeof s->key, key, strlen(key), NULL, + NULL, NULL)) + { + fprintf(stderr, "%s: sodium_hex2bin failed\n", __func__); + goto end; + } + + ret = 0; + +end: + free(key); + return ret; +} + +static int signup_tr(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + static const char query[] = "SELECT value FROM globals " + "WHERE key = 'tokenkey'"; + struct signup *const s = args; + const struct op_cfg op = + { + .error = free_signup, + .end = end_tokenkey, + .row = tokenkey, + .args = s + }; + + if (!op_run(s->db, query, &op, r)) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + return -1; + } + + return 0; +} + +static int post(const struct http_payload *const p, + struct http_response *const r, void *const user) +{ + int error; + const struct cfg *const cfg = user; + struct signup *s = NULL; + sqlite3 *db = NULL; + + if ((error = db_open(cfg->dir, &db)) != SQLITE_OK) + { + if (error != SQLITE_BUSY) + { + fprintf(stderr, "%s: db_open: %s\n", __func__, + sqlite3_errstr(error)); + goto failure; + } + } + else if (!(s = malloc(sizeof *s))) + { + fprintf(stderr, "%s: malloc(3): %s\n", __func__, strerror(errno)); + goto failure; + } + else + { + *s = (const struct signup){.db = db}; + + const struct op_cfg op = + { + .error = free_signup, + .end = signup_tr, + .args = s + }; + + if (!op_run(db, "BEGIN TRANSACTION;", &op, r)) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + goto failure; + } + } + + return 0; + +failure: + + if ((error = sqlite3_close(db)) != SQLITE_OK) + fprintf(stderr, "%s: sqlite3_close: %s\n", __func__, + sqlite3_errstr(error)); + + free(s); + return -1; +} + +int ep_signup(const struct http_payload *const p, struct http_response *const r, + void *const user) +{ + switch (p->op) + { + case HTTP_OP_GET: + return get(p, r, user); + + case HTTP_OP_POST: + return post(p, r, user); + + default: + fprintf(stderr, "%s: unreachable\n", __func__); + break; + } + + return -1; +} diff --git a/ep_style.c b/ep_style.c new file mode 100644 index 0000000..c2a4efa --- /dev/null +++ b/ep_style.c @@ -0,0 +1,89 @@ +/* + * nanobbs, a tiny forums software. + * Copyright (C) 2025-2026 Xavier Del Campo Romero + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#define _POSIX_C_SOURCE 200809L + +#include "endpoints.h" +#include "defs.h" +#include <libweb/http.h> +#include <dynstr.h> +#include <sys/stat.h> +#include <unistd.h> +#include <errno.h> +#include <stdio.h> +#include <string.h> + +int ep_style(const struct http_payload *const p, struct http_response *const r, + void *const user) +{ + int ret = -1; + FILE *f = NULL; + const struct cfg *const cfg = user; + const char *const dir = cfg->dir; + struct dynstr d; + struct stat sb; + + dynstr_init(&d); + + if (!dir) + { + fprintf(stderr, "%s: auth_dir failed\n", __func__); + goto end; + } + else if (dynstr_append(&d, "%s/" STYLE_PATH, dir)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + else if (stat(d.str, &sb)) + { + fprintf(stderr, "%s: stat(2) %s: %s\n", __func__, d.str, + strerror(errno)); + goto end; + } + else if (!(f = fopen(d.str, "rb"))) + { + fprintf(stderr, "%s: fopen(3) %s: %s\n", + __func__, dir, strerror(errno)); + goto end; + } + + *r = (const struct http_response) + { + .status = HTTP_STATUS_OK, + .f = f, + .n = sb.st_size + }; + + if (http_response_add_header(r, "Content-Type", "text/css")) + { + fprintf(stderr, "%s: http_response_add_header failed\n", __func__); + goto end; + } + + ret = 0; + +end: + + if (ret && f && fclose(f)) + fprintf(stderr, "%s: fclose(3) %s: %s\n", + __func__, dir, strerror(errno)); + + dynstr_free(&d); + return ret; +} diff --git a/ep_ucp.c b/ep_ucp.c new file mode 100644 index 0000000..30fb9de --- /dev/null +++ b/ep_ucp.c @@ -0,0 +1,241 @@ +/* + * nanobbs, a tiny forums software. + * Copyright (C) 2025-2026 Xavier Del Campo Romero + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#include "endpoints.h" +#include "db.h" +#include "defs.h" +#include "auth.h" +#include "form.h" +#include <libweb/html.h> +#include <libweb/http.h> +#include <stddef.h> +#include <stdlib.h> +#include <stdio.h> + +static int setup_passwd(struct html_node *const n, const char *const username) +{ + int ret = -1; + struct dynstr ud; + struct html_node *div, *user, *form, *lold, *iold, *lnew, + *inew, *lcnew, *icnew, *submit; + + dynstr_init(&ud); + + if (dynstr_append(&ud, "User: %s", username)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + else if (!(div = html_node_add_child(n, "div")) + || !(form = html_node_add_child(div, "form")) + || !(user = html_node_add_child(form, "label")) + || !(lold = html_node_add_child(form, "label")) + || !(iold = html_node_add_child(form, "input")) + || !(lnew = html_node_add_child(form, "label")) + || !(inew = html_node_add_child(form, "input")) + || !(lcnew = html_node_add_child(form, "label")) + || !(icnew = html_node_add_child(form, "input")) + || !(submit = html_node_add_child(form, "input"))) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + goto end; + } + else if (html_node_add_attr(form, "action", "/passwd") + || html_node_add_attr(form, "form", "passwdform") + || html_node_add_attr(form, "method", "post") + || html_node_add_attr(lold, "for", "old") + || html_node_add_attr(lnew, "for", "new") + || html_node_add_attr(lcnew, "for", "cnew") + || html_node_add_attr(iold, "type", "password") + || html_node_add_attr(iold, "id", "old") + || html_node_add_attr(iold, "name", "old") + || html_node_add_attr(inew, "type", "password") + || html_node_add_attr(inew, "id", "new") + || html_node_add_attr(inew, "name", "new") + || html_node_add_attr(icnew, "type", "password") + || html_node_add_attr(icnew, "id", "cnew") + || html_node_add_attr(icnew, "name", "cnew") + || html_node_add_attr(submit, "type", "submit") + || html_node_add_attr(submit, "value", "Change password")) + { + fprintf(stderr, "%s: html_node_add_attr failed\n", __func__); + goto end; + } + else if (html_node_set_value(user, ud.str) + || html_node_set_value(lold, "Current password:") + || html_node_set_value(lnew, "New password:") + || html_node_set_value(lcnew, "Confirm new password:")) + { + fprintf(stderr, "%s: html_node_set_value failed\n", __func__); + goto end; + } + + ret = 0; + +end: + dynstr_free(&ud); + return ret; +} + +static int setup_useract(struct html_node *const div) +{ + struct f + { + const char *text, *id, *url; + struct html_node *luser, *iuser, *form, *submit; + } fs[] = + { + {.text = "Ban user", .id = "banform", .url = "/confirm/ban"}, + {.text = "Delete user", .id = "delform", .url = "/confirm/deluser"} + }; + + for (size_t i = 0; i < sizeof fs / sizeof *fs; i++) + { + struct f *const f = &fs[i]; + + if (!(f->form = html_node_add_child(div, "form")) + || !(f->luser = html_node_add_child(f->form, "label")) + || !(f->iuser = html_node_add_child(f->form, "input")) + || !(f->submit = html_node_add_child(f->form, "input"))) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + return -1; + } + else if (html_node_add_attr(f->form, "action", f->url) + || html_node_add_attr(f->form, "form", f->id) + || html_node_add_attr(f->form, "method", "post") + || html_node_add_attr(f->luser, "for", "user") + || html_node_add_attr(f->iuser, "type", "text") + || html_node_add_attr(f->iuser, "id", "user") + || html_node_add_attr(f->iuser, "name", "user") + || html_node_add_attr(f->submit, "type", "submit") + || html_node_add_attr(f->submit, "value", f->text)) + { + fprintf(stderr, "%s: html_node_add_attr failed\n", __func__); + return -1; + } + else if (html_node_set_value(f->luser, "Username:")) + { + fprintf(stderr, "%s: html_node_set_value failed\n", __func__); + return -1; + } + } + + return 0; +} + +static int setup(const struct http_payload *const p, + struct http_response *const r, void *const user, sqlite3 *const db, + const struct auth_user *const u) +{ + int ret = -1, error; + struct html_node *root = NULL, *body, *div; + struct dynstr d; + + dynstr_init(&d); + + if (!u) + { + ret = form_unauthorized("Login required", r); + goto end; + } + else if (u->role <= AUTH_ROLE_BANNED) + { + ret = form_unauthorized("Banned account", r); + goto end; + } + else if (!(root = html_node_alloc("html"))) + { + fprintf(stderr, "%s: html_node_alloc failed\n", __func__); + goto end; + } + else if (form_head(root)) + { + fprintf(stderr, "%s: form_head failed\n", __func__); + goto end; + } + else if (!(body = html_node_add_child(root, "body")) + || !(div = html_node_add_child(body, "div"))) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + goto end; + } + else if (setup_passwd(div, u->username)) + { + fprintf(stderr, "%s: setup_passwd failed\n", __func__); + goto end; + } + else if (u->role >= AUTH_ROLE_MOD && setup_useract(div)) + { + fprintf(stderr, "%s: setup_useract failed\n", __func__); + goto end; + } + else if (form_footer(body, p->resource)) + { + fprintf(stderr, "%s: form_footer failed\n", __func__); + goto end; + } + else if (dynstr_append(&d, "%s", DOCTYPE_TAG)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + else if (html_serialize(root, &d)) + { + fprintf(stderr, "%s: html_serialize failed\n", __func__); + goto end; + } + + *r = (const struct http_response) + { + .status = HTTP_STATUS_OK, + .buf.rw = d.str, + .n = d.len, + .free = free + }; + + ret = 0; + +end: + + if ((error = sqlite3_close(db)) != SQLITE_OK) + { + fprintf(stderr, "%s: sqlite3_close: %s\n", __func__, + sqlite3_errstr(error)); + ret = -1; + } + + if (ret) + dynstr_free(&d); + + html_node_free(root); + return ret; +} + +int ep_ucp(const struct http_payload *const p, struct http_response *const r, + void *const user) +{ + const int n = auth_validate(p, r, user, setup); + + if (n < 0) + fprintf(stderr, "%s: auth_validate failed\n", __func__); + else if (n) + return setup(p, r, user, NULL, NULL); + + return n; +} diff --git a/ep_view.c b/ep_view.c new file mode 100644 index 0000000..2bc9e47 --- /dev/null +++ b/ep_view.c @@ -0,0 +1,1180 @@ +/* + * nanobbs, a tiny forums software. + * Copyright (C) 2025-2026 Xavier Del Campo Romero + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#define _POSIX_C_SOURCE 200809L + +#include "endpoints.h" +#include "auth.h" +#include "db.h" +#include "defs.h" +#include "form.h" +#include "op.h" +#include "utils.h" +#include <libweb/http.h> +#include <libweb/html.h> +#include <sqlite3.h> +#include <errno.h> +#include <stdbool.h> +#include <stddef.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +struct view +{ + unsigned long category, section, topic, msg, page; + unsigned n_topics; + char *username, *cntq; + struct op *op; + sqlite3 *db; + enum auth_role role; + struct html_node *root, *body, *section_ul, *topic_ul, *topic_div; + int (*end)(const struct http_payload *, struct http_response *, + void *, void *); + + union + { + struct category + { + unsigned *secids; + size_t n_secids; + } category; + + struct post + { + struct db_user user; + } post; + } u; +}; + +static const char timefmt[] = "%B %e %Y %H:%M:%S %Z"; + +static void free_view(struct view *const v) +{ + int error; + + if (!v) + return; + else if ((error = sqlite3_close(v->db)) != SQLITE_OK) + fprintf(stderr, "%s: sqlite3_close: %s\n", __func__, + sqlite3_errstr(error)); + + html_node_free(v->root); + free(v->username); + free(v->cntq); + free(v); +} + +static int append_section(struct category *const c, const unsigned id) +{ + const size_t n = c->n_secids + 1; + unsigned *const secids = realloc(c->secids, n * sizeof *c->secids); + + if (!secids) + { + fprintf(stderr, "%s: realloc(3): %s\n", __func__, strerror(errno)); + return -1; + } + + secids[c->n_secids] = id; + c->secids = secids; + c->n_secids = n; + return 0; +} + +static int reply(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + int ret = -1; + struct view *const v = args; + struct dynstr d; + const int error = sqlite3_close(v->db); + + v->db = NULL; + dynstr_init(&d); + + if (error != SQLITE_OK) + { + fprintf(stderr, "%s: sqlite3_close: %s\n", __func__, + sqlite3_errstr(error)); + goto end; + } + else if (dynstr_append(&d, "%s", DOCTYPE_TAG)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + else if (html_serialize(v->root, &d)) + { + fprintf(stderr, "%s: html_serialize failed\n", __func__); + goto end; + } + + *r = (const struct http_response) + { + .status = HTTP_STATUS_OK, + .buf.rw = d.str, + .n = d.len, + .free = free + }; + + ret = 0; + +end: + + if (ret) + dynstr_free(&d); + + free(v->u.category.secids); + free_view(v); + return ret; +} + +static int post(sqlite3_stmt *const stmt, const struct http_payload *const pl, + struct http_response *const r, void *const user, void *const args) +{ + int ret = -1, n; + char *name = NULL, *datetime = NULL, *enctext = NULL; + char id[sizeof "4294967295"]; + struct view *const v = args; + sqlite3 *const db = v->db; + struct db_post p = {0}; + struct dynstr d; + struct tm tm; + struct html_node *a, *div, *udiv, *tdiv, *pname; + + dynstr_init(&d); + + /* TODO: push user info */ + if (!(div = html_node_add_child(v->body, "div")) + || !(udiv = html_node_add_child(div, "div")) + || !(tdiv = html_node_add_child(div, "div")) + || !(pname = html_node_add_child(tdiv, "p")) + || !(a = html_node_add_child(tdiv, "a"))) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + goto end; + } + else if (db_post(db, stmt, &p)) + { + fprintf(stderr, "%s: db_post failed\n", __func__); + goto end; + } + else if (!(name = db_str(db, stmt, "name"))) + { + fprintf(stderr, "%s: db_str failed\n", __func__); + goto end; + } + else if ((n = snprintf(id, sizeof id, "%lu", p.id)) < 0 + || n >= sizeof id) + { + fprintf(stderr, "%s: snprintf(3) returned %d\n", __func__, n); + goto end; + } + else if (dynstr_append(&d, "/view/%lu/%lu/%lu#%lu", v->category, + v->section, v->topic, p.id)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + else if (!localtime_r(&p.creat, &tm)) + { + fprintf(stderr, "%s: localtime_r(3): %s\n", __func__, strerror(errno)); + goto end; + } + else if (!(datetime = astrftime(timefmt, &tm))) + { + fprintf(stderr, "%s: astrftime failed\n", __func__); + goto end; + } + else if (!(enctext = html_encode(p.text))) + { + fprintf(stderr, "%s: html_encode failed\n", __func__); + goto end; + } + else if (html_node_add_attr(a, "href", d.str) + || html_node_add_attr(div, "id", id)) + { + fprintf(stderr, "%s: html_node_add_attr failed\n", __func__); + goto end; + } + else if (html_node_set_value(a, datetime) + || html_node_set_value(pname, name)) + { + fprintf(stderr, "%s: html_node_set_value failed\n", __func__); + goto end; + } + else if (html_node_set_value_unescaped(tdiv, enctext)) + { + fprintf(stderr, "%s: html_node_set_value_unescaped failed\n", __func__); + goto end; + } + + ret = 0; + +end: + free(name); + free(enctext); + free(datetime); + db_post_free(&p); + dynstr_free(&d); + return ret; +} + +static int end_posts(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + struct view *const v = args; + + if (form_footer(v->body, p->resource)) + { + fprintf(stderr, "%s: form_footer failed\n", __func__); + return -1; + } + + *r = (const struct http_response) + { + .step.payload = reply, + .args = v + }; + + return 0; +} + +static void view_error(void *args) +{ + free_view(args); +} + +static int view_topic(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + int ret = -1; + struct view *const v = args; + struct dynstr d; + struct html_node *const root = html_node_alloc("html"), *body; + + dynstr_init(&d); + + if (!root) + { + fprintf(stderr, "%s: html_node_alloc failed\n", __func__); + goto end; + } + else if (form_head(root)) + { + fprintf(stderr, "%s: form_head failed\n", __func__); + goto end; + } + else if (!(body = html_node_add_child(root, "body"))) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + goto end; + } + else if (form_login(body, v->username)) + { + fprintf(stderr, "%s: form_login failed\n", __func__); + goto end; + } + else if (v->username + && v->role >= AUTH_ROLE_USER + && form_post(body, v->category, v->section, v->topic)) + { + fprintf(stderr, "%s: form_post failed\n", __func__); + goto end; + } + /* TODO: paging */ + else if (dynstr_append(&d, "SELECT posts.*, name FROM posts " + "JOIN users ON users.id = posts.uid " + "AND posts.topid = %lu " + "ORDER BY creat ASC;", v->topic)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + + const struct op_cfg op = + { + .error = view_error, + .end = end_posts, + .row = post, + .args = v + }; + + if (!op_run(v->db, d.str, &op, r)) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + goto end; + } + + v->root = root; + v->body = body; + ret = 0; + +end: + dynstr_free(&d); + + if (ret) + { + html_node_free(root); + free_view(v); + } + + return ret; +} + +static int end_topics(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + struct view *const v = args; + + if (form_footer(v->body, p->resource)) + { + fprintf(stderr, "%s: form_footer failed\n", __func__); + return -1; + } + + *r = (const struct http_response) + { + .step.payload = reply, + .args = v + }; + + return 0; +} + +static void rollback(void *const args) +{ + struct view *const v = args; + + if (!v) + return; + + db_rollback(v->db); + free_view(v); +} + +static int commit(const struct http_payload *const p, + struct http_response *const r, struct view *const v) +{ + const struct op_cfg cfg = + { + .error = rollback, + .end = v->end, + .args = v, + }; + + if (!op_run(v->db, "COMMIT;", &cfg, r)) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + return -1; + } + + return 0; +} + +static int end_lastmsg(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + const struct view *const v = args; + + op_resume(v->op, r); + return 0; +} + +static int lastmsg(sqlite3_stmt *const stmt, + const struct http_payload *const p, struct http_response *const r, + void *const user, void *const args) +{ + int ret = -1; + char *stime = NULL; + unsigned id; + long long creat; + struct tm tm; + struct dynstr d, url; + struct html_node *span, *a; + struct view *const v = args; + sqlite3 *const db = v->db; + char *const name = db_str(db, stmt, "name"); + + dynstr_init(&d); + dynstr_init(&url); + + if (!name) + { + fprintf(stderr, "%s: db_str failed\n", __func__); + goto end; + } + else if (db_uint(db, stmt, "id", &id)) + { + fprintf(stderr, "%s: db_uint failed\n", __func__); + goto end; + } + else if (db_bigint(db, stmt, "creat", &creat)) + { + fprintf(stderr, "%s: db_bigint failed\n", __func__); + goto end; + } + else if (!localtime_r(&(time_t){creat}, &tm)) + { + fprintf(stderr, "%s: localtime_r(3): %s\n", __func__, strerror(errno)); + goto end; + } + else if (!(span = html_node_add_child(v->topic_div, "span")) + || !(a = html_node_add_child(span, "a"))) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + goto end; + } + else if (!(stime = astrftime(timefmt, &tm))) + { + fprintf(stderr, "%s: astrftime failed\n", __func__); + goto end; + } + else if (dynstr_append(&url, "/view/%u/%u/%u#%u", v->category, v->section, + v->topic, id) + || dynstr_append(&d, "Last reply by: %s at %s", name, stime)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + else if (html_node_add_attr(a, "href", url.str)) + { + fprintf(stderr, "%s: html_node_add_attr failed\n", __func__); + goto end; + } + else if (html_node_set_value(a, d.str)) + { + fprintf(stderr, "%s: html_node_set_value failed\n", __func__); + goto end; + } + + ret = 0; + +end: + free(name); + free(stime); + dynstr_free(&d); + dynstr_free(&url); + return ret; +} + +static char *gentimestr(const time_t *const creat) +{ + char *ret = NULL, *hrtime = NULL, *datetime = NULL; + struct tm tm; + struct dynstr d; + struct html_node *const time = html_node_alloc("time"); + + dynstr_init(&d); + + if (!time) + { + fprintf(stderr, "%s: html_node_alloc failed\n", __func__); + goto end; + } + else if (!(localtime_r(creat, &tm))) + { + fprintf(stderr, "%s: localtime_r(3): %s\n", __func__, strerror(errno)); + goto end; + } + else if (!(hrtime = astrftime(timefmt, &tm)) + || !(datetime = astrftime("%Y-%m-%d %H:%M:%S%z", &tm))) + { + fprintf(stderr, "%s: astrftime failed\n", __func__); + goto end; + } + else if (html_node_add_attr(time, "datetime", datetime)) + { + fprintf(stderr, "%s: html_node_add_attr failed\n", __func__); + goto end; + } + else if (html_node_set_value(time, hrtime)) + { + fprintf(stderr, "%s: html_node_add_attr failed\n", __func__); + goto end; + } + else if (html_serialize(time, &d)) + { + fprintf(stderr, "%s: html_serialize failed\n", __func__); + goto end; + } + + ret = d.str; + +end: + if (!ret) + dynstr_free(&d); + + html_node_free(time); + free(hrtime); + free(datetime); + return ret; +} + +static int topic(sqlite3_stmt *const stmt, const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + int ret = -1; + char *name = NULL, *time = NULL; + struct db_topic t = {0}; + struct dynstr url, created, expr; + struct view *const v = args; + sqlite3 *const db = v->db; + struct html_node *li, *dl, *dt, *a, *span; + + dynstr_init(&url); + dynstr_init(&created); + dynstr_init(&expr); + + if (!(li = html_node_add_child(v->topic_ul, "li")) + || !(dl = html_node_add_child(li, "dl")) + || !(dt = html_node_add_child(dl, "dt")) + || !(v->topic_div = html_node_add_child(dt, "div")) + || !(a = html_node_add_child(v->topic_div, "a")) + || !(span = html_node_add_child(v->topic_div, "span"))) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + goto end; + } + else if (db_topic(db, stmt, &t)) + { + fprintf(stderr, "%s: db_topic failed\n", __func__); + goto end; + } + else if (!(name = db_str(db, stmt, "name"))) + { + fprintf(stderr, "%s: db_str failed\n", __func__); + goto end; + } + else if (!(time = gentimestr(&t.creat))) + { + fprintf(stderr, "%s: gentimestr failed\n", __func__); + goto end; + } + else if (dynstr_append(&url, "/view/%u/%u/%u", v->category, t.secid, t.id) + || dynstr_append(&created, "Created by %s at %s", name, time)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + else if (html_node_add_attr(a, "href", url.str)) + { + fprintf(stderr, "%s: html_node_add_attr failed\n", __func__); + goto end; + } + else if (html_node_set_value(a, t.title)) + { + fprintf(stderr, "%s: html_node_set_value failed\n", __func__); + goto end; + } + else if (html_node_set_value_unescaped(span, created.str)) + { + fprintf(stderr, "%s: html_node_set_value_unescaped failed\n", __func__); + goto end; + } + else if (dynstr_append(&expr, "SELECT posts.id, posts.creat, " + "name FROM posts JOIN users ON posts.topid = %u " + "AND posts.uid = users.id " + "ORDER BY posts.creat DESC LIMIT 1;", t.id)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + + const struct op_cfg cfg = + { + .end = end_lastmsg, + .row = lastmsg, + .args = v + }; + + if (!op_run(db, expr.str, &cfg, r)) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + goto end; + } + + v->section = t.secid; + v->topic = t.id; + ret = 0; + +end: + free(name); + free(time); + dynstr_free(&expr); + dynstr_free(&created); + dynstr_free(&url); + db_topic_free(&t); + return ret; +} + +static int print_pages(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + struct view *const v = args; + + fprintf(stderr, "%s: TODO\n", __func__); + v->end = end_topics; + return commit(p, r, v); +} + +static int topic_count(sqlite3_stmt *const stmt, + const struct http_payload *const p, struct http_response *const r, + void *const user, void *const args) +{ + struct view *const v = args; + unsigned n; + + if (db_uint(v->db, stmt, "COUNT(*)", &n)) + { + fprintf(stderr, "%s: db_uint failed\n", __func__); + return -1; + } + + v->n_topics = n; + return 0; +} + +static int count_pages(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + struct view *const v = args; + const struct op_cfg cfg = + { + .end = print_pages, + .row = topic_count, + .error = rollback, + .args = v + }; + + if (!op_run(v->db, v->cntq, &cfg, r)) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + return -1; + } + + return 0; +} + +static char *query_topics(const struct view *const v, + const unsigned *const secids, const size_t n_secids) +{ + struct dynstr d; + + dynstr_init(&d); + + if (dynstr_append(&d, "SELECT topics.*, name FROM topics " + "JOIN users ON (")) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto failure; + } + + for (size_t i = 0; i < n_secids; i++) + if (dynstr_append(&d, " topics.secid = %u ", secids[i]) + || (i < n_secids - 1 && dynstr_append(&d, "OR"))) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto failure; + } + + if (dynstr_append(&d, ") AND topics.uid = users.id " + "ORDER BY creat DESC LIMIT %d OFFSET %ju", + PAGE_LIMIT, (uintmax_t)PAGE_LIMIT * v->page)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto failure; + } + + return d.str; + +failure: + dynstr_free(&d); + return NULL; +} + +static char *query_topcnt(const struct view *const v, + const unsigned *const secids, const size_t n_secids) +{ + struct dynstr d; + + dynstr_init(&d); + + if (dynstr_append(&d, "SELECT COUNT(*) FROM topics WHERE")) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto failure; + } + + for (size_t i = 0; i < n_secids; i++) + if (dynstr_append(&d, " topics.secid = %u ", secids[i]) + || (i < n_secids - 1 && dynstr_append(&d, "OR"))) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto failure; + } + + if (dynstr_append(&d, ";")) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto failure; + } + + return d.str; + +failure: + dynstr_free(&d); + return NULL; +} + +static int prepare_topics(struct view *const v, + struct http_response *const r, const unsigned *const secids, + const size_t n_secids) +{ + int ret = 1; + char *topq = NULL, *cntq = NULL; + struct html_node *div, *tdiv, *h2; + + if (!(div = html_node_add_child(v->body, "div")) + || !(tdiv = html_node_add_child(v->body, "div")) + || !(h2 = html_node_add_child(div, "h2")) + || !(v->topic_ul = html_node_add_child(tdiv, "ul"))) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + goto end; + } + else if (html_node_set_value(h2, "Topics")) + { + fprintf(stderr, "%s: html_node_set_value failed\n", __func__); + goto end; + } + else if (!(topq = query_topics(v, secids, n_secids))) + { + fprintf(stderr, "%s: query_topics failed\n", __func__); + goto end; + } + else if (!(cntq = query_topcnt(v, secids, n_secids))) + { + fprintf(stderr, "%s: query_topcnt failed\n", __func__); + goto end; + } + + const struct op_cfg op = + { + .end = count_pages, + .error = rollback, + .row = topic, + .args = v + }; + + if (!(v->op = op_run(v->db, topq, &op, r))) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + goto end; + } + + v->cntq = cntq; + ret = 0; + +end: + + if (ret) + free(cntq); + + free(topq); + return ret; +} + +static int section_tr(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + struct view *const v = args; + const unsigned secids[] = {v->section}; + + if (prepare_topics(v, r, secids, 1)) + { + fprintf(stderr, "%s: prepare_topics failed\n", __func__); + return -1; + } + + return 0; +} + +static int view_section(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + struct view *const v = args; + struct html_node *const root = html_node_alloc("html"), *body; + + if (!root) + { + fprintf(stderr, "%s: html_node_alloc failed\n", __func__); + goto failure; + } + else if (form_head(root)) + { + fprintf(stderr, "%s: form_head failed\n", __func__); + goto failure; + } + else if (!(body = html_node_add_child(root, "body"))) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + goto failure; + } + else if (form_login(body, v->username)) + { + fprintf(stderr, "%s: form_login failed\n", __func__); + goto failure; + } + else if (v->username && form_topic(body, v->category, v->section)) + { + fprintf(stderr, "%s: form_topic failed\n", __func__); + goto failure; + } + + const struct op_cfg ocfg = + { + .end = section_tr, + .error = rollback, + .args = v + }; + + if (!op_run(v->db, "BEGIN TRANSACTION;", &ocfg, r)) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + goto failure; + } + + v->root = root; + v->body = body; + return 0; + +failure: + html_node_free(root); + return -1; +} + +static int end_category(const struct http_payload *const p, + struct http_response *const r, void *const user, void *const args) +{ + struct view *const v = args; + const struct category *const c = &v->u.category; + + if (!c->n_secids) + { + v->end = end_topics; + + if (commit(p, r, v)) + { + fprintf(stderr, "%s: commit failed\n", __func__); + return -1; + } + } + else if (prepare_topics(v, r, c->secids, c->n_secids)) + { + fprintf(stderr, "%s: prepare_topics failed\n", __func__); + return -1; + } + + return 0; +} + +static int section(sqlite3_stmt *const stmt, + const struct http_payload *const pl, struct http_response *const r, + void *const user, void *const args) +{ + int ret = -1; + struct view *const v = args; + struct dynstr d; + struct db_section s = {0}; + struct html_node *li, *div, *a, *p; + + dynstr_init(&d); + + if (!(li = html_node_add_child(v->section_ul, "li")) + || !(div = html_node_add_child(li, "div")) + || !(a = html_node_add_child(div, "a")) + || !(p = html_node_add_child(div, "p"))) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + goto end; + } + else if (db_section(v->db, stmt, &s)) + { + fprintf(stderr, "%s: db_section failed\n", __func__); + goto end; + } + else if (dynstr_append(&d, "/view/%u/%u", s.catid, s.id)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + else if (html_node_add_attr(a, "href", d.str)) + { + fprintf(stderr, "%s: html_node_add_attr failed\n", __func__); + goto end; + } + else if (html_node_set_value(a, s.name) + || html_node_set_value(p, s.desc)) + { + fprintf(stderr, "%s: html_node_set_value failed\n", __func__); + goto end; + } + else if (append_section(&v->u.category, s.id)) + { + fprintf(stderr, "%s: append_section failed\n", __func__); + goto end; + } + + ret = 0; + +end: + dynstr_free(&d); + db_section_free(&s); + return ret; +} + +static int category_tr(const struct http_payload *const pl, + struct http_response *const r, void *const user, void *const args) +{ + int ret = -1; + struct view *const v = args; + struct dynstr d; + + dynstr_init(&d); + + if (dynstr_append(&d, "SELECT * FROM sections WHERE " + "sections.catid = %lu ORDER BY sections.id ASC;", v->category)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + + const struct op_cfg op = + { + .end = end_category, + .error = rollback, + .row = section, + .args = v + }; + + if (!op_run(v->db, d.str, &op, r)) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + goto end; + } + + ret = 0; + +end: + dynstr_free(&d); + return ret; +} + +static int view_category(const struct http_payload *const pl, + struct http_response *const r, void *const user, void *const args) +{ + int ret = -1; + struct view *const v = args; + struct html_node *root = NULL, *body, *div, *h2; + + if (!(root = html_node_alloc("html"))) + { + fprintf(stderr, "%s: html_node_alloc failed\n", __func__); + goto failure; + } + else if (form_head(root)) + { + fprintf(stderr, "%s: form_head failed\n", __func__); + goto failure; + } + else if (!(body = html_node_add_child(root, "body"))) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + goto failure; + } + else if (form_login(body, v->username)) + { + fprintf(stderr, "%s: form_login failed\n", __func__); + goto failure; + } + else if (v->username + && v->role >= AUTH_ROLE_MOD + && form_section(body, v->category)) + { + fprintf(stderr, "%s: form_section failed\n", __func__); + goto failure; + } + else if (!(div = html_node_add_child(body, "div")) + || !(h2 = html_node_add_child(div, "h2")) + || !(v->section_ul = html_node_add_child(body, "ul"))) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + goto failure; + } + else if (html_node_set_value(h2, "Sections")) + { + fprintf(stderr, "%s: html_node_set_value failed\n", __func__); + goto failure; + } + + const struct op_cfg op = + { + .end = category_tr, + .error = rollback, + .args = v + }; + + if (!op_run(v->db, "BEGIN TRANSACTION;", &op, r)) + { + fprintf(stderr, "%s: op_run failed\n", __func__); + goto failure; + } + + v->root = root; + v->body = body; + return 0; + +failure: + html_node_free(root); + free_view(v); + return ret; +} + +static int parse_args(const struct http_payload *const p, struct view *const v) +{ + for (size_t i = 0; i < p->n_args; i++) + { + const struct http_arg *const arg = &p->args[i]; + const char *const key = arg->key, *const value = arg->value; + + if (!strcmp(key, "page") && getul_n(value, &v->page)) + { + fprintf(stderr, "%s: invalid page: %s\n", __func__, value); + return -1; + } + else if (!strcmp(key, "msg") && getul_n(value, &v->msg)) + { + fprintf(stderr, "%s: invalid msg: %s\n", __func__, value); + return -1; + } + } + + return 0; +} + +static int setup(const struct http_payload *const p, + struct http_response *const r, void *const user, sqlite3 *const db, + const struct auth_user *const u) +{ + int ret = -1, error; + const char *tree = p->resource + strlen("/view/"); + struct view *v = NULL; + char *userdup = NULL; + + if (!(v = malloc(sizeof *v))) + { + fprintf(stderr, "%s: malloc(3): %s\n", __func__, strerror(errno)); + goto failure; + } + + *v = (const struct view){.db = db}; + *r = (const struct http_response){.args = v}; + + if (u) + { + if (!(userdup = strdup(u->username))) + { + fprintf(stderr, "%s: strdup(3): %s\n", __func__, strerror(errno)); + goto failure; + } + else if (u->role <= AUTH_ROLE_BANNED) + { + ret = form_unauthorized("Banned account", r); + goto failure; + } + + v->username = userdup; + v->role = u->role; + } + + if (parse_args(p, v)) + { + ret = form_badreq("Invalid arguments", r); + goto failure; + } + else if (getul(&tree, &v->category)) + { + ret = form_badreq("Invalid category", r); + goto failure; + } + else if (!*tree) + r->step.payload = view_category; + else if (getul(&tree, &v->section)) + { + ret = form_badreq("Invalid section", r); + goto failure; + } + else if (!*tree) + r->step.payload = view_section; + else if (getul(&tree, &v->topic)) + { + ret = form_badreq("Invalid topic", r); + goto failure; + } + else + r->step.payload = view_topic; + + return 0; + +failure: + + if ((error = sqlite3_close(db)) != SQLITE_OK) + fprintf(stderr, "%s: sqlite3_close: %s\n", __func__, + sqlite3_errstr(error)); + + free(v); + free(userdup); + return ret; +} + +int ep_view(const struct http_payload *const p, struct http_response *const r, + void *const user) +{ + const int n = auth_validate(p, r, user, setup); + + if (n < 0) + fprintf(stderr, "%s: auth_validate failed\n", __func__); + else if (n) + { + const struct cfg *const cfg = user; + sqlite3 *db; + + if (db_open(cfg->dir, &db)) + { + fprintf(stderr, "%s: db_open failed\n", __func__); + return -1; + } + + return setup(p, r, user, db, NULL); + } + + return n; +} @@ -0,0 +1,38 @@ +/* + * nanobbs, a tiny forums software. + * Copyright (C) 2025-2026 Xavier Del Campo Romero + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#ifndef FORM_H +#define FORM_H + +#include <libweb/html.h> +#include <libweb/http.h> + +int form_footer(struct html_node *n, const char *resource); +int form_login(struct html_node *n, const char *username); +int form_head(struct html_node *n); +int form_category(struct html_node *n); +int form_section(struct html_node *n, unsigned long category); +int form_topic(struct html_node *n, unsigned long category, + unsigned long section); +int form_post(struct html_node *n, unsigned long category, + unsigned long section, unsigned long topic); +int form_badreq(const char *msg, struct http_response *r); +int form_unauthorized(const char *msg, struct http_response *r); +int form_shortpwd(struct http_response *const r); + +#endif diff --git a/form_badreq.c b/form_badreq.c new file mode 100644 index 0000000..e789259 --- /dev/null +++ b/form_badreq.c @@ -0,0 +1,29 @@ +#include "form.h" +#include "defs.h" +#include <dynstr.h> +#include <libweb/http.h> +#include <stdio.h> +#include <stdlib.h> + +int form_badreq(const char *const msg, struct http_response *const r) +{ + struct dynstr d; + + dynstr_init(&d); + + if (dynstr_append(&d, "%s\n<html>%s</html>", DOCTYPE_TAG, msg)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + return -1; + } + + *r = (const struct http_response) + { + .status = HTTP_STATUS_BAD_REQUEST, + .buf.ro = d.str, + .n = d.len, + .free = free + }; + + return 0; +} diff --git a/form_category.c b/form_category.c new file mode 100644 index 0000000..34d442b --- /dev/null +++ b/form_category.c @@ -0,0 +1,40 @@ +#define _POSIX_C_SOURCE 200809L + +#include "form.h" +#include <libweb/html.h> +#include <stdio.h> + +int form_category(struct html_node *const n) +{ + struct html_node *div, *form, *lname, *iname, *submit; + + if (!(div = html_node_add_child(n, "div")) + || !(form = html_node_add_child(div, "form")) + || !(lname = html_node_add_child(form, "label")) + || !(iname = html_node_add_child(form, "input")) + || !(submit = html_node_add_child(form, "input"))) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + return -1; + } + else if (html_node_add_attr(form, "action", "/create/") + || html_node_add_attr(form, "form", "categoryform") + || html_node_add_attr(form, "method", "post") + || html_node_add_attr(lname, "for", "name") + || html_node_add_attr(iname, "type", "text") + || html_node_add_attr(iname, "id", "name") + || html_node_add_attr(iname, "name", "name") + || html_node_add_attr(submit, "type", "submit") + || html_node_add_attr(submit, "value", "Create")) + { + fprintf(stderr, "%s: html_node_add_attr failed\n", __func__); + return -1; + } + else if (html_node_set_value(lname, "Create category")) + { + fprintf(stderr, "%s: html_node_set_value failed\n", __func__); + return -1; + } + + return 0; +} diff --git a/form_footer.c b/form_footer.c new file mode 100644 index 0000000..c29dfd4 --- /dev/null +++ b/form_footer.c @@ -0,0 +1,105 @@ +#include "form.h" +#include "defs.h" +#include <libweb/html.h> +#include <stdio.h> +#include <string.h> + +static int poweredby(struct html_node *const footer) +{ + int ret = -1; + struct html_node *const a = html_node_alloc("a"), *p; + struct dynstr d; + + dynstr_init(&d); + + if (!a) + { + fprintf(stderr, "%s: html_node_alloc failed\n", __func__); + goto end; + } + else if (!(p = html_node_add_child(footer, "p"))) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + goto end; + } + else if (html_node_add_attr(a, "href", PROJECT_URL)) + { + fprintf(stderr, "%s: html_node_add_attr failed\n", __func__); + goto end; + } + else if (html_node_set_value(a, PROJECT_NAME)) + { + fprintf(stderr, "%s: html_node_set_value a failed\n", __func__); + goto end; + } + else if (dynstr_append(&d, "Powered by ")) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + else if (html_serialize(a, &d)) + { + fprintf(stderr, "%s: html_serialize failed\n", __func__); + goto end; + } + else if (html_node_set_value_unescaped(p, d.str)) + { + fprintf(stderr, "%s: html_node_set_value_unescaped failed\n", __func__); + goto end; + } + + ret = 0; + +end: + dynstr_free(&d); + html_node_free(a); + return ret; +} + +static int back(struct html_node *const footer) +{ + struct html_node *const a = html_node_add_child(footer, "a"); + + if (!a) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + return -1; + } + else if (html_node_add_attr(a, "href", "/")) + { + fprintf(stderr, "%s: html_node_add_attr failed\n", __func__); + return -1; + } + else if (html_node_set_value(a, "Back to index")) + { + fprintf(stderr, "%s: html_node_set_value failed\n", __func__); + return -1; + } + + return 0; +} + +int form_footer(struct html_node *const n, const char *const resource) +{ + struct html_node *const footer = html_node_add_child(n, "footer"); + + if (!footer) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + return -1; + } + else if (strcmp(resource, "/") + && strcmp(resource, "/index.html") + && back(footer)) + { + fprintf(stderr, "%s: back failed\n", __func__); + return -1; + } + else if (poweredby(footer)) + { + fprintf(stderr, "%s: poweredby failed\n", __func__); + return -1; + } + + return 0; +} diff --git a/form_head.c b/form_head.c new file mode 100644 index 0000000..64325e9 --- /dev/null +++ b/form_head.c @@ -0,0 +1,45 @@ +#define _POSIX_C_SOURCE 200809L + +#include "form.h" +#include "defs.h" +#include <libweb/html.h> +#include <stdio.h> + +#if 0 + "<link href=\"/" STYLE_PATH "\" rel=\"stylesheet\">" + " <meta charset=\"UTF-8\">\n" \ + " <meta name=\"viewport\"\n" content=\"width=device-width, initial-scale=1, maximum-scale=1\">" \ + "<title>" PROJECT_TITLE "</title>" +#endif + +int form_head(struct html_node *const n) +{ + struct html_node *head, *link, *charset, *name, *title; + + if (!(head = html_node_add_child(n, "head")) + || !(link = html_node_add_child(head, "link")) + || !(charset = html_node_add_child(head, "meta")) + || !(name = html_node_add_child(head, "meta")) + || !(title = html_node_add_child(head, "title"))) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + return -1; + } + else if (html_node_add_attr(link, "href", "/" STYLE_PATH) + || html_node_add_attr(link, "rel", "stylesheet") + || html_node_add_attr(charset, "charset", "UTF-8") + || html_node_add_attr(name, "name", "viewport") + || html_node_add_attr(name, "content", + "width=device-width, initial-scale=1, maximum-scale=1")) + { + fprintf(stderr, "%s: html_node_add_attr failed\n", __func__); + return -1; + } + else if (html_node_set_value(title, PROJECT_TITLE)) + { + fprintf(stderr, "%s: html_node_set_value failed\n", __func__); + return -1; + } + + return 0; +} diff --git a/form_login.c b/form_login.c new file mode 100644 index 0000000..9309c78 --- /dev/null +++ b/form_login.c @@ -0,0 +1,105 @@ +#define _POSIX_C_SOURCE 200809L + +#include "form.h" +#include <libweb/html.h> +#include <stdio.h> + +static int anonymous(struct html_node *const n) +{ + struct html_node *div, *form, *luser, *iuser, *lpass, *ipass, *submit, + *signup; + + if (!(div = html_node_add_child(n, "div")) + || !(form = html_node_add_child(div, "form")) + || !(luser = html_node_add_child(form, "label")) + || !(iuser = html_node_add_child(form, "input")) + || !(lpass = html_node_add_child(form, "label")) + || !(ipass = html_node_add_child(form, "input")) + || !(submit = html_node_add_child(form, "input")) + || !(signup = html_node_add_child(div, "a"))) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + return -1; + } + else if (html_node_add_attr(form, "action", "/login") + || html_node_add_attr(form, "form", "loginform") + || html_node_add_attr(form, "method", "post") + || html_node_add_attr(luser, "for", "username") + || html_node_add_attr(lpass, "for", "password") + || html_node_add_attr(iuser, "type", "text") + || html_node_add_attr(iuser, "id", "username") + || html_node_add_attr(iuser, "name", "username") + || html_node_add_attr(ipass, "type", "password") + || html_node_add_attr(ipass, "id", "password") + || html_node_add_attr(ipass, "name", "password") + || html_node_add_attr(submit, "type", "submit") + || html_node_add_attr(submit, "value", "Log in") + || html_node_add_attr(signup, "href", "/signup")) + { + fprintf(stderr, "%s: html_node_add_attr failed\n", __func__); + return -1; + } + else if (html_node_set_value(luser, "Username:") + || html_node_set_value(lpass, "Password:") + || html_node_set_value(signup, "Sign up")) + { + fprintf(stderr, "%s: html_node_set_value failed\n", __func__); + return -1; + } + + return 0; +} + +static int loggedin(struct html_node *const n, const char *const username) +{ + int ret = -1; + struct html_node *div, *p, *form, *submit, *a; + struct dynstr d; + + dynstr_init(&d); + + if (!(div = html_node_add_child(n, "div")) + || !(p = html_node_add_child(div, "p")) + || !(form = html_node_add_child(div, "form")) + || !(submit = html_node_add_child(form, "input")) + || !(a = html_node_add_child(div, "a"))) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + goto end; + } + else if (dynstr_append(&d, "Logged in as %s", username)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + else if (html_node_set_value(p, d.str) + || html_node_set_value(a, "User control panel")) + { + fprintf(stderr, "%s: html_node_set_value failed\n", __func__); + goto end; + } + else if (html_node_add_attr(form, "action", "/logout") + || html_node_add_attr(form, "form", "logoutform") + || html_node_add_attr(form, "method", "post") + || html_node_add_attr(submit, "type", "submit") + || html_node_add_attr(submit, "value", "Logout") + || html_node_add_attr(a, "href", "/ucp")) + { + fprintf(stderr, "%s: html_node_add_attr failed\n", __func__); + goto end; + } + + ret = 0; + +end: + dynstr_free(&d); + return ret; +} + +int form_login(struct html_node *const n, const char *const username) +{ + if (!username) + return anonymous(n); + + return loggedin(n, username); +} diff --git a/form_post.c b/form_post.c new file mode 100644 index 0000000..c485a55 --- /dev/null +++ b/form_post.c @@ -0,0 +1,54 @@ +#define _POSIX_C_SOURCE 200809L + +#include "form.h" +#include <libweb/html.h> +#include <stdio.h> + +int form_post(struct html_node *const n, const unsigned long category, + const unsigned long section, const unsigned long topic) +{ + int ret = -1; + struct dynstr d; + struct html_node *div, *form, *textarea, *submit; + + dynstr_init(&d); + + if (dynstr_append(&d, "/create/%lu/%lu/%lu", category, section, topic)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + else if (!(div = html_node_add_child(n, "div")) + || !(form = html_node_add_child(div, "form")) + || !(textarea = html_node_add_child(form, "textarea")) + || !(submit = html_node_add_child(form, "input"))) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + goto end; + } + else if (html_node_add_attr(form, "action", d.str) + || html_node_add_attr(form, "form", "topicform") + || html_node_add_attr(form, "method", "post") + || html_node_add_attr(textarea, "id", "post") + || html_node_add_attr(textarea, "name", "post") + || html_node_add_attr(textarea, "maxlength", "1800") + || html_node_add_attr(textarea, "cols", "72") + || html_node_add_attr(textarea, "rows", "8") + || html_node_add_attr(submit, "type", "submit") + || html_node_add_attr(submit, "value", "Create")) + { + fprintf(stderr, "%s: html_node_add_attr failed\n", __func__); + goto end; + } + else if (html_node_set_value(textarea, "")) + { + fprintf(stderr, "%s: html_node_set_value failed\n", __func__); + goto end; + } + + ret = 0; + +end: + dynstr_free(&d); + return ret; +} diff --git a/form_section.c b/form_section.c new file mode 100644 index 0000000..d0bf0b1 --- /dev/null +++ b/form_section.c @@ -0,0 +1,64 @@ +#define _POSIX_C_SOURCE 200809L + +#include "form.h" +#include <libweb/html.h> +#include <stdio.h> + +int form_section(struct html_node *const n, const unsigned long category) +{ + int ret = -1; + struct dynstr d; + struct html_node *div, *form, *lname, *iname, *ltitle, *ititle, *submit; + + dynstr_init(&d); + + if (dynstr_append(&d, "/create/%lu", category)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + else if (!(div = html_node_add_child(n, "div")) + || !(form = html_node_add_child(div, "form")) + || !(lname = html_node_add_child(form, "label")) + || !(iname = html_node_add_child(form, "input")) + || !(ltitle = html_node_add_child(form, "label")) + || !(ititle = html_node_add_child(form, "input")) + || !(submit = html_node_add_child(form, "input"))) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + goto end; + } + else if (html_node_add_attr(form, "action", d.str) + || html_node_add_attr(form, "form", "sectionform") + || html_node_add_attr(form, "method", "post") + || html_node_add_attr(lname, "for", "name") + || html_node_add_attr(iname, "type", "text") + || html_node_add_attr(iname, "id", "name") + || html_node_add_attr(iname, "name", "name") + || html_node_add_attr(iname, "hint", "Section name") + || html_node_add_attr(iname, "size", "40") + || html_node_add_attr(ltitle, "for", "description") + || html_node_add_attr(ititle, "type", "text") + || html_node_add_attr(ititle, "id", "title") + || html_node_add_attr(ititle, "name", "description") + || html_node_add_attr(ititle, "hint", "Section description") + || html_node_add_attr(ititle, "size", "72") + || html_node_add_attr(submit, "type", "submit") + || html_node_add_attr(submit, "value", "Create")) + { + fprintf(stderr, "%s: html_node_add_attr failed\n", __func__); + goto end; + } + else if (html_node_set_value(lname, "Section name") + || html_node_set_value(ltitle, "Section description")) + { + fprintf(stderr, "%s: html_node_set_value failed\n", __func__); + goto end; + } + + ret = 0; + +end: + dynstr_free(&d); + return ret; +} diff --git a/form_shortpwd.c b/form_shortpwd.c new file mode 100644 index 0000000..63f0139 --- /dev/null +++ b/form_shortpwd.c @@ -0,0 +1,21 @@ +#include "form.h" +#include "defs.h" +#include <libweb/http.h> + +int form_shortpwd(struct http_response *const r) +{ + int ret; + struct dynstr d; + + dynstr_init(&d); + + if (dynstr_append(&d, "Password is too short, minimum %d bytes", MINPWDLEN)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + return -1; + } + + ret = form_badreq(d.str, r); + dynstr_free(&d); + return ret; +} diff --git a/form_topic.c b/form_topic.c new file mode 100644 index 0000000..ffd67eb --- /dev/null +++ b/form_topic.c @@ -0,0 +1,63 @@ +#define _POSIX_C_SOURCE 200809L + +#include "form.h" +#include <libweb/html.h> +#include <stdio.h> + +int form_topic(struct html_node *const n, const unsigned long category, + const unsigned long section) +{ + int ret = -1; + struct dynstr d; + struct html_node *div, *form, *lname, *iname, *textarea, *submit; + + dynstr_init(&d); + + if (dynstr_append(&d, "/create/%lu/%lu", category, section)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + else if (!(div = html_node_add_child(n, "div")) + || !(form = html_node_add_child(div, "form")) + || !(lname = html_node_add_child(form, "label")) + || !(iname = html_node_add_child(form, "input")) + || !(textarea = html_node_add_child(form, "textarea")) + || !(submit = html_node_add_child(form, "input"))) + { + fprintf(stderr, "%s: html_node_add_child failed\n", __func__); + goto end; + } + else if (html_node_add_attr(form, "action", d.str) + || html_node_add_attr(form, "form", "topicform") + || html_node_add_attr(form, "method", "post") + || html_node_add_attr(lname, "for", "name") + || html_node_add_attr(iname, "type", "text") + || html_node_add_attr(iname, "id", "name") + || html_node_add_attr(iname, "name", "name") + || html_node_add_attr(iname, "hint", "Topic name") + || html_node_add_attr(iname, "size", "50") + || html_node_add_attr(textarea, "id", "post") + || html_node_add_attr(textarea, "name", "post") + || html_node_add_attr(textarea, "maxlength", "1800") + || html_node_add_attr(textarea, "cols", "72") + || html_node_add_attr(textarea, "rows", "8") + || html_node_add_attr(submit, "type", "submit") + || html_node_add_attr(submit, "value", "Create")) + { + fprintf(stderr, "%s: html_node_add_attr failed\n", __func__); + goto end; + } + else if (html_node_set_value(lname, "Topic name") + || html_node_set_value(textarea, "")) + { + fprintf(stderr, "%s: html_node_set_value failed\n", __func__); + goto end; + } + + ret = 0; + +end: + dynstr_free(&d); + return ret; +} diff --git a/form_unauthorized.c b/form_unauthorized.c new file mode 100644 index 0000000..3d699f2 --- /dev/null +++ b/form_unauthorized.c @@ -0,0 +1,29 @@ +#include "form.h" +#include "defs.h" +#include <dynstr.h> +#include <libweb/http.h> +#include <stdio.h> +#include <stdlib.h> + +int form_unauthorized(const char *const msg, struct http_response *const r) +{ + struct dynstr d; + + dynstr_init(&d); + + if (dynstr_append(&d, "%s\n<html>%s</html>", DOCTYPE_TAG, msg)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + return -1; + } + + *r = (const struct http_response) + { + .status = HTTP_STATUS_UNAUTHORIZED, + .buf.ro = d.str, + .n = d.len, + .free = free + }; + + return 0; +} diff --git a/gencookie.c b/gencookie.c new file mode 100644 index 0000000..9a71b3e --- /dev/null +++ b/gencookie.c @@ -0,0 +1,72 @@ +#define _POSIX_C_SOURCE 200809L + +#include "jwt.h" +#include <cjson/cJSON.h> +#include <libweb/http.h> +#include <sodium.h> +#include <errno.h> +#include <stddef.h> +#include <string.h> +#include <time.h> + +char *gencookie(const char *const name, const char *const key) +{ + char *ret = NULL, *sname = NULL, *c = NULL, *jwt = NULL; + unsigned char dkey[crypto_auth_hmacsha256_KEYBYTES]; + size_t len; + cJSON *j = NULL; + time_t t = time(NULL); + struct tm tm; + + if (t == (time_t)-1) + { + fprintf(stderr, "%s: time(2): %s\n", __func__, strerror(errno)); + goto end; + } + + /* Set 5-year lifetime for cookies. */ + t += 5 * 365 * 24 * 60 * 60; + + if (!localtime_r(&t, &tm)) + { + fprintf(stderr, "%s: localtime_r(3): %s\n", __func__, strerror(errno)); + goto end; + } + else if (!(j = cJSON_CreateObject())) + { + fprintf(stderr, "%s: cJSON_CreateObject failed\n", __func__); + goto end; + } + else if (!cJSON_AddStringToObject(j, "name", name)) + { + fprintf(stderr, "%s: cJSON_AddStringToObject failed\n", __func__); + goto end; + } + else if (sodium_hex2bin(dkey, sizeof dkey, key, strlen(key), NULL, &len, + NULL)) + { + fprintf(stderr, "%s: sodium_hex2bin failed\n", __func__); + goto end; + } + else if (!(jwt = jwt_encode(j, dkey, sizeof dkey))) + { + fprintf(stderr, "%s: jwt_encode failed\n", __func__); + goto end; + } + else if (!(c = http_cookie_create(name, jwt, &tm))) + { + fprintf(stderr, "%s: http_cookie_create failed\n", __func__); + goto end; + } + + ret = c; + +end: + if (!ret) + free(c); + + free(jwt); + free(sname); + cJSON_Delete(j); + return ret; +} @@ -0,0 +1,52 @@ +/* + * nanobbs, a tiny forums software. + * Copyright (C) 2025-2026 Xavier Del Campo Romero + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#define _POSIX_C_SOURCE 200809L + +#include "utils.h" +#include <errno.h> +#include <limits.h> +#include <stdlib.h> +#include <stdio.h> +#include <string.h> + +int getul(const char **const s, unsigned long *const out) +{ + char *end; + unsigned long v; + + errno = 0; + v = strtoul(*s, &end, 10); + + if (errno) + { + fprintf(stderr, "%s: strtoul(3) %s: %s\n", __func__, *s, + strerror(errno)); + return -1; + } + else if ((*end != '/' && *end) || v > LONG_MAX || !v) + { + fprintf(stderr, "%s: invalid number: %.*s\n", __func__, + (int)(end - *s), *s); + return -1; + } + + *out = v; + *s = *end ? end + 1 : end; + return 0; +} diff --git a/getul_n.c b/getul_n.c new file mode 100644 index 0000000..cf150c9 --- /dev/null +++ b/getul_n.c @@ -0,0 +1,33 @@ +#define _POSIX_C_SOURCE 200809L + +#include "utils.h" +#include <errno.h> +#include <limits.h> +#include <stdlib.h> +#include <stdio.h> +#include <string.h> + +int getul_n(const char *const s, unsigned long *const out) +{ + char *end; + unsigned long v; + + errno = 0; + v = strtoull(s, &end, 10); + + if (errno) + { + fprintf(stderr, "%s: strtoul(3) %s: %s\n", __func__, s, + strerror(errno)); + return -1; + } + else if (*end || v > LONG_MAX) + { + fprintf(stderr, "%s: invalid number: %.*s\n", __func__, + (int)(end - s), s); + return -1; + } + + *out = v; + return 0; +} @@ -0,0 +1,229 @@ +/* + * nanobbs, a tiny forums software. + * Copyright (C) 2025-2026 Xavier Del Campo Romero + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#include "jwt.h" +#include <dynstr.h> +#include <sodium.h> +#include <errno.h> +#include <stddef.h> +#include <stdlib.h> +#include <stdio.h> +#include <string.h> + +#define VARIANT sodium_base64_VARIANT_URLSAFE + +static char *b64(const void *const p, const size_t len) +{ + char *ret = NULL; + const size_t n = sodium_base64_encoded_len(len, VARIANT); + + if (!(ret = malloc(n))) + { + fprintf(stderr, "%s: malloc(3): %s\n", __func__, strerror(errno)); + return NULL; + } + else if (!sodium_bin2base64(ret, n, p, len, VARIANT)) + { + fprintf(stderr, "%s: sodium_bin2base64 failed\n", __func__); + free(ret); + return NULL; + } + + return ret; +} + +static int b64d(const char *const b64, const size_t len, void **const outbuf, + size_t *const outsz) +{ + int ret = 1; + size_t sz = 0, binlen; + const char *end; + void *buf = NULL, *p; + +again: + + if (!(p = realloc(buf, ++sz))) + { + fprintf(stderr, "%s: realloc(3): %s\n", __func__, strerror(errno)); + goto failure; + } + else if (sodium_base642bin(buf = p, sz, b64, len, NULL, &binlen, &end, + VARIANT)) + { + if (errno == ERANGE) + goto again; + + fprintf(stderr, "%s: sodium_base642bin failed\n", __func__); + goto failure; + } + + *outbuf = buf; + *outsz = sz; + return 0; + +failure: + free(buf); + return ret; +} + +static char *get_hmac(const void *const buf, const size_t n, + const void *const key, const size_t keyn) +{ + unsigned char hmac[crypto_auth_hmacsha256_KEYBYTES]; + char *ret; + + if (keyn != crypto_auth_hmacsha256_KEYBYTES) + { + fprintf(stderr, "%s: invalid key size (%zu), expected %u\n", __func__, + n, crypto_auth_hmacsha256_KEYBYTES); + return NULL; + } + else if (crypto_auth_hmacsha256(hmac, buf, n, key)) + { + fprintf(stderr, "%s: HMAC failed\n", __func__); + return NULL; + } + else if (!(ret = b64(hmac, sizeof hmac))) + fprintf(stderr, "%s: b64 failed\n", __func__); + + return ret; +} + +char *jwt_encode(const cJSON *const j, const void *const key, const size_t n) +{ + static const char jwt_header[] = "{\"alg\": \"HS256\", \"typ\": \"JWT\"}"; + struct dynstr d; + char *ret = NULL, *p = NULL, *header = NULL, *hmac = NULL, *encp = NULL; + + dynstr_init(&d); + + if (!(p = cJSON_PrintUnformatted(j))) + { + fprintf(stderr, "%s: cJSON_PrintUnformatted failed\n", __func__); + goto end; + } + else if (!(encp = b64(p, strlen(p)))) + { + fprintf(stderr, "%s: b64 %s failed\n", __func__, p); + goto end; + } + else if (!(header = b64(jwt_header, strlen(jwt_header)))) + { + fprintf(stderr, "%s: b64 %s failed\n", __func__, jwt_header); + } + else if (dynstr_append(&d, "%s.%s", header, encp)) + { + fprintf(stderr, "%s: dynstr_append header+payload failed", __func__); + goto end; + } + else if (!(hmac = get_hmac(d.str, d.len, key, n))) + { + fprintf(stderr, "%s: get_hmac failed\n", __func__); + goto end; + } + else if (dynstr_append(&d, ".%s", hmac)) + { + fprintf(stderr, "%s: dynstr_append hmac failed\n", __func__); + goto end; + } + + ret = d.str; + +end: + free(header); + free(hmac); + free(encp); + cJSON_free(p); + + if (!ret) + dynstr_free(&d); + + return ret; +} + +static size_t cnt(const char *s, const char c) +{ + size_t ret = 0; + + while (*s) + if (*s++ == c) + ret++; + + return ret; +} + +int jwt_decode(const char *const jwt, const void *const key, const size_t n, + cJSON **const payload) +{ + int ret = 1; + cJSON *j = NULL; + void *p = NULL; + const char *first = strchr(jwt, '.'), *const last = strrchr(jwt, '.'); + const unsigned char *const in = (const unsigned char *)jwt; + unsigned char hmac[crypto_auth_hmacsha256_KEYBYTES]; + size_t sz; + + if (n != crypto_auth_hmacsha256_KEYBYTES) + { + fprintf(stderr, "%s: invalid key size (%zu), expected %u\n", __func__, + n, crypto_auth_hmacsha256_KEYBYTES); + ret = -1; + goto end; + } + else if (cnt(jwt, '.') != 2 || ++first == last) + goto end; + else if (sodium_base642bin(hmac, sizeof hmac, last + 1, strlen(last + 1), + NULL, NULL, NULL, VARIANT)) + { + fprintf(stderr, "%s: sodium_base642bin failed\n", __func__); + goto end; + } + else if (crypto_auth_hmacsha256_verify(hmac, in, last - jwt, key)) + { + fprintf(stderr, "%s: crypto_auth_hmacsha256_verify failed\n", __func__); + goto end; + } + else if ((ret = b64d(first, last - first, &p, &sz))) + { + if (ret < 0) + fprintf(stderr, "%s: b64d failed\n", __func__); + + goto end; + } + else if (!(j = cJSON_ParseWithLength(p, sz))) + { + fprintf(stderr, "%s: cJSON_ParseWithLength failed\n", __func__); + goto end; + } + else if (!cJSON_IsObject(j)) + { + fprintf(stderr, "%s: cJSON_IsObject failed\n", __func__); + goto end; + } + + *payload = j; + ret = 0; + +end: + + if (ret) + cJSON_Delete(j); + + free(p); + return ret; +} @@ -0,0 +1,28 @@ +/* + * nanobbs, a tiny forums software. + * Copyright (C) 2025-2026 Xavier Del Campo Romero + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#ifndef JWT_H +#define JWT_H + +#include <cjson/cJSON.h> +#include <stddef.h> + +char *jwt_encode(const cJSON *payload, const void *key, size_t n); +int jwt_decode(const char *jwt, const void *key, size_t n, cJSON **payload); + +#endif /* JWT_H */ diff --git a/login_get.c b/login_get.c new file mode 100644 index 0000000..0699cbc --- /dev/null +++ b/login_get.c @@ -0,0 +1,49 @@ +#define _POSIX_C_SOURCE 200809L + +#include "endpoints.h" +#include "defs.h" +#include <libweb/http.h> + +#define LOGIN_BODY \ + "<header>\n" \ + " <a href=\"" PROJECT_URL "\">" PROJECT_NAME "</a>, " \ + PROJECT_DESC "\n" \ + "</header>\n" \ + " <form class=\"loginform\" action=\"/login\" method=\"post\">\n" \ + " <label for=\"username\">Username:</label>\n" \ + " <input type=\"text\" class=\"form-control\"\n" \ + " id=\"username\" name=\"username\" autofocus><br>\n" \ + " <label for=\"username\">Password:</label>\n" \ + " <input type=\"password\" class=\"form-control\" \n" \ + " id=\"password\" name=\"password\"><br>\n" \ + " <input type=\"submit\" value=\"Submit\">\n" \ + " </form>\n" + +int ep_login_get(const struct http_payload *const p, + struct http_response *const r, void *const user) +{ + static const char body[] = + DOCTYPE_TAG + "<html>\n" + " <head>\n" + " " STYLE_A "\n" + " " COMMON_HEAD "\n" + " </head>\n" + " " LOGIN_BODY "\n" + "</html>\n"; + + *r = (const struct http_response) + { + .status = HTTP_STATUS_OK, + .buf.ro = body, + .n = sizeof body - 1 + }; + + if (http_response_add_header(r, "Content-Type", "text/html")) + { + fprintf(stderr, "%s: http_response_add_header failed\n", __func__); + return -1; + } + + return 0; +} @@ -0,0 +1,648 @@ +/* + * nanobbs, a tiny forums software. + * Copyright (C) 2025-2026 Xavier Del Campo Romero + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* As of FreeBSD 13.2, sigaction(2) still conforms to IEEE Std + * 1003.1-1990 (POSIX.1), which did not define SA_RESTART. + * FreeBSD supports it as an extension, but then _POSIX_C_SOURCE must + * not be defined. */ +#ifndef __FreeBSD__ +#define _POSIX_C_SOURCE 200809L +#endif + +#include "db.h" +#include "defs.h" +#include "default.h" +#include "endpoints.h" +#include <libweb/handler.h> +#include <dynstr.h> +#include <sodium.h> +#include <sqlite3.h> +#include <sys/stat.h> +#include <unistd.h> +#include <errno.h> +#include <signal.h> +#include <stdint.h> +#include <stdlib.h> +#include <stdio.h> +#include <string.h> +#include <time.h> + +static struct handler *handler; + +static void usage(char *const argv[]) +{ + fprintf(stderr, "%s [-t tmpdir] [-p port] dir\n", *argv); +} + +static int parse_args(const int argc, char *const argv[], + const char **const dir, unsigned short *const port, + const char **const tmpdir) +{ + const char *const envtmp = getenv("TMPDIR"); + int opt; + + /* Default values. */ + *port = 0; + *tmpdir = envtmp ? envtmp : "/tmp"; + + while ((opt = getopt(argc, argv, "t:p:")) != -1) + { + switch (opt) + { + case 't': + *tmpdir = optarg; + break; + + case 'p': + { + char *endptr; + const unsigned long portul = strtoul(optarg, &endptr, 10); + + if (*endptr || portul > UINT16_MAX) + { + fprintf(stderr, "%s: invalid port %s\n", __func__, optarg); + return -1; + } + + *port = portul; + } + break; + + default: + usage(argv); + return -1; + } + } + + if (optind >= argc) + { + usage(argv); + return -1; + } + + *dir = argv[optind]; + return 0; +} + +static int ensure_dir(const char *const dir) +{ + struct stat sb; + + if (stat(dir, &sb)) + { + switch (errno) + { + case ENOENT: + if (mkdir(dir, S_IRWXU)) + { + fprintf(stderr, "%s: mkdir(2) %s: %s\n", + __func__, dir, strerror(errno)); + return -1; + } + + printf("Created empty directory at %s\n", dir); + break; + + default: + fprintf(stderr, "%s: stat(2): %s\n", __func__, strerror(errno)); + return -1; + } + } + else if (!S_ISDIR(sb.st_mode)) + { + fprintf(stderr, "%s: %s not a directory\n", __func__, dir); + return -1; + } + + return 0; +} + +static int dump_file(const char *const path, const void *const buf, + const size_t n) +{ + int ret = -1; + FILE *const f = fopen(path, "wb"); + + if (!f) + { + fprintf(stderr, "%s: fopen(3) %s: %s\n", + __func__, path, strerror(errno)); + goto end; + } + else if (!fwrite(buf, n, 1, f)) + { + fprintf(stderr, "%s: fwrite(3): %s\n", __func__, strerror(errno)); + goto end; + } + + ret = 0; + +end: + if (f && fclose(f)) + { + fprintf(stderr, "%s: fclose(3): %s\n", __func__, strerror(errno)); + ret = -1; + } + + return ret; +} + +static int dump_default_style(const char *const path) +{ + if (dump_file(path, default_style, default_style_len)) + return -1; + + printf("Dumped default stylesheet into %s\n", path); + return 0; +} + +static int dump_default_terms(const char *const path) +{ + if (dump_file(path, default_terms, default_terms_len)) + return -1; + + printf("Dumped default terms of service into %s\n", path); + return 0; +} + +static int dump_default_prv_policy(const char *const path) +{ + if (dump_file(path, default_prv_policy, default_prv_policy_len)) + return -1; + + printf("Dumped default privacy policy into %s\n", path); + return 0; +} + +static int ensure_file(const char *const dir, const char *const f, + int (*const fn)(const char *)) +{ + int ret = -1; + struct dynstr d; + struct stat sb; + + dynstr_init(&d); + + if (dynstr_append(&d, "%s/%s", dir, f)) + { + fprintf(stderr, "%s: dynstr_append user failed\n", __func__); + goto end; + } + else if (stat(d.str, &sb)) + { + if (errno == ENOENT) + { + if (fn(d.str)) + { + fprintf(stderr, "%s: user callback failed\n", __func__); + goto end; + } + } + else + { + fprintf(stderr, "%s: stat(2): %s\n", __func__, strerror(errno)); + goto end; + } + } + else if (!S_ISREG(sb.st_mode)) + { + fprintf(stderr, "%s: %s not a regular file\n", __func__, d.str); + return -1; + } + + ret = 0; + +end: + dynstr_free(&d); + return ret; +} + +static int ensure_style(const char *const dir) +{ + return ensure_file(dir, STYLE_PATH, dump_default_style); +} + +static int ensure_terms(const char *const dir) +{ + return ensure_file(dir, TERMS_PATH, dump_default_terms); +} + +static int ensure_prv_policy(const char *const dir) +{ + return ensure_file(dir, PRV_PATH, dump_default_prv_policy); +} + +static void handle_signal(const int signum) +{ + switch (signum) + { + case SIGINT: + /* Fall through. */ + case SIGTERM: + handler_notify_close(handler); + break; + + default: + break; + } +} + +static int init_signals(void) +{ + struct sigaction sa = + { + .sa_handler = handle_signal, + .sa_flags = SA_RESTART + }; + + sigemptyset(&sa.sa_mask); + + static const struct signal + { + int signal; + const char *name; + } signals[] = + { + {.signal = SIGINT, .name = "SIGINT"}, + {.signal = SIGTERM, .name = "SIGTERM"}, + {.signal = SIGPIPE, .name = "SIGPIPE"} + }; + + for (size_t i = 0; i < sizeof signals / sizeof *signals; i++) + { + const struct signal *const s = &signals[i]; + + if (sigaction(s->signal, &sa, NULL)) + { + fprintf(stderr, "%s: sigaction(2) %s: %s\n", + __func__, s->name, strerror(errno)); + return -1; + } + } + + return 0; +} + +static int add_urls(struct handler *const h, void *const user) +{ + static const struct url + { + const char *url; + enum http_op op; + handler_fn f; + } urls[] = + { + {.url = "/", .op = HTTP_OP_GET, .f = ep_index}, + {.url = "/view/*", .op = HTTP_OP_GET, .f = ep_view}, + {.url = "/index.html", .op = HTTP_OP_GET, .f = ep_index}, + {.url = "/style.css", .op = HTTP_OP_GET, .f = ep_style}, + {.url = "/login", .op = HTTP_OP_POST, .f = ep_login}, + {.url = "/logout", .op = HTTP_OP_POST, .f = ep_logout}, + {.url = "/signup", .op = HTTP_OP_GET, .f = ep_signup}, + {.url = "/signup", .op = HTTP_OP_POST, .f = ep_signup}, + {.url = "/passwd", .op = HTTP_OP_POST, .f = ep_passwd}, + {.url = "/create/*", .op = HTTP_OP_POST, .f = ep_create}, + {.url = "/ucp", .op = HTTP_OP_GET, .f = ep_ucp} + }; + + for (size_t i = 0; i < sizeof urls / sizeof *urls; i++) + { + const struct url *const u = &urls[i]; + + if (handler_add(h, u->url, u->op, u->f, user)) + { + fprintf(stderr, "%s: handler_add %s failed\n", __func__, u->url); + return -1; + } + } + + return 0; +} + +static int check_length(const enum http_op op, const char *const res, + const unsigned long long len, const struct http_cookie *const c, + struct http_response *const r, void *const user) +{ + return 1; +} + +static int statement(sqlite3 *const db, const char *const st) +{ + int ret = -1; + sqlite3_stmt *stmt = NULL; + int error = sqlite3_prepare_v2(db, st, -1, &stmt, NULL); + + if (error != SQLITE_OK) + { + fprintf(stderr, "%s: sqlite3_prepare_v2 \"%s\": %s\n", __func__, st, + sqlite3_errstr(error)); + goto end; + } + + while ((error = sqlite3_step(stmt)) == SQLITE_BUSY) + ; + + if (error != SQLITE_DONE) + { + sqlite3_reset(stmt); + fprintf(stderr, "%s: sqlite3_step: %s\n", __func__, + sqlite3_errstr(error)); + goto end; + } + + ret = 0; + +end: + + if ((error = sqlite3_finalize(stmt))) + { + fprintf(stderr, "%s: sqlite3_finalize: %s\n", __func__, + sqlite3_errstr(error)); + ret = -1; + } + + return ret; +} + +static int ensure_admin(sqlite3 *const db) +{ + int ret = -1, error; + sqlite3_stmt *stmt = NULL; + unsigned char key[crypto_auth_hmacsha256_KEYBYTES]; + char hashpwd[crypto_pwhash_STRBYTES], enckey[sizeof key * 2 + 1]; + struct dynstr d; + static const char username[] = "admin", password[] = "1234"; + const time_t t = time(NULL); + + dynstr_init(&d); + crypto_auth_hmacsha256_keygen(key); + + if (t == (time_t)-1) + { + fprintf(stderr, "%s: time(2): %s\n", __func__, strerror(errno)); + goto end; + } + else if (!sodium_bin2hex(enckey, sizeof enckey, key, sizeof key)) + { + fprintf(stderr, "%s: sodium_bin2hex failed\n", __func__); + goto end; + } + else if (crypto_pwhash_str(hashpwd, password, strlen(password), + crypto_pwhash_OPSLIMIT_INTERACTIVE, crypto_pwhash_MEMLIMIT_INTERACTIVE)) + { + fprintf(stderr, "%s: crypto_pwhash_str failed\n", __func__); + goto end; + } + else if (dynstr_append(&d, "INSERT INTO " + "users(name, password, signkey, creat, roleid) VALUES" + "('%s', '%s', '%s', %jd, (SELECT id FROM roles WHERE name = '%s'));", + username, hashpwd, enckey, (intmax_t)t, username)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + else if ((error = sqlite3_prepare_v2(db, d.str, d.len, &stmt, NULL) + != SQLITE_OK)) + { + fprintf(stderr, "%s: sqlite3_prepare_v2 \"%s\": %s\n", __func__, + d.str, sqlite3_errstr(error)); + goto end; + } + + while ((error = sqlite3_step(stmt)) == SQLITE_BUSY) + ; + + if (error == SQLITE_DONE) + fprintf(stderr, "Created default '%s' user with password '%s'. " + "Please change its password before deploying.\n", username, + password); + else + { + sqlite3_reset(stmt); + + if (error != SQLITE_CONSTRAINT) + { + fprintf(stderr, "%s: sqlite3_step: %s\n", __func__, + sqlite3_errstr(error)); + goto end; + } + } + + ret = 0; + +end: + + if ((error = sqlite3_finalize(stmt)) != SQLITE_OK) + { + fprintf(stderr, "%s: sqlite3_finalize: %s\n", __func__, + sqlite3_errstr(error)); + ret = -1; + } + + dynstr_free(&d); + return ret; +} + +static int ensure_tokenkey(sqlite3 *const db) +{ + int ret = -1; + struct dynstr d; + unsigned char key[32]; + char enckey[sizeof key * 2 + 1]; + + dynstr_init(&d); + randombytes_buf(key, sizeof key); + + if (!sodium_bin2hex(enckey, sizeof enckey, key, sizeof key)) + { + fprintf(stderr, "%s: sodium_bin2hex failed\n", __func__); + goto end; + } + else if (dynstr_append(&d, "INSERT OR IGNORE INTO globals(key, value) " + "VALUES('tokenkey', '%s');", enckey)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + else if (statement(db, d.str)) + { + fprintf(stderr, "%s: statement failed\n", __func__); + goto end; + } + + ret = 0; + +end: + dynstr_free(&d); + return ret; +} + +static int ensure_tables(const char *const dir) +{ + static const char roles[] = "CREATE TABLE IF NOT EXISTS roles (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "name TEXT NOT NULL UNIQUE" + ");", + users[] = "CREATE TABLE IF NOT EXISTS users (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "name TEXT NOT NULL UNIQUE," + "password TEXT NOT NULL," + "signkey TEXT NOT NULL," + "creat BIGINT NOT NULL," + "thumbnail BLOB," + "roleid INTEGER NOT NULL," + "FOREIGN KEY (roleid) REFERENCES roles (id)" + ");", + categories[] = "CREATE TABLE IF NOT EXISTS categories (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "name TEXT NOT NULL" + ");", + sections[] = "CREATE TABLE IF NOT EXISTS sections (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "name TEXT NOT NULL," + "description TEXT NOT NULL," + "catid INTEGER NOT NULL," + "FOREIGN KEY (catid) REFERENCES categories (id)" + ");", + topics[] = "CREATE TABLE IF NOT EXISTS topics (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "title TEXT NOT NULL," + "creat INTEGER NOT NULL," + "secid INTEGER NOT NULL," + "uid INTEGER NOT NULL," + "FOREIGN KEY (secid) REFERENCES sections (id)" + "FOREIGN KEY (uid) REFERENCES users (id)" + ");", + posts[] = "CREATE TABLE IF NOT EXISTS posts (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "text TEXT NOT NULL," + "creat BIGINT NOT NULL," + "uid INTEGER NOT NULL," + "topid INTEGER NOT NULL," + "FOREIGN KEY (topid) REFERENCES topics (id)," + "FOREIGN KEY (uid) REFERENCES users (id)" + ");", + globals[] = "CREATE TABLE IF NOT EXISTS globals (" + "id INTEGER PRIMARY KEY," + "key TEXT NOT NULL UNIQUE," + "value TEXT NOT NULL" + ");", + role_insert[] = "INSERT OR IGNORE INTO roles(name) " + "VALUES('admin'),('mod'),('user'),('banned');", + version_insert[] = "INSERT OR IGNORE INTO globals(key, value) " + "VALUES('version', '1');"; + + int ret = -1, error; + sqlite3 *db = NULL; + + if (db_open(dir, &db)) + { + fprintf(stderr, "%s: db_open failed\n", __func__); + goto end; + } + else if (statement(db, "BEGIN TRANSACTION;") + || statement(db, roles) + || statement(db, role_insert) + || statement(db, users) + || statement(db, categories) + || statement(db, sections) + || statement(db, topics) + || statement(db, posts) + || statement(db, globals) + || statement(db, version_insert) + || ensure_tokenkey(db) + || ensure_admin(db) + || statement(db, "COMMIT;")) + { + if (statement(db, "ROLLBACK;")) + fprintf(stderr, "%s: rollback failed\n", __func__); + + goto end; + } + + ret = 0; + +end: + + if ((error = sqlite3_close(db)) != SQLITE_OK) + { + fprintf(stderr, "%s: sqlite3_close: %s\n", __func__, + sqlite3_errstr(error)); + ret = -1; + } + + return ret; +} + +int main(int argc, char *argv[]) +{ + int ret = EXIT_FAILURE; + const char *dir, *tmpdir; + unsigned short port; + + if (sodium_init()) + { + fprintf(stderr, "%s: sodium_init failed\n", __func__); + goto end; + } + else if (parse_args(argc, argv, &dir, &port, &tmpdir) + || ensure_dir(dir) + || ensure_terms(dir) + || ensure_prv_policy(dir) + || ensure_style(dir) + || ensure_tables(dir)) + goto end; + + const struct handler_cfg cfg = + { + .length = check_length, + .tmpdir = tmpdir, + /* Arbitrary limit. */ + .max_headers = 25, + .post = + { + /* Arbitrary limit. */ + .max_files = 1, + /* File upload only requires one pair. */ + .max_pairs = 1 + } + }; + + unsigned short outport; + struct cfg c = {.dir = dir}; + + if (!(handler = handler_alloc(&cfg)) + || add_urls(handler, &c) + || init_signals() + || handler_listen(handler, port, &outport)) + goto end; + + printf("Listening on port %hu\n", outport); + + if (handler_loop(handler)) + { + fprintf(stderr, "%s: handler_loop failed\n", __func__); + goto end; + } + + ret = EXIT_SUCCESS; + +end: + handler_free(handler); + return ret; +} @@ -0,0 +1,183 @@ +/* + * nanobbs, a tiny forums software. + * Copyright (C) 2025-2026 Xavier Del Campo Romero + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#include "op.h" +#include <libweb/http.h> +#include <errno.h> +#include <stddef.h> +#include <stdlib.h> +#include <string.h> + +struct op +{ + sqlite3_stmt *stmt; + struct op_cfg cfg; +}; + +static int teardown(struct op *const op) +{ + int ret = 0, error; + + if (!op) + return 0; + else if ((error = sqlite3_finalize(op->stmt)) != SQLITE_OK) + { + fprintf(stderr, "%s: sqlite3_finalize: %s\n", __func__, + sqlite3_errstr(error)); + ret = -1; + } + + const struct op_cfg *const cfg = &op->cfg; + + if (cfg->error) + cfg->error(cfg->args); + + free(op); + return ret; +} + +static void run_error(void *const p) +{ + teardown(p); +} + +static int run(const struct http_payload *const pl, + struct http_response *const r, void *const user, void *const args) +{ + struct op *const op = args; + sqlite3_stmt *const stmt = op->stmt; + const struct op_cfg *const cfg = &op->cfg; + int ret = -1, error = sqlite3_step(stmt); + + switch (error) + { + case SQLITE_BUSY: + break; + + case SQLITE_ROW: + if (!cfg->row) + { + fprintf(stderr, "%s: expected row callback\n", __func__); + goto failure; + } + else if ((ret = cfg->row(op->stmt, pl, r, user, cfg->args))) + goto failure; + + break; + + case SQLITE_CONSTRAINT: + sqlite3_reset(op->stmt); + + if (!cfg->constraint) + { + fprintf(stderr, "%s: expected constraint callback\n", __func__); + goto failure; + } + else if ((ret = cfg->constraint(cfg->args))) + goto failure; + + /* Fall through. */ + case SQLITE_DONE: + { + const int error = sqlite3_finalize(op->stmt); + + if (error != SQLITE_OK) + { + fprintf(stderr, "%s: sqlite3_finalize: %s\n", __func__, + sqlite3_errstr(error)); + ret = -1; + goto failure; + } + else if (cfg->end && (ret = cfg->end(pl, r, user, cfg->args))) + goto failure; + + free(op); + } + break; + + default: + fprintf(stderr, "%s: sqlite3_step: %s\n", __func__, + sqlite3_errstr(error)); + sqlite3_reset(stmt); + goto failure; + } + + return 0; + +failure: + + if (teardown(op)) + { + fprintf(stderr, "%s: teardown failed\n", __func__); + ret = -1; + } + + return ret; +} + +void op_resume(struct op *const op, struct http_response *const r) +{ + *r = (const struct http_response) + { + .step.payload = run, + .free = run_error, + .args = op + }; +} + +struct op *op_run(sqlite3 *const db, const char *const query, + const struct op_cfg *const cfg, struct http_response *const r) +{ + sqlite3_stmt *stmt = NULL; + struct op *const ret = malloc(sizeof *ret); + + if (!ret) + { + fprintf(stderr, "%s: malloc(3): %s\n", __func__, strerror(errno)); + goto failure; + } + + const int error = sqlite3_prepare_v2(db, query, -1, &stmt, NULL); + + if (error != SQLITE_OK && error != SQLITE_BUSY) + { + fprintf(stderr, "%s: sqlite3_prepare_v2 \"%s\": %s\n", __func__, + query, sqlite3_errstr(error)); + goto failure; + } + + *r = (const struct http_response) + { + .step.payload = run, + .free = run_error, + .args = ret + }; + + *ret = (const struct op) + { + .stmt = stmt, + .cfg = *cfg + }; + + return ret; + +failure: + free(ret); + sqlite3_finalize(stmt); + return NULL; +} @@ -0,0 +1,40 @@ +/* + * nanobbs, a tiny forums software. + * Copyright (C) 2025-2026 Xavier Del Campo Romero + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#ifndef OP_H +#define OP_H + +#include <libweb/http.h> +#include <sqlite3.h> + +struct op_cfg +{ + int (*constraint)(void *args); + void (*error)(void *args); + int (*row)(sqlite3_stmt *stmt, const struct http_payload *p, + struct http_response *r, void *user, void *args); + int (*end)(const struct http_payload *p, + struct http_response *r, void *user, void *args); + void *args; +}; + +struct op *op_run(sqlite3 *db, const char *query, const struct op_cfg *cfg, + struct http_response *r); +void op_resume(struct op *op, struct http_response *r); + +#endif diff --git a/sanitize.c b/sanitize.c new file mode 100644 index 0000000..50aee3b --- /dev/null +++ b/sanitize.c @@ -0,0 +1,54 @@ +#define _POSIX_C_SOURCE 200809L + +#include "utils.h" +#include <dynstr.h> +#include <stddef.h> +#include <string.h> +#include <stdio.h> + +char *sanitize(const char *s) +{ + struct dynstr d; + + dynstr_init(&d); + + while (*s) + { + char ss[sizeof "\"\""] = {0}; + + switch (*s) + { + case '\'': + strcpy(ss, "\'\'"); + break; + + case '\"': + strcpy(ss, "\"\""); + break; + + default: + *ss = *s; + break; + } + + if (dynstr_append(&d, "%s", ss)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto failure; + } + + s++; + } + + if (!d.str && dynstr_append(&d, "%c", '\0')) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto failure; + } + + return d.str; + +failure: + dynstr_free(&d); + return NULL; +} diff --git a/tokengen.c b/tokengen.c new file mode 100644 index 0000000..9b88085 --- /dev/null +++ b/tokengen.c @@ -0,0 +1,155 @@ +#define _POSIX_C_SOURCE 200809L + +#include "jwt.h" +#include <cjson/cJSON.h> +#include <dynstr.h> +#include <sodium.h> +#include <sqlite3.h> +#include <errno.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> + +static int tokenkey(void *const args, const int n, char **const values, + char **const keys) +{ + char *key; + + if (n != 1) + { + fprintf(stderr, "%s: expected 1 key only, got %d\n", __func__, n); + return -1; + } + else if (!(key = strdup(*values))) + { + fprintf(stderr, "%s: strdup(3): %s\n", __func__, strerror(errno)); + return -1; + } + + *(char **)args = key; + return 0; +} + +static int process(sqlite3 *const db, const char *const user) +{ + static const char query[] = "SELECT value FROM globals " + "WHERE key = 'tokenkey';"; + int ret = -1, error; + cJSON *j = NULL; + char *s = NULL, *key = NULL, *jwt = NULL; + unsigned char dkey[32]; + time_t t; + + if ((error = sqlite3_exec(db, query, tokenkey, &key, &s)) != SQLITE_OK) + { + fprintf(stderr, "%s: sqlite3_exec \"%s\": %s (%s)\n", __func__, query, + sqlite3_errstr(error), s); + goto end; + } + else if (strlen(key) != sizeof dkey * 2) + { + fprintf(stderr, "%s: unexpected key length: %zu\n", __func__, + strlen(key)); + goto end; + } + else if (sodium_hex2bin(dkey, sizeof dkey, key, strlen(key), NULL, NULL, + NULL)) + { + fprintf(stderr, "%s: sodium_hex2bin failed\n", __func__); + goto end; + } + else if (!(j = cJSON_CreateObject())) + { + fprintf(stderr, "%s: cJSON_CreateObject failed\n", __func__); + goto end; + } + else if (!cJSON_AddStringToObject(j, "name", user)) + { + fprintf(stderr, "%s: cJSON_AddStringToObject failed\n", __func__); + goto end; + } + else if ((t = time(NULL)) == (time_t)-1) + { + fprintf(stderr, "%s: time(2): %s\n", __func__, strerror(errno)); + goto end; + } + else if (!cJSON_AddNumberToObject(j, "exp", t + 24 * 60 * 60)) + { + fprintf(stderr, "%s: cJSON_AddNumberToObject failed\n", __func__); + goto end; + } + else if (!(jwt = jwt_encode(j, dkey, sizeof dkey))) + { + fprintf(stderr, "%s: jwt_encode failed\n", __func__); + goto end; + } + + printf("%s %s\n", user, jwt); + ret = 0; + +end: + free(jwt); + free(key); + sqlite3_free(s); + cJSON_Delete(j); + return ret; +} + +static sqlite3 *open_db(const char *const dir) +{ + sqlite3 *ret = NULL, *db = NULL; + struct dynstr d; + int error; + + dynstr_init(&d); + + if (dynstr_append(&d, "%s/nanobbs.db", dir)) + { + fprintf(stderr, "%s: dynstr_append failed\n", __func__); + goto end; + } + else if ((error = sqlite3_open(d.str, &db)) != SQLITE_OK) + { + fprintf(stderr, "%s: sqlite3_open %s: %s\n", __func__, d.str, + sqlite3_errstr(error)); + goto end; + } + + ret = db; + +end: + dynstr_free(&d); + return ret; +} + +int main(int argc, char *argv[]) +{ + int ret = EXIT_FAILURE, error; + sqlite3 *db = NULL; + + if (argc < 3) + { + fprintf(stderr, "%s <dir> <user> [...]\n", *argv); + goto end; + } + else if (!(db = open_db(argv[1]))) + goto end; + + for (int i = 2; i < argc; i++) + if (process(db, argv[i])) + goto end; + + ret = EXIT_SUCCESS; + +end: + + if ((error = sqlite3_close(db)) != SQLITE_OK) + { + fprintf(stderr, "%s: sqlite3_close: %s\n", __func__, + sqlite3_errstr(error)); + ret = EXIT_FAILURE; + } + + return ret; +} @@ -0,0 +1,30 @@ +/* + * nanobbs, a tiny forums software. + * Copyright (C) 2025-2026 Xavier Del Campo Romero + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +#ifndef UTILS_H +#define UTILS_H + +#include <time.h> + +char *astrftime(const char *fmt, const struct tm *tm); +int getul(const char **s, unsigned long *out); +int getul_n(const char *s, unsigned long *out); +char *sanitize(const char *s); +char *gencookie(const char *name, const char *key); + +#endif |
