1
0
forked from iarv/lobbs

Initial commit

This commit is contained in:
Ben Allfree
2025-11-06 23:27:23 -08:00
commit c9112447a3
11 changed files with 1731 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
lobbs.pb.*

129
CommandParser.cpp Normal file
View File

@@ -0,0 +1,129 @@
#include "CommandParser.h"
#include <cctype>
#include <cstdint>
CommandParser::CommandParser(const uint8_t *buffer, size_t size)
: payload(buffer), payloadSize(size), position(0), argsStartPos(0)
{
// Skip the '/' if present and find where arguments start
if (isCommand() && payloadSize > 1) {
// Find the first space after the command name
size_t pos = 1; // Start after '/'
while (pos < payloadSize && payload[pos] != ' ' && payload[pos] != '\0') {
pos++;
}
// Skip the space
while (pos < payloadSize && payload[pos] == ' ') {
pos++;
}
argsStartPos = pos;
position = pos;
}
}
bool CommandParser::isCommand() const
{
return payloadSize > 0 && payload[0] == '/';
}
bool CommandParser::commandName(char *buffer, size_t bufferSize) const
{
if (!isCommand() || bufferSize == 0) {
return false;
}
// Start after '/'
size_t srcPos = 1;
size_t dstPos = 0;
// Copy until space, null terminator, or end of payload
while (srcPos < payloadSize && dstPos < bufferSize - 1) {
char c = payload[srcPos];
if (c == ' ' || c == '\0') {
break;
}
buffer[dstPos++] = c;
srcPos++;
}
buffer[dstPos] = '\0';
return dstPos > 0;
}
bool CommandParser::nextWord(char *buffer, size_t bufferSize)
{
if (bufferSize == 0 || position >= payloadSize) {
return false;
}
// Skip leading spaces
while (position < payloadSize && payload[position] == ' ') {
position++;
}
if (position >= payloadSize) {
return false;
}
// Copy the word
size_t dstPos = 0;
while (position < payloadSize && dstPos < bufferSize - 1) {
char c = payload[position];
if (c == ' ' || c == '\0') {
break;
}
buffer[dstPos++] = c;
position++;
}
buffer[dstPos] = '\0';
return dstPos > 0;
}
bool CommandParser::rest(char *buffer, size_t bufferSize) const
{
if (bufferSize == 0 || position >= payloadSize) {
buffer[0] = '\0';
return false;
}
// Skip leading spaces
size_t srcPos = position;
while (srcPos < payloadSize && payload[srcPos] == ' ') {
srcPos++;
}
if (srcPos >= payloadSize) {
buffer[0] = '\0';
return false;
}
// Copy the rest
size_t dstPos = 0;
while (srcPos < payloadSize && dstPos < bufferSize - 1) {
char c = payload[srcPos];
if (c == '\0') {
break;
}
buffer[dstPos++] = c;
srcPos++;
}
buffer[dstPos] = '\0';
return dstPos > 0;
}
void CommandParser::reset()
{
position = argsStartPos;
}
bool CommandParser::hasMore() const
{
// Check if there's non-space content remaining
size_t pos = position;
while (pos < payloadSize && payload[pos] == ' ') {
pos++;
}
return pos < payloadSize && payload[pos] != '\0';
}

70
CommandParser.h Normal file
View File

@@ -0,0 +1,70 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <cstring>
/**
* CommandParser - Parse text commands from mesh packets
*
* Commands start with '/' followed by command name, then optional arguments.
* Example: "/hi alice mypassword"
*/
class CommandParser
{
public:
/**
* Initialize parser with a payload buffer
* @param buffer Pointer to the payload bytes
* @param size Size of the payload
*/
CommandParser(const uint8_t *buffer, size_t size);
/**
* Check if this is a command (starts with '/')
* @return true if payload starts with '/'
*/
bool isCommand() const;
/**
* Get the command name (everything after '/' until first space or end)
* @param buffer Buffer to store command name (null-terminated)
* @param bufferSize Size of the buffer
* @return true if command name extracted successfully
*/
bool commandName(char *buffer, size_t bufferSize) const;
/**
* Get the next word from the current position
* Advances internal position pointer
* @param buffer Buffer to store the word (null-terminated)
* @param bufferSize Size of the buffer
* @return true if word extracted successfully, false if no more words
*/
bool nextWord(char *buffer, size_t bufferSize);
/**
* Get the rest of the string from current position
* @param buffer Buffer to store the rest (null-terminated)
* @param bufferSize Size of the buffer
* @return true if there's content remaining
*/
bool rest(char *buffer, size_t bufferSize) const;
/**
* Reset the parser position to start of arguments (after command name)
*/
void reset();
/**
* Check if there are more arguments to parse
* @return true if more content available
*/
bool hasMore() const;
private:
const uint8_t *payload;
size_t payloadSize;
size_t position; // Current parsing position
size_t argsStartPos; // Position where arguments start (after command name)
};

22
LICENSE Normal file
View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2025 MeshEnvy
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

408
LoBBSDal.cpp Normal file
View File

