aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorXavier Del Campo Romero <xavi92@disroot.org>2025-09-22 17:32:44 +0200
committerXavier Del Campo Romero <xavi92@disroot.org>2026-02-13 09:57:39 +0100
commit78bf2fe4a5bf37514f6dfd203ef969da0bf40c2e (patch)
tree33f9440b8ee0fa7a3b3ad033616d722d2101bb4d
parent107a2e43d54f9a42fb85b00b83cb0d9abb194680 (diff)
Setup project skeletonHEADmaster
-rw-r--r--.gitignore6
-rw-r--r--CMakeLists.txt65
-rw-r--r--LICENSE661
-rw-r--r--README.md176
-rw-r--r--astrftime.c58
-rw-r--r--auth.c327
-rw-r--r--auth.h45
-rw-r--r--cmake/FindGraphicsMagick.cmake310
-rw-r--r--cmake/Findlibsodium.cmake29
-rw-r--r--cmake/Findweb.cmake24
-rwxr-xr-xconfigure262
-rw-r--r--db.c188
-rw-r--r--db.h69
-rw-r--r--db_post.c63
-rw-r--r--db_section.c59
-rw-r--r--db_topic.c57
-rw-r--r--default.h10
-rw-r--r--default_prv_policy.c30
-rw-r--r--default_style.c63
-rw-r--r--default_terms.c13
-rw-r--r--defs.h43
-rw-r--r--endpoints.h21
-rw-r--r--ep_create.c695
-rw-r--r--ep_index.c432
-rw-r--r--ep_login.c306
-rw-r--r--ep_logout.c85
-rw-r--r--ep_passwd.c400
-rw-r--r--ep_signup.c651
-rw-r--r--ep_style.c89
-rw-r--r--ep_ucp.c241
-rw-r--r--ep_view.c1180
-rw-r--r--form.h38
-rw-r--r--form_badreq.c29
-rw-r--r--form_category.c40
-rw-r--r--form_footer.c105
-rw-r--r--form_head.c45
-rw-r--r--form_login.c105
-rw-r--r--form_post.c54
-rw-r--r--form_section.c64
-rw-r--r--form_shortpwd.c21
-rw-r--r--form_topic.c63
-rw-r--r--form_unauthorized.c29
-rw-r--r--gencookie.c72
-rw-r--r--getul.c52
-rw-r--r--getul_n.c33
-rw-r--r--jwt.c229
-rw-r--r--jwt.h28
-rw-r--r--login_get.c49
-rw-r--r--main.c648
-rw-r--r--op.c183
-rw-r--r--op.h40
-rw-r--r--sanitize.c54
-rw-r--r--tokengen.c155
-rw-r--r--utils.h30
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)
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..be3f7b2
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <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;
+}
diff --git a/auth.c b/auth.c
new file mode 100644
index 0000000..f8fd6bd
--- /dev/null
+++ b/auth.c
@@ -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;
+}
diff --git a/auth.h b/auth.h
new file mode 100644
index 0000000..de3511f
--- /dev/null
+++ b/auth.h
@@ -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
diff --git a/db.c b/db.c
new file mode 100644
index 0000000..1feb927
--- /dev/null
+++ b/db.c
@@ -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;
+}
diff --git a/db.h b/db.h
new file mode 100644
index 0000000..37219c7
--- /dev/null
+++ b/db.h
@@ -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;
diff --git a/defs.h b/defs.h
new file mode 100644
index 0000000..c63e95e
--- /dev/null
+++ b/defs.h
@@ -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;
+}
diff --git a/form.h b/form.h
new file mode 100644
index 0000000..83d2697
--- /dev/null
+++ b/form.h
@@ -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&nbsp;"))
+ {
+ 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;
+}
diff --git a/getul.c b/getul.c
new file mode 100644
index 0000000..57fc394
--- /dev/null
+++ b/getul.c
@@ -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;
+}
diff --git a/jwt.c b/jwt.c
new file mode 100644
index 0000000..48d249a
--- /dev/null
+++ b/jwt.c
@@ -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;
+}
diff --git a/jwt.h b/jwt.h
new file mode 100644
index 0000000..af7e257
--- /dev/null
+++ b/jwt.h
@@ -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;
+}
diff --git a/main.c b/main.c
new file mode 100644
index 0000000..5ef8681
--- /dev/null
+++ b/main.c
@@ -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;
+}
diff --git a/op.c b/op.c
new file mode 100644
index 0000000..ead1ae5
--- /dev/null
+++ b/op.c
@@ -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;
+}
diff --git a/op.h b/op.h
new file mode 100644
index 0000000..ebd0923
--- /dev/null
+++ b/op.h
@@ -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;
+}
diff --git a/utils.h b/utils.h
new file mode 100644
index 0000000..5338e64
--- /dev/null
+++ b/utils.h
@@ -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