start working on friends system

This commit is contained in:
2026-07-22 17:15:05 +10:00
parent e99d312937
commit abd51e347c
12 changed files with 26028 additions and 8 deletions

View File

@@ -8,6 +8,11 @@ bin2cpp --file=client/header_bottom.html --output=server/src/generated
bin2cpp --file=client/footer.html --output=server/src/generated bin2cpp --file=client/footer.html --output=server/src/generated
bin2cpp --file=client/login.html --output=server/src/generated bin2cpp --file=client/login.html --output=server/src/generated
bin2cpp --file=client/settings.html --output=server/src/generated bin2cpp --file=client/settings.html --output=server/src/generated
bin2cpp --file=client/friends.html --output=server/src/generated
bin2cpp --file=client/e404.html --output=server/src/generated bin2cpp --file=client/e404.html --output=server/src/generated
bin2cpp --file=client/style.css --output=server/src/generated bin2cpp --file=client/style.css --output=server/src/generated
bin2cpp --file=client/script.js --output=server/src/generated bin2cpp --file=client/script.js --output=server/src/generated
bin2cpp --file=client/script_friends.js --output=server/src/generated
bin2cpp --file=client/notify.opus --output=server/src/generated

42
client/friends.html Normal file
View File

@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html>
<head>
<title>friends</title>
<link rel="stylesheet" type="text/css" href="/style.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="/script.js"></script>
<script src="/script_friends.js"></script>
</head>
<body>
<nav>
<div id="brand">
<h2><a href="/">chookchat</a></h2>
</div>
<ul>
<li><a id="loginLink" href="/login">login</a></li>
<li><a href="/friends">friends</a></li>
<li><a href="/settings">settings</a></li>
</ul>
</nav>
<div id="friends">
<div id="friends-list">
<div id="friends-header">
<h1>friends</h1>
</div>
<div id="friends-list-links">
<!-- List of friends inserted here -->
</div>
</div>
<div id="friends-messenger">
<div id="friends-messages">
<!-- List of messages inserted here -->
</div>
<div id="friends-message-box">
<textarea id="friends-textarea"></textarea>
<button onclick="sendMessage()">Send</button>
</div>
</div>
</div>
</body>
</html>

View File

@@ -7,6 +7,7 @@
<ul> <ul>
<li><a id="loginLink" href="/login">login</a></li> <li><a id="loginLink" href="/login">login</a></li>
<li><a href="/friends">friends</a></li>
<li><a href="/settings">settings</a></li> <li><a href="/settings">settings</a></li>
</ul> </ul>
</nav> </nav>

View File

@@ -14,6 +14,7 @@
<ul> <ul>
<li><a id="loginLink" href="/login">login</a></li> <li><a id="loginLink" href="/login">login</a></li>
<li><a href="/friends">friends</a></li>
<li><a href="/settings">settings</a></li> <li><a href="/settings">settings</a></li>
</ul> </ul>
</nav> </nav>

BIN
client/notify.opus Normal file

Binary file not shown.

View File

