Add room catch-up and Wi-Fi administration

This commit is contained in:
mikecarper committed 2026-10-10 11:51:00 -07:00
1 parent 0eb1d03a16
commit 4606f3da60
20 files changed
+2585 -64

No files matched your search

+23
View File
@@ -2299,6 +2299,29 @@ bool Mesh::cancelActiveRetries(const uint8_t retry_key[MAX_HASH_SIZE]) {
return cancelled;
}
bool Mesh::cancelActiveMessageRetries(const uint8_t message_key[MAX_HASH_SIZE],
uint32_t message_timestamp) {
if (message_key == NULL) return false;
uint8_t key[MAX_HASH_SIZE];
memcpy(key, message_key, sizeof(key));
bool cancelled = false;
for (int i = 0; i < TOTAL_DIRECT_RETRY_SLOTS; i++) {
if (_direct_retries[i].active && _direct_retries[i].has_message_replacement_key
&& _direct_retries[i].message_timestamp == message_timestamp
&& memcmp(key, _direct_retries[i].message_replacement_key, sizeof(key)) == 0) {
retireDirectRetrySlot(i); cancelled = true;
}
}
for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; i++) {
if (_flood_retries[i].active && _flood_retries[i].has_message_replacement_key
&& _flood_retries[i].message_timestamp == message_timestamp
&& memcmp(key, _flood_retries[i].message_replacement_key, sizeof(key)) == 0) {
retireFloodRetrySlot(i); cancelled = true;
}
}
return cancelled;
}
void Mesh::replaceActiveRetries(const Packet* replacement_packet,
const uint8_t retry_key[MAX_HASH_SIZE]) {
cancelActiveRetries(retry_key);
+4
View File
@@ -323,6 +323,10 @@ protected:
* \returns true if at least one active retry sequence was cancelled.
*/
bool cancelActiveRetries(const uint8_t retry_key[MAX_HASH_SIZE]);
// Cancel one locally identified message delivery across both radio profiles.
// This does not rely on the retained text or a short destination path hash.
bool cancelActiveMessageRetries(const uint8_t message_key[MAX_HASH_SIZE],
uint32_t message_timestamp);
/**
* \brief After a replacement packet is queued, retire the prior retry
+156
View File
@@ -0,0 +1,156 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
namespace mesh {
enum class RoomCatchUpMode : uint8_t { Before, SkipNext, KeepNewest };
struct RoomCatchUpPlan {
uint32_t candidate_cursor;
uint16_t skipped;
bool pending_post_cancelled;
uint16_t backlog_remaining;
};
inline bool parseRoomCatchUpCount(const char* input, uint32_t& result) {
if (input == nullptr || *input == 0) return false;
uint32_t value = 0;
for (const char* next = input; *next; ++next) {
if (*next < '0' || *next > '9') return false;
const uint32_t digit = static_cast<uint32_t>(*next - '0');
if (value > (UINT32_MAX - digit) / 10) return false;
value = value * 10 + digit;
}
result = value;
return true;
}
inline bool parseRoomCatchUpDatePart(const char* input, size_t length,
uint32_t& value) {
value = 0;
for (size_t index = 0; index < length; ++index) {
if (input[index] < '0' || input[index] > '9') return false;
value = value * 10 + static_cast<uint32_t>(input[index] - '0');
}
return true;
}
// UTC only. Date-only values denote midnight. A zero epoch is a valid no-op
// cutoff. No libc time conversion, timezone state, or allocation is needed.
inline bool parseRoomCatchUpBefore(const char* input, uint32_t& result) {
if (input == nullptr) return false;
const size_t length = strlen(input);
if ((length != 10 && length != 20) || input[4] != '-') {
return parseRoomCatchUpCount(input, result);
}
if (input[7] != '-') return false;
uint32_t year, month, day, hour = 0, minute = 0, second = 0;
if (!parseRoomCatchUpDatePart(input, 4, year)
|| !parseRoomCatchUpDatePart(input + 5, 2, month)
|| !parseRoomCatchUpDatePart(input + 8, 2, day)) return false;
if (length == 20 && (input[10] != 'T' || input[13] != ':'
|| input[16] != ':' || input[19] != 'Z'
|| !parseRoomCatchUpDatePart(input + 11, 2, hour)
|| !parseRoomCatchUpDatePart(input + 14, 2, minute)
|| !parseRoomCatchUpDatePart(input + 17, 2, second))) return false;
if (year < 1970 || year > 2106 || month == 0 || month > 12
|| hour > 23 || minute > 59 || second > 59) return false;
const bool leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
static const uint8_t month_days[] = {31,28,31,30,31,30,31,31,30,31,30,31};
const uint32_t limit = month_days[month - 1] + (month == 2 && leap ? 1 : 0);
if (day == 0 || day > limit) return false;
const uint32_t previous_year = year - 1;
uint32_t days = (year - 1970) * 365
+ (previous_year / 4 - previous_year / 100 + previous_year / 400)
- (1969 / 4 - 1969 / 100 + 1969 / 400);
for (uint32_t index = 0; index + 1 < month; ++index) {
days += month_days[index] + (index == 1 && leap ? 1 : 0);
}
days += day - 1;
const uint64_t timestamp = static_cast<uint64_t>(days) * 86400
+ hour * 3600 + minute * 60 + second;
if (timestamp > UINT32_MAX) return false;
result = static_cast<uint32_t>(timestamp);
return true;
}
// Room retention is bounded to 32 posts. The cyclic array need not be in
// timestamp order. Own posts never count toward unread backlog or a skip count.
// Cursor selection uses retained timestamps only, never a requested future
// date or cursor+1 arithmetic near UINT32_MAX.
template <typename Client, typename Post>
RoomCatchUpPlan planRoomCatchUp(const Client& client, const Post* posts,
size_t count, RoomCatchUpMode mode,
uint32_t value) {
const uint32_t floor = client.extra.room.sync_since;
RoomCatchUpPlan plan = {floor, 0, false, 0};
if (posts == nullptr || count > 32) return plan;
uint16_t total = 0;
for (size_t index = 0; index < count; ++index) {
const auto& post = posts[index];
if (post.post_timestamp > floor && !post.author.matches(client.id)) ++total;
}
if (mode == RoomCatchUpMode::Before) {
for (size_t index = 0; index < count; ++index) {
const auto& post = posts[index];
if (post.post_timestamp > plan.candidate_cursor && post.post_timestamp < value
&& !post.author.matches(client.id)) plan.candidate_cursor = post.post_timestamp;
}
} else if (mode == RoomCatchUpMode::SkipNext || mode == RoomCatchUpMode::KeepNewest) {
const uint32_t target = mode == RoomCatchUpMode::SkipNext
? (value < total ? value : total) : (value < total ? total - value : 0);
while (plan.skipped < target) {
uint32_t next_timestamp = 0;
for (size_t index = 0; index < count; ++index) {
const auto& post = posts[index];
if (post.post_timestamp > plan.candidate_cursor && !post.author.matches(client.id)
&& (next_timestamp == 0 || post.post_timestamp < next_timestamp)) {
next_timestamp = post.post_timestamp;
}
}
if (next_timestamp == 0) break;
plan.candidate_cursor = next_timestamp;
plan.skipped = 0;
for (size_t index = 0; index < count; ++index) {
const auto& post = posts[index];
if (post.post_timestamp > floor && post.post_timestamp <= plan.candidate_cursor
&& !post.author.matches(client.id)) ++plan.skipped;
}
}
}
plan.skipped = 0;
for (size_t index = 0; index < count; ++index) {
const auto& post = posts[index];
if (post.post_timestamp > floor && post.post_timestamp <= plan.candidate_cursor
&& !post.author.matches(client.id)) ++plan.skipped;
}
plan.backlog_remaining = total - plan.skipped;
plan.pending_post_cancelled = plan.candidate_cursor > floor
&& client.extra.room.pending_ack != 0 && client.extra.room.pending_topic_revision == 0
&& client.extra.room.push_post_timestamp != 0
&& client.extra.room.push_post_timestamp <= plan.candidate_cursor;
return plan;
}
// The caller cancels the matching transport retry before/after applying this
// plan. A delayed post ACK cannot match once pending_ack is cleared. An
// outstanding topic or a newer post is rechecked and retained unchanged.
template <typename Client>
bool applyRoomCatchUpPlan(Client& client, const RoomCatchUpPlan& plan) {
if (plan.candidate_cursor <= client.extra.room.sync_since) return false;
client.extra.room.sync_since = plan.candidate_cursor;
client.extra.room.push_failures = 0;
if (plan.pending_post_cancelled && client.extra.room.pending_topic_revision == 0
&& client.extra.room.push_post_timestamp != 0
&& client.extra.room.push_post_timestamp <= plan.candidate_cursor) {
client.extra.room.pending_ack = 0;
client.extra.room.push_post_timestamp = 0;
client.extra.room.ack_timeout = 0;
}
return true;
}
} // namespace mesh
+14
View File
@@ -37,6 +37,20 @@ inline RoomClientPathCommand classifyRoomClientPathCommand(const char* command)
return RoomClientPathCommand::None;
}
// One outstanding delivery per recipient, with separate namespaces for topic
// and chat. The complete room/recipient identities participate in the key.
template <typename Hash>
inline void roomDeliveryRetryKey(uint8_t* output, size_t size,
const uint8_t* room, const uint8_t* recipient,
bool topic, Hash hash) {
const uint8_t domain[] = {'r','o','o','m', topic ? uint8_t(1) : uint8_t(0)};
uint8_t material[5 + 64];
memcpy(material, domain, sizeof(domain));
memcpy(material + sizeof(domain), room, 32);
memcpy(material + sizeof(domain) + 32, recipient, 32);
hash(output, size, material, sizeof(material));
}
inline void formatRoomClientPathReply(const uint8_t* path, uint8_t length,
char* reply, size_t capacity) {
if (length == 0xfe) { snprintf(reply, capacity, "> flood"); return; }
+265 -16
View File
@@ -8,13 +8,14 @@
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}
:root{color-scheme:dark}body{font:16px system-ui;background:#111820;color:#eee;max-width:1000px;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}
.panel{border:1px solid #526273;border-radius:10px;padding:18px;margin:20px 0}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px}.card{background:#202b38;padding:12px;border-radius:6px}.card strong{display:block;font-size:1.3em}.table-wrap{overflow:auto}table{border-collapse:collapse;width:100%;font-size:.92em}th,td{text-align:left;padding:9px 7px;border-bottom:1px solid #364351;vertical-align:top}th{color:#b5beca}.key{font-family:monospace;overflow-wrap:anywhere}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:16px}.notice{padding:10px;background:#273947;border-radius:6px}.warning{color:#ffd3a3}.success{color:#a5e1b1}dl{display:grid;grid-template-columns:minmax(110px,1fr) 2fr;gap:8px}dt{color:#b5beca}dd{margin:0;overflow-wrap:anywhere}select{max-width:100%}.inline{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.inline label{display:inline}.inline input{width:auto;margin:0}fieldset{border:1px solid #526273;border-radius:6px;margin:14px 0;padding:12px}.small{font-size:.9em}
</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>
@@ -27,24 +28,53 @@ details{margin:18px 0}summary{cursor:pointer}code{overflow-wrap:anywhere}a{color
<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>
<button id="refresh" type="button">Refresh chat</button><button id="disconnect" type="button">Disconnect</button><div id="posts" aria-live="polite"></div>
<details id="browser-catchup"><summary>Choose chat shown in this browser</summary>
<p class="muted">Filter the room's retained history in this browser. This changes only your local view and does not delete history or change a radio's delivery cursor.</p>
<form id="browser-catchup-form" class="grid"><div><label for="browser-skip-date">Show messages from this date and time (local time)</label><input id="browser-skip-date" type="datetime-local"><button id="browser-skip-before" type="button">Show from date</button></div>
<div><label for="browser-skip-count">Show the newest number of retained messages</label><input id="browser-skip-count" type="number" min="1" max="32" value="10"><button id="browser-skip-number" type="button">Show newest messages</button></div></form>
<p id="browser-catchup-result" class="muted" role="status"></p></details>
<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">
<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 id="admin-panel" class="panel" hidden><h2>Room administration</h2>
<p class="muted">Manage radio users, their room-to-user routes, and chat delivery. Activity and delivery details are available only with admin access.</p>
<div class="inline"><button id="admin-refresh" type="button">Load users and delivery</button><label><input id="admin-live" type="checkbox"> Refresh every 15 seconds</label></div>
<p id="admin-state" role="status" class="muted">Load users to begin.</p>
<div id="admin-cards" class="cards" hidden></div>
<div class="table-wrap"><table id="admin-users" hidden><thead><tr><th scope="col">User</th><th scope="col">Access</th><th scope="col">Activity</th><th scope="col">Unread retained</th><th scope="col">Delivery</th><th scope="col">Manage</th></tr></thead><tbody id="admin-user-rows"></tbody></table></div>
<div class="inline"><button id="admin-previous" type="button" disabled>Previous users</button><button id="admin-next" type="button" disabled>Next users</button><span id="admin-page" class="muted"></span></div>
<section id="admin-selected" hidden><h3>Selected radio user</h3><p id="admin-selected-key" class="key"></p><button id="admin-selected-refresh" type="button">Reload selected user</button><dl id="admin-details"></dl>
<div class="grid"><form id="admin-outpath"><fieldset><legend>Primary route (outpath)</legend><label for="admin-out-mode">Route mode</label><select id="admin-out-mode"><option value="path">Explicit path</option><option value="direct">Direct</option><option value="flood">Flood</option><option value="clear">Clear saved route</option></select><label for="admin-out-value">Repeater path</label><input id="admin-out-value" spellcheck="false" autocomplete="off" placeholder="A1,B2 or A100,B200"><button type="submit">Save primary route</button></fieldset></form>
<form id="admin-altpath"><fieldset><legend>Alternate route (altpath)</legend><label for="admin-alt-mode">Route mode</label><select id="admin-alt-mode"><option value="path">Explicit path</option><option value="direct">Direct</option><option value="clear">No secondary copy (clear)</option></select><label for="admin-alt-value">Repeater path</label><input id="admin-alt-value" spellcheck="false" autocomplete="off" placeholder="C1,D2 or C100,D200"><button type="submit">Save alternate route</button></fieldset></form></div>
<p class="muted small">These are routes from the room server to this user. A route change does not grant access or change the user's role. Clear on the primary route allows normal route discovery. Clear on the alternate route disables the secondary copy; an alternate route is an additional copy, not a second delivery queue.</p>
<fieldset><legend>Advance this user's chat catch-up</legend><p class="warning">Skipped messages remain in room history but will no longer be delivered as unread chat to this user. Other users are unchanged.</p>
<div class="grid"><div><label for="admin-skip-date">Skip chat before this date and time (local time)</label><input id="admin-skip-date" type="datetime-local"><button id="admin-skip-before" type="button">Review date skip</button></div><div><label for="admin-skip-count">Keep the newest number of unread retained messages (0 skips all)</label><input id="admin-skip-count" type="number" min="0" max="32" value="10"><button id="admin-skip-number" type="button">Review older-message skip</button></div></div></fieldset>
<fieldset><legend>User access and moderation</legend><p class="muted small">Saved access uses this complete public key. Blocking also stops an existing room session. Password-based access remains separate from a saved ACL role.</p>
<label for="admin-access-role">Saved role</label><select id="admin-access-role"><option value="0">Remove saved access</option><option value="1">Read only</option><option value="2">Read and write</option><option value="3">Admin</option></select><button id="admin-access-save" type="button">Review access change</button><button id="admin-ban-selected" type="button">Block selected user</button></fieldset>
<p id="admin-result" role="status"></p></section>
<details><summary>Room settings and blocked identities</summary><button id="admin-settings-load" type="button">Load room settings</button>
<section id="admin-settings-panel" hidden><div class="grid"><form id="admin-topic"><label for="admin-topic-value">Room topic (up to 151 UTF-8 bytes)</label><textarea id="admin-topic-value"></textarea><button type="submit">Save topic</button></form>
<div><label><input id="admin-history-enabled" type="checkbox"> Keep the newest 32 chat posts across reboots</label><button id="admin-history-save" type="button">Save history setting</button><p class="muted small">Turning this off keeps the existing archive; it does not erase messages.</p></div>
<form id="admin-rates"><label for="admin-post-rate">Posts per user per minute (0 disables this limit)</label><input id="admin-post-rate" type="number" min="0" max="65535"><label for="admin-poll-rate">Polls per user per minute (0 disables this limit)</label><input id="admin-poll-rate" type="number" min="0" max="65535"><button type="submit">Save rate limits</button></form></div><p id="admin-settings-result" role="status"></p></section>
<fieldset><legend>Block or unblock a complete identity</legend><label for="admin-ban-key">Radio or browser public identity (64 hexadecimal characters)</label><input id="admin-ban-key" class="key" spellcheck="false" autocomplete="off" maxlength="64"><button id="admin-ban-add" type="button">Review block</button><button id="admin-ban-remove" type="button">Review unblock</button><p id="admin-ban-result" role="status"></p></fieldset></details></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 token='', sequence=0, password='', boot=0, role=0, after=0, joined=false, polling=false, connectionGeneration=0;
let chain=Promise.resolve(), notices=new Map(), transfers=new Map();
let sending=false, editId=1, editVersion=0;
let adminLoaded=false, adminLoading=false, adminBusy=false, adminCursor=0, adminNext=null, adminPages=[];
let selectedKey='', selectedUser=null, adminSettings=null;
const routeDirty={outpath:false,altpath:false},routeBaseline={outpath:'',altpath:''};
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(){
@@ -54,7 +84,7 @@ function makeIdentity(){
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.');
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));
@@ -64,21 +94,23 @@ async function fetchBounded(url,options){
finally{clearTimeout(timer)}
}
function api(op,values={}){
const intent=Object.assign({},values,{boot:boot});
const run=()=>request(op,intent), result=chain.then(run,run);
const intent=Object.assign({},values,{boot:boot}),generation=connectionGeneration,secret=password;
const run=()=>request(op,intent,generation,secret), result=chain.then(run,run);
chain=result.catch(()=>{});return result;
}
async function request(op,values){
async function request(op,values,generation,secret){
if(generation!==connectionGeneration)throw Error('Room connection changed. Reconnect before trying again.');
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 body=JSON.stringify(Object.assign({},values,{op:op,password:secret}));
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){
if(generation!==connectionGeneration)throw Error('Room connection changed. Reconnect before trying again.');
let response;
try{response=await fetchBounded(accepted?'/api/room/result':'/api/room',
{method:'POST',headers:headers,body:accepted?undefined:body})}
@@ -87,6 +119,7 @@ async function request(op,values){
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(generation!==connectionGeneration)throw Error('Room connection changed. Reconnect before trying again.');
if(!response.ok||result.error)throw Error(result.error||('Room request failed ('+response.status+').'));
return result;
}
@@ -99,13 +132,14 @@ function updateBudget(){
}
async function status(){
const result=await api('status');
if(boot&&boot!==result.boot){after=0;$('posts').replaceChildren();transfers.clear();notices.clear()}
if(boot&&boot!==result.boot){after=0;$('posts').replaceChildren();transfers.clear();notices.clear();clearAdmin()}
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();
$('editor').hidden=role!==3;$('admin-panel').hidden=role!==3;
if(role!==3)clearAdmin();updateAdminButtons();updateBudget();
}
async function refreshChat(){
if(polling||!joined)return;polling=true;
@@ -115,7 +149,7 @@ async function refreshChat(){
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);
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;
@@ -143,8 +177,187 @@ function renderNotices(){
button.addEventListener('click',()=>openArticle(item).catch(message));$('notices').append(button);
}
}
function retainedCount(id,minimum=1){
const value=$(id).value;
if(!/^\d+$/.test(value)||Number(value)<minimum||Number(value)>32)throw Error('Choose a number from '+minimum+' to 32 retained messages.');
return Number(value);
}
function dateSeconds(id){
const value=$(id).value,seconds=Math.floor(new Date(value).getTime()/1000);
if(!value||!Number.isInteger(seconds)||seconds<1||seconds>4294967295)throw Error('Choose a valid date and time.');
return seconds;
}
async function browserCatchup(mode){
if(!joined)throw Error('Connect to the room first.');
if(polling)throw Error('Chat is refreshing. Try again when it finishes.');
const before=mode==='before'?dateSeconds('browser-skip-date'):0;
const count=mode==='keep'?retainedCount('browser-skip-count'):32;
let skipped=0;polling=true;
try{
const unread=[];let cursor=0;
for(let index=0;index<32;index++){
const result=await api('posts',{after:cursor}),post=result.post;
if(!post)break;
if(!Number.isInteger(post.timestamp)||post.timestamp<=cursor)throw Error('Invalid message cursor.');
if(mode==='before'&&post.timestamp>=before)break;
// Advance only to an actual retained message. A future date must not
// make this browser miss posts that arrive after this request.
unread.push(post.timestamp);cursor=post.timestamp;
}
skipped=mode==='keep'?Math.max(0,unread.length-count):unread.length;
after=skipped?unread[skipped-1]:0;
$('posts').replaceChildren();$('browser-catchup-result').textContent='Hidden '+skipped+' older retained message'+(skipped===1?'':'s')+' in this browser view.';
}finally{polling=false}
await refreshChat();
}
function requireAdmin(){if(!joined||role!==3)throw Error('Admin permission required. Reconnect with the room admin password.');}
function clearAdmin(){
adminLoaded=false;adminCursor=0;adminNext=null;adminPages=[];selectedKey='';selectedUser=null;adminSettings=null;
routeDirty.outpath=false;routeDirty.altpath=false;routeBaseline.outpath='';routeBaseline.altpath='';
$('admin-user-rows').replaceChildren();$('admin-details').replaceChildren();$('admin-cards').replaceChildren();
$('admin-selected-key').textContent='';$('admin-users').hidden=true;$('admin-selected').hidden=true;$('admin-cards').hidden=true;
$('admin-live').checked=false;$('admin-state').textContent='Load users to begin.';$('admin-page').textContent='';$('admin-result').textContent='';
$('admin-out-value').value='';$('admin-alt-value').value='';$('admin-settings-panel').hidden=true;
$('admin-topic-value').value='';$('admin-ban-key').value='';$('admin-settings-result').textContent='';$('admin-ban-result').textContent='';updateAdminButtons();
}
function roleName(value){return({0:'Guest',1:'Read only',2:'Read and write',3:'Admin',4:'Region manager',5:'Filter manager'})[value]||('Role '+value);}
function age(value){
if(value===null||value===undefined)return 'Not heard this boot';
if(value<60)return value+' seconds ago';if(value<3600)return Math.floor(value/60)+' minutes ago';
return Math.floor(value/3600)+' hours ago';
}
function stamp(value){return value?new Date(value*1000).toLocaleString():'None';}
function deliveryName(user){
if(user.banned)return 'Blocked';if(user.failures>=3)return 'Paused after failures';
if(user.delivery==='topic')return 'Waiting for topic ACK';if(user.delivery==='post')return 'Waiting for post ACK';
return user.pending_count?'Ready to deliver':'Caught up';
}
function updateAdminButtons(){
const locked=adminBusy||adminLoading||role!==3;
for(const button of $('admin-panel').querySelectorAll('button'))button.disabled=locked;
$('admin-previous').disabled=locked||!adminPages.length;$('admin-next').disabled=locked||adminNext===null;
for(const input of $('admin-panel').querySelectorAll('input,select,textarea'))if(input.id!=='admin-live')input.disabled=locked;
for(const prefix of ['admin-out','admin-alt'])$(prefix+'-value').disabled=locked||$(prefix+'-mode').value!=='path';
}
function validKey(key){return typeof key==='string'&&/^[0-9a-fA-F]{64}$/.test(key)&&!/^0+$/.test(key);}
function renderAdminCards(result){
$('admin-cards').replaceChildren();
for(const [label,value] of [['Users',result.total],['Heard this boot',result.active],['Unread retained',result.backlog],['Awaiting ACK',result.pending],['Users with failures',result.failed]]){
const card=document.createElement('div'),number=document.createElement('strong'),title=document.createElement('span');
card.className='card';number.textContent=String(value);title.textContent=label;card.append(number,title);$('admin-cards').append(card);
}
$('admin-cards').hidden=false;
}
async function loadAdmin(cursor=adminCursor){
requireAdmin();if(adminLoading||adminBusy)return;adminLoading=true;updateAdminButtons();
try{
const result=await api('admin.users',{cursor:cursor});requireAdmin();
if(result.boot!==boot)throw Error('Radio restarted; refresh the room before managing users.');
if(!Array.isArray(result.users)||result.users.length>2||result.users.some(user=>!validKey(user.key))
||(result.next!==null&&(!Number.isInteger(result.next)||result.next<=cursor||result.next>65535)))throw Error('Invalid user page.');
adminCursor=cursor;adminNext=result.next;adminLoaded=true;renderAdminCards(result);$('admin-user-rows').replaceChildren();
for(const user of result.users){
const row=document.createElement('tr');
for(const text of [user.key.slice(0,12)+(user.retained?' (saved)':''),roleName(user.role),age(user.heard_ago),String(user.pending_count),deliveryName(user)]){
const cell=document.createElement('td');cell.textContent=text;row.append(cell);
}
const cell=document.createElement('td'),button=document.createElement('button');button.type='button';button.textContent='Select';
button.addEventListener('click',()=>selectAdminUser(user.key,true).catch(message));cell.append(button);row.append(cell);$('admin-user-rows').append(row);
}
$('admin-users').hidden=false;$('admin-page').textContent='Page '+(adminPages.length+1);
$('admin-state').textContent=result.users.length?'Updated '+new Date().toLocaleTimeString()+'. Heard this boot means recorded room activity, not guaranteed current radio reachability.':'No radio users retained or connected.';
if(selectedKey)await selectAdminUser(selectedKey,false);
}finally{adminLoading=false;updateAdminButtons()}
}
function addDetail(label,value){const term=document.createElement('dt'),description=document.createElement('dd');term.textContent=label;description.textContent=String(value);$('admin-details').append(term,description);}
function routeEditor(which,value,force){
const prefix=which==='outpath'?'admin-out':'admin-alt';
if(routeDirty[which]&&!force){
if(routeBaseline[which]!==value)$('admin-result').textContent='A route changed on the radio. Your draft is preserved; refresh the selected user before replacing it.';
return;
}
const mode=value==='direct'?'direct':value==='flood'?(which==='altpath'?'clear':'flood'):value==='unknown'||value==='clear'?'clear':'path';
$(prefix+'-mode').value=mode;$(prefix+'-value').value=mode==='path'?value:'';$(prefix+'-value').disabled=mode!=='path'||adminBusy;
routeBaseline[which]=value;routeDirty[which]=false;
}
async function selectAdminUser(key,force=false){
requireAdmin();if(!validKey(key))throw Error('Select a complete radio public key.');
const previous=selectedKey;selectedKey=key;
try{
const result=await api('admin.user',{key:key});requireAdmin();if(selectedKey!==key)return;
if(result.boot!==boot||!result.user||result.user.key!==key)throw Error('User changed or radio restarted; refresh users.');
selectedUser=result.user;$('admin-selected-key').textContent=key;$('admin-selected').hidden=false;$('admin-details').replaceChildren();
const user=selectedUser;
for(const [label,value] of [['Access',roleName(user.role)+(user.retained?' (saved ACL)':' (session only)')],['Activity',user.active?'Heard this boot':'No allowed room activity this boot'],['Last activity',age(user.heard_ago)],['Delivery',deliveryName(user)],['Unread retained posts',user.pending_count],['Chat cursor',stamp(user.sync_since)],['Pending post',stamp(user.pending_post)],['Topic revision waiting',user.topic_pending||'None'],['ACK wait',user.ack_wait_ms===null?'None':user.ack_wait_ms+' ms'],['Delivery failures',user.failures],['Primary route',user.outpath],['Alternate route',user.altpath],['Observed return path',user.observed_pending?'Awaiting path discovery':user.observed]])addDetail(label,value);
routeEditor('outpath',user.outpath,force||previous!==key);routeEditor('altpath',user.altpath,force||previous!==key);
$('admin-access-role').value=String(user.role<=3?user.role:1);$('admin-ban-selected').textContent=user.banned?'Unblock selected user':'Block selected user';
updateAdminButtons();
}catch(error){if(selectedKey===key&&previous!==key){selectedKey='';selectedUser=null;$('admin-selected').hidden=true}throw error}
}
async function adminMutation(action,success){
requireAdmin();if(adminBusy)return;adminBusy=true;updateAdminButtons();$('admin-result').textContent='';
try{await action();requireAdmin();$('admin-result').textContent=typeof success==='function'?success():success;}
finally{adminBusy=false;updateAdminButtons()}
}
async function saveAdminRoute(which){
if(!selectedUser)throw Error('Select a radio user first.');
const prefix=which==='outpath'?'admin-out':'admin-alt',mode=$(prefix+'-mode').value;
const value=mode==='path'?$(prefix+'-value').value.trim():mode;
if(mode==='path'&&(!value||value.length>191||!/^[0-9a-fA-F, \t]+$/.test(value)))throw Error('Use a hexadecimal repeater path separated by commas or spaces.');
const key=selectedKey,expected=routeBaseline[which];
await adminMutation(async()=>{await api('admin.route',{key:key,which:which,value:value,expected:expected});
if(selectedKey===key){routeDirty[which]=false;await selectAdminUser(key,false);}},'Saved '+which+' for '+key.slice(0,12)+'.');
}
async function adminCatchup(mode){
requireAdmin();if(!selectedUser)throw Error('Select a radio user first.');
const key=selectedKey,values={key:key,mode:mode,expected_sync:selectedUser.sync_since};
let explanation;
if(mode==='before'){values.before=dateSeconds('admin-skip-date');explanation='Skip unread chat before '+new Date(values.before*1000).toLocaleString();}
else{values.count=retainedCount('admin-skip-count',0);explanation=values.count===0?'Skip all unread retained chat messages':'Keep only the newest '+values.count+' unread retained messages and skip the older backlog';}
if(!confirm(explanation+' for radio '+key+'?\nThis advances only this user and does not delete room history.'))return;
await adminMutation(async()=>{const result=await api('admin.catchup',values);if(selectedKey===key)await selectAdminUser(key,false);
$('admin-state').textContent='Skipped '+result.skipped+' retained messages for '+key.slice(0,12)+'.';},'Chat catch-up updated.');
}
async function saveAdminAccess(){
requireAdmin();if(!selectedUser)throw Error('Select a radio user first.');
const key=selectedKey,selectedRole=Number($('admin-access-role').value),expected=selectedUser.role;
if(!confirm('Set saved access to '+(selectedRole===0?'none':roleName(selectedRole))+' for radio '+key+'?'))return;
let pendingSave=false;
await adminMutation(async()=>{const result=await api('admin.access',{key:key,role:selectedRole,expected_role:expected});pendingSave=result.pending_save===true;
if(selectedKey===key){
if(selectedRole===0){selectedKey='';selectedUser=null;$('admin-selected').hidden=true;$('admin-selected-key').textContent='';$('admin-details').replaceChildren();routeDirty.outpath=false;routeDirty.altpath=false;}
else await selectAdminUser(key,false);
}},()=>pendingSave?'Access updated; keep power on for at least 5 seconds while it is saved.':'Access updated.');
if(adminLoaded)await loadAdmin();
}
async function setAdminBan(key,banned){
requireAdmin();if(!validKey(key))throw Error('Use a complete nonzero 64-character public identity.');
if(!confirm((banned?'Block ':'Unblock ')+key+' for room access?'))return;
await adminMutation(async()=>{await api('admin.ban',{key:key,banned:banned});
if(selectedKey===key)await selectAdminUser(key,false);},banned?'Identity blocked.':'Identity unblocked.');
$('admin-ban-result').textContent=banned?'Identity blocked.':'Identity unblocked.';
}
async function loadAdminSettings(){
requireAdmin();const result=await api('admin.settings');requireAdmin();
if(result.boot!==boot)throw Error('Radio restarted; refresh the room before changing settings.');
adminSettings=result;$('admin-topic-value').value=result.topic;$('admin-history-enabled').checked=result.persistent_history;
$('admin-post-rate').value=result.post_rate;$('admin-poll-rate').value=result.poll_rate;$('admin-settings-panel').hidden=false;
}
function rateValue(id){const value=$(id).value;if(!/^\d+$/.test(value)||Number(value)>65535)throw Error('Rate limits must be whole numbers from 0 to 65535.');return Number(value);}
async function saveAdminSetting(which){
requireAdmin();if(!adminSettings)throw Error('Load room settings before editing.');
let values;
if(which==='topic'){
const value=$('admin-topic-value').value;
if(encoder.encode(value).length>151)throw Error('The topic must fit in 151 UTF-8 bytes.');
values={value:value,expected:adminSettings.topic};
}else if(which==='history')values={enabled:$('admin-history-enabled').checked,expected:adminSettings.persistent_history};
else values={post_rate:rateValue('admin-post-rate'),poll_rate:rateValue('admin-poll-rate')};
await adminMutation(async()=>{await api('admin.'+which,values);await loadAdminSettings();},'Room '+which+' saved.');
$('admin-settings-result').textContent='Saved. Changes are confirmed by the radio.';
}
async function openArticle(item){
if(!Number.isInteger(item.length)||item.length<0||item.length>2048)throw Error('Article exceeds the radio’s limit.');
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{
@@ -169,7 +382,8 @@ async function openArticle(item){
}
$('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()}
connectionGeneration++;joined=false;role=0;clearAdmin();$('editor').hidden=true;$('admin-panel').hidden=true;$('room').hidden=true;updateBudget();
try{makeIdentity();password=$('password').value;$('password').value='';await status();joined=true;$('room').hidden=false;updateBudget();await refreshChat();await refreshBoard()}
catch(e){message(e)}
});
$('name').value=saved('mc-room-name','Web');
@@ -182,6 +396,40 @@ $('compose').addEventListener('submit',async event=>{
catch(e){message(e)}finally{sending=false;updateBudget()}
});
$('refresh').addEventListener('click',()=>refreshChat().catch(message));
$('disconnect').addEventListener('click',()=>{
connectionGeneration++;joined=false;role=0;password='';clearAdmin();$('room').hidden=true;$('admin-panel').hidden=true;$('editor').hidden=true;$('state').textContent='Disconnected';updateBudget();
});
$('browser-catchup-form').addEventListener('submit',event=>event.preventDefault());
$('browser-skip-before').addEventListener('click',()=>browserCatchup('before').catch(message));
$('browser-skip-number').addEventListener('click',()=>browserCatchup('keep').catch(message));
$('admin-refresh').addEventListener('click',()=>loadAdmin().catch(message));
$('admin-next').addEventListener('click',()=>{
if(adminNext===null)return;const previous=adminCursor;adminPages.push(previous);
loadAdmin(adminNext).catch(error=>{adminPages.pop();message(error);updateAdminButtons()});
});
$('admin-previous').addEventListener('click',()=>{
if(!adminPages.length)return;const cursor=adminPages.pop();
loadAdmin(cursor).catch(error=>{adminPages.push(cursor);message(error);updateAdminButtons()});
});
$('admin-selected-refresh').addEventListener('click',()=>{
if((routeDirty.outpath||routeDirty.altpath)&&!confirm('Replace the unsaved route drafts with the current radio routes?'))return;
selectAdminUser(selectedKey,true).catch(message);
});
for(const [which,prefix] of [['outpath','admin-out'],['altpath','admin-alt']]){
$(prefix+'-value').addEventListener('input',()=>{routeDirty[which]=true});
$(prefix+'-mode').addEventListener('change',()=>{routeDirty[which]=true;updateAdminButtons()});
$(which==='outpath'?'admin-outpath':'admin-altpath').addEventListener('submit',event=>{event.preventDefault();saveAdminRoute(which).catch(message)});
}
$('admin-skip-before').addEventListener('click',()=>adminCatchup('before').catch(message));
$('admin-skip-number').addEventListener('click',()=>adminCatchup('keep').catch(message));
$('admin-access-save').addEventListener('click',()=>saveAdminAccess().catch(message));
$('admin-ban-selected').addEventListener('click',()=>{if(selectedUser)setAdminBan(selectedKey,!selectedUser.banned).catch(message)});
$('admin-ban-add').addEventListener('click',()=>setAdminBan($('admin-ban-key').value.trim(),true).catch(message));
$('admin-ban-remove').addEventListener('click',()=>setAdminBan($('admin-ban-key').value.trim(),false).catch(message));
$('admin-settings-load').addEventListener('click',()=>loadAdminSettings().catch(message));
$('admin-topic').addEventListener('submit',event=>{event.preventDefault();saveAdminSetting('topic').catch(message)});
$('admin-history-save').addEventListener('click',()=>saveAdminSetting('history').catch(message));
$('admin-rates').addEventListener('submit',event=>{event.preventDefault();saveAdminSetting('rates').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=>{
@@ -201,6 +449,7 @@ $('delete').addEventListener('click',async()=>{
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);
setInterval(()=>{if(joined)refreshChat().catch(message);if(joined&&role===3&&adminLoaded&&$('admin-live').checked)loadAdmin().catch(message)},15000);
updateBudget();
updateAdminButtons();
</script></body></html>)roomhtml";