@@ -0,0 +1,408 @@
#include "LoBBSDal.h"
#include "configuration.h"
#include "gps/RTC.h"
#include <SHA256.h>
#include <algorithm>
#include <cctype>
#include <cstring>
// Helper: normalize username to lowercase for case-insensitive lookups
static void normalizeUsername(const char *username, char *normalized)
{
size_t len = strlen(username);
if (len > LOBBS_MAX_USERNAME_LEN)
len = LOBBS_MAX_USERNAME_LEN;
for (size_t i = 0; i < len; i++) {
normalized[i] = tolower(username[i]);
}
normalized[len] = '\0';
}
static void buildNewsReadKey(uint64_t newsUuid, uint64_t userUuid, char *out, size_t outSize)
{
char newsHex[17];
char userHex[17];
lodb_uuid_to_hex(newsUuid, newsHex);
lodb_uuid_to_hex(userUuid, userHex);
snprintf(out, outSize, "%s:%s", newsHex, userHex);
}
static lodb_uuid_t usernameToUuid(const char *username, uint32_t hostNodeId)
{
char normalized[LOBBS_USERNAME_BUFFER_SIZE];
normalizeUsername(username, normalized);
return lodb_new_uuid(normalized, hostNodeId);
}
LoBBSDal::LoBBSDal(uint32_t hostNodeId) : hostNodeId(hostNodeId)
{
// Initialize LoDB database
db = new LoDb("lobbs");
db->registerTable("users", &meshtastic_LoBBSUser_msg, sizeof(meshtastic_LoBBSUser));
db->registerTable("sessions", &meshtastic_LoBBSSession_msg, sizeof(meshtastic_LoBBSSession));
db->registerTable("mail", &meshtastic_LoBBSMail_msg, sizeof(meshtastic_LoBBSMail));
db->registerTable("news", &meshtastic_LoBBSNews_msg, sizeof(meshtastic_LoBBSNews));
db->registerTable("news_reads", &meshtastic_LoBBSNewsRead_msg, sizeof(meshtastic_LoBBSNewsRead));
}
LoBBSDal::~LoBBSDal()
{
delete db;
}
bool LoBBSDal::isValidUsername(const char *username)
{
// Must start with a letter
if (!isalpha(username[0])) {
return false;
}
// Check rest of characters: alphanumeric or underscore only
for (size_t i = 1; username[i] != '\0'; i++) {
if (!isalnum(username[i]) && username[i] != '_') {
return false;
}
}
return true;
}
/**
* Validate a password. Must be between 5 and 50 characters long and contain only letters, numbers, underscores, and common
* special characters.
* @param password The password to validate
* @return True if the password is valid, false otherwise
*/
bool LoBBSDal::isValidPassword(const char *password)
{
// Check each character: alphanumeric, underscore, or common special chars
for (size_t i = 0; password[i] != '\0'; i++) {
char c = password[i];
if (!isalnum(c) && c != '_' && c != '-' && c != '.' && c != '!' && c != '@' && c != '#' && c != '$' && c != '%') {
return false;
}
}
return true;
}
void LoBBSDal::hashPassword(const char *password, uint8_t *hash)
{
SHA256 sha256;
sha256.reset();
sha256.update(password, strlen(password));
sha256.finalize(hash, 32);
}
bool LoBBSDal::loadUserByUsername(const char *username, meshtastic_LoBBSUser *user)
{
// Convert username to UUID with host node ID as salt
lodb_uuid_t userUuid = usernameToUuid(username, hostNodeId);
LoDbError err = db->get("users", userUuid, user);
if (err == LODB_OK) {
LOG_DEBUG("Loaded user by username: %s", username);
return true;
}
LOG_DEBUG("User not found: %s", username);
return false;
}
bool LoBBSDal::loadUserByNodeId(uint32_t nodeId, meshtastic_LoBBSUser *user)
{
// First, lookup session by node ID (use node ID directly as UUID)
lodb_uuid_t sessionUuid = (lodb_uuid_t)nodeId;
LOG_DEBUG("sessionUuid: " LODB_UUID_FMT, LODB_UUID_ARGS(sessionUuid));
meshtastic_LoBBSSession session = meshtastic_LoBBSSession_init_zero;
LoDbError err = db->get("sessions", sessionUuid, &session);
if (err != LODB_OK) {
LOG_DEBUG("No session found for node 0x%08x", nodeId);
return false;
} else {
LOG_DEBUG("Session found for node 0x%08x", nodeId);
LOG_DEBUG("Session user UUID: " LODB_UUID_FMT, LODB_UUID_ARGS(session.user_uuid));
LOG_DEBUG("Session last login time: %d", session.last_login_time);
LOG_DEBUG("Session node ID: %08x", nodeId);
}
// Now load the user by UUID from session
err = db->get("users", session.user_uuid, user);
if (err == LODB_OK) {
LOG_DEBUG("Loaded user by node ID: 0x%08x -> UUID: " LODB_UUID_FMT, nodeId, LODB_UUID_ARGS(session.user_uuid));
return true;
}
LOG_DEBUG("User UUID not found: " LODB_UUID_FMT, LODB_UUID_ARGS(session.user_uuid));
return false;
}
bool LoBBSDal::createUser(const char *username, const char *password, uint32_t nodeId)
{
// Calculate UUID first (username with host node ID as salt)
lodb_uuid_t userUuid = usernameToUuid(username, hostNodeId);
// Create user record
meshtastic_LoBBSUser user = meshtastic_LoBBSUser_init_zero;
strncpy(user.username, username, sizeof(user.username) - 1);
user.uuid = userUuid;
user.password_hash.size = 32;
hashPassword(password, user.password_hash.bytes);
LoDbError err = db->insert("users", userUuid, &user);
if (err != LODB_OK) {
LOG_ERROR("Failed to create user: %s", username);
return false;
}
LOG_INFO("Created user: %s", username);
// Log in the user (create session)
return loginUser(username, nodeId);
}
bool LoBBSDal::verifyPassword(const meshtastic_LoBBSUser *user, const char *password)
{
uint8_t providedHash[32];
hashPassword(password, providedHash);
return memcmp(user->password_hash.bytes, providedHash, 32) == 0;
}
bool LoBBSDal::loginUser(const char *username, uint32_t nodeId)
{
// Create session record
meshtastic_LoBBSSession session = meshtastic_LoBBSSession_init_zero;
session.user_uuid = usernameToUuid(username, hostNodeId);
session.node_id = nodeId;
session.last_login_time = getTime();
// Use node ID directly as UUID
lodb_uuid_t sessionUuid = (lodb_uuid_t)nodeId;
// Delete existing session if any (upsert pattern)
db->deleteRecord("sessions", sessionUuid);
// Insert new session
LoDbError err = db->insert("sessions", sessionUuid, &session);
if (err != LODB_OK) {
LOG_ERROR("Failed to create session for node 0x%08x", nodeId);
return false;
}
LOG_INFO("Created session for user %s (UUID: " LODB_UUID_FMT ") on node 0x%08x", username, LODB_UUID_ARGS(session.user_uuid),
nodeId);
return true;
}
bool LoBBSDal::logoutUser(uint32_t nodeId)
{
// Use node ID directly as UUID
lodb_uuid_t sessionUuid = (lodb_uuid_t)nodeId;
LoDbError err = db->deleteRecord("sessions", sessionUuid);
if (err == LODB_OK) {
LOG_INFO("Logged out node 0x%08x", nodeId);
return true;
}
LOG_WARN("No session found to log out for node 0x%08x", nodeId);
return false;
}
uint64_t LoBBSDal::getUserUuidByUsername(const char *username)
{
// Convert username to UUID with host node ID as salt
uint64_t userUuid = usernameToUuid(username, hostNodeId);
// Verify user exists
meshtastic_LoBBSUser user = meshtastic_LoBBSUser_init_zero;
LoDbError err = db->get("users", userUuid, &user);
if (err == LODB_OK) {
LOG_DEBUG("Found user UUID for %s: " LODB_UUID_FMT, username, LODB_UUID_ARGS(userUuid));
return userUuid;
}
LOG_DEBUG("User not found: %s", username);
return 0;
}
bool LoBBSDal::sendMail(uint64_t fromUserUuid, uint64_t toUserUuid, const char *message)
{
// Generate a unique UUID for the mail message (using timestamp and recipient UUID)
lodb_uuid_t mailUuid = lodb_new_uuid((const char *)&toUserUuid, getTime());
// Create mail record
meshtastic_LoBBSMail mail = meshtastic_LoBBSMail_init_zero;
mail.uuid = mailUuid;
mail.from_user_uuid = fromUserUuid;
mail.to_user_uuid = toUserUuid;
strncpy(mail.message, message, sizeof(mail.message) - 1);
mail.message[sizeof(mail.message) - 1] = '\0';
mail.timestamp = getTime();
mail.read = false;
LoDbError err = db->insert("mail", mailUuid, &mail);
if (err != LODB_OK) {
LOG_ERROR("Failed to send mail from " LODB_UUID_FMT " to " LODB_UUID_FMT, LODB_UUID_ARGS(fromUserUuid),
LODB_UUID_ARGS(toUserUuid));
return false;
}
LOG_INFO("Sent mail from " LODB_UUID_FMT " to " LODB_UUID_FMT, LODB_UUID_ARGS(fromUserUuid), LODB_UUID_ARGS(toUserUuid));
return true;
}
// Comparator for sorting mail by timestamp descending (newest first)
static int compareMailByTimestamp(const void *a, const void *b)
{
const meshtastic_LoBBSMail *m1 = (const meshtastic_LoBBSMail *)a;
const meshtastic_LoBBSMail *m2 = (const meshtastic_LoBBSMail *)b;
// Reverse order: newer (larger timestamp) first
if (m2->timestamp > m1->timestamp)
return 1;
if (m2->timestamp < m1->timestamp)
return -1;
return 0;
}
std::vector<void *> LoBBSDal::getMailForUser(uint64_t userUuid, uint32_t offset, uint32_t limit)
{
// Build filter lambda for mail matching recipient
auto mail_filter = [userUuid](const void *rec) -> bool {
const meshtastic_LoBBSMail *m = (const meshtastic_LoBBSMail *)rec;
return m->to_user_uuid == userUuid;
};
// Execute select with filter and sort
auto allMail = db->select("mail", mail_filter, compareMailByTimestamp);
// Apply offset and limit
std::vector<void *> result;
for (size_t i = offset; i < allMail.size() && i < offset + limit; i++) {
result.push_back(allMail[i]);
}
// Free records not included in result
for (size_t i = 0; i < allMail.size(); i++) {
if (i < offset || i >= offset + limit) {
delete[] (uint8_t *)allMail[i];
}
}
LOG_DEBUG("Retrieved %d mail messages for user " LODB_UUID_FMT " (offset=%d, limit=%d)", result.size(),
LODB_UUID_ARGS(userUuid), offset, limit);
return result;
}
bool LoBBSDal::markMailAsRead(uint64_t mailUuid)
{
// Load the mail record
meshtastic_LoBBSMail mail = meshtastic_LoBBSMail_init_zero;
LoDbError err = db->get("mail", mailUuid, &mail);
if (err != LODB_OK) {
LOG_WARN("Mail not found: " LODB_UUID_FMT, LODB_UUID_ARGS(mailUuid));
return false;
}
// Update read flag
mail.read = true;
// Delete old record and insert updated one
db->deleteRecord("mail", mailUuid);
err = db->insert("mail", mailUuid, &mail);
if (err != LODB_OK) {
LOG_ERROR("Failed to mark mail as read: " LODB_UUID_FMT, LODB_UUID_ARGS(mailUuid));
return false;
}
LOG_DEBUG("Marked mail as read: " LODB_UUID_FMT, LODB_UUID_ARGS(mailUuid));
return true;
}
bool LoBBSDal::postNews(uint64_t authorUserUuid, const char *message)
{
// Generate a unique UUID for the news item
lodb_uuid_t newsUuid = lodb_new_uuid(nullptr, authorUserUuid ^ (uint64_t)getTime());
// Create news record
meshtastic_LoBBSNews news = meshtastic_LoBBSNews_init_zero;
news.uuid = newsUuid;
news.author_user_uuid = authorUserUuid;
strncpy(news.message, message, sizeof(news.message) - 1);
news.message[sizeof(news.message) - 1] = '\0';
news.timestamp = getTime();
LoDbError err = db->insert("news", newsUuid, &news);
if (err != LODB_OK) {
LOG_ERROR("Failed to post news from " LODB_UUID_FMT, LODB_UUID_ARGS(authorUserUuid));
return false;
}
LOG_INFO("Posted news from " LODB_UUID_FMT, LODB_UUID_ARGS(authorUserUuid));
return true;
}
bool LoBBSDal::isNewsReadByUser(uint64_t newsUuid, uint64_t userUuid)
{
char key[35];
buildNewsReadKey(newsUuid, userUuid, key, sizeof(key));
lodb_uuid_t readUuid = lodb_new_uuid(key, 0);
meshtastic_LoBBSNewsRead readRecord = meshtastic_LoBBSNewsRead_init_zero;
LoDbError err = db->get("news_reads", readUuid, &readRecord);
return (err == LODB_OK);
}
bool LoBBSDal::markNewsAsRead(uint64_t newsUuid, uint64_t userUuid)
{
if (isNewsReadByUser(newsUuid, userUuid)) {
LOG_DEBUG("News " LODB_UUID_FMT " already marked as read by user " LODB_UUID_FMT, LODB_UUID_ARGS(newsUuid),
LODB_UUID_ARGS(userUuid));
return true;
}
char key[35];
buildNewsReadKey(newsUuid, userUuid, key, sizeof(key));
lodb_uuid_t readUuid = lodb_new_uuid(key, 0);
meshtastic_LoBBSNewsRead readRecord = meshtastic_LoBBSNewsRead_init_zero;
readRecord.news_uuid = newsUuid;
readRecord.user_uuid = userUuid;
readRecord.read_timestamp = getTime();
LoDbError err = db->insert("news_reads", readUuid, &readRecord);
if (err != LODB_OK) {
LOG_ERROR("Failed to mark news as read: " LODB_UUID_FMT, LODB_UUID_ARGS(newsUuid));
return false;
}
LOG_DEBUG("Marked news " LODB_UUID_FMT " as read by user " LODB_UUID_FMT, LODB_UUID_ARGS(newsUuid), LODB_UUID_ARGS(userUuid));
return true;
}
std::vector<LoBBSNewsEntry> LoBBSDal::getNewsForUser(uint64_t userUuid, uint32_t offset, uint32_t limit)
{
auto allNews = db->select("news", nullptr, nullptr);
std::vector<LoBBSNewsEntry> newsWithStatus;
newsWithStatus.reserve(allNews.size());
for (auto *newsPtr : allNews) {
auto *news = (meshtastic_LoBBSNews *)newsPtr;
bool isRead = isNewsReadByUser(news->uuid, userUuid);
newsWithStatus.push_back({news, isRead});
}
std::sort(newsWithStatus.begin(), newsWithStatus.end(), [](const LoBBSNewsEntry &a, const LoBBSNewsEntry &b) {
if (a.isRead != b.isRead)
return !a.isRead;
return a.news->timestamp > b.news->timestamp;
});
std::vector<LoBBSNewsEntry> result;
for (size_t i = offset; i < newsWithStatus.size() && i < offset + limit; i++) {
result.push_back(newsWithStatus[i]);
}
for (size_t i = 0; i < newsWithStatus.size(); i++) {
if (i < offset || i >= offset + limit) {
delete[] (uint8_t *)newsWithStatus[i].news;
}
}
LOG_DEBUG("Retrieved %d news items for user " LODB_UUID_FMT " (offset=%d, limit=%d)", result.size(), LODB_UUID_ARGS(userUuid),
offset, limit);
return result;
}