@@ -104,7 +104,7 @@ document.addEventListener('DOMContentLoaded', async function() {
// update nav link / redirect regardless of whether a postbox exists // update nav link / redirect regardless of whether a postbox exists
const loginLink = document.getElementById("loginLink"); const loginLink = document.getElementById("loginLink");
if (userStatus.username === "") { if (userStatus.username === "") {
if (window.location.pathname === "/settings") { if (window.location.pathname === "/settings" || window.location.pathname === "/friends") {
window.location.href = "/login"; window.location.href = "/login";
return; return;
} }

240
client/script_friends.js Normal file
View File

@@ -0,0 +1,240 @@
class Message {
sender = 0;
content = "";
constructor(sender, content) {
this.sender = sender;
this.content = content;
}
}
// websocket connection
/** @type {WebSocket} */
let socket;
// array of userId's
/** @type {Array<number>} */
let connectedFriends = [];
// map of userId's to arrays of Message objects
/** @type {Map<number, Array<Message>>} */
let recievedMessages = new Map();
// map of userId's to usernames
/** @type {Map<number, string>} */
let usernames = new Map();
// userId of the current user who is being talked to
let currentUser = 0;
// userId of the logged-in user (this client). Fetched from /me on load.
// /me responds with headers X-Logged-In, X-Username, X-Bio, X-User-ID
// (no body), so we read the id back out of the response headers.
let selfId = 0;
// notification sound, loaded after ws initialization
let sound;
async function fetchSelfId() {
try {
const res = await fetch("/me");
if (!res.ok) {
console.error("Failed to fetch /me:", res.status);
return;
}
const loggedIn = res.headers.get("X-Logged-In");
if (loggedIn !== "true") {
console.log("Not logged in");
return;
}
const idHeader = res.headers.get("X-User-ID");
selfId = parseInt(idHeader, 10);
} catch (err) {
console.error("Error fetching /me:", err);
}
}
/**
* @param {number} id
* @returns {string}
*/
async function fetchUsername(id) {
try {
const res = await fetch("/userinfo/" + id);
if (!res.ok) {
console.error("Failed to fetch /userinfo/" + id + " :", res.status);
return;
}
const usernameHeader = res.headers.get("X-Username");
usernames.set(id, usernameHeader);
return usernameHeader;
} catch (err) {
console.error("Error fetching /me:", err);
}
}
document.addEventListener('DOMContentLoaded', function() {
fetchSelfId();
if (typeof Notification !== "undefined") {
Notification.requestPermission();
}
// create a websocket
socket = new WebSocket("ws://" + document.location.hostname + ":" + document.location.port + "/friends/ws");
socket.onopen = function() {
console.log("connected to server!");
setInterval(() => { // ping the server every 5 seconds
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 3, userId: 0 }));
}
}, 5000);
};
socket.onmessage = function(event) {
console.log("recieved data", event.data);
const data = JSON.parse(event.data);
switch (data.type) {
case "ok": {
console.log("Server is ok");
break;
}
case "error": {
console.error("Server sent an error:", data.content);
break;
}
case "message": {
console.log("message from", data.userId, ":", data.content);
// update the messages variable
if (recievedMessages.get(data.userId) === undefined) {
recievedMessages.set(data.userId, []);
}
recievedMessages.get(data.userId).push(new Message(data.userId, data.content));
if (currentUser === data.userId) {
updateMessages();
}
// send a notification to the user
if (document.hidden || currentUser != data.userId) {
if (Notification !== undefined) {
new Notification(usernames.get(data.userId), {body: data.content});
}
if (audio !== undefined) {
audio.play();
}
}
break;
}
case "users": {
console.log("updating user list");
connectedFriends = data.content;
updateOnlineUsers();
break;
}
}
}
socket.onerror = function(error) {
console.log(error);
alert("There was an error with the socket! Check console logs for details. Refresh the page to reconnect");
socket.close();
}
socket.onclose = function(event) {
console.log("disconnected from server");
}
audio = new Audio();
audio.preload = "auto";
audio.src = "/notify.opus";
document.body.appendChild(audio);
});
function updateOnlineUsers() {
const friendsList = document.getElementById("friends-list-links");
friendsList.innerHTML = "";
// Get everyone's username
for (const user of connectedFriends) {
if (usernames.get(user) === undefined) {
fetchUsername(user);
}
}
for (const user of connectedFriends) {
const newElement = document.createElement("p");
newElement.textContent = usernames.get(user);
newElement.onclick = function() {
openUser(user);
}
friendsList.appendChild(newElement);
}
}
/** @param {Message} message */
function renderMessage(message) {
const messagesList = document.getElementById("friends-messages");
const newElement = document.createElement("p");
const who = message.sender === selfId ? "You" : usernames.get(message.sender);
newElement.textContent = who + ": " + message.content;
messagesList.appendChild(newElement);
messagesList.scrollTop = messagesList.scrollHeight;
}
function updateMessages() {
const messages = recievedMessages.get(currentUser);
if (messages === undefined || messages.length === 0) {
return;
}
const message = messages[messages.length - 1];
renderMessage(message);
}
/** @param {number} user */
function openUser(user) {
currentUser = user;
const messagesList = document.getElementById("friends-messages");
messagesList.innerHTML = "";
const messages = recievedMessages.get(user);
if (messages === undefined) {
return;
}
for (const message of messages) {
renderMessage(message);
}
}
function sendMessage() {
if (currentUser === 0) {
alert("Select a friend to message first");
return;
}
const textarea = document.getElementById("friends-textarea");
const content = textarea.value.trim();
if (content === "") {
return;
}
if (socket.readyState !== WebSocket.OPEN) {
alert("Not connected to the server. Refresh the page and try again.");
return;
}
socket.send(JSON.stringify({ type: 0, userId: currentUser, content: content }));
// optimistically show the message locally, since the server only
// relays messages between the two other parties, not back to the sender
if (recievedMessages.get(currentUser) === undefined) {
recievedMessages.set(currentUser, []);
}
const sentMessage = new Message(selfId, content);
recievedMessages.get(currentUser).push(sentMessage);
renderMessage(sentMessage);
textarea.value = "";
}

