230 lines
8.1 KiB
C++
230 lines
8.1 KiB
C++
|
|
#include "db.h"
|
||
|
|
|
||
|
|
#ifdef VOICECAT_HAS_NET
|
||
|
|
|
||
|
|
#include <chrono>
|
||
|
|
#include <cstring>
|
||
|
|
#include <stdexcept>
|
||
|
|
|
||
|
|
#include <sodium.h>
|
||
|
|
#include <sqlite3.h>
|
||
|
|
|
||
|
|
namespace voicecat::server {
|
||
|
|
|
||
|
|
// ── Schema ────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
static constexpr const char* kCreateSchema = R"sql(
|
||
|
|
CREATE TABLE IF NOT EXISTS accounts (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
username TEXT UNIQUE NOT NULL,
|
||
|
|
pw_hash TEXT NOT NULL,
|
||
|
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||
|
|
created_at INTEGER NOT NULL,
|
||
|
|
last_login INTEGER NOT NULL DEFAULT 0
|
||
|
|
);
|
||
|
|
CREATE TABLE IF NOT EXISTS server_meta (
|
||
|
|
key TEXT PRIMARY KEY,
|
||
|
|
value TEXT NOT NULL
|
||
|
|
);
|
||
|
|
INSERT OR IGNORE INTO server_meta (key, value) VALUES ('schema_version', '1');
|
||
|
|
)sql";
|
||
|
|
|
||
|
|
// ── Database ──────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
Database::Database(std::string path) : path_(std::move(path)) {}
|
||
|
|
|
||
|
|
Database::~Database() {
|
||
|
|
if (db_) { sqlite3_close(db_); db_ = nullptr; }
|
||
|
|
}
|
||
|
|
|
||
|
|
bool Database::open(std::string& error) {
|
||
|
|
int rc = sqlite3_open(path_.c_str(), &db_);
|
||
|
|
if (rc != SQLITE_OK) {
|
||
|
|
error = sqlite3_errmsg(db_);
|
||
|
|
sqlite3_close(db_);
|
||
|
|
db_ = nullptr;
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
sqlite3_busy_timeout(db_, 5000);
|
||
|
|
// WAL mode for concurrency
|
||
|
|
exec("PRAGMA journal_mode=WAL", error);
|
||
|
|
exec("PRAGMA synchronous=NORMAL", error);
|
||
|
|
error.clear();
|
||
|
|
if (!exec(kCreateSchema, error)) return false;
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
bool Database::is_empty() {
|
||
|
|
sqlite3_stmt* stmt = nullptr;
|
||
|
|
sqlite3_prepare_v2(db_, "SELECT COUNT(*) FROM accounts", -1, &stmt, nullptr);
|
||
|
|
int count = 0;
|
||
|
|
if (sqlite3_step(stmt) == SQLITE_ROW) count = sqlite3_column_int(stmt, 0);
|
||
|
|
sqlite3_finalize(stmt);
|
||
|
|
return count == 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
std::optional<Account> Database::create_account(const std::string& username,
|
||
|
|
const std::string& password,
|
||
|
|
bool is_admin, std::string& error) {
|
||
|
|
if (username.empty() || password.empty()) {
|
||
|
|
error = "username and password must not be empty";
|
||
|
|
return std::nullopt;
|
||
|
|
}
|
||
|
|
// Hash with Argon2id via libsodium
|
||
|
|
char hash[crypto_pwhash_STRBYTES];
|
||
|
|
if (crypto_pwhash_str(hash, password.c_str(), password.size(),
|
||
|
|
crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
||
|
|
crypto_pwhash_MEMLIMIT_INTERACTIVE) != 0) {
|
||
|
|
error = "Argon2id hashing failed (OOM?)";
|
||
|
|
return std::nullopt;
|
||
|
|
}
|
||
|
|
|
||
|
|
int64_t now = now_unix();
|
||
|
|
sqlite3_stmt* stmt = nullptr;
|
||
|
|
int rc = sqlite3_prepare_v2(db_,
|
||
|
|
"INSERT INTO accounts (username, pw_hash, is_admin, created_at) VALUES (?,?,?,?)",
|
||
|
|
-1, &stmt, nullptr);
|
||
|
|
if (rc != SQLITE_OK) { error = sqlite3_errmsg(db_); return std::nullopt; }
|
||
|
|
|
||
|
|
sqlite3_bind_text(stmt, 1, username.c_str(), -1, SQLITE_TRANSIENT);
|
||
|
|
sqlite3_bind_text(stmt, 2, hash, -1, SQLITE_TRANSIENT);
|
||
|
|
sqlite3_bind_int(stmt, 3, is_admin ? 1 : 0);
|
||
|
|
sqlite3_bind_int64(stmt, 4, now);
|
||
|
|
rc = sqlite3_step(stmt);
|
||
|
|
sqlite3_finalize(stmt);
|
||
|
|
|
||
|
|
if (rc != SQLITE_DONE) {
|
||
|
|
error = sqlite3_errmsg(db_);
|
||
|
|
return std::nullopt;
|
||
|
|
}
|
||
|
|
|
||
|
|
Account acc;
|
||
|
|
acc.id = sqlite3_last_insert_rowid(db_);
|
||
|
|
acc.username = username;
|
||
|
|
acc.is_admin = is_admin;
|
||
|
|
acc.created_at = now;
|
||
|
|
return acc;
|
||
|
|
}
|
||
|
|
|
||
|
|
bool Database::reset_password(const std::string& username, const std::string& new_password,
|
||
|
|
std::string& error) {
|
||
|
|
char hash[crypto_pwhash_STRBYTES];
|
||
|
|
if (crypto_pwhash_str(hash, new_password.c_str(), new_password.size(),
|
||
|
|
crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
||
|
|
crypto_pwhash_MEMLIMIT_INTERACTIVE) != 0) {
|
||
|
|
error = "Argon2id hashing failed";
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
sqlite3_stmt* stmt = nullptr;
|
||
|
|
sqlite3_prepare_v2(db_, "UPDATE accounts SET pw_hash=? WHERE username=?", -1, &stmt, nullptr);
|
||
|
|
sqlite3_bind_text(stmt, 1, hash, -1, SQLITE_TRANSIENT);
|
||
|
|
sqlite3_bind_text(stmt, 2, username.c_str(), -1, SQLITE_TRANSIENT);
|
||
|
|
int rc = sqlite3_step(stmt);
|
||
|
|
sqlite3_finalize(stmt);
|
||
|
|
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return false; }
|
||
|
|
if (sqlite3_changes(db_) == 0) { error = "user not found: " + username; return false; }
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
bool Database::delete_account(const std::string& username, std::string& error) {
|
||
|
|
sqlite3_stmt* stmt = nullptr;
|
||
|
|
sqlite3_prepare_v2(db_, "DELETE FROM accounts WHERE username=?", -1, &stmt, nullptr);
|
||
|
|
sqlite3_bind_text(stmt, 1, username.c_str(), -1, SQLITE_TRANSIENT);
|
||
|
|
int rc = sqlite3_step(stmt);
|
||
|
|
sqlite3_finalize(stmt);
|
||
|
|
if (rc != SQLITE_DONE) { error = sqlite3_errmsg(db_); return false; }
|
||
|
|
if (sqlite3_changes(db_) == 0) { error = "user not found: " + username; return false; }
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
std::vector<Account> Database::list_accounts() {
|
||
|
|
std::vector<Account> result;
|
||
|
|
sqlite3_stmt* stmt = nullptr;
|
||
|
|
sqlite3_prepare_v2(db_,
|
||
|
|
"SELECT id, username, is_admin, created_at, last_login FROM accounts ORDER BY username",
|
||
|
|
-1, &stmt, nullptr);
|
||
|
|
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||
|
|
Account acc;
|
||
|
|
acc.id = sqlite3_column_int64(stmt, 0);
|
||
|
|
acc.username = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
|
||
|
|
acc.is_admin = sqlite3_column_int(stmt, 2) != 0;
|
||
|
|
acc.created_at = sqlite3_column_int64(stmt, 3);
|
||
|
|
acc.last_login = sqlite3_column_int64(stmt, 4);
|
||
|
|
result.push_back(acc);
|
||
|
|
}
|
||
|
|
sqlite3_finalize(stmt);
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
std::optional<Account> Database::authenticate(const std::string& username,
|
||
|
|
const std::string& password) {
|
||
|
|
sqlite3_stmt* stmt = nullptr;
|
||
|
|
sqlite3_prepare_v2(db_,
|
||
|
|
"SELECT id, pw_hash, is_admin, created_at, last_login FROM accounts WHERE username=?",
|
||
|
|
-1, &stmt, nullptr);
|
||
|
|
sqlite3_bind_text(stmt, 1, username.c_str(), -1, SQLITE_TRANSIENT);
|
||
|
|
int rc = sqlite3_step(stmt);
|
||
|
|
if (rc != SQLITE_ROW) { sqlite3_finalize(stmt); return std::nullopt; }
|
||
|
|
|
||
|
|
int64_t id = sqlite3_column_int64(stmt, 0);
|
||
|
|
std::string hash = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1));
|
||
|
|
bool is_admin = sqlite3_column_int(stmt, 2) != 0;
|
||
|
|
int64_t created = sqlite3_column_int64(stmt, 3);
|
||
|
|
sqlite3_finalize(stmt);
|
||
|
|
|
||
|
|
// Verify Argon2id — deliberately slow
|
||
|
|
if (crypto_pwhash_str_verify(hash.c_str(), password.c_str(), password.size()) != 0)
|
||
|
|
return std::nullopt;
|
||
|
|
|
||
|
|
// Update last_login
|
||
|
|
int64_t now = now_unix();
|
||
|
|
sqlite3_stmt* upd = nullptr;
|
||
|
|
sqlite3_prepare_v2(db_, "UPDATE accounts SET last_login=? WHERE id=?", -1, &upd, nullptr);
|
||
|
|
sqlite3_bind_int64(upd, 1, now);
|
||
|
|
sqlite3_bind_int64(upd, 2, id);
|
||
|
|
sqlite3_step(upd);
|
||
|
|
sqlite3_finalize(upd);
|
||
|
|
|
||
|
|
Account acc;
|
||
|
|
acc.id = id;
|
||
|
|
acc.username = username;
|
||
|
|
acc.is_admin = is_admin;
|
||
|
|
acc.created_at = created;
|
||
|
|
acc.last_login = now;
|
||
|
|
return acc;
|
||
|
|
}
|
||
|
|
|
||
|
|
std::string Database::generate_password(size_t length) {
|
||
|
|
static const char kAlphabet[] =
|
||
|
|
"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*";
|
||
|
|
constexpr size_t kAlphaLen = sizeof(kAlphabet) - 1;
|
||
|
|
std::string pw;
|
||
|
|
pw.reserve(length);
|
||
|
|
for (size_t i = 0; i < length; ++i) {
|
||
|
|
uint8_t rnd[1];
|
||
|
|
randombytes_buf(rnd, 1);
|
||
|
|
pw += kAlphabet[rnd[0] % kAlphaLen];
|
||
|
|
}
|
||
|
|
return pw;
|
||
|
|
}
|
||
|
|
|
||
|
|
bool Database::exec(const std::string& sql, std::string& error) {
|
||
|
|
char* errmsg = nullptr;
|
||
|
|
int rc = sqlite3_exec(db_, sql.c_str(), nullptr, nullptr, &errmsg);
|
||
|
|
if (rc != SQLITE_OK) {
|
||
|
|
error = errmsg ? errmsg : "unknown error";
|
||
|
|
if (errmsg) sqlite3_free(errmsg);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
int64_t Database::now_unix() const {
|
||
|
|
using namespace std::chrono;
|
||
|
|
return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
|
||
|
|
}
|
||
|
|
|
||
|
|
} // namespace voicecat::server
|
||
|
|
|
||
|
|
#endif // VOICECAT_HAS_NET
|