142
LoBBSDal.h Normal file
View File

@@ -0,0 +1,142 @@
#pragma once
#include "lobbs.pb.h"
#include "lodb/LoDB.h"
#include <stdint.h>
#include <vector>
struct LoBBSNewsEntry {
meshtastic_LoBBSNews *news;
bool isRead;
};
// Maximum username length (from lobbs.options: meshtastic.LoBBSUser.username max_size:32)
#define LOBBS_MAX_USERNAME_LEN 32
#define LOBBS_USERNAME_BUFFER_SIZE (LOBBS_MAX_USERNAME_LEN + 1) // +1 for null terminator
// Stringify macro for converting numeric defines to string literals
#define LOBBS_XSTR(x) LOBBS_STR(x)
#define LOBBS_STR(x) #x
/**
* LoBBS Data Access Layer
*
* Handles all database operations for LoBBS:
* - User management (creation, lookup, authentication)
* - Session management (login, logout)
* - Password hashing and verification
*/
class LoBBSDal
{
public:
/**
* Constructor
* @param hostNodeId Node ID used as salt for UUID generation
*/
LoBBSDal(uint32_t hostNodeId);
/**
* Destructor
*/
~LoBBSDal();
/**
* Validate username format
*/
bool isValidUsername(const char *username);
/**
* Validate password format
*/
bool isValidPassword(const char *password);
/**
* Load user by username from LoDB
*/
bool loadUserByUsername(const char *username, meshtastic_LoBBSUser *user);
/**
* Load user by node ID (via session lookup)
*/
bool loadUserByNodeId(uint32_t nodeId, meshtastic_LoBBSUser *user);
/**
* Create a new user account
*/
bool createUser(const char *username, const char *password, uint32_t nodeId);
/**
* Verify password for a user
*/
bool verifyPassword(const meshtastic_LoBBSUser *user, const char *password);
/**
* Log in a user (create session)
*/
bool loginUser(const char *username, uint32_t nodeId);
/**
* Log out a user (delete session)
*/
bool logoutUser(uint32_t nodeId);
/**
* Get user UUID by username lookup
* @return User UUID, or 0 if user not found
*/
uint64_t getUserUuidByUsername(const char *username);
/**
* Send a mail message from one user to another
*/
bool sendMail(uint64_t fromUserUuid, uint64_t toUserUuid, const char *message);
/**
* Get mail for a user (sorted by timestamp descending)
* Returns vector of mail records - caller must free
*/
std::vector<void *> getMailForUser(uint64_t userUuid, uint32_t offset, uint32_t limit);
/**
* Mark a mail message as read
*/
bool markMailAsRead(uint64_t mailUuid);
/**
* Post a news item
*/
bool postNews(uint64_t authorUserUuid, const char *message);
/**
* Get news for a user (with read status, sorted unread first then by timestamp desc)
* Returns vector of news records - caller must free
*/
std::vector<LoBBSNewsEntry> getNewsForUser(uint64_t userUuid, uint32_t offset, uint32_t limit);
/**
* Check if a news item has been read by a user
*/
bool isNewsReadByUser(uint64_t newsUuid, uint64_t userUuid);
/**
* Mark a news item as read by a user
*/
bool markNewsAsRead(uint64_t newsUuid, uint64_t userUuid);
/**
* Get the underlying database instance
*/
LoDb *getDb() { return db; }
private:
/**
* Hash a password using SHA256
*/
static void hashPassword(const char *password, uint8_t *hash);
// LoDB database instance
LoDb *db;
// Host node ID used as salt for user UUID generation
uint32_t hostNodeId;
};

