Files
MeshCore/src/helpers/RoomBoardProtocol.h
T
mikecarper 74bc91bf69 Improve room retention, moderation and local browser access
Add opt-in bounded chat history, persist explicit room ACL roles, enforce full-key bans and per-user quotas, and provide versioned information-board reads plus an ESP32 local room page. Preserve stored evidence on faults and withhold room delivery acknowledgments when a Companion cannot retain the message.

Exercise production handlers and filesystem fault recovery, pinned official-app JavaScript, real Chromium interactions, and representative ESP32, nRF52, RP2040 and STM32 builds. Add regression suites and bounded browser-workspace RAM reservations to CI.
2026-10-09 23:44:00 -07:00

251 lines
13 KiB
C++

#pragma once
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "RoomBoardStore.h"
namespace mesh {
static constexpr uint8_t ROOM_BOARD_REQUEST_SUBTYPE = 0x0a;
static constexpr size_t ROOM_BOARD_MAX_COMMAND_LENGTH = 512;
namespace room_board_protocol_detail {
inline uint8_t status(RoomBoardResult result) {
switch (result) {
case RoomBoardResult::Success: return 0;
case RoomBoardResult::Invalid: return 1;
case RoomBoardResult::Unavailable: return 2;
case RoomBoardResult::WriteFailure: return 3;
case RoomBoardResult::NotFound: return 4;
case RoomBoardResult::StaleVersion: return 5;
}
return 1;
}
inline size_t shortError(uint8_t op, RoomBoardResult result, uint8_t* reply, size_t capacity) {
if (!reply || capacity < 3) return 0;
reply[0] = ROOM_BOARD_REQUEST_SUBTYPE; reply[1] = op; reply[2] = status(result); return 3;
}
inline void textResult(char* reply, size_t capacity, RoomBoardResult result, uint32_t revision = 0) {
if (!reply || capacity == 0) return;
const char* message;
switch (result) {
case RoomBoardResult::Success: snprintf(reply, capacity, "OK rev=%lu", (unsigned long)revision); return;
case RoomBoardResult::Unavailable: message = "Error unavailable"; break;
case RoomBoardResult::WriteFailure: message = "Error write failure"; break;
case RoomBoardResult::NotFound: message = "Error not found"; break;
case RoomBoardResult::StaleVersion: message = "Error stale version"; break;
default: message = "Error invalid"; break;
}
snprintf(reply, capacity, "%s", message);
}
inline bool prefix(const char* command, const char* expected) {
const size_t length = strlen(expected);
return strncmp(command, expected, length) == 0 && (command[length] == 0 || command[length] == ' ');
}
inline const char* spaces(const char* cursor) { while (*cursor == ' ') ++cursor; return cursor; }
inline bool number(const char*& cursor, uint32_t maximum, uint32_t& value) {
cursor = spaces(cursor); value = 0;
if (*cursor < '0' || *cursor > '9') return false;
do {
const uint8_t digit = uint8_t(*cursor++ - '0');
if (value > maximum / 10 || (value == maximum / 10 && digit > maximum % 10)) return false;
value = value * 10 + digit;
} while (*cursor >= '0' && *cursor <= '9');
return *cursor == 0 || *cursor == ' ';
}
inline size_t base64(const uint8_t* data, size_t length, char* output) {
static const char alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
size_t position = 0;
for (size_t i = 0; i < length; i += 3) {
const uint32_t bits = (uint32_t(data[i]) << 16)
| (i + 1 < length ? uint32_t(data[i + 1]) << 8 : 0)
| (i + 2 < length ? uint32_t(data[i + 2]) : 0);
output[position++] = alphabet[(bits >> 18) & 63];
output[position++] = alphabet[(bits >> 12) & 63];
output[position++] = i + 1 < length ? alphabet[(bits >> 6) & 63] : '=';
output[position++] = i + 2 < length ? alphabet[bits & 63] : '=';
}
output[position] = 0; return position;
}
} // namespace room_board_protocol_detail
// Read-only requests, callable for any authorized room reader, including
// guests. The caller reserves its transport tag outside this reply capacity.
// All integer fields are little-endian. No partial index entry is emitted.
template <typename Filesystem>
size_t handleRoomBoardRequest(Filesystem* fs, const uint8_t* payload, size_t length,
uint8_t* reply, size_t capacity) {
using namespace room_board_protocol_detail;
const uint8_t op = payload && length > 1 ? payload[1] : 0xff;
if (!payload || length < 2 || payload[0] != ROOM_BOARD_REQUEST_SUBTYPE
|| !reply || capacity < 3 || op > 1) return shortError(op, RoomBoardResult::Invalid, reply, capacity);
if (op == 0) {
if (length < 7 || capacity < 10) return shortError(op, RoomBoardResult::Invalid, reply, capacity);
const uint32_t expected = room_board_detail::get32(payload + 2);
const uint8_t start = payload[6];
RoomBoardIndex index;
RoomBoardResult result = RoomBoardResult::Success;
if (!loadRoomBoard(fs, index)) result = RoomBoardResult::Unavailable;
else if ((!expected && start) || (expected && expected != index.revision)) result = RoomBoardResult::StaleVersion;
else if (start > index.count) result = RoomBoardResult::Invalid;
reply[0] = ROOM_BOARD_REQUEST_SUBTYPE; reply[1] = op;
room_board_detail::put32(reply + 3, index.revision);
reply[7] = index.count; reply[8] = start; reply[9] = start;
size_t used = 10;
if (result == RoomBoardResult::Success) {
uint8_t position = start;
while (position < index.count && position < size_t(start) + 2) {
const RoomBoardArticle& entry = index.articles[position];
const size_t title_length = strlen(entry.title);
const size_t entry_length = 8 + title_length;
if (entry_length > capacity - used) {
if (position == start) result = RoomBoardResult::Invalid;
break;
}
uint8_t* encoded = reply + used;
encoded[0] = entry.id; room_board_detail::put32(encoded + 1, entry.version);
room_board_detail::put16(encoded + 5, entry.body_length); encoded[7] = uint8_t(title_length);
memcpy(encoded + 8, entry.title, title_length); used += entry_length; ++position;
}
reply[9] = position;
}
reply[2] = status(result); return used;
}
if (length < 9 || capacity < 13) return shortError(op, RoomBoardResult::Invalid, reply, capacity);
const uint8_t id = payload[2];
const uint32_t version = room_board_detail::get32(payload + 3);
const uint16_t offset = room_board_detail::get16(payload + 7);
reply[0] = ROOM_BOARD_REQUEST_SUBTYPE; reply[1] = op; reply[3] = id;
room_board_detail::put32(reply + 4, version); room_board_detail::put16(reply + 8, offset);
room_board_detail::put16(reply + 10, 0); reply[12] = 0;
RoomBoardIndex index;
RoomBoardResult result = RoomBoardResult::Success;
const RoomBoardArticle* article = nullptr;
if (id == 0 || id > ROOM_BOARD_MAX_ARTICLES || version == 0) result = RoomBoardResult::Invalid;
else if (!loadRoomBoard(fs, index)) result = RoomBoardResult::Unavailable;
else {
for (uint8_t i = 0; i < index.count; ++i) if (index.articles[i].id == id) article = &index.articles[i];
if (!article) result = RoomBoardResult::NotFound;
else {
room_board_detail::put32(reply + 4, article->version);
room_board_detail::put16(reply + 10, article->body_length);
if (article->version != version) result = RoomBoardResult::StaleVersion;
else if (offset > article->body_length || (capacity == 13 && offset < article->body_length)) result = RoomBoardResult::Invalid;
}
}
size_t copied = 0;
if (result == RoomBoardResult::Success && offset < article->body_length) {
const size_t available = capacity - 13;
const size_t chunk = available < ROOM_BOARD_READ_MAX_CHUNK ? available : ROOM_BOARD_READ_MAX_CHUNK;
result = readRoomBoardArticle(fs, id, version, offset, reply + 13, chunk, copied);
}
reply[2] = status(result); reply[12] = uint8_t(copied); return 13 + copied;
}
// Caller owns administrative authorization for put/del (and may grant get
// to ordinary room readers). The command body is borrowed, never buffered.
// `get room.board [cursor]` returns `next=` for the next whole-entry page.
template <typename Filesystem>
bool handleRoomBoardCommand(Filesystem* fs, const char* command, char* reply, size_t capacity) {
using namespace room_board_protocol_detail;
if (!command) return false;
const bool put = prefix(command, "room.board.put");
const bool del = prefix(command, "room.board.del");
const bool read = prefix(command, "get room.board.read");
const bool list = prefix(command, "get room.board");
if (!put && !del && !read && !list) return false;
if (!reply || capacity == 0) return true;
reply[0] = 0;
size_t length = 0;
while (length <= ROOM_BOARD_MAX_COMMAND_LENGTH && command[length]) ++length;
if (length > ROOM_BOARD_MAX_COMMAND_LENGTH) { textResult(reply, capacity, RoomBoardResult::Invalid); return true; }
if (put) {
const char* cursor = command + strlen("room.board.put"); uint32_t id;
if (!number(cursor, ROOM_BOARD_MAX_ARTICLES, id) || id == 0) { textResult(reply, capacity, RoomBoardResult::Invalid); return true; }
cursor = spaces(cursor); const char* separator = strchr(cursor, '|');
size_t title_length = separator ? size_t(separator - cursor) : 0;
while (title_length && cursor[title_length - 1] == ' ') --title_length;
if (!separator || title_length == 0 || title_length >= ROOM_BOARD_TITLE_CAPACITY) { textResult(reply, capacity, RoomBoardResult::Invalid); return true; }
char title[ROOM_BOARD_TITLE_CAPACITY] = {}; memcpy(title, cursor, title_length);
const char* body = separator + 1; const size_t body_length = strlen(body);
const RoomBoardResult result = saveRoomBoardArticle(fs, uint8_t(id), title, body_length,
[body](size_t offset, uint8_t* output, size_t count) -> size_t {
memcpy(output, body + offset, count); return count;
});
RoomBoardIndex index;
if (result == RoomBoardResult::Success) loadRoomBoard(fs, index);
textResult(reply, capacity, result, index.revision); return true;
}
if (del) {
const char* cursor = command + strlen("room.board.del"); uint32_t id, version;
if (!number(cursor, ROOM_BOARD_MAX_ARTICLES, id) || id == 0 || !number(cursor, UINT32_MAX, version)
|| version == 0 || *spaces(cursor)) { textResult(reply, capacity, RoomBoardResult::Invalid); return true; }
const RoomBoardResult result = deleteRoomBoardArticle(fs, uint8_t(id), version);
RoomBoardIndex index;
if (result == RoomBoardResult::Success) loadRoomBoard(fs, index);
textResult(reply, capacity, result, index.revision); return true;
}
RoomBoardIndex index;
if (!loadRoomBoard(fs, index)) { textResult(reply, capacity, RoomBoardResult::Unavailable); return true; }
if (read) {
const char* cursor = command + strlen("get room.board.read"); uint32_t id, version, offset;
if (!number(cursor, ROOM_BOARD_MAX_ARTICLES, id) || id == 0 || !number(cursor, UINT32_MAX, version)
|| version == 0 || !number(cursor, ROOM_BOARD_MAX_BODY_LENGTH, offset) || *spaces(cursor)) {
textResult(reply, capacity, RoomBoardResult::Invalid); return true;
}
const RoomBoardArticle* article = nullptr;
for (uint8_t i = 0; i < index.count; ++i) if (index.articles[i].id == id) article = &index.articles[i];
if (!article) { textResult(reply, capacity, RoomBoardResult::NotFound); return true; }
if (article->version != version) { textResult(reply, capacity, RoomBoardResult::StaleVersion); return true; }
if (offset > article->body_length) { textResult(reply, capacity, RoomBoardResult::Invalid); return true; }
const size_t remaining = article->body_length - offset;
size_t count = remaining < 80 ? remaining : 80;
int prefix_length = -1;
while (true) {
prefix_length = snprintf(reply, capacity, "id=%lu v=%lu off=%lu total=%u n=%u data=",
(unsigned long)id, (unsigned long)version, (unsigned long)offset,
unsigned(article->body_length), unsigned(count));
if (prefix_length >= 0 && size_t(prefix_length) + ((count + 2) / 3) * 4 < capacity) break;
if (count == 0) { textResult(reply, capacity, RoomBoardResult::Invalid); return true; }
--count;
}
if (count == 0 && remaining) { textResult(reply, capacity, RoomBoardResult::Invalid); return true; }
uint8_t data[80]; size_t copied = 0;
if (count) {
const RoomBoardResult result = readRoomBoardArticle(fs, uint8_t(id), version, offset, data, count, copied);
if (result != RoomBoardResult::Success) { textResult(reply, capacity, result); return true; }
}
base64(data, copied, reply + prefix_length); return true;
}
const char* cursor = spaces(command + strlen("get room.board")); uint32_t start = 0;
if ((*cursor && !number(cursor, ROOM_BOARD_MAX_ARTICLES, start)) || *spaces(cursor) || start > index.count) {
textResult(reply, capacity, RoomBoardResult::Invalid); return true;
}
// Format entries first, then the fixed prefix with the final next cursor.
char entries[2 * (ROOM_BOARD_TITLE_CAPACITY + 35)] = {};
size_t used = 0; uint32_t next = start;
while (next < index.count && next < start + 2) {
const RoomBoardArticle& article = index.articles[next];
const int written = snprintf(entries + used, sizeof(entries) - used, " %u@%lu/%u:%s",
unsigned(article.id), (unsigned long)article.version, unsigned(article.body_length), article.title);
char header[64];
const int header_length = snprintf(header, sizeof(header), "rev=%lu total=%u start=%lu next=%lu",
(unsigned long)index.revision, unsigned(index.count), (unsigned long)start, (unsigned long)(next + 1));
if (written < 0 || header_length < 0 || size_t(written) >= sizeof(entries) - used
|| size_t(header_length) + used + size_t(written) >= capacity) {
entries[used] = 0;
if (next == start) { textResult(reply, capacity, RoomBoardResult::Invalid); return true; }
break;
}
used += size_t(written); ++next;
}
const int written = snprintf(reply, capacity, "rev=%lu total=%u start=%lu next=%lu%s",
(unsigned long)index.revision, unsigned(index.count), (unsigned long)start, (unsigned long)next, entries);
if (written < 0 || size_t(written) >= capacity) textResult(reply, capacity, RoomBoardResult::Invalid);
return true;
}
} // namespace mesh