mirror of
https://github.com/MeshEnvy/lobbs.git
synced 2026-03-28 16:22:33 +01:00
Initial commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
lobbs.pb.*
|
||||||
129
CommandParser.cpp
Normal file
129
CommandParser.cpp
Normal 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
70
CommandParser.h
Normal 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
22
LICENSE
Normal 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
408
LoBBSDal.cpp
Normal 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
142
LoBBSDal.h
Normal 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
691
LoBBSModule.cpp
Normal 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
39
LoBBSModule.h
Normal 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
84
README.md
Normal 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
5
lobbs.options
Normal 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
140
lobbs.proto
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user