691
LoBBSModule.cpp Normal file
View File

@@ -0,0 +1,691 @@
#include "LoBBSModule.h"
#include "LoBBSDal.h"
#include "MeshService.h"
#include "airtime.h"
#include "configuration.h"
#include "gps/RTC.h"
#include "lobbs.pb.h"
#include <algorithm>
#include <cctype>
#include <cstring>
#include <string>
// Static helper: case-insensitive substring search
static const char *stristr(const char *haystack, const char *needle)
{
if (!*needle)
return haystack;
for (; *haystack; haystack++) {
const char *h = haystack;
const char *n = needle;
while (*h && *n && tolower(*h) == tolower(*n)) {
h++;
n++;
}
if (!*n)
return haystack;
}
return nullptr;
}
// Static helper: comparator for case-insensitive alphabetical username sorting
static int compareUsernames(const void *a, const void *b)
{
const meshtastic_LoBBSUser *u1 = (const meshtastic_LoBBSUser *)a;
const meshtastic_LoBBSUser *u2 = (const meshtastic_LoBBSUser *)b;
return strcasecmp(u1->username, u2->username);
}
// Static helper: load a user by UUID
static bool loadUserByUuid(LoBBSDal *dal, uint64_t uuid, meshtastic_LoBBSUser *outUser)
{
auto users = dal->getDb()->select(
"users",
[uuid](const void *rec) -> bool {
const meshtastic_LoBBSUser *u = (const meshtastic_LoBBSUser *)rec;
return u->uuid == uuid;
},
nullptr);
bool found = false;
if (!users.empty()) {
*outUser = *(const meshtastic_LoBBSUser *)users[0];
found = true;
}
for (auto *userPtr : users) {
delete[] (uint8_t *)userPtr;
}
return found;
}
// Static helper: format time ago (e.g., "2h ago", "5m ago", "1d ago")
static void formatTimeAgo(uint32_t timestamp, char *buffer, size_t bufferSize)
{
uint32_t now = getTime();
if (now < timestamp) {
snprintf(buffer, bufferSize, "now");
return;
}
uint32_t diff = now - timestamp;
if (diff < 60) {
snprintf(buffer, bufferSize, "%ds ago", diff);
} else if (diff < 3600) {
snprintf(buffer, bufferSize, "%dm ago", diff / 60);
} else if (diff < 86400) {
snprintf(buffer, bufferSize, "%dh ago", diff / 3600);
} else {
snprintf(buffer, bufferSize, "%dd ago", diff / 86400);
}
}
// Static helper: truncate message for list view
static void truncateMessage(const char *message, char *buffer, size_t bufferSize, size_t maxLen)
{
size_t msgLen = strlen(message);
if (msgLen <= maxLen) {
strncpy(buffer, message, bufferSize - 1);
buffer[bufferSize - 1] = '\0';
} else {
size_t copyLen = maxLen < bufferSize - 4 ? maxLen : bufferSize - 4;
strncpy(buffer, message, copyLen);
buffer[copyLen] = '\0';
strncat(buffer, "...", bufferSize - strlen(buffer) - 1);
}
}
static void freeMailMessages(std::vector<void *> &mailMessages)
{
for (auto *mailPtr : mailMessages) {
delete[] (uint8_t *)mailPtr;
}
mailMessages.clear();
}
static void freeNewsEntries(std::vector<LoBBSNewsEntry> &newsItems)
{
for (auto &entry : newsItems) {
delete[] (uint8_t *)entry.news;
}
newsItems.clear();
}
LoBBSModule::LoBBSModule() : SinglePortModule("LoBBS", meshtastic_PortNum_TEXT_MESSAGE_APP)
{
// Create data access layer with host node ID as salt
dal = new LoBBSDal(nodeDB->getNodeNum());
}
ProcessMessage LoBBSModule::handleReceived(const meshtastic_MeshPacket &mp)
{
LOG_DEBUG("LoBBS received DM from=0x%0x, id=%d, msg=%.*s", mp.from, mp.id, mp.decoded.payload.size, mp.decoded.payload.bytes);
meshtastic_LoBBSUser existingUser = meshtastic_LoBBSUser_init_zero;
bool isAuthenticated = dal->loadUserByNodeId(mp.from, &existingUser);
if (isAuthenticated) {
LOG_DEBUG("User node ID: %08x", mp.from);
LOG_DEBUG("User UUID: " LODB_UUID_FMT, LODB_UUID_ARGS(existingUser.uuid));
LOG_DEBUG("User: %s", existingUser.username);
LOG_DEBUG("User is authenticated: %d", isAuthenticated);
} else {
LOG_DEBUG("User node ID: %08x", mp.from);
LOG_DEBUG("User is not authenticated");
}
// Copy payload to mutable buffer and null terminate (there is no null terminator in the payload)
memcpy(msgBuffer, mp.decoded.payload.bytes, mp.decoded.payload.size);
msgBuffer[mp.decoded.payload.size] = '\0';
// Check for @mention mail sending BEFORE tokenizing (authenticated users only)
// This must happen before strtok destroys the buffer
if (isAuthenticated && strchr(msgBuffer, '@')) {
LOG_DEBUG("Processing @mention mail from node=0x%0x", mp.from);
// Extract message content and find all @mentions
std::vector<std::string> recipients;
std::string messageContent = std::string(msgBuffer);
// Parse the message for @mentions
char *token = msgBuffer;
bool foundAny = false;
while (*token) {
if (*token == '@') {
foundAny = true;
// Found a mention, extract username
token++; // Skip @
char *usernameStart = token;
while (*token && (isalnum(*token) || *token == '_')) {
token++;
}
// Save username (only if not already in list)
char username[LOBBS_USERNAME_BUFFER_SIZE];
size_t usernameLen = token - usernameStart;
if (usernameLen > 0 && usernameLen < LOBBS_USERNAME_BUFFER_SIZE) {
strncpy(username, usernameStart, usernameLen);
username[usernameLen] = '\0';
std::string usernameStr(username);
// Only add if not already in recipients list
if (std::find(recipients.begin(), recipients.end(), usernameStr) == recipients.end()) {
recipients.push_back(usernameStr);
}
}
} else {
token++;
}
}
if (foundAny && !recipients.empty()) {
// Validate and send to each recipient
int successCount = 0;
int failCount = 0;
std::string failedUsers;
for (const auto &recipient : recipients) {
// Get recipient UUID
uint64_t recipientUuid = dal->getUserUuidByUsername(recipient.c_str());
if (recipientUuid == 0) {
LOG_WARN("User not found: %s", recipient.c_str());
if (failCount > 0)
failedUsers += ", ";
failedUsers += "@" + recipient;
failCount++;
continue;
}
// Send mail
if (dal->sendMail(existingUser.uuid, recipientUuid, messageContent.c_str())) {
LOG_INFO("Sent mail from %s to %s", existingUser.username, recipient.c_str());
successCount++;
} else {
LOG_ERROR("Failed to send mail to %s", recipient.c_str());
if (failCount > 0)
failedUsers += ", ";
failedUsers += "@" + recipient;
failCount++;
}
}
// Send confirmation
if (successCount > 0 && failCount == 0) {
if (successCount == 1) {
snprintf(replyBuffer, sizeof(replyBuffer), "Mail sent to @%s", recipients[0].c_str());
} else {
snprintf(replyBuffer, sizeof(replyBuffer), "Mail sent to %d users", successCount);
}
sendReply(mp.from, replyBuffer);
} else if (successCount > 0 && failCount > 0) {
snprintf(replyBuffer, sizeof(replyBuffer), "Mail sent to %d users. Failed: %s", successCount,
failedUsers.c_str());
sendReply(mp.from, replyBuffer);
} else {
snprintf(replyBuffer, sizeof(replyBuffer), "Failed to send mail. Users not found: %s", failedUsers.c_str());
sendReply(mp.from, replyBuffer);
}
return ProcessMessage::CONTINUE;
}
}
// Tokenize for command processing (this modifies msgBuffer)
char *cmdName = strtok(msgBuffer, " ");
LOG_DEBUG("Token: %s", cmdName);
if (strcasecmp(cmdName, "/hi") == 0) {
LOG_DEBUG("Processing /hi command from node=0x%0x", mp.from);
// Get username
char *username = strtok(NULL, " ");
if (!username) {
sendReply(mp.from, "Usage: /hi <username> <password>");
return ProcessMessage::CONTINUE;
}
// Validate username length
size_t usernameLen = strlen(username);
if (usernameLen == 0 || usernameLen > LOBBS_MAX_USERNAME_LEN || !dal->isValidUsername(username)) {
sendReply(
mp.from,
"Username not found or is invalid. Username must be 1-" LOBBS_XSTR(
LOBBS_MAX_USERNAME_LEN) " characters and contain only letters, numbers, and common special characters.");
return ProcessMessage::CONTINUE;
}
// Get password (rest of the string)
char *password = strtok(NULL, "");
if (!password) {
sendReply(mp.from, "Usage: /hi <username> <password>");
return ProcessMessage::CONTINUE;
}
// Validate password is at least 5 characters long
if (strlen(password) < 5 || strlen(password) > 50 || !dal->isValidPassword(password)) {
sendReply(mp.from, "Password incorrect or invalid. Password must be between 5 and 50 characters long and contain "
"only letters, numbers, and common "
"special characters.");
return ProcessMessage::CONTINUE;
}
LOG_DEBUG("Processing /hi command: username=%s from node=0x%0x", username, mp.from);
// Try to load existing user
meshtastic_LoBBSUser existingUser = meshtastic_LoBBSUser_init_zero;
bool userExists = dal->loadUserByUsername(username, &existingUser);
if (userExists) {
// User exists - verify password
if (dal->verifyPassword(&existingUser, password)) {
LOG_DEBUG("User %s authenticated successfully from node 0x%0x", username, mp.from);
// Log in user (create/update session)
if (dal->loginUser(username, mp.from)) {
snprintf(replyBuffer, sizeof(replyBuffer), "Welcome back %s!", username);
sendReply(mp.from, replyBuffer);
} else {
sendReply(mp.from, "Error creating session");
}
} else {
LOG_WARN("Invalid password for user %s from node 0x%0x", username, mp.from);
sendReply(mp.from, "Invalid password");
}
} else {
// New user - create account
LOG_DEBUG("Creating new user: %s for node 0x%0x", username, mp.from);
if (dal->createUser(username, password, mp.from)) {
snprintf(replyBuffer, sizeof(replyBuffer), "Welcome %s!", username);
sendReply(mp.from, replyBuffer);
} else {
sendReply(mp.from, "Error creating account");
}
}
return ProcessMessage::CONTINUE;
}
if (!isAuthenticated) {
const char *helpMsg = LOBBS_HEADER "/hi <user> <pass> - Login or create account\n";
LOG_DEBUG("Help message: %s", helpMsg);
sendReply(mp.from, helpMsg);
return ProcessMessage::CONTINUE;
}
/**
* ================================
* Authenticated commands
* ================================
*/
if (strcasecmp(cmdName, "/bye") == 0) {
LOG_INFO("Processing /bye command from node=0x%0x", mp.from);
dal->logoutUser(mp.from);
sendReply(mp.from, "Goodbye!");
return ProcessMessage::CONTINUE;
}
if (strcasecmp(cmdName, "/users") == 0) {
LOG_INFO("Processing /users command from node=0x%0x", mp.from);
char *filterStr = strtok(NULL, " ");
// Parse optional filter argument
if (filterStr && !dal->isValidUsername(filterStr)) {
sendReply(mp.from, "Filter must contain only letters, numbers, and common special characters.");
return ProcessMessage::CONTINUE;
}
// Build filter lambda for username matching (captures filterStr)
auto username_filter = [filterStr](const void *rec) -> bool {
const meshtastic_LoBBSUser *u = (const meshtastic_LoBBSUser *)rec;
return !filterStr || !filterStr[0] || stristr(u->username, filterStr) != nullptr;
};
// Execute synchronous select with filter and sort
auto users = dal->getDb()->select("users", username_filter, compareUsernames);
// Build and send response
if (users.empty()) {
std::string reply;
LOG_DEBUG("No users match '%s'", filterStr);
if (filterStr && filterStr[0]) {
reply += "No users match '";
reply += filterStr;
reply += "'";
} else {
reply += "No users found";
}
} else {
LOG_DEBUG("# users: %d", users.size());
// Build user directory message
std::string userListMsg = "User directory:\n";
for (size_t i = 0; i < users.size(); i++) {
const meshtastic_LoBBSUser *u = (const meshtastic_LoBBSUser *)users[i];
if (i > 0) {
userListMsg += ", ";
}
userListMsg += u->username;
}
LOG_DEBUG("User list message: %s", userListMsg.c_str());
sendReply(mp.from, userListMsg.c_str());
// Free allocated records
for (auto *userPtr : users) {
delete[] (uint8_t *)userPtr;
}
}
return ProcessMessage::CONTINUE;
}
if (strcasecmp(cmdName, "/mail") == 0) {
LOG_INFO("Processing /mail command from node=0x%0x", mp.from);
// Parse optional arguments
char *arg1 = strtok(NULL, " ");
// Determine action: list (default), list with offset, or read specific message
uint32_t offset = 0;
int readMessageId = -1;
bool isReadCommand = false;
if (arg1) {
if (!isdigit(static_cast<unsigned char>(*arg1))) {
sendReply(mp.from, "Invalid mail command");
return ProcessMessage::CONTINUE;
}
char *argCopy = arg1;
size_t len = strlen(argCopy);
if (len > 0 && argCopy[len - 1] == '-') {
// "/mail <n>-" format for listing from offset
argCopy[len - 1] = '\0';
offset = atoi(argCopy);
if (offset > 0)
offset--; // Convert to 0-based
} else {
// "/mail <n>" format for reading message
readMessageId = atoi(argCopy);
isReadCommand = true;
}
}
// Get mail for current user
const uint32_t MAIL_PAGE_SIZE = 10;
auto mailMessages = dal->getMailForUser(existingUser.uuid, offset, MAIL_PAGE_SIZE);
if (isReadCommand && readMessageId > 0) {
// Read specific message
if (readMessageId > (int)mailMessages.size()) {
sendReply(mp.from, "Invalid message number");
freeMailMessages(mailMessages);
return ProcessMessage::CONTINUE;
}
const meshtastic_LoBBSMail *mail = (const meshtastic_LoBBSMail *)mailMessages[readMessageId - 1];
// Get sender username
meshtastic_LoBBSUser sender = meshtastic_LoBBSUser_init_zero;
bool foundSender = loadUserByUuid(dal, mail->from_user_uuid, &sender);
// Format time
char timeStr[32];
formatTimeAgo(mail->timestamp, timeStr, sizeof(timeStr));
// Build message
std::string reply;
reply += "From: @";
reply += foundSender ? sender.username : "unknown";
reply += " (";
reply += timeStr;
reply += ")\n";
reply += mail->message;
sendReply(mp.from, reply.c_str());
// Mark as read
dal->markMailAsRead(mail->uuid);
freeMailMessages(mailMessages);
return ProcessMessage::CONTINUE;
}
// List mail (default action)
if (mailMessages.empty()) {
sendReply(mp.from, "No mail");
return ProcessMessage::CONTINUE;
}
// Count unread messages
int unreadCount = 0;
for (auto *mailPtr : mailMessages) {
const meshtastic_LoBBSMail *mail = (const meshtastic_LoBBSMail *)mailPtr;
if (!mail->read) {
unreadCount++;
}
}
// Build mail list
std::string mailList;
if (unreadCount > 0) {
char unreadStr[32];
snprintf(unreadStr, sizeof(unreadStr), "(%d unread)\n", unreadCount);
mailList += unreadStr;
}
for (size_t i = 0; i < mailMessages.size(); i++) {
const meshtastic_LoBBSMail *mail = (const meshtastic_LoBBSMail *)mailMessages[i];
// Get sender username
meshtastic_LoBBSUser sender = meshtastic_LoBBSUser_init_zero;
bool foundSender = loadUserByUuid(dal, mail->from_user_uuid, &sender);
// Format message entry
char entryBuffer[256];
char timeStr[32];
char truncMsg[50];
formatTimeAgo(mail->timestamp, timeStr, sizeof(timeStr));
truncateMessage(mail->message, truncMsg, sizeof(truncMsg), 25);
snprintf(entryBuffer, sizeof(entryBuffer), "[%d]%s @%s: %s (%s)\n", (int)(offset + i + 1), mail->read ? "" : "*",
foundSender ? sender.username : "unknown", truncMsg, timeStr);
mailList += entryBuffer;
}
sendReply(mp.from, mailList.c_str());
freeMailMessages(mailMessages);
return ProcessMessage::CONTINUE;
}
if (strcasecmp(cmdName, "/news") == 0) {
LOG_INFO("Processing /news command from node=0x%0x", mp.from);
// Parse optional arguments
char *arg1 = strtok(NULL, " ");
char *arg2 = strtok(NULL, " ");
uint32_t offset = 0;
int readNewsId = -1;
bool isReadCommand = false;
bool isPostCommand = false;
if (arg1) {
if (strcasecmp(arg1, "r") == 0 && arg2) {
readNewsId = atoi(arg2);
isReadCommand = true;
} else if (strcasecmp(arg1, "l") == 0 && arg2) {
offset = atoi(arg2);
if (offset > 0)
offset--; // Convert to 0-based
} else if (isdigit((unsigned char)arg1[0])) {
// "/news <n>" or "/news <n>-"
char *argCopy = arg1;
size_t len = strlen(argCopy);
if (len > 0 && argCopy[len - 1] == '-') {
argCopy[len - 1] = '\0';
offset = atoi(argCopy);
if (offset > 0)
offset--; // Convert to 0-based
} else {
readNewsId = atoi(argCopy);
isReadCommand = true;
}
} else {
isPostCommand = true;
}
}
if (isPostCommand) {
// This is a news post - get the full message from original payload
const char *msgStart = (const char *)mp.decoded.payload.bytes;
size_t payloadSize = mp.decoded.payload.size;
// Skip command prefix
const char *payloadEnd = msgStart + payloadSize;
while (msgStart < payloadEnd && *msgStart != ' ')
msgStart++;
while (msgStart < payloadEnd && *msgStart == ' ')
msgStart++;
if (msgStart >= payloadEnd || *msgStart == '\0') {
sendReply(mp.from, "News message cannot be empty");
return ProcessMessage::CONTINUE;
}
if (!isalpha((unsigned char)*msgStart)) {
sendReply(mp.from, "News must start with a letter");
return ProcessMessage::CONTINUE;
}
std::string newsMessage(msgStart, payloadEnd - msgStart);
if (dal->postNews(existingUser.uuid, newsMessage.c_str())) {
sendReply(mp.from, "News posted");
} else {
sendReply(mp.from, "Failed to post news");
}
return ProcessMessage::CONTINUE;
}
// Get news for current user
const uint32_t NEWS_PAGE_SIZE = 10;
auto newsItems = dal->getNewsForUser(existingUser.uuid, offset, NEWS_PAGE_SIZE);
if (isReadCommand && readNewsId > 0) {
if (readNewsId > (int)newsItems.size()) {
sendReply(mp.from, "Invalid news number");
freeNewsEntries(newsItems);
return ProcessMessage::CONTINUE;
}
auto &entry = newsItems[readNewsId - 1];
const meshtastic_LoBBSNews *news = entry.news;
// Get author username
meshtastic_LoBBSUser author = meshtastic_LoBBSUser_init_zero;
bool foundAuthor = loadUserByUuid(dal, news->author_user_uuid, &author);
char timeStr[32];
formatTimeAgo(news->timestamp, timeStr, sizeof(timeStr));
std::string reply;
reply += "From: @";
reply += foundAuthor ? author.username : "unknown";
reply += " (";
reply += timeStr;
reply += ")\n";
reply += news->message;
sendReply(mp.from, reply.c_str());
dal->markNewsAsRead(news->uuid, existingUser.uuid);
freeNewsEntries(newsItems);
return ProcessMessage::CONTINUE;
}
if (newsItems.empty()) {
sendReply(mp.from, "No news");
return ProcessMessage::CONTINUE;
}
int unreadCount = 0;
for (const auto &entry : newsItems) {
if (!entry.isRead)
unreadCount++;
}
std::string newsList;
if (unreadCount > 0) {
char unreadStr[32];
snprintf(unreadStr, sizeof(unreadStr), "(%d unread)\n", unreadCount);
newsList += unreadStr;
}
for (size_t i = 0; i < newsItems.size(); i++) {
const auto &entry = newsItems[i];
const meshtastic_LoBBSNews *news = entry.news;
meshtastic_LoBBSUser author = meshtastic_LoBBSUser_init_zero;
bool foundAuthor = loadUserByUuid(dal, news->author_user_uuid, &author);
char entryBuffer[256];
char timeStr[32];
char truncMsg[50];
formatTimeAgo(news->timestamp, timeStr, sizeof(timeStr));
truncateMessage(news->message, truncMsg, sizeof(truncMsg), 25);
snprintf(entryBuffer, sizeof(entryBuffer), "[%d]%s @%s: %s (%s)\n", (int)(offset + i + 1), entry.isRead ? "" : "*",
foundAuthor ? author.username : "unknown", truncMsg, timeStr);
newsList += entryBuffer;
}
sendReply(mp.from, newsList.c_str());
freeNewsEntries(newsItems);
return ProcessMessage::CONTINUE;
}
std::string helpMsg = LOBBS_HEADER "/bye - Logout\n"
"/users [filter] - List users (optional filter)\n"
"/mail [<n>|r <n>|l <n>|<n>-] - List/read mail\n"
"@user <msg> - Send mail\n"
"/news [<n>|r <n>|l <n>|<n>-] - List/read news\n"
"/news <msg> - Post news";
LOG_DEBUG("Help message: %s", helpMsg.c_str());
sendReply(mp.from, helpMsg);
return ProcessMessage::CONTINUE;
}
void LoBBSModule::sendReply(NodeNum to, const std::string &msg)
{
meshtastic_MeshPacket *reply = allocDataPacket();
static constexpr char truncMarker[] = "[...]";
static constexpr size_t truncMarkerLen = sizeof(truncMarker) - 1;
bool isTruncated = msg.size() > MAX_REPLY_BYTES;
size_t payloadSize = isTruncated ? MAX_REPLY_BYTES : msg.size();
reply->decoded.payload.size = payloadSize;
if (isTruncated) {
size_t copyLen = MAX_REPLY_BYTES > truncMarkerLen ? MAX_REPLY_BYTES - truncMarkerLen : 0;
memcpy(reply->decoded.payload.bytes, msg.c_str(), copyLen);
memcpy(reply->decoded.payload.bytes + copyLen, truncMarker, truncMarkerLen);
} else {
memcpy(reply->decoded.payload.bytes, msg.c_str(), payloadSize);
}
reply->to = to;
reply->decoded.want_response = false;
service->sendToMesh(reply);
}

