feat(meshtastic): extract LoBBS plugin library

This commit is contained in:
Ben Allfree committed 2026-10-06 23:04:05 -07:00
1 parent 1f7ded8f3f
commit 5bffe8d259
124 files changed
+10907 -39

No files matched your search

+3 -3
View File
@@ -12,15 +12,15 @@ Cross-platform, on-firmware BBS for **Meshtastic** and **MeshCore**. Greenfield:
**Plugin owns everything, forks stay upstream.**
1. **Plugin** (this repo, PlatformIO library): core, apps, LoScalar, LoDB, LoFS, protocol, and the platform providers under `platforms/<platform>/`. On MeshCore the plugin also owns the firmware app (`setup()`/`loop()`).
2. **Firmware forks** (`../lobbs-meshtastic-firmware`, `../lobbs-meshcore-firmware`, both on `lobbs`): upstream plus one new `variants/lobbs/` folder that defines the `*_lobbs` envs and pins the plugin in `lib_deps`.
2. **Firmware forks** (`../lobbs-meshtastic-firmware`, `../lobbs-meshcore-firmware`, both on `lobbs`): upstream plus a thin overlay that pins the plugin in `lib_deps`. The Meshtastic fork enables LoBBS on every env.
The repos are siblings under `/Volumes/Code/repos/meshenvy/`. No submodules anywhere: PlatformIO clones git deps with `--recursive`, and MeshForge builds a fork repo alone.
Until extraction finishes, LoBBS code still lives in-tree at `../lobbs-meshtastic-firmware/src/modules/LoBBS/`.
Meshtastic fork pulls this repo through `lib_deps` (see `../lobbs-meshtastic-firmware/lobbs-overrides.ini`).
## Fork diff policy
- Allowed: one new `variants/lobbs/` folder per fork (ini, `.gitignore` for the local override, README pointer).
- Allowed: fork overlay files (`lobbs-overrides.ini`, `lobbs.local.ini.example`, gitignored `lobbs.local.ini`) and minimal upstream edits listed in fork README.
- No edits to upstream files. If an extension point is missing, make the smallest possible change, list it in [`README.md`](README.md), and open an upstream PR.
## Pins and releases
+4 -4
View File
@@ -5,7 +5,7 @@ alwaysApply: true
# Pins and releases
Three sibling repos: `lobbs` (plugin), `lobbs-meshtastic-firmware`, `lobbs-meshcore-firmware`. Each fork's `variants/lobbs/` config pulls the plugin through `lib_deps`.
Three sibling repos: `lobbs` (plugin), `lobbs-meshtastic-firmware`, `lobbs-meshcore-firmware`. Each fork pins the plugin through `lib_deps`. The Meshtastic fork enables LoBBS on every env.
## Local dev: symlink, never committed
@@ -14,10 +14,10 @@ Point a fork at the working copy with a gitignored override:
| Fork | Override file | Content |
| --- | --- | --- |
| MeshCore | `platformio.local.ini` (already gitignored, already in `extra_configs`) | `lib_deps` with `lobbs=symlink://../lobbs` |
| Meshtastic | `variants/lobbs/local.ini` (gitignored by `variants/lobbs/.gitignore`, picked up by the `variants/*/*.ini` glob) | same |
| Meshtastic | `lobbs.local.ini` (gitignored, loaded last via `platformio.ini` `extra_configs`) | same |
- `symlink://` never appears in a committed file. A pushed symlink breaks MeshForge and CI, because the path only exists locally.
- Before pushing a fork, `git grep -n 'symlink://' -- variants/lobbs` must return nothing.
- Before pushing a fork, `git grep -n 'symlink://'` on overlay ini files must return nothing.
## Committed pin
@@ -42,5 +42,5 @@ Pin by commit, not branch. A commit pin is immutable, and PlatformIO fetches it
## Release procedure
1. Plugin: update `CHANGELOG.md`, commit, `git tag -a vX.Y.Z`, push the tag.
2. Each fork: set the committed `lib_deps` pin to the `vX.Y.Z` commit sha. Confirm no `symlink://` is committed. Build the `*_lobbs` envs from a clean clone.
2. Each fork: set the committed `lib_deps` pin to the `vX.Y.Z` commit sha. Confirm no `symlink://` is committed. Build a representative env from a clean clone (`seeed_solar_node` on Meshtastic).
3. Commit the pin bump, tag with the fork format above, push branch and tag.
+94 -31
View File
@@ -1,60 +1,123 @@
# Changelog
All notable changes to LoBBS will be documented in this file.
LoBBS product history for [lobbs-meshtastic-firmware](https://github.com/MeshEnvy/lobbs-meshtastic-firmware). Meshtastic upstream release notes live in [meshtastic/firmware](https://github.com/meshtastic/firmware); the `[VERSION]` section in `version.properties` tracks that base.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
LoBBS semver is the `[LOBBS]` section in `version.properties` when present. Builds inject `LOBBS_VERSION` / `LOBBS_VERSION_SHORT` (see repo root `bin/readprops.py`, `bin/platformio-custom.py`). Until LoBBS 2.0.0 ships, dev builds may show a pre-release `[LOBBS]` value on branch `lobbs`.
Release tags use:
`lobbs-{lobbsSemVer}.{lobbsSha7}-meshtastic-{mtSemVer}.{mtSha7}`
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
Planned major release **2.0.0** (not tagged yet).
### Added
- Stateless slash CLI: `/[id] command args…`, replies optionally prefixed `<id>`. No menu stack. The last successful reply is cached for `/pN` paging.
- Plugin command IoC: Auth, Mail, News, Help, Status, Wall, Yarn apps register via filters.
- Long replies page with `/p2`, `/p3`, … against the cached last reply, with or without a request id.
- `/wall`: shared 12×12 ASCII canvas, batch paint, erase tokens, paint quota, new/seen on `/status` when logged in.
- `/news`: announcement feed (list, read, post, unread, sysop delete).
- `/yarn`: collaborative word tail and sysop quotas.
- Public `/status` and `/hi`; all-time totals when logged out; unread mail/news when logged in.
- Machine replies echo the request id and mark pages separately: `<id>ok [n:max]` (single-page replies are `<id>ok`).
- SysOp `/cd` and `/pwd`: per-session working directory for `/ls`, `/cat` and `/hex`. `/rm`, `/rmdir` and `/rmtree` take absolute paths only.
- LoFS mount table (`/flash`, optional `/sd` and `/extra`), virtual `/`, `/install` blank-node setup, and SysOp `/cp`, `/mv`, `/mkdir`, `/stat`, `/df`.
- SysOp chunked upload: `/upload` (offset base62 chunks) and CRC-checked `/commit`; host script [bin/lobbs_chunkify.py](bin/lobbs_chunkify.py).
- `/passwd` (self and sysop reset) and `/time` (show Unix time; sysop set clock).
- Logins are held in RAM and end on reboot or after an idle timeout. SysOp `/config` sets `session.max`, `session.idle`, wall/yarn quotas, `pager.ttl`, and `password.min`. `/help config key` explains a setting. Overrides live in one LoDB `config` table. Logging in never writes flash.
- Ignore non-slash DMs and self-originated loopback (`from` = local node).
- nRF52840: LoBBS gets its own 100 KB `/flash2` partition between the app and Meshtastic's filesystem. `/install` offers `flash2` there instead of `/flash`. Install points come from the `install_mounts` filter, in preference order `sd`, `extra`, `flash2`, `flash`.
- Seeed XIAO nRF52840 kit and SenseCAP Solar: `/extra` on 2 MB onboard QSPI (`LoFSQspi`, JEDEC-gated mount).
- All LoBBS files live under `/<mount>/lobbs` (`install.ls`, `db/`, reserved `apps/` and `home/`). Boot finds the install by scanning install mounts for `lobbs/install.ls`; nothing is kept on `/flash`. `/install` on an installed node replies `Already installed at /<mount>.`
- SysOp `/format <mount> [code]` erases any mount but `sd` after a 2-minute confirmation code. `/flash` re-saves radio settings; the install mount leaves LoBBS blank. `/ls` shows `dir/` and file sizes. `/df` reads `extra: 1.8M free of 2.0M (12% used) [lobbs]`.
- LoBBS writes keep a free-space reserve on `/flash` (16 KB on nRF52, 128 KB elsewhere) so Meshtastic can still save its own files. Writes that would cut into it reply `Disk full.`. `/df` shows the reserve.
### Changed
- LoFS: cross-mount `move`, streaming `crc32File`, and `moveIfCrc32Matches`; glob helpers in `lofs/Glob`. FsCommands maps results to CLI replies; upload gap check unchanged in `/upload`.
- Operator role renamed from admin to SysOp in CLI text, help, and user records (`is_sysop` in auth proto, field 4 unchanged).
- LoBBS protos and DALs split into per-app modules; LoDB hardening for nRF52.
- `/login` for sign-in; build-time LoBBS versioning from `version.properties`.
- Topic commands for mail, news, and users (development on branch; superseded by stateless CLI for 2.0.0).
### Removed
- Numbered menu stack and session page buffer from the shipping 2.0.0 line (experimental on branch only).
## [1.3.0] - 2026-10-01
### Added
- LoBBS integrated in the Meshtastic firmware tree (fork of meshtastic/firmware on branch `lobbs`).
- LoBBS enabled on ESP32 builds; nRF52 compatibility and LoFS path fixes.
### Changed
- Canonical product home moved from archived [MeshEnvy/lobbs](https://github.com/MeshEnvy/lobbs) plugin monorepo to this repository.
## [1.2.1] - 2025-12-09
### Patch
- Updated module registration from `#pragma MPM_MODULE` to `MPM_REGISTER_MESHTASTIC_MODULE` comment directive
- Renamed module variable from `lobbsPlugin` to `lobbsModule` for consistency
Released from the archived MPM plugin monorepo ([MeshEnvy/lobbs](https://github.com/MeshEnvy/lobbs)).
### Changed
- Updated module registration from `#pragma MPM_MODULE` to `MPM_REGISTER_MESHTASTIC_MODULE` comment directive.
- Renamed module variable from `lobbsPlugin` to `lobbsModule` for consistency.
## [1.2.0] - 2025-12-09
### Minor
- Logo assets with logo.pxd and logo.webp files
- Admin user functionality - first registered user automatically becomes administrator
### Added
### Patch
- Walkthrough video link in README
- Updated README with improved documentation, license information, and links
- Replaced manual memory management with LoDb::freeRecords() in LoBBSModule
- Added extern declaration for lobbsPlugin and updated MPM module pragma
- Logo assets with logo.pxd and logo.webp files.
- Admin user functionality: first registered user automatically becomes administrator.
### Changed
- Walkthrough video link in README.
- Updated README with improved documentation, license information, and links.
- Replaced manual memory management with LoDb::freeRecords() in LoBBSModule.
- Added extern declaration for lobbsPlugin and updated MPM module pragma.
## [1.1.1] - 2025-12-05
### Patch
- Refactored header structure: removed LoBBS and meta headers, updated include paths, introduced new plugin header
### Changed
- Refactored header structure: removed LoBBS and meta headers, updated include paths, introduced new plugin header.
## [1.1.0] - 2025-12-05
### Minor
- MPM plugin compatibility
### Added
### Patch
- Direct messages now properly filter out broadcasts
- MPM plugin compatibility.
### Changed
- Direct messages now properly filter out broadcasts.
## [1.0.1] - 2025-12-05
### Patch
- Enhanced installation documentation with MeshForge method
- Updated installation instructions for MPM integration
### Changed
- Enhanced installation documentation with MeshForge method.
- Updated installation instructions for MPM integration.
## [1.0.0] - 2025-11-28
### Major
- Initial release of LoBBS (LoDB Bulletin Board System)
- User registration and authentication
- Bulletin board messaging system
- Direct messaging between users
- Session management
- User directory and search functionality
### Added
[Unreleased]: https://github.com/MeshEnvy/lobbs/compare/v1.2.1...HEAD
- Initial release of LoBBS (LoDB Bulletin Board System).
- User registration and authentication.
- Bulletin board messaging system.
- Direct messaging between users.
- Session management.
- User directory and search functionality.
[Unreleased]: https://github.com/MeshEnvy/lobbs-meshtastic-firmware/compare/lobbs-1.3.0.f18d6d6-meshtastic-2.7.26.54e0d8d...lobbs
[1.3.0]: https://github.com/MeshEnvy/lobbs-meshtastic-firmware/releases/tag/lobbs-1.3.0.f18d6d6-meshtastic-2.7.26.54e0d8d
[1.2.1]: https://github.com/MeshEnvy/lobbs/compare/v1.2.0...v1.2.1
[1.2.0]: https://github.com/MeshEnvy/lobbs/compare/v1.1.1...v1.2.0
[1.1.1]: https://github.com/MeshEnvy/lobbs/compare/v1.1.0...v1.1.1
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "configuration.h"
/** Log immediately before a boot step; last line wins when diagnosing hangs. */
#define LOBBS_BOOT_STEP(msg) LOG_INFO("LoBBS> %s", (msg))
#endif
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "LoBBSConfig.h"
#include <stdint.h>
class LoBBSModule;
typedef struct _meshtastic_MeshPacket meshtastic_MeshPacket;
/** Resolved once per incoming command in dispatch (Auth owns load rules). */
struct LoBBSSession {
uint32_t nodeId = 0;
uint64_t userUuid = 0;
char username[LOBBS_USERNAME_BUFFER_SIZE] = {0};
bool isSysop = false;
char cwd[LOBBS_CWD_BUFFER_SIZE] = "/";
};
struct LoBBSCommandCtx {
LoBBSModule *mod = nullptr;
const meshtastic_MeshPacket *mp = nullptr;
uint32_t reqId = 0;
LoBBSSession session;
/** Remainder after verb; mutable cursor for shift/takePage. */
char *rest = nullptr;
uint32_t page = 1;
};
#endif
+378
View File
@@ -0,0 +1,378 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "LoBBSCommandRegistry.h"
#include "LoBBSHooks.h"
#include "LoBBSInstall.h"
#include "LoBBSModule.h"
#include "LoBBSReply.h"
#include "LoBBSReplyCache.h"
#include "LoBBSResponse.h"
#include "gps/RTC.h"
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <lodb/LoDB.h>
#include "LoBBSStackGuard.h"
static void lobbsSkipWs(char *&p)
{
while (p && (*p == ' ' || *p == '\t'))
p++;
}
static bool lobbsParsePageToken(const char *tok, uint32_t &pageOut)
{
if (!tok || (tok[0] != 'p' && tok[0] != 'P') || !tok[1])
return false;
for (size_t i = 1; tok[i]; i++) {
if (!isdigit((unsigned char)tok[i]))
return false;
}
pageOut = (uint32_t)atoi(tok + 1);
if (pageOut == 0)
pageOut = 1;
return true;
}
void lobbsCommandReply(LoBBSCommandCtx &ctx, const char *body)
{
if (!body)
body = "";
LoBBSResponse resp;
if (strchr(body, '\n')) {
LoScalar rec;
rec.setString(LODB_F_DESCRIPTION, body);
lobbsResponseAppendRecord(resp, rec);
} else {
lobbsRecordPush(resp.records, body);
}
lobbsCommandReplyResponse(ctx, resp);
}
const char *lobbsArgShift(LoBBSCommandCtx &ctx)
{
if (!ctx.rest)
return nullptr;
lobbsSkipWs(ctx.rest);
if (!ctx.rest[0])
return nullptr;
char *start = ctx.rest;
while (*ctx.rest && *ctx.rest != ' ' && *ctx.rest != '\t')
ctx.rest++;
if (*ctx.rest) {
*ctx.rest++ = '\0';
lobbsSkipWs(ctx.rest);
}
return start;
}
/** Next token without consuming it. Valid until the next peek; long tokens are truncated. */
const char *lobbsArgPeek(const LoBBSCommandCtx &ctx)
{
static char tok[64];
if (!ctx.rest)
return nullptr;
char *p = ctx.rest;
lobbsSkipWs(p);
if (!p[0])
return nullptr;
size_t n = 0;
while (p[n] && p[n] != ' ' && p[n] != '\t' && n + 1 < sizeof(tok)) {
tok[n] = p[n];
n++;
}
tok[n] = '\0';
return tok;
}
const char *lobbsArgRest(LoBBSCommandCtx &ctx)
{
if (!ctx.rest)
return "";
lobbsSkipWs(ctx.rest);
return ctx.rest;
}
bool lobbsArgHasMore(const LoBBSCommandCtx &ctx)
{
return lobbsArgPeek(ctx) != nullptr;
}
static bool lobbsTokIsUint(const char *tok, uint32_t &out)
{
if (!tok || !tok[0])
return false;
char *end = nullptr;
unsigned long v = strtoul(tok, &end, 10);
if (end == tok || (end && *end != '\0'))
return false;
if (v > UINT32_MAX)
return false;
out = (uint32_t)v;
return true;
}
static bool lobbsArgPeekUint(const LoBBSCommandCtx &ctx, uint32_t &out)
{
return lobbsTokIsUint(lobbsArgPeek(ctx), out);
}
static bool lobbsCommandIsPageOnly(const char *verb, const char *rest, uint32_t &pageOut)
{
pageOut = 1;
if (!verb || !verb[0])
return false;
if (lobbsParsePageToken(verb, pageOut))
return !rest || !rest[0];
if ((verb[0] == 'p' || verb[0] == 'P') && verb[1] == '\0' && lobbsTokIsUint(rest, pageOut))
return true;
return false;
}
bool lobbsArgPeekIsUint(const LoBBSCommandCtx &ctx)
{
uint32_t dummy = 0;
return lobbsArgPeekUint(ctx, dummy);
}
bool lobbsArgShiftUint(LoBBSCommandCtx &ctx, uint32_t &out)
{
const char *tok = lobbsArgShift(ctx);
return lobbsTokIsUint(tok, out);
}
int lobbsArgShiftMany(LoBBSCommandCtx &ctx, const char *out[], int maxOut)
{
if (!out || maxOut <= 0)
return 0;
int n = 0;
while (n < maxOut) {
const char *t = lobbsArgShift(ctx);
if (!t)
break;
out[n++] = t;
}
return n;
}
static bool lobbsHelpQueryMatches(const char *query, const char *prefix)
{
if (!query || !prefix || !prefix[0])
return false;
if (strcasecmp(query, prefix) == 0)
return true;
size_t plen = strlen(prefix);
if (strncasecmp(query, prefix, plen) != 0)
return false;
return query[plen] == ' ';
}
bool lobbsSlashVerbIs(const LoScalar &args, const char *verb)
{
std::string v;
return verb && args.getString(LOBBS_ARG_VERB, v) && strcasecmp(v.c_str(), verb) == 0;
}
static bool lobbsHelpVisible(const LoBBSCommandCtx *ctx, uint8_t flags)
{
return !(flags & LOBBS_V_SYSOP) || (ctx && lobbsCtxIsSysop(*ctx));
}
void lobbsHelpForTable(const LoBBSCommandCtx *ctx, LoScalar &value, const LoScalar &args, const char *topic,
const LoBBSVerb *table, size_t count)
{
std::string query;
if (!topic || !table || !args.getString(LODB_F_TITLE, query) || !lobbsHelpQueryMatches(query.c_str(), topic))
return;
std::string body;
bool whole = strcasecmp(query.c_str(), topic) == 0;
for (size_t i = 0; i < count; i++) {
if (!table[i].help || !table[i].verb || !lobbsHelpVisible(ctx, table[i].flags))
continue;
if (!whole) {
char path[80];
snprintf(path, sizeof(path), "%s %s", topic, table[i].verb);
if (strcasecmp(query.c_str(), path) != 0)
continue;
}
if (!body.empty())
body.push_back('\n');
body += table[i].help;
if (!whole)
break;
}
if (!body.empty())
value.setString(LODB_F_DESCRIPTION, body);
}
bool lobbsDispatchSub(LoBBSCommandCtx &ctx, const LoBBSVerb *table, size_t count)
{
if (!table || count == 0)
return false;
const char *sub = lobbsArgPeek(ctx);
if (!sub || strcasecmp(sub, "list") == 0) {
for (size_t i = 0; i < count; i++) {
if (!table[i].verb || !table[i].fn || strcasecmp(table[i].verb, "list") != 0)
continue;
if (sub && strcasecmp(sub, "list") == 0)
lobbsArgShift(ctx);
if ((table[i].flags & LOBBS_V_LOGIN) && !lobbsCommandRequireLogin(ctx))
return true;
if ((table[i].flags & LOBBS_V_SYSOP) && !lobbsCommandRequireSysop(ctx))
return true;
table[i].fn(ctx);
return true;
}
}
if (!sub)
return false;
for (size_t i = 0; i < count; i++) {
if (!table[i].verb || !table[i].fn || strcasecmp(sub, table[i].verb) != 0)
continue;
if ((table[i].flags & LOBBS_V_LOGIN) && !lobbsCommandRequireLogin(ctx))
return true;
if ((table[i].flags & LOBBS_V_SYSOP) && !lobbsCommandRequireSysop(ctx))
return true;
table[i].fn(ctx);
return true;
}
return false;
}
bool lobbsCtxLoggedIn(const LoBBSCommandCtx &ctx)
{
return ctx.session.userUuid != 0;
}
bool lobbsCtxIsSysop(const LoBBSCommandCtx &ctx)
{
return lobbsCtxLoggedIn(ctx) && ctx.session.isSysop;
}
uint64_t lobbsCtxUserUuid(const LoBBSCommandCtx &ctx)
{
return ctx.session.userUuid;
}
bool lobbsCtxUsername(const LoBBSCommandCtx &ctx, char *buf, size_t bufCap)
{
if (!buf || bufCap == 0)
return false;
buf[0] = '\0';
if (!lobbsCtxLoggedIn(ctx))
return false;
strncpy(buf, ctx.session.username, bufCap - 1);
buf[bufCap - 1] = '\0';
return buf[0] != '\0';
}
bool lobbsCommandRequireLogin(LoBBSCommandCtx &ctx)
{
if (lobbsCtxLoggedIn(ctx))
return true;
LoBBSResponse resp;
lobbsResponseSetError(resp, "Login required.");
lobbsCommandReplyResponse(ctx, resp);
return false;
}
bool lobbsCommandRequireSysop(LoBBSCommandCtx &ctx)
{
if (lobbsCtxIsSysop(ctx))
return true;
LoBBSResponse resp;
lobbsResponseSetError(resp, "SysOp only.");
lobbsCommandReplyResponse(ctx, resp);
return false;
}
static bool lobbsPeelSlashLine(char *line, LoBBSCommandCtx &ctx, char **verbOut, char **restOut)
{
ctx.reqId = 0;
if (!line || line[0] != '/')
return false;
char *p = line + 1;
if (isdigit((unsigned char)*p)) {
char *start = p;
while (isdigit((unsigned char)*p))
p++;
if (*p == ' ') {
*p++ = '\0';
ctx.reqId = (uint32_t)strtoul(start, nullptr, 10);
lobbsSkipWs(p);
} else {
p = start;
}
}
lobbsSkipWs(p);
if (!*p)
return false;
*verbOut = p;
while (*p && *p != ' ' && *p != '\t')
p++;
if (*p) {
*p++ = '\0';
lobbsSkipWs(p);
}
*restOut = p;
return true;
}
void lobbsCommandsHandle(LoBBSModule *mod, const meshtastic_MeshPacket &mp, const LoBBSSession &session, char *line)
{
LoBBSCommandCtx ctx;
ctx.mod = mod;
ctx.mp = &mp;
ctx.session = session;
ctx.page = 1;
lobbsReplyCacheGc(getTime());
char *verb = nullptr;
char *rest = nullptr;
if (!lobbsPeelSlashLine(line, ctx, &verb, &rest))
return;
if (!verb[0]) {
lobbsCommandReplyError(ctx, "Missing command.");
return;
}
uint32_t pageOnly = 1;
if (lobbsCommandIsPageOnly(verb, rest, pageOnly)) {
ctx.page = pageOnly;
lobbsReplySendCachedPage(ctx);
return;
}
const LoBBSInstallState install = mod->installState();
const bool isHelp = strcasecmp(verb, "help") == 0;
const bool isInstall = strcasecmp(verb, "install") == 0;
if (install != LoBBSInstallState::Ready && !isHelp && !isInstall) {
if (install == LoBBSInstallState::Blank) {
char mounts[48];
lobbsInstallMountList(ctx, mounts, sizeof(mounts));
char msg[160];
snprintf(msg, sizeof(msg), "New LoBBS. Sysop: /install <%s> <user> <pass>", mounts[0] ? mounts : "flash");
lobbsCommandReplyError(ctx, msg);
} else {
char msg[96];
snprintf(msg, sizeof(msg), "LoBBS offline: cannot open %s.", lobbsInstallOfflineHome(*mod));
lobbsCommandReplyError(ctx, msg);
}
return;
}
ctx.rest = rest;
LoScalar args;
args.setString(LOBBS_ARG_VERB, verb);
args.setString(LOBBS_ARG_REST, rest);
lobbsDoAction("slash_cmd", ctx, args);
}
#endif
+48
View File
@@ -0,0 +1,48 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "LoBBSCommandCtx.h"
#include "LoBBSHooks.h"
#include <stddef.h>
#include <stdint.h>
class LoBBSModule;
typedef struct _meshtastic_MeshPacket meshtastic_MeshPacket;
static constexpr uint8_t LOBBS_V_LOGIN = 1;
static constexpr uint8_t LOBBS_V_SYSOP = 2;
struct LoBBSVerb {
const char *verb;
void (*fn)(LoBBSCommandCtx &);
uint8_t flags;
const char *help;
};
void lobbsCommandReply(LoBBSCommandCtx &ctx, const char *body);
void lobbsCommandReplyError(LoBBSCommandCtx &ctx, const char *message);
bool lobbsCtxLoggedIn(const LoBBSCommandCtx &ctx);
bool lobbsCtxIsSysop(const LoBBSCommandCtx &ctx);
uint64_t lobbsCtxUserUuid(const LoBBSCommandCtx &ctx);
bool lobbsCtxUsername(const LoBBSCommandCtx &ctx, char *buf, size_t bufCap);
bool lobbsCommandRequireLogin(LoBBSCommandCtx &ctx);
bool lobbsCommandRequireSysop(LoBBSCommandCtx &ctx);
const char *lobbsArgShift(LoBBSCommandCtx &ctx);
const char *lobbsArgPeek(const LoBBSCommandCtx &ctx);
const char *lobbsArgRest(LoBBSCommandCtx &ctx);
bool lobbsArgHasMore(const LoBBSCommandCtx &ctx);
bool lobbsArgPeekIsUint(const LoBBSCommandCtx &ctx);
bool lobbsArgShiftUint(LoBBSCommandCtx &ctx, uint32_t &out);
bool lobbsSlashVerbIs(const LoScalar &args, const char *verb);
/** help_for_topic helper: sets value's description when args' query is `topic` or `topic <verb>`. LOBBS_V_SYSOP rows are
* hidden unless ctx is a sysop. Rows with fn == nullptr are help-only. */
void lobbsHelpForTable(const LoBBSCommandCtx *ctx, LoScalar &value, const LoScalar &args, const char *topic,
const LoBBSVerb *table, size_t count);
bool lobbsDispatchSub(LoBBSCommandCtx &ctx, const LoBBSVerb *table, size_t count);
int lobbsArgShiftMany(LoBBSCommandCtx &ctx, const char *out[], int maxOut);
void lobbsCommandsHandle(LoBBSModule *mod, const meshtastic_MeshPacket &mp, const LoBBSSession &session, char *line);
#endif
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#define LOBBS_MAX_USERNAME_LEN 32
#define LOBBS_USERNAME_BUFFER_SIZE (LOBBS_MAX_USERNAME_LEN + 1)
/** Mail/news body stored in LoDB (LOBBS_REPLY_BYTES caps the DM). */
#define LOBBS_MESSAGE_BODY_MAX 200
#define LOBBS_MESSAGE_BODY_BUFFER_SIZE (LOBBS_MESSAGE_BODY_MAX + 1)
/** Body excerpt in a read reply (header + body must fit LOBBS_REPLY_BYTES). */
#define LOBBS_MESSAGE_READ_BODY_MAX 120
#define LOBBS_MESSAGE_READ_BODY_BUFFER_SIZE (LOBBS_MESSAGE_READ_BODY_MAX + 1)
/** `lobbsAppTimeAgo` (e.g. "999999d ago"). */
#define LOBBS_TIME_AGO_BUFFER_SIZE 32
/** Inbox/news list line: truncated preview of the message. */
#define LOBBS_LIST_LINE_TRUNC_BUFFER_SIZE 50
#define LOBBS_LIST_LINE_TRUNC_MAX_CHARS 25
/** SysOp working directory for /cd (held in the RAM session slot). */
#define LOBBS_CWD_BUFFER_SIZE 128
/** Shared in /hi and /help root (omit topic for list, or name a command). */
#define LOBBS_HELP_HINT "Use /help [topic] for help"
#ifndef LOBBS_EXTRA_QSPI
#define LOBBS_EXTRA_QSPI 0
#endif
#if defined(LOBBS_DEMO_MODE) || defined(PIO_UNIT_TESTING)
#define LOBBS_SEED 1
#endif
#endif
+80
View File
@@ -0,0 +1,80 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "LoBBSDispatch.h"
#include "LoBBSCommandRegistry.h"
#include "LoBBSModule.h"
#include "apps/Auth/AuthDal.h"
#include "mesh/MeshTypes.h"
#include "mesh/NodeDB.h"
#include <cstring>
#include "LoBBSStackGuard.h"
static void lobbsTrimLine(char *line)
{
if (!line)
return;
size_t len = strlen(line);
while (len > 0 && (line[len - 1] == ' ' || line[len - 1] == '\t' || line[len - 1] == '\r' || line[len - 1] == '\n'))
line[--len] = '\0';
char *start = line;
while (*start == ' ' || *start == '\t')
start++;
if (start != line)
memmove(line, start, strlen(start) + 1);
}
static LoBBSSession lobbsResolveSession(LoBBSModule *mod, uint32_t wireNodeId)
{
LoBBSSession session;
session.nodeId = wireNodeId;
AuthDal &auth = mod->auth().dal();
LoScalar userRow;
uint32_t sessionNodeId = wireNodeId;
uint64_t authUserUuid = 0;
std::string cwd;
if (!auth.loadUserByNodeId(wireNodeId, &userRow, &sessionNodeId, &authUserUuid, &cwd))
return session;
session.nodeId = sessionNodeId;
session.userUuid = authUserUuid;
if (!cwd.empty() && cwd[0] == '/' && cwd.size() < sizeof(session.cwd))
memcpy(session.cwd, cwd.c_str(), cwd.size() + 1);
if (!AuthDal::userUsername(userRow, session.username, sizeof(session.username)))
session.username[0] = '\0';
session.isSysop = AuthDal::userIsSysop(userRow);
return session;
}
ProcessMessage lobbsDispatchReceived(LoBBSModule *mod, const meshtastic_MeshPacket &mp)
{
if (!isToUs(&mp))
return ProcessMessage::CONTINUE;
if (mp.decoded.payload.size == 0)
return ProcessMessage::CONTINUE;
size_t copyLen = mp.decoded.payload.size;
if (copyLen >= sizeof(mod->msgBuffer))
copyLen = sizeof(mod->msgBuffer) - 1;
memcpy(mod->msgBuffer, mp.decoded.payload.bytes, copyLen);
mod->msgBuffer[copyLen] = '\0';
lobbsTrimLine(mod->msgBuffer);
if (mod->msgBuffer[0] == '\0' || mod->msgBuffer[0] != '/')
return ProcessMessage::CONTINUE;
const LoBBSSession session = lobbsResolveSession(mod, getFrom(&mp));
lobbsCommandsHandle(mod, mp, session, mod->msgBuffer);
#ifdef ARCH_NRF52
// The loop stack has no overflow trap; warn once while there is still room to see the log.
static bool lowStackWarned = false;
unsigned headroom = (unsigned)(uxTaskGetStackHighWaterMark(NULL) * sizeof(StackType_t));
if (!lowStackWarned && headroom < 1024) {
lowStackWarned = true;
LOG_WARN("LoBBS loop stack headroom low: %u B", headroom);
}
#endif
return ProcessMessage::CONTINUE;
}
#endif
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "MeshModule.h"
#include "mesh/generated/meshtastic/mesh.pb.h"
class LoBBSModule;
ProcessMessage lobbsDispatchReceived(LoBBSModule *mod, const meshtastic_MeshPacket &mp);
#endif
+100
View File
@@ -0,0 +1,100 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "LoBBSHooks.h"
#include "configuration.h"
#include <cstring>
#include "LoBBSStackGuard.h"
enum class LobbsHookKind : uint8_t { Action, Record, List };
struct LobbsHookEntry {
const char *name = nullptr;
LobbsHookKind kind = LobbsHookKind::Action;
void (*fn)() = nullptr;
int priority = LOBBS_HOOK_PRIORITY_DEFAULT;
};
static std::vector<LobbsHookEntry> lobbsHooks;
void lobbsHooksReset()
{
lobbsHooks.clear();
}
static void lobbsAddHook(const char *name, LobbsHookKind kind, void (*fn)(), int priority)
{
if (!name || !fn)
return;
LobbsHookEntry entry{name, kind, fn, priority};
size_t insertAt = lobbsHooks.size();
for (size_t i = 0; i < lobbsHooks.size(); i++) {
if (priority < lobbsHooks[i].priority) {
insertAt = i;
break;
}
}
lobbsHooks.insert(lobbsHooks.begin() + insertAt, entry);
}
void lobbsAddAction(const char *name, LoBBSActionFn fn, int priority)
{
lobbsAddHook(name, LobbsHookKind::Action, reinterpret_cast<void (*)()>(fn), priority);
}
void lobbsAddFilter(const char *name, LoBBSRecordFilterFn fn, int priority)
{
lobbsAddHook(name, LobbsHookKind::Record, reinterpret_cast<void (*)()>(fn), priority);
}
void lobbsAddFilter(const char *name, LoBBSListFilterFn fn, int priority)
{
lobbsAddHook(name, LobbsHookKind::List, reinterpret_cast<void (*)()>(fn), priority);
}
void lobbsDoAction(const char *name, LoBBSCommandCtx &ctx, const LoScalar &args)
{
if (!name)
return;
for (size_t i = 0; i < lobbsHooks.size(); i++) {
if (!lobbsHooks[i].name || strcasecmp(lobbsHooks[i].name, name) != 0)
continue;
if (lobbsHooks[i].kind != LobbsHookKind::Action) {
LOG_ERROR("LoBBS hook %s: handler registered with the wrong kind", name);
continue;
}
reinterpret_cast<LoBBSActionFn>(lobbsHooks[i].fn)(&ctx, args);
}
}
void lobbsApplyFilter(const char *name, LoBBSCommandCtx &ctx, LoScalar &value, const LoScalar &args)
{
if (!name)
return;
for (size_t i = 0; i < lobbsHooks.size(); i++) {
if (!lobbsHooks[i].name || strcasecmp(lobbsHooks[i].name, name) != 0)
continue;
if (lobbsHooks[i].kind != LobbsHookKind::Record) {
LOG_ERROR("LoBBS hook %s: handler registered with the wrong kind", name);
continue;
}
reinterpret_cast<LoBBSRecordFilterFn>(lobbsHooks[i].fn)(&ctx, value, args);
}
}
void lobbsApplyFilter(const char *name, LoBBSCommandCtx &ctx, std::vector<LoScalar> &value, const LoScalar &args)
{
if (!name)
return;
for (size_t i = 0; i < lobbsHooks.size(); i++) {
if (!lobbsHooks[i].name || strcasecmp(lobbsHooks[i].name, name) != 0)
continue;
if (lobbsHooks[i].kind != LobbsHookKind::List) {
LOG_ERROR("LoBBS hook %s: handler registered with the wrong kind", name);
continue;
}
reinterpret_cast<LoBBSListFilterFn>(lobbsHooks[i].fn)(&ctx, value, args);
}
}
#endif
+61
View File
@@ -0,0 +1,61 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "LoBBSCommandCtx.h"
#include "LoBBSResponse.h"
#include <loscalar/LoScalar.h>
#include <stddef.h>
#include <stdint.h>
#include <string>
#include <vector>
/**
* Hook bus modeled on WordPress actions and filters.
*
* A filter threads one value through its handlers in priority order. The caller supplies the
* initial value (defaults go there), each handler modifies it in place or leaves it alone, and the
* caller gets the result. Actions are fire-and-forget. Every hook receives `args` as a LoScalar.
*
* The hook name fixes the value kind and the field conventions:
*
* | Hook | Kind | Initial value (caller) | args |
* |------------------|--------|---------------------------------|-----------------------|
* | `slash_cmd` | action | n/a | verb, rest |
* | `seed` | action | n/a | none |
* | `help_topics` | list | builtin topics | none |
* | `help_for_topic` | record | title = topic, no description | title = query |
* | `status_lines` | list | empty | none |
* | `display_human` | record | title = generic rendering | the record to render |
* | `config_keys` | list | empty | none |
* | `config_validate`| record | title = key, field 0 = value | key definition record |
* | `config_changed` | action | n/a | title = key, field 0 = value |
* | `install_mounts` | list | empty (title = mount name) | none |
*
* Records use `title` and `description` (LODB_F_TITLE / LODB_F_DESCRIPTION) unless noted.
* Validation filters may set LODB_F_ERROR on the value record; set succeeds only if it stays empty.
* `slash_cmd` args use LOBBS_ARG_VERB / LOBBS_ARG_REST; C++ handlers parse ctx->rest.
*/
static constexpr int LOBBS_HOOK_PRIORITY_HELP = 0;
static constexpr int LOBBS_HOOK_PRIORITY_AUTH = 10;
static constexpr int LOBBS_HOOK_PRIORITY_FEATURE = 20;
static constexpr int LOBBS_HOOK_PRIORITY_STATUS = 30;
static constexpr int LOBBS_HOOK_PRIORITY_TIME = 31;
static constexpr int LOBBS_HOOK_PRIORITY_DEFAULT = 10;
static constexpr uint32_t LOBBS_ARG_VERB = 0;
static constexpr uint32_t LOBBS_ARG_REST = 1;
using LoBBSActionFn = void (*)(LoBBSCommandCtx *ctx, const LoScalar &args);
using LoBBSRecordFilterFn = void (*)(LoBBSCommandCtx *ctx, LoScalar &value, const LoScalar &args);
using LoBBSListFilterFn = void (*)(LoBBSCommandCtx *ctx, std::vector<LoScalar> &value, const LoScalar &args);
void lobbsHooksReset();
void lobbsAddAction(const char *name, LoBBSActionFn fn, int priority = LOBBS_HOOK_PRIORITY_DEFAULT);
void lobbsAddFilter(const char *name, LoBBSRecordFilterFn fn, int priority = LOBBS_HOOK_PRIORITY_DEFAULT);
void lobbsAddFilter(const char *name, LoBBSListFilterFn fn, int priority = LOBBS_HOOK_PRIORITY_DEFAULT);
void lobbsDoAction(const char *name, LoBBSCommandCtx &ctx, const LoScalar &args);
void lobbsApplyFilter(const char *name, LoBBSCommandCtx &ctx, LoScalar &value, const LoScalar &args);
void lobbsApplyFilter(const char *name, LoBBSCommandCtx &ctx, std::vector<LoScalar> &value, const LoScalar &args);
#endif
+314
View File
@@ -0,0 +1,314 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "LoBBSInstall.h"
#include "LoBBSCommandRegistry.h"
#include "LoBBSConfig.h"
#include "LoBBSHooks.h"
#include "LoBBSModule.h"
#include "apps/AppUtil.h"
#include "apps/Auth/AuthDal.h"
#include "configuration.h"
#include "mesh/NodeDB.h"
#include "mesh/Router.h"
#include <cstdio>
#include <cstring>
#include <lodb/LoDB.h>
#include <lofs/LoFS.h>
#include <loscalar/LoScalar.h>
#include "LoBBSBootTrace.h"
#include "LoBBSStackGuard.h"
static LoBBSInstallState gInstallState = LoBBSInstallState::Blank;
static char gInstallRoot[32] = {0};
static char gOfflineHome[32] = {0};
LoBBSInstallState lobbsInstallState(const LoBBSModule &mod)
{
(void)mod;
return gInstallState;
}
const char *lobbsInstallRoot(const LoBBSModule &mod)
{
(void)mod;
return gInstallRoot;
}
const char *lobbsInstallOfflineHome(const LoBBSModule &mod)
{
(void)mod;
return gOfflineHome;
}
static void filterInstallMountsDefault(LoBBSCommandCtx *ctx, std::vector<LoScalar> &value, const LoScalar &args)
{
(void)ctx;
(void)args;
LoFS::eachPresentMount(
[](void *v, const char *name) {
if (!LoFS::mountDbSafe(name))
return;
LoScalar rec;
rec.setString(LODB_F_TITLE, name);
((std::vector<LoScalar> *)v)->push_back(rec);
},
&value);
}
static std::vector<LoScalar> lobbsInstallMounts(LoBBSCommandCtx &ctx)
{
std::vector<LoScalar> mounts;
lobbsApplyFilter("install_mounts", ctx, mounts, LoScalar());
return mounts;
}
void lobbsInstallMountList(LoBBSCommandCtx &ctx, char *out, size_t cap)
{
if (!out || cap == 0)
return;
out[0] = '\0';
std::string name;
for (const LoScalar &rec : lobbsInstallMounts(ctx)) {
if (!rec.getString(LODB_F_TITLE, name))
continue;
size_t len = strlen(out);
if (len && len + 1 < cap)
strncat(out, "|", cap - len - 1);
len = strlen(out);
strncat(out, name.c_str(), cap - len - 1);
}
}
bool lobbsInstallHome(const char *root, char *out, size_t cap)
{
if (!root || root[0] != '/' || !out)
return false;
int n = snprintf(out, cap, "%s/%s", root, LOBBS_HOME_DIR);
return n > 0 && (size_t)n < cap;
}
static bool lobbsInstallMarkerPath(const char *root, char *out, size_t cap)
{
int n = snprintf(out, cap, "%s/%s/%s", root, LOBBS_HOME_DIR, LOBBS_INSTALL_MARKER_NAME);
return n > 0 && (size_t)n < cap;
}
bool lobbsInstallWriteMarker(const char *root)
{
char path[48];
if (!root || root[0] != '/' || !lobbsInstallMarkerPath(root, path, sizeof(path)))
return false;
LoScalar rec;
rec.setString(LOBBS_INSTALL_FIELD_VERSION, LOBBS_VERSION);
std::string line;
if (!rec.encode(line, 256))
return false;
File f = LoFS::open(path, FILE_O_WRITE);
if (!f)
return false;
size_t w = f.write((const uint8_t *)line.data(), line.size());
f.flush();
f.close();
return w == line.size();
}
static bool lobbsOpenHome(LoBBSModule &mod, const char *root)
{
char home[32];
LoDb *db = mod.lodb();
return db && lobbsInstallHome(root, home, sizeof(home)) && db->open(home) == LODB_OK;
}
void lobbsInstallInit(LoBBSModule &mod)
{
gInstallState = LoBBSInstallState::Blank;
gInstallRoot[0] = '\0';
gOfflineHome[0] = '\0';
LoBBSCommandCtx ctx;
ctx.mod = &mod;
char root[16] = {0};
std::string name;
for (const LoScalar &rec : lobbsInstallMounts(ctx)) {
char candidate[16], marker[48];
if (!rec.getString(LODB_F_TITLE, name))
continue;
snprintf(candidate, sizeof(candidate), "/%s", name.c_str());
if (!lobbsInstallMarkerPath(candidate, marker, sizeof(marker)) || !LoFS::exists(marker))
continue;
if (root[0])
LOG_WARN("LoBBS: ignoring second install at %s (using %s)", candidate, root);
else
strncpy(root, candidate, sizeof(root) - 1);
}
if (!root[0]) {
LOG_INFO("LoBBS: blank (no install found)");
return;
}
if (!lobbsOpenHome(mod, root)) {
gInstallState = LoBBSInstallState::Offline;
lobbsInstallHome(root, gOfflineHome, sizeof(gOfflineHome));
LOG_ERROR("LoBBS offline: failed to open database at %s", gOfflineHome);
return;
}
strncpy(gInstallRoot, root, sizeof(gInstallRoot) - 1);
gInstallState = LoBBSInstallState::Ready;
lobbsInstallDatabaseOpened(mod);
LOG_INFO("LoBBS ready at %s", root);
}
void lobbsInstallDatabaseOpened(LoBBSModule &mod)
{
mod.auth().dal().clearSessions();
mod.config().dal().notifyDatabaseOpened(mod);
}
bool lobbsInstallAuthorized(const meshtastic_MeshPacket &mp)
{
if (mp.from == 0)
return true;
#if !MESHTASTIC_EXCLUDE_PKI
if (mp.pki_encrypted) {
if ((config.security.admin_key[0].size == 32 &&
memcmp(mp.public_key.bytes, config.security.admin_key[0].bytes, 32) == 0) ||
(config.security.admin_key[1].size == 32 &&
memcmp(mp.public_key.bytes, config.security.admin_key[1].bytes, 32) == 0) ||
(config.security.admin_key[2].size == 32 && memcmp(mp.public_key.bytes, config.security.admin_key[2].bytes, 32) == 0))
return true;
}
#endif
return false;
}
static bool lobbsInstallLocToRoot(LoBBSCommandCtx &ctx, const char *loc, char *rootOut, size_t cap)
{
if (!loc || !rootOut)
return false;
std::string name;
for (const LoScalar &rec : lobbsInstallMounts(ctx)) {
if (rec.getString(LODB_F_TITLE, name) && name == loc && LoFS::mountPresent(loc)) {
snprintf(rootOut, cap, "/%s", loc);
return true;
}
}
return false;
}
static void handleInstall(LoBBSCommandCtx &ctx)
{
if (gInstallState != LoBBSInstallState::Blank) {
char msg[64];
snprintf(msg, sizeof(msg), "Already installed at %s.", gInstallRoot[0] ? gInstallRoot : gOfflineHome);
lobbsCommandReplyError(ctx, msg);
return;
}
if (!lobbsInstallAuthorized(*ctx.mp)) {
lobbsCommandReplyError(ctx, "Not authorized.");
return;
}
const char *loc = lobbsArgShift(ctx);
const char *user = lobbsArgShift(ctx);
const char *pass = lobbsArgShift(ctx);
if (!loc || !user || !pass || lobbsArgHasMore(ctx)) {
char mounts[48];
lobbsInstallMountList(ctx, mounts, sizeof(mounts));
char msg[96];
snprintf(msg, sizeof(msg), "Usage: /install <%s> <user> <pass>", mounts);
lobbsCommandReplyError(ctx, msg);
return;
}
char root[16];
if (!lobbsInstallLocToRoot(ctx, loc, root, sizeof(root))) {
lobbsCommandReplyError(ctx, "Mount not available.");
return;
}
if (!lobbsOpenHome(*ctx.mod, root)) {
lobbsCommandReplyError(ctx, "Failed to open database.");
return;
}
AuthDal &auth = ctx.mod->auth().dal();
lobbsInstallDatabaseOpened(*ctx.mod);
const bool hasUsers = auth.countAllUsers() > 0;
if (hasUsers) {
LoScalar row;
if (!auth.loadUserByUsername(user, &row) || !AuthDal::userIsSysop(row) || !auth.verifyPassword(&row, pass)) {
lobbsCommandReplyError(ctx, "Existing database: sysop credentials required.");
return;
}
auth.loginUser(user, getFrom(ctx.mp));
} else if (LoDbError err = auth.createUser(user, pass, getFrom(ctx.mp), true)) {
lobbsCommandReplyError(ctx, lobbsDbErrorText(err, "Failed to create sysop."));
return;
}
if (!lobbsInstallWriteMarker(root)) {
lobbsCommandReplyError(ctx, "Failed to write install marker.");
return;
}
strncpy(gInstallRoot, root, sizeof(gInstallRoot) - 1);
gInstallState = LoBBSInstallState::Ready;
lobbsCommandReply(ctx, "Installed.");
}
static void slashInstall(LoBBSCommandCtx *ctx, const LoScalar &args)
{
std::string v;
if (!ctx || !args.getString(LOBBS_ARG_VERB, v))
return;
if (strcasecmp(v.c_str(), "install") != 0)
return;
handleInstall(*ctx);
}
void lobbsInstallRegisterCommands()
{
lobbsAddAction("slash_cmd", slashInstall, LOBBS_HOOK_PRIORITY_AUTH - 1);
lobbsAddFilter("install_mounts", filterInstallMountsDefault);
}
#if LOBBS_SEED
void lobbsInstallAutoSeed(LoBBSModule &mod)
{
LOBBS_BOOT_STEP("autoseed: enter");
gInstallState = LoBBSInstallState::Blank;
gInstallRoot[0] = '\0';
gOfflineHome[0] = '\0';
LoBBSCommandCtx ctx;
ctx.mod = &mod;
LOBBS_BOOT_STEP("autoseed: install_mounts");
std::vector<LoScalar> mounts = lobbsInstallMounts(ctx);
std::string name;
if (mounts.empty() || !mounts[0].getString(LODB_F_TITLE, name)) {
LOG_ERROR("LoBBS seed: no install mount");
return;
}
char root[16];
snprintf(root, sizeof(root), "/%s", name.c_str());
char home[32];
lobbsInstallHome(root, home, sizeof(home));
LOG_INFO("LoBBS> autoseed: rmdir %s", home);
LoFS::rmdir(home, true);
LOG_INFO("LoBBS> autoseed: lodb open %s", home);
if (!lobbsOpenHome(mod, root) || !lobbsInstallWriteMarker(root)) {
LOG_ERROR("LoBBS seed install failed at %s", root);
return;
}
LOBBS_BOOT_STEP("autoseed: lobbsInstallDatabaseOpened");
lobbsInstallDatabaseOpened(mod);
strncpy(gInstallRoot, root, sizeof(gInstallRoot) - 1);
gInstallState = LoBBSInstallState::Ready;
LOG_INFO("LoBBS seed install at %s", root);
}
#endif
#endif
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "LoBBSConfig.h"
#include <stddef.h>
#include <stdint.h>
class LoBBSModule;
struct LoBBSCommandCtx;
typedef struct _meshtastic_MeshPacket meshtastic_MeshPacket;
enum class LoBBSInstallState : uint8_t { Blank, Ready, Offline };
/** Every LoBBS file lives under `<mount root>/lobbs`: `install.ls`, `db/` (LoDB), `apps/<app>/`, `home/<user>/`. */
static constexpr const char *LOBBS_HOME_DIR = "lobbs";
static constexpr const char *LOBBS_INSTALL_MARKER_NAME = "install.ls";
static constexpr uint32_t LOBBS_INSTALL_FIELD_VERSION = 0;
LoBBSInstallState lobbsInstallState(const LoBBSModule &mod);
/** Mount root of the install (e.g. `/extra`), empty when not Ready. */
const char *lobbsInstallRoot(const LoBBSModule &mod);
/** LoBBS home that failed to open while Offline (e.g. `/extra/lobbs`). */
const char *lobbsInstallOfflineHome(const LoBBSModule &mod);
void lobbsInstallInit(LoBBSModule &mod);
void lobbsInstallDatabaseOpened(LoBBSModule &mod);
/** `install_mounts` names joined with `|`, e.g. `sd|flash2`. */
void lobbsInstallMountList(LoBBSCommandCtx &ctx, char *out, size_t cap);
bool lobbsInstallAuthorized(const meshtastic_MeshPacket &mp);
/** `<root>/lobbs`, e.g. `/extra/lobbs`. */
bool lobbsInstallHome(const char *root, char *out, size_t cap);
/** Writes `<root>/lobbs/install.ls`; its presence marks the install. */
bool lobbsInstallWriteMarker(const char *root);
#if LOBBS_SEED
void lobbsInstallAutoSeed(LoBBSModule &mod);
#endif
void lobbsInstallRegisterCommands();
#endif
+117
View File
@@ -0,0 +1,117 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "LoBBSModule.h"
#include "LoBBSConfig.h"
#include "LoBBSDispatch.h"
#include "LoBBSInstall.h"
#include "LoBBSWireup.h"
#include <lofs/LoFS.h>
#if LOBBS_SEED
#include "LoBBSSeed.h"
#endif
#include "LoBBSReply.h"
#include "MeshService.h"
#include <cstdio>
#include <cstring>
#include "LoBBSBootTrace.h"
#include "LoBBSStackGuard.h"
LoBBSModule::LoBBSModule()
: SinglePortModule("LoBBS", meshtastic_PortNum_TEXT_MESSAGE_APP), lodb_(new LoDb("db")), config_(*lodb_), auth_(*lodb_),
mail_(*lodb_), news_(*lodb_), yarn_(*lodb_), wall_(*lodb_)
{
LOBBS_BOOT_STEP("module ctor body");
LOBBS_BOOT_STEP("LoFS::begin");
LoFS::begin();
LOBBS_BOOT_STEP("lobbsWireup");
lobbsWireup();
#ifdef LOBBS_DEMO_MODE
LOBBS_BOOT_STEP("lobbsInstallAutoSeed");
lobbsInstallAutoSeed(*this);
LOBBS_BOOT_STEP("lobbsSeedAll");
lobbsSeedAll(*this);
#else
LOBBS_BOOT_STEP("lobbsInstallInit");
lobbsInstallInit(*this);
#endif
LOBBS_BOOT_STEP("module ctor done");
}
LoBBSInstallState LoBBSModule::installState() const
{
return lobbsInstallState(*this);
}
LoBBSModule::~LoBBSModule()
{
delete lodb_;
}
ProcessMessage LoBBSModule::handleReceived(const meshtastic_MeshPacket &mp)
{
return lobbsDispatchReceived(this, mp);
}
void lobbsBreadcrumb(const char *step)
{
if (!step)
return;
#if !defined(ARCH_PORTDUINO)
Serial.print("INFO | LoBBS ");
Serial.println(step);
#else
LOG_INFO("LoBBS %s", step);
#endif
}
#ifdef PIO_UNIT_TESTING
#include <vector>
std::vector<std::string> *lobbsTestReplySink = nullptr;
#endif
void LoBBSModule::sendReply(const meshtastic_MeshPacket &req, const char *msg)
{
if (!msg)
return;
#ifdef PIO_UNIT_TESTING
if (lobbsTestReplySink) {
lobbsTestReplySink->push_back(msg);
return;
}
#endif
char mark[40];
snprintf(mark, sizeof(mark), "reply alloc %uB", (unsigned)strlen(msg));
lobbsBreadcrumb(mark);
meshtastic_MeshPacket *reply = allocDataPacket();
if (!reply) {
lobbsBreadcrumb("reply alloc failed");
LOG_WARN("LoBBS: no packet for reply (pool exhausted)");
return;
}
static constexpr char truncMarker[] = "[...]";
static constexpr size_t truncMarkerLen = sizeof(truncMarker) - 1;
size_t msgLen = strlen(msg);
bool isTruncated = msgLen > LOBBS_REPLY_BYTES;
size_t payloadSize = isTruncated ? LOBBS_REPLY_BYTES : msgLen;
reply->decoded.payload.size = payloadSize;
if (isTruncated) {
size_t copyLen = LOBBS_REPLY_BYTES > truncMarkerLen ? LOBBS_REPLY_BYTES - truncMarkerLen : 0;
memcpy(reply->decoded.payload.bytes, msg, copyLen);
memcpy(reply->decoded.payload.bytes + copyLen, truncMarker, truncMarkerLen);
} else {
memcpy(reply->decoded.payload.bytes, msg, payloadSize);
}
setReplyTo(reply, req);
reply->decoded.want_response = false;
snprintf(mark, sizeof(mark), "reply send %08x", reply->id);
lobbsBreadcrumb(mark);
// Phone ToRadio uses from=0; RoutingModule only forwards to the app when from != 0.
const bool ccPhone = (req.from == 0);
service->sendToMesh(reply, RX_SRC_LOCAL, ccPhone);
lobbsBreadcrumb("reply out");
}
#endif
+61
View File
@@ -0,0 +1,61 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "LoBBSInstall.h"
#include "LoBBSVersion.h"
#include "MeshModule.h"
#include "SinglePortModule.h"
#include "apps/Auth/Auth.h"
#include "apps/Config/Config.h"
#include "apps/Mail/Mail.h"
#include "apps/News/News.h"
#include "apps/Wall/Wall.h"
#include "apps/Yarn/Yarn.h"
#include <lodb/LoDB.h>
#include <string>
class LoBBSModule;
void lobbsBreadcrumb(const char *step);
class LoBBSModule : public SinglePortModule
{
friend ProcessMessage lobbsDispatchReceived(class LoBBSModule *mod, const meshtastic_MeshPacket &mp);
public:
LoBBSModule();
~LoBBSModule();
void sendReply(const meshtastic_MeshPacket &req, const char *msg);
void sendReply(const meshtastic_MeshPacket &req, const std::string &msg) { sendReply(req, msg.c_str()); }
ConfigApp &config() { return config_; }
AuthApp &auth() { return auth_; }
MailApp &mail() { return mail_; }
NewsApp &news() { return news_; }
WallApp &wall() { return wall_; }
YarnApp &yarn() { return yarn_; }
LoDb *lodb() { return lodb_; }
LoBBSInstallState installState() const;
protected:
virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
char msgBuffer[256];
private:
LoDb *lodb_;
ConfigApp config_;
AuthApp auth_;
MailApp mail_;
NewsApp news_;
WallApp wall_;
YarnApp yarn_;
};
#ifdef PIO_UNIT_TESTING
#include <string>
#include <vector>
extern std::vector<std::string> *lobbsTestReplySink;
#endif
#endif
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include <stddef.h>
#include <stdint.h>
static constexpr size_t LOBBS_REPLY_BYTES = 200;
#endif
+83
View File
@@ -0,0 +1,83 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "LoBBSReplyCache.h"
#include "apps/Config/ConfigCommands.h"
#include "gps/RTC.h"
#include <cstddef>
#include <map>
#include <string>
#include "LoBBSStackGuard.h"
struct LobbsReplyCacheEntry {
LoBBSResponse resp;
uint32_t expiresSec = 0;
};
static std::map<uint32_t, LobbsReplyCacheEntry> lobbsReplyCacheBySession;
static size_t lobbsResponsePayloadBytes(const LoBBSResponse &resp)
{
size_t n = resp.error.size();
for (const LoScalar &rec : resp.records) {
std::string line;
if (rec.encode(line, SIZE_MAX))
n += line.size() + 1;
}
return n;
}
void lobbsReplyCacheGc(uint32_t nowSec)
{
for (auto it = lobbsReplyCacheBySession.begin(); it != lobbsReplyCacheBySession.end();) {
if (nowSec > it->second.expiresSec)
it = lobbsReplyCacheBySession.erase(it);
else
++it;
}
while (lobbsReplyCacheBySession.size() > LOBBS_REPLY_CACHE_MAX_ENTRIES) {
lobbsReplyCacheBySession.erase(lobbsReplyCacheBySession.begin());
}
}
void lobbsReplyCacheErase(uint32_t sessionNodeId)
{
lobbsReplyCacheBySession.erase(sessionNodeId);
}
bool lobbsReplyCacheStore(uint32_t sessionNodeId, const LoBBSResponse &resp)
{
if (!resp.ok)
return false;
if (lobbsResponsePayloadBytes(resp) > LOBBS_REPLY_CACHE_MAX_BYTES) {
lobbsReplyCacheErase(sessionNodeId);
return false;
}
LobbsReplyCacheEntry entry;
entry.resp = resp;
entry.expiresSec = getTime() + lobbsReplyCacheTtlSec();
lobbsReplyCacheBySession[sessionNodeId] = std::move(entry);
while (lobbsReplyCacheBySession.size() > LOBBS_REPLY_CACHE_MAX_ENTRIES) {
lobbsReplyCacheBySession.erase(lobbsReplyCacheBySession.begin());
}
return true;
}
bool lobbsReplyCacheLoad(uint32_t sessionNodeId, LoBBSResponse &out)
{
auto it = lobbsReplyCacheBySession.find(sessionNodeId);
if (it == lobbsReplyCacheBySession.end())
return false;
uint32_t now = getTime();
if (now > it->second.expiresSec) {
lobbsReplyCacheBySession.erase(it);
return false;
}
it->second.expiresSec = now + lobbsReplyCacheTtlSec();
out = it->second.resp;
return true;
}
#endif
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "LoBBSResponse.h"
#include <stdint.h>
#ifndef LOBBS_REPLY_CACHE_TTL_SEC
#define LOBBS_REPLY_CACHE_TTL_SEC 300
#endif
#ifndef LOBBS_REPLY_CACHE_MAX_BYTES
#define LOBBS_REPLY_CACHE_MAX_BYTES 8192
#endif
#ifndef LOBBS_REPLY_CACHE_MAX_ENTRIES
#define LOBBS_REPLY_CACHE_MAX_ENTRIES 32
#endif
void lobbsReplyCacheGc(uint32_t nowSec);
void lobbsReplyCacheErase(uint32_t sessionNodeId);
bool lobbsReplyCacheStore(uint32_t sessionNodeId, const LoBBSResponse &resp);
bool lobbsReplyCacheLoad(uint32_t sessionNodeId, LoBBSResponse &out);
#endif
+119
View File
@@ -0,0 +1,119 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "LoBBSResponse.h"
#include "LoBBSModule.h"
#include "LoBBSReply.h"
#include "LoBBSReplyCache.h"
#include "gps/RTC.h"
#include "protocol/machine/paginate.h"
#include "protocol/machine/serialize.h"
#include "protocol/plain_text/paginate.h"
#include "protocol/plain_text/serialize.h"
#include <cstdio>
#include <cstring>
#include <lodb/LoDB.h>
#include "LoBBSStackGuard.h"
void lobbsResponseSetError(LoBBSResponse &resp, const char *message)
{
resp.ok = false;
resp.error = message ? message : "";
resp.records.clear();
}
void lobbsRecordPush(std::vector<LoScalar> &list, const char *title, const char *description)
{
LoScalar rec;
if (title && title[0])
rec.setString(LODB_F_TITLE, title);
if (description && description[0])
rec.setString(LODB_F_DESCRIPTION, description);
list.push_back(rec);
}
void lobbsResponseAppendRecord(LoBBSResponse &resp, const LoScalar &record)
{
resp.records.push_back(record);
}
static void lobbsSendSentence(LoBBSCommandCtx &ctx, const char *msg)
{
if (!ctx.mod || !ctx.mp)
return;
if (!msg)
msg = "";
if (ctx.reqId == 0) {
ctx.mod->sendReply(*ctx.mp, msg);
return;
}
char buf[LOBBS_REPLY_BYTES + 32];
snprintf(buf, sizeof(buf), "<%u>%s", ctx.reqId, msg);
ctx.mod->sendReply(*ctx.mp, buf);
}
static bool lobbsRenderPage(LoBBSCommandCtx &ctx, const LoBBSResponse &resp)
{
const char *errMsg = nullptr;
uint32_t page = ctx.page ? ctx.page : 1;
std::string pageText;
if (ctx.reqId == 0) {
std::string text;
if (!lobbsSerializePlainText(ctx, resp, text)) {
lobbsSendSentence(ctx, "Empty.");
return false;
}
if (!lobbsPaginatePlainText(text, page, pageText, &errMsg)) {
lobbsSendSentence(ctx, errMsg ? errMsg : "Empty.");
return false;
}
ctx.mod->sendReply(*ctx.mp, pageText.c_str());
return true;
}
std::string doc;
if (!lobbsSerializeMachine(resp, doc)) {
lobbsSendSentence(ctx, "Empty.");
return false;
}
if (!lobbsPaginateMachine(ctx.reqId, doc, page, pageText, &errMsg)) {
lobbsSendSentence(ctx, errMsg ? errMsg : "Empty.");
return false;
}
ctx.mod->sendReply(*ctx.mp, pageText.c_str());
return true;
}
void lobbsCommandReplyError(LoBBSCommandCtx &ctx, const char *message)
{
lobbsReplyCacheErase(ctx.session.nodeId);
lobbsSendSentence(ctx, message ? message : "");
}
void lobbsReplySendCachedPage(LoBBSCommandCtx &ctx)
{
if (!ctx.mod || !ctx.mp)
return;
LoBBSResponse resp;
if (!lobbsReplyCacheLoad(ctx.session.nodeId, resp)) {
lobbsSendSentence(ctx, "No cached reply.");
return;
}
lobbsRenderPage(ctx, resp);
}
void lobbsCommandReplyResponse(LoBBSCommandCtx &ctx, const LoBBSResponse &resp)
{
if (!ctx.mod || !ctx.mp)
return;
if (!resp.ok) {
lobbsReplyCacheErase(ctx.session.nodeId);
lobbsSendSentence(ctx, resp.error.c_str());
return;
}
lobbsReplyCacheStore(ctx.session.nodeId, resp);
lobbsRenderPage(ctx, resp);
}
#endif
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "LoBBSCommandCtx.h"
#include <loscalar/LoScalar.h>
#include <string>
#include <vector>
struct LoBBSResponse {
bool ok = true;
std::string error;
std::vector<LoScalar> records;
};
void lobbsResponseSetError(LoBBSResponse &resp, const char *message);
/** Push a `title` (+ optional `description`) record. Empty strings leave the field unset. */
void lobbsRecordPush(std::vector<LoScalar> &list, const char *title, const char *description = nullptr);
void lobbsResponseAppendRecord(LoBBSResponse &resp, const LoScalar &record);
void lobbsCommandReplyResponse(LoBBSCommandCtx &ctx, const LoBBSResponse &resp);
void lobbsCommandReplyError(LoBBSCommandCtx &ctx, const char *message);
void lobbsReplySendCachedPage(LoBBSCommandCtx &ctx);
#endif
+24
View File
@@ -0,0 +1,24 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "LoBBSConfig.h"
#if LOBBS_SEED
#include "LoBBSCommandCtx.h"
#include "LoBBSHooks.h"
#include "LoBBSModule.h"
#include "LoBBSSeed.h"
#include "LoBBSBootTrace.h"
#include "LoBBSStackGuard.h"
void lobbsSeedAll(LoBBSModule &mod)
{
LOBBS_BOOT_STEP("seed all: lobbsDoAction seed");
LoBBSCommandCtx ctx;
ctx.mod = &mod;
lobbsDoAction("seed", ctx, LoScalar());
LOBBS_BOOT_STEP("seed all: done");
}
#endif
#endif
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS && LOBBS_SEED
class LoBBSModule;
void lobbsSeedAll(LoBBSModule &mod);
#endif
+6
View File
@@ -0,0 +1,6 @@
#pragma once
// Include after the other includes in every LoBBS .cpp. Commands run on the nRF52 loop task, whose 4 KB stack
// is shared with the router and has no overflow trap, so each LoBBS function is capped at compile time.
#if defined(ARDUINO_ARCH_NRF52) && defined(__GNUC__) && !defined(__clang__)
#pragma GCC diagnostic error "-Wstack-usage=512"
#endif
+11
View File
@@ -0,0 +1,11 @@
#pragma once
// LoBBS semver comes from version.properties [LOBBS], injected like APP_VERSION in bin/platformio-custom.py
#ifndef LOBBS_VERSION_SHORT
#error LOBBS_VERSION_SHORT must be set by the build environment
#endif
#ifndef LOBBS_VERSION
#error LOBBS_VERSION must be set by the build environment
#endif
+37
View File
@@ -0,0 +1,37 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "LoBBSWireup.h"
#include "LoBBSHooks.h"
#include "LoBBSInstall.h"
#include "apps/Auth/AuthCommands.h"
#include "apps/Config/ConfigCommands.h"
#include "apps/Fs/FsCommands.h"
#include "apps/Help/HelpCommands.h"
#include "apps/Mail/MailCommands.h"
#include "apps/Msg/MsgCommon.h"
#include "apps/News/NewsCommands.h"
#include "apps/Status/StatusCommands.h"
#include "apps/Time/TimeCommands.h"
#include "apps/Wall/WallCommands.h"
#include "apps/Yarn/YarnCommands.h"
#include "LoBBSStackGuard.h"
void lobbsWireup()
{
lobbsHooksReset();
lobbsMsgRegisterDisplay();
lobbsInstallRegisterCommands();
lobbsConfigRegisterCommands();
lobbsHelpRegisterCommands();
lobbsAuthRegisterCommands();
lobbsMailRegisterCommands();
lobbsNewsRegisterCommands();
lobbsYarnRegisterCommands();
lobbsWallRegisterCommands();
lobbsStatusRegisterCommands();
lobbsTimeRegisterCommands();
lobbsFsRegisterCommands();
}
#endif
+6
View File
@@ -0,0 +1,6 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
void lobbsWireup();
#endif
+1 -1
View File
@@ -31,6 +31,6 @@ The plugin is tagged `vX.Y.Z`. Each fork release is tagged `lobbs-vX.Y.Z.<lobbs
The forks stay upstream plus one new folder: `variants/lobbs/`, which defines the `*_lobbs` build envs and pulls in the plugin. No edits to upstream files. If an extension point is missing, the fix is the smallest possible change, listed here and paired with an upstream PR.
Until extraction finishes, LoBBS code still lives in-tree at `lobbs-meshtastic-firmware/src/modules/LoBBS/`.
Meshtastic integration lives in `lobbs-meshtastic-firmware` (`lobbs-overrides.ini`, `variants/lobbs/`). This repo is the plugin source.
Architecture and greenfield rules: [`.cursor/rules/lobbs-project.mdc`](.cursor/rules/lobbs-project.mdc).
+153
View File
@@ -0,0 +1,153 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "AppUtil.h"
#include "../LoBBSCommandCtx.h"
#include "../LoBBSCommandRegistry.h"
#include "../LoBBSConfig.h"
#include "../LoBBSModule.h"
#include "../LoBBSResponse.h"
#include "Auth/AuthDal.h"
#include "gps/RTC.h"
#include <cstdio>
#include <cstring>
#include <lodb/LoDB.h>
#include "LoBBSStackGuard.h"
void lobbsAppCopyCapped(char *dst, size_t dstCap, const char *src, size_t srcCap)
{
size_t n = 0;
if (dst && dstCap > 0) {
if (src) {
while (n + 1 < dstCap && n < srcCap && src[n] != '\0')
n++;
memcpy(dst, src, n);
}
dst[n] = '\0';
}
}
void lobbsAppTimeAgo(uint32_t timestamp, char *buffer, size_t bufferSize)
{
uint32_t now = getTime();
if (now < timestamp) {
snprintf(buffer, bufferSize, "now");
return;
}
uint32_t diff = now - timestamp;
if (diff < 60)
snprintf(buffer, bufferSize, "%ds ago", diff);
else if (diff < 3600)
snprintf(buffer, bufferSize, "%dm ago", diff / 60);
else if (diff < 86400)
snprintf(buffer, bufferSize, "%dh ago", diff / 3600);
else
snprintf(buffer, bufferSize, "%dd ago", diff / 86400);
}
void lobbsAppTruncMsg(const char *message, char *buffer, size_t bufferSize, size_t maxLen)
{
if (!message)
message = "";
size_t msgLen = strnlen(message, LOBBS_MESSAGE_BODY_MAX);
if (msgLen <= maxLen) {
snprintf(buffer, bufferSize, "%.*s", (int)msgLen, message);
} else {
size_t copyLen = maxLen < bufferSize - 4 ? maxLen : bufferSize - 4;
snprintf(buffer, bufferSize, "%.*s...", (int)copyLen, message);
}
}
bool lobbsAppLoadUser(LoBBSModule *mod, uint64_t uuid, LoScalar *outUser)
{
if (!mod || !outUser || uuid == 0)
return false;
return mod->auth().dal().loadUserByUuid(uuid, outUser);
}
void lobbsAppUsernameForUuid(LoBBSModule *mod, uint64_t uuid, char *buf, size_t bufCap)
{
if (!buf || bufCap == 0)
return;
buf[0] = '\0';
LoScalar user;
if (!lobbsAppLoadUser(mod, uuid, &user) || !AuthDal::userUsername(user, buf, bufCap) || !buf[0])
lobbsAppCopyCapped(buf, bufCap, "unknown", 7);
}
uint64_t lobbsAppUuidForUsername(LoBBSModule *mod, const char *username)
{
if (!mod || !username || !username[0])
return 0;
return mod->auth().dal().getUserUuidByUsername(username);
}
bool lobbsAppResolveUsername(LoBBSCommandCtx &ctx, const char *username, uint64_t &uuidOut)
{
uuidOut = lobbsAppUuidForUsername(ctx.mod, username);
if (uuidOut != 0)
return true;
char msg[64];
snprintf(msg, sizeof(msg), "User '%s' not found.", username ? username : "");
LoBBSResponse resp;
lobbsResponseSetError(resp, msg);
lobbsCommandReplyResponse(ctx, resp);
return false;
}
static uint32_t quotaLoad(LoDb &db, const char *table, uint64_t userUuid, uint32_t periodSec, uint32_t *used, size_t n)
{
memset(used, 0, n * sizeof(uint32_t));
LoScalar rec;
uint32_t start = 0;
if (db.get(table, userUuid, rec) != LODB_OK || !rec.getUint32(LoBBSQuotaField::FIELD_CYCLE_START, start) || start == 0)
return 0;
if (getTime() >= start + periodSec)
return 0;
for (size_t i = 0; i < n; i++)
rec.getUint32(LoBBSQuotaField::FIELD_USED + (uint32_t)i, used[i]);
return start;
}
void lobbsQuotaUsed(LoDb &db, const char *table, uint64_t userUuid, uint32_t periodSec, uint32_t *used, size_t n)
{
quotaLoad(db, table, userUuid, periodSec, used, n);
}
uint32_t lobbsCrc32Update(uint32_t crc, const uint8_t *data, size_t len)
{
for (size_t i = 0; data && i < len; i++) {
crc ^= data[i];
for (int b = 0; b < 8; b++)
crc = (crc >> 1) ^ (0xedb88320 & (~((crc & 1) - 1)));
}
return crc;
}
uint32_t lobbsCrc32(const uint8_t *data, size_t len)
{
return ~lobbsCrc32Update(0xffffffff, data, len);
}
const char *lobbsDbErrorText(LoDbError err, const char *otherwise)
{
return err == LODB_ERR_FULL ? "Disk full." : otherwise;
}
LoDbError lobbsQuotaAdd(LoDb &db, const char *table, uint64_t userUuid, uint32_t periodSec, const uint32_t *add, size_t n)
{
if (n > LOBBS_QUOTA_MAX_COUNTERS)
return LODB_ERR_INVALID;
uint32_t used[LOBBS_QUOTA_MAX_COUNTERS];
uint32_t start = quotaLoad(db, table, userUuid, periodSec, used, n);
if (start == 0)
start = getTime();
LoScalar rec;
rec.setUint64(LoBBSQuotaField::FIELD_USER_UUID, userUuid);
rec.setUint32(LoBBSQuotaField::FIELD_CYCLE_START, start);
for (size_t i = 0; i < n; i++)
rec.setUint32(LoBBSQuotaField::FIELD_USED + (uint32_t)i, used[i] + add[i]);
return db.upsert(table, userUuid, rec);
}
#endif
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include <lodb/LoDB.h>
#include <loscalar/LoScalar.h>
#include <stddef.h>
#include <stdint.h>
class LoBBSModule;
struct LoBBSCommandCtx;
/** Reply text for a failed write: "Disk full." for LODB_ERR_FULL, else `otherwise`. */
const char *lobbsDbErrorText(LoDbError err, const char *otherwise = "Failed.");
void lobbsAppCopyCapped(char *dst, size_t dstCap, const char *src, size_t srcCap);
void lobbsAppTimeAgo(uint32_t timestamp, char *buffer, size_t bufferSize);
void lobbsAppTruncMsg(const char *message, char *buffer, size_t bufferSize, size_t maxLen);
bool lobbsAppLoadUser(LoBBSModule *mod, uint64_t uuid, LoScalar *outUser);
/** Username for display; uses "unknown" when missing. */
void lobbsAppUsernameForUuid(LoBBSModule *mod, uint64_t uuid, char *buf, size_t bufCap);
uint64_t lobbsAppUuidForUsername(LoBBSModule *mod, const char *username);
/** Sets uuidOut and returns true, or replies and returns false. */
bool lobbsAppResolveUsername(LoBBSCommandCtx &ctx, const char *username, uint64_t &uuidOut);
/** Per-user quota record: user uuid, cycle start, then one counter per field from 2. */
namespace LoBBSQuotaField
{
inline constexpr uint32_t FIELD_USER_UUID = 0;
inline constexpr uint32_t FIELD_CYCLE_START = 1;
inline constexpr uint32_t FIELD_USED = 2;
} // namespace LoBBSQuotaField
static constexpr size_t LOBBS_QUOTA_MAX_COUNTERS = 4;
/** Fills `used` with this cycle's counters; zeros when missing or the cycle has expired. */
void lobbsQuotaUsed(LoDb &db, const char *table, uint64_t userUuid, uint32_t periodSec, uint32_t *used, size_t n);
/** Adds `add` to this cycle's counters, starting a new cycle when the last one expired. */
LoDbError lobbsQuotaAdd(LoDb &db, const char *table, uint64_t userUuid, uint32_t periodSec, const uint32_t *add, size_t n);
/** IEEE CRC32; pass 0xffffffff before the first byte, invert the return value for the digest. */
uint32_t lobbsCrc32Update(uint32_t crc, const uint8_t *data, size_t len);
uint32_t lobbsCrc32(const uint8_t *data, size_t len);
#endif
+9
View File
@@ -0,0 +1,9 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "Auth.h"
#include "LoBBSStackGuard.h"
AuthApp::AuthApp(LoDb &lodb) : dal_(lodb) {}
#endif
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "AuthDal.h"
class AuthApp
{
public:
explicit AuthApp(LoDb &lodb);
AuthDal &dal() { return dal_; }
private:
AuthDal dal_;
};
#endif
+459
View File
@@ -0,0 +1,459 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "AuthCommands.h"
#include "../../LoBBSCommandRegistry.h"
#include "../../LoBBSHooks.h"
#include "../../LoBBSModule.h"
#include "../../LoBBSResponse.h"
#include "../AppUtil.h"
#include "../Config/ConfigCommon.h"
#include "AuthDal.h"
#include "AuthRecords.h"
#include "mesh/NodeDB.h"
#include <cstdio>
#include <cstring>
#include <lodb/LoDB.h>
#include <string>
#include "LoBBSStackGuard.h"
static bool passwordLongEnough(LoBBSCommandCtx &ctx, const char *password)
{
const uint32_t minLen = lobbsConfigGet(ctx, "password.min");
return password && strlen(password) >= minLen;
}
static void authAppendUserRecord(LoBBSResponse &resp, const LoScalar &user)
{
char name[LOBBS_USERNAME_BUFFER_SIZE];
AuthDal::userUsername(user, name, sizeof(name));
std::string line = name;
if (AuthDal::userIsSysop(user))
line += "*";
LoScalar rec;
rec.setString(LODB_F_TITLE, line.c_str());
rec.setUint64(LODB_F_ID, AuthDal::userUuid(user));
rec.setBool(AuthUser::FIELD_SYSOP, AuthDal::userIsSysop(user));
lobbsResponseAppendRecord(resp, rec);
}
static void handleWhoami(LoBBSCommandCtx &ctx)
{
if (!lobbsCommandRequireLogin(ctx))
return;
char uname[LOBBS_USERNAME_BUFFER_SIZE];
lobbsCtxUsername(ctx, uname, sizeof(uname));
char title[96];
snprintf(title, sizeof(title), "Logged in as %s.", uname);
LoScalar rec;
rec.setString(LODB_F_TITLE, title);
if (ctx.session.isSysop)
rec.setString(LODB_F_DESCRIPTION, "You are the SysOp.");
rec.setUint64(LODB_F_ID, lobbsCtxUserUuid(ctx));
rec.setBool(AuthUser::FIELD_SYSOP, ctx.session.isSysop);
LoBBSResponse resp;
lobbsResponseAppendRecord(resp, rec);
lobbsCommandReplyResponse(ctx, resp);
}
static void handleLogout(LoBBSCommandCtx &ctx)
{
ctx.mod->auth().dal().logoutUser(ctx.session.nodeId);
LoBBSResponse resp;
lobbsRecordPush(resp.records, "Goodbye!");
lobbsCommandReplyResponse(ctx, resp);
}
static void handleLogin(LoBBSCommandCtx &ctx)
{
AuthDal &auth = ctx.mod->auth().dal();
const char *username = lobbsArgShift(ctx);
const char *password = lobbsArgShift(ctx);
LoBBSResponse resp;
if (!username || !password) {
lobbsResponseSetError(resp, "Usage: /login user password");
lobbsCommandReplyResponse(ctx, resp);
return;
}
if (!auth.isValidUsername(username)) {
lobbsResponseSetError(resp, "Invalid username.");
lobbsCommandReplyResponse(ctx, resp);
return;
}
if (!passwordLongEnough(ctx, password) || !auth.isValidPassword(password)) {
lobbsResponseSetError(resp, "Password too short.");
lobbsCommandReplyResponse(ctx, resp);
return;
}
LoScalar dbUser;
if (auth.loadUserByUsername(username, &dbUser)) {
if (!auth.verifyPassword(&dbUser, password)) {
lobbsResponseSetError(resp, "Invalid password");
lobbsCommandReplyResponse(ctx, resp);
return;
}
const uint32_t sessionKey = getFrom(ctx.mp);
if (!auth.loginUser(username, sessionKey)) {
lobbsResponseSetError(resp, "Error creating session");
lobbsCommandReplyResponse(ctx, resp);
return;
}
} else if (LoDbError err = auth.createUser(username, password, getFrom(ctx.mp))) {
lobbsResponseSetError(resp, lobbsDbErrorText(err, "Error creating account"));
lobbsCommandReplyResponse(ctx, resp);
return;
} else {
auth.loadUserByUsername(username, &dbUser);
}
char title[96];
snprintf(title, sizeof(title), "Welcome %s!", username);
LoScalar rec;
rec.setString(LODB_F_TITLE, title);
if (AuthDal::userIsSysop(dbUser))
rec.setString(LODB_F_DESCRIPTION, "You are the SysOp.");
rec.setBool(AuthUser::FIELD_SYSOP, AuthDal::userIsSysop(dbUser));
lobbsResponseAppendRecord(resp, rec);
lobbsCommandReplyResponse(ctx, resp);
}
static bool passwdApply(AuthDal &auth, const char *username, const char *newPass, const char *confirm, LoBBSCommandCtx &ctx)
{
LoBBSResponse resp;
if (strcmp(newPass, confirm) != 0) {
lobbsResponseSetError(resp, "Passwords do not match.");
lobbsCommandReplyResponse(ctx, resp);
return false;
}
if (!passwordLongEnough(ctx, newPass) || !auth.isValidPassword(newPass)) {
lobbsResponseSetError(resp, "Password too short.");
lobbsCommandReplyResponse(ctx, resp);
return false;
}
if (LoDbError err = auth.setPasswordByUsername(username, newPass)) {
lobbsResponseSetError(resp, lobbsDbErrorText(err, "Error updating password."));
lobbsCommandReplyResponse(ctx, resp);
return false;
}
lobbsRecordPush(resp.records, "Password updated.");
lobbsCommandReplyResponse(ctx, resp);
return true;
}
static void handlePasswd(LoBBSCommandCtx &ctx)
{
AuthDal &auth = ctx.mod->auth().dal();
const char *a = lobbsArgShift(ctx);
const char *b = lobbsArgShift(ctx);
const char *c = lobbsArgShift(ctx);
if (a && b && !c) {
if (!lobbsCommandRequireLogin(ctx))
return;
char uname[LOBBS_USERNAME_BUFFER_SIZE];
lobbsCtxUsername(ctx, uname, sizeof(uname));
passwdApply(auth, uname, a, b, ctx);
return;
}
if (a && b && c) {
if (!lobbsCommandRequireSysop(ctx))
return;
if (!auth.isValidUsername(a)) {
LoBBSResponse resp;
lobbsResponseSetError(resp, "Invalid username.");
lobbsCommandReplyResponse(ctx, resp);
return;
}
LoScalar dbUser;
if (!auth.loadUserByUsername(a, &dbUser)) {
LoBBSResponse resp;
lobbsResponseSetError(resp, "User not found.");
lobbsCommandReplyResponse(ctx, resp);
return;
}
passwdApply(auth, a, b, c, ctx);
return;
}
LoBBSResponse resp;
lobbsResponseSetError(resp, "Usage: /passwd new confirm\nUsage: /passwd user new confirm (sysop)");
lobbsCommandReplyResponse(ctx, resp);
}
static void usersSubList(LoBBSCommandCtx &ctx)
{
const char *sub = lobbsArgPeek(ctx);
if (sub && strcasecmp(sub, "list") == 0)
lobbsArgShift(ctx);
AuthDal &auth = ctx.mod->auth().dal();
auto users = auth.listUsers(nullptr);
LoBBSResponse resp;
if (users.empty()) {
lobbsResponseSetError(resp, "No users found");
lobbsCommandReplyResponse(ctx, resp);
return;
}
for (const LoScalar &user : users)
authAppendUserRecord(resp, user);
lobbsCommandReplyResponse(ctx, resp);
}
static void usersSubFind(LoBBSCommandCtx &ctx)
{
const char *sub = lobbsArgShift(ctx);
if (!sub || strcasecmp(sub, "find") != 0) {
LoBBSResponse resp;
lobbsResponseSetError(resp, "Usage: /users find text");
lobbsCommandReplyResponse(ctx, resp);
return;
}
char filterBuf[128];
strncpy(filterBuf, lobbsArgRest(ctx), sizeof(filterBuf) - 1);
filterBuf[sizeof(filterBuf) - 1] = '\0';
AuthDal &auth = ctx.mod->auth().dal();
auto users = auth.listUsers(filterBuf[0] ? filterBuf : nullptr);
LoBBSResponse resp;
if (users.empty()) {
lobbsResponseSetError(resp, "No users match filter.");
lobbsCommandReplyResponse(ctx, resp);
return;
}
for (const LoScalar &user : users)
authAppendUserRecord(resp, user);
lobbsCommandReplyResponse(ctx, resp);
}
static void usersSubKick(LoBBSCommandCtx &ctx)
{
const char *sub = lobbsArgShift(ctx);
const char *user = lobbsArgShift(ctx);
if (!sub || strcasecmp(sub, "kick") != 0 || !user) {
LoBBSResponse resp;
lobbsResponseSetError(resp, "Usage: /users kick user");
lobbsCommandReplyResponse(ctx, resp);
return;
}
LoBBSResponse resp;
lobbsRecordPush(resp.records, ctx.mod->auth().dal().kickUserByUsername(user) ? "Sessions cleared." : "User not found.");
lobbsCommandReplyResponse(ctx, resp);
}
static void usersSubPromote(LoBBSCommandCtx &ctx)
{
const char *sub = lobbsArgShift(ctx);
const char *user = lobbsArgShift(ctx);
if (!sub || strcasecmp(sub, "promote") != 0 || !user) {
LoBBSResponse resp;
lobbsResponseSetError(resp, "Usage: /users promote user");
lobbsCommandReplyResponse(ctx, resp);
return;
}
LoBBSResponse resp;
LoDbError err = ctx.mod->auth().dal().setUserSysopByUsername(user, true);
lobbsRecordPush(resp.records, err == LODB_OK ? "Promoted." : lobbsDbErrorText(err, "User not found."));
lobbsCommandReplyResponse(ctx, resp);
}
static void usersSubDemote(LoBBSCommandCtx &ctx)
{
const char *sub = lobbsArgShift(ctx);
const char *user = lobbsArgShift(ctx);
if (!sub || strcasecmp(sub, "demote") != 0 || !user) {
LoBBSResponse resp;
lobbsResponseSetError(resp, "Usage: /users demote user");
lobbsCommandReplyResponse(ctx, resp);
return;
}
AuthDal &auth = ctx.mod->auth().dal();
LoScalar target;
LoBBSResponse resp;
if (!auth.loadUserByUsername(user, &target)) {
lobbsResponseSetError(resp, "User not found.");
lobbsCommandReplyResponse(ctx, resp);
return;
}
if (AuthDal::userIsSysop(target) && auth.countSysopUsers() <= 1) {
lobbsResponseSetError(resp, "Cannot demote last SysOp.");
lobbsCommandReplyResponse(ctx, resp);
return;
}
LoDbError err = auth.setUserSysopByUsername(user, false);
lobbsRecordPush(resp.records, err == LODB_OK ? "Demoted." : lobbsDbErrorText(err));
lobbsCommandReplyResponse(ctx, resp);
}
static const LoBBSVerb usersVerbs[] = {
{"list", usersSubList, LOBBS_V_LOGIN, "list — all users"},
{"find", usersSubFind, LOBBS_V_LOGIN, "find text — filter by username"},
{"kick", usersSubKick, LOBBS_V_LOGIN | LOBBS_V_SYSOP, "kick user — sysop: clear sessions"},
{"promote", usersSubPromote, LOBBS_V_LOGIN | LOBBS_V_SYSOP, "promote user — sysop: grant sysop"},
{"demote", usersSubDemote, LOBBS_V_LOGIN | LOBBS_V_SYSOP, "demote user — sysop: revoke sysop"},
};
static const LoBBSVerb whoamiHelpVerbs[] = {
{"whoami", nullptr, LOBBS_V_LOGIN, "whoami — show logged-in user"},
};
static const LoBBSVerb loginHelpVerbs[] = {
{"login", nullptr, 0, "login user pass — sign in or create account (pass min 5 chars)"},
};
static const LoBBSVerb logoutHelpVerbs[] = {
{"logout", nullptr, LOBBS_V_LOGIN, "logout — end session"},
};
static const LoBBSVerb passwdHelpVerbs[] = {
{"passwd", nullptr, LOBBS_V_LOGIN, "passwd new confirm — change your password"},
{"passwd", nullptr, LOBBS_V_LOGIN | LOBBS_V_SYSOP, "passwd user new confirm — sysop: reset user password"},
};
static void handleUsers(LoBBSCommandCtx &ctx)
{
if (!lobbsCommandRequireLogin(ctx))
return;
if (!lobbsDispatchSub(ctx, usersVerbs, sizeof(usersVerbs) / sizeof(usersVerbs[0]))) {
LoBBSResponse resp;
lobbsResponseSetError(resp, "Unknown command. Try /help users");
lobbsCommandReplyResponse(ctx, resp);
}
}
static void slashAuth(LoBBSCommandCtx *ctx, const LoScalar &args)
{
std::string v;
if (!ctx || !args.getString(LOBBS_ARG_VERB, v))
return;
LoBBSCommandCtx &c = *ctx;
const char *verb = v.c_str();
if (strcasecmp(verb, "whoami") == 0) {
handleWhoami(c);
return;
}
if (strcasecmp(verb, "login") == 0) {
handleLogin(c);
return;
}
if (strcasecmp(verb, "logout") == 0) {
handleLogout(c);
return;
}
if (strcasecmp(verb, "passwd") == 0) {
handlePasswd(c);
return;
}
if (strcasecmp(verb, "users") == 0) {
handleUsers(c);
return;
}
}
static void filterAuthConfigKeys(LoBBSCommandCtx *ctx, std::vector<LoScalar> &keys, const LoScalar &args)
{
(void)ctx;
(void)args;
lobbsConfigPushKey(keys, "session.max", LOBBS_SESSION_DEFAULT_MAX, 1, 64, "Max concurrent login sessions");
lobbsConfigPushKey(keys, "session.idle", LOBBS_SESSION_DEFAULT_IDLE_SEC, 60, 30 * 86400,
"Idle seconds before session expires");
lobbsConfigPushKey(keys, "password.min", 5, 1, 32, "Minimum password length");
}
static void actionAuthConfigChanged(LoBBSCommandCtx *ctx, const LoScalar &args)
{
if (!ctx || !ctx->mod)
return;
std::string key;
if (!args.getString(LODB_F_TITLE, key))
return;
if (key == "session.max" || key == "session.idle") {
AuthDal::SessionConfig cfg = {lobbsConfigGet(*ctx, "session.max"), lobbsConfigGet(*ctx, "session.idle")};
ctx->mod->auth().dal().applySessionConfig(cfg);
}
}
static void filterAuthHelpTopics(LoBBSCommandCtx *ctx, std::vector<LoScalar> &topics, const LoScalar &args)
{
(void)args;
if (!ctx)
return;
if (lobbsCtxLoggedIn(*ctx)) {
lobbsRecordPush(topics, "whoami", "show your account");
lobbsRecordPush(topics, "logout", "sign out");
lobbsRecordPush(topics, "passwd", "change your password");
lobbsRecordPush(topics, "users", "list users");
} else {
lobbsRecordPush(topics, "login", "sign in or create an account");
}
}
static void filterAuthHelpForTopic(LoBBSCommandCtx *ctx, LoScalar &value, const LoScalar &args)
{
lobbsHelpForTable(ctx, value, args, "whoami", whoamiHelpVerbs, sizeof(whoamiHelpVerbs) / sizeof(whoamiHelpVerbs[0]));
lobbsHelpForTable(ctx, value, args, "login", loginHelpVerbs, sizeof(loginHelpVerbs) / sizeof(loginHelpVerbs[0]));
lobbsHelpForTable(ctx, value, args, "logout", logoutHelpVerbs, sizeof(logoutHelpVerbs) / sizeof(logoutHelpVerbs[0]));
lobbsHelpForTable(ctx, value, args, "passwd", passwdHelpVerbs, sizeof(passwdHelpVerbs) / sizeof(passwdHelpVerbs[0]));
lobbsHelpForTable(ctx, value, args, "users", usersVerbs, sizeof(usersVerbs) / sizeof(usersVerbs[0]));
}
static void filterAuthStatusLines(LoBBSCommandCtx *ctx, std::vector<LoScalar> &lines, const LoScalar &args)
{
(void)args;
if (!ctx || !ctx->mod)
return;
char title[32];
char value[32];
uint32_t n = ctx->mod->auth().dal().countAllUsers();
snprintf(title, sizeof(title), "Users");
snprintf(value, sizeof(value), "%u total", (unsigned)n);
lobbsRecordPush(lines, title, value);
}
static bool displayAuthRecord(const LoScalar &record, std::string &lineOut)
{
if (record.has(LODB_F_ID) && record.has(AuthUser::FIELD_SYSOP) && record.has(LODB_F_TITLE)) {
std::string title;
if (record.getString(LODB_F_TITLE, title)) {
lineOut = title;
return true;
}
}
std::string title;
std::string body;
if (record.getString(LODB_F_TITLE, title) && record.getString(LODB_F_DESCRIPTION, body) &&
(title.rfind("Welcome ", 0) == 0 || title.rfind("Logged in as ", 0) == 0)) {
lineOut = title + "\n" + body;
return true;
}
return false;
}
static void displayAuthHuman(LoBBSCommandCtx *ctx, LoScalar &value, const LoScalar &record)
{
(void)ctx;
std::string line;
if (displayAuthRecord(record, line))
value.setString(LODB_F_TITLE, line);
}
#if LOBBS_SEED
#include "AuthSeed.h"
static void actionAuthSeed(LoBBSCommandCtx *ctx, const LoScalar &args)
{
(void)args;
if (ctx && ctx->mod)
lobbsSeedAuth(*ctx->mod);
}
#endif
void lobbsAuthRegisterCommands()
{
lobbsAddFilter("config_keys", filterAuthConfigKeys, LOBBS_HOOK_PRIORITY_AUTH);
lobbsAddAction("config_changed", actionAuthConfigChanged, LOBBS_HOOK_PRIORITY_AUTH);
lobbsAddAction("slash_cmd", slashAuth, LOBBS_HOOK_PRIORITY_AUTH);
lobbsAddFilter("help_topics", filterAuthHelpTopics, LOBBS_HOOK_PRIORITY_AUTH);
lobbsAddFilter("help_for_topic", filterAuthHelpForTopic, LOBBS_HOOK_PRIORITY_AUTH);
lobbsAddFilter("status_lines", filterAuthStatusLines, LOBBS_HOOK_PRIORITY_AUTH);
lobbsAddFilter("display_human", displayAuthHuman, LOBBS_HOOK_PRIORITY_AUTH);
#if LOBBS_SEED
lobbsAddAction("seed", actionAuthSeed, LOBBS_HOOK_PRIORITY_AUTH);
#endif
}
#endif
+6
View File
@@ -0,0 +1,6 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
void lobbsAuthRegisterCommands();
#endif
+362
View File
@@ -0,0 +1,362 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "AuthDal.h"
#include "AuthRecords.h"
#include "configuration.h"
#include "gps/RTC.h"
#include "mesh/NodeDB.h"
#include "mesh/Throttle.h"
#include <SHA256.h>
#include <algorithm>
#include <cctype>
#include <cstring>
#include <vector>
#include "LoBBSStackGuard.h"
static void normalizeUsername(const char *username, char *normalized)
{
size_t len = strlen(username);
if (len > LOBBS_MAX_USERNAME_LEN)
len = LOBBS_MAX_USERNAME_LEN;
for (size_t i = 0; i < len; i++) {
normalized[i] = tolower(username[i]);
}
normalized[len] = '\0';
}
AuthDal::AuthDal(LoDb &lodb) : lodb_(lodb)
{
lodb_.registerTable("users");
}
uint64_t AuthDal::userUuid(const LoScalar &user)
{
uint64_t v = 0;
user.getUint64(LODB_F_ID, v);
return v;
}
bool AuthDal::userUsername(const LoScalar &user, char *buf, size_t bufCap)
{
std::string name;
if (!user.getString(AuthUser::FIELD_USERNAME, name) || bufCap == 0)
return false;
strncpy(buf, name.c_str(), bufCap - 1);
buf[bufCap - 1] = '\0';
return true;
}
bool AuthDal::userIsSysop(const LoScalar &user)
{
bool v = false;
return user.getBool(AuthUser::FIELD_SYSOP, v) && v;
}
static lodb_uuid_t usernameToUuid(const char *username)
{
char normalized[LOBBS_USERNAME_BUFFER_SIZE];
normalizeUsername(username, normalized);
return lodb_new_uuid(normalized, nodeDB->getNodeNum());
}
static const char *stristrLocal(const char *haystack, const char *needle)
{
if (!needle || !*needle)
return haystack;
for (; *haystack; haystack++) {
const char *h = haystack;
const char *n = needle;
while (*h && *n && tolower((unsigned char)*h) == tolower((unsigned char)*n)) {
h++;
n++;
}
if (!*n)
return haystack;
}
return nullptr;
}
static int compareUsersByName(const LoScalar &a, const LoScalar &b)
{
char na[LOBBS_USERNAME_BUFFER_SIZE];
char nb[LOBBS_USERNAME_BUFFER_SIZE];
AuthDal::userUsername(a, na, sizeof(na));
AuthDal::userUsername(b, nb, sizeof(nb));
return strcasecmp(na, nb);
}
bool AuthDal::loadUserByUuid(uint64_t uuid, LoScalar *user)
{
return lodb_.get("users", uuid, *user) == LODB_OK;
}
std::vector<LoScalar> AuthDal::listUsers(const char *filterSubstr)
{
const bool hasFilter = filterSubstr && filterSubstr[0];
return lodb_.select(
"users",
[filterSubstr, hasFilter](const LoScalar &rec) -> bool {
if (!hasFilter)
return true;
char name[LOBBS_USERNAME_BUFFER_SIZE];
AuthDal::userUsername(rec, name, sizeof(name));
return stristrLocal(name, filterSubstr) != nullptr;
},
compareUsersByName);
}
bool AuthDal::isValidUsername(const char *username)
{
if (!isalpha(username[0])) {
return false;
}
for (size_t i = 1; username[i] != '\0'; i++) {
if (!isalnum(username[i]) && username[i] != '_') {
return false;
}
}
return true;
}
bool AuthDal::isValidPassword(const char *password)
{
for (size_t i = 0; password[i] != '\0'; i++) {
char c = password[i];
if (!isalnum(c) && c != '_' && c != '-' && c != '.' && c != '!' && c != '@' && c != '#' && c != '$' && c != '%') {
return false;
}
}
return true;
}
void AuthDal::hashPassword(const char *password, uint8_t *hash)
{
SHA256 sha256;
sha256.reset();
sha256.update(password, strlen(password));
sha256.finalize(hash, 32);
}
bool AuthDal::loadUserByUsername(const char *username, LoScalar *user)
{
lodb_uuid_t userUuid = usernameToUuid(username);
LoDbError err = lodb_.get("users", userUuid, *user);
if (err == LODB_OK) {
LOG_DEBUG("Loaded user by username: %s", username);
return true;
}
LOG_DEBUG("User not found: %s", username);
return false;
}
void AuthDal::applySessionConfig(const SessionConfig &cfg)
{
if (cfg.maxSessions < sessions_.size()) {
const uint32_t now = millis();
std::sort(sessions_.begin(), sessions_.end(), [now](const Session &a, const Session &b) {
if (a.used != b.used)
return a.used;
return (now - a.lastActiveMs) < (now - b.lastActiveMs);
});
}
sessions_.resize(cfg.maxSessions, Session{});
sessionCfg_ = cfg;
}
bool AuthDal::sessionExpired(const Session &s) const
{
return !Throttle::isWithinTimespanMs(s.lastActiveMs, sessionCfg_.idleSeconds * 1000u);
}
AuthDal::Session *AuthDal::findSession(uint32_t nodeId)
{
for (auto &s : sessions_) {
if (!s.used || s.nodeId != nodeId)
continue;
if (sessionExpired(s)) {
LOG_INFO("Session for node 0x%08x expired", nodeId);
s = Session{};
return nullptr;
}
return &s;
}
return nullptr;
}
void AuthDal::clearSessions()
{
sessions_.clear();
}
bool AuthDal::loadUserByNodeId(uint32_t nodeId, LoScalar *user, uint32_t *sessionNodeIdOut, uint64_t *authUserUuidOut,
std::string *cwdOut)
{
Session *s = findSession(nodeId);
if (!s) {
LOG_DEBUG("No session found for node 0x%08x", nodeId);
return false;
}
if (lodb_.get("users", s->userUuid, *user) != LODB_OK) {
LOG_WARN("Session 0x%08x references missing user, removing session", nodeId);
*s = Session{};
return false;
}
s->lastActiveMs = millis();
LOG_DEBUG("Loaded user by node ID: 0x%08x -> UUID: " LODB_UUID_FMT, nodeId, LODB_UUID_ARGS(s->userUuid));
if (sessionNodeIdOut)
*sessionNodeIdOut = nodeId;
if (authUserUuidOut)
*authUserUuidOut = s->userUuid;
if (cwdOut)
cwdOut->assign(s->cwd);
return true;
}
bool AuthDal::setSessionCwd(uint32_t nodeId, const char *cwd)
{
Session *s = findSession(nodeId);
if (!s)
return false;
strncpy(s->cwd, cwd ? cwd : "", sizeof(s->cwd) - 1);
s->cwd[sizeof(s->cwd) - 1] = '\0';
return true;
}
LoDbError AuthDal::createUser(const char *username, const char *password, uint32_t nodeId, bool asSysop)
{
lodb_uuid_t userUuid = usernameToUuid(username);
LoScalar user;
user.setString(AuthUser::FIELD_USERNAME, username);
uint8_t hash[32];
hashPassword(password, hash);
user.setBytesHex(AuthUser::FIELD_PASSWORD, hash, 32);
user.setBool(AuthUser::FIELD_SYSOP, asSysop);
LoDbError err = lodb_.insert("users", userUuid, user);
if (err != LODB_OK) {
LOG_ERROR("Failed to create user: %s", username);
return err;
}
LOG_INFO("Created user: %s (sysop: %s)", username, asSysop ? "yes" : "no");
return loginUser(username, nodeId) ? LODB_OK : LODB_ERR_INVALID;
}
bool AuthDal::verifyPassword(const LoScalar *user, const char *password)
{
uint8_t stored[32];
size_t n = 0;
if (!user->getBytesHex(AuthUser::FIELD_PASSWORD, stored, sizeof(stored), n) || n != 32)
return false;
uint8_t providedHash[32];
hashPassword(password, providedHash);
return memcmp(stored, providedHash, 32) == 0;
}
bool AuthDal::loginUser(const char *username, uint32_t nodeId)
{
Session *s = findSession(nodeId);
if (!s) {
const uint32_t now = millis();
uint32_t oldestAge = 0;
for (auto &c : sessions_) {
if (!c.used || sessionExpired(c)) {
s = &c;
break;
}
if (!s || now - c.lastActiveMs > oldestAge) {
s = &c;
oldestAge = now - c.lastActiveMs;
}
}
if (!s)
return false;
if (s->used && !sessionExpired(*s))
LOG_INFO("Session table full, evicting node 0x%08x", s->nodeId);
}
*s = Session{};
s->used = true;
s->nodeId = nodeId;
s->userUuid = usernameToUuid(username);
s->lastActiveMs = millis();
LOG_INFO("Created session for user %s on node 0x%08x", username, nodeId);
return true;
}
bool AuthDal::logoutUser(uint32_t nodeId)
{
Session *s = findSession(nodeId);
if (s) {
*s = Session{};
LOG_INFO("Logged out node 0x%08x", nodeId);
return true;
}
LOG_WARN("No session found to log out for node 0x%08x", nodeId);
return false;
}
uint64_t AuthDal::getUserUuidByUsername(const char *username)
{
uint64_t userUuid = usernameToUuid(username);
LoScalar user;
LoDbError err = lodb_.get("users", userUuid, user);
if (err == LODB_OK)
return userUuid;
return 0;
}
LoDbError AuthDal::setUserSysopByUsername(const char *username, bool isSysop)
{
LoScalar user;
if (!loadUserByUsername(username, &user))
return LODB_ERR_NOT_FOUND;
uint64_t uuid = userUuid(user);
user.setBool(AuthUser::FIELD_SYSOP, isSysop);
user.removeField(LODB_F_CREATED);
user.removeField(LODB_F_UPDATED);
return lodb_.update("users", uuid, user);
}
LoDbError AuthDal::setPasswordByUsername(const char *username, const char *password)
{
LoScalar user;
if (!loadUserByUsername(username, &user))
return LODB_ERR_NOT_FOUND;
uint64_t uuid = userUuid(user);
uint8_t hash[32];
hashPassword(password, hash);
user.setBytesHex(AuthUser::FIELD_PASSWORD, hash, 32);
user.removeField(LODB_F_CREATED);
user.removeField(LODB_F_UPDATED);
return lodb_.update("users", uuid, user);
}
uint32_t AuthDal::countSysopUsers()
{
return (uint32_t)lodb_.count("users", [](const LoScalar &rec) -> bool { return AuthDal::userIsSysop(rec); });
}
uint32_t AuthDal::countAllUsers()
{
int n = lodb_.count("users");
return n < 0 ? 0 : (uint32_t)n;
}
bool AuthDal::kickUserByUsername(const char *username)
{
LoScalar user;
if (!loadUserByUsername(username, &user))
return false;
uint64_t userUuidVal = userUuid(user);
for (auto &s : sessions_) {
if (s.used && s.userUuid == userUuidVal)
s = Session{};
}
return true;
}
#endif
+66
View File
@@ -0,0 +1,66 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "../../LoBBSConfig.h"
#include <lodb/LoDB.h>
#include <stdint.h>
#include <string>
#include <vector>
static constexpr uint32_t LOBBS_SESSION_DEFAULT_MAX = 16;
static constexpr uint32_t LOBBS_SESSION_DEFAULT_IDLE_SEC = 86400;
class AuthDal
{
public:
struct SessionConfig {
uint32_t maxSessions;
uint32_t idleSeconds;
};
AuthDal(LoDb &lodb);
static uint64_t userUuid(const LoScalar &user);
static bool userUsername(const LoScalar &user, char *buf, size_t bufCap);
static bool userIsSysop(const LoScalar &user);
bool isValidUsername(const char *username);
bool isValidPassword(const char *password);
bool loadUserByUsername(const char *username, LoScalar *user);
bool loadUserByUuid(uint64_t uuid, LoScalar *user);
bool loadUserByNodeId(uint32_t nodeId, LoScalar *user, uint32_t *sessionNodeIdOut = nullptr,
uint64_t *authUserUuidOut = nullptr, std::string *cwdOut = nullptr);
bool setSessionCwd(uint32_t nodeId, const char *cwd);
std::vector<LoScalar> listUsers(const char *filterSubstr);
LoDbError createUser(const char *username, const char *password, uint32_t nodeId, bool asSysop = false);
bool verifyPassword(const LoScalar *user, const char *password);
bool loginUser(const char *username, uint32_t nodeId);
bool logoutUser(uint32_t nodeId);
uint64_t getUserUuidByUsername(const char *username);
LoDbError setUserSysopByUsername(const char *username, bool isSysop);
LoDbError setPasswordByUsername(const char *username, const char *password);
uint32_t countSysopUsers();
uint32_t countAllUsers();
bool kickUserByUsername(const char *username);
void clearSessions();
void applySessionConfig(const SessionConfig &cfg);
private:
struct Session {
bool used;
uint32_t nodeId;
uint32_t lastActiveMs;
uint64_t userUuid;
char cwd[LOBBS_CWD_BUFFER_SIZE];
};
static void hashPassword(const char *password, uint8_t *hash);
bool sessionExpired(const Session &s) const;
Session *findSession(uint32_t nodeId);
LoDb &lodb_;
std::vector<Session> sessions_;
SessionConfig sessionCfg_ = {LOBBS_SESSION_DEFAULT_MAX, LOBBS_SESSION_DEFAULT_IDLE_SEC};
};
#endif
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include <cstdint>
/** App field slots for `users` rows (system fields 95..99 are in LoDB.h). */
namespace AuthUser
{
inline constexpr uint32_t FIELD_USERNAME = 0;
inline constexpr uint32_t FIELD_SYSOP = 1;
inline constexpr uint32_t FIELD_PASSWORD = 2;
} // namespace AuthUser
#endif
+29
View File
@@ -0,0 +1,29 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "../../LoBBSConfig.h"
#if LOBBS_SEED
#include "../../LoBBSModule.h"
#include "AuthDal.h"
#include "AuthSeed.h"
#include <cstdio>
#include "LoBBSStackGuard.h"
void lobbsSeedAuth(LoBBSModule &mod)
{
AuthDal &auth = mod.auth().dal();
static constexpr const char *kPass = "demo1";
auth.createUser("sysop", kPass, 0xDE000001, true);
auth.logoutUser(0xDE000001);
for (int i = 1; i <= 12; i++) {
char name[16];
snprintf(name, sizeof(name), "demo%02d", i);
uint32_t nodeId = 0xDE000010u + (uint32_t)i;
auth.createUser(name, kPass, nodeId);
auth.logoutUser(nodeId);
}
}
#endif
#endif
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS && LOBBS_SEED
class LoBBSModule;
void lobbsSeedAuth(LoBBSModule &mod);
#endif
+9
View File
@@ -0,0 +1,9 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "Config.h"
#include "LoBBSStackGuard.h"
ConfigApp::ConfigApp(LoDb &lodb) : dal_(lodb) {}
#endif
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "ConfigDal.h"
#include <lodb/LoDB.h>
class ConfigApp
{
public:
explicit ConfigApp(LoDb &lodb);
ConfigDal &dal() { return dal_; }
private:
ConfigDal dal_;
};
#endif
+200
View File
@@ -0,0 +1,200 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "ConfigCommands.h"
#include "../../LoBBSCommandRegistry.h"
#include "../../LoBBSHooks.h"
#include "../../LoBBSModule.h"
#include "../../LoBBSReplyCache.h"
#include "../../LoBBSResponse.h"
#include "../AppUtil.h"
#include "ConfigCommon.h"
#include "ConfigDal.h"
#include "ConfigRecords.h"
#include <cstdio>
#include <cstring>
#include <strings.h>
#include "LoBBSStackGuard.h"
static uint32_t gLobbsPagerTtlSec = LOBBS_REPLY_CACHE_TTL_SEC;
uint32_t lobbsReplyCacheTtlSec()
{
return gLobbsPagerTtlSec;
}
static void filterConfigKeysPager(LoBBSCommandCtx *ctx, std::vector<LoScalar> &keys, const LoScalar &args)
{
(void)ctx;
(void)args;
lobbsConfigPushKey(keys, "pager.ttl", LOBBS_REPLY_CACHE_TTL_SEC, 60, 3600, "Seconds /pN cache lasts");
}
static void filterConfigValidateRange(LoBBSCommandCtx *ctx, LoScalar &value, const LoScalar &args)
{
(void)ctx;
std::string existing;
if (value.getString(LODB_F_ERROR, existing) && !existing.empty())
return;
uint32_t proposed = 0;
uint32_t minV = 0;
uint32_t maxV = 0;
if (!value.getUint32(ConfigValidateField::FIELD_VALUE, proposed))
return;
if (!args.getUint32(ConfigKeyField::FIELD_MIN, minV) || !args.getUint32(ConfigKeyField::FIELD_MAX, maxV))
return;
if (proposed < minV || proposed > maxV) {
char buf[48];
snprintf(buf, sizeof(buf), "Must be %u-%u.", (unsigned)minV, (unsigned)maxV);
value.setString(LODB_F_ERROR, buf);
}
}
static void actionConfigChangedPager(LoBBSCommandCtx *ctx, const LoScalar &args)
{
(void)ctx;
std::string key;
if (!args.getString(LODB_F_TITLE, key) || key != "pager.ttl")
return;
uint32_t ttl = LOBBS_REPLY_CACHE_TTL_SEC;
args.getUint32(ConfigValidateField::FIELD_VALUE, ttl);
gLobbsPagerTtlSec = ttl;
}
static void handleConfig(LoBBSCommandCtx &ctx)
{
if (!lobbsCommandRequireSysop(ctx))
return;
ConfigDal &cfg = ctx.mod->config().dal();
const char *key = lobbsArgShift(ctx);
if (!key) {
LoBBSResponse resp;
lobbsRecordPush(resp.records, "Use /help config key for details");
for (const LoScalar &def : cfg.keyDefs(ctx)) {
std::string name;
if (!def.getString(LODB_F_TITLE, name))
continue;
uint32_t minV = 0;
uint32_t maxV = 0;
def.getUint32(ConfigKeyField::FIELD_MIN, minV);
def.getUint32(ConfigKeyField::FIELD_MAX, maxV);
uint32_t val = cfg.effectiveValue(ctx, name.c_str());
char line[96];
snprintf(line, sizeof(line), "%s %u (%u-%u)", name.c_str(), (unsigned)val, (unsigned)minV, (unsigned)maxV);
lobbsRecordPush(resp.records, line);
}
lobbsCommandReplyResponse(ctx, resp);
return;
}
const char *next = lobbsArgPeek(ctx);
if (!next) {
cfg.ensureRegistry(ctx);
const LoScalar *def = cfg.findKeyDef(key);
if (!def) {
lobbsCommandReplyError(ctx, "Unknown setting.");
return;
}
uint32_t val = cfg.effectiveValue(ctx, key);
uint32_t defV = 0;
uint32_t minV = 0;
uint32_t maxV = 0;
std::string help;
def->getUint32(ConfigKeyField::FIELD_DEFAULT, defV);
def->getUint32(ConfigKeyField::FIELD_MIN, minV);
def->getUint32(ConfigKeyField::FIELD_MAX, maxV);
def->getString(LODB_F_DESCRIPTION, help);
char line[128];
snprintf(line, sizeof(line), "%s = %u (default %u, %u-%u). %s", key, (unsigned)val, (unsigned)defV, (unsigned)minV,
(unsigned)maxV, help.c_str());
lobbsCommandReply(ctx, line);
return;
}
if (strcasecmp(next, "reset") == 0) {
lobbsArgShift(ctx);
if (lobbsArgHasMore(ctx)) {
lobbsCommandReplyError(ctx, "Usage: /config key reset");
return;
}
if (const char *err = cfg.resetKey(ctx, key)) {
lobbsCommandReplyError(ctx, err);
return;
}
char reply[64];
snprintf(reply, sizeof(reply), "%s reset.", key);
lobbsCommandReply(ctx, reply);
return;
}
uint32_t value = 0;
if (!lobbsArgShiftUint(ctx, value) || lobbsArgHasMore(ctx)) {
lobbsCommandReplyError(ctx, "Usage: /config key value");
return;
}
if (const char *err = cfg.validateAndSet(ctx, key, value)) {
lobbsCommandReplyError(ctx, err);
return;
}
char reply[64];
snprintf(reply, sizeof(reply), "%s = %u.", key, (unsigned)value);
lobbsCommandReply(ctx, reply);
}
static void slashConfig(LoBBSCommandCtx *ctx, const LoScalar &args)
{
if (!ctx || !lobbsSlashVerbIs(args, "config"))
return;
handleConfig(*ctx);
}
static const LoBBSVerb configHelpVerbs[] = {
{"config", nullptr, LOBBS_V_SYSOP, "config — sysop: list/get/set/reset settings (/help config key explains one)"},
};
static void filterConfigHelpTopics(LoBBSCommandCtx *ctx, std::vector<LoScalar> &topics, const LoScalar &args)
{
(void)ctx;
(void)args;
lobbsRecordPush(topics, "config", "sysop settings");
}
static void filterConfigHelpForTopic(LoBBSCommandCtx *ctx, LoScalar &value, const LoScalar &args)
{
lobbsHelpForTable(ctx, value, args, "config", configHelpVerbs, sizeof(configHelpVerbs) / sizeof(configHelpVerbs[0]));
if (!ctx || !ctx->mod || value.has(LODB_F_DESCRIPTION) || !lobbsCtxIsSysop(*ctx))
return;
std::string query;
if (!args.getString(LODB_F_TITLE, query) || strncasecmp(query.c_str(), "config ", 7) != 0)
return;
const char *key = query.c_str() + 7;
ConfigDal &cfg = ctx->mod->config().dal();
cfg.ensureRegistry(*ctx);
const LoScalar *def = cfg.findKeyDef(key);
if (!def)
return;
std::string help;
uint32_t defV = 0;
uint32_t minV = 0;
uint32_t maxV = 0;
def->getString(LODB_F_DESCRIPTION, help);
def->getUint32(ConfigKeyField::FIELD_DEFAULT, defV);
def->getUint32(ConfigKeyField::FIELD_MIN, minV);
def->getUint32(ConfigKeyField::FIELD_MAX, maxV);
char buf[128];
snprintf(buf, sizeof(buf), "%s: %s. Default %u, range %u-%u.", key, help.c_str(), (unsigned)defV, (unsigned)minV,
(unsigned)maxV);
value.setString(LODB_F_DESCRIPTION, buf);
}
void lobbsConfigRegisterCommands()
{
lobbsAddFilter("config_keys", filterConfigKeysPager, LOBBS_HOOK_PRIORITY_HELP);
lobbsAddFilter("config_validate", filterConfigValidateRange, LOBBS_HOOK_PRIORITY_HELP);
lobbsAddAction("config_changed", actionConfigChangedPager, LOBBS_HOOK_PRIORITY_HELP);
lobbsAddAction("slash_cmd", slashConfig, LOBBS_HOOK_PRIORITY_FEATURE);
lobbsAddFilter("help_topics", filterConfigHelpTopics, LOBBS_HOOK_PRIORITY_FEATURE);
lobbsAddFilter("help_for_topic", filterConfigHelpForTopic, LOBBS_HOOK_PRIORITY_FEATURE);
}
#endif
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include <cstdint>
void lobbsConfigRegisterCommands();
uint32_t lobbsReplyCacheTtlSec();
#endif
+31
View File
@@ -0,0 +1,31 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "ConfigCommon.h"
#include "../../LoBBSModule.h"
#include "ConfigDal.h"
#include <cstring>
#include "LoBBSStackGuard.h"
void lobbsConfigPushKey(std::vector<LoScalar> &keys, const char *key, uint32_t def, uint32_t min, uint32_t max, const char *help)
{
if (!key)
return;
LoScalar rec;
rec.setString(LODB_F_TITLE, key);
if (help)
rec.setString(LODB_F_DESCRIPTION, help);
rec.setUint32(ConfigKeyField::FIELD_DEFAULT, def);
rec.setUint32(ConfigKeyField::FIELD_MIN, min);
rec.setUint32(ConfigKeyField::FIELD_MAX, max);
keys.push_back(rec);
}
uint32_t lobbsConfigGet(LoBBSCommandCtx &ctx, const char *key)
{
if (!ctx.mod || !key)
return 0;
return ctx.mod->config().dal().effectiveValue(ctx, key);
}
#endif
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "../../LoBBSCommandCtx.h"
#include "ConfigRecords.h"
#include <lodb/LoDB.h>
#include <loscalar/LoScalar.h>
#include <vector>
void lobbsConfigPushKey(std::vector<LoScalar> &keys, const char *key, uint32_t def, uint32_t min, uint32_t max, const char *help);
uint32_t lobbsConfigGet(LoBBSCommandCtx &ctx, const char *key);
#endif
+158
View File
@@ -0,0 +1,158 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "ConfigDal.h"
#include "../../LoBBSCommandCtx.h"
#include "../../LoBBSHooks.h"
#include "../../LoBBSModule.h"
#include "../AppUtil.h"
#include "ConfigRecords.h"
#include <cstdio>
#include <cstring>
#include "LoBBSStackGuard.h"
ConfigDal::ConfigDal(LoDb &lodb) : lodb_(lodb)
{
lodb_.registerTable("config");
}
lodb_uuid_t ConfigDal::rowUuid(const char *key) const
{
return lodb_new_uuid(key, 0);
}
void ConfigDal::resetCache()
{
overrides_.clear();
registry_.clear();
registryBuilt_ = false;
overridesLoaded_ = false;
}
void ConfigDal::reloadOverrides()
{
overrides_.clear();
overridesLoaded_ = true;
auto rows = lodb_.select(
"config", [](const LoScalar &) { return true; }, LoDbComparator());
for (const LoScalar &rec : rows) {
std::string key;
if (!rec.getString(LODB_F_TITLE, key) || key.empty())
continue;
uint32_t val = 0;
if (rec.getUint32(ConfigStoreField::FIELD_VALUE, val))
overrides_[key] = val;
}
}
void ConfigDal::ensureRegistry(LoBBSCommandCtx &ctx)
{
if (registryBuilt_)
return;
registryBuilt_ = true;
registry_.clear();
lobbsApplyFilter("config_keys", ctx, registry_, LoScalar());
}
const std::vector<LoScalar> &ConfigDal::keyDefs(LoBBSCommandCtx &ctx)
{
ensureRegistry(ctx);
return registry_;
}
const LoScalar *ConfigDal::findKeyDef(const char *key)
{
if (!key)
return nullptr;
for (const LoScalar &rec : registry_) {
std::string title;
if (rec.getString(LODB_F_TITLE, title) && strcasecmp(title.c_str(), key) == 0)
return &rec;
}
return nullptr;
}
uint32_t ConfigDal::effectiveValue(LoBBSCommandCtx &ctx, const char *key)
{
ensureRegistry(ctx);
const LoScalar *def = findKeyDef(key);
if (!def)
return 0;
uint32_t fallback = 0;
def->getUint32(ConfigKeyField::FIELD_DEFAULT, fallback);
if (!overridesLoaded_)
return fallback;
auto it = overrides_.find(key);
if (it != overrides_.end())
return it->second;
return fallback;
}
void ConfigDal::fireConfigChanged(LoBBSCommandCtx &ctx, const char *key, uint32_t value)
{
LoScalar args;
args.setString(LODB_F_TITLE, key);
args.setUint32(ConfigValidateField::FIELD_VALUE, value);
lobbsDoAction("config_changed", ctx, args);
}
const char *ConfigDal::validateAndSet(LoBBSCommandCtx &ctx, const char *key, uint32_t value)
{
ensureRegistry(ctx);
const LoScalar *def = findKeyDef(key);
if (!def)
return "Unknown setting.";
LoScalar probe;
probe.setString(LODB_F_TITLE, key);
probe.setUint32(ConfigValidateField::FIELD_VALUE, value);
lobbsApplyFilter("config_validate", ctx, probe, *def);
std::string err;
if (probe.getString(LODB_F_ERROR, err) && !err.empty()) {
strncpy(lastValidateError_, err.c_str(), sizeof(lastValidateError_) - 1);
lastValidateError_[sizeof(lastValidateError_) - 1] = '\0';
return lastValidateError_;
}
LoScalar row;
row.setString(LODB_F_TITLE, key);
row.setUint32(ConfigStoreField::FIELD_VALUE, value);
LoDbError dbErr = lodb_.upsert("config", rowUuid(key), row);
if (dbErr != LODB_OK)
return lobbsDbErrorText(dbErr);
overrides_[key] = value;
fireConfigChanged(ctx, key, value);
return nullptr;
}
const char *ConfigDal::resetKey(LoBBSCommandCtx &ctx, const char *key)
{
ensureRegistry(ctx);
const LoScalar *def = findKeyDef(key);
if (!def)
return "Unknown setting.";
uint32_t fallback = 0;
def->getUint32(ConfigKeyField::FIELD_DEFAULT, fallback);
lodb_.deleteRecord("config", rowUuid(key));
overrides_.erase(key);
fireConfigChanged(ctx, key, fallback);
return nullptr;
}
void ConfigDal::notifyDatabaseOpened(LoBBSModule &mod)
{
resetCache();
reloadOverrides();
LoBBSCommandCtx ctx;
ctx.mod = &mod;
ensureRegistry(ctx);
for (const LoScalar &rec : registry_) {
std::string key;
if (!rec.getString(LODB_F_TITLE, key) || key.empty())
continue;
fireConfigChanged(ctx, key.c_str(), effectiveValue(ctx, key.c_str()));
}
}
#endif
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include <lodb/LoDB.h>
#include <loscalar/LoScalar.h>
#include <map>
#include <string>
#include <vector>
class LoBBSModule;
struct LoBBSCommandCtx;
class ConfigDal
{
public:
explicit ConfigDal(LoDb &lodb);
void reloadOverrides();
void resetCache();
void ensureRegistry(LoBBSCommandCtx &ctx);
const LoScalar *findKeyDef(const char *key);
const std::vector<LoScalar> &keyDefs(LoBBSCommandCtx &ctx);
uint32_t effectiveValue(LoBBSCommandCtx &ctx, const char *key);
const char *validateAndSet(LoBBSCommandCtx &ctx, const char *key, uint32_t value);
const char *resetKey(LoBBSCommandCtx &ctx, const char *key);
void notifyDatabaseOpened(LoBBSModule &mod);
private:
lodb_uuid_t rowUuid(const char *key) const;
void fireConfigChanged(LoBBSCommandCtx &ctx, const char *key, uint32_t value);
LoDb &lodb_;
std::map<std::string, uint32_t> overrides_;
std::vector<LoScalar> registry_;
bool registryBuilt_ = false;
bool overridesLoaded_ = false;
char lastValidateError_[128] = {0};
};
#endif
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include <cstdint>
/** Stored override row in table `config`. */
namespace ConfigStoreField
{
inline constexpr uint32_t FIELD_VALUE = 0;
} // namespace ConfigStoreField
/** Key definition rows from `config_keys`. */
namespace ConfigKeyField
{
inline constexpr uint32_t FIELD_DEFAULT = 0;
inline constexpr uint32_t FIELD_MIN = 1;
inline constexpr uint32_t FIELD_MAX = 2;
} // namespace ConfigKeyField
/** Proposed value in `config_validate` value record. */
namespace ConfigValidateField
{
inline constexpr uint32_t FIELD_VALUE = 0;
} // namespace ConfigValidateField
#endif
File diff suppressed because it is too large. Load diff
+6
View File
@@ -0,0 +1,6 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
void lobbsFsRegisterCommands();
#endif
+124
View File
@@ -0,0 +1,124 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "HelpCommands.h"
#include "../../LoBBSCommandRegistry.h"
#include "../../LoBBSConfig.h"
#include "../../LoBBSHooks.h"
#include "../../LoBBSResponse.h"
#include "../../LoBBSVersion.h"
#include <cstring>
#include <lodb/LoDB.h>
#include "LoBBSStackGuard.h"
static void lobbsTrimRestInPlace(char *s)
{
if (!s)
return;
char *p = s;
while (*p == ' ' || *p == '\t')
p++;
if (p != s)
memmove(s, p, strlen(p) + 1);
size_t len = strlen(s);
while (len > 0 && (s[len - 1] == ' ' || s[len - 1] == '\t'))
s[--len] = '\0';
}
static void replyCatalog(LoBBSCommandCtx &ctx)
{
std::vector<LoScalar> topics;
lobbsRecordPush(topics, "help", "this list, or /help topic");
lobbsRecordPush(topics, "hi", "welcome screen");
lobbsRecordPush(topics, "pN", "page N of the last reply");
lobbsApplyFilter("help_topics", ctx, topics, LoScalar());
LoBBSResponse resp;
lobbsRecordPush(resp.records, "LoBBS v" LOBBS_VERSION_SHORT " Help");
resp.records.insert(resp.records.end(), topics.begin(), topics.end());
lobbsCommandReplyResponse(ctx, resp);
}
static void replyTopicHelp(LoBBSCommandCtx &ctx, const char *query)
{
LoScalar args;
args.setString(LODB_F_TITLE, query);
LoScalar topic;
topic.setString(LODB_F_TITLE, query);
lobbsApplyFilter("help_for_topic", ctx, topic, args);
LoBBSResponse resp;
std::string body;
if (!topic.getString(LODB_F_DESCRIPTION, body) || body.empty()) {
lobbsRecordPush(resp.records, (std::string("No help found for ") + query).c_str());
lobbsCommandReplyResponse(ctx, resp);
return;
}
size_t start = 0;
while (start <= body.size()) {
size_t nl = body.find('\n', start);
if (nl == std::string::npos)
nl = body.size();
lobbsRecordPush(resp.records, body.substr(start, nl - start).c_str());
start = nl + 1;
}
lobbsCommandReplyResponse(ctx, resp);
}
static void replyHi(LoBBSCommandCtx &ctx)
{
LoBBSResponse resp;
lobbsRecordPush(resp.records, "LoBBS v" LOBBS_VERSION_SHORT);
char uname[LOBBS_USERNAME_BUFFER_SIZE];
if (lobbsCtxLoggedIn(ctx) && lobbsCtxUsername(ctx, uname, sizeof(uname)))
lobbsRecordPush(resp.records, (std::string("Welcome back, ") + uname + "!").c_str());
else
lobbsRecordPush(resp.records, "Welcome!");
lobbsRecordPush(resp.records, LOBBS_HELP_HINT);
lobbsRecordPush(resp.records, "Use /status to see what's happening");
if (!lobbsCtxLoggedIn(ctx))
lobbsRecordPush(resp.records, "Use /login to sign in");
lobbsCommandReplyResponse(ctx, resp);
}
static void slashHelp(LoBBSCommandCtx *ctx, const LoScalar &args)
{
if (!ctx)
return;
if (lobbsSlashVerbIs(args, "hi")) {
replyHi(*ctx);
return;
}
if (!lobbsSlashVerbIs(args, "help"))
return;
char work[128];
work[0] = '\0';
if (ctx->rest && ctx->rest[0])
strncpy(work, ctx->rest, sizeof(work) - 1);
lobbsTrimRestInPlace(work);
if (!work[0]) {
replyCatalog(*ctx);
return;
}
replyTopicHelp(*ctx, work);
}
static const LoBBSVerb pagingHelpVerbs[] = {
{"pN", nullptr, 0, "pN — next page of last reply (/p2, /43 p2 machine)"},
};
static void filterHelpForTopic(LoBBSCommandCtx *ctx, LoScalar &value, const LoScalar &args)
{
lobbsHelpForTable(ctx, value, args, "pN", pagingHelpVerbs, sizeof(pagingHelpVerbs) / sizeof(pagingHelpVerbs[0]));
}
void lobbsHelpRegisterCommands()
{
lobbsAddAction("slash_cmd", slashHelp, LOBBS_HOOK_PRIORITY_HELP);
lobbsAddFilter("help_for_topic", filterHelpForTopic, LOBBS_HOOK_PRIORITY_HELP);
}
#endif
+6
View File
@@ -0,0 +1,6 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
void lobbsHelpRegisterCommands();
#endif
+9
View File
@@ -0,0 +1,9 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "Mail.h"
#include "LoBBSStackGuard.h"
MailApp::MailApp(LoDb &lodb) : dal_(lodb) {}
#endif
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "MailDal.h"
class MailApp
{
public:
explicit MailApp(LoDb &lodb);
MailDal &dal() { return dal_; }
private:
MailDal dal_;
};
#endif
+287
View File
@@ -0,0 +1,287 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "MailCommands.h"
#include "../../LoBBSCommandRegistry.h"
#include "../../LoBBSConfig.h"
#include "../../LoBBSHooks.h"
#include "../../LoBBSModule.h"
#include "../../LoBBSReply.h"
#include "../../LoBBSResponse.h"
#include "../AppUtil.h"
#include "../Msg/MsgCommon.h"
#include "MailDal.h"
#include "MailRecords.h"
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <lodb/LoDB.h>
#include <vector>
#include "LoBBSStackGuard.h"
static void mailAppendListRecord(LoBBSModule *mod, LoBBSResponse &resp, uint32_t oneBasedIndex, const LoScalar &mail)
{
char name[LOBBS_USERNAME_BUFFER_SIZE];
char when[LOBBS_TIME_AGO_BUFFER_SIZE];
char trunc[LOBBS_LIST_LINE_TRUNC_BUFFER_SIZE];
// One char past the preview limit is enough for lobbsAppTruncMsg to add "...".
char msg[LOBBS_LIST_LINE_TRUNC_MAX_CHARS + 2];
char line[LOBBS_REPLY_BYTES + 1];
lobbsAppUsernameForUuid(mod, MailDal::mailFromUuid(mail), name, sizeof(name));
lobbsAppTimeAgo(MailDal::mailTimestamp(mail), when, sizeof(when));
MailDal::mailMessage(mail, msg, sizeof(msg));
lobbsAppTruncMsg(msg, trunc, sizeof(trunc), LOBBS_LIST_LINE_TRUNC_MAX_CHARS);
snprintf(line, sizeof(line), "[%u]%s @%s: %s (%s)", (unsigned)oneBasedIndex, MailDal::mailRead(mail) ? "" : "*", name, trunc,
when);
LoScalar rec;
rec.setUint64(LODB_F_ID, MailDal::mailUuid(mail));
rec.setString(LODB_F_TITLE, line);
rec.setString(LODB_F_DESCRIPTION, trunc);
rec.setBool(MailField::FIELD_READ, MailDal::mailRead(mail));
rec.setUint32(LODB_F_CREATED, MailDal::mailTimestamp(mail));
lobbsResponseAppendRecord(resp, rec);
}
static void mailSubList(LoBBSCommandCtx &ctx)
{
const char *sub = lobbsArgPeek(ctx);
if (sub && strcasecmp(sub, "list") == 0)
lobbsArgShift(ctx);
uint64_t inboxUuid = lobbsCtxUserUuid(ctx);
const char *maybeUser = lobbsArgPeek(ctx);
if (maybeUser) {
if (!lobbsCommandRequireSysop(ctx))
return;
const char *user = lobbsArgShift(ctx);
if (!lobbsAppResolveUsername(ctx, user, inboxUuid))
return;
}
auto all = ctx.mod->mail().dal().getAllMailForUser(inboxUuid);
LoBBSResponse resp;
if (all.empty()) {
lobbsResponseSetError(resp, "No mail");
lobbsCommandReplyResponse(ctx, resp);
return;
}
for (size_t i = 0; i < all.size(); i++)
mailAppendListRecord(ctx.mod, resp, (uint32_t)(i + 1), all[i]);
lobbsCommandReplyResponse(ctx, resp);
}
static void mailSubRead(LoBBSCommandCtx &ctx, bool numericShorthand)
{
MailDal &mail = ctx.mod->mail().dal();
if (!numericShorthand) {
const char *sub = lobbsArgShift(ctx);
(void)sub;
}
uint32_t idx = 0;
uint64_t inboxUuid = lobbsCtxUserUuid(ctx);
bool markRead = true;
bool sysopRead = ctx.session.isSysop && lobbsArgPeek(ctx) && !lobbsArgPeekIsUint(ctx);
if (sysopRead) {
const char *user = lobbsArgShift(ctx);
if (!user || !lobbsAppResolveUsername(ctx, user, inboxUuid))
return;
const char *beforeNum = lobbsArgPeek(ctx);
if (!lobbsArgShiftUint(ctx, idx)) {
LoBBSResponse resp;
lobbsResponseSetError(resp, beforeNum ? "Invalid message number." : "Usage: /mail read N");
lobbsCommandReplyResponse(ctx, resp);
return;
}
markRead = false;
} else {
const char *beforeNum = lobbsArgPeek(ctx);
if (!lobbsArgShiftUint(ctx, idx)) {
LoBBSResponse resp;
lobbsResponseSetError(resp, beforeNum ? "Invalid message number." : "Usage: /mail read N");
lobbsCommandReplyResponse(ctx, resp);
return;
}
}
auto mailMessages = mail.getAllMailForUser(inboxUuid);
if (idx == 0 || idx > mailMessages.size()) {
LoBBSResponse resp;
lobbsResponseSetError(resp, "Invalid message number");
lobbsCommandReplyResponse(ctx, resp);
return;
}
const LoScalar &m = mailMessages[idx - 1];
char name[LOBBS_USERNAME_BUFFER_SIZE];
char when[LOBBS_TIME_AGO_BUFFER_SIZE];
char body[LOBBS_MESSAGE_READ_BODY_BUFFER_SIZE];
char header[LOBBS_USERNAME_BUFFER_SIZE + LOBBS_TIME_AGO_BUFFER_SIZE + 16];
lobbsAppUsernameForUuid(ctx.mod, MailDal::mailFromUuid(m), name, sizeof(name));
MailDal::mailMessage(m, body, sizeof(body));
lobbsAppTimeAgo(MailDal::mailTimestamp(m), when, sizeof(when));
snprintf(header, sizeof(header), "From: @%s (%s)", name, when);
LoScalar rec;
rec.setUint64(LODB_F_ID, MailDal::mailUuid(m));
rec.setString(LODB_F_TITLE, header);
rec.setString(LODB_F_DESCRIPTION, body);
rec.setUint32(LODB_F_CREATED, MailDal::mailTimestamp(m));
if (markRead && inboxUuid == lobbsCtxUserUuid(ctx))
mail.markMailAsRead(MailDal::mailUuid(m));
LoBBSResponse resp;
lobbsResponseAppendRecord(resp, rec);
lobbsCommandReplyResponse(ctx, resp);
}
static void mailSubReadCmd(LoBBSCommandCtx &ctx)
{
mailSubRead(ctx, false);
}
static void mailSubUnread(LoBBSCommandCtx &ctx)
{
uint32_t idx = 0;
if (!lobbsMsgShiftIndex(ctx, 1, "Usage: /mail unread N", "Invalid message number.", idx))
return;
MailDal &mail = ctx.mod->mail().dal();
auto mailMessages = mail.getAllMailForUser(lobbsCtxUserUuid(ctx));
if (idx == 0 || idx > mailMessages.size()) {
LoBBSResponse resp;
lobbsResponseSetError(resp, "Invalid message number");
lobbsCommandReplyResponse(ctx, resp);
return;
}
bool ok = mail.markMailAsUnread(MailDal::mailUuid(mailMessages[idx - 1]));
LoBBSResponse resp;
lobbsRecordPush(resp.records, ok ? "Marked unread." : "Failed.");
lobbsCommandReplyResponse(ctx, resp);
}
static void mailSubDelete(LoBBSCommandCtx &ctx)
{
uint32_t idx = 0;
if (!lobbsMsgShiftIndex(ctx, 1, "Usage: /mail delete N", "Invalid message number.", idx))
return;
LoBBSResponse resp;
lobbsRecordPush(resp.records,
ctx.mod->mail().dal().deleteMailInboxIndex(lobbsCtxUserUuid(ctx), idx) ? "Deleted." : "Failed.");
lobbsCommandReplyResponse(ctx, resp);
}
static void mailSubSend(LoBBSCommandCtx &ctx)
{
const char *sub = lobbsArgShift(ctx);
if (!sub || strcasecmp(sub, "send") != 0) {
LoBBSResponse resp;
lobbsResponseSetError(resp, "Usage: /mail send user message...");
lobbsCommandReplyResponse(ctx, resp);
return;
}
const char *user = lobbsArgShift(ctx);
const char *body = lobbsArgRest(ctx);
if (!user || !body[0]) {
LoBBSResponse resp;
lobbsResponseSetError(resp, "Usage: /mail send user message...");
lobbsCommandReplyResponse(ctx, resp);
return;
}
uint64_t toUuid = 0;
if (!lobbsAppResolveUsername(ctx, user, toUuid))
return;
LoBBSResponse resp;
LoDbError err = ctx.mod->mail().dal().sendMail(lobbsCtxUserUuid(ctx), toUuid, body);
lobbsRecordPush(resp.records, err == LODB_OK ? "Mail sent." : lobbsDbErrorText(err, "Failed to send mail."));
lobbsCommandReplyResponse(ctx, resp);
}
static const LoBBSVerb mailVerbs[] = {
{"list", mailSubList, LOBBS_V_LOGIN, "list — inbox"},
{"list", nullptr, LOBBS_V_LOGIN | LOBBS_V_SYSOP, "list user — sysop: another inbox"},
{"read", mailSubReadCmd, LOBBS_V_LOGIN, "read N — read message (/mail N)"},
{"read", nullptr, LOBBS_V_LOGIN | LOBBS_V_SYSOP, "read user N — sysop: read without marking read"},
{"unread", mailSubUnread, LOBBS_V_LOGIN, "unread N — mark message unread"},
{"delete", mailSubDelete, LOBBS_V_LOGIN, "delete N — delete message from inbox"},
{"send", mailSubSend, LOBBS_V_LOGIN, "send user message... — send mail"},
};
static void handleMail(LoBBSCommandCtx &ctx)
{
if (!lobbsCommandRequireLogin(ctx))
return;
if (lobbsArgPeekIsUint(ctx)) {
mailSubRead(ctx, true);
return;
}
if (!lobbsDispatchSub(ctx, mailVerbs, sizeof(mailVerbs) / sizeof(mailVerbs[0]))) {
LoBBSResponse resp;
lobbsResponseSetError(resp, "Unknown command. Try /help mail");
lobbsCommandReplyResponse(ctx, resp);
}
}
static void slashMail(LoBBSCommandCtx *ctx, const LoScalar &args)
{
if (!ctx || !lobbsSlashVerbIs(args, "mail"))
return;
handleMail(*ctx);
}
static void filterMailHelpTopics(LoBBSCommandCtx *ctx, std::vector<LoScalar> &topics, const LoScalar &args)
{
(void)args;
if (!ctx || !lobbsCtxLoggedIn(*ctx))
return;
lobbsRecordPush(topics, "mail", "read and send mail");
}
static void filterMailHelpForTopic(LoBBSCommandCtx *ctx, LoScalar &value, const LoScalar &args)
{
lobbsHelpForTable(ctx, value, args, "mail", mailVerbs, sizeof(mailVerbs) / sizeof(mailVerbs[0]));
}
static void filterMailStatusLines(LoBBSCommandCtx *ctx, std::vector<LoScalar> &lines, const LoScalar &args)
{
(void)args;
if (!ctx || !ctx->mod)
return;
char title[64];
char value[32];
if (lobbsCtxLoggedIn(*ctx)) {
uint32_t n = ctx->mod->mail().dal().countUnreadMail(lobbsCtxUserUuid(*ctx));
snprintf(title, sizeof(title), "Mail");
snprintf(value, sizeof(value), "%u unread", (unsigned)n);
} else {
uint32_t n = ctx->mod->mail().dal().countAllMail();
snprintf(title, sizeof(title), "Mail");
snprintf(value, sizeof(value), "%u total", (unsigned)n);
}
lobbsRecordPush(lines, title, value);
}
#if LOBBS_SEED
#include "MailSeed.h"
static void actionMailSeed(LoBBSCommandCtx *ctx, const LoScalar &args)
{
(void)args;
if (ctx && ctx->mod)
lobbsSeedMail(*ctx->mod);
}
#endif
void lobbsMailRegisterCommands()
{
lobbsAddAction("slash_cmd", slashMail, LOBBS_HOOK_PRIORITY_FEATURE);
lobbsAddFilter("help_topics", filterMailHelpTopics, LOBBS_HOOK_PRIORITY_FEATURE);
lobbsAddFilter("help_for_topic", filterMailHelpForTopic, LOBBS_HOOK_PRIORITY_FEATURE);
lobbsAddFilter("status_lines", filterMailStatusLines, LOBBS_HOOK_PRIORITY_FEATURE);
#if LOBBS_SEED
lobbsAddAction("seed", actionMailSeed, LOBBS_HOOK_PRIORITY_FEATURE);
#endif
}
#endif
+6
View File
@@ -0,0 +1,6 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
void lobbsMailRegisterCommands();
#endif
+158
View File
@@ -0,0 +1,158 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "MailDal.h"
#include "../../LoBBSConfig.h"
#include "MailRecords.h"
#include "configuration.h"
#include "gps/RTC.h"
#include <cstring>
#include "LoBBSStackGuard.h"
MailDal::MailDal(LoDb &lodb) : lodb_(lodb)
{
lodb_.registerTable("mail");
}
uint64_t MailDal::mailUuid(const LoScalar &m)
{
uint64_t v = 0;
m.getUint64(LODB_F_ID, v);
return v;
}
bool MailDal::mailMessage(const LoScalar &m, char *buf, size_t bufCap)
{
std::string s;
if (!m.getString(LODB_F_DESCRIPTION, s) || bufCap == 0)
return false;
strncpy(buf, s.c_str(), bufCap - 1);
buf[bufCap - 1] = '\0';
return true;
}
bool MailDal::mailRead(const LoScalar &m)
{
bool v = false;
m.getBool(MailField::FIELD_READ, v);
return v;
}
uint32_t MailDal::mailTimestamp(const LoScalar &m)
{
uint32_t v = 0;
m.getUint32(LODB_F_CREATED, v);
return v;
}
uint64_t MailDal::mailFromUuid(const LoScalar &m)
{
uint64_t v = 0;
m.getUint64(MailField::FIELD_FROM, v);
return v;
}
uint64_t MailDal::mailToUuid(const LoScalar &m)
{
uint64_t v = 0;
m.getUint64(MailField::FIELD_TO, v);
return v;
}
static int compareMailByTimestamp(const LoScalar &a, const LoScalar &b)
{
uint32_t t1 = MailDal::mailTimestamp(a);
uint32_t t2 = MailDal::mailTimestamp(b);
if (t2 > t1)
return 1;
if (t2 < t1)
return -1;
return 0;
}
static constexpr uint32_t LOBBS_MAX_LIST_ROWS = 256;
LoDbError MailDal::sendMail(uint64_t fromUserUuid, uint64_t toUserUuid, const char *message)
{
lodb_uuid_t mailUuidVal = lodb_new_uuid(nullptr, toUserUuid ^ fromUserUuid);
LoScalar mail;
char msgBuf[LOBBS_MESSAGE_BODY_BUFFER_SIZE];
strncpy(msgBuf, message, LOBBS_MESSAGE_BODY_MAX);
msgBuf[LOBBS_MESSAGE_BODY_MAX] = '\0';
mail.setString(LODB_F_DESCRIPTION, msgBuf);
mail.setBool(MailField::FIELD_READ, false);
mail.setUint64(MailField::FIELD_FROM, fromUserUuid);
mail.setUint64(MailField::FIELD_TO, toUserUuid);
LoDbError err = lodb_.insert("mail", mailUuidVal, mail);
if (err != LODB_OK)
LOG_ERROR("Failed to send mail");
return err;
}
std::vector<LoScalar> MailDal::getAllMailForUser(uint64_t userUuid)
{
auto mail_filter = [userUuid](const LoScalar &rec) -> bool { return MailDal::mailToUuid(rec) == userUuid; };
return lodb_.select("mail", mail_filter, compareMailByTimestamp, LOBBS_MAX_LIST_ROWS);
}
bool MailDal::markMailAsRead(uint64_t mailUuidVal)
{
LoScalar mail;
LoDbError err = lodb_.get("mail", mailUuidVal, mail);
if (err != LODB_OK)
return false;
mail.setBool(MailField::FIELD_READ, true);
mail.removeField(LODB_F_CREATED);
mail.removeField(LODB_F_UPDATED);
return lodb_.update("mail", mailUuidVal, mail) == LODB_OK;
}
bool MailDal::markMailAsUnread(uint64_t mailUuidVal)
{
LoScalar mail;
LoDbError err = lodb_.get("mail", mailUuidVal, mail);
if (err != LODB_OK)
return false;
mail.setBool(MailField::FIELD_READ, false);
mail.removeField(LODB_F_CREATED);
mail.removeField(LODB_F_UPDATED);
return lodb_.update("mail", mailUuidVal, mail) == LODB_OK;
}
uint32_t MailDal::countAllMail()
{
int n = lodb_.count("mail");
return n < 0 ? 0 : (uint32_t)n;
}
uint16_t MailDal::countUnreadMail(uint64_t userUuid)
{
auto mail_filter = [userUuid](const LoScalar &rec) -> bool {
return MailDal::mailToUuid(rec) == userUuid && !MailDal::mailRead(rec);
};
auto rows = lodb_.select("mail", mail_filter, LoDbComparator());
uint16_t count = (uint16_t)(rows.size() > 0xffff ? 0xffff : rows.size());
return count;
}
bool MailDal::deleteMailUuid(uint64_t mailUuidVal)
{
return lodb_.deleteRecord("mail", mailUuidVal) == LODB_OK;
}
bool MailDal::deleteMailInboxIndex(uint64_t inboxOwnerUuid, uint32_t oneBasedIndex)
{
if (oneBasedIndex == 0)
return false;
auto mail = getAllMailForUser(inboxOwnerUuid);
if (oneBasedIndex > mail.size())
return false;
uint64_t uuid = mailUuid(mail[oneBasedIndex - 1]);
return deleteMailUuid(uuid);
}
#endif
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include <lodb/LoDB.h>
#include <stdint.h>
#include <vector>
class MailDal
{
public:
explicit MailDal(LoDb &lodb);
static uint64_t mailUuid(const LoScalar &m);
static bool mailMessage(const LoScalar &m, char *buf, size_t bufCap);
static bool mailRead(const LoScalar &m);
static uint32_t mailTimestamp(const LoScalar &m);
static uint64_t mailFromUuid(const LoScalar &m);
static uint64_t mailToUuid(const LoScalar &m);
LoDbError sendMail(uint64_t fromUserUuid, uint64_t toUserUuid, const char *message);
std::vector<LoScalar> getAllMailForUser(uint64_t userUuid);
bool markMailAsRead(uint64_t mailUuid);
bool markMailAsUnread(uint64_t mailUuid);
uint16_t countUnreadMail(uint64_t userUuid);
uint32_t countAllMail();
bool deleteMailUuid(uint64_t mailUuid);
bool deleteMailInboxIndex(uint64_t inboxOwnerUuid, uint32_t oneBasedIndex);
private:
LoDb &lodb_;
};
#endif
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include <cstdint>
/** App field slots for `mail` rows (message body is LODB_F_DESCRIPTION). */
namespace MailField
{
inline constexpr uint32_t FIELD_READ = 0;
inline constexpr uint32_t FIELD_FROM = 1;
inline constexpr uint32_t FIELD_TO = 2;
} // namespace MailField
#endif
+39
View File
@@ -0,0 +1,39 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "../../LoBBSConfig.h"
#if LOBBS_SEED
#include "../../LoBBSModule.h"
#include "../AppUtil.h"
#include "MailDal.h"
#include "MailSeed.h"
#include <cstdio>
#include <vector>
#include "LoBBSStackGuard.h"
void lobbsSeedMail(LoBBSModule &mod)
{
MailDal &mail = mod.mail().dal();
uint64_t sysop = lobbsAppUuidForUsername(&mod, "sysop");
if (!sysop)
return;
for (int i = 1; i <= 8; i++) {
char fromName[16];
snprintf(fromName, sizeof(fromName), "demo%02d", i);
uint64_t fromUuid = lobbsAppUuidForUsername(&mod, fromName);
if (!fromUuid)
continue;
char body[64];
snprintf(body, sizeof(body), "Demo mail #%d to sysop with extra text.", i);
mail.sendMail(fromUuid, sysop, body);
}
std::vector<LoScalar> inbox = mail.getAllMailForUser(sysop);
for (size_t j = 0; j < inbox.size() && j < 3; j++)
mail.markMailAsRead(MailDal::mailUuid(inbox[j]));
}
#endif
#endif
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS && LOBBS_SEED
class LoBBSModule;
void lobbsSeedMail(LoBBSModule &mod);
#endif
+61
View File
@@ -0,0 +1,61 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "MsgCommon.h"
#include "../../LoBBSCommandRegistry.h"
#include "../../LoBBSHooks.h"
#include "../../LoBBSResponse.h"
#include "../Mail/MailRecords.h"
#include "../News/NewsRecords.h"
#include <lodb/LoDB.h>
#include <string>
#include "LoBBSStackGuard.h"
bool lobbsMsgShiftIndex(LoBBSCommandCtx &ctx, size_t count, const char *usage, const char *badNumMsg, uint32_t &idxOut)
{
for (size_t i = 0; i < count; i++)
lobbsArgShift(ctx);
const char *beforeNum = lobbsArgPeek(ctx);
if (!lobbsArgShiftUint(ctx, idxOut)) {
LoBBSResponse resp;
lobbsResponseSetError(resp, beforeNum ? badNumMsg : usage);
lobbsCommandReplyResponse(ctx, resp);
return false;
}
return true;
}
static bool lobbsMsgTryDisplayRecord(const LoScalar &record, uint32_t readField, std::string &lineOut)
{
std::string title;
if (!record.getString(LODB_F_TITLE, title))
return false;
if (!title.empty() && title[0] == '[' && record.has(readField)) {
lineOut = title;
return true;
}
if (title.find("From:") == 0 && record.has(LODB_F_DESCRIPTION)) {
std::string body;
if (record.getString(LODB_F_DESCRIPTION, body)) {
lineOut = title + "\n" + body;
return true;
}
}
return false;
}
static void displayMsgHuman(LoBBSCommandCtx *ctx, LoScalar &value, const LoScalar &record)
{
(void)ctx;
std::string line;
if (lobbsMsgTryDisplayRecord(record, MailField::FIELD_READ, line) ||
lobbsMsgTryDisplayRecord(record, NewsField::FIELD_LIST_READ, line))
value.setString(LODB_F_TITLE, line);
}
void lobbsMsgRegisterDisplay()
{
lobbsAddFilter("display_human", displayMsgHuman, LOBBS_HOOK_PRIORITY_FEATURE);
}
#endif
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "../../LoBBSCommandCtx.h"
#include <stddef.h>
#include <stdint.h>
bool lobbsMsgShiftIndex(LoBBSCommandCtx &ctx, size_t count, const char *usage, const char *badNumMsg, uint32_t &idxOut);
void lobbsMsgRegisterDisplay();
#endif
+9
View File
@@ -0,0 +1,9 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "News.h"
#include "LoBBSStackGuard.h"
NewsApp::NewsApp(LoDb &lodb) : dal_(lodb) {}
#endif
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "NewsDal.h"
class NewsApp
{
public:
explicit NewsApp(LoDb &lodb);
NewsDal &dal() { return dal_; }
private:
NewsDal dal_;
};
#endif
+263
View File
@@ -0,0 +1,263 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "NewsCommands.h"
#include "../../LoBBSCommandRegistry.h"
#include "../../LoBBSConfig.h"
#include "../../LoBBSHooks.h"
#include "../../LoBBSModule.h"
#include "../../LoBBSReply.h"
#include "../../LoBBSResponse.h"
#include "../AppUtil.h"
#include "../Msg/MsgCommon.h"
#include "NewsDal.h"
#include "NewsRecords.h"
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <lodb/LoDB.h>
#include <vector>
#include "LoBBSStackGuard.h"
static void newsAppendListRecord(LoBBSModule *mod, LoBBSResponse &resp, uint32_t oneBasedIndex, const LoBBSNewsEntry &entry)
{
const LoScalar &news = entry.news;
char name[LOBBS_USERNAME_BUFFER_SIZE];
char when[LOBBS_TIME_AGO_BUFFER_SIZE];
char trunc[LOBBS_LIST_LINE_TRUNC_BUFFER_SIZE];
// One char past the preview limit is enough for lobbsAppTruncMsg to add "...".
char msg[LOBBS_LIST_LINE_TRUNC_MAX_CHARS + 2];
char line[LOBBS_REPLY_BYTES + 1];
lobbsAppUsernameForUuid(mod, NewsDal::newsAuthorUuid(news), name, sizeof(name));
lobbsAppTimeAgo(NewsDal::newsTimestamp(news), when, sizeof(when));
NewsDal::newsMessage(news, msg, sizeof(msg));
lobbsAppTruncMsg(msg, trunc, sizeof(trunc), LOBBS_LIST_LINE_TRUNC_MAX_CHARS);
snprintf(line, sizeof(line), "[%u]%s @%s: %s (%s)", (unsigned)oneBasedIndex, entry.isRead ? "" : "*", name, trunc, when);
LoScalar rec;
rec.setUint64(LODB_F_ID, NewsDal::newsUuid(news));
rec.setString(LODB_F_TITLE, line);
rec.setString(LODB_F_DESCRIPTION, trunc);
rec.setBool(NewsField::FIELD_LIST_READ, entry.isRead);
rec.setUint32(LODB_F_CREATED, NewsDal::newsTimestamp(news));
lobbsResponseAppendRecord(resp, rec);
}
static void newsSubList(LoBBSCommandCtx &ctx)
{
const char *sub = lobbsArgPeek(ctx);
if (sub && strcasecmp(sub, "list") == 0)
lobbsArgShift(ctx);
auto all = ctx.mod->news().dal().getAllNewsForUser(lobbsCtxUserUuid(ctx));
LoBBSResponse resp;
if (all.empty()) {
lobbsResponseSetError(resp, "No news");
lobbsCommandReplyResponse(ctx, resp);
return;
}
for (size_t i = 0; i < all.size(); i++)
newsAppendListRecord(ctx.mod, resp, (uint32_t)(i + 1), all[i]);
lobbsCommandReplyResponse(ctx, resp);
}
static void newsSubRead(LoBBSCommandCtx &ctx, bool numericShorthand)
{
if (!numericShorthand)
lobbsArgShift(ctx);
const char *beforeNum = lobbsArgPeek(ctx);
uint32_t idx = 0;
if (!lobbsArgShiftUint(ctx, idx)) {
LoBBSResponse resp;
lobbsResponseSetError(resp, beforeNum ? "Invalid news number." : "Usage: /news read N");
lobbsCommandReplyResponse(ctx, resp);
return;
}
NewsDal &news = ctx.mod->news().dal();
auto newsItems = news.getAllNewsForUser(lobbsCtxUserUuid(ctx));
if (idx == 0 || idx > newsItems.size()) {
LoBBSResponse resp;
lobbsResponseSetError(resp, "Invalid news number");
lobbsCommandReplyResponse(ctx, resp);
return;
}
const LoScalar &item = newsItems[idx - 1].news;
char name[LOBBS_USERNAME_BUFFER_SIZE];
char when[LOBBS_TIME_AGO_BUFFER_SIZE];
char body[LOBBS_MESSAGE_READ_BODY_BUFFER_SIZE];
char header[LOBBS_USERNAME_BUFFER_SIZE + LOBBS_TIME_AGO_BUFFER_SIZE + 16];
lobbsAppUsernameForUuid(ctx.mod, NewsDal::newsAuthorUuid(item), name, sizeof(name));
NewsDal::newsMessage(item, body, sizeof(body));
lobbsAppTimeAgo(NewsDal::newsTimestamp(item), when, sizeof(when));
snprintf(header, sizeof(header), "From: @%s (%s)", name, when);
LoScalar rec;
rec.setUint64(LODB_F_ID, NewsDal::newsUuid(item));
rec.setString(LODB_F_TITLE, header);
rec.setString(LODB_F_DESCRIPTION, body);
rec.setUint32(LODB_F_CREATED, NewsDal::newsTimestamp(item));
news.markNewsAsRead(NewsDal::newsUuid(item), lobbsCtxUserUuid(ctx));
LoBBSResponse resp;
lobbsResponseAppendRecord(resp, rec);
lobbsCommandReplyResponse(ctx, resp);
}
static void newsSubUnread(LoBBSCommandCtx &ctx)
{
uint32_t idx = 0;
if (!lobbsMsgShiftIndex(ctx, 1, "Usage: /news unread N", "Invalid news number.", idx))
return;
NewsDal &news = ctx.mod->news().dal();
auto newsItems = news.getAllNewsForUser(lobbsCtxUserUuid(ctx));
if (idx == 0 || idx > newsItems.size()) {
LoBBSResponse resp;
lobbsResponseSetError(resp, "Invalid news number");
lobbsCommandReplyResponse(ctx, resp);
return;
}
uint64_t uuid = NewsDal::newsUuid(newsItems[idx - 1].news);
LoBBSResponse resp;
lobbsRecordPush(resp.records, news.markNewsAsUnread(uuid, lobbsCtxUserUuid(ctx)) ? "Marked unread." : "Failed.");
lobbsCommandReplyResponse(ctx, resp);
}
static void newsSubDelete(LoBBSCommandCtx &ctx)
{
uint32_t idx = 0;
if (!lobbsMsgShiftIndex(ctx, 1, "Usage: /news delete N", "Invalid news number.", idx))
return;
NewsDal &news = ctx.mod->news().dal();
auto newsItems = news.getAllNewsForUser(lobbsCtxUserUuid(ctx));
if (idx == 0 || idx > newsItems.size()) {
LoBBSResponse resp;
lobbsResponseSetError(resp, "Invalid news number");
lobbsCommandReplyResponse(ctx, resp);
return;
}
uint64_t uuid = NewsDal::newsUuid(newsItems[idx - 1].news);
LoBBSResponse resp;
lobbsRecordPush(resp.records, news.deleteNewsUuid(uuid) ? "Deleted." : "Failed.");
lobbsCommandReplyResponse(ctx, resp);
}
static void newsSubPost(LoBBSCommandCtx &ctx)
{
const char *sub = lobbsArgShift(ctx);
if (!sub || strcasecmp(sub, "post") != 0) {
LoBBSResponse resp;
lobbsResponseSetError(resp, "Usage: /news post message...");
lobbsCommandReplyResponse(ctx, resp);
return;
}
const char *msgBody = lobbsArgRest(ctx);
if (!msgBody[0]) {
LoBBSResponse resp;
lobbsResponseSetError(resp, "Usage: /news post message...");
lobbsCommandReplyResponse(ctx, resp);
return;
}
if (!isalpha((unsigned char)msgBody[0])) {
LoBBSResponse resp;
lobbsResponseSetError(resp, "Start with a letter.");
lobbsCommandReplyResponse(ctx, resp);
return;
}
LoBBSResponse resp;
LoDbError err = ctx.mod->news().dal().postNews(lobbsCtxUserUuid(ctx), msgBody);
lobbsRecordPush(resp.records, err == LODB_OK ? "News posted." : lobbsDbErrorText(err, "Failed to post news."));
lobbsCommandReplyResponse(ctx, resp);
}
static void newsSubReadCmd(LoBBSCommandCtx &ctx)
{
newsSubRead(ctx, false);
}
static const LoBBSVerb newsVerbs[] = {
{"list", newsSubList, LOBBS_V_LOGIN, "list — news index"},
{"read", newsSubReadCmd, LOBBS_V_LOGIN, "read N — read and mark read"},
{"unread", newsSubUnread, LOBBS_V_LOGIN, "unread N — mark unread"},
{"delete", newsSubDelete, LOBBS_V_LOGIN | LOBBS_V_SYSOP, "delete N — delete (sysop)"},
{"post", newsSubPost, LOBBS_V_LOGIN, "post message... — post news"},
};
static void handleNews(LoBBSCommandCtx &ctx)
{
if (!lobbsCommandRequireLogin(ctx))
return;
if (lobbsArgPeekIsUint(ctx)) {
newsSubRead(ctx, true);
return;
}
if (!lobbsDispatchSub(ctx, newsVerbs, sizeof(newsVerbs) / sizeof(newsVerbs[0]))) {
LoBBSResponse resp;
lobbsResponseSetError(resp, "Unknown command. Try /help news");
lobbsCommandReplyResponse(ctx, resp);
}
}
static void slashNews(LoBBSCommandCtx *ctx, const LoScalar &args)
{
if (!ctx || !lobbsSlashVerbIs(args, "news"))
return;
handleNews(*ctx);
}
static void filterNewsHelpTopics(LoBBSCommandCtx *ctx, std::vector<LoScalar> &topics, const LoScalar &args)
{
(void)args;
if (!ctx || !lobbsCtxLoggedIn(*ctx))
return;
lobbsRecordPush(topics, "news", "read and post news");
}
static void filterNewsHelpForTopic(LoBBSCommandCtx *ctx, LoScalar &value, const LoScalar &args)
{
lobbsHelpForTable(ctx, value, args, "news", newsVerbs, sizeof(newsVerbs) / sizeof(newsVerbs[0]));
}
static void filterNewsStatusLines(LoBBSCommandCtx *ctx, std::vector<LoScalar> &lines, const LoScalar &args)
{
(void)args;
if (!ctx || !ctx->mod)
return;
char title[64];
char value[32];
if (lobbsCtxLoggedIn(*ctx)) {
uint32_t n = ctx->mod->news().dal().countUnreadNews(lobbsCtxUserUuid(*ctx));
snprintf(title, sizeof(title), "News");
snprintf(value, sizeof(value), "%u unread", (unsigned)n);
} else {
uint32_t n = ctx->mod->news().dal().countAllNews();
snprintf(title, sizeof(title), "News");
snprintf(value, sizeof(value), "%u total", (unsigned)n);
}
lobbsRecordPush(lines, title, value);
}
#if LOBBS_SEED
#include "NewsSeed.h"
static void actionNewsSeed(LoBBSCommandCtx *ctx, const LoScalar &args)
{
(void)args;
if (ctx && ctx->mod)
lobbsSeedNews(*ctx->mod);
}
#endif
void lobbsNewsRegisterCommands()
{
lobbsAddAction("slash_cmd", slashNews, LOBBS_HOOK_PRIORITY_FEATURE);
lobbsAddFilter("help_topics", filterNewsHelpTopics, LOBBS_HOOK_PRIORITY_FEATURE);
lobbsAddFilter("help_for_topic", filterNewsHelpForTopic, LOBBS_HOOK_PRIORITY_FEATURE);
lobbsAddFilter("status_lines", filterNewsStatusLines, LOBBS_HOOK_PRIORITY_FEATURE);
#if LOBBS_SEED
lobbsAddAction("seed", actionNewsSeed, LOBBS_HOOK_PRIORITY_FEATURE);
#endif
}
#endif
+6
View File
@@ -0,0 +1,6 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
void lobbsNewsRegisterCommands();
#endif
+159
View File
@@ -0,0 +1,159 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "NewsDal.h"
#include "../../LoBBSConfig.h"
#include "NewsRecords.h"
#include "configuration.h"
#include "gps/RTC.h"
#include <algorithm>
#include <cstring>
#include "LoBBSStackGuard.h"
NewsDal::NewsDal(LoDb &lodb) : lodb_(lodb)
{
lodb_.registerTable("news");
lodb_.registerTable("news_reads");
}
uint64_t NewsDal::newsUuid(const LoScalar &n)
{
uint64_t v = 0;
n.getUint64(LODB_F_ID, v);
return v;
}
bool NewsDal::newsMessage(const LoScalar &n, char *buf, size_t bufCap)
{
std::string s;
if (!n.getString(LODB_F_DESCRIPTION, s) || bufCap == 0)
return false;
strncpy(buf, s.c_str(), bufCap - 1);
buf[bufCap - 1] = '\0';
return true;
}
uint64_t NewsDal::newsAuthorUuid(const LoScalar &n)
{
uint64_t v = 0;
n.getUint64(NewsField::FIELD_AUTHOR, v);
return v;
}
uint32_t NewsDal::newsTimestamp(const LoScalar &n)
{
uint32_t v = 0;
n.getUint32(LODB_F_CREATED, v);
return v;
}
static void buildNewsReadKey(uint64_t newsUuid, uint64_t userUuid, char *out, size_t outSize)
{
char newsHex[17];
char userHex[17];
lodb_uuid_to_hex(newsUuid, newsHex);
lodb_uuid_to_hex(userUuid, userHex);
snprintf(out, outSize, "%s:%s", newsHex, userHex);
}
static constexpr uint32_t LOBBS_MAX_LIST_ROWS = 256;
LoDbError NewsDal::postNews(uint64_t authorUserUuid, const char *message)
{
lodb_uuid_t newsUuidVal = lodb_new_uuid(nullptr, authorUserUuid ^ (uint64_t)getTime());
LoScalar news;
char msgBuf[LOBBS_MESSAGE_BODY_BUFFER_SIZE];
strncpy(msgBuf, message, LOBBS_MESSAGE_BODY_MAX);
msgBuf[LOBBS_MESSAGE_BODY_MAX] = '\0';
news.setString(LODB_F_DESCRIPTION, msgBuf);
news.setUint64(NewsField::FIELD_AUTHOR, authorUserUuid);
return lodb_.insert("news", newsUuidVal, news);
}
bool NewsDal::isNewsReadByUser(uint64_t newsUuidVal, uint64_t userUuid)
{
char key[35];
buildNewsReadKey(newsUuidVal, userUuid, key, sizeof(key));
lodb_uuid_t readUuid = lodb_new_uuid(key, 0);
LoScalar readRecord;
LoDbError err = lodb_.get("news_reads", readUuid, readRecord);
return err == LODB_OK;
}
bool NewsDal::markNewsAsRead(uint64_t newsUuidVal, uint64_t userUuid)
{
if (isNewsReadByUser(newsUuidVal, userUuid))
return true;
char key[35];
buildNewsReadKey(newsUuidVal, userUuid, key, sizeof(key));
lodb_uuid_t readUuid = lodb_new_uuid(key, 0);
LoScalar readRecord;
readRecord.setUint64(NewsReadField::FIELD_NEWS_UUID, newsUuidVal);
readRecord.setUint64(NewsReadField::FIELD_USER_UUID, userUuid);
return lodb_.insert("news_reads", readUuid, readRecord) == LODB_OK;
}
bool NewsDal::markNewsAsUnread(uint64_t newsUuidVal, uint64_t userUuid)
{
if (!isNewsReadByUser(newsUuidVal, userUuid))
return true;
char key[35];
buildNewsReadKey(newsUuidVal, userUuid, key, sizeof(key));
lodb_uuid_t readUuid = lodb_new_uuid(key, 0);
return lodb_.deleteRecord("news_reads", readUuid) == LODB_OK;
}
std::vector<LoBBSNewsEntry> NewsDal::getAllNewsForUser(uint64_t userUuid)
{
auto allNews = lodb_.select("news", LoDbFilter(), LoDbComparator());
std::vector<LoBBSNewsEntry> newsWithStatus;
newsWithStatus.reserve(allNews.size());
for (const auto &newsRec : allNews) {
uint64_t uuid = newsUuid(newsRec);
newsWithStatus.push_back({newsRec, isNewsReadByUser(uuid, userUuid)});
}
std::sort(newsWithStatus.begin(), newsWithStatus.end(), [](const LoBBSNewsEntry &a, const LoBBSNewsEntry &b) {
if (a.isRead != b.isRead)
return !a.isRead;
return newsTimestamp(a.news) > newsTimestamp(b.news);
});
if (newsWithStatus.size() > LOBBS_MAX_LIST_ROWS)
newsWithStatus.resize(LOBBS_MAX_LIST_ROWS);
return newsWithStatus;
}
uint32_t NewsDal::countAllNews()
{
int n = lodb_.count("news");
return n < 0 ? 0 : (uint32_t)n;
}
uint16_t NewsDal::countUnreadNews(uint64_t userUuid)
{
auto allNews = lodb_.select("news", LoDbFilter(), LoDbComparator());
uint16_t count = 0;
for (const auto &newsRec : allNews) {
if (!isNewsReadByUser(newsUuid(newsRec), userUuid)) {
if (count < 0xffff)
count++;
}
}
return count;
}
bool NewsDal::deleteNewsUuid(uint64_t newsUuidVal)
{
return lodb_.deleteRecord("news", newsUuidVal) == LODB_OK;
}
#endif
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include <lodb/LoDB.h>
#include <stdint.h>
#include <vector>
struct LoBBSNewsEntry {
LoScalar news;
bool isRead;
};
class NewsDal
{
public:
explicit NewsDal(LoDb &lodb);
static uint64_t newsUuid(const LoScalar &n);
static bool newsMessage(const LoScalar &n, char *buf, size_t bufCap);
static uint64_t newsAuthorUuid(const LoScalar &n);
static uint32_t newsTimestamp(const LoScalar &n);
LoDbError postNews(uint64_t authorUserUuid, const char *message);
std::vector<LoBBSNewsEntry> getAllNewsForUser(uint64_t userUuid);
bool markNewsAsRead(uint64_t newsUuid, uint64_t userUuid);
bool markNewsAsUnread(uint64_t newsUuid, uint64_t userUuid);
uint16_t countUnreadNews(uint64_t userUuid);
uint32_t countAllNews();
bool deleteNewsUuid(uint64_t newsUuid);
private:
bool isNewsReadByUser(uint64_t newsUuid, uint64_t userUuid);
LoDb &lodb_;
};
#endif
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include <cstdint>
/** App field slots for `news` rows (message body is LODB_F_DESCRIPTION). */
namespace NewsField
{
inline constexpr uint32_t FIELD_AUTHOR = 0;
/** Ephemeral list-row flag (not stored on `news` DB rows). */
inline constexpr uint32_t FIELD_LIST_READ = 1;
} // namespace NewsField
/** App field slots for `news_reads` rows. */
namespace NewsReadField
{
inline constexpr uint32_t FIELD_NEWS_UUID = 0;
inline constexpr uint32_t FIELD_USER_UUID = 1;
} // namespace NewsReadField
#endif
+30
View File
@@ -0,0 +1,30 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "../../LoBBSConfig.h"
#if LOBBS_SEED
#include "../../LoBBSModule.h"
#include "../AppUtil.h"
#include "NewsDal.h"
#include "NewsSeed.h"
#include <cstdio>
#include "LoBBSStackGuard.h"
void lobbsSeedNews(LoBBSModule &mod)
{
NewsDal &news = mod.news().dal();
for (int i = 1; i <= 10; i++) {
char authorName[16];
snprintf(authorName, sizeof(authorName), "demo%02d", ((i - 1) % 12) + 1);
uint64_t author = lobbsAppUuidForUsername(&mod, authorName);
if (!author)
continue;
char body[80];
snprintf(body, sizeof(body), "Demo news item %d for paging tests on the board.", i);
news.postNews(author, body);
}
}
#endif
#endif
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS && LOBBS_SEED
class LoBBSModule;
void lobbsSeedNews(LoBBSModule &mod);
#endif
+40
View File
@@ -0,0 +1,40 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "StatusCommands.h"
#include "../../LoBBSCommandRegistry.h"
#include "../../LoBBSHooks.h"
#include "../../LoBBSResponse.h"
#include "LoBBSStackGuard.h"
static void handleStatus(LoBBSCommandCtx &ctx)
{
LoBBSResponse resp;
lobbsApplyFilter("status_lines", ctx, resp.records, LoScalar());
if (resp.records.empty()) {
lobbsResponseSetError(resp, "No status.");
}
lobbsCommandReplyResponse(ctx, resp);
}
static void slashStatus(LoBBSCommandCtx *ctx, const LoScalar &args)
{
if (!ctx || !lobbsSlashVerbIs(args, "status"))
return;
handleStatus(*ctx);
}
static void filterStatusHelpTopics(LoBBSCommandCtx *ctx, std::vector<LoScalar> &topics, const LoScalar &args)
{
(void)ctx;
(void)args;
lobbsRecordPush(topics, "status", "what's happening");
}
void lobbsStatusRegisterCommands()
{
lobbsAddAction("slash_cmd", slashStatus, LOBBS_HOOK_PRIORITY_STATUS);
lobbsAddFilter("help_topics", filterStatusHelpTopics, LOBBS_HOOK_PRIORITY_STATUS);
}
#endif
+6
View File
@@ -0,0 +1,6 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
void lobbsStatusRegisterCommands();
#endif
+80
View File
@@ -0,0 +1,80 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "TimeCommands.h"
#include "../../LoBBSCommandRegistry.h"
#include "../../LoBBSHooks.h"
#include "../../LoBBSReply.h"
#include "../../LoBBSResponse.h"
#include "gps/RTC.h"
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <stdint.h>
#include <sys/time.h>
#include "LoBBSStackGuard.h"
static void handleTime(LoBBSCommandCtx &ctx)
{
const char *peek = lobbsArgPeek(ctx);
if (!peek) {
char buf[LOBBS_REPLY_BYTES + 1];
snprintf(buf, sizeof(buf), "Time: %u (%s)", (unsigned)getTime(), RtcName(getRTCQuality()));
lobbsCommandReply(ctx, buf);
return;
}
if (!lobbsCommandRequireSysop(ctx))
return;
uint32_t sec = 0;
if (!lobbsArgShiftUint(ctx, sec)) {
lobbsCommandReplyError(ctx, "Invalid time.");
return;
}
if (lobbsArgHasMore(ctx)) {
lobbsCommandReplyError(ctx, "Usage: /time\nUsage: /time unix (sysop)");
return;
}
struct timeval tv;
tv.tv_sec = (time_t)sec;
tv.tv_usec = 0;
RTCSetResult r = perhapsSetRTC(RTCQualityNTP, &tv, true);
if (r == RTCSetResultSuccess)
lobbsCommandReply(ctx, "Time set.");
else if (r == RTCSetResultInvalidTime)
lobbsCommandReplyError(ctx, "Invalid time.");
else
lobbsCommandReplyError(ctx, "Could not set time.");
}
static const LoBBSVerb timeHelpVerbs[] = {
{"time", nullptr, 0, "time — show Unix time and source"},
{"time", nullptr, LOBBS_V_SYSOP, "time unix — sysop: set clock to Unix epoch"},
};
static void slashTime(LoBBSCommandCtx *ctx, const LoScalar &args)
{
if (!ctx || !lobbsSlashVerbIs(args, "time"))
return;
handleTime(*ctx);
}
static void filterTimeHelpTopics(LoBBSCommandCtx *ctx, std::vector<LoScalar> &topics, const LoScalar &args)
{
(void)ctx;
(void)args;
lobbsRecordPush(topics, "time", "show the clock");
}
static void filterTimeHelpForTopic(LoBBSCommandCtx *ctx, LoScalar &value, const LoScalar &args)
{
lobbsHelpForTable(ctx, value, args, "time", timeHelpVerbs, sizeof(timeHelpVerbs) / sizeof(timeHelpVerbs[0]));
}
void lobbsTimeRegisterCommands()
{
lobbsAddAction("slash_cmd", slashTime, LOBBS_HOOK_PRIORITY_TIME);
lobbsAddFilter("help_topics", filterTimeHelpTopics, LOBBS_HOOK_PRIORITY_TIME);
lobbsAddFilter("help_for_topic", filterTimeHelpForTopic, LOBBS_HOOK_PRIORITY_TIME);
}
#endif
+6
View File
@@ -0,0 +1,6 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
void lobbsTimeRegisterCommands();
#endif
+9
View File
@@ -0,0 +1,9 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "Wall.h"
#include "LoBBSStackGuard.h"
WallApp::WallApp(LoDb &lodb) : dal_(lodb) {}
#endif
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "WallDal.h"
#include <lodb/LoDB.h>
class WallApp
{
public:
explicit WallApp(LoDb &lodb);
WallDal &dal() { return dal_; }
private:
WallDal dal_;
};
#endif
+125
View File
@@ -0,0 +1,125 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "WallCommands.h"
#include "../../LoBBSCommandRegistry.h"
#include "../../LoBBSHooks.h"
#include "../../LoBBSModule.h"
#include "../../LoBBSReply.h"
#include "../../LoBBSResponse.h"
#include "../Config/ConfigCommon.h"
#include "WallDal.h"
#include <cstdio>
#include <cstring>
#include <lodb/LoDB.h>
#include <strings.h>
#include "LoBBSStackGuard.h"
static void replyGrid(LoBBSCommandCtx &ctx, bool markSeen)
{
WallDal &wall = ctx.mod->wall().dal();
char buf[LOBBS_REPLY_BYTES + 1];
if (!wall.formatGridLines(buf, sizeof(buf))) {
lobbsCommandReplyError(ctx, "Canvas error.");
return;
}
if (markSeen && lobbsCtxLoggedIn(ctx))
wall.markSeen(lobbsCtxUserUuid(ctx), wall.canvasCrc32());
lobbsCommandReply(ctx, buf);
}
static const LoBBSVerb wallVerbs[] = {
{"view", nullptr, 0, "view — /wall shows 12x12 grid (no login)"},
{"paint", nullptr, LOBBS_V_LOGIN, "paint — a4x set; -a4 blank; 1/cycle default"},
};
static void handleWall(LoBBSCommandCtx &ctx)
{
const char *peek = lobbsArgPeek(ctx);
if (!peek) {
replyGrid(ctx, true);
return;
}
if (lobbsDispatchSub(ctx, wallVerbs, sizeof(wallVerbs) / sizeof(wallVerbs[0])))
return;
if (!lobbsCommandRequireLogin(ctx))
return;
const char *toks[48];
int n = lobbsArgShiftMany(ctx, toks, 48);
if (n <= 0) {
replyGrid(ctx, true);
return;
}
const uint32_t period = lobbsConfigGet(ctx, "wall.period");
const uint32_t cells = lobbsConfigGet(ctx, "wall.cells");
if (const char *err =
ctx.mod->wall().dal().applyPaintTokens(lobbsCtxUserUuid(ctx), ctx.session.isSysop, toks, n, period, cells)) {
lobbsCommandReplyError(ctx, err);
return;
}
replyGrid(ctx, true);
}
static void slashWall(LoBBSCommandCtx *ctx, const LoScalar &args)
{
if (!ctx || !lobbsSlashVerbIs(args, "wall"))
return;
handleWall(*ctx);
}
static void filterWallHelpTopics(LoBBSCommandCtx *ctx, std::vector<LoScalar> &topics, const LoScalar &args)
{
(void)ctx;
(void)args;
lobbsRecordPush(topics, "wall", "shared 12x12 paint grid");
}
static void filterWallHelpForTopic(LoBBSCommandCtx *ctx, LoScalar &value, const LoScalar &args)
{
lobbsHelpForTable(ctx, value, args, "wall", wallVerbs, sizeof(wallVerbs) / sizeof(wallVerbs[0]));
}
static void filterWallStatusLines(LoBBSCommandCtx *ctx, std::vector<LoScalar> &lines, const LoScalar &args)
{
(void)args;
if (!ctx || !ctx->mod || !lobbsCtxLoggedIn(*ctx))
return;
bool dirty = ctx->mod->wall().dal().isDirtyForUser(lobbsCtxUserUuid(*ctx));
lobbsRecordPush(lines, "Wall", dirty ? "new" : "seen");
}
#if LOBBS_SEED
#include "WallSeed.h"
static void actionWallSeed(LoBBSCommandCtx *ctx, const LoScalar &args)
{
(void)args;
if (ctx && ctx->mod)
lobbsSeedWall(*ctx->mod);
}
#endif
static void filterWallConfigKeys(LoBBSCommandCtx *ctx, std::vector<LoScalar> &keys, const LoScalar &args)
{
(void)ctx;
(void)args;
lobbsConfigPushKey(keys, "wall.period", LOBBS_WALL_DEFAULT_PERIOD_SEC, 60, 86400, "Wall paint quota period (seconds)");
lobbsConfigPushKey(keys, "wall.cells", LOBBS_WALL_DEFAULT_MAX_CELLS, 1, LOBBS_WALL_CELLS, "Max cells painted per period");
}
void lobbsWallRegisterCommands()
{
lobbsAddFilter("config_keys", filterWallConfigKeys, LOBBS_HOOK_PRIORITY_FEATURE);
lobbsAddAction("slash_cmd", slashWall, LOBBS_HOOK_PRIORITY_FEATURE);
lobbsAddFilter("help_topics", filterWallHelpTopics, LOBBS_HOOK_PRIORITY_FEATURE);
lobbsAddFilter("help_for_topic", filterWallHelpForTopic, LOBBS_HOOK_PRIORITY_FEATURE);
lobbsAddFilter("status_lines", filterWallStatusLines, LOBBS_HOOK_PRIORITY_FEATURE);
#if LOBBS_SEED
lobbsAddAction("seed", actionWallSeed, LOBBS_HOOK_PRIORITY_FEATURE);
#endif
}
#endif
+6
View File
@@ -0,0 +1,6 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
void lobbsWallRegisterCommands();
#endif
+208
View File
@@ -0,0 +1,208 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "WallDal.h"
#include "../AppUtil.h"
#include "WallRecords.h"
#include <cctype>
#include <cstdio>
#include <cstring>
#include "LoBBSStackGuard.h"
WallDal::WallDal(LoDb &lodb) : lodb_(lodb)
{
lodb_.registerTable("wall_canvas");
lodb_.registerTable("wall_seen");
lodb_.registerTable("wall_quota");
}
static lodb_uuid_t wallCanvasRecordUuid()
{
return lodb_new_uuid("lobbs_wall_canvas_v1", 0);
}
static lodb_uuid_t wallSeenRecordUuid(uint64_t userUuid)
{
char hex[17];
lodb_uuid_to_hex(userUuid, hex);
return lodb_new_uuid(hex, 0);
}
static void wallSealCells(char *cells)
{
cells[LOBBS_WALL_CELLS] = '\0';
}
uint32_t WallDal::computeCrc32(const uint8_t *data, size_t len)
{
return lobbsCrc32(data, len);
}
void WallDal::loadCanvas(CanvasState &out)
{
lodb_uuid_t id = wallCanvasRecordUuid();
LoScalar rec;
if (lodb_.get("wall_canvas", id, rec) == LODB_OK) {
std::string cells;
if (rec.getString(WallCanvasField::FIELD_CELLS, cells))
strncpy(out.cells, cells.c_str(), LOBBS_WALL_CELLS);
else
memset(out.cells, ' ', LOBBS_WALL_CELLS);
wallSealCells(out.cells);
rec.getUint32(WallCanvasField::FIELD_CRC32, out.crc32);
return;
}
memset(out.cells, ' ', LOBBS_WALL_CELLS);
wallSealCells(out.cells);
out.crc32 = computeCrc32((const uint8_t *)out.cells, LOBBS_WALL_CELLS);
}
LoDbError WallDal::saveCanvas(CanvasState &canvas)
{
wallSealCells(canvas.cells);
canvas.crc32 = computeCrc32((const uint8_t *)canvas.cells, LOBBS_WALL_CELLS);
lodb_uuid_t id = wallCanvasRecordUuid();
LoScalar rec;
rec.setString(WallCanvasField::FIELD_CELLS, canvas.cells);
rec.setUint32(WallCanvasField::FIELD_CRC32, canvas.crc32);
return lodb_.upsert("wall_canvas", id, rec);
}
bool WallDal::formatGridLines(char *out, size_t outCap)
{
CanvasState canvas = {};
loadCanvas(canvas);
size_t n = 0;
for (int row = 0; row < LOBBS_WALL_ROWS; row++) {
if (row > 0 && n + 1 < outCap)
out[n++] = '\n';
for (int col = 0; col < LOBBS_WALL_COLS; col++) {
if (n + 1 >= outCap)
return false;
out[n++] = canvas.cells[row * LOBBS_WALL_COLS + col];
}
}
if (n >= outCap)
return false;
out[n] = '\0';
return true;
}
uint32_t WallDal::canvasCrc32()
{
CanvasState canvas = {};
loadCanvas(canvas);
return canvas.crc32;
}
uint32_t WallDal::getLastSeenCrc(uint64_t userUuid)
{
LoScalar seen;
if (lodb_.get("wall_seen", wallSeenRecordUuid(userUuid), seen) != LODB_OK)
return 0xffffffff;
uint32_t crc = 0;
seen.getUint32(WallSeenField::FIELD_CRC32, crc);
return crc;
}
bool WallDal::markSeen(uint64_t userUuid, uint32_t crc32Val)
{
LoScalar seen;
seen.setUint64(WallSeenField::FIELD_USER_UUID, userUuid);
seen.setUint32(WallSeenField::FIELD_CRC32, crc32Val);
lodb_uuid_t id = wallSeenRecordUuid(userUuid);
return lodb_.upsert("wall_seen", id, seen) == LODB_OK;
}
bool WallDal::isDirtyForUser(uint64_t userUuid)
{
uint32_t cur = canvasCrc32();
uint32_t last = getLastSeenCrc(userUuid);
if (last == 0xffffffff)
return true;
return cur != last;
}
static bool parseCoord(const char *tok, int &rowOut, int &colOut, const char **endOut)
{
if (!tok || !tok[0])
return false;
char r = (char)tolower((unsigned char)tok[0]);
if (r < 'a' || r > 'l')
return false;
rowOut = r - 'a';
const char *p = tok + 1;
if (!isdigit((unsigned char)*p))
return false;
int col = 0;
while (isdigit((unsigned char)*p)) {
col = col * 10 + (*p - '0');
p++;
}
if (col < 1 || col > LOBBS_WALL_COLS)
return false;
colOut = col;
if (endOut)
*endOut = p;
return true;
}
static bool parseWallToken(const char *tok, int &rowOut, int &colOut, char &chOut)
{
if (!tok || !tok[0])
return false;
const char *coord = tok;
if (tok[0] == '-') {
coord = tok + 1;
if (!coord[0])
return false;
const char *end = nullptr;
if (!parseCoord(coord, rowOut, colOut, &end) || !end || *end != '\0')
return false;
chOut = ' ';
return true;
}
const char *end = nullptr;
if (!parseCoord(coord, rowOut, colOut, &end) || !end || end[1] != '\0')
return false;
chOut = *end;
if ((unsigned char)chOut < 0x20 || (unsigned char)chOut > 0x7e)
return false;
return true;
}
const char *WallDal::applyPaintTokens(uint64_t userUuid, bool isSysop, const char *const *tokens, int count,
uint32_t periodSeconds, uint32_t maxCellsPerCycle)
{
if (count <= 0)
return "No paint tokens.";
CanvasState canvas = {};
loadCanvas(canvas);
for (int i = 0; i < count; i++) {
int row = 0;
int col = 0;
char ch = 0;
if (!parseWallToken(tokens[i], row, col, ch))
return "Bad token.";
canvas.cells[row * LOBBS_WALL_COLS + (col - 1)] = ch;
}
wallSealCells(canvas.cells);
uint32_t cells = (uint32_t)count;
if (!isSysop) {
if (cells > maxCellsPerCycle)
return "Too many cells.";
uint32_t used = 0;
lobbsQuotaUsed(lodb_, "wall_quota", userUuid, periodSeconds, &used, 1);
if (used + cells > maxCellsPerCycle)
return "Quota full.";
}
LoDbError err = saveCanvas(canvas);
if (err != LODB_OK)
return lobbsDbErrorText(err, "Save failed.");
if (!isSysop && (err = lobbsQuotaAdd(lodb_, "wall_quota", userUuid, periodSeconds, &cells, 1)) != LODB_OK)
return lobbsDbErrorText(err, "Quota save.");
return nullptr;
}
#endif
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include <lodb/LoDB.h>
#include <stddef.h>
#include <stdint.h>
static constexpr int LOBBS_WALL_ROWS = 12;
static constexpr int LOBBS_WALL_COLS = 12;
static constexpr int LOBBS_WALL_CELLS = LOBBS_WALL_ROWS * LOBBS_WALL_COLS;
static constexpr uint32_t LOBBS_WALL_DEFAULT_PERIOD_SEC = 3600;
static constexpr uint32_t LOBBS_WALL_DEFAULT_MAX_CELLS = 1;
class WallDal
{
public:
explicit WallDal(LoDb &lodb);
bool formatGridLines(char *out, size_t outCap);
uint32_t canvasCrc32();
bool markSeen(uint64_t userUuid, uint32_t crc32);
bool isDirtyForUser(uint64_t userUuid);
/** Returns nullptr on success, else a user-facing error. */
const char *applyPaintTokens(uint64_t userUuid, bool isSysop, const char *const *tokens, int count, uint32_t periodSeconds,
uint32_t maxCellsPerCycle);
private:
struct CanvasState {
char cells[LOBBS_WALL_CELLS + 1];
uint32_t crc32;
};
void loadCanvas(CanvasState &out);
LoDbError saveCanvas(CanvasState &canvas);
uint32_t computeCrc32(const uint8_t *data, size_t len);
uint32_t getLastSeenCrc(uint64_t userUuid);
LoDb &lodb_;
};
#endif
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include <cstdint>
/** `wall_canvas`. */
namespace WallCanvasField
{
inline constexpr uint32_t FIELD_CELLS = 0;
inline constexpr uint32_t FIELD_CRC32 = 1;
} // namespace WallCanvasField
/** `wall_seen`. */
namespace WallSeenField
{
inline constexpr uint32_t FIELD_USER_UUID = 0;
inline constexpr uint32_t FIELD_CRC32 = 1;
} // namespace WallSeenField
#endif
+24
View File
@@ -0,0 +1,24 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "../../LoBBSConfig.h"
#if LOBBS_SEED
#include "../../LoBBSModule.h"
#include "../AppUtil.h"
#include "WallDal.h"
#include "WallSeed.h"
#include "LoBBSStackGuard.h"
void lobbsSeedWall(LoBBSModule &mod)
{
WallDal &wall = mod.wall().dal();
uint64_t sysop = lobbsAppUuidForUsername(&mod, "sysop");
if (!sysop)
return;
const char *tokens[] = {"a1#", "a2#", "b2#", "c3x", "d4y"};
wall.applyPaintTokens(sysop, true, tokens, 5, LOBBS_WALL_DEFAULT_PERIOD_SEC, LOBBS_WALL_DEFAULT_MAX_CELLS);
}
#endif
#endif
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS && LOBBS_SEED
class LoBBSModule;
void lobbsSeedWall(LoBBSModule &mod);
#endif
+13
View File
@@ -0,0 +1,13 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "Yarn.h"
#include "../../LoBBSBootTrace.h"
#include "LoBBSStackGuard.h"
YarnApp::YarnApp(LoDb &lodb) : dal_(lodb)
{
LOBBS_BOOT_STEP("apps init done (yarn last)");
}
#endif
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "YarnDal.h"
#include <lodb/LoDB.h>
class YarnApp
{
public:
explicit YarnApp(LoDb &lodb);
YarnDal &dal() { return dal_; }
private:
YarnDal dal_;
};
#endif
+133
View File
@@ -0,0 +1,133 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "YarnCommands.h"
#include "../../LoBBSCommandRegistry.h"
#include "../../LoBBSHooks.h"
#include "../../LoBBSModule.h"
#include "../../LoBBSReply.h"
#include "../../LoBBSResponse.h"
#include "../Config/ConfigCommon.h"
#include "YarnDal.h"
#include <cstdio>
#include <cstring>
#include <lodb/LoDB.h>
#include <strings.h>
#include "LoBBSStackGuard.h"
static void replyYarnView(LoBBSCommandCtx &ctx, bool markSeen)
{
char buf[LOBBS_REPLY_BYTES + 1];
if (!ctx.mod->yarn().dal().formatYarnView(buf, sizeof(buf))) {
lobbsCommandReplyError(ctx, "Yarn error.");
return;
}
if (markSeen && lobbsCtxLoggedIn(ctx))
ctx.mod->yarn().dal().markYarnSeen(lobbsCtxUserUuid(ctx));
lobbsCommandReply(ctx, buf);
}
static const LoBBSVerb yarnVerbs[] = {
{"view", nullptr, 0, "view — /yarn shows the tail (no login)"},
{"add", nullptr, LOBBS_V_LOGIN, "add — /yarn word … (login, quota)"},
};
static void handleYarn(LoBBSCommandCtx &ctx)
{
const char *peek = lobbsArgPeek(ctx);
if (!peek) {
replyYarnView(ctx, true);
return;
}
if (lobbsDispatchSub(ctx, yarnVerbs, sizeof(yarnVerbs) / sizeof(yarnVerbs[0])))
return;
if (!lobbsCommandRequireLogin(ctx))
return;
const char *toks[32];
int n = lobbsArgShiftMany(ctx, toks, 32);
if (n <= 0) {
replyYarnView(ctx, true);
return;
}
const uint32_t period = lobbsConfigGet(ctx, "yarn.period");
const uint32_t words = lobbsConfigGet(ctx, "yarn.words");
const uint32_t chars = lobbsConfigGet(ctx, "yarn.chars");
if (const char *err =
ctx.mod->yarn().dal().appendWords(lobbsCtxUserUuid(ctx), ctx.session.isSysop, toks, n, period, words, chars)) {
lobbsCommandReplyError(ctx, err);
return;
}
replyYarnView(ctx, true);
}
static void slashYarn(LoBBSCommandCtx *ctx, const LoScalar &args)
{
if (!ctx || !lobbsSlashVerbIs(args, "yarn"))
return;
handleYarn(*ctx);
}
static void filterYarnHelpTopics(LoBBSCommandCtx *ctx, std::vector<LoScalar> &topics, const LoScalar &args)
{
(void)ctx;
(void)args;
lobbsRecordPush(topics, "yarn", "shared story, a few words at a time");
}
static void filterYarnHelpForTopic(LoBBSCommandCtx *ctx, LoScalar &value, const LoScalar &args)
{
lobbsHelpForTable(ctx, value, args, "yarn", yarnVerbs, sizeof(yarnVerbs) / sizeof(yarnVerbs[0]));
}
static void filterYarnStatusLines(LoBBSCommandCtx *ctx, std::vector<LoScalar> &lines, const LoScalar &args)
{
(void)args;
if (!ctx || !ctx->mod)
return;
char value[32];
if (lobbsCtxLoggedIn(*ctx)) {
uint32_t n = ctx->mod->yarn().dal().newWordsForUser(lobbsCtxUserUuid(*ctx));
snprintf(value, sizeof(value), "%u new words", (unsigned)n);
} else {
uint32_t n = ctx->mod->yarn().dal().totalWordsAppended();
snprintf(value, sizeof(value), "%u words total", (unsigned)n);
}
lobbsRecordPush(lines, "Yarn", value);
}
#if LOBBS_SEED
#include "YarnSeed.h"
static void actionYarnSeed(LoBBSCommandCtx *ctx, const LoScalar &args)
{
(void)args;
if (ctx && ctx->mod)
lobbsSeedYarn(*ctx->mod);
}
#endif
static void filterYarnConfigKeys(LoBBSCommandCtx *ctx, std::vector<LoScalar> &keys, const LoScalar &args)
{
(void)ctx;
(void)args;
lobbsConfigPushKey(keys, "yarn.period", LOBBS_YARN_DEFAULT_PERIOD_SEC, 60, 86400, "Yarn quota period (seconds)");
lobbsConfigPushKey(keys, "yarn.words", LOBBS_YARN_DEFAULT_MAX_WORDS, 1, 1000, "Max words per period");
lobbsConfigPushKey(keys, "yarn.chars", LOBBS_YARN_DEFAULT_MAX_CHARS, 1, LOBBS_YARN_BODY_MAX, "Max chars per period");
}
void lobbsYarnRegisterCommands()
{
lobbsAddFilter("config_keys", filterYarnConfigKeys, LOBBS_HOOK_PRIORITY_FEATURE);
lobbsAddAction("slash_cmd", slashYarn, LOBBS_HOOK_PRIORITY_FEATURE);
lobbsAddFilter("help_topics", filterYarnHelpTopics, LOBBS_HOOK_PRIORITY_FEATURE);
lobbsAddFilter("help_for_topic", filterYarnHelpForTopic, LOBBS_HOOK_PRIORITY_FEATURE);
lobbsAddFilter("status_lines", filterYarnStatusLines, LOBBS_HOOK_PRIORITY_FEATURE);
#if LOBBS_SEED
lobbsAddAction("seed", actionYarnSeed, LOBBS_HOOK_PRIORITY_FEATURE);
#endif
}
#endif
+6
View File
@@ -0,0 +1,6 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
void lobbsYarnRegisterCommands();
#endif
+190
View File
@@ -0,0 +1,190 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "YarnDal.h"
#include "../AppUtil.h"
#include "YarnRecords.h"
#include <cstdio>
#include <cstring>
#include <string>
#include "LoBBSStackGuard.h"
YarnDal::YarnDal(LoDb &lodb) : lodb_(lodb)
{
lodb_.registerTable("yarn_current");
lodb_.registerTable("yarn_seen");
lodb_.registerTable("yarn_quota");
}
static lodb_uuid_t yarnCurrentRecordUuid()
{
return lodb_new_uuid("lobbs_yarn_current_v1", 0);
}
static lodb_uuid_t yarnSeenRecordUuid(uint64_t userUuid)
{
char hex[17];
lodb_uuid_to_hex(userUuid, hex);
return lodb_new_uuid(hex, 0);
}
void YarnDal::loadCurrent(CurrentState &out)
{
out.text[0] = '\0';
out.total_words_appended = 0;
LoScalar rec;
if (lodb_.get("yarn_current", yarnCurrentRecordUuid(), rec) != LODB_OK)
return;
std::string text;
if (rec.getString(LODB_F_DESCRIPTION, text))
strncpy(out.text, text.c_str(), LOBBS_YARN_BODY_MAX);
out.text[LOBBS_YARN_BODY_MAX] = '\0';
rec.getUint32(YarnCurrentField::FIELD_TOTAL_WORDS, out.total_words_appended);
}
LoDbError YarnDal::saveCurrent(CurrentState &cur)
{
lodb_uuid_t id = yarnCurrentRecordUuid();
LoScalar rec;
rec.setString(LODB_F_DESCRIPTION, cur.text);
rec.setUint32(YarnCurrentField::FIELD_TOTAL_WORDS, cur.total_words_appended);
return lodb_.upsert("yarn_current", id, rec);
}
void YarnDal::trimTailToMax(char *text)
{
if (!text)
return;
while (strlen(text) > LOBBS_YARN_BODY_MAX) {
char *sp = strchr(text, ' ');
if (!sp) {
text[LOBBS_YARN_BODY_MAX] = '\0';
return;
}
memmove(text, sp + 1, strlen(sp + 1) + 1);
}
}
bool YarnDal::isValidWordToken(const char *word)
{
if (!word || !word[0])
return false;
for (const char *p = word; *p; p++) {
unsigned char c = (unsigned char)*p;
if (c < 0x21 || c > 0x7e)
return false;
}
return true;
}
bool YarnDal::formatYarnView(char *out, size_t outCap)
{
if (!out || outCap == 0)
return false;
CurrentState cur = {};
loadCurrent(cur);
if (!cur.text[0]) {
snprintf(out, outCap, "Yarn empty.");
return true;
}
snprintf(out, outCap, "%s", cur.text);
return true;
}
uint32_t YarnDal::totalWordsAppended()
{
CurrentState cur = {};
loadCurrent(cur);
return cur.total_words_appended;
}
uint32_t YarnDal::newWordsForUser(uint64_t userUuid)
{
CurrentState cur = {};
loadCurrent(cur);
LoScalar seen;
if (lodb_.get("yarn_seen", yarnSeenRecordUuid(userUuid), seen) != LODB_OK)
return cur.total_words_appended;
uint32_t lastSeen = 0;
seen.getUint32(YarnSeenField::FIELD_LAST_TOTAL_WORDS, lastSeen);
if (cur.total_words_appended <= lastSeen)
return 0;
return cur.total_words_appended - lastSeen;
}
bool YarnDal::markYarnSeen(uint64_t userUuid)
{
CurrentState cur = {};
loadCurrent(cur);
LoScalar seen;
seen.setUint64(YarnSeenField::FIELD_USER_UUID, userUuid);
seen.setUint32(YarnSeenField::FIELD_LAST_TOTAL_WORDS, cur.total_words_appended);
lodb_uuid_t id = yarnSeenRecordUuid(userUuid);
return lodb_.upsert("yarn_seen", id, seen) == LODB_OK;
}
const char *YarnDal::appendWords(uint64_t userUuid, bool isSysop, const char *const *words, int wordCount, uint32_t periodSeconds,
uint32_t maxWords, uint32_t maxChars)
{
if (!words || wordCount <= 0)
return "No words.";
for (int i = 0; i < wordCount; i++) {
if (!isValidWordToken(words[i]))
return "Bad word.";
if (strlen(words[i]) > LOBBS_YARN_BODY_MAX)
return "Word too long.";
}
CurrentState cur = {};
loadCurrent(cur);
uint32_t charCost = 0;
bool needSpace = cur.text[0] != '\0';
for (int i = 0; i < wordCount; i++) {
if (needSpace)
charCost += 1;
charCost += (uint32_t)strlen(words[i]);
needSpace = true;
}
uint32_t add[2] = {(uint32_t)wordCount, charCost};
if (!isSysop) {
if (add[0] > maxWords || add[1] > maxChars)
return "Too many words.";
uint32_t used[2];
lobbsQuotaUsed(lodb_, "yarn_quota", userUuid, periodSeconds, used, 2);
if (used[0] + add[0] > maxWords)
return "Quota full.";
if (used[1] + add[1] > maxChars)
return "Too many chars.";
}
static constexpr size_t kCandidateCap = LOBBS_YARN_BODY_MAX + 64;
std::string candidate(cur.text);
for (int i = 0; i < wordCount; i++) {
const char *w = words[i];
size_t wlen = strlen(w);
if (!candidate.empty()) {
if (candidate.size() + 1 >= kCandidateCap)
break;
candidate += ' ';
}
if (candidate.size() + wlen >= kCandidateCap)
break;
candidate += w;
}
char *text = &candidate[0];
trimTailToMax(text);
if (strlen(text) > LOBBS_YARN_BODY_MAX)
return "Too many chars.";
strncpy(cur.text, text, LOBBS_YARN_BODY_MAX);
cur.text[LOBBS_YARN_BODY_MAX] = '\0';
cur.total_words_appended += (uint32_t)wordCount;
LoDbError err = saveCurrent(cur);
if (err != LODB_OK)
return lobbsDbErrorText(err, "Save failed.");
if (!isSysop && (err = lobbsQuotaAdd(lodb_, "yarn_quota", userUuid, periodSeconds, add, 2)) != LODB_OK)
return lobbsDbErrorText(err, "Quota save.");
return nullptr;
}
#endif
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include <lodb/LoDB.h>
#include <stddef.h>
#include <stdint.h>
static constexpr size_t LOBBS_YARN_BODY_MAX = 186;
static constexpr uint32_t LOBBS_YARN_DEFAULT_PERIOD_SEC = 3600;
static constexpr uint32_t LOBBS_YARN_DEFAULT_MAX_WORDS = 1;
static constexpr uint32_t LOBBS_YARN_DEFAULT_MAX_CHARS = 32;
class YarnDal
{
public:
explicit YarnDal(LoDb &lodb);
bool formatYarnView(char *out, size_t outCap);
uint32_t newWordsForUser(uint64_t userUuid);
bool markYarnSeen(uint64_t userUuid);
/** Returns nullptr on success, else a user-facing error. */
const char *appendWords(uint64_t userUuid, bool isSysop, const char *const *words, int wordCount, uint32_t periodSeconds,
uint32_t maxWords, uint32_t maxChars);
uint32_t totalWordsAppended();
private:
struct CurrentState {
char text[LOBBS_YARN_BODY_MAX + 1];
uint32_t total_words_appended;
};
void loadCurrent(CurrentState &out);
LoDbError saveCurrent(CurrentState &cur);
static void trimTailToMax(char *text);
static bool isValidWordToken(const char *word);
LoDb &lodb_;
};
#endif
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS
#include <cstdint>
/** `yarn_current` (collaborative text is LODB_F_DESCRIPTION). */
namespace YarnCurrentField
{
inline constexpr uint32_t FIELD_TOTAL_WORDS = 0;
} // namespace YarnCurrentField
/** `yarn_seen`. */
namespace YarnSeenField
{
inline constexpr uint32_t FIELD_USER_UUID = 0;
inline constexpr uint32_t FIELD_LAST_TOTAL_WORDS = 1;
} // namespace YarnSeenField
#endif
+28
View File
@@ -0,0 +1,28 @@
#if !MESHTASTIC_EXCLUDE_LOBBS
#include "../../LoBBSConfig.h"
#if LOBBS_SEED
#include "../../LoBBSModule.h"
#include "../AppUtil.h"
#include "YarnDal.h"
#include "YarnSeed.h"
#include "LoBBSStackGuard.h"
void lobbsSeedYarn(LoBBSModule &mod)
{
YarnDal &yarn = mod.yarn().dal();
uint64_t sysop = lobbsAppUuidForUsername(&mod, "sysop");
if (!sysop)
return;
const char *words1[] = {"Demo", "yarn", "seed", "line", "one."};
const char *words2[] = {"Another", "short", "contribution."};
yarn.appendWords(sysop, true, words1, 5, LOBBS_YARN_DEFAULT_PERIOD_SEC, LOBBS_YARN_DEFAULT_MAX_WORDS,
LOBBS_YARN_DEFAULT_MAX_CHARS);
yarn.appendWords(sysop, true, words2, 3, LOBBS_YARN_DEFAULT_PERIOD_SEC, LOBBS_YARN_DEFAULT_MAX_WORDS,
LOBBS_YARN_DEFAULT_MAX_CHARS);
}
#endif
#endif
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#if !MESHTASTIC_EXCLUDE_LOBBS && LOBBS_SEED
class LoBBSModule;
void lobbsSeedYarn(LoBBSModule &mod);
#endif
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Turn a local file into LoBBS /upload and /commit commands (base62 chunks)."""
from __future__ import annotations
import argparse
import os
import sys
import zlib
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
BYTE_TO_WIDTH = {1: 2, 2: 3, 3: 5, 4: 6, 5: 7, 6: 9, 7: 10, 8: 11}
def b62_encode_fixed(val: int, width: int) -> str:
chars: list[str] = []
for _ in range(width):
val, rem = divmod(val, 62)
chars.append(ALPHABET[rem])
if val != 0:
raise ValueError("value too large for width")
return "".join(reversed(chars))
def encode_chunk(data: bytes) -> str:
k = len(data)
if k < 1 or k > 8:
raise ValueError("chunk length must be 1..8")
width = BYTE_TO_WIDTH[k]
val = int.from_bytes(data, "big")
return b62_encode_fixed(val, width)
def consume_payload(data: bytes, payload: str) -> int:
"""Return how many bytes from data the payload encodes."""
pos = 0
p = 0
while p < len(payload):
matched = False
for k in (8, 7, 6, 5, 4, 3, 2, 1):
if pos + k > len(data):
continue
w = BYTE_TO_WIDTH[k]
if p + w > len(payload):
continue
seg = payload[p : p + w]
if encode_chunk(data[pos : pos + k]) != seg:
continue
pos += k
p += w
matched = True
break
if not matched:
raise ValueError(f"payload does not match data at byte {pos}")
return pos
def pack_one_line(data: bytes, max_payload_chars: int) -> tuple[str, int]:
payload = ""
pos = 0
while pos < len(data):
take = min(8, len(data) - pos)
piece = encode_chunk(data[pos : pos + take])
if payload and len(payload) + len(piece) > max_payload_chars:
break
if not payload and len(piece) > max_payload_chars:
raise ValueError("single chunk exceeds line budget")
payload += piece
pos += take
if not payload:
raise ValueError("empty line")
return payload, pos
def main() -> int:
parser = argparse.ArgumentParser(
description="LoBBS chunked upload command generator"
)
parser.add_argument("local_path", help="Local file to upload")
parser.add_argument(
"dest_path", help="Destination on the node (e.g. /flash/files/foo.txt)"
)
parser.add_argument(
"--max-line", type=int, default=200, help="Max characters per output line"
)
parser.add_argument(
"--tmp-dir", default="/flash/tmp", help="Temp directory on the node"
)
args = parser.parse_args()
with open(args.local_path, "rb") as f:
blob = f.read()
crc = zlib.crc32(blob) & 0xFFFFFFFF
crc_hex = f"{crc:08x}"
base = os.path.basename(args.local_path)
tmp_dir = args.tmp_dir.rstrip("/")
tmp_path = f"{tmp_dir}/{base}.{crc_hex}.tmp"
print(f"/mkdir {tmp_dir}")
offset = 0
while offset < len(blob):
prefix = f"/upload {tmp_path} {offset}:"
budget = args.max_line - len(prefix)
if budget < 2:
print("line budget too small for one chunk", file=sys.stderr)
return 1
payload, nbytes = pack_one_line(blob[offset:], budget)
consume_payload(blob[offset:], payload)
print(f"{prefix}{payload}")
offset += nbytes
print(f"/commit {tmp_path} {args.dest_path}")
return 0
if __name__ == "__main__":
sys.exit(main())
+86
View File
@@ -0,0 +1,86 @@
# LoBBS Daily User Guide
[https://discord.gg/DMrGcGQfMN](https://discord.gg/DMrGcGQfMN)
If you found LoBBS somewhere on your mesh and are a daily user, this guide is for you.
DM the node a line that starts with `/`.
`/login username password` signs in or creates an account after the node is installed. If the node says it needs install, find the SysOp. See the [SysOp guide](lobbs-sysop.md).
Long answers end with `{p 1/3}`. Send `/p2`, then `/p3`. Paging works on your last answer for about 5 minutes. A new command replaces it.
## Public
| Command | Notes |
| --------------- | ---------------------------------------- |
| `/hi` | Welcome screen and hints |
| `/status` | Overall status (paged) |
| `/time` | Current time |
| `/help [topic]` | Command catalog, or topic help |
| `/pN` | Next page of the last reply (e.g. `/p2`) |
| `/wall` | Play the Wall game (shared ASCII art) |
| `/yarn` | Play the Yarn game (shared story) |
## Session
| Command | Notes |
| ---------------------- | ---------------------------- |
| `/login user password` | Sign in or create an account |
| `/logout` | End session |
| `/whoami` | Who you are |
| `/passwd new confirm` | Change your password |
A login ends when the node reboots or after a day without commands (the SysOp can change both limits). When every login slot is taken, a new login signs out whoever has been idle longest. Run `/login` again if `/whoami` says you are not logged in.
## Users
Login required.
| Command | Notes |
| ------------------ | ---------------- |
| `/users list` | User list |
| `/users find text` | Search usernames |
## Mail
Login required. SysOps reading someone else's inbox: [SysOp guide](lobbs-sysop.md).
| Command | Notes |
| -------------------------- | ---------------------- |
| `/mail list` | Your inbox |
| `/mail read N` | Read message N |
| `/mail N` | Same as `/mail read N` |
| `/mail unread N` | Mark unread |
| `/mail delete N` | Delete from your inbox |
| `/mail send user message…` | Send mail |
## News
Login required.
| Command | Notes |
| --------------------- | ---------------------- |
| `/news list` | News index |
| `/news read N` | Read item N |
| `/news N` | Same as `/news read N` |
| `/news unread N` | Mark unread |
| `/news post message…` | Post news |
## Wall
`/wall` shows the grid (no login). Painting needs login.
Rows `a`–`l`, columns `1`–`12`. `a4x` paints `x` at row a, column 4. `-a4` clears that cell. Several tokens in one command: `/wall a1# a2# b2#`.
| Command | Notes |
| -------------- | --------------------------------------- |
| `/wall token…` | Paint. Default quota is 1 cell per hour |
## Yarn
`/yarn` shows the tail (no login). Adding words needs login.
| Command | Notes |
| -------------- | ----------------------------------------------------------- |
| `/yarn word …` | Add words. Default quota is 1 word (32 characters) per hour |
+179
View File
@@ -0,0 +1,179 @@
# LoBBS Internals
[https://discord.gg/DMrGcGQfMN](https://discord.gg/DMrGcGQfMN)
Player commands are in the [daily user guide](lobbs-daily.md). The machine wire format is in the [Machine API](lobbs-machine.md). Why plugins work the way they do, how to define your own hooks, and the new-app checklist are in the [plugin author's guide](lobbs-plugins.md).
## Request flow
1. A DM line starting with `/` may include an optional request id (`/42 mail list`).
2. Page-only verbs (`/p2`, `/p 2`, `/43 p2`) load the cached `LoBBSResponse` for that sender and render a page.
3. Otherwise `slash_cmd` runs. Each plugin compares the verb and returns quietly when it is not the owner.
4. Handlers build a `LoBBSResponse` and call `lobbsCommandReplyResponse`. Successful replies are cached, then page 1 is rendered.
5. Without a request id, plain text uses `{p n/t}` footers. With an id, machine replies use `<id>ok [n:max]`.
There is no central unknown-command reply. If no module owns the verb, the node stays silent.
## One verb, rest untouched
After peeling `/[id]`, the registry takes one verb token and leaves the rest on `ctx.rest`.
Example: `/mail send ben this is my long message` keeps `this is my long message` intact. Mail peels `send`, then `ben`, then treats the rest as the body. Nothing re-tokenizes the whole line up front.
Handlers use `lobbsArgShift`, `lobbsArgPeek`, `lobbsArgRest`, `lobbsArgShiftUint`, `lobbsArgPeekIsUint`, and `lobbsArgShiftMany` on `ctx.rest`. Do not use partial `atoi` on numeric tokens.
Subcommands use one `LoBBSVerb` table for dispatch (`lobbsDispatchSub`) and help (`lobbsHelpForTable`). Rows with `fn == nullptr` are help-only. `LOBBS_V_LOGIN` and `LOBBS_V_SYSOP` gate dispatch and hide sysop rows from help when the caller is not a sysop.
## Paging and reply cache
Every successful command answers with page 1 and stores the full result set in RAM for that sender's node id.
| Constant | Value | Role |
| ------------------------------- | ----- | ------------------------------------------- |
| `LOBBS_REPLY_CACHE_TTL_SEC` | 300 | About five minutes, reset on each page read |
| `LOBBS_REPLY_CACHE_MAX_BYTES` | 8192 | Larger payloads are not cached |
| `LOBBS_REPLY_CACHE_MAX_ENTRIES` | 32 | Oldest entry dropped when full |
A new successful command replaces the cache. Errors do not change it. Over-limit payloads send page 1 and clear the cache, so `/p2` replies `No cached reply.`. Past the end: `No such page.`
Follow with `/p2` or `/43 p2` (request id optional). Plain-text serialization runs `display_human` on each record before paging.
## Help and `/hi`
`/help` with no remainder builds a topic list: built-in rows (`help`, `hi`, `pN`) plus rows from `help_topics`. Logged-in users see feature topics. Sysop-only topics follow each plugin's rules.
`/help mail` or `/help mail send` runs `help_for_topic`. The query is in `args` as `LODB_F_TITLE`. Plugins set `LODB_F_DESCRIPTION` on the value record when they recognize the query. If nothing matches, the reply is `No help found for …`.
`/hi` is a welcome screen, not the command catalog. `/status` uses `status_lines`.
## Hook bus
Source: `LoBBSHooks.h`. Modeled on WordPress actions and filters. The bus is shaped so a handler could later come from a script instead of compiled C++. Why it works this way, and how to define hooks of your own, is in the [plugin author's guide](lobbs-plugins.md).
Rules:
- Hooks are named by string, matched case-insensitively. The bus stores the name pointer, so pass a string literal. Any number of handlers, added at runtime, no cap. `lobbsHooksReset()` clears all.
- There is no list of allowed names. Firing a name nobody registered does nothing. Registering a name nobody fires is never called.
- Every hook receives `args` as a `LoScalar`. Data crosses the bus as LoScalar fields, not C++ types.
- No hook claims or short-circuits. Every handler runs.
- Priority is fixed at registration (lower first, ties keep registration order). Bands: `HELP` 0, `AUTH` 10, `FEATURE` 20, `STATUS` 30, `TIME` 31. Install registers at `AUTH - 1`.
- A handler registered with the wrong kind for a name is logged and skipped.
- Handlers talk only through `ctx`, `args`, and the value. No handler calls another plugin.
Three kinds:
- Action: `(ctx, args)`, fire and forget.
- Record filter: `(ctx, LoScalar &value, args)`. Caller seeds `value`, each handler edits it or leaves it.
- List filter: `(ctx, std::vector<LoScalar> &value, args)`. Same, over a list.
| Hook | Kind | Initial value | args | Role |
| ----------------- | ------ | ---------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------- |
| `slash_cmd` | action | n/a | verb (`LOBBS_ARG_VERB`), rest (`LOBBS_ARG_REST`) | Compare with `lobbsSlashVerbIs`, parse `ctx->rest` |
| `seed` | action | n/a | empty | Demo and test builds (`LOBBS_SEED`). Each app seeds its tables |
| `help_topics` | list | built-in topics | empty | Append `{title, description}` rows |
| `help_for_topic` | record | title = topic | title = query | Set description when the topic matches |
| `status_lines` | list | empty | empty | Append `{title, description}` (`Users` / `12 total` renders as `Users (12 total)`) |
| `display_human` | record | generic title line | source record | Rewrite title for records you recognize |
| `config_keys` | list | empty | empty | Append key definition rows (`default`, `min`, `max` in fields 0–2) |
| `config_validate` | record | title = key, 0 = value | key definition record | Set `LODB_F_ERROR` to reject; empty means ok |
| `config_changed` | action | n/a | title = key, field 0 = effective value | Apply new settings after set, reset, or database open |
| `install_mounts` | list | empty | empty | Append `{title = mount name}` rows a database may be installed on |
These are the hooks core and the bundled apps fire. Apps add their own names the same way.
Something happened: action. Contribute to one shared value: record filter. Contribute to a shared list: list filter. A new need gets a new hook name, not a new kind.
## Result sets and rendering
Handlers build `LoBBSResponse` (`ok`, `error`, `records`) with `lobbsRecordPush`, `lobbsResponseAppendRecord`, or `lobbsResponseSetError`, then call `lobbsCommandReplyResponse`. `lobbsCommandReply` and `lobbsCommandReplyError` wrap it. Handlers never page.
Human: generic `title (description)` per record, then `display_human`, packed into 200 bytes with `{p n/t}`.
Machine: records encoded as LoScalar lines, sliced into pages with `<id>ok [n:max]`. See the Machine API guide.
`lobbsMsgRegisterDisplay()` (wireup) registers one `display_human` handler for mail and news list and read rows. Mail and news command modules do not register their own `display_human` filters.
## LoFS and install
LoFS exposes `/` as a virtual root listing mounts. Writable paths start with `/<mount>/…` (optional `sd`, optional `extra` when built with `LOBBS_EXTRA_QSPI=1`, `flash2` on nRF52840, `flash`). Mount roots cannot be removed, renamed, or used as copy sources. Cross-mount `mv` copies files then deletes the source. Directories require same-mount rename.
`/flash2` is a second nRF52840 internal-flash partition, 100 KiB at `0xD4000`–`0xED000` between the app and InternalFS. The linker scripts `src/platform/nrf52/nrf52840_s140_v6.ld` (set on `nrf52840_base`) and `nrf52840_s140_v7.ld` end `FLASH` at `0xD4000` and provide `__flash2_start` / `__flash2_end`. LoFS reads them as weak symbols, so nRF builds on other linker scripts have no `/flash2`. The partition is a second `Adafruit_LittleFS` with 128 B blocks over `flash_nrf5x`. It is formatted when mount fails or by SysOp `/format flash2` (`LoFS::format`, which erases the pages first). Its block IO takes InternalFS's lock because the `flash_nrf5x` page cache is shared and the BLE task writes bonds to InternalFS.
`/extra` is optional onboard QSPI flash (2 MiB, 4096 B sectors, 256 B read/prog, 512-block lookahead). LoFS uses `LoFSQspi` (`nrfx_qspi`, FASTREAD/PP at SCK/8, no quad-enable). Boot sends release-from-deep-power-down (`0xAB`) and software reset (`0x66`/`0x99`), then reads the JEDEC ID and expects Puya `0x85`. A wrong or missing ID skips the mount and never formats. A mount failure on a good ID formats, as does `/format extra`. `/format flash` runs `FSCom.format()` then `nodeDB->saveToDisk()`, the same recovery Meshtastic uses after a failed save. Formatting the install mount reruns `lobbsInstallInit`, so the node goes Blank unless another mount holds an install. Every custom instruction holds IO2/IO3 high: they are WP#/HOLD# while the quad-enable bit is clear, so LoFSQspi polls the status register (`0x05`) itself instead of `nrfx_qspi_mem_busy_check`, which sends it with HOLD# low.
`LoFS::list` callbacks get `(ctx, basename, isDirectory, size)`. `size` is the file length, 0 for directories and mounts.
Each mount has a `dbSafe` flag (`LoFS::mountDbSafe`). `sd`, `extra`, and `flash2` are db-safe. `flash` is db-safe only when there is no `flash2` mount, because a failed Meshtastic save on nRF52 formats InternalFS. Install points come from the `install_mounts` list filter. Core registers a provider that lists present db-safe mounts in mount-table order, which is preference order (`sd`, `extra`, `flash2`, `flash`). `/install` accepts only listed mounts, the install hint and usage show them, and demo seeding uses the first one.
Shared mounts (`/flash`) keep `LOFS_SHARED_RESERVE_BYTES` free (16 KiB nRF52, 128 KiB other hardware, 0 Portduino; override with `-D`). `LoFS::hasRoom(path, bytes)` rounds `bytes` up to the block size, adds slack for metadata, adds the mount reserve, and compares with free space. It returns true when the mount reports no size. LoDB checks it before every record write and returns `LODB_ERR_FULL`. FsCommands checks it for `/mkdir`, `/cp`, `/upload`, and cross-mount `/mv`. Apps map `LODB_ERR_FULL` to `Disk full.` via `lobbsDbErrorText`. Deletes are never blocked.
LoBBS home: everything lives under `/<mount>/lobbs` (`LOBBS_HOME_DIR`). `install.ls` (field `LOBBS_INSTALL_FIELD_VERSION` = `LOBBS_VERSION` at install) marks the install and is written last. `db/` is the LoDB (`LoDb("db")` opened at the home). `apps/<app>/` and `home/<user>/` are reserved conventions. At boot `lobbsInstallInit` walks `install_mounts` in order and takes the first mount holding `lobbs/install.ls`. Later hits are logged and ignored. No marker lives on `/flash`.
| State | Behavior |
| ------- | ----------------------------------------------------------------------- |
| Blank | No `lobbs/install.ls` on any install mount. Only `/help` and `/install` |
| Ready | Marker found and `LoDb::open(home)` succeeded |
| Offline | Marker found but the database failed to open. Non-help commands error |
`/install` authorization matches admin PKI in `AdminModule` (local client or encrypted DM with a configured admin key). Only `/install` creates SysOp accounts.
## Sessions
Logins live in RAM, not LoDB: `AuthDal` holds a vector of `{nodeId, userUuid, lastActiveMs, cwd}` slots, keyed by sender node id. Slot count and idle expiry come from `session.max` and `session.idle` in the config registry (defaults 16 and 86400 s). Each lookup refreshes `lastActiveMs`. A slot idle past the expiry, or whose user row is gone, is freed on lookup. Logging in reuses the node's slot, takes a free or expired one, or evicts the longest idle. Shrinking the limit keeps the most recently active. Sessions end on reboot, and `clearSessions()` runs whenever the database opens. Logins never write flash; overrides live in the `config` table.
## Config registry
SysOp `/config` lists, reads, sets, and resets uint32 settings. Plugins declare keys on `config_keys` via `lobbsConfigPushKey`. `/help config key` replies with the key's help text, default, and range. Validation runs on `config_validate` (core registers a min/max range check at help priority). Successful sets upsert one row in table `config` (title = key); reset deletes the row so defaults cost no flash. `config_changed` fires after each set or reset and for every key when the database opens (`lobbsInstallDatabaseOpened`).
SysOp file commands, chunked upload, and path rules are documented in the [SysOp guide](lobbs-sysop.md).
## LoScalar
One record is one line of numbered fields, `number:value`, joined by `|` in ascending field order, numbers `0..99`. Values escape `\`, `|`, and newline with a backslash. Typed accessors cover strings, `uint32`, `uint64`, bools, and 32-byte keys as hex. `encode` takes a byte limit and fails rather than write past it.
```
95:1790000000|96:1790000000|97:see you at the trailhead|99:9168245037165234184
```
Why this shape:
- One format everywhere. The same line is a row on disk, a record in a `LoBBSResponse`, a hook argument, and a line in a machine reply. Nothing translates between a storage format and a wire format, and a client decodes exactly what the node stored.
- You can read it. `/cat` on a row file, or the SD card in a laptop, shows the fields as text. Debugging a node in the field needs no decoder.
- No schema compiler. A record type is a `*Records.h` header of field numbers. A new field is a new constant, with no generated code and no build step.
- Numbers instead of names. A field costs two digits and a colon, not a key name. That matters when the whole reply is 200 bytes.
- Decoding keeps every field, including numbers the reader does not know. Two apps, or a firmware and a client a release apart, can share a record without agreeing on all of it.
- Flat on purpose. There is no nesting and no quoting. A news body full of quotes needs no JSON parser. A structured value, like the wall grid, is one string field the owning app parses.
- Small. The codec is one class with no dependencies, which fits nRF52 flash and the 512-byte stack cap.
On nRF52, do not format `uint64_t` with `%llu`. Use `loU64ToDec` from `loutil/LoUtil.h`. `loHumanBytes` prints short sizes (`9B`, `1.2K`, `1.8M`).
## LoDB
`LoDb::open(root)` stores tables at `<root>/<db>/<table>/` (LoBBS: `/<mount>/lobbs/db/<table>/`), one `<16 hex uuid>.ls` file per row. Writes go to `<path>.w` then rename. API: `registerTable`, `insert`, `get`, `update`, `upsert`, `deleteRecord`, `select`, `count`. Row cap `LODB_MAX_RECORD_BYTES` (1024). Uuid from `lodb_new_uuid`. Prefer `upsert` for keyed or singleton rows. Runtime quotas and session limits use the config registry, not per-app singleton tables.
### Field numbers
App fields use `0..93` (`LODB_F_USER_LIMIT` is 94). Each app's `*Records.h` is the schema. System fields count down from 99.
| Number | Name | Who writes it |
| ------ | -------------------- | -------------------------------- |
| 99 | `LODB_F_ID` | LoDB, the row uuid |
| 98 | `LODB_F_TITLE` | The app. Human renderer line |
| 97 | `LODB_F_DESCRIPTION` | The app. Mail, news, yarn bodies |
| 96 | `LODB_F_CREATED` | LoDB unless set. Unix seconds |
| 94 | `LODB_F_ERROR` | Any filter. User-facing error |
| 95 | `LODB_F_UPDATED` | LoDB unless set. Unix seconds |
Strip 95 and 96 before `update` to get default stamps. Times are Unix seconds, not milliseconds.
## Stack budget
`LoBBSStackGuard.h`: on nRF52 every LoBBS function is capped at 512 bytes of stack (`-Wstack-usage=512` as an error). Commands run on the 4 KB loop task shared with the router. Include the guard last in every LoBBS `.cpp`. Keep large buffers off the stack.
## Verifying
Build `pio run -e rak4631` and run `trunk fmt` before commit. Native tests are not green on this branch. See `.cursor/rules/lobbs-verification.mdc`.
## Plugin checklist
See [lobbs-plugins.md](lobbs-plugins.md).
+55
View File
@@ -0,0 +1,55 @@
# LoBBS Machine API
[https://discord.gg/DMrGcGQfMN](https://discord.gg/DMrGcGQfMN)
For rich clients and agents over DM. Command names and player behavior are in the [daily user guide](lobbs-daily.md). Field numbers and hook behavior are in [LoBBS internals](lobbs-internals.md).
## Why a machine mode
The plain-text reply is written for a person on a phone. A mail row reads `[2]*@ben: see you at the trailhead (3h ago)`. The index, the `*` unread mark, the truncated preview, the relative time, and the `{p n/t}` footer exist for eyes, and plugins can rewrite them through `display_human`. A client that scrapes that line breaks whenever the wording changes.
A machine reply is the same result set before rendering. That mail row arrives as separate fields: the row id, the read flag, the created time in Unix seconds, and the preview, next to the title line a person would see. A client reads the fields it knows and ignores the rest.
The request id exists because the mesh is not a socket. DMs arrive seconds or minutes late, some never arrive, and they can arrive out of order. A client with several requests in flight matches each reply to its request by the echoed id. A person typing by hand does not need that, so the id is optional, and its presence is what selects machine mode.
There is no second API. Every command a person can type, a client can send with an id, and the same handler answers both. A new command works from a client the day it ships.
Paging uses the same cached reply for both. Each page is its own request with its own id, so a client that loses page 2 asks for page 2 again instead of re-running the command. `[n:max]` tells it how many DMs to collect before it parses.
## Requests
Put a number and a space after `/` on a command to get a machine reply. The number only works when a space follows it (`/42 login`, not `/42login`). Without a leading id, replies use plain text and `{p i/n}` footers on the same cached result set.
Run the command first, then request further pages without re-running it.
```
/42 mail list
<42>ok [1:2]
…payload…
/43 p2
<43>ok [2:2]
…payload…
```
## Reply shape
Each DM is 200 bytes or less, header included.
| Case | Header | Body |
| ----------------------- | ------------------------ | --------------------- |
| Success, one page | `<id>ok` newline | LoScalar record lines |
| Success, multiple pages | `<id>ok [n:max]` newline | Slice of the document |
| Error | `<id>` | One sentence, no `ok` |
The `[n:max]` marker is separate from the request id. Use a fresh id on each page request (`/43 p2`, `/44 p3`). The reply echoes the id from that page request. Stop when `n == max`.
Success bodies are the cached result set encoded as LoScalar lines (`number:value|number:value`), one record per line. Concatenate page bodies in order, then split on newlines. Pages may cut through the middle of a line. Reassemble the full document before parsing records.
## Paging
Human paging: `/p2` after `/mail list`.
Machine paging: `/43 p2` with a new id each page.
The node caches your last successful reply for about five minutes (reset on each page read). A new successful command replaces the cache. Errors do not change it. Payloads larger than 8 KiB are not cached. Page 1 is sent and `/p2` replies `No cached reply.`. Past the end: `No such page.`
+85
View File
@@ -0,0 +1,85 @@
# LoBBS Plugin Author's Guide
[https://discord.gg/DMrGcGQfMN](https://discord.gg/DMrGcGQfMN)
How LoBBS plugins fit together, how to add hooks of your own, and checklists for a new app. Hook signatures, paging, storage, and stack limits are in [LoBBS internals](lobbs-internals.md).
## How plugins fit together
LoBBS core does not know what mail, news, or the wall are. It peels the request id and the verb, fires `slash_cmd`, and renders whatever result set comes back. Every command, including `/login` and `/help`, is a plugin that subscribed to hooks.
- Adding or removing an app is one line in `LoBBSWireup.cpp`. Nothing else changes, because nothing else calls it.
- Every handler runs and none can claim a hook. A new plugin can add to what LoBBS does, but it cannot quietly switch another one off. What the node does is the sum of what is installed, in priority order.
- Plugins never call each other. They meet at named hooks and pass LoScalar records. Remove a plugin and the ones listening to it keep working. Their handlers for its hooks are just never called.
- Data crosses the bus as LoScalar fields, not C++ types. A handler that only reads and writes numbered fields could later be a script instead of compiled code.
## Define your own hooks
The hooks in the internals table are the ones core and the bundled apps fire. They are not a closed set. A hook is only a name, so your app can fire its own and any other plugin can subscribe without including your headers.
Fire an action where something happened that others might react to:
```cpp
LoScalar args;
args.setUint64(LODB_F_ID, mailUuid);
args.setString(LODB_F_TITLE, toUsername);
lobbsDoAction("mail_sent", ctx, args);
```
Apply a filter where others might want to change a value before you use it. You seed the default, and the value you read back is what every handler agreed on:
```cpp
LoScalar post;
post.setString(LODB_F_DESCRIPTION, body);
lobbsApplyFilter("news_before_post", ctx, post, LoScalar());
post.getString(LODB_F_DESCRIPTION, body);
```
Another plugin subscribes by name:
```cpp
static void notifyOnMail(LoBBSCommandCtx *ctx, const LoScalar &args)
{
// args carries the mail id and recipient
}
lobbsAddAction("mail_sent", notifyOnMail, LOBBS_HOOK_PRIORITY_FEATURE);
```
These two hooks are examples. The bundled apps do not fire them.
Rules for a hook you own:
1. Prefix the name with your app (`mail_`, `wall_`). Unprefixed names are the shared hooks in the internals table.
2. Pass a string literal. The bus keeps the pointer.
3. One kind per name. A handler registered with a different kind is logged and skipped.
4. Use title (`98`) and description (`97`) for the obvious text. Give other args fields numbers in `0..93`.
5. Fire with the `ctx` of the command you are handling, so subscribers see the same sender and session.
6. Document it in the same change: a row in the hook table in `LoBBSHooks.h` and in [internals](lobbs-internals.md#hook-bus) with name, kind, initial value, and args.
## New command app
1. In `lobbsXRegisterCommands()`, register `slash_cmd`, `help_topics`, and `help_for_topic`. Add `status_lines`, `display_human`, or `seed` when needed.
2. One `LoBBSVerb` table drives `lobbsDispatchSub` and `lobbsHelpForTable`. Gate with `LOBBS_V_LOGIN` and `LOBBS_V_SYSOP`.
3. In `slash_cmd`, return quietly unless `lobbsSlashVerbIs` matches your verb.
4. Build a `LoBBSResponse` and call `lobbsCommandReplyResponse`. Do not page or parse `pN`.
5. Talk only through `ctx`, `args`, and the filter value. Do not call another plugin. Do not assume you run first or alone.
6. Include `LoBBSStackGuard.h` last in every `.cpp`. Keep functions under 512 bytes of stack on nRF52.
7. Add one `lobbsXRegisterCommands()` call in `LoBBSWireup.cpp`.
8. Do not `#include` `AuthDal.h` from feature code. Use the session on `ctx` and `AppUtil`.
## Runtime settings
1. Register a `config_keys` list filter and call `lobbsConfigPushKey` for each uint32 setting (default, min, max, help text shown by `/help config key`).
2. Read values with `lobbsConfigGet(ctx, "your.key")` in command handlers before calling your DAL.
3. Optional: register `config_validate` at a priority after `LOBBS_HOOK_PRIORITY_HELP` for cross-field checks.
4. Register `config_changed` to apply side effects (resize buffers, refresh caches).
Mail is the reference implementation for subcommands and help tables.
## New record type
1. Pick a table name. App fields use `0..93`. Body in field `97`, short name in `98`.
2. Do not set field `99`. Leave `95` and `96` unset on insert. `removeField` them before `update` when you want LoDB to stamp times.
3. `registerTable` at app construction. Prefer `LoDb::upsert` for keyed or singleton rows.
4. Build uuids with `lodb_new_uuid`. Do not print `uint64_t` with `%llu` on nRF52. Use `loU64ToDec` from `loutil/LoUtil.h`.
+94
View File
@@ -0,0 +1,94 @@
# LoBBS SysOp Guide
[https://discord.gg/DMrGcGQfMN](https://discord.gg/DMrGcGQfMN)
If you run a LoBBS node, you are the System Operator (SysOp) and this guide is for you.
Normal player commands are in the [daily user guide](lobbs-daily.md).
## Install
[Install on MeshForge](https://meshforge.org/MeshEnvy/lobbs-meshtastic-firmware) or compile and flash from source.
A new LoBBS node has no database until you install it. Only `/help` and `/install` work until then. The install hint lists the mounts this hardware can hold a database on:
| Hardware | Install mounts |
| ------------------------ | ---------------------------------------------------- |
| nRF52840 | `sd`, `extra`, `flash2` (100 KB set aside for LoBBS) |
| ESP32 and other hardware | `sd`, `extra`, `flash` |
Mounts are listed in preference order. `sd` appears when a card is present. `extra` (about 2 MB) appears on Seeed XIAO nRF52840 kit and SenseCAP Solar Node builds (`LOBBS_EXTRA_QSPI=1`). On nRF52840 `/flash` is Meshtastic's own 28 KB and is not offered.
`/install <mount> <user> <pass>` creates the database on that mount and makes `<user>` the SysOp. Use it from the local client (phone app connected over serial/BLE) or from a remote DM encrypted with a configured admin key. If the target already holds a LoBBS database, the same command adopts it and you must supply an existing SysOp username and password. Once installed, `/install` replies `Already installed at /<mount>.`
Everything LoBBS stores lives under `/<mount>/lobbs`:
| Path | Contents |
| ----------------------------- | ---------------------------------------------------- |
| `/<mount>/lobbs/install.ls` | Install record. Its presence marks the install |
| `/<mount>/lobbs/db/` | The database (users, mail, news, wall, yarn, config) |
| `/<mount>/lobbs/apps/<app>/` | Reserved for per-app files |
| `/<mount>/lobbs/home/<user>/` | Reserved for user files |
On boot LoBBS checks the install mounts in preference order and uses the first one holding `lobbs/install.ls`. Nothing is stored on `/flash`, so a Meshtastic wipe of `/flash` does not lose the install. Pulling the SD card that holds it makes the node look new until the card is back.
## Demo builds
Add `-D LOBBS_DEMO_MODE` to `build_flags` (or `PLATFORMIO_BUILD_FLAGS="-D LOBBS_DEMO_MODE" pio run -e <env>`) to wipe `/<mount>/lobbs` on the first install mount every boot and seed demo data. Accounts: `sysop` and `demo01` through `demo12`, password `demo1`. Sample mail, news, wall paint, and yarn text are included for paging tests.
## SysOp commands
SysOp-only lines reply `SysOp only.` to everyone else.
Config keys (uint32, defaults in parentheses): `session.max` (16), `session.idle` (86400 s), `wall.period` (3600), `wall.cells` (1), `yarn.period` (3600), `yarn.words` (1), `yarn.chars` (32), `pager.ttl` (300), `password.min` (5). Run `/config` with no args to list current values and ranges. `/help config key` explains one setting.
| Command | Notes |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/install mount user pass` | First-time setup. Blank node only; adopts an existing database with SysOp credentials |
| `/passwd user new confirm` | Reset a user's password |
| `/users kick user` | Log that user out |
| `/config` | List settings. `/config key`, `/config key value`, `/config key reset` (SysOp only) |
| `/users promote user` | Make them a SysOp |
| `/users demote user` | Remove SysOp (not the last SysOp) |
| `/mail list user` | Their inbox |
| `/mail read user N` | Read their message (does not mark it read) |
| `/news delete N` | Delete a news item |
| `/time unix` | Set the clock. The time must be after this firmware was built, or the reply is `Invalid time.` |
| `/cd [path]` | Set your working directory (default `/`). Each login keeps its own, reset on re-login/reboot |
| `/pwd` | Show your working directory |
| `/ls [path]` | List a directory, default the working directory (`*` glob on the last name only). Directories end in `/`, files show a size like `9B` or `1.2K` |
| `/cat path` | Read a file as text |
| `/hex path` | Hex dump a file |
| `/rm /path` | Delete a file (absolute path only) |
| `/rmdir /path` | Remove an empty directory (absolute path only) |
| `/rmtree /path /path` | Recursive delete (absolute path, typed twice) |
| `/mkdir path` | Create a directory under a mount (`/flash/...`, etc.) |
| `/cp src dst` | Copy a file (no overwrite; directories refused) |
| `/mv src dst` | Rename on one mount, or copy then delete across mounts (files only) |
| `/upload path [offset:b62]` | Chunked file write at byte offset, or show current size |
| `/commit src dst` | Move when file CRC32 matches the 8-hex segment in the source name |
| `/stat path` | `file N` or `dir` |
| `/df` | One line per mount, e.g. `extra: 1.8M free of 2.0M (12% used) [lobbs]`. Free space excludes the `/flash` reserve. `[lobbs]` marks the install mount |
| `/format mount [code]` | Erase a mount. The first call replies with a code; repeat with the code within 2 minutes from the same node. On `/flash` the radio settings are saved again from memory (BLE pairings are lost). On the install mount LoBBS goes blank until `/install`. `sd` is refused (format cards on a PC) |
SysOps painting the wall or adding yarn do not use quota.
## Files
Paths use mount prefixes: `/flash/...`, `/flash2/...` on nRF52840, `/sd/...`, `/extra/...` on builds with `LOBBS_EXTRA_QSPI=1` and a QSPI flash chip. `/` lists mounts. Do not `/rmtree` `<mount>/lobbs` on your install mount unless you intend to wipe the BBS.
`/flash` is shared with Meshtastic's own settings and node database. LoBBS leaves a reserve free there (16 KB on nRF52, 128 KB elsewhere). Posts, mail, uploads, and copies that would cut into it reply `Disk full.`. Deleting files always works. `/flash2`, `/sd` and `/extra` have no reserve.
Relative paths work under your session working directory for `ls`, `cat`, `hex`, `stat`, `mkdir`, `cp`, `mv`, `upload`, and `commit`. `rm`, `rmdir`, and `rmtree` require absolute paths.
## Chunked upload
`/upload path offset:b62` writes base62-decoded bytes at `offset`. `/upload path` with no offset replies with the current size. `/commit src dst` verifies CRC32 from the source filename before replacing the destination.
From the firmware repo root:
```bash
python src/modules/LoBBS/bin/lobbs_chunkify.py local.txt /flash/path/dest.txt
```
The script prints `/mkdir`, `/upload`, and `/commit` lines. Temp files use `<name>.<crc8>.tmp`.
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more