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.
This commit is contained in:
mikecarper committed 2026-10-09 23:44:00 -07:00
1 parent a2a63ea5cc
commit d031609f26
61 files changed
+6993 -91

No files matched your search

+15 -2
View File
@@ -428,13 +428,26 @@ void BaseChatMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender
}
}
} else if (flags == TXT_TYPE_SIGNED_PLAIN) {
if (canMutateContacts()) {
if (len < 9) return; // timestamp, flags and complete author prefix
const uint32_t previous_sync_since = from.sync_since;
const uint32_t previous_lastmod = from.lastmod;
const bool mutate_contact = canMutateContacts();
if (mutate_contact) {
if (sender_timestamp > from.sync_since) { // make sure 'sync_since' is up-to-date
from.sync_since = sender_timestamp;
}
from.lastmod = getRTCClock()->getCurrentTime(); // update last heard time
}
onSignedMessageRecv(from, packet, sender_timestamp, &data[5], (const char *) &data[9]); // let UI know
// The accepted callback can persist the updated live contact. Rejected
// admission must not persist it or report delivery; restore its cursor
// so a subsequent keep-alive still requests this post.
if (!onSignedMessageRecv(from, packet, sender_timestamp, &data[5], (const char *) &data[9])) {
if (mutate_contact) {
from.sync_since = previous_sync_since;
from.lastmod = previous_lastmod;
}
return;
}
uint32_t ack_hash; // calc truncated hash of the message timestamp + text + OUR pub_key, to prove to sender that we got it
mesh::Utils::sha256((uint8_t *) &ack_hash, 4, data, 9 + strlen((char *)&data[9]), self_id.pub_key, PUB_KEY_SIZE);
+3 -1
View File
@@ -171,7 +171,9 @@ protected:
virtual void onMessageRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text) = 0;
virtual void onCommandDataRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text) = 0;
virtual void onCLICommandRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text, char* reply) = 0;
virtual void onSignedMessageRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const uint8_t *sender_prefix, const char *text) = 0;
// Return true only after retaining/delivering the room post. A false result
// leaves the catch-up cursor unchanged and suppresses the delivery ACK.
virtual bool onSignedMessageRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const uint8_t *sender_prefix, const char *text) = 0;
virtual uint32_t calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis) const = 0;
virtual uint32_t calcDirectTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t path_len) const = 0;
virtual void onSendTimeout() = 0;
+5 -1
View File
@@ -715,6 +715,7 @@ void ClientACL::load(FILESYSTEM* fs, const mesh::LocalIdentity& self_id) {
c.last_timestamp = UINT32_MAX;
}
self_id.calcSharedSecret(c.shared_secret, pub_key); // recalculate shared secrets in case our private key changed
c.permissions_are_explicit = true; // this record was deliberately retained by the role's save policy
clients[num_clients++] = c;
}
file.close();
@@ -908,7 +909,9 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) {
ClientInfo* oldest = NULL;
for (int i = 0; i < num_clients; i++) {
if (id.matches(clients[i].id)) return &clients[i]; // already known
if (!clients[i].isProtectedManager() && clients[i].last_activity < min_time) {
if (!clients[i].isProtectedManager()
&& !(protect_explicit_permissions && clients[i].permissions_are_explicit)
&& clients[i].last_activity < min_time) {
oldest = &clients[i];
min_time = oldest->last_activity;
}
@@ -953,6 +956,7 @@ bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8
if (c == NULL) return false;
c->permissions = perms; // update their permissions
c->permissions_are_explicit = true;
self_id.calcSharedSecret(c->shared_secret, pubkey);
}
return true;
+5
View File
@@ -32,6 +32,7 @@ struct ClientInfo {
uint8_t out_path_len;
uint8_t out_path[MAX_PATH_SIZE];
bool out_path_is_persistable; // live route may replace, but not erase, durable route
bool permissions_are_explicit; // operator assignment; room login caches remain transient
uint8_t alt_path_len;
uint8_t alt_path[MAX_PATH_SIZE];
uint8_t observed_path_len; // reciprocal login PATH (transient)
@@ -55,6 +56,8 @@ struct ClientInfo {
uint32_t topic_seen_revision;
uint32_t pending_topic_revision;
uint8_t topic_failures;
uint16_t post_quota_used;
uint16_t poll_quota_used;
#endif
} room;
#if !MESH_CLIENT_REPEATER_ONLY
@@ -96,6 +99,7 @@ class ClientACL {
int num_clients;
bool login_replay_store_available;
bool acl_load_complete;
bool protect_explicit_permissions = false;
public:
// MAX_CLIENTS entries run to several kilobytes. Classic ESP32's link-time
@@ -117,6 +121,7 @@ public:
ClientACL(const ClientACL&) = delete;
ClientACL& operator=(const ClientACL&) = delete;
void load(FILESYSTEM* _fs, const mesh::LocalIdentity& self_id);
void protectExplicitPermissions(bool protect = true) { protect_explicit_permissions = protect; }
bool save(FILESYSTEM* _fs, bool (*filter)(ClientInfo*)=NULL);
bool clear();
+226
View File
@@ -0,0 +1,226 @@
#pragma once
#include <stdio.h>
#include "RoomAccessPolicyStore.h"
namespace mesh {
inline bool roomAclShouldPersist(uint8_t permissions, bool explicitly_assigned,
uint8_t role_mask, uint8_t admin_role) {
return explicitly_assigned || (permissions & role_mask) == (admin_role & role_mask);
}
// The two counts live beside their actual ClientInfo identity, not in a second
// table keyed by a short hash. The role owns resetting all counts when the
// policy's one shared monotonic minute expires. Re-login must not reset them.
class RoomAccessPolicy {
RoomAccessSettings settings_;
uint32_t quota_window_started_ = 0;
bool quota_window_active_ = false;
bool healthy_ = false;
int findBan(const uint8_t* key) const {
if (!key) return -1;
for (uint8_t i = 0; i < settings_.ban_count; ++i) {
if (memcmp(settings_.bans[i], key, ROOM_ACCESS_KEY_SIZE) == 0) return i;
}
return -1;
}
template <typename Filesystem>
bool commit(Filesystem* fs, const RoomAccessSettings& next) {
if (!healthy_) return false;
const auto result = room_access_detail::save(fs, next);
if (result == room_access_detail::SaveResult::Unavailable) healthy_ = false;
if (result != room_access_detail::SaveResult::Saved) return false;
if (settings_.posts_per_minute != next.posts_per_minute
|| settings_.polls_per_minute != next.polls_per_minute) quota_window_active_ = false;
settings_ = next;
return true;
}
bool consume(uint16_t& used, uint16_t limit) const {
if (!healthy_) return false;
if (limit == 0) return true;
if (used >= limit) return false;
++used;
return true;
}
static bool prefix(const char* command, const char* expected, const char*& args) {
const size_t length = strlen(expected);
if (strncmp(command, expected, length) != 0
|| (command[length] != 0 && command[length] != ' ')) return false;
args = command + length;
while (*args == ' ') ++args;
return true;
}
static bool parseUnsigned(const char* value, uint16_t& result) {
if (!value || *value < '0' || *value > '9') return false;
uint32_t number = 0;
do {
number = number * 10 + unsigned(*value++ - '0');
if (number > UINT16_MAX) return false;
} while (*value >= '0' && *value <= '9');
if (*value != 0) return false;
result = static_cast<uint16_t>(number);
return true;
}
public:
enum class BanResult : uint8_t { Saved, Unchanged, InvalidKey, Full, StorageFailure };
bool healthy() const { return healthy_; }
uint16_t postRate() const { return settings_.posts_per_minute; }
uint16_t pollRate() const { return settings_.polls_per_minute; }
uint8_t banCount() const { return settings_.ban_count; }
const uint8_t* banAt(size_t index) const {
return index < settings_.ban_count ? settings_.bans[index] : nullptr;
}
bool isBanned(const uint8_t* key) const { return findBan(key) >= 0; }
// Apply before password checks and to existing sessions too. Unknown policy
// storage cannot silently turn a banned identity into an admitted identity.
bool allowsIdentity(const uint8_t* key) const {
return healthy_ && room_access_detail::nonzeroKey(key) && !isBanned(key);
}
template <typename Filesystem>
bool load(Filesystem* fs) {
RoomAccessSettings staged;
healthy_ = room_access_detail::recover(fs, staged, false);
if (healthy_) settings_ = staged;
quota_window_active_ = false;
return healthy_;
}
template <typename Filesystem>
BanResult addBan(Filesystem* fs, const uint8_t* key) {
if (!room_access_detail::nonzeroKey(key)) return BanResult::InvalidKey;
if (!healthy_) return BanResult::StorageFailure;
if (isBanned(key)) return BanResult::Unchanged;
if (settings_.ban_count == ROOM_ACCESS_MAX_BANS) return BanResult::Full;
RoomAccessSettings next = settings_;
memcpy(next.bans[next.ban_count++], key, ROOM_ACCESS_KEY_SIZE);
return commit(fs, next) ? BanResult::Saved : BanResult::StorageFailure;
}
template <typename Filesystem>
BanResult removeBan(Filesystem* fs, const uint8_t* key) {
if (!room_access_detail::nonzeroKey(key)) return BanResult::InvalidKey;
if (!healthy_) return BanResult::StorageFailure;
const int at = findBan(key);
if (at < 0) return BanResult::Unchanged;
RoomAccessSettings next = settings_;
--next.ban_count;
if (at < next.ban_count) {
memmove(next.bans[at], next.bans[at + 1], (next.ban_count - at) * ROOM_ACCESS_KEY_SIZE);
}
memset(next.bans[next.ban_count], 0, ROOM_ACCESS_KEY_SIZE);
return commit(fs, next) ? BanResult::Saved : BanResult::StorageFailure;
}
template <typename Filesystem>
bool setRates(Filesystem* fs, uint16_t post_rate, uint16_t poll_rate) {
if (!healthy_) return false;
if (post_rate == postRate() && poll_rate == pollRate()) return true;
RoomAccessSettings next = settings_;
next.posts_per_minute = post_rate;
next.polls_per_minute = poll_rate;
return commit(fs, next);
}
template <typename ResetCounts>
void serviceQuotaWindow(uint32_t now, ResetCounts reset_counts) {
if (!quota_window_active_ || uint32_t(now - quota_window_started_) >= 60000UL) {
reset_counts();
quota_window_started_ = now;
quota_window_active_ = true;
}
}
bool consumePost(uint16_t& used) const { return consume(used, postRate()); }
bool consumeKeepAlive(uint16_t& used) const { return consume(used, pollRate()); }
static bool parseFullKey(const char* text, uint8_t (&key)[ROOM_ACCESS_KEY_SIZE]) {
if (!text || strlen(text) != ROOM_ACCESS_KEY_SIZE * 2) return false;
uint8_t parsed[ROOM_ACCESS_KEY_SIZE];
for (size_t i = 0; i < sizeof(parsed); ++i) {
uint8_t byte = 0;
for (unsigned nibble = 0; nibble < 2; ++nibble) {
const char digit = text[i * 2 + nibble];
unsigned value;
if (digit >= '0' && digit <= '9') value = digit - '0';
else if (digit >= 'a' && digit <= 'f') value = digit - 'a' + 10;
else if (digit >= 'A' && digit <= 'F') value = digit - 'A' + 10;
else return false;
byte = static_cast<uint8_t>((byte << 4) | value);
}
parsed[i] = byte;
}
if (!room_access_detail::nonzeroKey(parsed)) return false;
memcpy(key, parsed, sizeof(parsed));
return true;
}
void formatBanPage(unsigned page, char* reply, size_t capacity) const {
if (!reply || capacity == 0) return;
if (!healthy_) { snprintf(reply, capacity, "Err - room access storage unavailable"); return; }
const unsigned pages = settings_.ban_count == 0 ? 1 : (settings_.ban_count + 1) / 2;
if (page == 0 || page > pages) {
snprintf(reply, capacity, "Err - ban page range: 1-%u", pages); return;
}
if (settings_.ban_count == 0) { snprintf(reply, capacity, "Room bans: empty"); return; }
const unsigned offset = (page - 1) * 2;
const unsigned rows = settings_.ban_count - offset < 2 ? settings_.ban_count - offset : 2;
const int header = snprintf(reply, capacity, "Room bans %u/%u", page, pages);
if (header < 0 || size_t(header) >= capacity
|| rows > (capacity - size_t(header) - 1) / (ROOM_ACCESS_KEY_SIZE * 2 + 1)) {
snprintf(reply, capacity, "Err - ban reply too small"); return;
}
static const char hex_digits[] = "0123456789abcdef";
size_t used = static_cast<size_t>(header);
for (unsigned row = 0; row < rows; ++row) {
reply[used++] = '\n';
const uint8_t* key = settings_.bans[offset + row];
for (size_t i = 0; i < ROOM_ACCESS_KEY_SIZE; ++i) {
reply[used++] = hex_digits[key[i] >> 4];
reply[used++] = hex_digits[key[i] & 15];
}
}
reply[used] = 0;
}
// The role must authorize Admin/local access before invoking this backend.
template <typename Filesystem>
bool handleConfig(Filesystem* fs, const char* command, char* reply, size_t capacity) {
if (!command || !reply || capacity == 0) return false;
const char* args;
bool remove = false;
if (prefix(command, "room.ban", args) || (remove = prefix(command, "room.unban", args))) {
uint8_t key[ROOM_ACCESS_KEY_SIZE];
if (!parseFullKey(args, key)) {
snprintf(reply, capacity, "Err - use a complete 64-hex public key"); return true;
}
const BanResult result = remove ? removeBan(fs, key) : addBan(fs, key);
if (result == BanResult::Full) snprintf(reply, capacity, "Err - room ban list full (32)");
else if (result == BanResult::StorageFailure) snprintf(reply, capacity, "Err - room access save failed");
else snprintf(reply, capacity, remove ? "OK - room ban removed" : "OK - room ban saved");
return true;
}
if (prefix(command, "get room.bans", args)) {
uint16_t page = 1;
if (*args && !parseUnsigned(args, page)) snprintf(reply, capacity, "Err - usage: get room.bans [page]");
else formatBanPage(page, reply, capacity);
return true;
}
bool post = false;
if ((post = prefix(command, "get room.post.rate", args)) || prefix(command, "get room.poll.rate", args)) {
if (*args) return false;
if (!healthy_) snprintf(reply, capacity, "Err - room access storage unavailable");
else snprintf(reply, capacity, "> %u/min (0=off)", unsigned(post ? postRate() : pollRate()));
return true;
}
if ((post = prefix(command, "set room.post.rate", args)) || prefix(command, "set room.poll.rate", args)) {
uint16_t value;
if (!parseUnsigned(args, value)) snprintf(reply, capacity, "Err - rate must be 0-65535/min");
else if (!setRates(fs, post ? value : postRate(), post ? pollRate() : value))
snprintf(reply, capacity, "Err - room access save failed");
else snprintf(reply, capacity, "OK - room rate saved");
return true;
}
return false;
}
};
static_assert(sizeof(RoomAccessPolicy) <= 1040,
"Room access policy must not allocate a duplicate identity table");
} // namespace mesh
+204
View File
@@ -0,0 +1,204 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#include "FilePresence.h"
namespace mesh {
static constexpr size_t ROOM_ACCESS_KEY_SIZE = 32;
static constexpr size_t ROOM_ACCESS_MAX_BANS = 32;
static const char ROOM_ACCESS_PRIMARY_PATH[] = "/room_access";
static const char ROOM_ACCESS_TEMP_PATH[] = "/room_access.tmp";
static const char ROOM_ACCESS_BACKUP_PATH[] = "/room_access.bak";
struct RoomAccessSettings {
uint16_t posts_per_minute = 0;
uint16_t polls_per_minute = 0;
uint8_t ban_count = 0;
uint8_t bans[ROOM_ACCESS_MAX_BANS][ROOM_ACCESS_KEY_SIZE] = {};
};
static_assert(sizeof(RoomAccessSettings) <= 1032,
"Room access settings must remain bounded");
namespace room_access_detail {
static const uint8_t MAGIC[4] = {'R', 'A', 'P', 1};
static constexpr size_t HEADER_SIZE = 12;
static constexpr size_t TRAILER_SIZE = 4;
inline uint32_t crc(uint32_t value, const uint8_t* data, size_t length) {
while (length-- != 0) {
value ^= *data++;
for (uint8_t bit = 0; bit < 8; ++bit) {
value = (value >> 1) ^ ((value & 1) ? UINT32_C(0xedb88320) : 0);
}
}
return value;
}
inline bool nonzeroKey(const uint8_t* key) {
if (!key) return false;
uint8_t any = 0;
for (size_t i = 0; i < ROOM_ACCESS_KEY_SIZE; ++i) any |= key[i];
return any != 0;
}
inline bool validSettings(const RoomAccessSettings& settings) {
if (settings.ban_count > ROOM_ACCESS_MAX_BANS) return false;
for (size_t i = 0; i < settings.ban_count; ++i) {
if (!nonzeroKey(settings.bans[i])) return false;
for (size_t prior = 0; prior < i; ++prior) {
if (memcmp(settings.bans[prior], settings.bans[i], ROOM_ACCESS_KEY_SIZE) == 0) return false;
}
}
return true;
}
template <typename Filesystem>
bool removeArtifact(Filesystem* fs, const char* path) {
bool present;
if (!filePresence(fs, path, present)) return false;
if (!present) return true;
fs->remove(path);
return filePresence(fs, path, present) && !present;
}
// Decode into a staging object. Callers publish it only after full validation;
// a failed read can never replace the active policy with a partial ban list.
template <typename Filesystem>
bool readState(Filesystem* fs, const char* path, RoomAccessSettings& staged,
const RoomAccessSettings* expected = nullptr) {
#if defined(RP2040_PLATFORM)
auto file = fs->open(path, "r");
#else
auto file = fs->open(path);
#endif
if (!file) return false;
uint8_t header[HEADER_SIZE];
bool valid = !file.isDirectory()
&& static_cast<size_t>(file.read(header, sizeof(header))) == sizeof(header);
if (valid) {
const size_t count = header[8];
valid = memcmp(header, MAGIC, sizeof(MAGIC)) == 0
&& header[9] == 0 && header[10] == 0 && header[11] == 0
&& count <= ROOM_ACCESS_MAX_BANS
&& file.size() == HEADER_SIZE + count * ROOM_ACCESS_KEY_SIZE + TRAILER_SIZE;
if (valid) {
staged.posts_per_minute = uint16_t(header[4]) | (uint16_t(header[5]) << 8);
staged.polls_per_minute = uint16_t(header[6]) | (uint16_t(header[7]) << 8);
staged.ban_count = static_cast<uint8_t>(count);
memset(staged.bans, 0, sizeof(staged.bans));
}
}
uint32_t checksum = valid ? crc(UINT32_C(0xffffffff), header, sizeof(header)) : 0;
for (size_t i = 0; valid && i < staged.ban_count; ++i) {
uint8_t* key = staged.bans[i];
valid = static_cast<size_t>(file.read(key, ROOM_ACCESS_KEY_SIZE)) == ROOM_ACCESS_KEY_SIZE
&& nonzeroKey(key);
if (valid) {
checksum = crc(checksum, key, ROOM_ACCESS_KEY_SIZE);
for (size_t prior = 0; prior < i; ++prior) {
if (memcmp(staged.bans[prior], key, ROOM_ACCESS_KEY_SIZE) == 0) valid = false;
}
}
}
uint8_t trailer[TRAILER_SIZE] = {};
if (valid) {
valid = static_cast<size_t>(file.read(trailer, sizeof(trailer))) == sizeof(trailer);
const uint32_t stored = uint32_t(trailer[0]) | (uint32_t(trailer[1]) << 8)
| (uint32_t(trailer[2]) << 16) | (uint32_t(trailer[3]) << 24);
valid = valid && (checksum ^ UINT32_C(0xffffffff)) == stored;
}
file.close();
if (valid && expected) {
valid = staged.posts_per_minute == expected->posts_per_minute
&& staged.polls_per_minute == expected->polls_per_minute
&& staged.ban_count == expected->ban_count
&& memcmp(staged.bans, expected->bans,
staged.ban_count * ROOM_ACCESS_KEY_SIZE) == 0;
}
return valid;
}
// A present primary is authoritative even when corrupt/newer/unreadable.
// Restore only a verified backup when the primary is genuinely absent. A temp
// file is uncommitted and can never be promoted after reboot.
template <typename Filesystem>
bool recover(Filesystem* fs, RoomAccessSettings& staged, bool require_cleanup) {
bool present;
if (!fs || !filePresence(fs, ROOM_ACCESS_PRIMARY_PATH, present)) return false;
if (present) {
if (!readState(fs, ROOM_ACCESS_PRIMARY_PATH, staged)) return false;
const bool removed_temp = removeArtifact(fs, ROOM_ACCESS_TEMP_PATH);
const bool removed_backup = removeArtifact(fs, ROOM_ACCESS_BACKUP_PATH);
return !require_cleanup || (removed_temp && removed_backup);
}
if (!filePresence(fs, ROOM_ACCESS_BACKUP_PATH, present)) return false;
if (present) {
if (!readState(fs, ROOM_ACCESS_BACKUP_PATH, staged)
|| !fs->rename(ROOM_ACCESS_BACKUP_PATH, ROOM_ACCESS_PRIMARY_PATH)) return false;
} else {
staged = RoomAccessSettings{};
}
const bool removed_temp = removeArtifact(fs, ROOM_ACCESS_TEMP_PATH);
return !require_cleanup || removed_temp;
}
enum class SaveResult : uint8_t { Saved, Unavailable, WriteFailure };
template <typename Filesystem>
SaveResult save(Filesystem* fs, const RoomAccessSettings& next) {
if (!validSettings(next)) return SaveResult::WriteFailure;
RoomAccessSettings checked;
if (!recover(fs, checked, true)) return SaveResult::Unavailable;
uint8_t header[HEADER_SIZE] = {};
memcpy(header, MAGIC, sizeof(MAGIC));
header[4] = static_cast<uint8_t>(next.posts_per_minute);
header[5] = static_cast<uint8_t>(next.posts_per_minute >> 8);
header[6] = static_cast<uint8_t>(next.polls_per_minute);
header[7] = static_cast<uint8_t>(next.polls_per_minute >> 8);
header[8] = next.ban_count;
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
auto file = fs->open(ROOM_ACCESS_TEMP_PATH, FILE_O_WRITE);
#elif defined(ESP32_PLATFORM)
auto file = fs->open(ROOM_ACCESS_TEMP_PATH, "w", true);
#else
auto file = fs->open(ROOM_ACCESS_TEMP_PATH, "w");
#endif
if (!file) return SaveResult::WriteFailure;
bool written = !file.isDirectory()
&& file.write(header, sizeof(header)) == sizeof(header);
uint32_t checksum = crc(UINT32_C(0xffffffff), header, sizeof(header));
for (size_t i = 0; written && i < next.ban_count; ++i) {
written = file.write(next.bans[i], ROOM_ACCESS_KEY_SIZE) == ROOM_ACCESS_KEY_SIZE;
checksum = crc(checksum, next.bans[i], ROOM_ACCESS_KEY_SIZE);
}
checksum ^= UINT32_C(0xffffffff);
uint8_t trailer[TRAILER_SIZE];
for (size_t i = 0; i < sizeof(trailer); ++i) trailer[i] = static_cast<uint8_t>(checksum >> (8 * i));
written = written && file.write(trailer, sizeof(trailer)) == sizeof(trailer);
file.flush();
file.close();
if (!written || !readState(fs, ROOM_ACCESS_TEMP_PATH, checked, &next)) {
removeArtifact(fs, ROOM_ACCESS_TEMP_PATH);
return SaveResult::WriteFailure;
}
bool had_primary;
if (!filePresence(fs, ROOM_ACCESS_PRIMARY_PATH, had_primary)
|| (had_primary && !fs->rename(ROOM_ACCESS_PRIMARY_PATH, ROOM_ACCESS_BACKUP_PATH))) {
removeArtifact(fs, ROOM_ACCESS_TEMP_PATH);
return SaveResult::WriteFailure;
}
if (!fs->rename(ROOM_ACCESS_TEMP_PATH, ROOM_ACCESS_PRIMARY_PATH)) {
if (had_primary) fs->rename(ROOM_ACCESS_BACKUP_PATH, ROOM_ACCESS_PRIMARY_PATH);
removeArtifact(fs, ROOM_ACCESS_TEMP_PATH);
return SaveResult::WriteFailure;
}
// Commit occurred at rename. Failed backup housekeeping cannot reject a
// policy that is already durable, nor publish an older in-memory policy.
removeArtifact(fs, ROOM_ACCESS_BACKUP_PATH);
return SaveResult::Saved;
}
} // namespace room_access_detail
} // namespace mesh
+250
View File
@@ -0,0 +1,250 @@
#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
+403
View File
@@ -0,0 +1,403 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "FilePresence.h"
namespace mesh {
static constexpr uint8_t ROOM_BOARD_MAX_ARTICLES = 8;
static constexpr size_t ROOM_BOARD_TITLE_CAPACITY = 64;
static constexpr size_t ROOM_BOARD_MAX_BODY_LENGTH = 2048;
static constexpr size_t ROOM_BOARD_READ_MAX_CHUNK = 128;
static const char ROOM_BOARD_PRIMARY_PATH[] = "/room_board";
static const char ROOM_BOARD_TEMP_PATH[] = "/room_board.tmp";
static const char ROOM_BOARD_BACKUP_PATH[] = "/room_board.bak";
struct RoomBoardArticle {
uint8_t id = 0; // Stable slots, 1 through 8.
uint8_t bank = 0; // Internal immutable-body file selection.
uint16_t body_length = 0;
uint32_t version = 0;
char title[ROOM_BOARD_TITLE_CAPACITY] = {};
};
struct RoomBoardIndex {
uint32_t revision = 0;
uint8_t count = 0;
RoomBoardArticle articles[ROOM_BOARD_MAX_ARTICLES]; // Sorted by article ID.
};
static_assert(sizeof(RoomBoardIndex) <= 640, "Board metadata must remain bounded");
enum class RoomBoardResult : uint8_t {
Success, Invalid, Unavailable, WriteFailure, NotFound, StaleVersion
};
namespace room_board_detail {
static constexpr size_t HEADER_SIZE = 16;
static constexpr size_t ENTRY_SIZE = 72;
static constexpr size_t STREAM_CHUNK = 64;
static const uint8_t INDEX_MAGIC[4] = {'R', 'B', 'I', 1};
static const uint8_t ARTICLE_MAGIC[4] = {'R', 'B', 'A', 1};
inline uint32_t crc(uint32_t value, const uint8_t* data, size_t length) {
while (length-- != 0) {
value ^= *data++;
for (uint8_t bit = 0; bit < 8; ++bit) {
value = (value >> 1) ^ ((value & 1) ? UINT32_C(0xedb88320) : 0);
}
}
return value;
}
inline uint32_t get32(const uint8_t* bytes) {
return uint32_t(bytes[0]) | (uint32_t(bytes[1]) << 8)
| (uint32_t(bytes[2]) << 16) | (uint32_t(bytes[3]) << 24);
}
inline void put32(uint8_t* bytes, uint32_t value) {
for (uint8_t i = 0; i < 4; ++i) bytes[i] = uint8_t(value >> (8 * i));
}
inline uint16_t get16(const uint8_t* bytes) {
return uint16_t(bytes[0]) | (uint16_t(bytes[1]) << 8);
}
inline void put16(uint8_t* bytes, uint16_t value) {
bytes[0] = uint8_t(value); bytes[1] = uint8_t(value >> 8);
}
// Titles are complete UTF-8 strings; reject overlong encodings, surrogate
// code points, embedded controls, and values beyond Unicode's maximum.
inline bool validTitle(const char* title) {
if (!title) return false;
size_t length = 0;
while (length < ROOM_BOARD_TITLE_CAPACITY && title[length] != 0) ++length;
if (length == 0 || length == ROOM_BOARD_TITLE_CAPACITY) return false;
for (size_t i = 0; i < length;) {
const uint8_t first = uint8_t(title[i++]);
if (first < 0x80) {
if (first < 0x20 || first == 0x7f) return false;
continue;
}
uint8_t count;
uint32_t point, minimum;
if (first >= 0xc2 && first <= 0xdf) { count = 1; point = first & 0x1f; minimum = 0x80; }
else if (first >= 0xe0 && first <= 0xef) { count = 2; point = first & 0x0f; minimum = 0x800; }
else if (first >= 0xf0 && first <= 0xf4) { count = 3; point = first & 0x07; minimum = 0x10000; }
else return false;
if (i + count > length) return false;
while (count-- != 0) {
const uint8_t next = uint8_t(title[i++]);
if ((next & 0xc0) != 0x80) return false;
point = (point << 6) | (next & 0x3f);
}
if (point < minimum || point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff)) return false;
}
return true;
}
inline void articlePath(const RoomBoardArticle& entry, char (&path)[24]) {
snprintf(path, sizeof(path), "/room_board_%u.%c", unsigned(entry.id), entry.bank ? 'b' : 'a');
}
inline void encodeEntry(const RoomBoardArticle& entry, uint8_t (&bytes)[ENTRY_SIZE]) {
memset(bytes, 0, sizeof(bytes));
bytes[0] = entry.id; bytes[1] = entry.bank;
put16(bytes + 2, entry.body_length); put32(bytes + 4, entry.version);
memcpy(bytes + 8, entry.title, sizeof(entry.title));
}
inline void articleHeader(const RoomBoardArticle& entry, uint8_t (&bytes)[HEADER_SIZE]) {
memset(bytes, 0, sizeof(bytes));
memcpy(bytes, ARTICLE_MAGIC, sizeof(ARTICLE_MAGIC));
bytes[4] = entry.id; bytes[5] = entry.bank;
put16(bytes + 6, entry.body_length); put32(bytes + 8, entry.version);
}
template <typename Filesystem>
auto openRead(Filesystem* fs, const char* path)
#if defined(RP2040_PLATFORM)
-> decltype(fs->open(path, static_cast<const char*>(nullptr))) { return fs->open(path, "r"); }
#else
-> decltype(fs->open(path)) { return fs->open(path); }
#endif
template <typename Filesystem>
auto openWrite(Filesystem* fs, const char* path)
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
-> decltype(fs->open(path, FILE_O_WRITE)) { return fs->open(path, FILE_O_WRITE); }
#elif defined(ESP32_PLATFORM)
-> decltype(fs->open(path, static_cast<const char*>(nullptr), true)) { return fs->open(path, "w", true); }
#else
-> decltype(fs->open(path, static_cast<const char*>(nullptr))) { return fs->open(path, "w"); }
#endif
template <typename Filesystem>
bool removeArtifact(Filesystem* fs, const char* path) {
bool present;
if (!filePresence(fs, path, present)) return false;
if (!present) return true;
fs->remove(path);
return filePresence(fs, path, present) && !present;
}
// Validate the whole selected body before any caller may send the copied
// slice. The only internal body buffer is 64 bytes, including readback.
template <typename Filesystem>
bool readBody(Filesystem* fs, const RoomBoardArticle& entry,
uint8_t* output = nullptr, size_t offset = 0, size_t capacity = 0,
const uint32_t* expected_checksum = nullptr) {
char path[24]; articlePath(entry, path);
auto file = openRead(fs, path);
if (!file) return false;
uint8_t header[HEADER_SIZE], expected[HEADER_SIZE];
articleHeader(entry, expected);
bool valid = !file.isDirectory()
&& file.size() == HEADER_SIZE + entry.body_length + 4
&& static_cast<size_t>(file.read(header, sizeof(header))) == sizeof(header)
&& memcmp(header, expected, sizeof(header)) == 0;
uint32_t checksum = valid ? crc(UINT32_C(0xffffffff), header, sizeof(header)) : 0;
uint8_t chunk[STREAM_CHUNK];
for (size_t position = 0; valid && position < entry.body_length;) {
const size_t remaining = entry.body_length - position;
const size_t length = remaining < sizeof(chunk) ? remaining : sizeof(chunk);
valid = static_cast<size_t>(file.read(chunk, length)) == length;
if (!valid) break;
checksum = crc(checksum, chunk, length);
if (output && position + length > offset && position < offset + capacity) {
const size_t start = position < offset ? offset - position : 0;
size_t end = length;
if (position + end > offset + capacity) end = offset + capacity - position;
memcpy(output + position + start - offset, chunk + start, end - start);
}
position += length;
}
uint8_t trailer[4];
if (valid) {
checksum ^= UINT32_C(0xffffffff);
valid = static_cast<size_t>(file.read(trailer, sizeof(trailer))) == sizeof(trailer)
&& get32(trailer) == checksum
&& (!expected_checksum || *expected_checksum == checksum);
}
file.close();
if (!valid && output) memset(output, 0, capacity);
return valid;
}
inline bool sameIndex(const RoomBoardIndex& first, const RoomBoardIndex& second) {
if (first.revision != second.revision || first.count != second.count) return false;
for (uint8_t i = 0; i < first.count; ++i) {
uint8_t a[ENTRY_SIZE], b[ENTRY_SIZE];
encodeEntry(first.articles[i], a); encodeEntry(second.articles[i], b);
if (memcmp(a, b, sizeof(a)) != 0) return false;
}
return true;
}
template <typename Filesystem>
bool readIndex(Filesystem* fs, const char* path, RoomBoardIndex& staged,
const RoomBoardIndex* expected = nullptr) {
auto file = openRead(fs, path);
if (!file) return false;
uint8_t header[HEADER_SIZE];
bool valid = !file.isDirectory()
&& static_cast<size_t>(file.read(header, sizeof(header))) == sizeof(header);
if (valid) {
valid = memcmp(header, INDEX_MAGIC, sizeof(INDEX_MAGIC)) == 0
&& get32(header + 4) != 0 && header[8] <= ROOM_BOARD_MAX_ARTICLES
&& file.size() == HEADER_SIZE + header[8] * ENTRY_SIZE + 4;
for (size_t i = 9; i < sizeof(header); ++i) valid = valid && header[i] == 0;
}
uint32_t checksum = valid ? crc(UINT32_C(0xffffffff), header, sizeof(header)) : 0;
staged = RoomBoardIndex{};
if (valid) { staged.revision = get32(header + 4); staged.count = header[8]; }
for (uint8_t i = 0; valid && i < staged.count; ++i) {
uint8_t bytes[ENTRY_SIZE];
valid = static_cast<size_t>(file.read(bytes, sizeof(bytes))) == sizeof(bytes);
if (!valid) break;
checksum = crc(checksum, bytes, sizeof(bytes));
RoomBoardArticle& entry = staged.articles[i];
entry.id = bytes[0]; entry.bank = bytes[1]; entry.body_length = get16(bytes + 2);
entry.version = get32(bytes + 4); memcpy(entry.title, bytes + 8, sizeof(entry.title));
valid = entry.id >= 1 && entry.id <= ROOM_BOARD_MAX_ARTICLES && entry.bank <= 1
&& entry.body_length <= ROOM_BOARD_MAX_BODY_LENGTH && entry.version != 0
&& entry.version <= staged.revision && validTitle(entry.title)
&& (i == 0 || staged.articles[i - 1].id < entry.id);
bool terminated = false;
for (size_t j = 0; j < sizeof(entry.title); ++j) {
if (terminated && entry.title[j] != 0) valid = false;
if (entry.title[j] == 0) terminated = true;
}
}
uint8_t trailer[4];
if (valid) valid = static_cast<size_t>(file.read(trailer, sizeof(trailer))) == sizeof(trailer)
&& get32(trailer) == (checksum ^ UINT32_C(0xffffffff));
file.close();
if (valid && expected) valid = sameIndex(staged, *expected);
for (uint8_t i = 0; valid && i < staged.count; ++i) valid = readBody(fs, staged.articles[i]);
return valid;
}
// A present primary, including corrupt/future/unreadable metadata or bodies,
// is authoritative. Never replace it with an older backup or delete evidence.
// A temp index is uncommitted; only a verified backup may restore absence.
template <typename Filesystem>
bool recover(Filesystem* fs, RoomBoardIndex& staged, bool require_cleanup) {
bool present;
if (!fs || !filePresence(fs, ROOM_BOARD_PRIMARY_PATH, present)) return false;
if (present) {
if (!readIndex(fs, ROOM_BOARD_PRIMARY_PATH, staged)) return false;
const bool temp_removed = removeArtifact(fs, ROOM_BOARD_TEMP_PATH);
const bool backup_removed = removeArtifact(fs, ROOM_BOARD_BACKUP_PATH);
return !require_cleanup || (temp_removed && backup_removed);
}
if (!filePresence(fs, ROOM_BOARD_BACKUP_PATH, present)) return false;
if (present) {
if (!readIndex(fs, ROOM_BOARD_BACKUP_PATH, staged)
|| !fs->rename(ROOM_BOARD_BACKUP_PATH, ROOM_BOARD_PRIMARY_PATH)) return false;
} else staged = RoomBoardIndex{};
const bool temp_removed = removeArtifact(fs, ROOM_BOARD_TEMP_PATH);
return !require_cleanup || temp_removed;
}
template <typename Filesystem>
RoomBoardResult commitIndex(Filesystem* fs, const RoomBoardIndex& next) {
uint8_t header[HEADER_SIZE] = {};
memcpy(header, INDEX_MAGIC, sizeof(INDEX_MAGIC));
put32(header + 4, next.revision); header[8] = next.count;
auto file = openWrite(fs, ROOM_BOARD_TEMP_PATH);
if (!file) return RoomBoardResult::WriteFailure;
bool written = !file.isDirectory() && file.write(header, sizeof(header)) == sizeof(header);
uint32_t checksum = crc(UINT32_C(0xffffffff), header, sizeof(header));
for (uint8_t i = 0; written && i < next.count; ++i) {
uint8_t bytes[ENTRY_SIZE]; encodeEntry(next.articles[i], bytes);
written = file.write(bytes, sizeof(bytes)) == sizeof(bytes);
checksum = crc(checksum, bytes, sizeof(bytes));
}
uint8_t trailer[4]; put32(trailer, checksum ^ UINT32_C(0xffffffff));
written = written && file.write(trailer, sizeof(trailer)) == sizeof(trailer);
file.flush(); file.close();
RoomBoardIndex checked;
if (!written || !readIndex(fs, ROOM_BOARD_TEMP_PATH, checked, &next)) {
removeArtifact(fs, ROOM_BOARD_TEMP_PATH); return RoomBoardResult::WriteFailure;
}
bool had_primary;
if (!filePresence(fs, ROOM_BOARD_PRIMARY_PATH, had_primary)
|| (had_primary && !fs->rename(ROOM_BOARD_PRIMARY_PATH, ROOM_BOARD_BACKUP_PATH))) {
removeArtifact(fs, ROOM_BOARD_TEMP_PATH); return RoomBoardResult::WriteFailure;
}
if (!fs->rename(ROOM_BOARD_TEMP_PATH, ROOM_BOARD_PRIMARY_PATH)) {
if (had_primary) fs->rename(ROOM_BOARD_BACKUP_PATH, ROOM_BOARD_PRIMARY_PATH);
removeArtifact(fs, ROOM_BOARD_TEMP_PATH); return RoomBoardResult::WriteFailure;
}
// The rename is the commit point. Failed housekeeping cannot undo success.
removeArtifact(fs, ROOM_BOARD_BACKUP_PATH);
return RoomBoardResult::Success;
}
} // namespace room_board_detail
template <typename Filesystem>
bool loadRoomBoard(Filesystem* fs, RoomBoardIndex& index) {
RoomBoardIndex staged;
index = RoomBoardIndex{};
if (!room_board_detail::recover(fs, staged, false)) return false;
index = staged; return true;
}
// Main-loop single writer. The reader is called as reader(offset, buffer,
// requested_length), returning exactly requested_length, in <=64-byte pieces.
// expected_version == 0 permits an administrative create/overwrite; nonzero
// versions perform compare-and-swap and reject an article changed since load.
template <typename Filesystem, typename Reader>
RoomBoardResult saveRoomBoardArticle(Filesystem* fs, uint8_t id, const char* title,
size_t body_length, Reader reader,
uint32_t expected_version = 0) {
using namespace room_board_detail;
if (id == 0 || id > ROOM_BOARD_MAX_ARTICLES || !validTitle(title)
|| body_length > ROOM_BOARD_MAX_BODY_LENGTH) return RoomBoardResult::Invalid;
RoomBoardIndex next;
if (!recover(fs, next, true)) return RoomBoardResult::Unavailable;
uint8_t position = 0;
while (position < next.count && next.articles[position].id < id) ++position;
const bool existing = position < next.count && next.articles[position].id == id;
if (expected_version && (!existing || next.articles[position].version != expected_version)) {
return RoomBoardResult::StaleVersion;
}
if (next.revision == UINT32_MAX) return RoomBoardResult::Unavailable;
RoomBoardArticle entry;
entry.id = id; entry.bank = existing ? 1 - next.articles[position].bank : 0;
entry.version = ++next.revision; entry.body_length = uint16_t(body_length);
memcpy(entry.title, title, strlen(title));
char path[24]; articlePath(entry, path);
if (!removeArtifact(fs, path)) return RoomBoardResult::WriteFailure;
auto file = openWrite(fs, path);
if (!file) return RoomBoardResult::WriteFailure;
uint8_t header[HEADER_SIZE]; articleHeader(entry, header);
bool written = !file.isDirectory() && file.write(header, sizeof(header)) == sizeof(header);
uint32_t checksum = crc(UINT32_C(0xffffffff), header, sizeof(header));
uint8_t chunk[STREAM_CHUNK];
for (size_t offset = 0; written && offset < body_length;) {
const size_t remaining = body_length - offset;
const size_t length = remaining < sizeof(chunk) ? remaining : sizeof(chunk);
written = reader(offset, chunk, length) == length
&& file.write(chunk, length) == length;
if (written) checksum = crc(checksum, chunk, length);
offset += length;
}
checksum ^= UINT32_C(0xffffffff);
uint8_t trailer[4]; put32(trailer, checksum);
written = written && file.write(trailer, sizeof(trailer)) == sizeof(trailer);
file.flush(); file.close();
if (!written || !readBody(fs, entry, nullptr, 0, 0, &checksum)) {
removeArtifact(fs, path); return RoomBoardResult::WriteFailure;
}
if (!existing) {
for (uint8_t i = next.count; i > position; --i) next.articles[i] = next.articles[i - 1];
++next.count;
}
next.articles[position] = entry;
return commitIndex(fs, next);
}
template <typename Filesystem>
RoomBoardResult deleteRoomBoardArticle(Filesystem* fs, uint8_t id,
uint32_t expected_version = 0) {
using namespace room_board_detail;
if (id == 0 || id > ROOM_BOARD_MAX_ARTICLES) return RoomBoardResult::Invalid;
RoomBoardIndex next;
if (!recover(fs, next, true)) return RoomBoardResult::Unavailable;
uint8_t position = 0;
while (position < next.count && next.articles[position].id != id) ++position;
if (position == next.count) return RoomBoardResult::NotFound;
if (expected_version && next.articles[position].version != expected_version) return RoomBoardResult::StaleVersion;
if (next.revision == UINT32_MAX) return RoomBoardResult::Unavailable;
++next.revision;
for (uint8_t i = position; i + 1 < next.count; ++i) next.articles[i] = next.articles[i + 1];
next.articles[--next.count] = RoomBoardArticle{};
// Bodies are bounded to two banks per ID. Retaining unreferenced banks
// avoids deleting a body still needed by recovery after a power cut.
return commitIndex(fs, next);
}
// Every page must identify the exact published article version. The caller
// supplies a <=128-byte capacity; offset==body_length returns successful EOF.
template <typename Filesystem>
RoomBoardResult readRoomBoardArticle(Filesystem* fs, uint8_t id,
uint32_t expected_version, size_t offset,
uint8_t* output, size_t capacity, size_t& bytes_read) {
using namespace room_board_detail;
bytes_read = 0;
if (id == 0 || id > ROOM_BOARD_MAX_ARTICLES || expected_version == 0
|| !output || capacity == 0 || capacity > ROOM_BOARD_READ_MAX_CHUNK) return RoomBoardResult::Invalid;
RoomBoardIndex index;
if (!recover(fs, index, false)) return RoomBoardResult::Unavailable;
uint8_t position = 0;
while (position < index.count && index.articles[position].id != id) ++position;
if (position == index.count) return RoomBoardResult::NotFound;
const RoomBoardArticle& entry = index.articles[position];
if (entry.version != expected_version) return RoomBoardResult::StaleVersion;
if (offset > entry.body_length) return RoomBoardResult::Invalid;
if (!readBody(fs, entry, output, offset, capacity)) return RoomBoardResult::Unavailable;
const size_t remaining = entry.body_length - offset;
bytes_read = remaining < capacity ? remaining : capacity;
return RoomBoardResult::Success;
}
} // namespace mesh
+443
View File
@@ -0,0 +1,443 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#include "FilePresence.h"
namespace mesh {
static constexpr uint8_t ROOM_HISTORY_MAX_RECORDS = 32;
static constexpr uint8_t ROOM_HISTORY_JOURNAL_RECORDS = 64;
static constexpr size_t ROOM_HISTORY_TEXT_CAPACITY = 152;
static constexpr size_t ROOM_HISTORY_RECORD_SIZE = 192;
static constexpr size_t ROOM_HISTORY_HEADER_SIZE = 12;
static constexpr size_t ROOM_HISTORY_MAX_FILE_SIZE = ROOM_HISTORY_HEADER_SIZE
+ ROOM_HISTORY_JOURNAL_RECORDS * ROOM_HISTORY_RECORD_SIZE;
static const char ROOM_HISTORY_PATH[] = "/room_history";
static const char ROOM_HISTORY_TEMP_PATH[] = "/room_history.tmp";
static const char ROOM_HISTORY_BACKUP_PATH[] = "/room_history.bak";
static const char ROOM_HISTORY_CONFIG_PATH[] = "/room_hist_cfg";
static const char ROOM_HISTORY_CONFIG_TEMP_PATH[] = "/room_hist_cfg.tmp";
static const char ROOM_HISTORY_CONFIG_BACKUP_PATH[] = "/room_hist_cfg.bak";
struct RoomHistoryRecord {
uint8_t author[32];
uint32_t timestamp;
char text[ROOM_HISTORY_TEXT_CAPACITY];
};
struct RoomHistoryState {
uint8_t count = 0;
uint8_t total_records = 0;
bool needs_repair = false;
bool present = false;
};
namespace room_history_detail {
inline uint32_t crcBytes(const uint8_t* data, size_t length) {
uint32_t crc = UINT32_C(0xffffffff);
for (size_t index = 0; index < length; ++index) {
crc ^= data[index];
for (uint8_t bit = 0; bit < 8; ++bit) {
crc = (crc >> 1) ^ ((crc & 1) ? UINT32_C(0xedb88320) : 0);
}
}
return crc ^ UINT32_C(0xffffffff);
}
inline void put32(uint8_t* data, uint32_t value) {
for (uint8_t index = 0; index < 4; ++index) data[index] = value >> (8 * index);
}
inline uint32_t get32(const uint8_t* data) {
uint32_t value = 0;
for (uint8_t index = 0; index < 4; ++index) value |= uint32_t(data[index]) << (8 * index);
return value;
}
template <typename Filesystem>
bool presence(Filesystem* fs, const char* path, bool& present) {
return filePresence(fs, path, present);
}
template <typename Filesystem>
bool removeArtifact(Filesystem* fs, const char* path) {
bool present;
if (!presence(fs, path, present)) return false;
if (!present) return true;
fs->remove(path);
return presence(fs, path, present) && !present;
}
template <typename Filesystem>
auto openRead(Filesystem* fs, const char* path)
#if defined(RP2040_PLATFORM)
-> decltype(fs->open(path, static_cast<const char*>(nullptr))) { return fs->open(path, "r"); }
#else
-> decltype(fs->open(path)) { return fs->open(path); }
#endif
template <typename Filesystem>
auto openWrite(Filesystem* fs, const char* path, bool append)
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
-> decltype(fs->open(path, FILE_O_WRITE)) {
(void)append;
return fs->open(path, FILE_O_WRITE); // verified temp absence makes this a fresh file
}
#elif defined(ESP32_PLATFORM)
-> decltype(fs->open(path, static_cast<const char*>(nullptr), true)) { return fs->open(path, append ? "a" : "w", true); }
#else
-> decltype(fs->open(path, static_cast<const char*>(nullptr))) { return fs->open(path, append ? "a" : "w"); }
#endif
inline void makeHeader(uint8_t (&image)[ROOM_HISTORY_HEADER_SIZE]) {
const uint8_t prefix[] = { 'R', 'H', 'J', 1, 192, 0,
ROOM_HISTORY_MAX_RECORDS, ROOM_HISTORY_JOURNAL_RECORDS };
memcpy(image, prefix, sizeof(prefix));
put32(image + 8, crcBytes(image, 8));
}
template <typename File>
bool readHeader(File& file) {
uint8_t actual[ROOM_HISTORY_HEADER_SIZE], expected[ROOM_HISTORY_HEADER_SIZE];
makeHeader(expected);
return static_cast<size_t>(file.read(actual, sizeof(actual))) == sizeof(actual)
&& memcmp(actual, expected, sizeof(actual)) == 0;
}
inline bool encodeRecord(const RoomHistoryRecord& record,
uint8_t (&image)[ROOM_HISTORY_RECORD_SIZE]) {
size_t length = 0;
while (length < ROOM_HISTORY_TEXT_CAPACITY && record.text[length] != 0) ++length;
bool author_nonzero = false;
for (uint8_t byte : record.author) author_nonzero |= byte != 0;
if (length == ROOM_HISTORY_TEXT_CAPACITY || record.timestamp == 0 || !author_nonzero) return false;
memset(image, 0, sizeof(image));
memcpy(image, record.author, sizeof(record.author));
put32(image + 32, record.timestamp);
memcpy(image + 36, record.text, length);
put32(image + 188, crcBytes(image, 188));
return true;
}
enum class RecordRead { Valid, Corrupt, Unreadable };
// An incomplete physical tail is detected from file size by scanJournal. A
// short read inside a physically present record is an I/O failure, not proof
// that the record is corrupt and may safely be discarded.
template <typename File>
RecordRead readRecord(File& file, RoomHistoryRecord* record = nullptr,
const uint8_t* expected = nullptr) {
uint8_t image[ROOM_HISTORY_RECORD_SIZE];
if (static_cast<size_t>(file.read(image, sizeof(image))) != sizeof(image)) return RecordRead::Unreadable;
bool author_nonzero = false;
for (size_t index = 0; index < 32; ++index) author_nonzero |= image[index] != 0;
bool terminated = false;
for (size_t index = 36; index < 188; ++index) {
if (terminated && image[index] != 0) return RecordRead::Corrupt;
if (image[index] == 0) terminated = true;
}
if (!author_nonzero || get32(image + 32) == 0 || !terminated
|| get32(image + 188) != crcBytes(image, 188)) return RecordRead::Corrupt;
if (expected && memcmp(image, expected, sizeof(image)) != 0) return RecordRead::Corrupt;
if (record) {
memcpy(record->author, image, sizeof(record->author));
record->timestamp = get32(image + 32);
memcpy(record->text, image + 36, sizeof(record->text));
}
return RecordRead::Valid;
}
template <typename Filesystem>
bool scanJournal(Filesystem* fs, const char* path, RoomHistoryState& result) {
auto file = openRead(fs, path);
if (!file) return false;
const size_t size = file.size();
bool valid = !file.isDirectory() && size >= ROOM_HISTORY_HEADER_SIZE
&& size <= ROOM_HISTORY_MAX_FILE_SIZE && readHeader(file);
RoomHistoryState staged;
staged.present = true;
const size_t physical_records = size >= ROOM_HISTORY_HEADER_SIZE
? (size - ROOM_HISTORY_HEADER_SIZE) / ROOM_HISTORY_RECORD_SIZE : 0;
for (size_t index = 0; valid && index < physical_records; ++index) {
const RecordRead read = readRecord(file);
if (read == RecordRead::Unreadable) valid = false;
else if (read == RecordRead::Corrupt) {
// A failed append can leave only the final physical record corrupt.
// Later complete records prove this is older storage corruption: keep
// the entire primary and do not publish a prefix for automatic repair.
valid = index + 1 == physical_records;
break;
}
else ++staged.total_records;
}
staged.count = staged.total_records > ROOM_HISTORY_MAX_RECORDS
? ROOM_HISTORY_MAX_RECORDS : staged.total_records;
staged.needs_repair = size != ROOM_HISTORY_HEADER_SIZE
+ staged.total_records * ROOM_HISTORY_RECORD_SIZE;
file.close();
if (valid) result = staged;
return valid;
}
template <typename Filesystem>
bool readConfig(Filesystem* fs, const char* path, bool& enabled) {
auto file = openRead(fs, path);
if (!file) return false;
uint8_t image[16];
bool valid = !file.isDirectory() && file.size() == sizeof(image)
&& static_cast<size_t>(file.read(image, sizeof(image))) == sizeof(image);
file.close();
const uint8_t magic[] = { 'R', 'H', 'C', 1 };
valid = valid && memcmp(image, magic, sizeof(magic)) == 0 && image[4] <= 1
&& get32(image + 12) == crcBytes(image, 12);
for (size_t index = 5; valid && index < 12; ++index) valid = image[index] == 0;
if (valid) enabled = image[4] != 0;
return valid;
}
// Primary authority is preserved even when corrupt or from a future format.
// Only an absent primary permits restoring backup; temp is never promoted.
template <typename Filesystem, typename Validator>
bool recover(Filesystem* fs, const char* primary, const char* temp,
const char* backup, Validator validate, bool strict, bool& found) {
if (!presence(fs, primary, found)) return false;
if (found) {
if (!validate(primary)) return false;
const bool cleaned_temp = removeArtifact(fs, temp);
const bool cleaned_backup = removeArtifact(fs, backup);
return !strict || (cleaned_temp && cleaned_backup);
}
bool had_backup;
if (!presence(fs, backup, had_backup)) return false;
if (had_backup) {
if (!validate(backup) || !fs->rename(backup, primary)) return false;
found = true;
}
const bool cleaned_temp = removeArtifact(fs, temp);
return !strict || cleaned_temp;
}
template <typename Filesystem>
bool publish(Filesystem* fs, const char* primary, const char* temp, const char* backup) {
bool had_primary;
if (!presence(fs, primary, had_primary)
|| (had_primary && !fs->rename(primary, backup))) {
removeArtifact(fs, temp);
return false;
}
if (!fs->rename(temp, primary)) {
if (had_primary) fs->rename(backup, primary);
removeArtifact(fs, temp);
return false;
}
removeArtifact(fs, backup); // post-commit cleanup cannot turn success into failure
return true;
}
struct EmptyProvider {
bool operator()(uint8_t, RoomHistoryRecord&) const { return false; }
};
} // namespace room_history_detail
template <typename Filesystem>
bool loadRoomHistoryConfig(Filesystem* fs, bool& enabled) {
if (!fs) return false;
bool found, staged = false;
const auto validate = [&](const char* path) { return room_history_detail::readConfig(fs, path, staged); };
if (!room_history_detail::recover(fs, ROOM_HISTORY_CONFIG_PATH,
ROOM_HISTORY_CONFIG_TEMP_PATH, ROOM_HISTORY_CONFIG_BACKUP_PATH,
validate, false, found)) return false;
enabled = staged;
return true;
}
template <typename Filesystem>
bool saveRoomHistoryConfig(Filesystem* fs, bool enabled) {
if (!fs) return false;
bool found, previous = false;
const auto validate = [&](const char* path) { return room_history_detail::readConfig(fs, path, previous); };
if (!room_history_detail::recover(fs, ROOM_HISTORY_CONFIG_PATH,
ROOM_HISTORY_CONFIG_TEMP_PATH, ROOM_HISTORY_CONFIG_BACKUP_PATH,
validate, true, found)) return false;
uint8_t image[16] = { 'R', 'H', 'C', 1, uint8_t(enabled ? 1 : 0) };
room_history_detail::put32(image + 12, room_history_detail::crcBytes(image, 12));
auto file = room_history_detail::openWrite(fs, ROOM_HISTORY_CONFIG_TEMP_PATH, false);
if (!file) return false;
if (file.isDirectory()) {
file.close();
room_history_detail::removeArtifact(fs, ROOM_HISTORY_CONFIG_TEMP_PATH);
return false;
}
const bool written = file.write(image, sizeof(image)) == sizeof(image);
file.flush(); file.close();
bool readback = !enabled;
if (!written || !room_history_detail::readConfig(fs, ROOM_HISTORY_CONFIG_TEMP_PATH, readback)
|| readback != enabled) {
room_history_detail::removeArtifact(fs, ROOM_HISTORY_CONFIG_TEMP_PATH);
return false;
}
return room_history_detail::publish(fs, ROOM_HISTORY_CONFIG_PATH,
ROOM_HISTORY_CONFIG_TEMP_PATH, ROOM_HISTORY_CONFIG_BACKUP_PATH);
}
// Validate the complete readable prefix before calling the consumer. The
// consumer receives only the newest 32 records, oldest first. A later read or
// consumer failure leaves state unchanged; boot callers must clear any partial
// destination ring. No live ring reload is needed for ordinary appends.
template <typename Filesystem, typename Consumer>
bool loadRoomHistory(Filesystem* fs, RoomHistoryState& state, Consumer consume) {
if (!fs) return false;
RoomHistoryState staged;
bool found;
const auto validate = [&](const char* path) { return room_history_detail::scanJournal(fs, path, staged); };
if (!room_history_detail::recover(fs, ROOM_HISTORY_PATH, ROOM_HISTORY_TEMP_PATH,
ROOM_HISTORY_BACKUP_PATH, validate, false, found)) return false;
if (found && staged.count != 0) {
auto file = room_history_detail::openRead(fs, ROOM_HISTORY_PATH);
if (!file) return false;
bool valid = !file.isDirectory() && room_history_detail::readHeader(file)
&& file.seek(ROOM_HISTORY_HEADER_SIZE
+ (staged.total_records - staged.count) * ROOM_HISTORY_RECORD_SIZE);
RoomHistoryRecord record;
for (uint8_t index = 0; valid && index < staged.count; ++index) {
valid = room_history_detail::readRecord(file, &record) == room_history_detail::RecordRead::Valid
&& consume(index, record);
}
file.close();
if (!valid) return false;
}
state = staged;
return true;
}
// Provider(index, record) supplies an immutable, oldest-first RAM snapshot.
// One record at a time is rendered and read back; there is no second 6 KB ring.
// Used to enable history, compact 64 journal entries, repair a failed tail, or
// clear with count zero. State changes only after atomic publication succeeds.
template <typename Filesystem, typename Provider>
bool saveRoomHistorySnapshot(Filesystem* fs, RoomHistoryState& state,
uint8_t count, Provider provide) {
if (!fs || count > ROOM_HISTORY_MAX_RECORDS) return false;
RoomHistoryState old;
bool found;
const auto validate = [&](const char* path) { return room_history_detail::scanJournal(fs, path, old); };
if (!room_history_detail::recover(fs, ROOM_HISTORY_PATH, ROOM_HISTORY_TEMP_PATH,
ROOM_HISTORY_BACKUP_PATH, validate, true, found)) return false;
auto file = room_history_detail::openWrite(fs, ROOM_HISTORY_TEMP_PATH, false);
if (!file) return false;
if (file.isDirectory()) {
file.close();
room_history_detail::removeArtifact(fs, ROOM_HISTORY_TEMP_PATH);
return false;
}
uint8_t header[ROOM_HISTORY_HEADER_SIZE]; room_history_detail::makeHeader(header);
bool valid = file.write(header, sizeof(header)) == sizeof(header);
RoomHistoryRecord record;
uint8_t image[ROOM_HISTORY_RECORD_SIZE];
for (uint8_t index = 0; valid && index < count; ++index) {
valid = provide(index, record) && room_history_detail::encodeRecord(record, image)
&& file.write(image, sizeof(image)) == sizeof(image);
}
file.flush(); file.close();
if (valid) {
auto verify = room_history_detail::openRead(fs, ROOM_HISTORY_TEMP_PATH);
if (!verify) valid = false;
else {
valid = !verify.isDirectory()
&& verify.size() == ROOM_HISTORY_HEADER_SIZE + count * ROOM_HISTORY_RECORD_SIZE
&& room_history_detail::readHeader(verify);
for (uint8_t index = 0; valid && index < count; ++index) {
valid = provide(index, record) && room_history_detail::encodeRecord(record, image)
&& room_history_detail::readRecord(verify, nullptr, image) == room_history_detail::RecordRead::Valid;
}
verify.close();
}
}
if (!valid) {
room_history_detail::removeArtifact(fs, ROOM_HISTORY_TEMP_PATH);
return false;
}
if (!room_history_detail::publish(fs, ROOM_HISTORY_PATH, ROOM_HISTORY_TEMP_PATH,
ROOM_HISTORY_BACKUP_PATH)) return false;
state.count = state.total_records = count;
state.needs_repair = false;
state.present = true;
return true;
}
template <typename Filesystem>
bool clearRoomHistory(Filesystem* fs, RoomHistoryState& state) {
return saveRoomHistorySnapshot(fs, state, 0, room_history_detail::EmptyProvider{});
}
// Single-writer main-loop use. A failed append can leave an uncommitted tail;
// mark it for snapshot repair from the unchanged trusted live ring before a
// retry. Never ACK or update the message replay cache when this returns false.
template <typename Filesystem>
bool appendRoomHistory(Filesystem* fs, RoomHistoryState& state,
const uint8_t* author, uint32_t timestamp, const char* text) {
if (!fs || !author || !text || state.needs_repair
|| state.total_records >= ROOM_HISTORY_JOURNAL_RECORDS) return false;
RoomHistoryRecord record;
memcpy(record.author, author, sizeof(record.author));
record.timestamp = timestamp;
size_t length = 0;
while (length < ROOM_HISTORY_TEXT_CAPACITY && text[length] != 0) ++length;
if (length == ROOM_HISTORY_TEXT_CAPACITY) return false;
memset(record.text, 0, sizeof(record.text)); memcpy(record.text, text, length);
uint8_t image[ROOM_HISTORY_RECORD_SIZE];
if (!room_history_detail::encodeRecord(record, image)) return false;
RoomHistoryState disk;
bool found;
const auto validate = [&](const char* path) { return room_history_detail::scanJournal(fs, path, disk); };
if (!room_history_detail::recover(fs, ROOM_HISTORY_PATH, ROOM_HISTORY_TEMP_PATH,
ROOM_HISTORY_BACKUP_PATH, validate, true, found)) {
state.needs_repair = true;
return false;
}
if (!state.present) {
// A caller must load an existing journal before appending. In particular,
// never turn a missed/failed load into an implicit clear of accepted posts.
if (found || state.count != 0 || state.total_records != 0) {
state.needs_repair = true;
return false;
}
if (!clearRoomHistory(fs, state)) return false;
disk = state;
found = true;
}
if (!found || disk.needs_repair || disk.total_records != state.total_records
|| disk.count != state.count) {
state.needs_repair = true;
return false;
}
const size_t expected_size = ROOM_HISTORY_HEADER_SIZE
+ state.total_records * ROOM_HISTORY_RECORD_SIZE;
auto file = room_history_detail::openWrite(fs, ROOM_HISTORY_PATH, true);
if (!file) { state.needs_repair = true; return false; }
if (file.isDirectory()) { file.close(); state.needs_repair = true; return false; }
const bool written = file.size() == expected_size
&& file.write(image, sizeof(image)) == sizeof(image);
file.flush(); file.close();
bool verified = false;
if (written) {
auto verify = room_history_detail::openRead(fs, ROOM_HISTORY_PATH);
if (verify) {
verified = !verify.isDirectory()
&& verify.size() == expected_size + ROOM_HISTORY_RECORD_SIZE
&& room_history_detail::readHeader(verify) && verify.seek(expected_size)
&& room_history_detail::readRecord(verify, nullptr, image) == room_history_detail::RecordRead::Valid;
verify.close();
}
}
if (!verified) { state.needs_repair = true; return false; }
++state.total_records;
state.count = state.total_records > ROOM_HISTORY_MAX_RECORDS ? ROOM_HISTORY_MAX_RECORDS : state.total_records;
return true;
}
} // namespace mesh
-7
View File
@@ -3,9 +3,7 @@
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#if defined(ESP32_PLATFORM)
#include "FilePresence.h"
#endif
namespace mesh {
@@ -30,12 +28,7 @@ inline uint32_t crcByte(uint32_t crc, uint8_t value) {
template <typename Filesystem>
bool presence(Filesystem* fs, const char* path, bool& present) {
#if defined(ESP32_PLATFORM)
return filePresence(fs, path, present);
#else
present = fs->exists(path);
return true;
#endif
}
template <typename Filesystem>
+112
View File
@@ -0,0 +1,112 @@
#pragma once
#include <ArduinoJson.h>
#include <stdint.h>
#include <stddef.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
namespace mesh {
// JsonDocument v7 is dynamically allocated. Bound actual allocations rather
// than relying on the deprecated StaticJsonDocument capacity shim.
class RoomJsonAllocator : public ArduinoJson::Allocator {
union Header { max_align_t alignment; size_t bytes; };
size_t used_ = 0;
public:
static constexpr size_t BUDGET = 8192;
void* allocate(size_t bytes) override {
if (bytes > BUDGET - used_ || sizeof(Header) > BUDGET - used_ - bytes) return nullptr;
auto* header = static_cast<Header*>(malloc(sizeof(Header) + bytes));
if (!header) return nullptr;
header->bytes = bytes; used_ += sizeof(Header) + bytes; return header + 1;
}
void deallocate(void* ptr) override {
if (!ptr) return;
auto* header = static_cast<Header*>(ptr) - 1;
used_ -= sizeof(Header) + header->bytes; free(header);
}
void* reallocate(void* ptr, size_t bytes) override {
if (!ptr) return allocate(bytes);
auto* header = static_cast<Header*>(ptr) - 1;
const size_t remaining = used_ - sizeof(Header) - header->bytes;
if (bytes > BUDGET - remaining || sizeof(Header) > BUDGET - remaining - bytes) return nullptr;
header = static_cast<Header*>(realloc(header, sizeof(Header) + bytes));
if (!header) return nullptr;
header->bytes = bytes; used_ = remaining + sizeof(Header) + bytes; return header + 1;
}
};
// Replies use their fixed mailbox buffer, with no second JSON heap pool.
class RoomJsonWriter {
char* output_;
size_t capacity_, used_ = 0;
bool valid_ = true;
public:
RoomJsonWriter(char* output, size_t capacity) : output_(output), capacity_(capacity) {
if (!output || !capacity) valid_ = false;
else output[0] = 0;
}
void raw(const char* text) {
const size_t length = strlen(text);
if (!valid_ || length >= capacity_ - used_) { valid_ = false; return; }
memcpy(output_ + used_, text, length + 1); used_ += length;
}
void number(uint32_t value) { char text[12]; snprintf(text, sizeof(text), "%lu", (unsigned long)value); raw(text); }
void string(const char* text) {
raw("\"");
for (const auto* p = reinterpret_cast<const uint8_t*>(text); valid_ && *p; ++p) {
char escaped[7] = {};
if (*p == '"' || *p == '\\') { escaped[0] = '\\'; escaped[1] = *p; }
else if (*p < 0x20) snprintf(escaped, sizeof(escaped), "\\u%04x", unsigned(*p));
else escaped[0] = *p;
raw(escaped);
}
raw("\"");
}
bool finish() {
if (!valid_ && output_ && capacity_ >= 29) strcpy(output_, "{\"error\":\"reply too large\"}");
return valid_;
}
};
inline void roomEncodeBase64(const uint8_t* bytes, size_t length, char* output) {
static const char alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
size_t used = 0;
for (size_t i = 0; i < length; i += 3) {
const uint32_t value = (uint32_t(bytes[i]) << 16)
| (i + 1 < length ? uint32_t(bytes[i + 1]) << 8 : 0)
| (i + 2 < length ? bytes[i + 2] : 0);
output[used++] = alphabet[(value >> 18) & 63]; output[used++] = alphabet[(value >> 12) & 63];
output[used++] = i + 1 < length ? alphabet[(value >> 6) & 63] : '=';
output[used++] = i + 2 < length ? alphabet[value & 63] : '=';
}
output[used] = 0;
}
inline bool roomDecodeBase64(const char* text, uint8_t* bytes, size_t capacity, size_t& length) {
length = 0;
const size_t encoded = strlen(text);
if (encoded % 4 != 0 || encoded > ((capacity + 2) / 3) * 4) return false;
auto digit = [](char c) -> int {
if (c >= 'A' && c <= 'Z') return c - 'A'; if (c >= 'a' && c <= 'z') return c - 'a' + 26;
if (c >= '0' && c <= '9') return c - '0' + 52; if (c == '+') return 62; if (c == '/') return 63;
return -1;
};
for (size_t i = 0; i < encoded; i += 4) {
int values[4];
for (size_t j = 0; j < 4; ++j) values[j] = text[i + j] == '=' ? 0 : digit(text[i + j]);
if (values[0] < 0 || values[1] < 0 || values[2] < 0 || values[3] < 0
|| text[i] == '=' || text[i + 1] == '='
|| (text[i + 2] == '=' && text[i + 3] != '=')
|| (i + 4 != encoded && (text[i + 2] == '=' || text[i + 3] == '='))) return false;
const size_t n = text[i + 2] == '=' ? 1 : (text[i + 3] == '=' ? 2 : 3);
if (length + n > capacity || (n == 1 && (values[1] & 15)) || (n == 2 && (values[2] & 3))) return false;
const uint32_t value = (uint32_t(values[0]) << 18) | (uint32_t(values[1]) << 12)
| (uint32_t(values[2]) << 6) | uint32_t(values[3]);
for (size_t j = 0; j < n; ++j) bytes[length++] = uint8_t(value >> (16 - 8 * j));
}
return true;
}
} // namespace mesh
+111
View File
@@ -0,0 +1,111 @@
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <string.h>
namespace mesh {
// One loop-owned operation and eight bounded browser sequence floors. Caller
// serializes submit/read/begin/finish with its mutex; processing runs outside
// that mutex. Running input and output cannot be recycled by an HTTP handler.
// Sequence floors expire after twenty minutes and on reboot. Clients never
// retry a write after that window or across a changed boot identifier.
class RoomWebMailbox {
public:
static constexpr size_t INPUT_CAPACITY = 4097;
static constexpr size_t OUTPUT_CAPACITY = 2048;
static constexpr uint32_t OWNER_TTL_MS = 1200000;
static constexpr uint32_t RESULT_TTL_MS = 30000;
enum class Result { Accepted, Busy, Invalid, Completed, Mismatch };
enum class State { Idle, Pending, Running, Done };
private:
struct Owner { uint8_t token[32]; uint32_t sequence = 0, seen = 0; } owners_[8] = {};
uint8_t token_[32] = {}, digest_[32] = {};
uint32_t sequence_ = 0, finished_ = 0;
State state_ = State::Idle;
bool delivered_ = false;
bool owner_was_new_ = false;
char input_[INPUT_CAPACITY] = {}, output_[OUTPUT_CAPACITY] = {};
public:
static bool decodeToken(const char* text, uint8_t (&out)[32]) {
if (!text || strlen(text) != 64) return false;
uint8_t parsed[32], nonzero = 0;
for (size_t i = 0; i < 32; ++i) {
unsigned byte = 0;
for (size_t n = 0; n < 2; ++n) {
char c = text[i * 2 + n];
unsigned value;
if (c >= '0' && c <= '9') value = c - '0';
else if (c >= 'a' && c <= 'f') value = c - 'a' + 10;
else if (c >= 'A' && c <= 'F') value = c - 'A' + 10;
else return false;
byte = (byte << 4) | value;
}
parsed[i] = uint8_t(byte); nonzero |= parsed[i];
}
if (!nonzero) return false;
memcpy(out, parsed, sizeof(parsed)); return true;
}
State state() const { return state_; }
uint32_t sequence() const { return sequence_; }
Result submit(const uint8_t (&token)[32], uint32_t sequence,
const uint8_t (&digest)[32], const char* body, size_t length, uint32_t now) {
if (!body || !sequence || !length || length >= INPUT_CAPACITY
|| memchr(body, 0, length)) return Result::Invalid;
if (state_ != State::Idle && sequence == sequence_ && memcmp(token_, token, 32) == 0) {
return memcmp(digest_, digest, 32) == 0 ? Result::Accepted : Result::Mismatch;
}
Owner* owner = nullptr;
Owner* empty = nullptr;
for (auto& entry : owners_) {
if (entry.sequence && uint32_t(now - entry.seen) < OWNER_TTL_MS) {
if (memcmp(entry.token, token, 32) == 0) owner = &entry;
} else if (!empty) empty = &entry;
}
if (owner && sequence <= owner->sequence) return Result::Completed;
if (state_ == State::Pending || state_ == State::Running
|| (state_ == State::Done && !delivered_
&& uint32_t(now - finished_) < RESULT_TTL_MS)) return Result::Busy;
owner_was_new_ = !owner;
if (!owner) owner = empty;
if (!owner) return Result::Busy;
memcpy(owner->token, token, 32); owner->sequence = sequence; owner->seen = now;
memcpy(token_, token, 32); memcpy(digest_, digest, 32);
memcpy(input_, body, length); input_[length] = 0; output_[0] = 0;
sequence_ = sequence; delivered_ = false; state_ = State::Pending;
return Result::Accepted;
}
bool begin() {
if (state_ != State::Pending) return false;
state_ = State::Running; return true;
}
char* input() { return input_; }
char* output() { return output_; }
const uint8_t* token() const { return token_; }
void finish(uint32_t now, bool authenticated = true) {
if (state_ != State::Running) return;
memset(input_, 0, sizeof(input_)); // passwords and article data have served their purpose
output_[OUTPUT_CAPACITY - 1] = 0;
finished_ = now; state_ = State::Done;
// Unauthenticated traffic must not occupy all eight sequence-floor slots
// for twenty minutes. Existing authenticated floors are never erased.
if (!authenticated && owner_was_new_) {
for (auto& owner : owners_) if (owner.sequence == sequence_ && memcmp(owner.token, token_, 32) == 0) owner = Owner{};
}
}
// Output must be copied while caller holds the mutex. Wrong owners never
// learn whether another user's operation is pending or completed.
const char* read(const uint8_t (&token)[32], uint32_t sequence, bool& pending) {
pending = false;
if (state_ == State::Idle || sequence_ != sequence || memcmp(token_, token, 32) != 0) return nullptr;
if (state_ != State::Done) { pending = true; return nullptr; }
return output_;
}
void delivered(const uint8_t (&token)[32], uint32_t sequence) {
if (state_ == State::Done && sequence_ == sequence && memcmp(token_, token, 32) == 0) delivered_ = true;
}
};
static_assert(sizeof(RoomWebMailbox) <= 6656, "Update the room WebConfig heap budget");
} // namespace mesh
+206
View File
@@ -0,0 +1,206 @@
#pragma once
#include <Arduino.h>
// Offline browser room client, served by the existing WebConfig listener.
// No radio private key is imported; a separate random browser token identifies
// posts and moderation. Keep untrusted room text out of HTML interpolation.
static const char ROOM_WEB_HTML[] PROGMEM = R"roomhtml(<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>MeshCore Room</title><style>
:root{color-scheme:dark}body{font:16px system-ui;background:#111820;color:#eee;max-width:720px;margin:auto;padding:20px}
input,textarea,button,select{font:inherit;border:1px solid #6b7786;border-radius:6px;padding:9px;background:#202b38;color:inherit}
input,textarea{box-sizing:border-box;width:100%;margin:5px 0 14px}textarea{min-height:85px}button{cursor:pointer;margin:4px 6px 4px 0}
button:disabled{opacity:.45;cursor:default}label{display:block}.muted{color:#b5beca}#error{color:#ffb9b9;white-space:pre-wrap}
#topic,#article,.post{white-space:pre-wrap;overflow-wrap:anywhere}#article{border:1px solid #6b7786;padding:14px}
.post{padding:10px 0;border-bottom:1px solid #364351}.post small{display:block;color:#b5beca}
details{margin:18px 0}summary{cursor:pointer}code{overflow-wrap:anywhere}a{color:#a8d5ff}[hidden]{display:none!important}
</style></head><body>
<h1 id="room-name">MeshCore Room</h1>
<p class="muted">Chat and selected notices over local Wi-Fi. This page also works without internet access.</p>
<form id="join"><label for="password">Room password (leave empty for public read-only access)</label>
<input id="password" type="password" autocomplete="current-password" maxlength="64">
<label for="name">Your display name</label><input id="name" autocomplete="nickname" value="Web">
<button type="submit">Connect</button></form>
<p id="error" role="alert"></p><p id="state" class="muted" role="status"></p>
<section id="room" hidden><p id="topic"></p>
<details><summary>Access and history</summary><p id="history"></p>
<p>Your browser identity for room moderation: <code id="web-key"></code></p>
<p class="muted">This browser identity is separate from a radio's public key. The password stays in memory for this session.</p></details>
<button id="refresh" type="button">Refresh chat</button><div id="posts" aria-live="polite"></div>
<form id="compose"><label for="message">Message</label><textarea id="message"></textarea>
<p id="budget" class="muted"></p><button id="send" type="submit">Send</button></form>
<h2>Information board</h2><button id="refresh-board" type="button">Refresh notices</button>
<div id="notices"></div><h3 id="article-title" hidden></h3><pre id="article" hidden></pre>
<section id="editor" hidden><h3>Publish or edit a notice</h3><form id="publish">
<label for="article-id">Article slot (1–8)</label><select id="article-id">
<option>1</option><option>2</option><option>3</option><option>4</option><option>5</option><option>6</option><option>7</option><option>8</option></select>
<label for="title">Title (up to 63 UTF-8 bytes)</label><input id="title">
<label for="body">Article (up to 2,048 UTF-8 bytes)</label><textarea id="body"></textarea>
<button type="submit">Save notice</button><button id="delete" type="button">Delete selected notice</button></form></section>
</section><p><a href="/">Radio configuration</a></p>
<script>
'use strict';
const $=id=>document.getElementById(id), encoder=new TextEncoder();
let token='', sequence=0, password='', boot=0, role=0, after=0, joined=false, polling=false;
let chain=Promise.resolve(), notices=new Map(), transfers=new Map();
let sending=false, editId=1, editVersion=0;
function saved(key,fallback){try{return localStorage.getItem(key)||fallback}catch(e){return fallback}}
function remember(key,value){try{localStorage.setItem(key,String(value))}catch(e){}}
function makeIdentity(){
token=saved('mc-room-token','');
if(!/^[0-9a-f]{64}$/.test(token)||/^0+$/.test(token)){
const bytes=new Uint8Array(32);crypto.getRandomValues(bytes);
token=Array.from(bytes,b=>b.toString(16).padStart(2,'0')).join('');remember('mc-room-token',token);
}
sequence=Number(saved('mc-room-sequence','0'));
if(!Number.isSafeInteger(sequence)||sequence<0||sequence>=4294967295) throw Error('Browser sequence exhausted. Clear this site’s stored room identity to reconnect.');
}
function message(error){$('error').textContent=error?String(error.message||error):''}
const pause=ms=>new Promise(resolve=>setTimeout(resolve,ms));
async function fetchBounded(url,options){
const controller=new AbortController(), timer=setTimeout(()=>controller.abort(),5000);
try{return await fetch(url,Object.assign({},options,{signal:controller.signal,cache:'no-store'}))}
finally{clearTimeout(timer)}
}
function api(op,values={}){
const intent=Object.assign({},values,{boot:boot});
const run=()=>request(op,intent), result=chain.then(run,run);
chain=result.catch(()=>{});return result;
}
async function request(op,values){
if(++sequence>4294967295)throw Error('Browser sequence exhausted.');
remember('mc-room-sequence',sequence);
const headers={'Content-Type':'application/json','X-Room-Token':token,'X-Room-Seq':String(sequence)};
const body=JSON.stringify(Object.assign({},values,{op:op,password:password}));
if(encoder.encode(body).length>4096)throw Error('Request exceeds the radio’s 4 KB limit.');
const deadline=Date.now()+20000;
let accepted=false;
// An interrupted submission retries the identical sequence and body. Once
// admitted, only poll its result. Never silently resend with a new identity.
while(Date.now()<deadline){
let response;
try{response=await fetchBounded(accepted?'/api/room/result':'/api/room',
{method:'POST',headers:headers,body:accepted?undefined:body})}
catch(e){await pause(400);continue}
if(!accepted&&response.status===429){await pause(500);continue}
if(response.status===202){accepted=true;await pause(250);continue}
let result;
try{result=await response.json()}catch(e){throw Error('Invalid response; refresh to check before retrying.')}
if(!response.ok||result.error)throw Error(result.error||('Room request failed ('+response.status+').'));
return result;
}
throw Error('No confirmation received. Refresh and check the room before trying the operation again.');
}
function updateBudget(){
const size=encoder.encode($('name').value+': '+$('message').value).length;
$('budget').textContent=size+' / 151 UTF-8 bytes (including your name)';
$('send').disabled=sending||!joined||!(role===2||role===3)||size>151||!$('message').value.trim();
}
async function status(){
const result=await api('status');
if(boot&&boot!==result.boot){after=0;$('posts').replaceChildren();transfers.clear();notices.clear()}
boot=result.boot;role=result.role;
$('room-name').textContent=result.name;$('topic').textContent=result.topic;
$('web-key').textContent=result.web_key;
$('history').textContent=result.persistent_history?'Radio retains its newest 32 posts across reboots.':'Radio history is in RAM and is lost on reboot.';
$('state').textContent=role===3?'Admin access':role===2?'Read and write access':'Read-only access';
$('editor').hidden=role!==3;updateBudget();
}
async function refreshChat(){
if(polling||!joined)return;polling=true;
try{
await status();
for(let i=0;i<32;i++){
const result=await api('posts',{after:after}), post=result.post;if(!post)break;
if(!Number.isInteger(post.timestamp)||post.timestamp<=after)throw Error('Invalid message cursor.');
const row=document.createElement('div'),stamp=document.createElement('small'),text=document.createElement('div');
row.className='post';stamp.textContent=new Date(post.timestamp*1000).toLocaleString()+' · '+post.author.slice(0,12);
text.textContent=post.text;row.append(stamp,text);$('posts').append(row);
while($('posts').childElementCount>32)$('posts').firstElementChild.remove();
after=post.timestamp;
}
}finally{polling=false}
}
async function refreshBoard(){
let cursor=0,revision=0,next=new Map();
for(let page=0;page<8;page++){
const result=await api('board.index',{revision:revision,cursor:cursor});
if(revision&&result.revision!==revision)throw Error('Board changed; refresh notices.');
revision=result.revision;
for(const item of result.articles)next.set(item.id,item);
if(result.next===255){notices=next;renderNotices();return}
if(!Number.isInteger(result.next)||result.next<=cursor||result.next>8)throw Error('Invalid board cursor.');
cursor=result.next;
}
throw Error('Board index did not finish.');
}
function renderNotices(){
$('notices').replaceChildren();
if(!notices.size){$('notices').textContent='No notices published.';return}
for(const item of notices.values()){
const button=document.createElement('button');button.type='button';button.textContent=item.title;
button.addEventListener('click',()=>openArticle(item).catch(message));$('notices').append(button);
}
}
async function openArticle(item){
if(!Number.isInteger(item.length)||item.length<0||item.length>2048)throw Error('Article exceeds the radio’s limit.');
let transfer=transfers.get(item.id);
if(!transfer||transfer.version!==item.version){transfer={version:item.version,next:0,bytes:new Uint8Array(item.length)};transfers.set(item.id,transfer)}
try{
if(!item.length){const result=await api('board.read',{id:item.id,version:item.version,offset:0});
if(result.version!==item.version||result.offset!==0||result.total!==0||result.next!==0||result.data64!=='')throw Error('Article changed; refresh notices.')}
while(transfer.next<item.length){
const result=await api('board.read',{id:item.id,version:item.version,offset:transfer.next});
const bytes=Uint8Array.from(atob(result.data64),c=>c.charCodeAt(0));
if(result.version!==item.version||result.offset!==transfer.next||result.total!==item.length
||!bytes.length||bytes.length>128||result.next!==transfer.next+bytes.length||result.next>item.length)throw Error('Article changed or its transfer is invalid.');
transfer.bytes.set(bytes,transfer.next);transfer.next=result.next;
}
const text=new TextDecoder('utf-8',{fatal:true}).decode(transfer.bytes);
$('article-title').textContent=item.title;$('article').textContent=text;$('article-title').hidden=false;$('article').hidden=false;
if(role===3){$('article-id').value=String(item.id);$('title').value=item.title;$('body').value=text;editId=item.id;editVersion=item.version}
message('');
}catch(e){
// Completed chunks stay available for another click after a lost link.
// A version mismatch always starts a new transfer after the index refresh.
message(e);throw e;
}
}
$('join').addEventListener('submit',async event=>{
event.preventDefault();message('');
try{makeIdentity();password=$('password').value;joined=false;await status();joined=true;$('room').hidden=false;updateBudget();await refreshChat();await refreshBoard()}
catch(e){message(e)}
});
$('name').value=saved('mc-room-name','Web');
$('name').addEventListener('input',()=>{remember('mc-room-name',$('name').value);updateBudget()});
$('message').addEventListener('input',updateBudget);
$('compose').addEventListener('submit',async event=>{
event.preventDefault();if($('send').disabled)return;message('');sending=true;updateBudget();
const text=$('message').value;
try{await api('post',{name:$('name').value,text:text});if($('message').value===text)$('message').value='';await refreshChat()}
catch(e){message(e)}finally{sending=false;updateBudget()}
});
$('refresh').addEventListener('click',()=>refreshChat().catch(message));
$('refresh-board').addEventListener('click',()=>refreshBoard().catch(message));
$('article-id').addEventListener('change',()=>{editId=Number($('article-id').value);editVersion=0});
$('publish').addEventListener('submit',async event=>{
event.preventDefault();message('');
try{
const id=Number($('article-id').value),bytes=encoder.encode($('body').value);
if(encoder.encode($('title').value).length>63||!$('title').value||bytes.length>2048)throw Error('Use a title up to 63 UTF-8 bytes and an article up to 2,048 bytes.');
const body64=btoa(Array.from(bytes,b=>String.fromCharCode(b)).join(''));
const result=await api('board.save',{id:id,version:editId===id?editVersion:0,title:$('title').value,body64:body64});
editId=id;editVersion=result.version;
transfers.delete(id);await refreshBoard();message('');
}catch(e){message(e)}
});
$('delete').addEventListener('click',async()=>{
const id=Number($('article-id').value),entry=notices.get(id);
if(!entry||!confirm('Delete '+entry.title+'?'))return;
try{await api('board.delete',{id:id,version:entry.version});transfers.delete(id);await refreshBoard();$('article').hidden=true;$('article-title').hidden=true}
catch(e){message(e)}
});
setInterval(()=>{if(joined)refreshChat().catch(message)},15000);
updateBudget();
</script></body></html>)roomhtml";
+117
View File
@@ -32,6 +32,8 @@ static_assert(sizeof(WEBCONFIG_AP_PREFIX) <= 28,
#endif
#include "WebConfigHtml.h"
#include "RoomWebHtml.h"
#include <Utils.h>
#include <helpers/HttpContentEncoding.h>
#include "WebTerminalStream.h"
#include <helpers/CLICommandUtils.h>
@@ -339,6 +341,9 @@ WebConfigServer::WebConfigServer(Callbacks* callbacks, void* mqtt_prefs, bool ow
_standalone_wifi(standalone_wifi || mqtt_prefs == nullptr || !owns_wifi), _pub_key(pub_key),
_fw_ver(fw_ver), _build_date(build_date), _role(role), _board_name(board_name) {
_mux = xSemaphoreCreateMutex();
if (_mux && _cb && _cb->supportsRoomService()) {
_room_mailbox = new (std::nothrow) mesh::RoomWebMailbox;
}
_cli_enabled = loadCliEnabled(true);
#ifdef WITH_MQTT_BRIDGE
@@ -364,6 +369,7 @@ WebConfigServer::WebConfigServer(Callbacks* callbacks, void* mqtt_prefs, bool ow
WebConfigServer::~WebConfigServer() {
detachRoutes();
closeTerminal();
delete _room_mailbox;
if (_mux) vSemaphoreDelete(_mux);
}
@@ -1043,6 +1049,8 @@ bool WebConfigServer::stopForOTA(char reply[]) {
void WebConfigServer::finalizeTeardown() {
closeTerminal();
delete _room_mailbox;
_room_mailbox = nullptr;
// Async requests keep a pointer to their server until disconnect. Retain the
// listener and route table for the firmware lifetime; only detach and reclaim
// the per-session state.
@@ -1127,6 +1135,7 @@ void WebConfigServer::tick(uint32_t now) {
if (!_board_cmds_probed) probeBoardCommands();
serviceTerminal(now);
serviceRoom(now);
// A primary ESP-NOW radio and its WiFi station cannot remain on different
// channels. Reject a router-driven channel move (for example a CSA) and put
@@ -1613,6 +1622,15 @@ void WebConfigServer::dispatchRequest(AsyncWebServerRequest* req,
}
void WebConfigServer::registerRoutes() {
_server->on("/room", HTTP_GET, [](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handleRoomPage);
});
_server->on("/api/room/result", HTTP_POST, [](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handleRoomResult);
});
_server->on("/api/room", HTTP_POST, [](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handleRoomPost);
}, NULL, collectBody);
_server->on("/ui", HTTP_GET, [](AsyncWebServerRequest* r) {
dispatchRequest(r, &WebConfigServer::handleUi);
});
@@ -1676,6 +1694,105 @@ void WebConfigServer::registerRoutes() {
});
}
void WebConfigServer::serviceRoom(uint32_t now) {
if (!_room_mailbox && _mux && _cb && _cb->supportsRoomService()) {
WCLock lock(_mux);
_room_mailbox = new (std::nothrow) mesh::RoomWebMailbox;
}
mesh::RoomWebMailbox* operation = nullptr;
{
WCLock lock(_mux);
if (_room_mailbox && _room_mailbox->begin()) operation = _room_mailbox;
}
if (!operation) return;
// Async submit/read may run here, but cannot recycle a Running operation.
// No filesystem, room client or radio mutation runs on async_tcp.
_cb->processRoomRequest(operation->token(), operation->sequence(), operation->input(),
operation->output(), mesh::RoomWebMailbox::OUTPUT_CAPACITY);
{
WCLock lock(_mux);
operation->finish(now, _cb->roomRequestAuthenticated());
}
}
static bool roomRequestIdentity(AsyncWebServerRequest* req, uint8_t (&token)[32], uint32_t& sequence) {
if (!req || !req->hasHeader("X-Room-Token") || !req->hasHeader("X-Room-Seq")) return false;
if (req->hasHeader("Origin")) {
if (!req->hasHeader("Host") || req->getHeader("Origin")->value()
!= String("http://") + req->getHeader("Host")->value()) return false;
}
return mesh::RoomWebMailbox::decodeToken(req->getHeader("X-Room-Token")->value().c_str(), token)
&& mesh::cli::parseUnsignedIntegerStrict(req->getHeader("X-Room-Seq")->value().c_str(), sequence)
&& sequence != 0;
}
void WebConfigServer::handleRoomPage(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF || !_cb || !_cb->supportsRoomService()) { req->send(404); return; }
_last_activity = millis();
auto* response = new (std::nothrow) WebConfigPacedProgmemResponse(
"text/html; charset=utf-8", reinterpret_cast<const uint8_t*>(ROOM_WEB_HTML), sizeof(ROOM_WEB_HTML) - 1);
if (!response) { req->send(503); return; }
response->addHeader("Cache-Control", "no-store");
response->addHeader("X-Content-Type-Options", "nosniff");
response->addHeader("Referrer-Policy", "no-referrer");
req->send(response);
}
void WebConfigServer::handleRoomPost(AsyncWebServerRequest* req) {
uint8_t token[32]; uint32_t sequence;
if (!roomRequestIdentity(req, token, sequence)) {
req->send(400, "application/json", "{\"error\":\"invalid room identity\"}"); return;
}
if (req->contentType() != "application/json" || !req->_tempObject
|| req->contentLength() == 0 || req->contentLength() > MAX_BODY) {
req->send(400, "application/json", "{\"error\":\"invalid room body\"}"); return;
}
uint8_t digest[32];
mesh::Utils::sha256(digest, sizeof(digest), (const uint8_t*)req->_tempObject, req->contentLength());
mesh::RoomWebMailbox::Result result;
{
WCLock lock(_mux);
if (!_room_mailbox || _stopping || _mode == MODE_OFF) {
req->send(503, "application/json", "{\"error\":\"room service unavailable\"}"); return;
}
result = _room_mailbox->submit(token, sequence, digest, (const char*)req->_tempObject,
req->contentLength(), millis());
// Do not keep a second password/article buffer while tick parses the
// accepted copy. The request destructor tolerates a null temporary object.
memset(req->_tempObject, 0, req->contentLength());
free(req->_tempObject);
req->_tempObject = nullptr;
}
if (result == mesh::RoomWebMailbox::Result::Accepted) {
_last_activity = millis();
req->send(202, "application/json", "{\"accepted\":true}");
} else if (result == mesh::RoomWebMailbox::Result::Busy) {
req->send(429, "application/json", "{\"error\":\"room service busy\"}");
} else {
req->send(409, "application/json", "{\"error\":\"request already completed or changed; refresh to check\"}");
}
}
void WebConfigServer::handleRoomResult(AsyncWebServerRequest* req) {
uint8_t token[32]; uint32_t sequence;
if (!roomRequestIdentity(req, token, sequence)) { req->send(400); return; }
WCLock lock(_mux);
if (!_room_mailbox || _stopping || _mode == MODE_OFF) { req->send(503); return; }
bool pending;
const char* result = _room_mailbox->read(token, sequence, pending);
if (pending) req->send(202, "application/json", "{\"pending\":true}");
else if (!result) req->send(404, "application/json", "{\"error\":\"room result unavailable; refresh to check\"}");
else {
String body(result);
if (body.length() != strlen(result)) { req->send(503); return; }
auto* response = req->beginResponse(200, "application/json", body);
if (!response) { req->send(503); return; }
response->addHeader("Cache-Control", "no-store");
req->send(response);
_room_mailbox->delivered(token, sequence);
}
}
// Accumulate a small JSON body into request->_tempObject (freed automatically
// by the request destructor). Oversized bodies are left unbuffered and
// rejected in the completion handler.
+12
View File
@@ -33,6 +33,7 @@
#include <helpers/CompanionWiFiStatus.h>
#include <helpers/WebConfigBatch.h>
#include <helpers/TerminalSession.h>
#include <helpers/RoomWebMailbox.h>
#include <helpers/WiFiPowerSave.h>
#include <helpers/WiFiReconnectPolicy.h>
#include <helpers/ui/DisplayBuildFlags.h>
@@ -161,6 +162,12 @@ public:
virtual void buildStatsJson(char* buf, size_t buf_size) = 0;
// Teardown finished (server + DNS freed, WiFi mode restored).
virtual void onWebConfigStopped() {}
virtual bool supportsRoomService() const { return false; }
// Input may be parsed in place. Authentication, storage and radio work
// happen only on tick(), never on async_tcp.
virtual void processRoomRequest(const uint8_t* token, uint32_t sequence,
char* request, char* response, size_t capacity) {}
virtual bool roomRequestAuthenticated() const { return false; }
};
// mqtt_prefs is an MQTTPrefs* on WITH_MQTT_BRIDGE builds and NULL otherwise.
@@ -339,6 +346,7 @@ private:
char _setup_wifi_handoff_ip[16] = {0};
BatchEntry _batch[MAX_BATCH];
WebTerminalStream* _terminal = NULL;
mesh::RoomWebMailbox* _room_mailbox = NULL;
// LAN-mode session (single slot; new login evicts the old session)
char _session_token[33] = {0};
@@ -378,6 +386,7 @@ private:
void drainBatch(uint32_t now);
void probeBoardCommands();
void serviceTerminal(uint32_t now);
void serviceRoom(uint32_t now);
void closeTerminal();
void serviceSetupWiFiHandoff(uint32_t now);
void finishBatch(uint32_t now);
@@ -404,6 +413,9 @@ private:
void handlePresets(AsyncWebServerRequest* req);
void handleReboot(AsyncWebServerRequest* req);
void handlePortalExit(AsyncWebServerRequest* req);
void handleRoomPage(AsyncWebServerRequest* req);
void handleRoomPost(AsyncWebServerRequest* req);
void handleRoomResult(AsyncWebServerRequest* req);
void handleNotFound(AsyncWebServerRequest* req);
};