/*
* 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 .
*/
/* 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
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
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;
}