From cbd2270628e61c71b7f0530947098e5eecd435fd Mon Sep 17 00:00:00 2001 From: Jacob Date: Wed, 11 Mar 2026 00:27:19 -0400 Subject: [PATCH] Some tyhigns. --- .gitignore | 3 + client/public/app.js | 217 ++++++++++++++++++++------------------ client/public/wsModule.js | 18 ++-- client/server.js | 18 ++++ server/config.mk | 1 + server/src/api.c | 53 ++++++++-- server/src/chat.c | 147 ++++++++++++++++++++------ server/src/data.c | 7 ++ server/src/db.c | 201 +++++++++++++++++++++++++++++++++++ server/src/include/api.h | 17 +++ server/src/include/chat.h | 14 +-- server/src/include/data.h | 6 ++ server/src/include/db.h | 25 +++++ server/src/main.c | 7 ++ 14 files changed, 571 insertions(+), 163 deletions(-) create mode 100644 server/src/db.c create mode 100644 server/src/include/db.h diff --git a/.gitignore b/.gitignore index 1d00464..0270b07 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ server/.cache/ # Node dependencies. client/node_modules/ +# Client server config files. +client/domain.txt + # Logs. *.log diff --git a/client/public/app.js b/client/public/app.js index 0fa1dcd..c760a5b 100644 --- a/client/public/app.js +++ b/client/public/app.js @@ -29,118 +29,127 @@ document.addEventListener("DOMContentLoaded", () => { const defaultName = "Anon"; const MAX_NAME_LEN = 15; // server limit is NAME_MAX_LENGTH-1 (15 chars) - const { socket, sendMessage, sendName } = initWebSocket( - defaultName, - chat, - inputContainer, - usersList, - ); - - // Renames. - nameButton.addEventListener("click", () => { - const newName = nameInput.value.trim() || defaultName; - if (newName.length > MAX_NAME_LEN) { - addNotice( - `Name may be at most ${MAX_NAME_LEN} characters.`, + // Fetch WebSocket domain from the client server so deployments can point to a + // remote websocket host without rebuilding the bundle. + fetch("/ws-domain") + .then((res) => res.json()) + .then(({ domain }) => domain || "") + .catch(() => "") + .then((wsDomain) => { + const { socket, sendMessage, sendName } = initWebSocket( + defaultName, + chat, + inputContainer, + usersList, + wsDomain, ); - renderChat(chat, inputContainer); - nameInput.focus(); - return; - } - sendName(newName); - updateUser(getMyId(), newName); - const groups = getGroupedUsers(); - usersList.innerHTML = "Online: " + groups.join(", "); + // Renames. + nameButton.addEventListener("click", () => { + const newName = nameInput.value.trim() || defaultName; + if (newName.length > MAX_NAME_LEN) { + addNotice( + `Name may be at most ${MAX_NAME_LEN} characters.`, + ); + renderChat(chat, inputContainer); + nameInput.focus(); + return; + } - nameInput.value = ""; - nameInput.placeholder = `Name: ${newName}`; - input.focus(); - }); + sendName(newName); + updateUser(getMyId(), newName); + const groups = getGroupedUsers(); + usersList.innerHTML = "Online: " + groups.join(", "); - // Enter to set name. - nameInput.addEventListener("keydown", (e) => { - if (e.key === "Enter") nameButton.click(); - }); + nameInput.value = ""; + nameInput.placeholder = `Name: ${newName}`; + input.focus(); + }); - // Button to send. - sendButton.addEventListener("click", () => { - const text = input.value.trim(); - if (!text) return; - sendMessage(text); - input.value = ""; - input.focus(); - }); + // Enter to set name. + nameInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") nameButton.click(); + }); - // Enter to send and escape to go to root. - input.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - sendButton.click(); - } else if (e.key === "Escape") { - setReplyTo(null); - input.placeholder = ""; - renderChat(chat, inputContainer); - input.focus(); - } - }); + // Button to send. + sendButton.addEventListener("click", () => { + const text = input.value.trim(); + if (!text) return; + sendMessage(text); + input.value = ""; + input.focus(); + }); - // Click message to reply. - chat.addEventListener("click", (e) => { - const msgDiv = e.target.closest(".msg"); - if (!msgDiv) return; - const id = msgDiv.dataset.id; - if (!id) return; - const threads = getThreads(); - const author = threads.get(id)?.username; - setReplyTo(id); - setFocused(id); - input.placeholder = author ? `Replying to @${author}` : ""; - renderChat(chat, inputContainer); - input.focus(); - }); + // Enter to send and escape to go to root. + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + sendButton.click(); + } else if (e.key === "Escape") { + setReplyTo(null); + input.placeholder = ""; + renderChat(chat, inputContainer); + input.focus(); + } + }); - // Go back to root. - rootButton.addEventListener("click", () => { - setReplyTo(null); - clearFocused(); - input.placeholder = ""; - renderChat(chat, inputContainer); - input.focus(); - }); + // Click message to reply. + chat.addEventListener("click", (e) => { + const msgDiv = e.target.closest(".msg"); + if (!msgDiv) return; + const id = msgDiv.dataset.id; + if (!id) return; + const threads = getThreads(); + const author = threads.get(id)?.username; + setReplyTo(id); + setFocused(id); + input.placeholder = author ? `Replying to @${author}` : ""; + renderChat(chat, inputContainer); + input.focus(); + }); - // Global Escape handler: clear reply context, focus input, and clear focused message. - document.addEventListener("keydown", (e) => { - if (e.key === "Escape") { - setReplyTo(null); - clearFocused(); - input.placeholder = ""; - renderChat(chat, inputContainer); - input.focus(); - } - }); + // Go back to root. + rootButton.addEventListener("click", () => { + setReplyTo(null); + clearFocused(); + input.placeholder = ""; + renderChat(chat, inputContainer); + input.focus(); + }); - // Arrow key navigation for focused messages (also sets reply target). - document.addEventListener("keydown", (e) => { - if (e.key === "ArrowDown" || e.key === "ArrowUp") { - const msgs = Array.from(chat.querySelectorAll("div.msg")); - if (!msgs.length) return; - let idx = msgs.findIndex((div) => div.dataset.id === getFocused()); - if (idx === -1) { - idx = e.key === "ArrowDown" ? -1 : msgs.length; - } - idx = - e.key === "ArrowDown" - ? Math.min(msgs.length - 1, idx + 1) - : Math.max(0, idx - 1); - const newId = msgs[idx].dataset.id; - setFocused(newId); - setReplyTo(newId); // Move input box under focused message. - const threads = getThreads(); - const author = threads.get(newId)?.username; - input.placeholder = author ? `Replying to @${author}` : ""; - renderChat(chat, inputContainer); - const newDiv = chat.querySelector(`div[data-id="${newId}"]`); - if (newDiv) newDiv.scrollIntoView({ block: "nearest" }); - } - }); + // Global Escape handler: clear reply context, focus input, and clear focused message. + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + setReplyTo(null); + clearFocused(); + input.placeholder = ""; + renderChat(chat, inputContainer); + input.focus(); + } + }); + + // Arrow key navigation for focused messages (also sets reply target). + document.addEventListener("keydown", (e) => { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + const msgs = Array.from(chat.querySelectorAll("div.msg")); + if (!msgs.length) return; + let idx = msgs.findIndex((div) => div.dataset.id === getFocused()); + if (idx === -1) { + idx = e.key === "ArrowDown" ? -1 : msgs.length; + } + idx = + e.key === "ArrowDown" + ? Math.min(msgs.length - 1, idx + 1) + : Math.max(0, idx - 1); + const newId = msgs[idx].dataset.id; + setFocused(newId); + setReplyTo(newId); // Move input box under focused message. + const threads = getThreads(); + const author = threads.get(newId)?.username; + input.placeholder = author ? `Replying to @${author}` : ""; + renderChat(chat, inputContainer); + const newDiv = chat.querySelector(`div[data-id="${newId}"]`); + if (newDiv) newDiv.scrollIntoView({ block: "nearest" }); + } + }); + }); }); diff --git a/client/public/wsModule.js b/client/public/wsModule.js index a4efaae..3dedbe2 100644 --- a/client/public/wsModule.js +++ b/client/public/wsModule.js @@ -27,14 +27,20 @@ import { renderChat } from "./render.js"; * @param {HTMLElement} chatEl - The container element for chat entries. * @param {HTMLElement} inputContainer - The message input area element. * @param {HTMLElement} usersListEl - The element displaying the online users. + * @param {string} wsDomain - Host (and optional port) for the WebSocket server. * @returns {{ socket: WebSocket, sendMessage: (content: string) => void }} */ -export function initWebSocket(username, chatEl, inputContainer, usersListEl) { - const socket = new WebSocket( //"ws://localhost:8080"); - (location.protocol === "https:" ? "wss" : "ws") + - "://" + - location.host + - "/ws", +export function initWebSocket( + username, + chatEl, + inputContainer, + usersListEl, + wsDomain, +) { + const socket = new WebSocket( + `${location.protocol === "https:" ? "wss" : "ws"}://${ + wsDomain || location.host + }/ws`, "coms", ); diff --git a/client/server.js b/client/server.js index c27908a..ae58331 100644 --- a/client/server.js +++ b/client/server.js @@ -1,5 +1,6 @@ const express = require("express"); const path = require("path"); +const fs = require("fs"); const isDev = process.env.NODE_ENV !== "production"; let livereload, connectLivereload; if (isDev) { @@ -8,6 +9,19 @@ if (isDev) { } const app = express(); const PORT = 3000; +const DOMAIN_FILE = path.join(__dirname, "domain.txt"); + +function readWsDomain() { + try { + const val = fs.readFileSync(DOMAIN_FILE, "utf8").trim(); + if (val) return val; + } catch (err) { + if (err.code !== "ENOENT") { + console.warn("Failed to read domain.txt:", err.message); + } + } + return process.env.WS_DOMAIN || "localhost:8080"; +} if (isDev) { const liveReloadServer = livereload.createServer(); @@ -17,6 +31,10 @@ if (isDev) { app.use(express.static("public")); +app.get("/ws-domain", (_req, res) => { + res.json({ domain: readWsDomain() }); +}); + app.get("/", (req, res) => { res.sendFile(path.join(__dirname, "public", "index.html")); }); diff --git a/server/config.mk b/server/config.mk index 10479b3..57d29b8 100644 --- a/server/config.mk +++ b/server/config.mk @@ -11,6 +11,7 @@ CC = clang -std=c23 LINK = clang CFLAGS = -Wall -DDBG -ggdb -fsanitize=leak -I$(INC_DIR) -I$(SRC_DIR) LDFLAGS = -lwebsockets +LDFLAGS += -lsqlite3 PRINT = echo -e SRC_FILES = $(wildcard $(SRC_DIR)/*.c) diff --git a/server/src/api.c b/server/src/api.c index 2a65b8d..070c032 100644 --- a/server/src/api.c +++ b/server/src/api.c @@ -6,13 +6,20 @@ #include static const char* packet_type_strings[] = { - [PACKET_TYPE_JOIN] = "join", [PACKET_TYPE_WELCOME] = "welcome", - [PACKET_TYPE_JOIN_EVT] = "join_evt", [PACKET_TYPE_MSG] = "msg", - [PACKET_TYPE_MSG_EVT] = "msg_evt", [PACKET_TYPE_NAME] = "name", - [PACKET_TYPE_NAME_EVT] = "name_evt", [PACKET_TYPE_LEAVE_EVT] = "leave_evt", - [PACKET_TYPE_PING] = "ping", [PACKET_TYPE_PONG] = "pong", - [PACKET_TYPE_MSG_ACK] = "msg_ack", [PACKET_TYPE_NAME_ACK] = "name_ack", - + [PACKET_TYPE_JOIN] = "join", + [PACKET_TYPE_WELCOME] = "welcome", + [PACKET_TYPE_JOIN_EVT] = "join_evt", + [PACKET_TYPE_MSG] = "msg", + [PACKET_TYPE_MSG_EVT] = "msg_evt", + [PACKET_TYPE_NAME] = "name", + [PACKET_TYPE_NAME_EVT] = "name_evt", + [PACKET_TYPE_PING] = "ping", + [PACKET_TYPE_PONG] = "pong", + [PACKET_TYPE_MSG_ACK] = "msg_ack", + [PACKET_TYPE_NAME_ACK] = "name_ack", + [PACKET_TYPE_HISTORY_REQ] = "history_req", + [PACKET_TYPE_HISTORY_RES] = "history_res", + [PACKET_TYPE_LEAVE_EVT] = "leave_evt", }; PacketType packet_type_parse(const char* type) { @@ -138,6 +145,35 @@ pack_name_ack(yyjson_mut_doc* doc, yyjson_mut_val* data, PacketNameAck* ack) { yyjson_mut_obj_add_str(doc, data, "name", ack->name); } +static void pack_history_res( + yyjson_mut_doc* doc, yyjson_mut_val* data, PacketHistoryRes* hist +) { + yyjson_mut_obj_add_str(doc, data, "room", hist->room); + yyjson_mut_obj_add_bool(doc, data, "has_more", hist->has_more); + yyjson_mut_obj_add_int(doc, data, "oldest_id", hist->oldest_id); + yyjson_mut_obj_add_int(doc, data, "historyc", hist->historyc); + yyjson_mut_val* history = yyjson_mut_arr(doc); + for (size_t i = 0; i < hist->historyc; i++) { + MsgData* m = hist->history[i]; + if (!m) continue; + yyjson_mut_val* msg = yyjson_mut_obj(doc); + yyjson_mut_obj_add_uint(doc, msg, "id", m->id); + yyjson_mut_val* auth = yyjson_mut_obj(doc); + yyjson_mut_obj_add_uint(doc, auth, "id", m->author.id); + if (m->author.name) + yyjson_mut_obj_add_str(doc, auth, "name", *m->author.name); + else yyjson_mut_obj_add_str(doc, auth, "name", ""); + yyjson_mut_obj_add_val(doc, msg, "author", auth); + if (m->parent != UINT64_MAX) + yyjson_mut_obj_add_uint(doc, msg, "parent", m->parent); + else yyjson_mut_obj_add_null(doc, msg, "parent"); + yyjson_mut_obj_add_str(doc, msg, "content", m->content); + yyjson_mut_obj_add_int(doc, msg, "timestamp", m->timestamp); + yyjson_mut_arr_add_val(history, msg); + } + yyjson_mut_obj_add_val(doc, data, "history", history); +} + char* packet_string(Packet* packet, size_t* sz) { yyjson_mut_doc* doc = yyjson_mut_doc_new(NULL); @@ -181,6 +217,9 @@ char* packet_string(Packet* packet, size_t* sz) { case PACKET_TYPE_NAME_ACK: pack_name_ack(doc, data, (PacketNameAck*)packet->data); break; + case PACKET_TYPE_HISTORY_RES: + pack_history_res(doc, data, (PacketHistoryRes*)packet->data); + break; default: printf( "Something's gone badly wrong. Trying to get string of " diff --git a/server/src/chat.c b/server/src/chat.c index 61457ec..d7601c5 100644 --- a/server/src/chat.c +++ b/server/src/chat.c @@ -1,6 +1,7 @@ #include "include/chat.h" #include "include/api.h" #include "include/data.h" +#include "include/db.h" #include "include/session.h" #include @@ -10,33 +11,9 @@ #include #include -MsgData* chat_history[CHAT_HISTORY_SZ] = {NULL}; -size_t chat_history_head = 0; // Next insertion index. -size_t chat_history_count = 0; // Number of valid entries. -static MsgID next_msg_id = 1; +static const char DEFAULT_ROOM[] = "global"; -MsgData* chat_history_msg_add(MsgData* msg) { - chat_history[chat_history_head] = msg; - chat_history_head = (chat_history_head + 1) % CHAT_HISTORY_SZ; - if (chat_history_count < CHAT_HISTORY_SZ) { chat_history_count++; } - - return msg; -} - -MsgData** chat_history_nice(void) { - MsgData** msgs = calloc(CHAT_HISTORY_SZ, sizeof(MsgData*)); - - // Oldest entry is head - count (mod size). - size_t start = (chat_history_head + CHAT_HISTORY_SZ - chat_history_count) % - CHAT_HISTORY_SZ; - - for (size_t j = 0; j < chat_history_count; j++) { - size_t idx = (start + j) % CHAT_HISTORY_SZ; - msgs[j] = chat_history[idx]; - } - - return msgs; -} +// Legacy in-memory ring removed; history is now persisted in SQLite. // Parse a raw packet. Packet* packet_parse(const char* in, size_t len) { @@ -69,6 +46,9 @@ void do_join(Session* sess, Packet* packet) { session_set_name(sess, name); } +#define WELCOME_HISTORY_LIMIT 50 +#define HISTORY_LIMIT_MAX 200 + // Do a welcome packet. void do_welcome(Session* sess) { // Build list of online users (only sessions with a name). @@ -85,10 +65,20 @@ void do_welcome(Session* sess) { oi++; } - // Build history list. - MsgData** history = chat_history_nice(); + // Fetch latest messages from DB, then reverse to oldest-first. + MsgData** history = NULL; size_t historyc = 0; - while (historyc < CHAT_HISTORY_SZ && history[historyc]) historyc++; + if (db_fetch_messages( + DEFAULT_ROOM, 0, WELCOME_HISTORY_LIMIT, &history, &historyc + ) != 0) { + history = NULL; + historyc = 0; + } + for (size_t i = 0; i < historyc / 2; i++) { + MsgData* tmp = history[i]; + history[i] = history[historyc - 1 - i]; + history[historyc - 1 - i] = tmp; + } PacketWelcome data = { .id = session_get_id(sess), @@ -103,7 +93,84 @@ void do_welcome(Session* sess) { session_send(sess, packet); free(packet); free(online); - free(history); + if (history) { + for (size_t i = 0; i < historyc; i++) free(history[i]); + free(history); + } +} + +// Handle a history request packet. +static void do_history_req(Session* sess, Packet* packet) { + if (!sess || !packet) return; + + const yyjson_val* data = (const yyjson_val*)packet->data; + const yyjson_val* jroom = data ? yyjson_obj_get(data, "room") : NULL; + const char* room_str = + (jroom && yyjson_is_str(jroom)) ? yyjson_get_str(jroom) : DEFAULT_ROOM; + if (!room_verify(room_str)) room_str = DEFAULT_ROOM; + + MsgID before = 0; + const yyjson_val* jbefore = data ? yyjson_obj_get(data, "before") : NULL; + if (jbefore) { + if (yyjson_is_int(jbefore)) { + int64_t v = yyjson_get_int(jbefore); + if (v > 0) before = (MsgID)v; + } else if (yyjson_is_str(jbefore)) { + const char* s = yyjson_get_str(jbefore); + char* endp = NULL; + unsigned long long tmp = strtoull(s, &endp, 10); + if (endp && *endp == '\0') before = (MsgID)tmp; + } + } + + size_t limit = WELCOME_HISTORY_LIMIT; + const yyjson_val* jlimit = data ? yyjson_obj_get(data, "limit") : NULL; + if (jlimit && yyjson_is_int(jlimit)) { + int64_t v = yyjson_get_int(jlimit); + if (v > 0 && v <= HISTORY_LIMIT_MAX) limit = (size_t)v; + } + + MsgData** rows = NULL; + size_t count = 0; + if (db_fetch_messages(room_str, before, limit + 1, &rows, &count) != 0) { + return; + } + + bool has_more = false; + if (count > limit) { + has_more = true; + free(rows[count - 1]); + rows[count - 1] = NULL; + count = limit; + } + + // reverse to oldest-first + for (size_t i = 0; i < count / 2; i++) { + MsgData* tmp = rows[i]; + rows[i] = rows[count - 1 - i]; + rows[count - 1 - i] = tmp; + } + + MsgID oldest_id = count ? rows[0]->id : 0; + + PacketHistoryRes res = { + .room = {0}, + .historyc = count, + .history = rows, + .has_more = has_more, + .oldest_id = oldest_id + }; + strncpy(res.room, room_str, ROOM_MAX_LENGTH - 1); + res.room[ROOM_MAX_LENGTH - 1] = '\0'; + + Packet* out = packet_init(PACKET_TYPE_HISTORY_RES, &res); + session_send(sess, out); + free(out); + + if (rows) { + for (size_t i = 0; i < count; i++) free(rows[i]); + free(rows); + } } // Do a welcome packet. @@ -188,15 +255,18 @@ void do_msg(Session* sess, Packet* packet) { } } - MsgData* msg = malloc(sizeof(MsgData)); + MsgData* msg = calloc(1, sizeof(MsgData)); if (!msg) { lwsl_err("Failed to allocate MsgData.\n"); return; } - msg->id = next_msg_id++; msg->author.id = session_get_id(sess); msg->author.name = session_get_name(sess); + strncpy(msg->author_name, *msg->author.name, NAME_MAX_LENGTH - 1); + msg->author_name[NAME_MAX_LENGTH - 1] = '\0'; + strncpy(msg->room, DEFAULT_ROOM, ROOM_MAX_LENGTH - 1); + msg->room[ROOM_MAX_LENGTH - 1] = '\0'; msg->parent = parent; strncpy(msg->content, content, MSG_MAX_LENGTH - 1); msg->content[MSG_MAX_LENGTH - 1] = '\0'; @@ -208,7 +278,14 @@ void do_msg(Session* sess, Packet* packet) { (msg->parent == UINT64_MAX ? "null" : "") ); - chat_history_msg_add(msg); + if (db_insert_message(msg, &msg->id) != 0) { + ack.status = "db_error"; + Packet* p = packet_init(PACKET_TYPE_MSG_ACK, &ack); + session_send(sess, p); + free(p); + free(msg); + return; + } ack.id = msg->id; Packet* ackp = packet_init(PACKET_TYPE_MSG_ACK, &ack); @@ -218,6 +295,7 @@ void do_msg(Session* sess, Packet* packet) { Packet* evt = packet_init(PACKET_TYPE_MSG_EVT, msg); session_send_all(evt); free(evt); + free(msg); } static void do_ping(Session* sess) { @@ -349,6 +427,9 @@ int cb_chat( free(ackp); break; } + case PACKET_TYPE_HISTORY_REQ: + do_history_req(sess, packet); + break; case PACKET_TYPE_PONG: // Client responded; nothing else to do (timer continues). if (sess) { diff --git a/server/src/data.c b/server/src/data.c index 95a0045..c111e05 100644 --- a/server/src/data.c +++ b/server/src/data.c @@ -13,3 +13,10 @@ int msg_verify(const char* content) { if (strlen(content) > MSG_MAX_LENGTH) return 0; return 1; } + +int room_verify(const char* room) { + if (!room) return 0; + if (strlen(room) == 0) return 0; + if (strlen(room) > ROOM_MAX_LENGTH) return 0; + return 1; +} diff --git a/server/src/db.c b/server/src/db.c new file mode 100644 index 0000000..d80302b --- /dev/null +++ b/server/src/db.c @@ -0,0 +1,201 @@ +#include "include/db.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static sqlite3* db = NULL; + +static int ensure_dir(const char* path) { + struct stat st = {0}; + if (stat(path, &st) == -1) { + if (mkdir(path, 0755) == -1 && errno != EEXIST) return -1; + } else if (!S_ISDIR(st.st_mode)) { + errno = ENOTDIR; + return -1; + } + return 0; +} + +static int exec_simple(const char* sql) { + char* err = NULL; + int rc = sqlite3_exec(db, sql, NULL, NULL, &err); + if (rc != SQLITE_OK) { + lwsl_err("SQLite exec failed: %s\n", err ? err : "(null)"); + sqlite3_free(err); + } + return rc; +} + +int db_init(const char* path) { + if (db) return 0; + + // Ensure parent directory exists. + char dir[256]; + const char* slash = strrchr(path, '/'); + size_t dlen = slash ? (size_t)(slash - path) : 0; + if (dlen >= sizeof(dir)) return -1; + if (dlen > 0) { + memcpy(dir, path, dlen); + dir[dlen] = '\0'; + if (ensure_dir(dir) != 0) { + lwsl_err( + "Failed to create data dir %s: %s\n", dir, strerror(errno) + ); + return -1; + } + } + + if (sqlite3_open(path, &db) != SQLITE_OK) { + lwsl_err("Failed to open db at %s: %s\n", path, sqlite3_errmsg(db)); + return -1; + } + + exec_simple("PRAGMA journal_mode=WAL;"); + exec_simple("PRAGMA synchronous=NORMAL;"); + exec_simple("PRAGMA foreign_keys=ON;"); + + const char* create_messages = "CREATE TABLE IF NOT EXISTS messages (" + " id INTEGER PRIMARY KEY," + " room TEXT NOT NULL," + " author_id INTEGER NOT NULL," + " author_name TEXT NOT NULL," + " parent INTEGER," + " content TEXT NOT NULL," + " timestamp INTEGER NOT NULL," + " deleted_at INTEGER" + ");"; + + const char* create_idx_ts = + "CREATE INDEX IF NOT EXISTS idx_messages_room_ts " + "ON messages(room, timestamp DESC);"; + const char* create_idx_id = + "CREATE INDEX IF NOT EXISTS idx_messages_room_id " + "ON messages(room, id DESC);"; + + if (exec_simple(create_messages) != SQLITE_OK) return -1; + if (exec_simple(create_idx_ts) != SQLITE_OK) return -1; + if (exec_simple(create_idx_id) != SQLITE_OK) return -1; + + return 0; +} + +void db_close(void) { + if (db) { + sqlite3_close(db); + db = NULL; + } +} + +int db_insert_message(const MsgData* msg, MsgID* out_id) { + if (!db || !msg) return -1; + + const char* sql = + "INSERT INTO messages " + "(room, author_id, author_name, parent, content, timestamp) " + "VALUES (?1, ?2, ?3, ?4, ?5, ?6);"; + + sqlite3_stmt* stmt = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) { + lwsl_err("prepare insert failed: %s\n", sqlite3_errmsg(db)); + return -1; + } + + sqlite3_bind_text(stmt, 1, msg->room, -1, SQLITE_STATIC); + sqlite3_bind_int64(stmt, 2, (sqlite3_int64)msg->author.id); + sqlite3_bind_text( + stmt, 3, (msg->author.name ? *msg->author.name : msg->author_name), -1, + SQLITE_STATIC + ); + if (msg->parent == UINT64_MAX) { + sqlite3_bind_null(stmt, 4); + } else { + sqlite3_bind_int64(stmt, 4, (sqlite3_int64)msg->parent); + } + sqlite3_bind_text(stmt, 5, msg->content, -1, SQLITE_STATIC); + sqlite3_bind_int64(stmt, 6, (sqlite3_int64)msg->timestamp); + + int rc = sqlite3_step(stmt); + if (rc != SQLITE_DONE) { + lwsl_err("insert failed: %s\n", sqlite3_errmsg(db)); + sqlite3_finalize(stmt); + return -1; + } + + sqlite3_finalize(stmt); + + if (out_id) { *out_id = (MsgID)sqlite3_last_insert_rowid(db); } + return 0; +} + +int db_fetch_messages( + const char* room, MsgID before_id, size_t limit, MsgData*** out_msgs, + size_t* out_count +) { + if (!db || !room || !out_msgs || !out_count) return -1; + + const char* sql = "SELECT id, room, author_id, author_name, " + " parent, content, timestamp " + "FROM messages " + "WHERE room = ?1 " + " AND deleted_at IS NULL " + " AND (?2 = 0 OR id < ?2) " + "ORDER BY id DESC " + "LIMIT ?3;"; + + sqlite3_stmt* stmt = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) { + lwsl_err("prepare select failed: %s\n", sqlite3_errmsg(db)); + return -1; + } + + sqlite3_bind_text(stmt, 1, room, -1, SQLITE_STATIC); + sqlite3_bind_int64(stmt, 2, (sqlite3_int64)before_id); + sqlite3_bind_int64(stmt, 3, (sqlite3_int64)limit); + + size_t cap = limit; + MsgData** rows = calloc(cap ? cap : 1, sizeof(MsgData*)); + size_t count = 0; + + while (sqlite3_step(stmt) == SQLITE_ROW) { + MsgData* m = calloc(1, sizeof(MsgData)); + m->id = (MsgID)sqlite3_column_int64(stmt, 0); + const unsigned char* r = sqlite3_column_text(stmt, 1); + if (r) { + strncpy(m->room, (const char*)r, ROOM_MAX_LENGTH - 1); + m->room[ROOM_MAX_LENGTH - 1] = '\0'; + } + m->author.id = (UserID)sqlite3_column_int64(stmt, 2); + const unsigned char* an = sqlite3_column_text(stmt, 3); + if (an) { + strncpy(m->author_name, (const char*)an, NAME_MAX_LENGTH - 1); + m->author_name[NAME_MAX_LENGTH - 1] = '\0'; + m->author.name = &m->author_name; + } + if (sqlite3_column_type(stmt, 4) == SQLITE_NULL) { + m->parent = UINT64_MAX; + } else { + m->parent = (MsgID)sqlite3_column_int64(stmt, 4); + } + const unsigned char* c = sqlite3_column_text(stmt, 5); + if (c) { + strncpy(m->content, (const char*)c, MSG_MAX_LENGTH - 1); + m->content[MSG_MAX_LENGTH - 1] = '\0'; + } + m->timestamp = (time_t)sqlite3_column_int64(stmt, 6); + + rows[count++] = m; + if (count == cap) break; + } + + sqlite3_finalize(stmt); + *out_msgs = rows; + *out_count = count; + return 0; +} diff --git a/server/src/include/api.h b/server/src/include/api.h index 7df5415..4c80f7e 100644 --- a/server/src/include/api.h +++ b/server/src/include/api.h @@ -2,6 +2,7 @@ #define API__H #include "data.h" +#include typedef enum { PACKET_TYPE_JOIN, // C -> S. @@ -15,6 +16,8 @@ typedef enum { PACKET_TYPE_PONG, // C -> S. Liveness pong. PACKET_TYPE_MSG_ACK, // S -> C. Acknowledge message submission. PACKET_TYPE_NAME_ACK, // S -> C. Acknowledge name change. + PACKET_TYPE_HISTORY_REQ, // C -> S. Request older history. + PACKET_TYPE_HISTORY_RES, // S -> C. Return history batch. PACKET_TYPE_LEAVE_EVT, // S -> A. Broadcast when a user leaves. PACKET_TYPE_MAX = PACKET_TYPE_LEAVE_EVT, PACKET_TYPE_BAD, @@ -80,4 +83,18 @@ typedef struct { Name name; // The name that was applied (empty on failure). } PacketNameAck; +typedef struct { + Room room; + MsgID before; + size_t limit; +} PacketHistoryReq; + +typedef struct { + Room room; + size_t historyc; + MsgData** history; + bool has_more; + MsgID oldest_id; +} PacketHistoryRes; + #endif diff --git a/server/src/include/chat.h b/server/src/include/chat.h index b61ca4f..7c56f32 100644 --- a/server/src/include/chat.h +++ b/server/src/include/chat.h @@ -7,19 +7,7 @@ #include #include -// Message history (ring). -#define CHAT_HISTORY_SZ 128 -extern MsgData* chat_history[CHAT_HISTORY_SZ]; -extern size_t chat_history_head; // Next insertion index. -extern size_t chat_history_count; // Number of valid history entries. - -// Add message to history ring. -MsgData* chat_history_msg_add(MsgData* msg); - -// Get a list of messages in order. -MsgData** chat_history_nice(void); - -/** +/** * cb_chat - libwebsockets protocol callback for COMS chat. * * This function is registered in the protocols array passed to the diff --git a/server/src/include/data.h b/server/src/include/data.h index 7d6cfe4..065ce5a 100644 --- a/server/src/include/data.h +++ b/server/src/include/data.h @@ -6,6 +6,7 @@ #define NAME_MAX_LENGTH 16 #define MSG_MAX_LENGTH 1024 +#define ROOM_MAX_LENGTH 32 typedef uint64_t UserID; typedef uint64_t MsgID; @@ -16,6 +17,9 @@ int name_verify(const char* name); typedef char MsgContent[MSG_MAX_LENGTH]; int msg_verify(const char* content); +typedef char Room[ROOM_MAX_LENGTH]; +int room_verify(const char* room); + typedef struct { UserID id; Name* name; @@ -24,6 +28,8 @@ typedef struct { typedef struct { MsgID id; UserData author; + Name author_name; // backing storage when author.name isn't set + Room room; MsgID parent; MsgContent content; time_t timestamp; diff --git a/server/src/include/db.h b/server/src/include/db.h new file mode 100644 index 0000000..670ffdd --- /dev/null +++ b/server/src/include/db.h @@ -0,0 +1,25 @@ +#ifndef DB__H +#define DB__H + +#include "data.h" +#include +#include + +// Initialize the SQLite database at the given path. Creates directories/tables. +int db_init(const char* path); + +// Close the global DB handle. +void db_close(void); + +// Insert a message; on success sets *out_id to the assigned rowid. +int db_insert_message(const MsgData* msg, MsgID* out_id); + +// Fetch newest-first messages for a room, optionally before a given id. +// Returns an allocated array of MsgData* in *out_msgs (caller must free each +// MsgData* and the array). Count in *out_count. If before_id==0, fetch latest. +int db_fetch_messages( + const char* room, MsgID before_id, size_t limit, MsgData*** out_msgs, + size_t* out_count +); + +#endif diff --git a/server/src/main.c b/server/src/main.c index 282b60b..ef95478 100644 --- a/server/src/main.c +++ b/server/src/main.c @@ -1,4 +1,5 @@ #include "include/chat.h" +#include "include/db.h" #include "include/session.h" #include @@ -29,6 +30,11 @@ static struct lws_protocols protocols[] = { int main(void) { signal(SIGINT, handle_sigint); + if (db_init("var/data/chat.sqlite") != 0) { + fprintf(stderr, "Failed to initialize database.\n"); + return EXIT_FAILURE; + } + // Create libws context. struct lws_context_creation_info info; memset(&info, 0, sizeof(info)); @@ -49,6 +55,7 @@ int main(void) { // Cleanse. lws_context_destroy(context); + db_close(); printf("Server shutting down.\n"); return EXIT_SUCCESS; }