View File

@@ -1,7 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>posts</title> <title>settings</title>
<link rel="stylesheet" type="text/css" href="/style.css"> <link rel="stylesheet" type="text/css" href="/style.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="/script.js"></script> <script src="/script.js"></script>
@@ -14,6 +14,7 @@
<ul> <ul>
<li><a id="loginLink" href="/login">login</a></li> <li><a id="loginLink" href="/login">login</a></li>
<li><a href="/friends">friends</a></li>
<li><a href="/settings">settings</a></li> <li><a href="/settings">settings</a></li>
</ul> </ul>
</nav> </nav>

View File

@@ -103,3 +103,32 @@ nav {
font-size: 16px; font-size: 16px;
} }
} }
/* Friends-specific CSS */
#friends {
display: flex;
padding-left: 16px;
flex-wrap: wrap;
gap: 1rem;
overflow: hidden;
}
#friends-list {
flex: 1;
min-width: 300px;
height: 100%;
overflow-y: auto;
}
#friends-messenger {
flex: 1;
min-width: 300px;
}
#friends-message-box {
position: fixed;
bottom: 0;
right: 0;
width: 50%;
}

View File

@@ -10,8 +10,11 @@ sources = [
'src/generated/footer.cpp', 'src/generated/footer.cpp',
'src/generated/login.cpp', 'src/generated/login.cpp',
'src/generated/settings.cpp', 'src/generated/settings.cpp',
'src/generated/friends.cpp',
'src/generated/script_friends.cpp',
'src/generated/style.cpp', 'src/generated/style.cpp',
'src/generated/script.cpp', 'src/generated/script.cpp',
'src/generated/notify.cpp',
# bcrypt # bcrypt
'src/bcrypt/bcrypt.cpp', 'src/bcrypt/bcrypt.cpp',

25526
server/src/json/json.hpp Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,11 +3,17 @@
#include <sstream> #include <sstream>
#include <stdexcept> #include <stdexcept>
#include <string> #include <string>
#include <unordered_map>
#include <vector> #include <vector>
#include "httplib/httplib.h" #include "httplib/httplib.h"
#include "bcrypt/bcrypt.h" #include "bcrypt/bcrypt.h"
#define JSON_USE_IMPLICIT_CONVERSIONS 0
#include "json/json.hpp"
using Json = nlohmann::json;
#include "db.h" #include "db.h"
#include "post.h" #include "post.h"
@@ -17,8 +23,11 @@
#include "generated/footer.h" #include "generated/footer.h"
#include "generated/login.h" #include "generated/login.h"
#include "generated/settings.h" #include "generated/settings.h"
#include "generated/friends.h"
#include "generated/script_friends.h"
#include "generated/style.h" #include "generated/style.h"
#include "generated/script.h" #include "generated/script.h"
#include "generated/notify.h"
std::optional<User> getLoggedInUser(const httplib::Request& request, Database& database) { std::optional<User> getLoggedInUser(const httplib::Request& request, Database& database) {
if (!request.has_header("Cookie")) { if (!request.has_header("Cookie")) {
@@ -46,23 +55,31 @@ int main() {
const bin2cpp::File& footerfile = bin2cpp::getFooterHtmlFile(); const bin2cpp::File& footerfile = bin2cpp::getFooterHtmlFile();
const bin2cpp::File& loginfile = bin2cpp::getLoginHtmlFile(); const bin2cpp::File& loginfile = bin2cpp::getLoginHtmlFile();
const bin2cpp::File& settingsfile = bin2cpp::getSettingsHtmlFile(); const bin2cpp::File& settingsfile = bin2cpp::getSettingsHtmlFile();
const bin2cpp::File& friendsfile = bin2cpp::getFriendsHtmlFile();
const bin2cpp::File& friendsjsfile = bin2cpp::getScript_friendsJsFile();
const bin2cpp::File& e404file = bin2cpp::getE404HtmlFile(); const bin2cpp::File& e404file = bin2cpp::getE404HtmlFile();
const bin2cpp::File& stylefile = bin2cpp::getStyleCssFile(); const bin2cpp::File& stylefile = bin2cpp::getStyleCssFile();
const bin2cpp::File& scriptfile = bin2cpp::getScriptJsFile(); const bin2cpp::File& scriptfile = bin2cpp::getScriptJsFile();
const bin2cpp::File& notifyfile = bin2cpp::getNotifyOpusFile();
std::string headerTop{headerTopFile.getBuffer(), headerTopFile.getSize()}; std::string headerTop{headerTopFile.getBuffer(), headerTopFile.getSize()};
std::string headerBottom{headerBottomFile.getBuffer(), headerBottomFile.getSize()}; std::string headerBottom{headerBottomFile.getBuffer(), headerBottomFile.getSize()};
std::string footer{footerfile.getBuffer(), footerfile.getSize()}; std::string footer{footerfile.getBuffer(), footerfile.getSize()};
std::string login{loginfile.getBuffer(), loginfile.getSize()}; std::string login{loginfile.getBuffer(), loginfile.getSize()};
std::string settings{settingsfile.getBuffer(), settingsfile.getSize()}; std::string settings{settingsfile.getBuffer(), settingsfile.getSize()};
std::string friends{friendsfile.getBuffer(), friendsfile.getSize()};
std::string friendsjs{friendsjsfile.getBuffer(), friendsjsfile.getSize()};
std::string e404{e404file.getBuffer(), e404file.getSize()}; std::string e404{e404file.getBuffer(), e404file.getSize()};
std::string style{stylefile.getBuffer(), stylefile.getSize()}; std::string style{stylefile.getBuffer(), stylefile.getSize()};
std::string script{scriptfile.getBuffer(), scriptfile.getSize()}; std::string script{scriptfile.getBuffer(), scriptfile.getSize()};
std::string notify{notifyfile.getBuffer(), notifyfile.getSize()};
Database database{"chookchat.db"}; Database database{"chookchat.db"};
std::mutex data_mutex; std::mutex data_mutex;
std::unordered_map<uint64_t, httplib::ws::WebSocket*> connectedFriends;
httplib::Server svr; httplib::Server svr;
svr.Get("/style.css", [&style](const httplib::Request& request, httplib::Response& response) { svr.Get("/style.css", [&style](const httplib::Request& request, httplib::Response& response) {
@@ -73,11 +90,15 @@ int main() {
response.set_content(script, "text/javascript"); response.set_content(script, "text/javascript");
}); });
svr.Get("/login", [&login](const httplib::Request& request, httplib::Response& response) { svr.Get("/script_friends.js", [&friendsjs](const httplib::Request& request, httplib::Response& response) {
response.set_content(login, "text/html"); response.set_content(friendsjs, "text/javascript");
}); });
svr.Get("/login.html", [&login](const httplib::Request& request, httplib::Response& response) { svr.Get("/notify.opus", [&notify](const httplib::Request& request, httplib::Response& response) {
response.set_content(notify, "audio/ogg; codecs=opus");
});
svr.Get("/login", [&login](const httplib::Request& request, httplib::Response& response) {
response.set_content(login, "text/html"); response.set_content(login, "text/html");
}); });
@@ -85,8 +106,8 @@ int main() {
response.set_content(settings, "text/html"); response.set_content(settings, "text/html");
}); });
svr.Get("/settings.html", [&settings](const httplib::Request& request, httplib::Response& response) { svr.Get("/friends", [&friends](const httplib::Request& request, httplib::Response& response) {
response.set_content(settings, "text/html"); response.set_content(friends, "text/html");
}); });
svr.Post("/login", [&database, &data_mutex](const httplib::Request& request, httplib::Response& response) { svr.Post("/login", [&database, &data_mutex](const httplib::Request& request, httplib::Response& response) {
@@ -363,6 +384,7 @@ int main() {
response.set_header("X-Logged-In", "true"); response.set_header("X-Logged-In", "true");
response.set_header("X-Username", user->name); response.set_header("X-Username", user->name);
response.set_header("X-Bio", user->bio); response.set_header("X-Bio", user->bio);
response.set_header("X-User-ID", std::to_string(user->id));
} catch (const std::runtime_error& e) { } catch (const std::runtime_error& e) {
response.status = 500; response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html"); response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
@@ -373,6 +395,156 @@ int main() {
}); });
svr.Get("/userinfo/:userid", [&database, &data_mutex](const httplib::Request& request, httplib::Response& response) {
std::lock_guard<std::mutex> lock(data_mutex);
try {
uint64_t userId = std::stoull(request.path_params.at("userid"));
std::optional<User> user = database.getUser(userId);
response.set_header("Cache-Control", "no-store");
if (!user.has_value()) {
response.status = 404;
return;
}
response.set_header("X-Logged-In", "true");
response.set_header("X-Username", user->name);
response.set_header("X-Bio", user->bio);
response.set_header("X-User-ID", std::to_string(user->id));
} catch (const std::runtime_error& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
} catch (const std::exception& e) {
response.status = 500;
response.set_content("<p>there was an error :( it is: " + std::string(e.what()) + "</p>", "text/html");
}
});
// friends endpoints
svr.WebSocket("/friends/ws", [&database, &data_mutex, &connectedFriends](const httplib::Request& request, httplib::ws::WebSocket& ws) {
std::optional<User> user = getLoggedInUser(request, database);
if (!user.has_value()) {
ws.close();
return;
}
// add us to the logged in users
connectedFriends[user->id] = &ws;
{
// send out an update to everyone with the new user list
Json data;
data["type"] = "users";
Json list;
for (const auto& [user, value] : connectedFriends) {
list.push_back(user);
}
data["content"] = list;
std::string datadump = data.dump();
for (const auto& [userid, conn] : connectedFriends) {
conn->send(datadump);
}
}
std::string msg;
while (ws.read(msg)) {
try {
/*
* type:
* 0 (message), 1 (friend request), 2 (change status), 3 (ping),
* 4 (get online friends)
* userId: <int>
* content:
* <string> (message),
* (0 (offline), 1 (online) (change status)),
* nil, (friend request)
*/
Json data = Json::parse(msg);
double type = data["type"].get<double>();
uint64_t userId = static_cast<uint64_t>(data["userId"].get<double>());
if (type == 0) { // message
std::string content = data["content"].get<std::string>();
if (connectedFriends.find(userId) == connectedFriends.end()) {
Json data;
data["type"] = "error";
data["content"] = "friend not online";
ws.send(data.dump());
continue;
}
httplib::ws::WebSocket* friendWs = connectedFriends[userId];
if (friendWs->is_open()) {
Json data;
data["type"] = "message";
data["userId"] = user->id;
data["content"] = content;
friendWs->send(data.dump());
Json returnData;
returnData["type"] = "ok";
ws.send(returnData.dump());
} else {
Json data;
data["type"] = "error";
data["content"] = "friend not online";
ws.send(data.dump());
connectedFriends.erase(userId);
}
// TODO:
// - Store in databse
// - Verify users are friends
} else if (type == 1) { // friend request
Json data;
data["type"] = "error";
data["content"] = "not implemented yet";
ws.send(data.dump());
continue;
} else if (type == 2) {
// blank for now
// no need for a ping anymore
} else if (type == 3) {
Json data;
data["type"] = "ok";
ws.send(data.dump());
} else if (type == 4) {
Json data;
data["type"] = "users";
Json list;
for (const auto& [user, value] : connectedFriends) {
list.push_back(user);
}
data["content"] = list;
ws.send(data.dump());
}
} catch (const std::runtime_error& e) {
Json data;
data["type"] = "error";
data["content"] = e.what();
ws.send(data.dump());
}
}
// clean up the connection
connectedFriends.erase(user->id);
ws.close();
// send out an update to everyone with the new user list
{
Json data;
data["type"] = "users";
Json list;
for (const auto& [user, value] : connectedFriends) {
list.push_back(user);
}
data["content"] = list;
std::string datadump = data.dump();
for (const auto& [userid, conn] : connectedFriends) {
conn->send(datadump);
}
}
});
// settings endpoints // settings endpoints
svr.Post("/settings/changePassword", [&database, &data_mutex](const httplib::Request& request, httplib::Response& response) { svr.Post("/settings/changePassword", [&database, &data_mutex](const httplib::Request& request, httplib::Response& response) {