39
LoBBSModule.h Normal file
View File

@@ -0,0 +1,39 @@
#pragma once
#include "LoBBSDal.h"
#include "SinglePortModule.h"
#include "lobbs.pb.h"
#define LOBBS_VERSION "1.0.0"
#define LOBBS_HEADER "LoBBS v" LOBBS_VERSION "\nCommands:\n"
/**
* LoBBS (Lo-Fi Bulletin Board System) Module
*
* A simple BBS-style messaging system for Meshtastic.
* Handles text messages on TEXT_MESSAGE_APP port.
*
* Uses LoDB for storage:
* - Users table: /lodb/lobbs/users/<username>.pr
* - Sessions table: /lodb/lobbs/sessions/<nodeid_hex>.pr
*/
class LoBBSModule : public SinglePortModule
{
public:
LoBBSModule();
static constexpr size_t MAX_REPLY_BYTES = 200;
protected:
/**
* Handle an incoming message
*/
virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
char msgBuffer[256];
char replyBuffer[256];
private:
// Data access layer for database operations
LoBBSDal *dal;
void sendReply(NodeNum to, const std::string &msg);
};

84
README.md Normal file
View File

@@ -0,0 +1,84 @@
# LoBBS - Meshtastic BBS on the Firmware
LoBBS is a full bulletin board system that runs entirely inside the Meshtastic firmware. Once the module is built into your node you can create user accounts, exchange private mail, broadcast news posts, and remotely administer the device without any sidecar services or host computer.
## Features
- **User directory** with username registration and secure password storage
- **Private mail inbox** with paging, read receipts, and inline `@mention` delivery
- **News feed** with threaded announcements and per-user read tracking
- Session-aware command parser with **contextual help**
- Backed by [LoDB](https://github.com/MeshEnvy/lodb) for on-device storage so the entire BBS persists across reboots
## Prerequisites
- A working Meshtastic firmware checkout with a build environment configured for your target board
- Python environment compatible with PlatformIO (used for the existing Meshtastic build pipeline)
- Git access to clone the LoBBS and LoDB submodules
## Installation
**Step 1: Vendor LoBBS and LoDB**
LoBBS ships as a firmware module and depends on LoDB for its embedded database layer. Add both submodules to your firmware tree and pull their contents:
```bash
git submodule add git@github.com/MeshEnvy/lobbs.git src/modules/LoBBSModule
git submodule add git@github.com/MeshEnvy/lodb.git src/lodb
git submodule update --init --recursive
```
**Step 2: Wire LoDB code generation into PlatformIO**
The protobuf definitions in `lobbs.proto` must be regenerated whenever they change. Append the following helper to `bin/platformio-custom.py` so PlatformIO can discover the generator provided by LoDB:
```python
# Ensure LoDB helper utilities are available when LoDB is vendored as a submodule
lodb_helper_path = join(env["PROJECT_DIR"], "src", "lodb") if env else None
if lodb_helper_path and lodb_helper_path not in sys.path:
sys.path.append(lodb_helper_path)
try:
from gen_proto import register_protobufs # type: ignore
register_protobufs(
env,
"$BUILD_DIR/src/modules/LoBBSModule/LoBBSModule.cpp.o",
"$PROJECT_DIR/src/modules/LoBBSModule/lobbs.proto",
)
except Exception:
print("Warning: gen_proto utilities not found; protobufs will not auto-regenerate")
```
**Step 3: Build and flash**
Use the standard Meshtastic PlatformIO targets to compile and upload. The example below targets an `esp32` environment; adjust the environment name to match your board:
```bash
pio run -e esp32 -t upload
```
After flashing, reboot the node. LoBBS registers automatically through `Modules.cpp`, so no additional firmware configuration is required.
## Using LoBBS
- **Joining the BBS** — Send a direct message to your node containing `/hi <username> <password>`. The command logs you in if the account exists or creates a new account if it does not.
- **Logging out** — Use `/bye` to terminate the current session and clear the binding between your node ID and account.
- **Mail** — `/mail` lists the 10 most recent messages, `/mail 3` reads message 3, and `/mail 5-` starts the listing at item 5. Mention another user in any authenticated message using `@username` to deliver instant mail.
- **News** — `/news` mirrors the mail workflow for public announcements. Append a message after the command (for example `/news Hello mesh!`) to post a new item.
- **User discovery** — `/users` returns the directory. Supply an optional filter string (e.g. `/users mesh`) to narrow the results.
LoBBS replies inline with human-readable summaries. Unread content is flagged with an asterisk in list views, and relative timestamps (for example, `2h ago`) provide context for each entry.
## Storage Layout
All user, mail, and news data is persisted via LoDB in the device filesystem. Clearing the filesystem, reflashing without preserving SPIFFS/LittleFS, or performing a full factory reset will delete the BBS contents. Regular backups of the filesystem are recommended for production deployments.
## Troubleshooting
- Ensure the LoDB generator is reachable; if protobufs fail to regenerate you will see the warning printed during the PlatformIO build.
- Verify your node clock is roughly correct. Timestamps in mail and news rely on the RTC and GPS time sources provided by the firmware.
- Confirm that your node stays logged in (no `/bye` issued) if you expect to receive `@mentions`. Unauthenticated nodes receive only the login help banner.
## License
LoBBS is distributed under the MIT license. See the accompanying `LICENSE` file within this module for full text.

5
lobbs.options Normal file
View File

@@ -0,0 +1,5 @@
meshtastic.LoBBSUser.username max_size:32
meshtastic.LoBBSUser.password_hash max_size:32
meshtastic.LoBBSMail.message max_size:200
meshtastic.LoBBSNews.message max_size:200

140
lobbs.proto Normal file
View File

@@ -0,0 +1,140 @@
syntax = "proto3";
package meshtastic;
option csharp_namespace = "Meshtastic.Protobufs";
option go_package = "github.com/meshtastic/go/generated";
option java_outer_classname = "LoBBSProtos";
option java_package = "com.geeksville.mesh";
option swift_prefix = "";
/*
* LoBBS User Record
* Stores user authentication information for the LoBBS system
* UUID: username (for direct lookups)
*/
message LoBBSUser {
/*
* Username for the user account
*/
string username = 1;
/*
* SHA256 hash of the user's password (32 bytes)
*/
bytes password_hash = 2;
/*
* User UUID - deterministic hash derived from username
*/
uint64 uuid = 3;
}
/*
* LoBBS Session Record
* Maps node IDs to logged-in user UUIDs
* UUID: node ID as uint64
*/
message LoBBSSession {
/*
* User UUID of the logged-in user
*/
uint64 user_uuid = 1;
/*
* Last login timestamp
*/
uint32 last_login_time = 2;
/*
* Node ID (stored for reference)
*/
uint32 node_id = 3;
}
/*
* LoBBS Mail Record
* Stores mail messages between users
* UUID: auto-incrementing message ID
*/
message LoBBSMail {
/*
* Message UUID (auto-generated)
*/
uint64 uuid = 1;
/*
* Sender's user UUID
*/
uint64 from_user_uuid = 2;
/*
* Recipient's user UUID
*/
uint64 to_user_uuid = 3;
/*
* Message content (max 200 bytes)
*/
string message = 4;
/*
* Timestamp when message was sent
*/
uint32 timestamp = 5;
/*
* Read status
*/
bool read = 6;
}
/*
* LoBBS News Record
* Stores news/bulletin posts visible to all users
* UUID: auto-generated news ID
*/
message LoBBSNews {
/*
* News UUID (auto-generated)
*/
uint64 uuid = 1;
/*
* Author's user UUID
*/
uint64 author_user_uuid = 2;
/*
* News content (max 200 bytes)
*/
string message = 3;
/*
* Timestamp when news was posted
*/
uint32 timestamp = 4;
}
/*
* LoBBS News Read Tracking Record
* Tracks which users have read which news items
* UUID: deterministic key derived from news_uuid + user_uuid
*/
message LoBBSNewsRead {
/*
* News item UUID
*/
uint64 news_uuid = 1;
/*
* User UUID who read the news
*/
uint64 user_uuid = 2;
/*
* Timestamp when read
*/
uint32 read_timestamp = 3;
}