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:
1 parent
a2a63ea5cc
commit
d031609f26
61 files changed
+6993
-91
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user