aboutsummaryrefslogtreecommitdiff
path: root/main.c
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 /main.c
parent107a2e43d54f9a42fb85b00b83cb0d9abb194680 (diff)
Setup project skeletonHEADmaster
Diffstat (limited to 'main.c')
-rw-r--r--main.c648
1 files changed, 648 insertions, 0 deletions
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;
+}