diff --git a/.cursor/rules/lobbs-project.mdc b/.cursor/rules/lobbs-project.mdc index e055cf3..207d1b4 100644 --- a/.cursor/rules/lobbs-project.mdc +++ b/.cursor/rules/lobbs-project.mdc @@ -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//`. 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 diff --git a/.cursor/rules/release.mdc b/.cursor/rules/release.mdc index eac681c..4f75647 100644 --- a/.cursor/rules/release.mdc +++ b/.cursor/rules/release.mdc @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index da89d2f..3339fbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ``. 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: `ok [n:max]` (single-page replies are `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 `//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 /.` +- SysOp `/format [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 diff --git a/LoBBSBootTrace.h b/LoBBSBootTrace.h new file mode 100644 index 0000000..796883c --- /dev/null +++ b/LoBBSBootTrace.h @@ -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 diff --git a/LoBBSCommandCtx.h b/LoBBSCommandCtx.h new file mode 100644 index 0000000..c859f66 --- /dev/null +++ b/LoBBSCommandCtx.h @@ -0,0 +1,29 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "LoBBSConfig.h" +#include + +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 diff --git a/LoBBSCommandRegistry.cpp b/LoBBSCommandRegistry.cpp new file mode 100644 index 0000000..a2e8557 --- /dev/null +++ b/LoBBSCommandRegistry.cpp @@ -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 +#include +#include +#include +#include + +#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 = ∓ + 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> ", 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 diff --git a/LoBBSCommandRegistry.h b/LoBBSCommandRegistry.h new file mode 100644 index 0000000..6ebdc0f --- /dev/null +++ b/LoBBSCommandRegistry.h @@ -0,0 +1,48 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "LoBBSCommandCtx.h" +#include "LoBBSHooks.h" +#include +#include + +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 `. 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 diff --git a/LoBBSConfig.h b/LoBBSConfig.h new file mode 100644 index 0000000..13e268e --- /dev/null +++ b/LoBBSConfig.h @@ -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 diff --git a/LoBBSDispatch.cpp b/LoBBSDispatch.cpp new file mode 100644 index 0000000..c504aeb --- /dev/null +++ b/LoBBSDispatch.cpp @@ -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 + +#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 diff --git a/LoBBSDispatch.h b/LoBBSDispatch.h new file mode 100644 index 0000000..9626fd8 --- /dev/null +++ b/LoBBSDispatch.h @@ -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 diff --git a/LoBBSHooks.cpp b/LoBBSHooks.cpp new file mode 100644 index 0000000..1558b3c --- /dev/null +++ b/LoBBSHooks.cpp @@ -0,0 +1,100 @@ +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "LoBBSHooks.h" +#include "configuration.h" +#include + +#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 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(fn), priority); +} + +void lobbsAddFilter(const char *name, LoBBSRecordFilterFn fn, int priority) +{ + lobbsAddHook(name, LobbsHookKind::Record, reinterpret_cast(fn), priority); +} + +void lobbsAddFilter(const char *name, LoBBSListFilterFn fn, int priority) +{ + lobbsAddHook(name, LobbsHookKind::List, reinterpret_cast(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(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(lobbsHooks[i].fn)(&ctx, value, args); + } +} + +void lobbsApplyFilter(const char *name, LoBBSCommandCtx &ctx, std::vector &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(lobbsHooks[i].fn)(&ctx, value, args); + } +} + +#endif diff --git a/LoBBSHooks.h b/LoBBSHooks.h new file mode 100644 index 0000000..328c6ea --- /dev/null +++ b/LoBBSHooks.h @@ -0,0 +1,61 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "LoBBSCommandCtx.h" +#include "LoBBSResponse.h" +#include +#include +#include +#include +#include + +/** + * 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 &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 &value, const LoScalar &args); + +#endif diff --git a/LoBBSInstall.cpp b/LoBBSInstall.cpp new file mode 100644 index 0000000..5f71b09 --- /dev/null +++ b/LoBBSInstall.cpp @@ -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 +#include +#include +#include +#include + +#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 &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 *)v)->push_back(rec); + }, + &value); +} + +static std::vector lobbsInstallMounts(LoBBSCommandCtx &ctx) +{ + std::vector 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> ", 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 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 diff --git a/LoBBSInstall.h b/LoBBSInstall.h new file mode 100644 index 0000000..bf13833 --- /dev/null +++ b/LoBBSInstall.h @@ -0,0 +1,42 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "LoBBSConfig.h" +#include +#include + +class LoBBSModule; +struct LoBBSCommandCtx; +typedef struct _meshtastic_MeshPacket meshtastic_MeshPacket; + +enum class LoBBSInstallState : uint8_t { Blank, Ready, Offline }; + +/** Every LoBBS file lives under `/lobbs`: `install.ls`, `db/` (LoDB), `apps//`, `home//`. */ +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); +/** `/lobbs`, e.g. `/extra/lobbs`. */ +bool lobbsInstallHome(const char *root, char *out, size_t cap); +/** Writes `/lobbs/install.ls`; its presence marks the install. */ +bool lobbsInstallWriteMarker(const char *root); + +#if LOBBS_SEED +void lobbsInstallAutoSeed(LoBBSModule &mod); +#endif + +void lobbsInstallRegisterCommands(); + +#endif diff --git a/LoBBSModule.cpp b/LoBBSModule.cpp new file mode 100644 index 0000000..c9a1314 --- /dev/null +++ b/LoBBSModule.cpp @@ -0,0 +1,117 @@ +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "LoBBSModule.h" +#include "LoBBSConfig.h" +#include "LoBBSDispatch.h" +#include "LoBBSInstall.h" +#include "LoBBSWireup.h" +#include +#if LOBBS_SEED +#include "LoBBSSeed.h" +#endif +#include "LoBBSReply.h" +#include "MeshService.h" +#include +#include + +#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 +std::vector *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 diff --git a/LoBBSModule.h b/LoBBSModule.h new file mode 100644 index 0000000..e454590 --- /dev/null +++ b/LoBBSModule.h @@ -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 +#include + +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 +#include +extern std::vector *lobbsTestReplySink; +#endif + +#endif diff --git a/LoBBSReply.h b/LoBBSReply.h new file mode 100644 index 0000000..a1e970a --- /dev/null +++ b/LoBBSReply.h @@ -0,0 +1,9 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include +#include + +static constexpr size_t LOBBS_REPLY_BYTES = 200; + +#endif diff --git a/LoBBSReplyCache.cpp b/LoBBSReplyCache.cpp new file mode 100644 index 0000000..ca27949 --- /dev/null +++ b/LoBBSReplyCache.cpp @@ -0,0 +1,83 @@ +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "LoBBSReplyCache.h" +#include "apps/Config/ConfigCommands.h" +#include "gps/RTC.h" +#include +#include +#include + +#include "LoBBSStackGuard.h" + +struct LobbsReplyCacheEntry { + LoBBSResponse resp; + uint32_t expiresSec = 0; +}; + +static std::map 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 diff --git a/LoBBSReplyCache.h b/LoBBSReplyCache.h new file mode 100644 index 0000000..eea0461 --- /dev/null +++ b/LoBBSReplyCache.h @@ -0,0 +1,24 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "LoBBSResponse.h" +#include + +#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 diff --git a/LoBBSResponse.cpp b/LoBBSResponse.cpp new file mode 100644 index 0000000..c747c85 --- /dev/null +++ b/LoBBSResponse.cpp @@ -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 +#include +#include + +#include "LoBBSStackGuard.h" + +void lobbsResponseSetError(LoBBSResponse &resp, const char *message) +{ + resp.ok = false; + resp.error = message ? message : ""; + resp.records.clear(); +} + +void lobbsRecordPush(std::vector &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 diff --git a/LoBBSResponse.h b/LoBBSResponse.h new file mode 100644 index 0000000..3daca0e --- /dev/null +++ b/LoBBSResponse.h @@ -0,0 +1,24 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "LoBBSCommandCtx.h" +#include +#include +#include + +struct LoBBSResponse { + bool ok = true; + std::string error; + std::vector records; +}; + +void lobbsResponseSetError(LoBBSResponse &resp, const char *message); +/** Push a `title` (+ optional `description`) record. Empty strings leave the field unset. */ +void lobbsRecordPush(std::vector &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 diff --git a/LoBBSSeed.cpp b/LoBBSSeed.cpp new file mode 100644 index 0000000..a2d7141 --- /dev/null +++ b/LoBBSSeed.cpp @@ -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 diff --git a/LoBBSSeed.h b/LoBBSSeed.h new file mode 100644 index 0000000..d6462f4 --- /dev/null +++ b/LoBBSSeed.h @@ -0,0 +1,7 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS && LOBBS_SEED + +class LoBBSModule; +void lobbsSeedAll(LoBBSModule &mod); + +#endif diff --git a/LoBBSStackGuard.h b/LoBBSStackGuard.h new file mode 100644 index 0000000..7df6e6b --- /dev/null +++ b/LoBBSStackGuard.h @@ -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 diff --git a/LoBBSVersion.h b/LoBBSVersion.h new file mode 100644 index 0000000..bd5789d --- /dev/null +++ b/LoBBSVersion.h @@ -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 diff --git a/LoBBSWireup.cpp b/LoBBSWireup.cpp new file mode 100644 index 0000000..79c135f --- /dev/null +++ b/LoBBSWireup.cpp @@ -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 diff --git a/LoBBSWireup.h b/LoBBSWireup.h new file mode 100644 index 0000000..30599f8 --- /dev/null +++ b/LoBBSWireup.h @@ -0,0 +1,6 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +void lobbsWireup(); + +#endif diff --git a/README.md b/README.md index 6ff9bd7..56e3a58 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,6 @@ The plugin is tagged `vX.Y.Z`. Each fork release is tagged `lobbs-vX.Y.Z. +#include +#include + +#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 diff --git a/apps/AppUtil.h b/apps/AppUtil.h new file mode 100644 index 0000000..ec2712a --- /dev/null +++ b/apps/AppUtil.h @@ -0,0 +1,43 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include +#include +#include +#include + +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 diff --git a/apps/Auth/Auth.cpp b/apps/Auth/Auth.cpp new file mode 100644 index 0000000..5a8feda --- /dev/null +++ b/apps/Auth/Auth.cpp @@ -0,0 +1,9 @@ +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "Auth.h" + +#include "LoBBSStackGuard.h" + +AuthApp::AuthApp(LoDb &lodb) : dal_(lodb) {} + +#endif diff --git a/apps/Auth/Auth.h b/apps/Auth/Auth.h new file mode 100644 index 0000000..3c67038 --- /dev/null +++ b/apps/Auth/Auth.h @@ -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 diff --git a/apps/Auth/AuthCommands.cpp b/apps/Auth/AuthCommands.cpp new file mode 100644 index 0000000..2a5b140 --- /dev/null +++ b/apps/Auth/AuthCommands.cpp @@ -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 +#include +#include +#include + +#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 &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 &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 &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 diff --git a/apps/Auth/AuthCommands.h b/apps/Auth/AuthCommands.h new file mode 100644 index 0000000..789d9a6 --- /dev/null +++ b/apps/Auth/AuthCommands.h @@ -0,0 +1,6 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +void lobbsAuthRegisterCommands(); + +#endif diff --git a/apps/Auth/AuthDal.cpp b/apps/Auth/AuthDal.cpp new file mode 100644 index 0000000..f70d330 --- /dev/null +++ b/apps/Auth/AuthDal.cpp @@ -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 +#include +#include +#include +#include + +#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 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 diff --git a/apps/Auth/AuthDal.h b/apps/Auth/AuthDal.h new file mode 100644 index 0000000..c218e4b --- /dev/null +++ b/apps/Auth/AuthDal.h @@ -0,0 +1,66 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "../../LoBBSConfig.h" +#include +#include +#include +#include + +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 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 sessions_; + SessionConfig sessionCfg_ = {LOBBS_SESSION_DEFAULT_MAX, LOBBS_SESSION_DEFAULT_IDLE_SEC}; +}; + +#endif diff --git a/apps/Auth/AuthRecords.h b/apps/Auth/AuthRecords.h new file mode 100644 index 0000000..a44f4ca --- /dev/null +++ b/apps/Auth/AuthRecords.h @@ -0,0 +1,14 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include + +/** 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 diff --git a/apps/Auth/AuthSeed.cpp b/apps/Auth/AuthSeed.cpp new file mode 100644 index 0000000..7ff0aca --- /dev/null +++ b/apps/Auth/AuthSeed.cpp @@ -0,0 +1,29 @@ +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "../../LoBBSConfig.h" +#if LOBBS_SEED + +#include "../../LoBBSModule.h" +#include "AuthDal.h" +#include "AuthSeed.h" +#include + +#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 diff --git a/apps/Auth/AuthSeed.h b/apps/Auth/AuthSeed.h new file mode 100644 index 0000000..838ef97 --- /dev/null +++ b/apps/Auth/AuthSeed.h @@ -0,0 +1,7 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS && LOBBS_SEED + +class LoBBSModule; +void lobbsSeedAuth(LoBBSModule &mod); + +#endif diff --git a/apps/Config/Config.cpp b/apps/Config/Config.cpp new file mode 100644 index 0000000..791edf3 --- /dev/null +++ b/apps/Config/Config.cpp @@ -0,0 +1,9 @@ +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "Config.h" + +#include "LoBBSStackGuard.h" + +ConfigApp::ConfigApp(LoDb &lodb) : dal_(lodb) {} + +#endif diff --git a/apps/Config/Config.h b/apps/Config/Config.h new file mode 100644 index 0000000..72f37ed --- /dev/null +++ b/apps/Config/Config.h @@ -0,0 +1,17 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "ConfigDal.h" +#include + +class ConfigApp +{ + public: + explicit ConfigApp(LoDb &lodb); + ConfigDal &dal() { return dal_; } + + private: + ConfigDal dal_; +}; + +#endif diff --git a/apps/Config/ConfigCommands.cpp b/apps/Config/ConfigCommands.cpp new file mode 100644 index 0000000..39a3b8a --- /dev/null +++ b/apps/Config/ConfigCommands.cpp @@ -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 +#include +#include + +#include "LoBBSStackGuard.h" + +static uint32_t gLobbsPagerTtlSec = LOBBS_REPLY_CACHE_TTL_SEC; + +uint32_t lobbsReplyCacheTtlSec() +{ + return gLobbsPagerTtlSec; +} + +static void filterConfigKeysPager(LoBBSCommandCtx *ctx, std::vector &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 &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 diff --git a/apps/Config/ConfigCommands.h b/apps/Config/ConfigCommands.h new file mode 100644 index 0000000..61748aa --- /dev/null +++ b/apps/Config/ConfigCommands.h @@ -0,0 +1,9 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include + +void lobbsConfigRegisterCommands(); +uint32_t lobbsReplyCacheTtlSec(); + +#endif diff --git a/apps/Config/ConfigCommon.cpp b/apps/Config/ConfigCommon.cpp new file mode 100644 index 0000000..3b2bbfd --- /dev/null +++ b/apps/Config/ConfigCommon.cpp @@ -0,0 +1,31 @@ +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "ConfigCommon.h" +#include "../../LoBBSModule.h" +#include "ConfigDal.h" +#include + +#include "LoBBSStackGuard.h" + +void lobbsConfigPushKey(std::vector &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 diff --git a/apps/Config/ConfigCommon.h b/apps/Config/ConfigCommon.h new file mode 100644 index 0000000..c779108 --- /dev/null +++ b/apps/Config/ConfigCommon.h @@ -0,0 +1,14 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "../../LoBBSCommandCtx.h" +#include "ConfigRecords.h" +#include +#include +#include + +void lobbsConfigPushKey(std::vector &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 diff --git a/apps/Config/ConfigDal.cpp b/apps/Config/ConfigDal.cpp new file mode 100644 index 0000000..28c5a6d --- /dev/null +++ b/apps/Config/ConfigDal.cpp @@ -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 +#include + +#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 &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 diff --git a/apps/Config/ConfigDal.h b/apps/Config/ConfigDal.h new file mode 100644 index 0000000..ba8aa02 --- /dev/null +++ b/apps/Config/ConfigDal.h @@ -0,0 +1,40 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include +#include +#include +#include +#include + +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 &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 overrides_; + std::vector registry_; + bool registryBuilt_ = false; + bool overridesLoaded_ = false; + char lastValidateError_[128] = {0}; +}; + +#endif diff --git a/apps/Config/ConfigRecords.h b/apps/Config/ConfigRecords.h new file mode 100644 index 0000000..acba725 --- /dev/null +++ b/apps/Config/ConfigRecords.h @@ -0,0 +1,26 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include + +/** 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 diff --git a/apps/Fs/FsCommands.cpp b/apps/Fs/FsCommands.cpp new file mode 100644 index 0000000..4426717 --- /dev/null +++ b/apps/Fs/FsCommands.cpp @@ -0,0 +1,1105 @@ +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "FsCommands.h" +#include "../../LoBBSCommandRegistry.h" +#include "../../LoBBSHooks.h" +#include "../../LoBBSInstall.h" +#include "../../LoBBSModule.h" +#include "../../LoBBSReply.h" +#include "../../LoBBSReplyCache.h" +#include "../../LoBBSResponse.h" +#include "../AppUtil.h" +#include "mesh/NodeDB.h" +#include "mesh/Throttle.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "LoBBSStackGuard.h" + +static constexpr int FS_LS_MAX_NAMES = 128; +static constexpr size_t FS_NAME_BYTES = 48; + +struct FsLsCollect { + const char *pattern; + char names[FS_LS_MAX_NAMES][FS_NAME_BYTES]; + uint32_t sizes[FS_LS_MAX_NAMES]; + bool dirs[FS_LS_MAX_NAMES]; + int count; + bool truncated; +}; + +/** One command at a time; ~7 KiB of entries must not live on the mesh handler stack. */ +static FsLsCollect fsLsCollectScratch; + +static bool fsLsCollectCb(void *ctx, const char *basename, bool isDirectory, uint32_t size) +{ + auto *c = (FsLsCollect *)ctx; + if (!lobfsGlobMatch(c->pattern, basename)) + return true; + if (c->count >= FS_LS_MAX_NAMES) { + c->truncated = true; + return false; + } + strncpy(c->names[c->count], basename, FS_NAME_BYTES - 1); + c->names[c->count][FS_NAME_BYTES - 1] = '\0'; + c->sizes[c->count] = size; + c->dirs[c->count] = isDirectory; + c->count++; + if (c->count >= FS_LS_MAX_NAMES) { + c->truncated = true; + return false; + } + return true; +} + +/** Absolute `path` with an optional glob on the last name -> one file in `out`. Splits `path` in place while listing. */ +static bool fsResolveOneFilePath(char *path, char *out, size_t outCap, LoBBSCommandCtx &ctx) +{ + switch (lobfsGlobResolveOneFile(path, out, outCap)) { + case LobfsGlobResolve::Ok: + return true; + case LobfsGlobResolve::GlobBeforeLast: + lobbsCommandReplyError(ctx, "glob only on the last name"); + return false; + case LobfsGlobResolve::NoDir: + lobbsCommandReplyError(ctx, "No such directory."); + return false; + case LobfsGlobResolve::NoMatch: + lobbsCommandReplyError(ctx, "No match."); + return false; + case LobfsGlobResolve::ManyMatches: + lobbsCommandReplyError(ctx, "Many matches."); + return false; + } + return false; +} + +/** Append `src`'s components to out[0..len), folding `.` and `..`. False if the result does not fit. */ +static bool fsFoldPath(const char *src, char *out, size_t cap, size_t &len) +{ + const char *p = src; + while (*p) { + while (*p == '/') + p++; + if (!*p) + break; + const char *start = p; + while (*p && *p != '/') + p++; + size_t n = (size_t)(p - start); + if (n == 1 && start[0] == '.') + continue; + if (n == 2 && start[0] == '.' && start[1] == '.') { + while (len > 0 && out[len - 1] != '/') + len--; + if (len > 0) + len--; + continue; + } + if (len + 1 + n >= cap) + return false; + out[len++] = '/'; + memcpy(out + len, start, n); + len += n; + } + return true; +} + +/** Resolve a spec against the session cwd and fold `.` and `..`. False if the result does not fit. */ +static bool fsResolvePath(const LoBBSCommandCtx &ctx, const char *spec, char *out, size_t cap) +{ + size_t len = 0; + if (spec[0] != '/' && !fsFoldPath(ctx.session.cwd, out, cap, len)) + return false; + if (!fsFoldPath(spec, out, cap, len)) + return false; + if (len == 0) + out[len++] = '/'; + out[len] = '\0'; + return true; +} + +static bool fsResolveOrReply(LoBBSCommandCtx &ctx, const char *spec, char *out, size_t cap) +{ + if (fsResolvePath(ctx, spec, out, cap)) + return true; + lobbsCommandReplyError(ctx, "Path too long."); + return false; +} + +/** rm, rmdir and rmtree never resolve against cwd. */ +static bool fsResolveAbsoluteOrReply(LoBBSCommandCtx &ctx, const char *spec, char *out, size_t cap) +{ + if (spec[0] != '/') { + lobbsCommandReplyError(ctx, "Absolute path only."); + return false; + } + return fsResolveOrReply(ctx, spec, out, cap); +} + +static bool fsPathWithin(const char *path, const char *ancestor) +{ + if (!path || !ancestor) + return false; + size_t alen = strlen(ancestor); + if (strcmp(path, ancestor) == 0) + return true; + if (alen == 0 || ancestor[0] != '/') + return false; + if (strncmp(path, ancestor, alen) != 0) + return false; + return path[alen] == '/' || path[alen] == '\0'; +} + +static void fsSessionPrep(LoBBSCommandCtx &ctx) +{ + if (LoFS::isDirectory(ctx.session.cwd)) + return; + if (ctx.mod->auth().dal().setSessionCwd(ctx.session.nodeId, "/")) + strncpy(ctx.session.cwd, "/", sizeof(ctx.session.cwd)); +} + +static bool fsRefuseCwdTarget(LoBBSCommandCtx &ctx, const char *path, const char *verbLabel) +{ + if (!fsPathWithin(path, ctx.session.cwd)) + return false; + (void)verbLabel; + lobbsCommandReplyError(ctx, "Cannot remove the current directory."); + return true; +} + +static void handleLs(LoBBSCommandCtx &ctx) +{ + fsSessionPrep(ctx); + + char spec[256]; + const char *pathTok = lobbsArgShift(ctx); + if (!fsResolveOrReply(ctx, pathTok ? pathTok : ".", spec, sizeof(spec))) + return; + if (lobfsGlobBeforeLastName(spec)) { + lobbsCommandReplyError(ctx, "glob only on the last name"); + return; + } + + const char *dir = spec; + const char *pat = "*"; + if (lobfsGlobLastComponentHasMeta(spec)) { + char *lastSlash = strrchr(spec, '/'); + pat = lastSlash + 1; + *lastSlash = '\0'; + if (lastSlash == spec) + dir = "/"; + } + + fsLsCollectScratch = {}; + fsLsCollectScratch.pattern = pat; + if (!LoFS::list(dir, &fsLsCollectScratch, fsLsCollectCb)) { + lobbsCommandReplyError(ctx, "No such directory."); + return; + } + + LoBBSResponse resp; + for (int i = 0; i < fsLsCollectScratch.count; i++) { + char line[FS_NAME_BYTES + LO_HUMAN_BYTES_LEN + 2]; + char size[LO_HUMAN_BYTES_LEN]; + if (fsLsCollectScratch.dirs[i]) + snprintf(line, sizeof(line), "%s/", fsLsCollectScratch.names[i]); + else + snprintf(line, sizeof(line), "%s %s", fsLsCollectScratch.names[i], loHumanBytes(fsLsCollectScratch.sizes[i], size)); + lobbsRecordPush(resp.records, line); + } + if (fsLsCollectScratch.truncated) + lobbsRecordPush(resp.records, "(list cut off)"); + if (resp.records.empty()) { + lobbsResponseSetError(resp, "Empty."); + lobbsCommandReplyResponse(ctx, resp); + return; + } + lobbsCommandReplyResponse(ctx, resp); +} + +static void fsReplyFileText(LoBBSCommandCtx &ctx, const char *path, bool hex) +{ + File f = LoFS::open(path, FILE_O_READ); + if (!f) { + lobbsCommandReplyError(ctx, "No such file."); + return; + } + if (f.isDirectory()) { + f.close(); + lobbsCommandReplyError(ctx, "Is a directory."); + return; + } + + std::string body; + body.reserve(4096); + static constexpr size_t kMaxBody = LOBBS_REPLY_CACHE_MAX_BYTES; + uint8_t chunk[64]; + bool truncated = false; + while (true) { + size_t n = f.read(chunk, sizeof(chunk)); + if (n == 0) + break; + if (hex) { + for (size_t i = 0; i < n; i++) { + if (body.size() + 3 > kMaxBody) { + truncated = true; + break; + } + char pair[4]; + snprintf(pair, sizeof(pair), "%02x ", chunk[i]); + body += pair; + } + } else { + size_t room = kMaxBody > body.size() ? kMaxBody - body.size() : 0; + if (n > room) { + body.append((const char *)chunk, room); + truncated = true; + break; + } + body.append((const char *)chunk, n); + } + if (truncated) + break; + } + if (truncated) + body += "[...]"; + f.close(); + + LoScalar rec; + rec.setString(LODB_F_DESCRIPTION, body); + LoBBSResponse resp; + lobbsResponseAppendRecord(resp, rec); + lobbsCommandReplyResponse(ctx, resp); +} + +static bool fsResolveFileArg(LoBBSCommandCtx &ctx, const char *spec, char *out, size_t cap) +{ + char full[256]; + if (!fsResolveOrReply(ctx, spec, full, sizeof(full))) + return false; + return fsResolveOneFilePath(full, out, cap, ctx); +} + +static void handleCat(LoBBSCommandCtx &ctx) +{ + fsSessionPrep(ctx); + const char *spec = lobbsArgShift(ctx); + if (!spec) { + lobbsCommandReplyError(ctx, "Usage: /cat path"); + return; + } + + char path[256]; + if (!fsResolveFileArg(ctx, spec, path, sizeof(path))) + return; + fsReplyFileText(ctx, path, false); +} + +static void handleHex(LoBBSCommandCtx &ctx) +{ + fsSessionPrep(ctx); + const char *spec = lobbsArgShift(ctx); + if (!spec) { + lobbsCommandReplyError(ctx, "Usage: /hex path"); + return; + } + + char path[256]; + if (!fsResolveFileArg(ctx, spec, path, sizeof(path))) + return; + fsReplyFileText(ctx, path, true); +} + +static void handleRm(LoBBSCommandCtx &ctx) +{ + fsSessionPrep(ctx); + const char *arg = lobbsArgShift(ctx); + if (!arg) { + lobbsCommandReplyError(ctx, "Usage: /rm /path"); + return; + } + char spec[256]; + if (!fsResolveAbsoluteOrReply(ctx, arg, spec, sizeof(spec))) + return; + if (lobfsGlobHasMeta(spec)) { + lobbsCommandReplyError(ctx, "No glob."); + return; + } + + File f = LoFS::open(spec, FILE_O_READ); + if (!f) { + lobbsCommandReplyError(ctx, "No such file."); + return; + } + if (f.isDirectory()) { + f.close(); + lobbsCommandReplyError(ctx, "Is a directory."); + return; + } + f.close(); + + if (LoFS::remove(spec)) + lobbsCommandReply(ctx, "Removed."); + else + lobbsCommandReplyError(ctx, "Failed."); +} + +static void handleRmdir(LoBBSCommandCtx &ctx) +{ + fsSessionPrep(ctx); + const char *arg = lobbsArgShift(ctx); + if (!arg) { + lobbsCommandReplyError(ctx, "Usage: /rmdir /path"); + return; + } + char spec[256]; + if (!fsResolveAbsoluteOrReply(ctx, arg, spec, sizeof(spec))) + return; + if (lobfsGlobHasMeta(spec)) { + lobbsCommandReplyError(ctx, "No glob."); + return; + } + if (LoFS::isMountPoint(spec)) { + lobbsCommandReplyError(ctx, "Refused."); + return; + } + if (fsRefuseCwdTarget(ctx, spec, "rmdir")) + return; + + if (!LoFS::isDirectory(spec)) { + lobbsCommandReplyError(ctx, "No such directory."); + return; + } + + struct DirEmptyCtx { + bool empty; + } dec{true}; + auto dirHasEntry = [](void *v, const char *basename, bool isDirectory, uint32_t size) -> bool { + (void)isDirectory; + (void)basename; + (void)size; + ((DirEmptyCtx *)v)->empty = false; + return false; + }; + if (!LoFS::list(spec, &dec, dirHasEntry)) { + lobbsCommandReplyError(ctx, "Failed."); + return; + } + if (!dec.empty) { + lobbsCommandReplyError(ctx, "Not empty."); + return; + } + + if (LoFS::rmdir(spec, false)) + lobbsCommandReply(ctx, "Removed."); + else + lobbsCommandReplyError(ctx, "Failed."); +} + +static void handleRmtree(LoBBSCommandCtx &ctx) +{ + fsSessionPrep(ctx); + const char *a = lobbsArgShift(ctx); + const char *b = lobbsArgShift(ctx); + if (!a || !b || strcmp(a, b) != 0 || lobbsArgHasMore(ctx)) { + lobbsCommandReplyError(ctx, "Usage: /rmtree /path /path"); + return; + } + + char path[256]; + if (!fsResolveAbsoluteOrReply(ctx, a, path, sizeof(path))) + return; + if (LoFS::isMountPoint(path)) { + lobbsCommandReplyError(ctx, "Refused."); + return; + } + if (fsRefuseCwdTarget(ctx, path, "rmtree")) + return; + if (lobfsGlobHasMeta(path)) { + lobbsCommandReplyError(ctx, "No glob."); + return; + } + if (!LoFS::exists(path)) { + lobbsCommandReplyError(ctx, "No such directory."); + return; + } + + if (LoFS::rmdir(path, true)) + lobbsCommandReply(ctx, "Removed."); + else + lobbsCommandReplyError(ctx, "Failed."); +} + +static void handleMkdir(LoBBSCommandCtx &ctx) +{ + fsSessionPrep(ctx); + const char *arg = lobbsArgShift(ctx); + if (!arg) { + lobbsCommandReplyError(ctx, "Usage: /mkdir path"); + return; + } + char path[256]; + if (!fsResolveOrReply(ctx, arg, path, sizeof(path))) + return; + if (LoFS::isMountPoint(path) || strcmp(path, "/") == 0) { + lobbsCommandReplyError(ctx, "Refused."); + return; + } + if (LoFS::exists(path)) { + lobbsCommandReplyError(ctx, "Already exists."); + return; + } + char *slash = strrchr(path, '/'); + if (!slash || slash == path) { + lobbsCommandReplyError(ctx, "No parent."); + return; + } + *slash = '\0'; + bool parentIsDir = LoFS::isDirectory(path); + *slash = '/'; + if (!parentIsDir) { + lobbsCommandReplyError(ctx, "No such directory."); + return; + } + if (!LoFS::hasRoom(path, 1)) { + lobbsCommandReplyError(ctx, "Disk full."); + return; + } + if (LoFS::mkdir(path)) + lobbsCommandReply(ctx, "Created."); + else + lobbsCommandReplyError(ctx, "Failed."); +} + +static void handleStat(LoBBSCommandCtx &ctx) +{ + fsSessionPrep(ctx); + const char *arg = lobbsArgShift(ctx); + if (!arg) { + lobbsCommandReplyError(ctx, "Usage: /stat path"); + return; + } + char path[256]; + if (!fsResolveOrReply(ctx, arg, path, sizeof(path))) + return; + uint32_t size = 0; + bool isDir = false; + if (!LoFS::stat(path, &size, &isDir)) { + lobbsCommandReplyError(ctx, "Not found."); + return; + } + char line[64]; + if (isDir) + snprintf(line, sizeof(line), "dir"); + else + snprintf(line, sizeof(line), "file %u", (unsigned)size); + lobbsCommandReply(ctx, line); +} + +static void handleDf(LoBBSCommandCtx &ctx) +{ + fsSessionPrep(ctx); + LoBBSResponse resp; + struct Ctx { + LoBBSResponse *resp; + const char *installRoot; + } dc{&resp, lobbsInstallRoot(*ctx.mod)}; + LoFS::eachPresentMount( + [](void *v, const char *name) { + auto *dc = (Ctx *)v; + char root[16]; + snprintf(root, sizeof(root), "/%s", name); + const uint64_t total = LoFS::totalBytes(root); + const uint64_t used = LoFS::usedBytes(root); + const uint32_t reserve = LoFS::mountReserve(name); + const char *tag = strcmp(dc->installRoot, root) == 0 ? " [lobbs]" : ""; + char line[80]; + if (total == 0) { + snprintf(line, sizeof(line), "%s: size unknown%s", name, tag); + } else { + const uint64_t taken = used + reserve; + const uint64_t freeB = taken < total ? total - taken : 0; + const unsigned pct = (unsigned)(used * 100 / total); + char freeS[LO_HUMAN_BYTES_LEN], totalS[LO_HUMAN_BYTES_LEN], resS[LO_HUMAN_BYTES_LEN]; + if (reserve) + snprintf(line, sizeof(line), "%s: %s free of %s (%u%% used, %s reserved)%s", name, loHumanBytes(freeB, freeS), + loHumanBytes(total, totalS), pct, loHumanBytes(reserve, resS), tag); + else + snprintf(line, sizeof(line), "%s: %s free of %s (%u%% used)%s", name, loHumanBytes(freeB, freeS), + loHumanBytes(total, totalS), pct, tag); + } + lobbsRecordPush(dc->resp->records, line); + }, + &dc); + if (resp.records.empty()) + lobbsResponseSetError(resp, "No mounts."); + lobbsCommandReplyResponse(ctx, resp); +} + +static constexpr uint32_t FS_FORMAT_CONFIRM_MS = 120000; + +/** One pending /format confirmation at a time, bound to the node that asked. */ +static struct { + uint32_t nodeId; + uint32_t armedMs; + char mount[16]; + char code[7]; +} fsFormatPending; + +static void handleFormat(LoBBSCommandCtx &ctx) +{ + const char *name = lobbsArgShift(ctx); + const char *code = lobbsArgShift(ctx); + if (!name || lobbsArgHasMore(ctx)) { + lobbsCommandReplyError(ctx, "Usage: /format [code]"); + return; + } + if (!LoFS::mountPresent(name)) { + lobbsCommandReplyError(ctx, "No such mount."); + return; + } + if (strcmp(name, "sd") == 0) { + lobbsCommandReplyError(ctx, "Refused: format SD cards on a PC."); + return; + } + const char *installRoot = lobbsInstallRoot(*ctx.mod); + const bool isFlash = strcmp(name, "flash") == 0; + const bool isInstall = installRoot[0] && strcmp(installRoot + 1, name) == 0; + + char msg[160]; + if (!code) { + static const char alphabet[] = "abcdefghjkmnpqrstuvwxyz23456789"; + for (size_t i = 0; i < sizeof(fsFormatPending.code) - 1; i++) + fsFormatPending.code[i] = alphabet[random(sizeof(alphabet) - 1)]; + fsFormatPending.code[sizeof(fsFormatPending.code) - 1] = '\0'; + fsFormatPending.nodeId = ctx.session.nodeId; + fsFormatPending.armedMs = millis(); + strncpy(fsFormatPending.mount, name, sizeof(fsFormatPending.mount) - 1); + fsFormatPending.mount[sizeof(fsFormatPending.mount) - 1] = '\0'; + char root[16], size[LO_HUMAN_BYTES_LEN]; + snprintf(root, sizeof(root), "/%s", name); + const char *note = isInstall ? " This uninstalls LoBBS." : isFlash ? " Radio settings are saved again." : ""; + snprintf(msg, sizeof(msg), "Formats /%s (%s): everything on it is erased.%s Run /format %s %s within 2 min.", name, + loHumanBytes(LoFS::totalBytes(root), size), note, name, fsFormatPending.code); + lobbsCommandReply(ctx, msg); + return; + } + + const bool ok = fsFormatPending.code[0] && fsFormatPending.nodeId == ctx.session.nodeId && + strcmp(fsFormatPending.mount, name) == 0 && strcmp(fsFormatPending.code, code) == 0 && + Throttle::isWithinTimespanMs(fsFormatPending.armedMs, FS_FORMAT_CONFIRM_MS); + fsFormatPending.code[0] = '\0'; + if (!ok) { + snprintf(msg, sizeof(msg), "Wrong or expired code. Run /format %s for a new one.", name); + lobbsCommandReplyError(ctx, msg); + return; + } + if (!LoFS::format(name)) { + lobbsCommandReplyError(ctx, "Format failed."); + return; + } + if (isFlash && nodeDB) + nodeDB->saveToDisk(); + if (isInstall) + lobbsInstallInit(*ctx.mod); + snprintf(msg, sizeof(msg), "Formatted /%s.%s", name, + lobbsInstallState(*ctx.mod) == LoBBSInstallState::Blank ? " LoBBS is blank. Run /install." : ""); + lobbsCommandReply(ctx, msg); +} + +/** One command at a time; keeps two path buffers off the mesh handler stack for /cp and /mv. */ +static struct { + char src[256]; + char dst[256]; +} fsPairScratch; + +static constexpr size_t FS_UPLOAD_DECODE_MAX = 160; +static uint8_t fsUploadDecodeScratch[FS_UPLOAD_DECODE_MAX]; + +static int fsB62CharValue(char c) +{ + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'A' && c <= 'Z') + return c - 'A' + 10; + if (c >= 'a' && c <= 'z') + return c - 'a' + 36; + return -1; +} + +static int fsB62DecodedByteCount(size_t charLen) +{ + switch (charLen) { + case 2: + return 1; + case 3: + return 2; + case 5: + return 3; + case 6: + return 4; + case 7: + return 5; + case 9: + return 6; + case 10: + return 7; + case 11: + return 8; + default: + return -1; + } +} + +/** Decode one base62 chunk into out (max outCap). Returns byte count or -1. */ +static int fsB62DecodeChunk(const char *b62, uint8_t *out, size_t outCap) +{ + if (!b62 || !out) + return -1; + size_t charLen = strlen(b62); + int byteCount = fsB62DecodedByteCount(charLen); + if (byteCount < 0 || (size_t)byteCount > outCap) + return -1; + + uint64_t val = 0; + for (size_t i = 0; i < charLen; i++) { + int d = fsB62CharValue(b62[i]); + if (d < 0) + return -1; + val = val * 62 + (uint64_t)d; + } + + uint64_t maxVal = byteCount >= 8 ? UINT64_MAX : ((uint64_t)1 << (8 * (unsigned)byteCount)) - 1; + if (val > maxVal) + return -1; + + for (int i = 0; i < byteCount; i++) + out[i] = (uint8_t)((val >> (8 * (byteCount - 1 - i))) & 0xff); + return byteCount; +} + +static bool fsParentIsDirectory(const char *path) +{ + if (!path || path[0] != '/') + return false; + char parent[256]; + strncpy(parent, path, sizeof(parent) - 1); + parent[sizeof(parent) - 1] = '\0'; + char *slash = strrchr(parent, '/'); + if (!slash) + return false; + if (slash == parent) { + parent[1] = '\0'; + return LoFS::isDirectory(parent); + } + *slash = '\0'; + return LoFS::isDirectory(parent); +} + +static bool fsParseCrcFromBasename(const char *path, uint32_t &crcOut) +{ + crcOut = 0; + if (!path) + return false; + const char *base = strrchr(path, '/'); + base = base ? base + 1 : path; + const char *found = nullptr; + for (const char *p = base; *p;) { + const char *dot = strchr(p, '.'); + size_t segLen = dot ? (size_t)(dot - p) : strlen(p); + if (segLen == 8) { + bool hex = true; + for (size_t i = 0; i < 8; i++) { + if (!isxdigit((unsigned char)p[i])) { + hex = false; + break; + } + } + if (hex) + found = p; + } + if (!dot) + break; + p = dot + 1; + } + if (!found) + return false; + char hex[9]; + memcpy(hex, found, 8); + hex[8] = '\0'; + char *end = nullptr; + unsigned long v = strtoul(hex, &end, 16); + if (end != hex + 8) + return false; + crcOut = (uint32_t)v; + return true; +} + +static void fsReplyMove(LoBBSCommandCtx &ctx, LoFSMoveResult r) +{ + switch (r) { + case LoFSMoveResult::Ok: + lobbsCommandReply(ctx, "Moved."); + break; + case LoFSMoveResult::RefusedMount: + lobbsCommandReplyError(ctx, "Refused."); + break; + case LoFSMoveResult::DstExists: + lobbsCommandReplyError(ctx, "Destination exists."); + break; + case LoFSMoveResult::SrcMissing: + lobbsCommandReplyError(ctx, "No such file."); + break; + case LoFSMoveResult::SrcIsDir: + case LoFSMoveResult::CrossMountDir: + lobbsCommandReplyError(ctx, "Cross-mount directory move not supported."); + break; + case LoFSMoveResult::NoSpace: + lobbsCommandReplyError(ctx, "Disk full."); + break; + case LoFSMoveResult::CopyFailed: + case LoFSMoveResult::Failed: + lobbsCommandReplyError(ctx, "Failed."); + break; + case LoFSMoveResult::SrcNotRemoved: + lobbsCommandReplyError(ctx, "Copied, but source not removed."); + break; + case LoFSMoveResult::CrcMismatch: + lobbsCommandReplyError(ctx, "CRC mismatch."); + break; + } +} + +static void handleCp(LoBBSCommandCtx &ctx) +{ + fsSessionPrep(ctx); + const char *srcSpec = lobbsArgShift(ctx); + const char *dstSpec = lobbsArgShift(ctx); + if (!srcSpec || !dstSpec || lobbsArgHasMore(ctx)) { + lobbsCommandReplyError(ctx, "Usage: /cp src dst"); + return; + } + if (lobfsGlobHasMeta(dstSpec)) { + lobbsCommandReplyError(ctx, "No glob on destination."); + return; + } + auto &src = fsPairScratch.src; + auto &dst = fsPairScratch.dst; + if (!fsResolveFileArg(ctx, srcSpec, src, sizeof(src))) + return; + if (!fsResolveOrReply(ctx, dstSpec, dst, sizeof(dst))) + return; + if (LoFS::isMountPoint(src)) { + lobbsCommandReplyError(ctx, "Refused."); + return; + } + bool srcIsDir = false; + uint32_t srcSz = 0; + if (LoFS::stat(src, &srcSz, &srcIsDir) && srcIsDir) { + lobbsCommandReplyError(ctx, "Is a directory."); + return; + } + if (LoFS::exists(dst)) { + lobbsCommandReplyError(ctx, "Destination exists."); + return; + } + if (!LoFS::hasRoom(dst, srcSz)) { + lobbsCommandReplyError(ctx, "Disk full."); + return; + } + if (!LoFS::copy(src, dst)) { + lobbsCommandReplyError(ctx, "Failed."); + return; + } + lobbsCommandReply(ctx, "Copied."); +} + +static void handleMv(LoBBSCommandCtx &ctx) +{ + fsSessionPrep(ctx); + const char *srcSpec = lobbsArgShift(ctx); + const char *dstSpec = lobbsArgShift(ctx); + if (!srcSpec || !dstSpec || lobbsArgHasMore(ctx)) { + lobbsCommandReplyError(ctx, "Usage: /mv src dst"); + return; + } + if (lobfsGlobHasMeta(dstSpec)) { + lobbsCommandReplyError(ctx, "No glob on destination."); + return; + } + auto &src = fsPairScratch.src; + auto &dst = fsPairScratch.dst; + if (!fsResolveOrReply(ctx, srcSpec, src, sizeof(src))) + return; + if (!fsResolveOrReply(ctx, dstSpec, dst, sizeof(dst))) + return; + if (fsRefuseCwdTarget(ctx, src, "mv")) + return; + fsReplyMove(ctx, LoFS::move(src, dst)); +} + +static void handleUpload(LoBBSCommandCtx &ctx) +{ + fsSessionPrep(ctx); + const char *pathSpec = lobbsArgShift(ctx); + if (!pathSpec) { + lobbsCommandReplyError(ctx, "Usage: /upload path [offset:b62]"); + return; + } + if (lobfsGlobHasMeta(pathSpec)) { + lobbsCommandReplyError(ctx, "No glob."); + return; + } + + char path[256]; + if (!fsResolveOrReply(ctx, pathSpec, path, sizeof(path))) + return; + if (LoFS::isMountPoint(path)) { + lobbsCommandReplyError(ctx, "Refused."); + return; + } + + const char *chunkTok = lobbsArgShift(ctx); + if (!chunkTok) { + uint32_t size = 0; + bool isDir = false; + if (LoFS::stat(path, &size, &isDir)) { + if (isDir) { + lobbsCommandReplyError(ctx, "Is a directory."); + return; + } + } else if (!fsParentIsDirectory(path)) { + lobbsCommandReplyError(ctx, "No such directory."); + return; + } + char line[32]; + snprintf(line, sizeof(line), "Size %u.", (unsigned)size); + lobbsCommandReply(ctx, line); + return; + } + if (lobbsArgHasMore(ctx)) { + lobbsCommandReplyError(ctx, "Usage: /upload path offset:b62"); + return; + } + + const char *colon = strchr(chunkTok, ':'); + if (!colon || colon == chunkTok) { + lobbsCommandReplyError(ctx, "Bad offset."); + return; + } + + char offBuf[16]; + size_t offLen = (size_t)(colon - chunkTok); + if (offLen >= sizeof(offBuf)) { + lobbsCommandReplyError(ctx, "Bad offset."); + return; + } + memcpy(offBuf, chunkTok, offLen); + offBuf[offLen] = '\0'; + char *end = nullptr; + unsigned long offVal = strtoul(offBuf, &end, 10); + if (end != offBuf + offLen || offVal > UINT32_MAX) { + lobbsCommandReplyError(ctx, "Bad offset."); + return; + } + const uint32_t offset = (uint32_t)offVal; + const char *b62 = colon + 1; + if (!b62[0]) { + lobbsCommandReplyError(ctx, "Bad data."); + return; + } + + int decodedLen = fsB62DecodeChunk(b62, fsUploadDecodeScratch, FS_UPLOAD_DECODE_MAX); + if (decodedLen < 0) { + lobbsCommandReplyError(ctx, "Bad data."); + return; + } + + uint32_t curSize = 0; + bool isDir = false; + if (LoFS::stat(path, &curSize, &isDir)) { + if (isDir) { + lobbsCommandReplyError(ctx, "Is a directory."); + return; + } + } else { + curSize = 0; + if (!fsParentIsDirectory(path)) { + lobbsCommandReplyError(ctx, "No such directory."); + return; + } + } + + if (offset > curSize) { + char line[40]; + snprintf(line, sizeof(line), "Gap: size %u.", (unsigned)curSize); + lobbsCommandReplyError(ctx, line); + return; + } + + if (!LoFS::hasRoom(path, (uint32_t)decodedLen)) { + lobbsCommandReplyError(ctx, "Disk full."); + return; + } + if (!LoFS::writeAt(path, offset, fsUploadDecodeScratch, (size_t)decodedLen)) { + lobbsCommandReplyError(ctx, "Failed."); + return; + } + + uint32_t newSize = 0; + if (!LoFS::stat(path, &newSize, &isDir) || isDir) { + lobbsCommandReplyError(ctx, "Failed."); + return; + } + char line[32]; + snprintf(line, sizeof(line), "Size %u.", (unsigned)newSize); + lobbsCommandReply(ctx, line); +} + +static void handleCommit(LoBBSCommandCtx &ctx) +{ + fsSessionPrep(ctx); + const char *srcSpec = lobbsArgShift(ctx); + const char *dstSpec = lobbsArgShift(ctx); + if (!srcSpec || !dstSpec || lobbsArgHasMore(ctx)) { + lobbsCommandReplyError(ctx, "Usage: /commit src dst"); + return; + } + if (lobfsGlobHasMeta(srcSpec) || lobfsGlobHasMeta(dstSpec)) { + lobbsCommandReplyError(ctx, "No glob."); + return; + } + auto &src = fsPairScratch.src; + auto &dst = fsPairScratch.dst; + if (!fsResolveOrReply(ctx, srcSpec, src, sizeof(src))) + return; + if (!fsResolveOrReply(ctx, dstSpec, dst, sizeof(dst))) + return; + + uint32_t nameCrc = 0; + if (!fsParseCrcFromBasename(src, nameCrc)) { + lobbsCommandReplyError(ctx, "No CRC in name."); + return; + } + if (fsRefuseCwdTarget(ctx, src, "commit")) + return; + fsReplyMove(ctx, LoFS::moveIfCrc32Matches(src, dst, nameCrc)); +} + +static void handleCd(LoBBSCommandCtx &ctx) +{ + fsSessionPrep(ctx); + const char *arg = lobbsArgShift(ctx); + char path[LOBBS_CWD_BUFFER_SIZE]; + if (!fsResolveOrReply(ctx, arg ? arg : "/", path, sizeof(path))) + return; + if (lobfsGlobHasMeta(path)) { + lobbsCommandReplyError(ctx, "No glob."); + return; + } + if (!LoFS::isDirectory(path)) { + lobbsCommandReplyError(ctx, "No such directory."); + return; + } + if (!ctx.mod->auth().dal().setSessionCwd(ctx.session.nodeId, path)) { + lobbsCommandReplyError(ctx, "Failed."); + return; + } + memcpy(ctx.session.cwd, path, strlen(path) + 1); + lobbsCommandReply(ctx, ctx.session.cwd); +} + +static void handlePwd(LoBBSCommandCtx &ctx) +{ + fsSessionPrep(ctx); + lobbsCommandReply(ctx, ctx.session.cwd); +} + +static const LoBBSVerb fsVerbs[] = { + {"cd", handleCd, LOBBS_V_SYSOP, "cd [path] — set working directory (default /)"}, + {"pwd", handlePwd, LOBBS_V_SYSOP, "pwd — show working directory"}, + {"ls", handleLs, LOBBS_V_SYSOP, "ls [path] — list directory (default cwd; /p2 …; * on last name only)"}, + {"cat", handleCat, LOBBS_V_SYSOP, "cat path — read a file (one match if glob)"}, + {"hex", handleHex, LOBBS_V_SYSOP, "hex path — hex dump (one match if glob)"}, + {"rm", handleRm, LOBBS_V_SYSOP, "rm /path — delete a file (absolute path)"}, + {"rmdir", handleRmdir, LOBBS_V_SYSOP, "rmdir /path — remove empty directory (absolute path)"}, + {"rmtree", handleRmtree, LOBBS_V_SYSOP, "rmtree /path /path — recursive delete (absolute path, typed twice)"}, + {"mkdir", handleMkdir, LOBBS_V_SYSOP, "mkdir path — create directory"}, + {"cp", handleCp, LOBBS_V_SYSOP, "cp src dst — copy file (no overwrite)"}, + {"mv", handleMv, LOBBS_V_SYSOP, "mv src dst — move/rename (files; dirs same mount only)"}, + {"upload", handleUpload, LOBBS_V_SYSOP, "upload path [offset:b62] — chunked write or show size"}, + {"commit", handleCommit, LOBBS_V_SYSOP, "commit src dst — move when file CRC matches name"}, + {"stat", handleStat, LOBBS_V_SYSOP, "stat path — file size or dir"}, + {"df", handleDf, LOBBS_V_SYSOP, "df — free space per mount"}, + {"format", handleFormat, LOBBS_V_SYSOP, "format [code] — erase a mount (asks for a code first)"}, +}; + +static void slashFs(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(); + const size_t n = sizeof(fsVerbs) / sizeof(fsVerbs[0]); + for (size_t i = 0; i < n; i++) { + if (!fsVerbs[i].verb || strcasecmp(verb, fsVerbs[i].verb) != 0) + continue; + if ((fsVerbs[i].flags & LOBBS_V_SYSOP) && !lobbsCommandRequireSysop(c)) + return; + fsVerbs[i].fn(c); + return; + } +} + +static void filterFsHelpTopics(LoBBSCommandCtx *ctx, std::vector &topics, const LoScalar &args) +{ + (void)args; + if (!ctx || !lobbsCtxIsSysop(*ctx)) + return; + lobbsRecordPush(topics, "cd", "set working directory"); + lobbsRecordPush(topics, "pwd", "show working directory"); + lobbsRecordPush(topics, "ls", "list files"); + lobbsRecordPush(topics, "cat", "print a file"); + lobbsRecordPush(topics, "hex", "hex dump a file"); + lobbsRecordPush(topics, "rm", "delete a file"); + lobbsRecordPush(topics, "rmdir", "remove an empty directory"); + lobbsRecordPush(topics, "rmtree", "remove a directory tree"); + lobbsRecordPush(topics, "mkdir", "create a directory"); + lobbsRecordPush(topics, "cp", "copy a file"); + lobbsRecordPush(topics, "mv", "move or rename"); + lobbsRecordPush(topics, "upload", "chunked file upload"); + lobbsRecordPush(topics, "commit", "CRC-checked move"); + lobbsRecordPush(topics, "stat", "file or directory info"); + lobbsRecordPush(topics, "df", "filesystem space"); + lobbsRecordPush(topics, "format", "erase a mount"); +} + +static void filterFsHelpForTopic(LoBBSCommandCtx *ctx, LoScalar &value, const LoScalar &args) +{ + const size_t n = sizeof(fsVerbs) / sizeof(fsVerbs[0]); + for (size_t i = 0; i < n; i++) { + if (!fsVerbs[i].verb || !fsVerbs[i].help) + continue; + lobbsHelpForTable(ctx, value, args, fsVerbs[i].verb, &fsVerbs[i], 1); + } +} + +void lobbsFsRegisterCommands() +{ + lobbsAddAction("slash_cmd", slashFs, LOBBS_HOOK_PRIORITY_FEATURE); + lobbsAddFilter("help_topics", filterFsHelpTopics, LOBBS_HOOK_PRIORITY_FEATURE); + lobbsAddFilter("help_for_topic", filterFsHelpForTopic, LOBBS_HOOK_PRIORITY_FEATURE); +} + +#endif diff --git a/apps/Fs/FsCommands.h b/apps/Fs/FsCommands.h new file mode 100644 index 0000000..cf2c5fd --- /dev/null +++ b/apps/Fs/FsCommands.h @@ -0,0 +1,6 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +void lobbsFsRegisterCommands(); + +#endif diff --git a/apps/Help/HelpCommands.cpp b/apps/Help/HelpCommands.cpp new file mode 100644 index 0000000..7635686 --- /dev/null +++ b/apps/Help/HelpCommands.cpp @@ -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 +#include + +#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 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 diff --git a/apps/Help/HelpCommands.h b/apps/Help/HelpCommands.h new file mode 100644 index 0000000..6e0bf9d --- /dev/null +++ b/apps/Help/HelpCommands.h @@ -0,0 +1,6 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +void lobbsHelpRegisterCommands(); + +#endif diff --git a/apps/Mail/Mail.cpp b/apps/Mail/Mail.cpp new file mode 100644 index 0000000..32f9ca0 --- /dev/null +++ b/apps/Mail/Mail.cpp @@ -0,0 +1,9 @@ +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "Mail.h" + +#include "LoBBSStackGuard.h" + +MailApp::MailApp(LoDb &lodb) : dal_(lodb) {} + +#endif diff --git a/apps/Mail/Mail.h b/apps/Mail/Mail.h new file mode 100644 index 0000000..6d527e2 --- /dev/null +++ b/apps/Mail/Mail.h @@ -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 diff --git a/apps/Mail/MailCommands.cpp b/apps/Mail/MailCommands.cpp new file mode 100644 index 0000000..8937be2 --- /dev/null +++ b/apps/Mail/MailCommands.cpp @@ -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 +#include +#include +#include +#include + +#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 &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 &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 diff --git a/apps/Mail/MailCommands.h b/apps/Mail/MailCommands.h new file mode 100644 index 0000000..4f95851 --- /dev/null +++ b/apps/Mail/MailCommands.h @@ -0,0 +1,6 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +void lobbsMailRegisterCommands(); + +#endif diff --git a/apps/Mail/MailDal.cpp b/apps/Mail/MailDal.cpp new file mode 100644 index 0000000..b50bed3 --- /dev/null +++ b/apps/Mail/MailDal.cpp @@ -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 + +#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 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 diff --git a/apps/Mail/MailDal.h b/apps/Mail/MailDal.h new file mode 100644 index 0000000..6eca824 --- /dev/null +++ b/apps/Mail/MailDal.h @@ -0,0 +1,33 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include +#include +#include + +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 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 diff --git a/apps/Mail/MailRecords.h b/apps/Mail/MailRecords.h new file mode 100644 index 0000000..170bc19 --- /dev/null +++ b/apps/Mail/MailRecords.h @@ -0,0 +1,14 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include + +/** 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 diff --git a/apps/Mail/MailSeed.cpp b/apps/Mail/MailSeed.cpp new file mode 100644 index 0000000..243b818 --- /dev/null +++ b/apps/Mail/MailSeed.cpp @@ -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 +#include + +#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 inbox = mail.getAllMailForUser(sysop); + for (size_t j = 0; j < inbox.size() && j < 3; j++) + mail.markMailAsRead(MailDal::mailUuid(inbox[j])); +} + +#endif +#endif diff --git a/apps/Mail/MailSeed.h b/apps/Mail/MailSeed.h new file mode 100644 index 0000000..5603964 --- /dev/null +++ b/apps/Mail/MailSeed.h @@ -0,0 +1,7 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS && LOBBS_SEED + +class LoBBSModule; +void lobbsSeedMail(LoBBSModule &mod); + +#endif diff --git a/apps/Msg/MsgCommon.cpp b/apps/Msg/MsgCommon.cpp new file mode 100644 index 0000000..63effb7 --- /dev/null +++ b/apps/Msg/MsgCommon.cpp @@ -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 +#include + +#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 diff --git a/apps/Msg/MsgCommon.h b/apps/Msg/MsgCommon.h new file mode 100644 index 0000000..7c366d5 --- /dev/null +++ b/apps/Msg/MsgCommon.h @@ -0,0 +1,11 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "../../LoBBSCommandCtx.h" +#include +#include + +bool lobbsMsgShiftIndex(LoBBSCommandCtx &ctx, size_t count, const char *usage, const char *badNumMsg, uint32_t &idxOut); +void lobbsMsgRegisterDisplay(); + +#endif diff --git a/apps/News/News.cpp b/apps/News/News.cpp new file mode 100644 index 0000000..fb4d0fc --- /dev/null +++ b/apps/News/News.cpp @@ -0,0 +1,9 @@ +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "News.h" + +#include "LoBBSStackGuard.h" + +NewsApp::NewsApp(LoDb &lodb) : dal_(lodb) {} + +#endif diff --git a/apps/News/News.h b/apps/News/News.h new file mode 100644 index 0000000..a8bb81c --- /dev/null +++ b/apps/News/News.h @@ -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 diff --git a/apps/News/NewsCommands.cpp b/apps/News/NewsCommands.cpp new file mode 100644 index 0000000..1f676e6 --- /dev/null +++ b/apps/News/NewsCommands.cpp @@ -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 +#include +#include +#include +#include +#include + +#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 &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 &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 diff --git a/apps/News/NewsCommands.h b/apps/News/NewsCommands.h new file mode 100644 index 0000000..f5e19a4 --- /dev/null +++ b/apps/News/NewsCommands.h @@ -0,0 +1,6 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +void lobbsNewsRegisterCommands(); + +#endif diff --git a/apps/News/NewsDal.cpp b/apps/News/NewsDal.cpp new file mode 100644 index 0000000..56c86be --- /dev/null +++ b/apps/News/NewsDal.cpp @@ -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 +#include + +#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 NewsDal::getAllNewsForUser(uint64_t userUuid) +{ + auto allNews = lodb_.select("news", LoDbFilter(), LoDbComparator()); + + std::vector 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 diff --git a/apps/News/NewsDal.h b/apps/News/NewsDal.h new file mode 100644 index 0000000..208afda --- /dev/null +++ b/apps/News/NewsDal.h @@ -0,0 +1,36 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include +#include +#include + +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 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 diff --git a/apps/News/NewsRecords.h b/apps/News/NewsRecords.h new file mode 100644 index 0000000..365b6fd --- /dev/null +++ b/apps/News/NewsRecords.h @@ -0,0 +1,21 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include + +/** 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 diff --git a/apps/News/NewsSeed.cpp b/apps/News/NewsSeed.cpp new file mode 100644 index 0000000..b57ddca --- /dev/null +++ b/apps/News/NewsSeed.cpp @@ -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 + +#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 diff --git a/apps/News/NewsSeed.h b/apps/News/NewsSeed.h new file mode 100644 index 0000000..a9566b1 --- /dev/null +++ b/apps/News/NewsSeed.h @@ -0,0 +1,7 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS && LOBBS_SEED + +class LoBBSModule; +void lobbsSeedNews(LoBBSModule &mod); + +#endif diff --git a/apps/Status/StatusCommands.cpp b/apps/Status/StatusCommands.cpp new file mode 100644 index 0000000..994d855 --- /dev/null +++ b/apps/Status/StatusCommands.cpp @@ -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 &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 diff --git a/apps/Status/StatusCommands.h b/apps/Status/StatusCommands.h new file mode 100644 index 0000000..1abf749 --- /dev/null +++ b/apps/Status/StatusCommands.h @@ -0,0 +1,6 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +void lobbsStatusRegisterCommands(); + +#endif diff --git a/apps/Time/TimeCommands.cpp b/apps/Time/TimeCommands.cpp new file mode 100644 index 0000000..535cae3 --- /dev/null +++ b/apps/Time/TimeCommands.cpp @@ -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 +#include +#include +#include +#include + +#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 &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 diff --git a/apps/Time/TimeCommands.h b/apps/Time/TimeCommands.h new file mode 100644 index 0000000..3241069 --- /dev/null +++ b/apps/Time/TimeCommands.h @@ -0,0 +1,6 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +void lobbsTimeRegisterCommands(); + +#endif diff --git a/apps/Wall/Wall.cpp b/apps/Wall/Wall.cpp new file mode 100644 index 0000000..7bcc034 --- /dev/null +++ b/apps/Wall/Wall.cpp @@ -0,0 +1,9 @@ +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "Wall.h" + +#include "LoBBSStackGuard.h" + +WallApp::WallApp(LoDb &lodb) : dal_(lodb) {} + +#endif diff --git a/apps/Wall/Wall.h b/apps/Wall/Wall.h new file mode 100644 index 0000000..ccbf6ca --- /dev/null +++ b/apps/Wall/Wall.h @@ -0,0 +1,17 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "WallDal.h" +#include + +class WallApp +{ + public: + explicit WallApp(LoDb &lodb); + WallDal &dal() { return dal_; } + + private: + WallDal dal_; +}; + +#endif diff --git a/apps/Wall/WallCommands.cpp b/apps/Wall/WallCommands.cpp new file mode 100644 index 0000000..0743f90 --- /dev/null +++ b/apps/Wall/WallCommands.cpp @@ -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 +#include +#include +#include + +#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 &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 &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 &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 diff --git a/apps/Wall/WallCommands.h b/apps/Wall/WallCommands.h new file mode 100644 index 0000000..68057e9 --- /dev/null +++ b/apps/Wall/WallCommands.h @@ -0,0 +1,6 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +void lobbsWallRegisterCommands(); + +#endif diff --git a/apps/Wall/WallDal.cpp b/apps/Wall/WallDal.cpp new file mode 100644 index 0000000..a65e10f --- /dev/null +++ b/apps/Wall/WallDal.cpp @@ -0,0 +1,208 @@ +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "WallDal.h" +#include "../AppUtil.h" +#include "WallRecords.h" +#include +#include +#include + +#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 diff --git a/apps/Wall/WallDal.h b/apps/Wall/WallDal.h new file mode 100644 index 0000000..8ec796c --- /dev/null +++ b/apps/Wall/WallDal.h @@ -0,0 +1,41 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include +#include +#include + +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 diff --git a/apps/Wall/WallRecords.h b/apps/Wall/WallRecords.h new file mode 100644 index 0000000..ca234b7 --- /dev/null +++ b/apps/Wall/WallRecords.h @@ -0,0 +1,20 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include + +/** `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 diff --git a/apps/Wall/WallSeed.cpp b/apps/Wall/WallSeed.cpp new file mode 100644 index 0000000..ab36ef1 --- /dev/null +++ b/apps/Wall/WallSeed.cpp @@ -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 diff --git a/apps/Wall/WallSeed.h b/apps/Wall/WallSeed.h new file mode 100644 index 0000000..c361ece --- /dev/null +++ b/apps/Wall/WallSeed.h @@ -0,0 +1,7 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS && LOBBS_SEED + +class LoBBSModule; +void lobbsSeedWall(LoBBSModule &mod); + +#endif diff --git a/apps/Yarn/Yarn.cpp b/apps/Yarn/Yarn.cpp new file mode 100644 index 0000000..fc96827 --- /dev/null +++ b/apps/Yarn/Yarn.cpp @@ -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 diff --git a/apps/Yarn/Yarn.h b/apps/Yarn/Yarn.h new file mode 100644 index 0000000..f1416ae --- /dev/null +++ b/apps/Yarn/Yarn.h @@ -0,0 +1,17 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "YarnDal.h" +#include + +class YarnApp +{ + public: + explicit YarnApp(LoDb &lodb); + YarnDal &dal() { return dal_; } + + private: + YarnDal dal_; +}; + +#endif diff --git a/apps/Yarn/YarnCommands.cpp b/apps/Yarn/YarnCommands.cpp new file mode 100644 index 0000000..7985fc7 --- /dev/null +++ b/apps/Yarn/YarnCommands.cpp @@ -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 +#include +#include +#include + +#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 &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 &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 &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 diff --git a/apps/Yarn/YarnCommands.h b/apps/Yarn/YarnCommands.h new file mode 100644 index 0000000..cd5bef2 --- /dev/null +++ b/apps/Yarn/YarnCommands.h @@ -0,0 +1,6 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +void lobbsYarnRegisterCommands(); + +#endif diff --git a/apps/Yarn/YarnDal.cpp b/apps/Yarn/YarnDal.cpp new file mode 100644 index 0000000..ac485e7 --- /dev/null +++ b/apps/Yarn/YarnDal.cpp @@ -0,0 +1,190 @@ +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "YarnDal.h" +#include "../AppUtil.h" +#include "YarnRecords.h" +#include +#include +#include + +#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 diff --git a/apps/Yarn/YarnDal.h b/apps/Yarn/YarnDal.h new file mode 100644 index 0000000..68689b4 --- /dev/null +++ b/apps/Yarn/YarnDal.h @@ -0,0 +1,39 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include +#include +#include + +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 diff --git a/apps/Yarn/YarnRecords.h b/apps/Yarn/YarnRecords.h new file mode 100644 index 0000000..5cb52a4 --- /dev/null +++ b/apps/Yarn/YarnRecords.h @@ -0,0 +1,19 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include + +/** `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 diff --git a/apps/Yarn/YarnSeed.cpp b/apps/Yarn/YarnSeed.cpp new file mode 100644 index 0000000..db7466f --- /dev/null +++ b/apps/Yarn/YarnSeed.cpp @@ -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 diff --git a/apps/Yarn/YarnSeed.h b/apps/Yarn/YarnSeed.h new file mode 100644 index 0000000..a624020 --- /dev/null +++ b/apps/Yarn/YarnSeed.h @@ -0,0 +1,7 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS && LOBBS_SEED + +class LoBBSModule; +void lobbsSeedYarn(LoBBSModule &mod); + +#endif diff --git a/bin/lobbs_chunkify.py b/bin/lobbs_chunkify.py new file mode 100644 index 0000000..25bd97f --- /dev/null +++ b/bin/lobbs_chunkify.py @@ -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()) diff --git a/docs/lobbs-daily.md b/docs/lobbs-daily.md new file mode 100644 index 0000000..27931b5 --- /dev/null +++ b/docs/lobbs-daily.md @@ -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 | diff --git a/docs/lobbs-internals.md b/docs/lobbs-internals.md new file mode 100644 index 0000000..17e9704 --- /dev/null +++ b/docs/lobbs-internals.md @@ -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 `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 &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 `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 `//…` (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 `//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//` and `home//` 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 `///` (LoBBS: `//lobbs/db/
/`), one `<16 hex uuid>.ls` file per row. Writes go to `.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). diff --git a/docs/lobbs-machine.md b/docs/lobbs-machine.md new file mode 100644 index 0000000..654772d --- /dev/null +++ b/docs/lobbs-machine.md @@ -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 | `ok` newline | LoScalar record lines | +| Success, multiple pages | `ok [n:max]` newline | Slice of the document | +| Error | `` | 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.` diff --git a/docs/lobbs-plugins.md b/docs/lobbs-plugins.md new file mode 100644 index 0000000..3281ee8 --- /dev/null +++ b/docs/lobbs-plugins.md @@ -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`. diff --git a/docs/lobbs-sysop.md b/docs/lobbs-sysop.md new file mode 100644 index 0000000..ea804fc --- /dev/null +++ b/docs/lobbs-sysop.md @@ -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 ` creates the database on that mount and makes `` 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 /.` + +Everything LoBBS stores lives under `//lobbs`: + +| Path | Contents | +| ----------------------------- | ---------------------------------------------------- | +| `//lobbs/install.ls` | Install record. Its presence marks the install | +| `//lobbs/db/` | The database (users, mail, news, wall, yarn, config) | +| `//lobbs/apps//` | Reserved for per-app files | +| `//lobbs/home//` | 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 `) to wipe `//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` `/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 `..tmp`. diff --git a/library.json b/library.json new file mode 100644 index 0000000..a26f521 --- /dev/null +++ b/library.json @@ -0,0 +1,29 @@ +{ + "name": "lobbs", + "version": "2.0.0", + "description": "LoBBS on-firmware BBS for Meshtastic and MeshCore", + "keywords": ["meshtastic", "meshcore", "bbs", "lobbs"], + "authors": [{ "name": "MeshEnvy", "maintainer": true }], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MeshEnvy/lobbs.git" + }, + "frameworks": ["arduino"], + "platforms": "*", + "build": { + "libArchive": false, + "libLDFMode": "deep+", + "srcDir": ".", + "srcFilter": [ + "+<*>", + "-", + "-", + "-", + "-<.cursor/**>", + "-", + "-" + ], + "extraScript": "platforms/meshtastic/extra_script.py" + } +} diff --git a/lodb/LoDB.cpp b/lodb/LoDB.cpp new file mode 100644 index 0000000..1e642ab --- /dev/null +++ b/lodb/LoDB.cpp @@ -0,0 +1,500 @@ +#include +#include +#if __has_include("configuration.h") +#include "configuration.h" +#include "gps/RTC.h" +#undef LODB_LOG_DEBUG +#define LODB_LOG_DEBUG(...) LOG_DEBUG(__VA_ARGS__) +#undef LODB_LOG_INFO +#define LODB_LOG_INFO(...) LOG_INFO(__VA_ARGS__) +#undef LODB_LOG_WARN +#define LODB_LOG_WARN(...) LOG_WARN(__VA_ARGS__) +#undef LODB_LOG_ERROR +#define LODB_LOG_ERROR(...) LOG_ERROR(__VA_ARGS__) +#endif +#include +#include +#include +#include +#include + +#include "LoBBSStackGuard.h" + +static_assert(LODB_FILE_IO_BUFFER_SIZE <= LODB_MAX_RECORD_FILE_BYTES, + "encode buffer cannot exceed max on-disk record (get would reject writes)"); + +static constexpr size_t LODB_RECORD_PATH_BYTES = 192; + +__attribute__((weak)) uint32_t lodb_now_ms(void) +{ + return static_cast(millis()); +} + +__attribute__((weak)) uint32_t lodb_now_unix(void) +{ +#if __has_include("configuration.h") + return getTime(); +#else + return 0; +#endif +} + +static void lodbStampInsert(lodb_uuid_t uuid, LoScalar &record) +{ + const uint32_t now = lodb_now_unix(); + record.setUint64(LODB_F_ID, uuid); + if (!record.has(LODB_F_CREATED)) + record.setUint32(LODB_F_CREATED, now); + if (!record.has(LODB_F_UPDATED)) + record.setUint32(LODB_F_UPDATED, now); +} + +static void lodbStampUpdate(lodb_uuid_t uuid, LoScalar &record, const LoScalar *stored) +{ + const uint32_t now = lodb_now_unix(); + record.setUint64(LODB_F_ID, uuid); + if (record.has(LODB_F_CREATED)) { + // caller value kept + } else if (stored && stored->has(LODB_F_CREATED)) { + uint32_t created = 0; + if (stored->getUint32(LODB_F_CREATED, created)) + record.setUint32(LODB_F_CREATED, created); + else + record.setUint32(LODB_F_CREATED, now); + } else { + record.setUint32(LODB_F_CREATED, now); + } + if (!record.has(LODB_F_UPDATED)) + record.setUint32(LODB_F_UPDATED, now); +} + +void lodb_uuid_to_hex(lodb_uuid_t uuid, char hex_out[17]) +{ + snprintf(hex_out, 17, LODB_UUID_FMT, LODB_UUID_ARGS(uuid)); +} + +lodb_uuid_t lodb_new_uuid(const char *str, uint64_t salt) +{ + char generated_str[32]; + const char *input_str = str; + + if (str == nullptr) { + uint32_t timestamp = lodb_now_ms(); + uint32_t random_val = (uint32_t)random(0x7fffffff) ^ ((uint32_t)random(0x7fffffff) << 1); + snprintf(generated_str, sizeof(generated_str), "%u:%u", timestamp, random_val); + input_str = generated_str; + } + + SHA256 sha256; + uint8_t hash[32]; + + sha256.reset(); + sha256.update(input_str, strlen(input_str)); + + uint8_t salt_bytes[8]; + memcpy(salt_bytes, &salt, 8); + sha256.update(salt_bytes, 8); + + sha256.finalize(hash, 32); + + lodb_uuid_t uuid; + memcpy(&uuid, hash, sizeof(lodb_uuid_t)); + return uuid; +} + +LoDb::LoDb(const char *db_name) : db_name(db_name) +{ + db_path[0] = '\0'; +} + +bool LoDb::mkdirPathSegments(const char *path) +{ + if (!path || path[0] != '/') + return false; + char buf[128]; + size_t len = strlen(path); + if (len >= sizeof(buf)) + return false; + memcpy(buf, path, len + 1); + + for (size_t i = 1; i < len; i++) { + if (buf[i] != '/') + continue; + buf[i] = '\0'; + if (!LoFS::mkdir(buf)) + LODB_LOG_DEBUG("mkdir segment may exist: %s", buf); + buf[i] = '/'; + } + if (!LoFS::mkdir(buf)) + LODB_LOG_DEBUG("Database directory may already exist: %s", buf); + return true; +} + +LoDbError LoDb::open(const char *root) +{ + if (!root || root[0] != '/') + return LODB_ERR_INVALID; + + if (snprintf(db_path, sizeof(db_path), "%s/%s", root, db_name.c_str()) >= (int)sizeof(db_path)) + return LODB_ERR_INVALID; + + if (!mkdirPathSegments(db_path)) + return LODB_ERR_IO; + + for (auto &entry : tables) { + if (snprintf(entry.second.table_path, sizeof(entry.second.table_path), "%s/%s", db_path, entry.first.c_str()) >= + (int)sizeof(entry.second.table_path)) + return LODB_ERR_INVALID; + if (!LoFS::mkdir(entry.second.table_path)) + LODB_LOG_DEBUG("Table directory may already exist: %s", entry.second.table_path); + } + + opened_ = true; + LODB_LOG_INFO("Opened LoDB database: %s", db_path); + return LODB_OK; +} + +LoDb::~LoDb() {} + +/** Calls fn for each `<16 hex>.ls` record in `dir_path`. False when the path is not a directory. */ +static bool lodbForEachRecord(const char *dir_path, const std::function &fn) +{ + File dir = LoFS::open(dir_path, FILE_O_READ); + if (!dir) { + LODB_LOG_DEBUG("Table directory not found: %s", dir_path); + return true; + } + if (!dir.isDirectory()) { + LODB_LOG_ERROR("Table path is not a directory: %s", dir_path); + dir.close(); + return false; + } + while (true) { + File file = dir.openNextFile(); + if (!file) + break; + bool isDir = file.isDirectory(); + std::string path = file.name(); + file.close(); + if (isDir) + continue; + size_t slash = path.rfind('/'); + const char *name = path.c_str() + (slash == std::string::npos ? 0 : slash + 1); + uint32_t high = 0; + uint32_t low = 0; + if (strlen(name) != 19 || strcmp(name + 16, ".ls") != 0 || sscanf(name, "%08x%08x", &high, &low) != 2) { + LODB_LOG_DEBUG("Skipped non-record file: %s", name); + continue; + } + fn(((uint64_t)high << 32) | (uint64_t)low); + } + dir.close(); + return true; +} + +bool LoDb::recordPath(const char *table_name, lodb_uuid_t uuid, char *out, size_t cap) +{ + TableMetadata *table = table_name ? getTable(table_name) : nullptr; + if (!table) + return false; + char uuid_hex[17]; + lodb_uuid_to_hex(uuid, uuid_hex); + return snprintf(out, cap, "%s/%s.ls", table->table_path, uuid_hex) < (int)cap; +} + +LoDbError LoDb::registerTable(const char *table_name) +{ + if (!table_name) { + return LODB_ERR_INVALID; + } + + TableMetadata metadata; + metadata.table_name = table_name; + metadata.table_path[0] = '\0'; + + if (opened_) { + snprintf(metadata.table_path, sizeof(metadata.table_path), "%s/%s", db_path, table_name); + if (!LoFS::mkdir(metadata.table_path)) + LODB_LOG_DEBUG("Table directory may already exist: %s", metadata.table_path); + } + + tables[table_name] = metadata; + LODB_LOG_INFO("Registered table: %s", table_name); + return LODB_OK; +} + +LoDb::TableMetadata *LoDb::getTable(const char *table_name) +{ + auto it = tables.find(table_name); + if (it == tables.end()) { + LODB_LOG_ERROR("Table not registered: %s", table_name); + return nullptr; + } + return &it->second; +} + +/** Writes to `.w` then renames over `path`, so a failed write never truncates the record. */ +static LoDbError writeRecordFile(const char *file_path, const LoScalar &record) +{ + std::string line; + if (!record.encode(line, LODB_FILE_IO_BUFFER_SIZE)) { + LODB_LOG_ERROR("Failed to encode LoScalar record"); + return LODB_ERR_ENCODE; + } + + char tmp_path[LODB_RECORD_PATH_BYTES + 2]; + if (snprintf(tmp_path, sizeof(tmp_path), "%s.w", file_path) >= (int)sizeof(tmp_path)) { + LODB_LOG_ERROR("Temp path too long: %s", file_path); + return LODB_ERR_IO; + } + LoFS::remove(tmp_path); + + size_t encoded_size = line.size(); + if (!LoFS::hasRoom(tmp_path, (uint32_t)encoded_size)) { + LODB_LOG_WARN("No room for record: %s", file_path); + return LODB_ERR_FULL; + } + + auto file = LoFS::open(tmp_path, FILE_O_WRITE); + if (!file) { + LODB_LOG_ERROR("Failed to open file for writing: %s", tmp_path); + return LODB_ERR_IO; + } + + size_t written = file.write((const uint8_t *)line.data(), encoded_size); + file.flush(); + file.close(); + if (written != encoded_size) { + LODB_LOG_ERROR("Failed to write file, wrote %u of %u bytes", (unsigned)written, (unsigned)encoded_size); + LoFS::remove(tmp_path); + return LoFS::hasRoom(tmp_path, (uint32_t)encoded_size) ? LODB_ERR_IO : LODB_ERR_FULL; + } + + if (!LoFS::rename(tmp_path, file_path)) { + LODB_LOG_ERROR("Failed to rename temp to: %s", file_path); + LoFS::remove(tmp_path); + return LODB_ERR_IO; + } + + LODB_LOG_DEBUG("Wrote record to: %s (%u bytes)", file_path, (unsigned)encoded_size); + return LODB_OK; +} + +static LoDbError writeUpdatedRecord(const char *file_path, lodb_uuid_t uuid, const LoScalar &record, const LoScalar &stored) +{ + LoScalar stamped = record; + lodbStampUpdate(uuid, stamped, &stored); + LoDbError err = writeRecordFile(file_path, stamped); + if (err == LODB_OK) + LODB_LOG_INFO("Updated record: " LODB_UUID_FMT, LODB_UUID_ARGS(uuid)); + return err; +} + +LoDbError LoDb::insert(const char *table_name, lodb_uuid_t uuid, const LoScalar &record) +{ + if (!opened_) + return LODB_ERR_IO; + char file_path[LODB_RECORD_PATH_BYTES]; + if (!recordPath(table_name, uuid, file_path, sizeof(file_path))) + return LODB_ERR_INVALID; + + { + auto existing = LoFS::open(file_path, FILE_O_READ); + if (existing) { + size_t existingSize = existing.size(); + existing.close(); + if (existingSize == 0) { + LoFS::remove(file_path); + } else { + LODB_LOG_ERROR("UUID already exists: " LODB_UUID_FMT, LODB_UUID_ARGS(uuid)); + return LODB_ERR_INVALID; + } + } + } + + LoScalar stamped = record; + lodbStampInsert(uuid, stamped); + LoDbError err = writeRecordFile(file_path, stamped); + if (err != LODB_OK) + return err; + + LODB_LOG_INFO("Inserted record with custom UUID: " LODB_UUID_FMT, LODB_UUID_ARGS(uuid)); + return LODB_OK; +} + +LoDbError LoDb::get(const char *table_name, lodb_uuid_t uuid, LoScalar &record_out) +{ + if (!opened_) + return LODB_ERR_IO; + char file_path[LODB_RECORD_PATH_BYTES]; + if (!recordPath(table_name, uuid, file_path, sizeof(file_path))) + return LODB_ERR_INVALID; + + auto file = LoFS::open(file_path, FILE_O_READ); + if (!file) { + LODB_LOG_DEBUG("Record not found: " LODB_UUID_FMT, LODB_UUID_ARGS(uuid)); + return LODB_ERR_NOT_FOUND; + } + + size_t total_size = file.size(); + if (total_size == 0) { + file.close(); + LoFS::remove(file_path); + LODB_LOG_WARN("Removed empty record file: " LODB_UUID_FMT, LODB_UUID_ARGS(uuid)); + return LODB_ERR_NOT_FOUND; + } + if (total_size > LODB_MAX_RECORD_FILE_BYTES) { + LODB_LOG_ERROR("Record file too large: %s (%u > %u)", file_path, (unsigned)total_size, + (unsigned)LODB_MAX_RECORD_FILE_BYTES); + file.close(); + return LODB_ERR_IO; + } + + std::unique_ptr buffer(new (std::nothrow) char[total_size + 1]); + if (!buffer) { + LODB_LOG_ERROR("LoDB get: buffer alloc failed (%u bytes)", (unsigned)total_size); + file.close(); + return LODB_ERR_IO; + } + + size_t file_size = file.read((uint8_t *)buffer.get(), total_size); + file.close(); + + if (file_size == 0 || file_size != total_size) { + LODB_LOG_ERROR("Record read bad size: %s (%u of %u)", file_path, (unsigned)file_size, (unsigned)total_size); + return LODB_ERR_IO; + } + + buffer[file_size] = '\0'; + while (file_size > 0 && (buffer[file_size - 1] == '\n' || buffer[file_size - 1] == '\r')) + file_size--; + + record_out.clear(); + if (!record_out.decode(buffer.get(), file_size)) { + LODB_LOG_ERROR("Failed to decode LoScalar from " LODB_UUID_FMT, LODB_UUID_ARGS(uuid)); + return LODB_ERR_DECODE; + } + + LODB_LOG_DEBUG("Retrieved record: " LODB_UUID_FMT, LODB_UUID_ARGS(uuid)); + return LODB_OK; +} + +LoDbError LoDb::update(const char *table_name, lodb_uuid_t uuid, const LoScalar &record) +{ + char file_path[LODB_RECORD_PATH_BYTES]; + if (!recordPath(table_name, uuid, file_path, sizeof(file_path))) + return LODB_ERR_INVALID; + LoScalar stored; + LoDbError err = get(table_name, uuid, stored); + if (err != LODB_OK) + return err; + return writeUpdatedRecord(file_path, uuid, record, stored); +} + +LoDbError LoDb::upsert(const char *table_name, lodb_uuid_t uuid, const LoScalar &record) +{ + char file_path[LODB_RECORD_PATH_BYTES]; + if (!recordPath(table_name, uuid, file_path, sizeof(file_path))) + return LODB_ERR_INVALID; + LoScalar stored; + LoDbError err = get(table_name, uuid, stored); + if (err == LODB_OK) + return writeUpdatedRecord(file_path, uuid, record, stored); + if (err != LODB_ERR_NOT_FOUND) + return err; + return insert(table_name, uuid, record); +} + +LoDbError LoDb::deleteRecord(const char *table_name, lodb_uuid_t uuid) +{ + if (!opened_) + return LODB_ERR_IO; + char file_path[LODB_RECORD_PATH_BYTES]; + if (!recordPath(table_name, uuid, file_path, sizeof(file_path))) + return LODB_ERR_INVALID; + + if (!LoFS::exists(file_path)) + return LODB_ERR_NOT_FOUND; + if (LoFS::remove(file_path)) { + LODB_LOG_DEBUG("Deleted record: " LODB_UUID_FMT, LODB_UUID_ARGS(uuid)); + return LODB_OK; + } + LODB_LOG_WARN("Failed to delete record: " LODB_UUID_FMT, LODB_UUID_ARGS(uuid)); + return LODB_ERR_IO; +} + +std::vector LoDb::select(const char *table_name, LoDbFilter filter, LoDbComparator comparator, size_t limit) +{ + std::vector results; + if (!opened_) + return results; + + if (!table_name) { + LODB_LOG_ERROR("Invalid table_name"); + return results; + } + + TableMetadata *table = getTable(table_name); + if (!table) { + LODB_LOG_ERROR("Table not found: %s", table_name); + return results; + } + + lodbForEachRecord(table->table_path, [&](lodb_uuid_t uuid) { + LoScalar record; + if (get(table_name, uuid, record) != LODB_OK) { + LODB_LOG_WARN("Failed to read record " LODB_UUID_FMT " during select", LODB_UUID_ARGS(uuid)); + return; + } + if (filter && !filter(record)) + return; + results.push_back(record); + }); + + LODB_LOG_INFO("Select from %s: %u records after filtering", table_name, (unsigned)results.size()); + + if (comparator && !results.empty()) { + std::sort(results.begin(), results.end(), + [&comparator](const LoScalar &a, const LoScalar &b) { return comparator(a, b) < 0; }); + LODB_LOG_DEBUG("Sorted %u records", (unsigned)results.size()); + } + + if (limit > 0 && results.size() > limit) { + results.resize(limit); + LODB_LOG_DEBUG("Limited results to %u records", (unsigned)limit); + } + + LODB_LOG_INFO("Select from %s complete: %u records returned", table_name, (unsigned)results.size()); + return results; +} + +int LoDb::count(const char *table_name, LoDbFilter filter) +{ + if (!opened_) + return 0; + if (!table_name) { + LODB_LOG_ERROR("Invalid table_name"); + return -1; + } + + TableMetadata *table = getTable(table_name); + if (!table) { + LODB_LOG_ERROR("Table not found: %s", table_name); + return -1; + } + + int cnt = 0; + + if (!filter) { + if (!lodbForEachRecord(table->table_path, [&](lodb_uuid_t) { cnt++; })) + return -1; + LODB_LOG_DEBUG("Counted %d records in %s (no filter)", cnt, table_name); + return cnt; + } + + auto results = select(table_name, filter, LoDbComparator(), 0); + cnt = (int)results.size(); + + LODB_LOG_DEBUG("Counted %d records in %s (with filter)", cnt, table_name); + return cnt; +} diff --git a/lodb/LoDB.h b/lodb/LoDB.h new file mode 100644 index 0000000..5a49461 --- /dev/null +++ b/lodb/LoDB.h @@ -0,0 +1,119 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * LoDB - Synchronous LoScalar Database + * + * Filesystem-backed records under `/` after `open(root)`. I/O goes through LoFS. + */ + +#ifndef LODB_VERSION +#define LODB_VERSION "2.0.0" +#endif + +/** + * Max encoded record size (single knob): insert/update encode buffer and get() read cap. + */ +#ifndef LODB_MAX_RECORD_BYTES +#define LODB_MAX_RECORD_BYTES 1024 +#endif +#ifndef LODB_FILE_IO_BUFFER_SIZE +#define LODB_FILE_IO_BUFFER_SIZE LODB_MAX_RECORD_BYTES +#endif +#ifndef LODB_MAX_RECORD_FILE_BYTES +#define LODB_MAX_RECORD_FILE_BYTES LODB_MAX_RECORD_BYTES +#endif + +#ifndef LODB_LOG_DEBUG +#define LODB_LOG_DEBUG(...) ((void)0) +#endif +#ifndef LODB_LOG_INFO +#define LODB_LOG_INFO(...) ((void)0) +#endif +#ifndef LODB_LOG_WARN +#define LODB_LOG_WARN(...) ((void)0) +#endif +#ifndef LODB_LOG_ERROR +#define LODB_LOG_ERROR(...) ((void)0) +#endif + +typedef uint64_t lodb_uuid_t; + +#define LODB_UUID_FMT "%08x%08x" +#define LODB_UUID_ARGS(uuid) (uint32_t)((uuid) >> 32), (uint32_t)((uuid)&0xFFFFFFFF) + +typedef enum { + LODB_OK = 0, + LODB_ERR_NOT_FOUND, + LODB_ERR_IO, + LODB_ERR_DECODE, + LODB_ERR_ENCODE, + LODB_ERR_INVALID, + /** Write refused or cut short: the mount lacks room above its reserve (`LoFS::hasRoom`). */ + LODB_ERR_FULL +} LoDbError; + +typedef std::function LoDbFilter; +typedef std::function LoDbComparator; + +/** LoScalar field numbers are 0..99 inclusive. */ +static constexpr uint32_t LODB_F_MAX = 99; +/** App fields use 0 .. LODB_F_USER_LIMIT - 1. */ +static constexpr uint32_t LODB_F_USER_LIMIT = 94; + +static constexpr uint32_t LODB_F_ERROR = 94; +static constexpr uint32_t LODB_F_UPDATED = 95; +static constexpr uint32_t LODB_F_CREATED = 96; +static constexpr uint32_t LODB_F_DESCRIPTION = 97; +static constexpr uint32_t LODB_F_TITLE = 98; +static constexpr uint32_t LODB_F_ID = 99; + +void lodb_uuid_to_hex(lodb_uuid_t uuid, char hex_out[17]); +lodb_uuid_t lodb_new_uuid(const char *str, uint64_t salt); + +/** Weak by default (`millis()`); override with a strong definition for wall time. */ +uint32_t lodb_now_ms(void); +/** Unix seconds from getTime() when RTC is linked; else 0. */ +uint32_t lodb_now_unix(void); + +class LoDb +{ + public: + LoDb(const char *db_name); + ~LoDb(); + + bool isOpen() const { return opened_; } + LoDbError open(const char *root); + LoDbError registerTable(const char *table_name); + LoDbError insert(const char *table_name, lodb_uuid_t uuid, const LoScalar &record); + LoDbError get(const char *table_name, lodb_uuid_t uuid, LoScalar &record_out); + LoDbError update(const char *table_name, lodb_uuid_t uuid, const LoScalar &record); + LoDbError upsert(const char *table_name, lodb_uuid_t uuid, const LoScalar &record); + LoDbError deleteRecord(const char *table_name, lodb_uuid_t uuid); + std::vector select(const char *table_name, LoDbFilter filter = LoDbFilter(), + LoDbComparator comparator = LoDbComparator(), size_t limit = 0); + int count(const char *table_name, LoDbFilter filter = LoDbFilter()); + + private: + struct TableMetadata { + std::string table_name; + char table_path[160]; + }; + + std::string db_name; + char db_path[128]; + bool opened_ = false; + std::map tables; + + TableMetadata *getTable(const char *table_name); + bool recordPath(const char *table_name, lodb_uuid_t uuid, char *out, size_t cap); + static bool mkdirPathSegments(const char *path); +}; diff --git a/lofs/Glob.cpp b/lofs/Glob.cpp new file mode 100644 index 0000000..8b69cd1 --- /dev/null +++ b/lofs/Glob.cpp @@ -0,0 +1,110 @@ +#include +#include +#include +#include + +bool lobfsGlobHasMeta(const char *s) +{ + for (; s && *s; s++) { + if (*s == '*' || *s == '?') + return true; + } + return false; +} + +bool lobfsGlobMatch(const char *pat, const char *str) +{ + if (!*pat) + return !*str; + if (*pat == '*') { + if (!pat[1]) + return true; + for (; *str; str++) { + if (lobfsGlobMatch(pat + 1, str)) + return true; + } + return lobfsGlobMatch(pat + 1, str); + } + if (*pat == '?') { + if (!*str) + return false; + return lobfsGlobMatch(pat + 1, str + 1); + } + if (*pat != *str) + return false; + return lobfsGlobMatch(pat + 1, str + 1); +} + +bool lobfsGlobBeforeLastName(const char *path) +{ + if (!path) + return false; + const char *lastSlash = strrchr(path, '/'); + const char *end = lastSlash ? lastSlash : path; + for (const char *p = path; p < end; p++) { + if (*p == '*' || *p == '?') + return true; + } + return false; +} + +bool lobfsGlobLastComponentHasMeta(const char *path) +{ + if (!path) + return false; + const char *lastSlash = strrchr(path, '/'); + const char *last = lastSlash ? lastSlash + 1 : path; + return lobfsGlobHasMeta(last); +} + +struct LobfsGlobOneFile { + const char *pattern; + const char *dirPath; + char *out; + size_t outCap; + int count; +}; + +static bool lobfsGlobOneFileCb(void *ctx, const char *basename, bool isDirectory, uint32_t size) +{ + (void)size; + if (isDirectory) + return true; + auto *mc = (LobfsGlobOneFile *)ctx; + if (!lobfsGlobMatch(mc->pattern, basename)) + return true; + if (mc->count == 0) + snprintf(mc->out, mc->outCap, "%s/%s", mc->dirPath, basename); + mc->count++; + return true; +} + +LobfsGlobResolve lobfsGlobResolveOneFile(char *path, char *out, size_t outCap) +{ + if (lobfsGlobBeforeLastName(path)) + return LobfsGlobResolve::GlobBeforeLast; + + if (!lobfsGlobLastComponentHasMeta(path)) { + strncpy(out, path, outCap - 1); + out[outCap - 1] = '\0'; + return LobfsGlobResolve::Ok; + } + + char *lastSlash = strrchr(path, '/'); + LobfsGlobOneFile m{}; + m.pattern = lastSlash + 1; + m.dirPath = lastSlash == path ? "/" : path; + m.out = out; + m.outCap = outCap; + *lastSlash = '\0'; + bool listed = LoFS::list(m.dirPath, &m, lobfsGlobOneFileCb); + *lastSlash = '/'; + + if (!listed) + return LobfsGlobResolve::NoDir; + if (m.count == 0) + return LobfsGlobResolve::NoMatch; + if (m.count > 1) + return LobfsGlobResolve::ManyMatches; + return LobfsGlobResolve::Ok; +} diff --git a/lofs/Glob.h b/lofs/Glob.h new file mode 100644 index 0000000..284cdb2 --- /dev/null +++ b/lofs/Glob.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +/** Path-only glob helpers for absolute LoFS paths (no session cwd). */ + +bool lobfsGlobHasMeta(const char *s); +bool lobfsGlobMatch(const char *pat, const char *str); +bool lobfsGlobBeforeLastName(const char *path); +bool lobfsGlobLastComponentHasMeta(const char *path); + +enum class LobfsGlobResolve : uint8_t { + Ok, + GlobBeforeLast, + NoDir, + NoMatch, + ManyMatches, +}; + +/** Optional glob on last path component only; splits `path` in place while listing. */ +LobfsGlobResolve lobfsGlobResolveOneFile(char *path, char *out, size_t outCap); diff --git a/lofs/LoFS.cpp b/lofs/LoFS.cpp new file mode 100644 index 0000000..8acde38 --- /dev/null +++ b/lofs/LoFS.cpp @@ -0,0 +1,976 @@ +#include "../LoBBSBootTrace.h" +#include "SPILock.h" +#include "configuration.h" +#include +#include +#include +#include + +#if LOBBS_EXTRA_QSPI +#if !defined(NRF52840_XXAA) || !defined(PIN_QSPI_SCK) +#error LOBBS_EXTRA_QSPI requires NRF52840_XXAA and PIN_QSPI_SCK +#endif +#include "LoFSQspi.h" +#endif + +#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) +#include +#include + +#ifdef SDCARD_USE_SPI1 +extern SPIClass SPI_HSPI; +#define SDHandler SPI_HSPI +#else +#define SDHandler SPI +#endif + +#ifndef SD_SPI_FREQUENCY +#define SD_SPI_FREQUENCY 4000000U +#endif +#endif + +#if defined(ARCH_NRF52) +#include "flash/flash_nrf5x.h" + +/** Second internal-flash partition bounds from the nRF52840 linker scripts. Weak: other nRF linker scripts get no `/flash2`. */ +extern "C" uint8_t __flash2_start[] __attribute__((weak)); +extern "C" uint8_t __flash2_end[] __attribute__((weak)); + +static constexpr uint32_t LOFS_FLASH2_BLOCK = 128; + +// The flash_nrf5x page cache is shared with InternalFS (which the BLE task also writes), so /flash2 block IO holds its lock. +static int lofsFlash2Read(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, void *buffer, lfs_size_t size) +{ + InternalFS._lockFS(); + int n = flash_nrf5x_read(buffer, (uint32_t)c->context + block * LOFS_FLASH2_BLOCK + off, size); + InternalFS._unlockFS(); + return n > 0 ? 0 : -1; +} + +static int lofsFlash2Prog(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, const void *buffer, lfs_size_t size) +{ + InternalFS._lockFS(); + int n = flash_nrf5x_write((uint32_t)c->context + block * LOFS_FLASH2_BLOCK + off, buffer, size); + InternalFS._unlockFS(); + return n > 0 ? 0 : -1; +} + +static int lofsFlash2Erase(const struct lfs_config *c, lfs_block_t block) +{ + uint8_t ff[LOFS_FLASH2_BLOCK]; + memset(ff, 0xFF, sizeof(ff)); + InternalFS._lockFS(); + int n = flash_nrf5x_write((uint32_t)c->context + block * LOFS_FLASH2_BLOCK, ff, sizeof(ff)); + InternalFS._unlockFS(); + return n > 0 ? 0 : -1; +} + +static int lofsFlash2Sync(const struct lfs_config *c) +{ + (void)c; + InternalFS._lockFS(); + flash_nrf5x_flush(); + InternalFS._unlockFS(); + return 0; +} + +static struct lfs_config lofsFlash2Cfg; +static Adafruit_LittleFS lofsFlash2(&lofsFlash2Cfg); + +static bool lofsFlash2Format() +{ + const uint32_t start = (uint32_t)lofsFlash2Cfg.context; + const uint32_t end = start + lofsFlash2Cfg.block_count * LOFS_FLASH2_BLOCK; + LOG_WARN("LoFS: formatting /flash2 (0x%x-0x%x)", (unsigned)start, (unsigned)end); + LOBBS_BOOT_STEP("flash2: erase partition pages"); + InternalFS._lockFS(); + flash_nrf5x_flush(); + for (uint32_t addr = start; addr < end; addr += FLASH_NRF52_PAGE_SIZE) + flash_nrf5x_erase(addr); + InternalFS._unlockFS(); + LOBBS_BOOT_STEP("flash2: lfs format"); + return lofsFlash2.format() && lofsFlash2.begin(); +} + +static bool lofsFlash2Begin() +{ + LOBBS_BOOT_STEP("flash2: begin enter"); + const uint32_t start = (uint32_t)__flash2_start; + const uint32_t end = (uint32_t)__flash2_end; + if (!start || end <= start) + return false; + lofsFlash2Cfg.context = (void *)start; + lofsFlash2Cfg.read = lofsFlash2Read; + lofsFlash2Cfg.prog = lofsFlash2Prog; + lofsFlash2Cfg.erase = lofsFlash2Erase; + lofsFlash2Cfg.sync = lofsFlash2Sync; + lofsFlash2Cfg.read_size = LOFS_FLASH2_BLOCK; + lofsFlash2Cfg.prog_size = LOFS_FLASH2_BLOCK; + lofsFlash2Cfg.block_size = LOFS_FLASH2_BLOCK; + lofsFlash2Cfg.block_count = (end - start) / LOFS_FLASH2_BLOCK; + lofsFlash2Cfg.lookahead = 128; + LOBBS_BOOT_STEP("flash2: lfs mount"); + if (lofsFlash2.begin()) + return true; + return lofsFlash2Format(); +} + +#if LOBBS_EXTRA_QSPI +static struct lfs_config lofsQspiCfg; +static Adafruit_LittleFS lofsQspi(&lofsQspiCfg); + +static bool lofsQspiFormat() +{ + LOG_WARN("LoFS: formatting /extra (QSPI)"); + LOBBS_BOOT_STEP("extra: lfs format"); + return lofsQspi.format() && lofsQspi.begin(); +} + +static bool lofsQspiBegin() +{ + LOBBS_BOOT_STEP("extra: begin enter"); + LOBBS_BOOT_STEP("extra: qspi config"); + if (!lofsQspiConfig(lofsQspiCfg)) + return false; + LOBBS_BOOT_STEP("extra: lfs mount"); + if (lofsQspi.begin()) + return true; + return lofsQspiFormat(); +} +#endif + +static Adafruit_LittleFS &lofsFs(LoFS::Backend b) +{ + if (b == LoFS::Backend::Flash2) + return lofsFlash2; +#if LOBBS_EXTRA_QSPI + if (b == LoFS::Backend::Extra) + return lofsQspi; +#endif + return FSCom; +} +#else +static auto &lofsFs(bool flash2) +{ + (void)flash2; + return FSCom; +} +#endif + +#include "../apps/AppUtil.h" +#include "LoBBSStackGuard.h" +#include + +LoFS::Mount LoFS::mounts[4]; +int LoFS::mountCount = 0; +bool LoFS::begun = false; + +static bool lobfsSdPresent() +{ +#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) + uint8_t cardType = SD.cardType(); + if (cardType == CARD_NONE) { + concurrency::LockGuard g(spiLock); + SDHandler.begin(SPI_SCK, SPI_MISO, SPI_MOSI); + if (SD.begin(SDCARD_CS, SDHandler, SD_SPI_FREQUENCY)) + cardType = SD.cardType(); + } + return cardType != CARD_NONE; +#else + return false; +#endif +} + +#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) +#if defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO) +static const char *convertToSDMode(const char *modeStr) +{ + return modeStr; +} +static const char *convertToSDMode(uint8_t mode) +{ + return (mode == 0) ? "r" : "w"; +} +#else +static uint8_t convertToSDMode(const char *modeStr) +{ + return (strcmp(modeStr, "r") == 0) ? FILE_READ : FILE_WRITE; +} +static uint8_t convertToSDMode(uint8_t mode) +{ + return (mode == 0) ? FILE_READ : FILE_WRITE; +} +#endif +#endif + +void LoFS::begin() +{ + if (begun) + return; + mountCount = 0; + bool hasFlash2 = false; + + // Registration order is install preference order. +#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) + LOBBS_BOOT_STEP("LoFS: sd present check"); + if (lobfsSdPresent()) + mounts[mountCount++] = Mount{"sd", Backend::Sd, true, false, true}; +#endif + +#if LOBBS_EXTRA_QSPI + LOBBS_BOOT_STEP("LoFS: lofsQspiBegin"); + if (lofsQspiBegin()) + mounts[mountCount++] = Mount{"extra", Backend::Extra, true, false, true}; +#endif + +#if defined(ARCH_NRF52) + LOBBS_BOOT_STEP("LoFS: lofsFlash2Begin"); + if (lofsFlash2Begin()) { + mounts[mountCount++] = Mount{"flash2", Backend::Flash2, true, false, true}; + hasFlash2 = true; + } +#endif + + LOBBS_BOOT_STEP("LoFS: register flash mount"); + mounts[mountCount++] = Mount{"flash", Backend::Flash, true, true, !hasFlash2}; + begun = true; + LOBBS_BOOT_STEP("LoFS: begin done"); +} + +LoFS::Mount *LoFS::findByName(const char *name, size_t len) +{ + for (int i = 0; i < mountCount; i++) { + if (strncmp(mounts[i].name, name, len) == 0 && mounts[i].name[len] == '\0' && mounts[i].present) + return &mounts[i]; + } + return nullptr; +} + +bool LoFS::mountPresent(const char *name) +{ + return findByName(name, strlen(name)) != nullptr; +} + +bool LoFS::mountDbSafe(const char *name) +{ + Mount *m = name ? findByName(name, strlen(name)) : nullptr; + return m && m->dbSafe; +} + +bool LoFS::format(const char *name) +{ + Mount *m = name ? findByName(name, strlen(name)) : nullptr; + if (!m) + return false; +#if defined(ARCH_NRF52) + if (m->backend == Backend::Flash2) + return lofsFlash2Format(); +#endif +#if LOBBS_EXTRA_QSPI + if (m->backend == Backend::Extra) + return lofsQspiFormat(); +#endif +#if defined(ARCH_NRF52) || defined(ARCH_ESP32) || defined(ARCH_RP2040) + if (m->backend == Backend::Flash) { + concurrency::LockGuard g(spiLock); + return FSCom.format(); + } +#endif + return false; +} + +void LoFS::eachPresentMount(void (*fn)(void *ctx, const char *name), void *ctx) +{ + if (!fn) + return; + for (int i = 0; i < mountCount; i++) { + if (mounts[i].present) + fn(ctx, mounts[i].name); + } +} + +bool LoFS::resolve(const char *filepath, Resolved &out) +{ + out = {}; + if (!filepath || filepath[0] != '/') + return false; + + if (filepath[1] == '\0') { + out.kind = PathKind::VirtualRoot; + return true; + } + + const char *p = filepath + 1; + const char *slash = strchr(p, '/'); + size_t nameLen = slash ? (size_t)(slash - p) : strlen(p); + if (nameLen == 0 || nameLen >= 16) + return false; + + Mount *m = findByName(p, nameLen); + if (!m) + return false; + + out.backend = m->backend; + if (!slash || slash[1] == '\0') { + out.kind = PathKind::MountRoot; + out.rel = "/"; + return true; + } + + out.rel = slash; + out.kind = PathKind::Normal; + return true; +} + +const char *LoFS::backendPath(const Resolved &r) +{ + return r.backend == Backend::Sd ? r.rel + 1 : r.rel; +} + +bool LoFS::isMountPoint(const char *path) +{ + if (!path) + return false; + if (strcmp(path, "/") == 0) + return true; + Resolved r; + if (!resolve(path, r)) + return false; + return r.kind == PathKind::MountRoot; +} + +const char *LoFS::mountNameForPath(const char *path) +{ + if (!path || path[0] != '/') + return nullptr; + if (path[1] == '\0') + return nullptr; + const char *p = path + 1; + const char *slash = strchr(p, '/'); + size_t nameLen = slash ? (size_t)(slash - p) : strlen(p); + Mount *m = findByName(p, nameLen); + return m ? m->name : nullptr; +} + +bool LoFS::isDirectory(const char *path) +{ + Resolved r; + if (!resolve(path, r)) + return false; + if (r.kind == PathKind::VirtualRoot || r.kind == PathKind::MountRoot) + return true; + + const char *bp = backendPath(r); + + concurrency::LockGuard g(spiLock); +#if defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO) + File f; +#else + File f(FSCom); +#endif +#if defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO) + if (r.backend == Backend::Sd) { +#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) + f = SD.open(bp, FILE_O_READ); +#endif + } else { + f = lofsFs(r.backend == Backend::Flash2).open(bp, FILE_O_READ); + } +#else + if (r.backend == Backend::Sd) { +#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) + f = SD.open(bp, FILE_O_READ); +#endif + } else { + f = lofsFs(r.backend).open(bp, FILE_O_READ); + } +#endif + if (!f) + return false; + bool isDir = f.isDirectory(); + f.close(); + return isDir; +} + +bool LoFS::refuseMountPointMutation(const char *filepath) +{ + return isMountPoint(filepath); +} + +File LoFS::open(const char *filepath, uint8_t mode) +{ +#if defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO) + const char *modeStr = (mode == 0) ? FILE_O_READ : FILE_O_WRITE; + return open(filepath, modeStr); +#else + Resolved r; + if (!resolve(filepath, r) || r.kind == PathKind::VirtualRoot) { + return File(FSCom); + } + + const char *bp = backendPath(r); + + concurrency::LockGuard g(spiLock); + if (r.backend == Backend::Sd) { +#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) + return SD.open(bp, convertToSDMode(mode)); +#endif + } + return lofsFs(r.backend).open(bp, mode); +#endif +} + +File LoFS::open(const char *filepath, const char *mode) +{ + Resolved r; + if (!resolve(filepath, r) || r.kind == PathKind::VirtualRoot) { +#if defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO) + return File(); +#else + return File(FSCom); +#endif + } + + const char *bp = backendPath(r); + + concurrency::LockGuard g(spiLock); + if (r.backend == Backend::Sd) { +#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) +#if defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO) + return SD.open(bp, mode); +#else + return SD.open(bp, convertToSDMode(mode)); +#endif +#endif + } +#if defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO) + return lofsFs(r.backend == Backend::Flash2).open(bp, mode); +#else + uint8_t flashMode = (mode && strcmp(mode, "r") == 0) ? 0 : 1; + return lofsFs(r.backend).open(bp, flashMode); +#endif +} + +bool LoFS::exists(const char *filepath) +{ + Resolved r; + if (!resolve(filepath, r)) + return false; + if (r.kind == PathKind::VirtualRoot) + return true; + if (r.kind == PathKind::MountRoot) + return true; + + const char *bp = backendPath(r); + concurrency::LockGuard g(spiLock); + if (r.backend == Backend::Sd) { +#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) + return SD.exists(bp); +#endif + } +#if defined(ARCH_NRF52) + return lofsFs(r.backend).exists(bp); +#else + return lofsFs(r.backend == Backend::Flash2).exists(bp); +#endif +} + +bool LoFS::mkdir(const char *filepath) +{ + if (refuseMountPointMutation(filepath)) + return false; + + Resolved r; + if (!resolve(filepath, r) || r.kind == PathKind::VirtualRoot) + return false; + + const char *bp = backendPath(r); + + concurrency::LockGuard g(spiLock); + if (r.backend == Backend::Sd) { +#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) + return SD.mkdir(bp); +#endif + } +#if defined(ARCH_NRF52) + return lofsFs(r.backend).mkdir(bp); +#else + return lofsFs(r.backend == Backend::Flash2).mkdir(bp); +#endif +} + +bool LoFS::remove(const char *filepath) +{ + if (refuseMountPointMutation(filepath)) + return false; + + Resolved r; + if (!resolve(filepath, r) || r.kind == PathKind::VirtualRoot || r.kind == PathKind::MountRoot) + return false; + + const char *bp = backendPath(r); + + concurrency::LockGuard g(spiLock); + if (r.backend == Backend::Sd) { +#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) + return SD.remove(bp); +#endif + } +#if defined(ARCH_NRF52) + return lofsFs(r.backend).remove(bp); +#else + return lofsFs(r.backend == Backend::Flash2).remove(bp); +#endif +} + +bool LoFS::rename(const char *oldfilepath, const char *newfilepath) +{ + if (refuseMountPointMutation(oldfilepath) || refuseMountPointMutation(newfilepath)) + return false; + + Resolved oldR; + Resolved newR; + if (!resolve(oldfilepath, oldR) || !resolve(newfilepath, newR)) + return false; + if (oldR.kind == PathKind::VirtualRoot || newR.kind == PathKind::VirtualRoot) + return false; + if (oldR.backend != newR.backend) + return false; + + const char *oldBp = backendPath(oldR); + const char *newBp = backendPath(newR); + + concurrency::LockGuard g(spiLock); + if (oldR.backend == Backend::Sd) { +#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) + return SD.rename(oldBp, newBp); +#endif + } +#if defined(ARCH_NRF52) + return lofsFs(oldR.backend).rename(oldBp, newBp); +#else + return lofsFs(oldR.backend == Backend::Flash2).rename(oldBp, newBp); +#endif +} + +static bool lobfsEachDirEntry(File &dir, void *ctx, LoFS::ListCallback fn) +{ + while (true) { + File file = dir.openNextFile(); + if (!file) + break; + + std::string pathFromFile = file.name(); + bool isDir = file.isDirectory(); + uint32_t size = isDir ? 0 : (uint32_t)file.size(); + file.close(); + + size_t lastSlash = pathFromFile.rfind('/'); + std::string entryName = (lastSlash != std::string::npos) ? pathFromFile.substr(lastSlash + 1) : pathFromFile; + + if (entryName == "." || entryName == "..") + continue; + + if (!fn(ctx, entryName.c_str(), isDir, size)) + return false; + } + return true; +} + +bool LoFS::list(const char *dirpath, void *ctx, ListCallback fn) +{ + if (!dirpath || !fn) + return false; + + Resolved r; + if (!resolve(dirpath, r)) + return false; + + if (r.kind == PathKind::VirtualRoot) { + for (int i = 0; i < mountCount; i++) { + if (!mounts[i].present) + continue; + if (!fn(ctx, mounts[i].name, true, 0)) + return false; + } + return true; + } + + File dir = open(dirpath, FILE_O_READ); + if (!dir) + return false; + if (!dir.isDirectory()) { + dir.close(); + return false; + } + + bool ok = lobfsEachDirEntry(dir, ctx, fn); + dir.close(); + return ok; +} + +bool LoFS::stat(const char *filepath, uint32_t *sizeOut, bool *isDirOut) +{ + if (sizeOut) + *sizeOut = 0; + if (isDirOut) + *isDirOut = false; + + Resolved r; + if (!resolve(filepath, r)) + return false; + if (r.kind == PathKind::VirtualRoot || r.kind == PathKind::MountRoot) { + if (isDirOut) + *isDirOut = true; + return true; + } + + File f = open(filepath, FILE_O_READ); + if (!f) + return false; + if (isDirOut) + *isDirOut = f.isDirectory(); + if (sizeOut && !f.isDirectory()) + *sizeOut = (uint32_t)f.size(); + f.close(); + return true; +} + +bool LoFS::writeAt(const char *filepath, uint32_t offset, const uint8_t *data, size_t len) +{ + if (!filepath || !data || len == 0) + return false; + if (refuseMountPointMutation(filepath)) + return false; + + Resolved r; + if (!resolve(filepath, r) || r.kind != PathKind::Normal) + return false; + + const bool creating = !exists(filepath); +#if defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO) + File f = open(filepath, creating ? "w" : "r+"); +#else + (void)creating; + File f = open(filepath, (uint8_t)1); +#endif + if (!f) + return false; + if (f.isDirectory()) { + f.close(); + return false; + } + + bool ok = false; + { + concurrency::LockGuard g(spiLock); + if (f.seek(offset)) { + size_t w = f.write(data, len); + ok = (w == len); + } + f.flush(); + f.close(); + } + return ok; +} + +bool LoFS::copy(const char *src, const char *dst) +{ + if (refuseMountPointMutation(src) || refuseMountPointMutation(dst)) + return false; + if (exists(dst)) + return false; + + Resolved srcR; + Resolved dstR; + if (!resolve(src, srcR) || !resolve(dst, dstR)) + return false; + if (srcR.kind == PathKind::VirtualRoot || srcR.kind == PathKind::MountRoot) + return false; + if (dstR.kind == PathKind::VirtualRoot || dstR.kind == PathKind::MountRoot) + return false; + + File srcFile = open(src, FILE_O_READ); + if (!srcFile) + return false; + if (srcFile.isDirectory()) { + srcFile.close(); + return false; + } + + File dstFile = open(dst, FILE_O_WRITE); + if (!dstFile) { + srcFile.close(); + return false; + } + + unsigned char buffer[128]; + bool ok = true; + while (true) { + size_t n = 0; + { + concurrency::LockGuard g(spiLock); + n = srcFile.read(buffer, sizeof(buffer)); + } + if (n == 0) + break; + size_t w = 0; + { + concurrency::LockGuard g(spiLock); + w = dstFile.write(buffer, n); + } + if (w != n) { + ok = false; + break; + } + } + + { + concurrency::LockGuard g(spiLock); + dstFile.flush(); + dstFile.close(); + srcFile.close(); + } + + if (!ok) + remove(dst); + return ok; +} + +LoFSMoveResult LoFS::move(const char *src, const char *dst) +{ + if (!src || !dst) + return LoFSMoveResult::Failed; + if (isMountPoint(src) || isMountPoint(dst)) + return LoFSMoveResult::RefusedMount; + if (exists(dst)) + return LoFSMoveResult::DstExists; + + const char *srcMount = mountNameForPath(src); + const char *dstMount = mountNameForPath(dst); + if (srcMount && dstMount && strcmp(srcMount, dstMount) == 0) { + return rename(src, dst) ? LoFSMoveResult::Ok : LoFSMoveResult::Failed; + } + + bool isDir = false; + uint32_t sz = 0; + if (!stat(src, &sz, &isDir)) + return LoFSMoveResult::SrcMissing; + if (isDir) + return LoFSMoveResult::CrossMountDir; + + if (!hasRoom(dst, sz)) + return LoFSMoveResult::NoSpace; + + if (!copy(src, dst)) + return LoFSMoveResult::CopyFailed; + if (remove(src)) + return LoFSMoveResult::Ok; + return LoFSMoveResult::SrcNotRemoved; +} + +bool LoFS::crc32File(const char *filepath, uint32_t *crcOut) +{ + if (!filepath || !crcOut) + return false; + File f = open(filepath, FILE_O_READ); + if (!f) + return false; + if (f.isDirectory()) { + f.close(); + return false; + } + uint32_t crc = 0xffffffff; + uint8_t chunk[64]; + while (true) { + size_t n = f.read(chunk, sizeof(chunk)); + if (n == 0) + break; + crc = lobbsCrc32Update(crc, chunk, n); + } + f.close(); + *crcOut = ~crc; + return true; +} + +LoFSMoveResult LoFS::moveIfCrc32Matches(const char *src, const char *dst, uint32_t expectedCrc) +{ + uint32_t crc = 0; + if (!crc32File(src, &crc)) + return LoFSMoveResult::SrcMissing; + if (crc != expectedCrc) + return LoFSMoveResult::CrcMismatch; + return move(src, dst); +} + +uint64_t LoFS::totalBytes(const char *mountRoot) +{ + Resolved r; + if (!resolve(mountRoot, r) || r.kind != PathKind::MountRoot) + return 0; + concurrency::LockGuard g(spiLock); + if (r.backend == Backend::Sd) { +#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) + return SD.totalBytes(); +#endif + } +#if defined(ARCH_ESP32) || defined(ARCH_RP2040) + return FSCom.totalBytes(); +#elif defined(ARCH_NRF52) + const lfs_config *cfg = lofsFs(r.backend)._getFS()->cfg; + return cfg ? (uint64_t)cfg->block_size * cfg->block_count : 0; +#else + return 0; +#endif +} + +#if defined(ARCH_NRF52) +static int lofsCountBlock(void *ctx, lfs_block_t block) +{ + (void)block; + (*(uint32_t *)ctx)++; + return 0; +} +#endif + +uint64_t LoFS::usedBytes(const char *mountRoot) +{ + Resolved r; + if (!resolve(mountRoot, r) || r.kind != PathKind::MountRoot) + return 0; + concurrency::LockGuard g(spiLock); + if (r.backend == Backend::Sd) { +#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) + return SD.usedBytes(); +#endif + } +#if defined(ARCH_ESP32) || defined(ARCH_RP2040) + return FSCom.usedBytes(); +#elif defined(ARCH_NRF52) + Adafruit_LittleFS &fs = lofsFs(r.backend); + lfs_t *lfs = fs._getFS(); + uint32_t blocks = 0; + fs._lockFS(); + int err = lfs_traverse(lfs, lofsCountBlock, &blocks); + fs._unlockFS(); + if (err || !lfs->cfg) + return 0; + return (uint64_t)blocks * lfs->cfg->block_size; +#else + return 0; +#endif +} + +uint64_t LoFS::freeBytes(const char *mountRoot) +{ + uint64_t total = totalBytes(mountRoot); + uint64_t used = usedBytes(mountRoot); + if (total == 0 || used > total) + return 0; + return total - used; +} + +uint32_t LoFS::mountReserve(const char *name) +{ + Mount *m = name ? findByName(name, strlen(name)) : nullptr; + return (m && m->shared) ? LOFS_SHARED_RESERVE_BYTES : 0; +} + +bool LoFS::hasRoom(const char *path, uint32_t bytes) +{ + Resolved r; + const char *name = mountNameForPath(path); + if (!name || !resolve(path, r)) + return true; + char root[20]; + snprintf(root, sizeof(root), "/%s", name); + const uint64_t total = totalBytes(root); + if (total == 0) + return true; + const uint64_t used = usedBytes(root); + const uint64_t freeB = used < total ? total - used : 0; + + uint32_t block = 4096; + uint32_t slack = 2 * 4096; + if (r.backend == Backend::Sd) { + block = 512; + slack = 64 * 1024; + } +#if defined(ARCH_NRF52) + if (r.backend == Backend::Flash || r.backend == Backend::Flash2) { + block = 128; + slack = 4 * 128; + } +#endif + const uint64_t need = (((uint64_t)bytes + block - 1) / block) * block + slack + mountReserve(name); + return freeB >= need; +} + +bool LoFS::rmdir(const char *filepath, bool recursive) +{ + if (refuseMountPointMutation(filepath)) + return false; + + if (!exists(filepath)) + return true; + + if (recursive) { + File dir = open(filepath, FILE_O_READ); + if (!dir) + return false; + + if (!dir.isDirectory()) { + dir.close(); + return remove(filepath); + } + + bool result = true; + while (true) { + File file = dir.openNextFile(); + if (!file) + break; + + std::string pathFromFile = file.name(); + bool isDir = file.isDirectory(); + file.close(); + + size_t lastSlash = pathFromFile.rfind('/'); + std::string entryName = (lastSlash != std::string::npos) ? pathFromFile.substr(lastSlash + 1) : pathFromFile; + + if (entryName == "." || entryName == "..") + continue; + + std::string fullPath = std::string(filepath) + "/" + entryName; + + if (isDir) { + if (!rmdir(fullPath.c_str(), true)) + result = false; + } else { + if (!remove(fullPath.c_str())) + result = false; + } + } + dir.close(); + + if (!result) + return false; + } + + Resolved r; + if (!resolve(filepath, r) || r.kind == PathKind::VirtualRoot || r.kind == PathKind::MountRoot) + return false; + + const char *bp = backendPath(r); + + concurrency::LockGuard g(spiLock); + if (r.backend == Backend::Sd) { +#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) + return SD.rmdir(bp); +#endif + } +#if defined(ARCH_NRF52) + return lofsFs(r.backend).rmdir(bp); +#else + return lofsFs(r.backend == Backend::Flash2).rmdir(bp); +#endif +} diff --git a/lofs/LoFS.h b/lofs/LoFS.h new file mode 100644 index 0000000..80d8731 --- /dev/null +++ b/lofs/LoFS.h @@ -0,0 +1,127 @@ +#pragma once + +#define LOFS_VERSION "0.3.0" + +#include "FSCommon.h" +#include "configuration.h" +#include + +#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) +#include +#include +#ifndef FILE_READ +#define FILE_READ O_READ +#endif +#ifndef FILE_WRITE +#define FILE_WRITE O_WRITE +#endif +#endif + +/** Free space LoFS keeps on shared mounts (`/flash`) so Meshtastic can still rewrite its own files. */ +#ifndef LOFS_SHARED_RESERVE_BYTES +#if defined(ARCH_NRF52) +#define LOFS_SHARED_RESERVE_BYTES (16 * 1024) +#elif defined(ARCH_PORTDUINO) +#define LOFS_SHARED_RESERVE_BYTES 0 +#else +#define LOFS_SHARED_RESERVE_BYTES (128 * 1024) +#endif +#endif + +/** + * Unified filesystem with a mount table in install preference order: /sd (when present), /extra (when + * LOBBS_EXTRA_QSPI), /flash2 (nRF52840 second internal-flash partition), /flash. + * "/" is a virtual root that lists mounts. Every other path must start with "//..." or "/". + * Absolute paths only; no glob metacharacters. Upload gap policy lives in FsCommands, not writeAt. + */ +enum class LoFSMoveResult : uint8_t { + Ok, + RefusedMount, + DstExists, + SrcMissing, + SrcIsDir, + CrossMountDir, + NoSpace, + CopyFailed, + SrcNotRemoved, + CrcMismatch, + Failed, +}; + +class LoFS +{ + public: + enum class Backend : uint8_t { Flash = 0, Sd = 1, Extra = 2, Flash2 = 3 }; + + static void begin(); + + static File open(const char *filepath, uint8_t mode); + static File open(const char *filepath, const char *mode); + + static bool exists(const char *filepath); + static bool mkdir(const char *filepath); + static bool remove(const char *filepath); + static bool rename(const char *oldfilepath, const char *newfilepath); + static bool rmdir(const char *filepath, bool recursive = false); + + static bool copy(const char *src, const char *dst); + /** Same-mount rename or cross-mount file copy+delete. */ + static LoFSMoveResult move(const char *src, const char *dst); + static bool crc32File(const char *filepath, uint32_t *crcOut); + static LoFSMoveResult moveIfCrc32Matches(const char *src, const char *dst, uint32_t expectedCrc); + /** Write len bytes at offset; extends file. Does not truncate an existing file. */ + static bool writeAt(const char *filepath, uint32_t offset, const uint8_t *data, size_t len); + static bool stat(const char *filepath, uint32_t *sizeOut, bool *isDirOut); + + static uint64_t totalBytes(const char *mountRoot); + static uint64_t usedBytes(const char *mountRoot); + static uint64_t freeBytes(const char *mountRoot); + /** Bytes LoFS writes leave free on this mount (`LOFS_SHARED_RESERVE_BYTES` on shared mounts, else 0). */ + static uint32_t mountReserve(const char *name); + /** True when writing `bytes` under `path` leaves the mount's reserve free. True when the mount reports no size or + * the path does not resolve (the write itself then fails). */ + static bool hasRoom(const char *path, uint32_t bytes); + /** True when a database may live on this mount. `flash` only qualifies when there is no `flash2` mount. */ + static bool mountDbSafe(const char *name); + /** Erase and reformat a dedicated mount (`flash2`, `extra`). False for shared or SD mounts. */ + static bool format(const char *name); + + static bool isMountPoint(const char *path); + static bool isDirectory(const char *path); + /** Returns mount name ("sd", "extra", "flash2", "flash") or nullptr. */ + static const char *mountNameForPath(const char *path); + + static bool mountPresent(const char *name); + static void eachPresentMount(void (*fn)(void *ctx, const char *name), void *ctx); + + /** `size` is the file length in bytes; 0 for directories and mounts. */ + typedef bool (*ListCallback)(void *ctx, const char *basename, bool isDirectory, uint32_t size); + static bool list(const char *dirpath, void *ctx, ListCallback fn); + + private: + struct Mount { + const char *name; + Backend backend; + bool present; + bool shared; + bool dbSafe; + }; + + static Mount mounts[]; + static int mountCount; + static bool begun; + + enum class PathKind { Invalid, VirtualRoot, MountRoot, Normal }; + + struct Resolved { + PathKind kind = PathKind::Invalid; + Backend backend = Backend::Flash; + /** Points into the caller's path (or a static "/"); valid while that path is. */ + const char *rel = nullptr; + }; + + static bool resolve(const char *filepath, Resolved &out); + static Mount *findByName(const char *name, size_t len); + static bool refuseMountPointMutation(const char *filepath); + static const char *backendPath(const Resolved &r); +}; diff --git a/lofs/LoFSQspi.cpp b/lofs/LoFSQspi.cpp new file mode 100644 index 0000000..73286b2 --- /dev/null +++ b/lofs/LoFSQspi.cpp @@ -0,0 +1,195 @@ +#include "LoFSQspi.h" +#include "configuration.h" + +#if LOBBS_EXTRA_QSPI && defined(ARCH_NRF52) +#include "nrf.h" +#include "variant.h" +#include +#include +#include +#include + +extern const uint32_t g_ADigitalPinMap[]; + +#include "../LoBBSBootTrace.h" +#include "LoBBSStackGuard.h" + +static constexpr uint32_t LOFS_QSPI_BLOCK = 4096; +static constexpr uint32_t LOFS_QSPI_PAGE = 256; +static constexpr uint32_t LOFS_QSPI_BLOCK_COUNT = 512; + +static uint8_t lofsQspiScratch[LOFS_QSPI_PAGE] __attribute__((aligned(4))); +static bool lofsQspiHwReady = false; + +static uint8_t lofsQspiPin(uint32_t dPin) +{ + return (uint8_t)g_ADigitalPinMap[dPin]; +} + +static nrfx_err_t lofsQspiCinstr(uint8_t op, nrf_qspi_cinstr_len_t len, void *rx) +{ + nrf_qspi_cinstr_conf_t c = NRFX_QSPI_DEFAULT_CINSTR(op, len); + // IO2/IO3 are WP#/HOLD# when the quad-enable bit is clear; low HOLD# makes the flash ignore the command. + c.io2_level = true; + c.io3_level = true; + return nrfx_qspi_cinstr_xfer(&c, NULL, rx); +} + +// nrfx_qspi_mem_busy_check() sends RDSR with HOLD# low, so the status byte floats. +static bool lofsQspiWaitReady() +{ + uint8_t sr[4]; + do { + if (lofsQspiCinstr(0x05, NRF_QSPI_CINSTR_LEN_2B, sr) != NRFX_SUCCESS) + return false; + } while (sr[0] & 0x01); + return true; +} + +static bool lofsQspiReadJedec(uint8_t out[3]) +{ + // Flash state survives MCU resets on battery: release deep power-down, then software reset (66h/99h). + for (uint8_t op : {0xAB, 0x66, 0x99}) { + lofsQspiCinstr(op, NRF_QSPI_CINSTR_LEN_1B, NULL); + delayMicroseconds(50); + } + + LOBBS_BOOT_STEP("qspi: jedec cinstr xfer"); + nrfx_err_t err = lofsQspiCinstr(0x9F, NRF_QSPI_CINSTR_LEN_4B, out); + if (err != NRFX_SUCCESS) { + LOG_ERROR("LoFS QSPI JEDEC cinstr err %d", (int)err); + return false; + } + return true; +} + +static bool lofsQspiInitHw() +{ + if (lofsQspiHwReady) + return true; + + LOBBS_BOOT_STEP("qspi: init hw enter"); + +#ifndef NRFX_QSPI_DEFAULT_CONFIG_IRQ_PRIORITY +#define NRFX_QSPI_DEFAULT_CONFIG_IRQ_PRIORITY 6 +#endif + + nrfx_qspi_config_t cfg = + NRFX_QSPI_DEFAULT_CONFIG(lofsQspiPin(PIN_QSPI_SCK), lofsQspiPin(PIN_QSPI_CS), lofsQspiPin(PIN_QSPI_IO0), + lofsQspiPin(PIN_QSPI_IO1), lofsQspiPin(PIN_QSPI_IO2), lofsQspiPin(PIN_QSPI_IO3)); + // FASTREAD default: works without QE bit; READ2IO needs status-register setup on P25Q16. + cfg.phy_if.sck_freq = NRF_QSPI_FREQ_DIV8; + + LOBBS_BOOT_STEP("qspi: nrfx_qspi_init"); + nrfx_err_t err = nrfx_qspi_init(&cfg, NULL, NULL); + if (err == NRFX_ERROR_INVALID_STATE) { + lofsQspiHwReady = true; + } else if (err != NRFX_SUCCESS) { + LOG_ERROR("LoFS QSPI init failed: %d", (int)err); + return false; + } else { + lofsQspiHwReady = true; + } + + uint8_t jedec[3]; + if (!lofsQspiReadJedec(jedec)) { + LOG_ERROR("LoFS QSPI JEDEC read failed"); + nrfx_qspi_uninit(); + lofsQspiHwReady = false; + return false; + } + LOG_INFO("LoFS QSPI JEDEC %02x %02x %02x", jedec[0], jedec[1], jedec[2]); + if (jedec[0] != 0x85) { + LOG_ERROR("LoFS QSPI unexpected manufacturer 0x%02x", jedec[0]); + nrfx_qspi_uninit(); + lofsQspiHwReady = false; + return false; + } + return true; +} + +static int lofsQspiRead(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, void *buffer, lfs_size_t size) +{ + (void)c; + uint32_t addr = block * LOFS_QSPI_BLOCK + off; + if (((uintptr_t)buffer & 3) == 0 && (size & 3) == 0) { + nrfx_err_t err = nrfx_qspi_read(buffer, size, addr); + return err == NRFX_SUCCESS ? 0 : -1; + } + uint8_t *dst = (uint8_t *)buffer; + while (size > 0) { + uint32_t chunk = size > LOFS_QSPI_PAGE ? LOFS_QSPI_PAGE : size; + uint32_t qchunk = (chunk + 3) & ~3u; + nrfx_err_t err = nrfx_qspi_read(lofsQspiScratch, qchunk, addr); + if (err != NRFX_SUCCESS) + return -1; + memcpy(dst, lofsQspiScratch, chunk); + dst += chunk; + addr += chunk; + size -= chunk; + } + return 0; +} + +static int lofsQspiProg(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, const void *buffer, lfs_size_t size) +{ + (void)c; + uint32_t addr = block * LOFS_QSPI_BLOCK + off; + if (((uintptr_t)buffer & 3) == 0 && (size & 3) == 0) { + nrfx_err_t err = nrfx_qspi_write(buffer, size, addr); + lofsQspiWaitReady(); + return err == NRFX_SUCCESS ? 0 : -1; + } + const uint8_t *src = (const uint8_t *)buffer; + while (size > 0) { + uint32_t chunk = size > LOFS_QSPI_PAGE ? LOFS_QSPI_PAGE : size; + uint32_t qchunk = (chunk + 3) & ~3u; + memcpy(lofsQspiScratch, src, chunk); + for (uint32_t i = chunk; i < qchunk; i++) + lofsQspiScratch[i] = 0xFF; + nrfx_err_t err = nrfx_qspi_write(lofsQspiScratch, qchunk, addr); + if (err != NRFX_SUCCESS) + return -1; + src += chunk; + addr += chunk; + size -= chunk; + } + lofsQspiWaitReady(); + return 0; +} + +static int lofsQspiErase(const struct lfs_config *c, lfs_block_t block) +{ + (void)c; + uint32_t addr = block * LOFS_QSPI_BLOCK; + nrfx_err_t err = nrfx_qspi_erase(NRF_QSPI_ERASE_LEN_4KB, addr); + lofsQspiWaitReady(); + return err == NRFX_SUCCESS ? 0 : -1; +} + +static int lofsQspiSync(const struct lfs_config *c) +{ + (void)c; + lofsQspiWaitReady(); + return 0; +} + +bool lofsQspiConfig(struct lfs_config &cfg) +{ + LOBBS_BOOT_STEP("qspi: lofsQspiConfig"); + if (!lofsQspiInitHw()) + return false; + cfg.context = NULL; + cfg.read = lofsQspiRead; + cfg.prog = lofsQspiProg; + cfg.erase = lofsQspiErase; + cfg.sync = lofsQspiSync; + cfg.read_size = LOFS_QSPI_PAGE; + cfg.prog_size = LOFS_QSPI_PAGE; + cfg.block_size = LOFS_QSPI_BLOCK; + cfg.block_count = LOFS_QSPI_BLOCK_COUNT; + cfg.lookahead = 512; + return true; +} + +#endif diff --git a/lofs/LoFSQspi.h b/lofs/LoFSQspi.h new file mode 100644 index 0000000..ff5dd93 --- /dev/null +++ b/lofs/LoFSQspi.h @@ -0,0 +1,12 @@ +#pragma once + +#include "LoBBSConfig.h" + +#if LOBBS_EXTRA_QSPI + +struct lfs_config; + +/** Init nrfx QSPI (once), verify JEDEC, fill LittleFS block callbacks. Returns false if hardware missing. */ +bool lofsQspiConfig(struct lfs_config &cfg); + +#endif diff --git a/loscalar/LoScalar.cpp b/loscalar/LoScalar.cpp new file mode 100644 index 0000000..da4f588 --- /dev/null +++ b/loscalar/LoScalar.cpp @@ -0,0 +1,331 @@ +#include +#include +#include +#include +#include + +#include "LoBBSStackGuard.h" + +static constexpr uint32_t kLoScalarFieldMax = 99; + +static bool loscalarFieldOk(uint32_t field) +{ + return field <= kLoScalarFieldMax; +} + +namespace +{ + +bool loscalarUnescapeValue(const char *in, size_t inLen, std::string &raw) +{ + raw.clear(); + raw.reserve(inLen); + for (size_t i = 0; i < inLen; i++) { + if (in[i] == '\\' && i + 1 < inLen) { + char n = in[i + 1]; + if (n == '\\') { + raw.push_back('\\'); + i++; + } else if (n == '|') { + raw.push_back('|'); + i++; + } else if (n == 'n') { + raw.push_back('\n'); + i++; + } else + return false; + } else + raw.push_back(in[i]); + } + return true; +} + +} // namespace + +void LoScalar::clear() +{ + fields_.clear(); +} + +static bool fieldLess(const LoScalar::Field &a, const LoScalar::Field &b) +{ + return a.number < b.number; +} + +LoScalar::Field *LoScalar::findOrInsert(uint32_t field) +{ + if (!loscalarFieldOk(field)) + return nullptr; + for (auto &f : fields_) { + if (f.number == field) + return &f; + } + Field nf; + nf.number = field; + fields_.push_back(nf); + return &fields_.back(); +} + +const LoScalar::Field *LoScalar::find(uint32_t field) const +{ + for (const auto &f : fields_) { + if (f.number == field) + return &f; + } + return nullptr; +} + +void LoScalar::setString(uint32_t field, const char *value) +{ + if (!value || !value[0]) + return; + Field *f = findOrInsert(field); + if (f) + f->value = value; +} + +void LoScalar::setString(uint32_t field, const std::string &value) +{ + if (value.empty()) + return; + Field *f = findOrInsert(field); + if (f) + f->value = value; +} + +void LoScalar::setUint64(uint32_t field, uint64_t value) +{ + char buf[LO_U64_DEC_LEN]; + const char *digits = loU64ToDec(value, buf); + Field *f = findOrInsert(field); + if (f) + f->value = digits; +} + +void LoScalar::setUint32(uint32_t field, uint32_t value) +{ + setUint64(field, value); +} + +void LoScalar::setBool(uint32_t field, bool value) +{ + Field *f = findOrInsert(field); + if (f) + f->value = value ? "1" : "0"; +} + +void LoScalar::removeField(uint32_t field) +{ + if (!loscalarFieldOk(field)) + return; + for (auto it = fields_.begin(); it != fields_.end(); ++it) { + if (it->number == field) { + fields_.erase(it); + return; + } + } +} + +void LoScalar::setBytesHex(uint32_t field, const uint8_t *data, size_t len) +{ + if (!data || len == 0) + return; + std::string hex; + hex.reserve(len * 2); + static const char digits[] = "0123456789abcdef"; + for (size_t i = 0; i < len; i++) { + hex.push_back(digits[(data[i] >> 4) & 0xf]); + hex.push_back(digits[data[i] & 0xf]); + } + Field *f = findOrInsert(field); + if (f) + f->value = hex; +} + +bool LoScalar::has(uint32_t field) const +{ + return find(field) != nullptr; +} + +bool LoScalar::getString(uint32_t field, std::string &out) const +{ + const Field *f = find(field); + if (!f) + return false; + out = f->value; + return true; +} + +bool LoScalar::getUint64(uint32_t field, uint64_t &out) const +{ + const Field *f = find(field); + if (!f || f->value.empty()) + return false; + char *end = nullptr; + unsigned long long v = strtoull(f->value.c_str(), &end, 10); + if (end == f->value.c_str() || (end && *end != '\0')) + return false; + out = v; + return true; +} + +bool LoScalar::getUint32(uint32_t field, uint32_t &out) const +{ + uint64_t v = 0; + if (!getUint64(field, v)) + return false; + out = (uint32_t)v; + return true; +} + +bool LoScalar::getBool(uint32_t field, bool &out) const +{ + const Field *f = find(field); + if (!f) + return false; + if (f->value == "1") { + out = true; + return true; + } + if (f->value == "0") { + out = false; + return true; + } + return false; +} + +bool LoScalar::getBytesHex(uint32_t field, uint8_t *out, size_t outLen, size_t &written) const +{ + written = 0; + const Field *f = find(field); + if (!f || f->value.size() % 2 != 0) + return false; + size_t byteCount = f->value.size() / 2; + if (byteCount > outLen) + return false; + for (size_t i = 0; i < byteCount; i++) { + unsigned hi = 0, lo = 0; + char c1 = f->value[i * 2]; + char c2 = f->value[i * 2 + 1]; + if (c1 >= '0' && c1 <= '9') + hi = (unsigned)(c1 - '0'); + else if (c1 >= 'a' && c1 <= 'f') + hi = (unsigned)(c1 - 'a' + 10); + else if (c1 >= 'A' && c1 <= 'F') + hi = (unsigned)(c1 - 'A' + 10); + else + return false; + if (c2 >= '0' && c2 <= '9') + lo = (unsigned)(c2 - '0'); + else if (c2 >= 'a' && c2 <= 'f') + lo = (unsigned)(c2 - 'a' + 10); + else if (c2 >= 'A' && c2 <= 'F') + lo = (unsigned)(c2 - 'A' + 10); + else + return false; + out[i] = (uint8_t)((hi << 4) | lo); + } + written = byteCount; + return true; +} + +void LoScalar::escapeValue(const std::string &raw, std::string &escaped) +{ + escaped.clear(); + escaped.reserve(raw.size()); + for (char c : raw) { + if (c == '\\') + escaped.append("\\\\"); + else if (c == '|') + escaped.append("\\|"); + else if (c == '\n') + escaped.append("\\n"); + else + escaped.push_back(c); + } +} + +bool LoScalar::encode(std::string &line, size_t maxBytes) const +{ + line.clear(); + std::vector sorted = fields_; + std::sort(sorted.begin(), sorted.end(), fieldLess); + for (const auto &fld : sorted) { + if (fld.value.empty()) + continue; + if (!line.empty()) + line.push_back('|'); + char numBuf[12]; + snprintf(numBuf, sizeof(numBuf), "%u:", (unsigned)fld.number); + line.append(numBuf); + std::string esc; + escapeValue(fld.value, esc); + line.append(esc); + if (line.size() > maxBytes) + return false; + } + return true; +} + +static bool decodeOneField(const char *line, size_t lineLen, size_t &pos, uint32_t &fieldNum, std::string &value) +{ + fieldNum = 0; + value.clear(); + if (pos >= lineLen) + return false; + if (line[pos] < '0' || line[pos] > '9') + return false; + while (pos < lineLen && line[pos] >= '0' && line[pos] <= '9') { + fieldNum = fieldNum * 10 + (uint32_t)(line[pos] - '0'); + if (fieldNum > kLoScalarFieldMax) + return false; + pos++; + } + if (pos >= lineLen || line[pos] != ':') + return false; + pos++; + + size_t start = pos; + while (pos < lineLen) { + if (line[pos] == '\\') { + pos += 2; + if (pos > lineLen) + return false; + continue; + } + if (line[pos] == '|') { + if (!loscalarUnescapeValue(line + start, pos - start, value)) + return false; + pos++; + return true; + } + pos++; + } + if (!loscalarUnescapeValue(line + start, pos - start, value)) + return false; + return true; +} + +bool LoScalar::decode(const char *line, size_t lineLen) +{ + clear(); + if (!line) + return false; + if (lineLen == 0) + return true; + size_t pos = 0; + while (pos < lineLen) { + uint32_t fieldNum = 0; + std::string value; + if (!decodeOneField(line, lineLen, pos, fieldNum, value)) + return false; + if (!loscalarFieldOk(fieldNum)) + return false; + Field *f = findOrInsert(fieldNum); + if (f) + f->value = value; + while (pos < lineLen && (line[pos] == ' ' || line[pos] == '\r' || line[pos] == '\n')) + pos++; + } + return true; +} diff --git a/loscalar/LoScalar.h b/loscalar/LoScalar.h new file mode 100644 index 0000000..85ae5cc --- /dev/null +++ b/loscalar/LoScalar.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include + +/** One LoScalar record: fields `number:value` joined by `|`, ascending field number. */ +class LoScalar +{ + public: + struct Field { + uint32_t number = 0; + std::string value; + }; + + void clear(); + void setString(uint32_t field, const char *value); + void setString(uint32_t field, const std::string &value); + void setUint64(uint32_t field, uint64_t value); + void setUint32(uint32_t field, uint32_t value); + void setBool(uint32_t field, bool value); + /** 32 bytes encoded as 64 lowercase hex chars. */ + void setBytesHex(uint32_t field, const uint8_t *data, size_t len); + void removeField(uint32_t field); + + bool has(uint32_t field) const; + bool getString(uint32_t field, std::string &out) const; + bool getUint64(uint32_t field, uint64_t &out) const; + bool getUint32(uint32_t field, uint32_t &out) const; + bool getBool(uint32_t field, bool &out) const; + bool getBytesHex(uint32_t field, uint8_t *out, size_t outLen, size_t &written) const; + + /** Encode to a single line (no trailing newline). Returns false if maxBytes would be exceeded. */ + bool encode(std::string &line, size_t maxBytes) const; + /** Decode one line. Returns false on parse error. */ + bool decode(const char *line, size_t lineLen); + + private: + Field *findOrInsert(uint32_t field); + const Field *find(uint32_t field) const; + static void escapeValue(const std::string &raw, std::string &escaped); + + std::vector fields_; +}; diff --git a/loutil/LoUtil.cpp b/loutil/LoUtil.cpp new file mode 100644 index 0000000..6c55f64 --- /dev/null +++ b/loutil/LoUtil.cpp @@ -0,0 +1,34 @@ +#include +#include + +const char *loU64ToDec(uint64_t value, char (&buf)[LO_U64_DEC_LEN]) +{ + char *p = buf + LO_U64_DEC_LEN - 1; + *p = '\0'; + do { + *--p = (char)('0' + (value % 10)); + value /= 10; + } while (value); + return p; +} + +const char *loHumanBytes(uint64_t bytes, char (&buf)[LO_HUMAN_BYTES_LEN]) +{ + static const char units[] = "KMGT"; + if (bytes < 1024) { + snprintf(buf, LO_HUMAN_BYTES_LEN, "%uB", (unsigned)bytes); + return buf; + } + uint64_t div = 1024; + int u = 0; + while (u < 3 && bytes >= div * 1024) { + div *= 1024; + u++; + } + const unsigned tenths = (unsigned)((bytes * 10 + div / 2) / div); + if (tenths < 100 && tenths % 10) + snprintf(buf, LO_HUMAN_BYTES_LEN, "%u.%u%c", tenths / 10, tenths % 10, units[u]); + else + snprintf(buf, LO_HUMAN_BYTES_LEN, "%u%c", (tenths + 5) / 10, units[u]); + return buf; +} diff --git a/loutil/LoUtil.h b/loutil/LoUtil.h new file mode 100644 index 0000000..dc6e7ed --- /dev/null +++ b/loutil/LoUtil.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +static constexpr unsigned LO_U64_DEC_LEN = 21; +static constexpr unsigned LO_HUMAN_BYTES_LEN = 8; + +/** Writes `value` in decimal into `buf` and returns the first digit. newlib-nano (nRF52) has no `%llu`. */ +const char *loU64ToDec(uint64_t value, char (&buf)[LO_U64_DEC_LEN]); + +/** Short size like `9B`, `1.2K`, `252K`, `1.8M` (1024-based, integer math). Returns `buf`. */ +const char *loHumanBytes(uint64_t bytes, char (&buf)[LO_HUMAN_BYTES_LEN]); diff --git a/platforms/meshtastic/extra_script.py b/platforms/meshtastic/extra_script.py new file mode 100644 index 0000000..343055a --- /dev/null +++ b/platforms/meshtastic/extra_script.py @@ -0,0 +1,3 @@ +Import("env") + +env.Append(CPPDEFINES=[("LOBBS_PLATFORM_MESHTASTIC", "1")]) diff --git a/protocol/machine/paginate.cpp b/protocol/machine/paginate.cpp new file mode 100644 index 0000000..abb5dc3 --- /dev/null +++ b/protocol/machine/paginate.cpp @@ -0,0 +1,52 @@ +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "paginate.h" +#include "LoBBSReply.h" +#include + +#include "LoBBSStackGuard.h" + +bool lobbsPaginateMachine(uint32_t reqId, const std::string &document, uint32_t page1, std::string &pageOut, const char **errMsg) +{ + pageOut.clear(); + if (page1 == 0) + page1 = 1; + + char prefix[40]; + size_t prefixLen = (size_t)snprintf(prefix, sizeof(prefix), "<%u>ok\n", reqId); + if (prefixLen + document.size() <= LOBBS_REPLY_BYTES) { + if (page1 > 1) { + if (errMsg) + *errMsg = "No such page."; + return false; + } + pageOut = prefix; + pageOut += document; + return true; + } + + // Size every page for the widest header at this digit count; widen until the page count fits. + uint32_t widest = 9; + size_t budget = 0; + uint32_t pages = 0; + while (true) { + prefixLen = (size_t)snprintf(prefix, sizeof(prefix), "<%u>ok [%u:%u]\n", reqId, widest, widest); + budget = LOBBS_REPLY_BYTES - prefixLen; + pages = (uint32_t)((document.size() + budget - 1) / budget); + if (pages <= widest) + break; + widest = widest * 10 + 9; + } + + if (page1 > pages) { + if (errMsg) + *errMsg = "No such page."; + return false; + } + snprintf(prefix, sizeof(prefix), "<%u>ok [%u:%u]\n", reqId, page1, pages); + pageOut = prefix; + pageOut.append(document, (size_t)(page1 - 1) * budget, budget); + return true; +} + +#endif diff --git a/protocol/machine/paginate.h b/protocol/machine/paginate.h new file mode 100644 index 0000000..b9a4282 --- /dev/null +++ b/protocol/machine/paginate.h @@ -0,0 +1,9 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include +#include + +bool lobbsPaginateMachine(uint32_t reqId, const std::string &document, uint32_t page1, std::string &pageOut, const char **errMsg); + +#endif diff --git a/protocol/machine/serialize.cpp b/protocol/machine/serialize.cpp new file mode 100644 index 0000000..9d565c7 --- /dev/null +++ b/protocol/machine/serialize.cpp @@ -0,0 +1,26 @@ +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "serialize.h" +#include + +#include "LoBBSStackGuard.h" + +bool lobbsSerializeMachine(const LoBBSResponse &resp, std::string &out) +{ + out.clear(); + if (!resp.ok) { + out = resp.error; + return true; + } + for (const LoScalar &rec : resp.records) { + if (!out.empty()) + out.push_back('\n'); + std::string line; + if (!rec.encode(line, SIZE_MAX)) + return false; + out += line; + } + return true; +} + +#endif diff --git a/protocol/machine/serialize.h b/protocol/machine/serialize.h new file mode 100644 index 0000000..833a49a --- /dev/null +++ b/protocol/machine/serialize.h @@ -0,0 +1,9 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "LoBBSResponse.h" +#include + +bool lobbsSerializeMachine(const LoBBSResponse &resp, std::string &out); + +#endif diff --git a/protocol/plain_text/paginate.cpp b/protocol/plain_text/paginate.cpp new file mode 100644 index 0000000..8ee653c --- /dev/null +++ b/protocol/plain_text/paginate.cpp @@ -0,0 +1,134 @@ +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "paginate.h" +#include "LoBBSReply.h" +#include +#include +#include + +#include "LoBBSStackGuard.h" + +static constexpr size_t LOBBS_PAGER_FOOTER_MAX = 16; +static constexpr int LOBBS_PAGER_MAX_PAGES = 64; +static constexpr char LOBBS_LINE_TRUNC[] = "[...]"; +static constexpr size_t LOBBS_LINE_TRUNC_LEN = sizeof(LOBBS_LINE_TRUNC) - 1; + +static size_t lobbsHumanLineLen(const std::string &line, size_t maxBytes) +{ + size_t len = strlen(line.c_str()); + if (len <= maxBytes) + return len; + return maxBytes <= LOBBS_LINE_TRUNC_LEN ? 0 : maxBytes; +} + +static void lobbsHumanAppendLine(std::string &out, const std::string &line, size_t maxBytes) +{ + size_t len = strlen(line.c_str()); + if (len <= maxBytes) { + out.append(line, 0, len); + return; + } + if (maxBytes <= LOBBS_LINE_TRUNC_LEN) + return; + out.append(line, 0, maxBytes - LOBBS_LINE_TRUNC_LEN); + out += LOBBS_LINE_TRUNC; +} + +static bool lobbsHumanPackPages(const std::vector &lines, size_t maxBytes, bool reserveFooter, uint32_t *pageStarts, + int &pageCountOut) +{ + pageCountOut = 0; + if (lines.empty()) + return false; + uint32_t idx = 0; + while (idx < lines.size() && pageCountOut < LOBBS_PAGER_MAX_PAGES) { + pageStarts[pageCountOut] = idx; + size_t used = 0; + while (idx < lines.size()) { + size_t footerReserve = reserveFooter ? LOBBS_PAGER_FOOTER_MAX : 0; + size_t maxLine = maxBytes > footerReserve ? maxBytes - footerReserve : 0; + size_t lineLen = lobbsHumanLineLen(lines[idx], maxLine); + size_t add = lineLen + (used > 0 ? 1 : 0); + if (used + add + footerReserve > maxBytes) { + if (used > 0) + break; + used += lineLen; + idx++; + break; + } + used += add; + idx++; + if (idx < lines.size()) { + size_t nextAdd = lobbsHumanLineLen(lines[idx], maxLine) + 1; + if (used + nextAdd + footerReserve > maxBytes) + break; + } + } + pageCountOut++; + } + if (pageCountOut < LOBBS_PAGER_MAX_PAGES) + pageStarts[pageCountOut] = (uint32_t)lines.size(); + return pageCountOut > 0; +} + +bool lobbsPaginatePlainText(const std::string &text, uint32_t page1, std::string &pageOut, const char **errMsg) +{ + pageOut.clear(); + if (text.empty()) { + if (errMsg) + *errMsg = "Empty."; + return false; + } + + std::vector lines; + size_t start = 0; + while (start <= text.size()) { + size_t end = text.find('\n', start); + if (end == std::string::npos) { + lines.push_back(text.substr(start)); + break; + } + lines.push_back(text.substr(start, end - start)); + start = end + 1; + } + + if (page1 == 0) + page1 = 1; + uint32_t pageStarts[LOBBS_PAGER_MAX_PAGES + 1]; + memset(pageStarts, 0, sizeof(pageStarts)); + int pageCount = 0; + lobbsHumanPackPages(lines, LOBBS_REPLY_BYTES, true, pageStarts, pageCount); + if (pageCount <= 0) { + if (errMsg) + *errMsg = "Empty."; + return false; + } + if (pageCount == 1) { + lobbsHumanPackPages(lines, LOBBS_REPLY_BYTES, false, pageStarts, pageCount); + pageStarts[1] = (uint32_t)lines.size(); + } + if (page1 > (uint32_t)pageCount) { + if (errMsg) + *errMsg = "No such page."; + return false; + } + + uint32_t from = pageStarts[page1 - 1]; + uint32_t to = pageStarts[page1]; + size_t footerReserve = pageCount > 1 ? LOBBS_PAGER_FOOTER_MAX : 0; + size_t maxLine = LOBBS_REPLY_BYTES > footerReserve ? LOBBS_REPLY_BYTES - footerReserve : 0; + for (uint32_t i = from; i < to; i++) { + if (i > from) + pageOut.push_back('\n'); + lobbsHumanAppendLine(pageOut, lines[i], maxLine); + } + if ((uint32_t)pageCount > 1) { + char footer[LOBBS_PAGER_FOOTER_MAX]; + snprintf(footer, sizeof(footer), "{p %u/%u}", page1, (uint32_t)pageCount); + pageOut.push_back('\n'); + pageOut += footer; + } + return true; +} + +#endif diff --git a/protocol/plain_text/paginate.h b/protocol/plain_text/paginate.h new file mode 100644 index 0000000..ff0b21b --- /dev/null +++ b/protocol/plain_text/paginate.h @@ -0,0 +1,9 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include +#include + +bool lobbsPaginatePlainText(const std::string &text, uint32_t page1, std::string &pageOut, const char **errMsg); + +#endif diff --git a/protocol/plain_text/serialize.cpp b/protocol/plain_text/serialize.cpp new file mode 100644 index 0000000..2b9088f --- /dev/null +++ b/protocol/plain_text/serialize.cpp @@ -0,0 +1,54 @@ +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "serialize.h" +#include "LoBBSHooks.h" +#include + +#include "LoBBSStackGuard.h" + +static void lobbsDisplayGeneric(const LoScalar &record, std::string &lineOut) +{ + std::string title; + std::string body; + record.getString(LODB_F_TITLE, title); + record.getString(LODB_F_DESCRIPTION, body); + if (title.empty() && body.empty()) { + lineOut.clear(); + return; + } + if (title.empty()) { + lineOut = body; + return; + } + if (body.empty()) { + lineOut = title; + return; + } + lineOut = title + " (" + body + ")"; +} + +bool lobbsSerializePlainText(const LoBBSCommandCtx &ctx, const LoBBSResponse &resp, std::string &out) +{ + out.clear(); + if (!resp.ok) { + out = resp.error; + return !out.empty(); + } + for (const LoScalar &rec : resp.records) { + std::string line; + lobbsDisplayGeneric(rec, line); + LoScalar shown; + shown.setString(LODB_F_TITLE, line); + lobbsApplyFilter("display_human", const_cast(ctx), shown, rec); + line.clear(); + shown.getString(LODB_F_TITLE, line); + if (line.empty()) + continue; + if (!out.empty()) + out.push_back('\n'); + out += line; + } + return !out.empty(); +} + +#endif diff --git a/protocol/plain_text/serialize.h b/protocol/plain_text/serialize.h new file mode 100644 index 0000000..8b440d4 --- /dev/null +++ b/protocol/plain_text/serialize.h @@ -0,0 +1,10 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "LoBBSCommandCtx.h" +#include "LoBBSResponse.h" +#include + +bool lobbsSerializePlainText(const LoBBSCommandCtx &ctx, const LoBBSResponse &resp, std::string &out); + +#endif diff --git a/tests/command_tests.h b/tests/command_tests.h new file mode 100644 index 0000000..237314c --- /dev/null +++ b/tests/command_tests.h @@ -0,0 +1,413 @@ +#pragma once +#include "LoBBSConfig.h" +#if !MESHTASTIC_EXCLUDE_LOBBS && LOBBS_SEED + +#include "LoBBSDispatch.h" +#include "LoBBSInstall.h" +#include "LoBBSModule.h" +#include "LoBBSReplyCache.h" +#include "LoBBSSeed.h" +#include "gps/RTC.h" +#include "lofs/LoFS.h" +#include "mesh/NodeDB.h" +#include +#include +#include +#include +#include + +static constexpr uint32_t kLobbsTestBbsNode = 0xBB500001; +static constexpr uint32_t kLobbsTestClientNode = 0xABCD0001; + +class LobbsTestNodeDB : public NodeDB +{ + public: + LobbsTestNodeDB() { myNodeInfo.my_node_num = kLobbsTestBbsNode; } +}; + +static std::unique_ptr lobbsTestNodeDb; +static std::unique_ptr lobbsTestModule; +static std::vector lobbsTestReplies; + +static void lobbsTestSendLine(const char *line) +{ + meshtastic_MeshPacket mp = {}; + mp.from = kLobbsTestClientNode; + mp.to = kLobbsTestBbsNode; + size_t len = strlen(line); + if (len > sizeof(mp.decoded.payload.bytes)) + len = sizeof(mp.decoded.payload.bytes); + memcpy(mp.decoded.payload.bytes, line, len); + mp.decoded.payload.size = len; + lobbsDispatchReceived(lobbsTestModule.get(), mp); +} + +static const char *lobbsTestLastReply() +{ + if (lobbsTestReplies.empty()) + return ""; + return lobbsTestReplies.back().c_str(); +} + +static void lobbsTestFixtureSetUp() +{ + resetRTCStateForTests(); + struct timeval tv = {1000, 0}; + setRTCSystemTimeForTests(&tv); + lobbsTestNodeDb = std::make_unique(); + nodeDB = lobbsTestNodeDb.get(); + lobbsTestReplies.clear(); + lobbsTestReplySink = &lobbsTestReplies; + lobbsTestModule = std::make_unique(); + lobbsInstallAutoSeed(*lobbsTestModule); + lobbsSeedAll(*lobbsTestModule); +} + +static void lobbsTestSendLocalLine(const char *line) +{ + meshtastic_MeshPacket mp = {}; + mp.from = 0; + mp.to = kLobbsTestBbsNode; + size_t len = strlen(line); + memcpy(mp.decoded.payload.bytes, line, len); + mp.decoded.payload.size = len; + lobbsDispatchReceived(lobbsTestModule.get(), mp); +} + +static void lobbsTestReboot() +{ + lobbsTestModule.reset(); + lobbsTestModule = std::make_unique(); +} + +static void lobbsTestFixtureTearDown() +{ + lobbsTestReplySink = nullptr; + lobbsTestReplies.clear(); + lobbsTestModule.reset(); + lobbsTestNodeDb.reset(); + nodeDB = nullptr; + resetRTCStateForTests(); +} + +static void test_command_mail_list_and_plain_page2() +{ + lobbsTestSendLine("/login sysop demo1"); + lobbsTestReplies.clear(); + lobbsTestSendLine("/mail list"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "{p 1/")); + lobbsTestSendLine("/p2"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "{p 2/")); +} + +static void test_command_machine_page_from_cache() +{ + lobbsTestSendLine("/login sysop demo1"); + lobbsTestReplies.clear(); + lobbsTestSendLine("/42 mail list"); + const char *p1 = lobbsTestLastReply(); + TEST_ASSERT_NOT_NULL(strstr(p1, "<42>ok [1:")); + lobbsTestSendLine("/43 p2"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "<43>ok [2:")); +} + +static void test_command_no_such_page() +{ + lobbsTestSendLine("/login sysop demo1"); + lobbsTestReplies.clear(); + lobbsTestSendLine("/mail list"); + lobbsTestSendLine("/p99"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "No such page.")); +} + +static void test_command_new_command_replaces_cache() +{ + lobbsTestSendLine("/login sysop demo1"); + lobbsTestReplies.clear(); + lobbsTestSendLine("/mail list"); + lobbsTestSendLine("/time"); + lobbsTestSendLine("/p2"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "No such page.")); +} + +static void test_command_error_keeps_cache() +{ + lobbsTestSendLine("/login sysop demo1"); + lobbsTestReplies.clear(); + lobbsTestSendLine("/mail list"); + lobbsTestSendLine("/mail read 99999"); + lobbsTestSendLine("/p2"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "{p 2/")); +} + +static void test_command_cache_expires() +{ + lobbsTestSendLine("/login sysop demo1"); + lobbsTestReplies.clear(); + lobbsTestSendLine("/mail list"); + struct timeval tv = {1000 + LOBBS_REPLY_CACHE_TTL_SEC + 1, 0}; + setRTCSystemTimeForTests(&tv); + lobbsTestSendLine("/p2"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "No cached reply.")); +} + +static void test_command_fs_cwd() +{ + lobbsTestSendLine("/login sysop demo1"); + lobbsTestSendLine("/pwd"); + TEST_ASSERT_EQUAL_STRING("/", lobbsTestLastReply()); + lobbsTestSendLine("/cd /no/such/dir"); + TEST_ASSERT_EQUAL_STRING("No such directory.", lobbsTestLastReply()); + lobbsTestSendLine("/rm relative.txt"); + TEST_ASSERT_EQUAL_STRING("Absolute path only.", lobbsTestLastReply()); + lobbsTestSendLine("/rmtree /flash/.. /flash/.."); + TEST_ASSERT_EQUAL_STRING("Refused.", lobbsTestLastReply()); + lobbsTestSendLine("/cd /flash"); + lobbsTestSendLine("/cd .."); + TEST_ASSERT_EQUAL_STRING("/", lobbsTestLastReply()); +} + +static void test_command_fs_mounts_and_tools() +{ + lobbsTestSendLine("/login sysop demo1"); + lobbsTestReplies.clear(); + lobbsTestSendLine("/ls /"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "flash")); + lobbsTestSendLine("/mkdir /flash/lobbs-test-dir"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Created.")); + lobbsTestSendLine("/stat /flash/lobbs-test-dir"); + TEST_ASSERT_EQUAL_STRING("dir", lobbsTestLastReply()); + lobbsTestSendLine("/mkdir /flash/lobbs-test-dir"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Already exists.")); + lobbsTestSendLine("/df"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "flash: ")); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "[lobbs]")); + lobbsTestSendLine("/ls /flash/lobbs"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "db/")); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "install.ls ")); + lobbsTestSendLine("/rmdir /flash/lobbs-test-dir"); +} + +static void test_command_fs_format_guards() +{ + lobbsTestSendLine("/login sysop demo1"); + lobbsTestSendLine("/format flash"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "This uninstalls LoBBS.")); + lobbsTestSendLine("/format flash 000000"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Wrong or expired code.")); + lobbsTestSendLine("/format nope"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "No such mount.")); +} + +static void test_command_fs_cp_mv() +{ + lobbsTestSendLine("/login sysop demo1"); + lobbsTestSendLine("/cp /flash/lobbs/install.ls /flash/lobbs-copy.ls"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Copied.")); + lobbsTestSendLine("/cp /flash/lobbs/install.ls /flash/lobbs-copy.ls"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Destination exists.")); + lobbsTestSendLine("/mv /flash/lobbs-copy.ls /flash/lobbs-moved.ls"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Moved.")); + lobbsTestSendLine("/rm /flash/lobbs-moved.ls"); +} + +static void test_command_fs_upload_commit() +{ + lobbsTestSendLine("/login sysop demo1"); + lobbsTestSendLine("/mkdir /flash/tmp"); + lobbsTestSendLine("/rm /flash/tmp/upload-a.bin"); + lobbsTestSendLine("/rm /flash/tmp/upload-b.bin"); + lobbsTestSendLine("/rm /flash/files/upload-done.bin"); + + lobbsTestSendLine("/upload /flash/tmp/upload-a.bin"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Size 0.")); + + lobbsTestSendLine("/upload /flash/tmp/upload-a.bin 10:AAAA"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Gap:")); + + lobbsTestSendLine("/upload /flash/tmp/upload-a.bin 0:8xhIpNzLldv25utFy"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Size 12.")); + lobbsTestSendLine("/upload /flash/tmp/upload-a.bin 0:8xhIpNzLldv25utFy"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Size 12.")); + + lobbsTestSendLine("/commit /flash/tmp/upload-a.bin /flash/files/upload-done.bin"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "No CRC in name.")); + + lobbsTestSendLine("/upload /flash/tmp/upload-b.bin.01020304.tmp 0:8xhIpNzLldv25utFy"); + lobbsTestSendLine("/commit /flash/tmp/upload-b.bin.01020304.tmp /flash/files/upload-done.bin"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "CRC mismatch.")); + + lobbsTestSendLine("/rm /flash/tmp/upload-a.bin"); + lobbsTestSendLine("/rm /flash/tmp/upload-b.bin.01020304.tmp"); +} + +/** Two sequential chunks: offset 0 then offset == size (append). Payload is "hello world\\n". */ +static void test_command_fs_upload_append_two_chunks() +{ + lobbsTestSendLine("/login sysop demo1"); + lobbsTestSendLine("/mkdir /flash/tmp"); + lobbsTestSendLine("/rm /flash/tmp/append-two.bin"); + + lobbsTestSendLine("/upload /flash/tmp/append-two.bin 0:0Waqlj8GO"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Size 6.")); + lobbsTestSendLine("/upload /flash/tmp/append-two.bin 6:0bHyFj1oY"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Size 12.")); + + lobbsTestSendLine("/upload /flash/tmp/append-two.bin 7:0bHyFj1oY"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Gap:")); + + lobbsTestSendLine("/rm /flash/tmp/append-two.bin"); +} + +static void test_command_fs_upload_commit_ok() +{ + lobbsTestSendLine("/login sysop demo1"); + lobbsTestSendLine("/mkdir /flash/tmp"); + lobbsTestSendLine("/mkdir /flash/files"); + lobbsTestSendLine("/rm /flash/tmp/chunk.ok.af083b2d.tmp"); + lobbsTestSendLine("/rm /flash/files/chunk.ok.bin"); + + lobbsTestSendLine("/upload /flash/tmp/chunk.ok.af083b2d.tmp 0:8xhIpNzLldv25utFy"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Size 12.")); + lobbsTestSendLine("/commit /flash/tmp/chunk.ok.af083b2d.tmp /flash/files/chunk.ok.bin"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Moved.")); + + lobbsTestSendLine("/cat /flash/files/chunk.ok.bin"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "hello world")); + + lobbsTestSendLine("/rm /flash/files/chunk.ok.bin"); + lobbsTestSendLine("/rm /flash/tmp/chunk.ok.af083b2d.tmp"); +} + +static void test_command_install_guards() +{ + lobbsTestSendLine("/install flash hacker pass"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Not authorized.")); + lobbsTestSendLine("/login sysop demo1"); + lobbsTestSendLine("/install flash other pass"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Already installed at /flash.")); + lobbsTestSendLine("/login demo99 demo1"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Welcome demo99")); +} + +static void test_command_install_autodetect() +{ + lobbsTestReboot(); + lobbsTestSendLine("/login sysop demo1"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Welcome")); +} + +static void test_command_install_blank_and_local() +{ + LoFS::rmdir("/flash/lobbs", true); + lobbsTestReboot(); + lobbsTestSendLine("/whoami"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "/install ")); + lobbsTestSendLine("/install flash boss pw"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Not authorized.")); + lobbsTestSendLocalLine("/install sd boss pw"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Mount not available.")); + lobbsTestSendLocalLine("/install flash boss pw"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Installed.")); + lobbsTestSendLine("/login newbie pw"); + lobbsTestSendLine("/df"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "SysOp only.")); +} + +static void test_command_install_adopts_existing() +{ + LoFS::remove("/flash/lobbs/install.ls"); + lobbsTestReboot(); + lobbsTestSendLocalLine("/install flash demo01 demo1"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "sysop credentials required")); + lobbsTestSendLocalLine("/install flash sysop demo1"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Installed.")); +} + +static void test_command_users_kick() +{ + lobbsTestSendLine("/login demo03 demo1"); + lobbsTestReplies.clear(); + lobbsTestSendLine("/login sysop demo1"); + lobbsTestSendLine("/users kick demo03"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Sessions cleared.")); +} + +static void test_command_config() +{ + lobbsTestSendLine("/login demo01 demo1"); + lobbsTestSendLine("/config"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "SysOp only.")); + lobbsTestSendLine("/login sysop demo1"); + lobbsTestSendLine("/config"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Use /help config key for details")); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "session.max 16")); + lobbsTestSendLine("/help config session.max"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "session.max: Max concurrent login sessions. Default 16, range 1-64.")); + lobbsTestSendLine("/help config bogus"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "No help found for config bogus")); + lobbsTestSendLine("/config session.max"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "session.max = 16")); + lobbsTestSendLine("/config session.max 0"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Must be 1-64.")); + lobbsTestSendLine("/config session.max 8"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "session.max = 8.")); + lobbsTestSendLine("/config session.max reset"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "session.max reset.")); + lobbsTestSendLine("/config bogus"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Unknown setting.")); + lobbsTestSendLine("/config password.min 8"); + lobbsTestSendLine("/passwd short short"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Password too short.")); + lobbsTestSendLine("/passwd longenuf longenuf"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Password updated.")); +} + +static void test_command_subcommand_with_args() +{ + lobbsTestSendLine("/login sysop demo1"); + lobbsTestReplies.clear(); + lobbsTestSendLine("/news read 1"); + TEST_ASSERT_NULL(strstr(lobbsTestLastReply(), "Unknown command")); + lobbsTestSendLine("/mail send demo01 hello there"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "Mail sent.")); +} + +static void test_command_help_catalog_and_topics() +{ + lobbsTestSendLine("/help"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), " Help\n")); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "pN (page N of the last reply)")); + lobbsTestSendLine("/help bogus"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "No help found for bogus")); + lobbsTestSendLine("/help news read"); + TEST_ASSERT_NOT_NULL(strstr(lobbsTestLastReply(), "read N")); + TEST_ASSERT_NULL(strstr(lobbsTestLastReply(), "post message")); +} + +inline void lobbsRunCommandTests() +{ + RUN_TEST(test_command_mail_list_and_plain_page2); + RUN_TEST(test_command_machine_page_from_cache); + RUN_TEST(test_command_no_such_page); + RUN_TEST(test_command_new_command_replaces_cache); + RUN_TEST(test_command_error_keeps_cache); + RUN_TEST(test_command_cache_expires); + RUN_TEST(test_command_fs_cwd); + RUN_TEST(test_command_fs_mounts_and_tools); + RUN_TEST(test_command_fs_format_guards); + RUN_TEST(test_command_fs_cp_mv); + RUN_TEST(test_command_fs_upload_commit); + RUN_TEST(test_command_fs_upload_append_two_chunks); + RUN_TEST(test_command_fs_upload_commit_ok); + RUN_TEST(test_command_install_guards); + RUN_TEST(test_command_install_autodetect); + RUN_TEST(test_command_install_blank_and_local); + RUN_TEST(test_command_install_adopts_existing); + RUN_TEST(test_command_users_kick); + RUN_TEST(test_command_config); + RUN_TEST(test_command_subcommand_with_args); + RUN_TEST(test_command_help_catalog_and_topics); +} + +#endif diff --git a/tests/protocol_tests.h b/tests/protocol_tests.h new file mode 100644 index 0000000..1afe05e --- /dev/null +++ b/tests/protocol_tests.h @@ -0,0 +1,188 @@ +#pragma once +#if !MESHTASTIC_EXCLUDE_LOBBS + +#include "LoBBSReply.h" +#include "LoBBSResponse.h" +#include "protocol/machine/paginate.h" +#include "protocol/machine/serialize.h" +#include "protocol/plain_text/paginate.h" +#include +#include +#include +#include +#include +#include + +static std::string lobbsTestMachineFragmentBody(const std::string &frag) +{ + size_t nl = frag.find('\n'); + if (nl == std::string::npos) + return ""; + return frag.substr(nl + 1); +} + +static void test_protocol_plain_multi_page_footer() +{ + std::string text; + for (int i = 0; i < 20; i++) + text += "line " + std::to_string(i) + " filler text here\n"; + std::string page; + const char *err = nullptr; + TEST_ASSERT_TRUE(lobbsPaginatePlainText(text, 1, page, &err)); + TEST_ASSERT_LESS_OR_EQUAL(LOBBS_REPLY_BYTES, page.size()); + TEST_ASSERT_NOT_EQUAL(std::string::npos, page.find("{p 1/")); +} + +static void test_protocol_plain_single_page_no_footer() +{ + std::string text = "one line only"; + std::string page; + const char *err = nullptr; + TEST_ASSERT_TRUE(lobbsPaginatePlainText(text, 1, page, &err)); + TEST_ASSERT_EQUAL_STRING("one line only", page.c_str()); + TEST_ASSERT_NULL(strstr(page.c_str(), "{p ")); +} + +static void test_protocol_plain_long_line_truncated() +{ + std::string text(250, 'x'); + std::string page; + const char *err = nullptr; + TEST_ASSERT_TRUE(lobbsPaginatePlainText(text, 1, page, &err)); + TEST_ASSERT_LESS_OR_EQUAL(LOBBS_REPLY_BYTES, page.size()); + TEST_ASSERT_NOT_NULL(strstr(page.c_str(), "[...]")); +} + +static void test_protocol_plain_no_such_page() +{ + std::string text = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n"; + std::string page; + const char *err = nullptr; + TEST_ASSERT_FALSE(lobbsPaginatePlainText(text, 99, page, &err)); + TEST_ASSERT_EQUAL_STRING("No such page.", err); +} + +static void test_protocol_plain_empty() +{ + std::string page; + const char *err = nullptr; + TEST_ASSERT_FALSE(lobbsPaginatePlainText("", 1, page, &err)); + TEST_ASSERT_EQUAL_STRING("Empty.", err); +} + +static void test_protocol_machine_fragments_fit_budget() +{ + std::string doc(700, 'x'); + for (uint32_t n = 1; n <= 32; n++) { + std::string frag; + const char *err = nullptr; + if (!lobbsPaginateMachine(42, doc, n, frag, &err)) + break; + TEST_ASSERT_LESS_OR_EQUAL(LOBBS_REPLY_BYTES, frag.size()); + } +} + +static void test_protocol_machine_last_fragment_marker() +{ + std::string doc(700, 'y'); + uint32_t last = 0; + for (uint32_t n = 1; n <= 32; n++) { + std::string frag; + const char *err = nullptr; + if (!lobbsPaginateMachine(7, doc, n, frag, &err)) + break; + last = n; + } + TEST_ASSERT_TRUE(last > 1); + std::string lastFrag; + const char *err = nullptr; + TEST_ASSERT_TRUE(lobbsPaginateMachine(7, doc, last, lastFrag, &err)); + char header[32]; + snprintf(header, sizeof(header), "<7>ok [%u:%u]\n", last, last); + TEST_ASSERT_EQUAL_STRING_LEN(header, lastFrag.c_str(), strlen(header)); +} + +static void test_protocol_machine_reassemble_document() +{ + std::string doc(700, 'z'); + std::string joined; + for (uint32_t n = 1;; n++) { + std::string frag; + const char *err = nullptr; + if (!lobbsPaginateMachine(42, doc, n, frag, &err)) + break; + joined += lobbsTestMachineFragmentBody(frag); + } + TEST_ASSERT_EQUAL_STRING(doc.c_str(), joined.c_str()); +} + +static void test_protocol_machine_single_no_double_ok() +{ + LoBBSResponse resp; + LoScalar rec; + rec.setString(LODB_F_TITLE, "hello"); + resp.records.push_back(rec); + std::string doc; + TEST_ASSERT_TRUE(lobbsSerializeMachine(resp, doc)); + TEST_ASSERT_NULL(strstr(doc.c_str(), "ok")); + std::string frag; + const char *err = nullptr; + TEST_ASSERT_TRUE(lobbsPaginateMachine(99, doc, 1, frag, &err)); + TEST_ASSERT_NOT_NULL(strstr(frag.c_str(), "<99>ok\n")); + std::string body = lobbsTestMachineFragmentBody(frag); + TEST_ASSERT_FALSE(body.rfind("ok\n", 0) == 0); +} + +static void test_protocol_machine_req_id_in_header() +{ + std::string doc(400, 'a'); + std::string f1; + std::string f2; + const char *err = nullptr; + TEST_ASSERT_TRUE(lobbsPaginateMachine(1, doc, 1, f1, &err)); + TEST_ASSERT_TRUE(lobbsPaginateMachine(999, doc, 1, f2, &err)); + TEST_ASSERT_NOT_NULL(strstr(f1.c_str(), "<1>ok [1:")); + TEST_ASSERT_NOT_NULL(strstr(f2.c_str(), "<999>ok [1:")); +} + +static void test_protocol_machine_serialize_error() +{ + LoBBSResponse resp; + resp.ok = false; + resp.error = "Nope."; + std::string out; + TEST_ASSERT_TRUE(lobbsSerializeMachine(resp, out)); + TEST_ASSERT_EQUAL_STRING("Nope.", out.c_str()); +} + +static void test_protocol_machine_serialize_escapes_and_fields() +{ + LoScalar rec; + rec.setString(LODB_F_ID, "id1"); + rec.setString(LODB_F_TITLE, "a|b\\c\nd"); + LoBBSResponse resp; + resp.records.push_back(rec); + std::string line; + TEST_ASSERT_TRUE(lobbsSerializeMachine(resp, line)); + TEST_ASSERT_NOT_NULL(strstr(line.c_str(), "99:id1")); + TEST_ASSERT_NOT_NULL(strstr(line.c_str(), "98:a\\|b\\\\c\\nd")); + TEST_ASSERT_TRUE(line.find("98:") < line.find("99:")); +} + +inline void lobbsRunProtocolTests() +{ + RUN_TEST(test_protocol_plain_multi_page_footer); + RUN_TEST(test_protocol_plain_single_page_no_footer); + RUN_TEST(test_protocol_plain_long_line_truncated); + RUN_TEST(test_protocol_plain_no_such_page); + RUN_TEST(test_protocol_plain_empty); + RUN_TEST(test_protocol_machine_fragments_fit_budget); + RUN_TEST(test_protocol_machine_last_fragment_marker); + RUN_TEST(test_protocol_machine_reassemble_document); + RUN_TEST(test_protocol_machine_single_no_double_ok); + RUN_TEST(test_protocol_machine_req_id_in_header); + RUN_TEST(test_protocol_machine_serialize_error); + RUN_TEST(test_protocol_machine_serialize_escapes_and_fields); +} + +#endif