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;
}