Storage: share the card with a phone's browser (#88) #89

Merged
twisla merged 5 commits from web-files into main 2026-10-07 22:37:13 +00:00
16 changed files with 934 additions and 5 deletions
Showing only changes of commit 6b02cd3d5f - Show all commits
+2
View File
@@ -136,6 +136,8 @@ The Storage App (docs/milestones/F1.md) shows what's on the SD card: each folder
A copy runs in the background of the card (about 400 KB a second) in short turns, so Logs and Captures keep being written; it shows its progress, Back cancels it and takes back what was copied, and each file's size is checked afterwards. Three things can't be changed: the top-level folders the firmware keeps its files in (what's inside them can), `/gemini/cache`, and any file being written right now (today's IRC Logs, a Track or a Capture being recorded). The App says why when it refuses. A folder with more than 256 entries shows the first 256 by name and says so.
**`w` shares the card with a browser on the same network** (issue #88): a small HTTP server and one page, for a phone with nothing to install. The screen shows the address as a QR code and a six-digit code, new each time; whoever has typed it can list, download, upload (streamed to the card under a temporary name), make folders and delete, under the Storage App's rules. It runs only while that screen is open, takes one request at a time, moves about 200 KB a second, and is not encrypted. It costs 57 KB of flash, and 13 KB of memory while it is on.
Enter on a file opens it by type; Tab switches to the same file as a hex dump or as text:
- **Text** (`.txt`, `.log`, `.gmi`, `.csv`, and anything that looks like text): only the screen's worth is read from the card, so a file of any size opens at once. Logs open at the end. Up and Down move a line, Left and Right a page, `t` and `b` go to the top and the end, `e` edits it, whatever its size (see Notes).
+41
View File
@@ -290,3 +290,44 @@ Test pictures were copied to a scratch folder and removed afterwards, with the t
**A decoder that worked once.** The library's PNG decoder showed the first picture and refused the next five: "no memory". It wants 44 KB in one piece, and after some use the largest piece is 43 to 47 KB. Writing a decoder that needs 32 KB was less work than it sounds, and unlike the library's it has tests.
**Two sentences still said "up to 16 KB"** about editing, in the README and the Storage guide, after issue #47 lifted that. Corrected here.
## Sharing the card with a browser (issue #88)
Files reached the card through the Debug Console's `put` and `get`, or by taking the card out. A phone has neither.
### Decisions (2026-10-07; the recommendation was accepted as it stood, without a round of questions)
- **A web page, not FTP, SFTP or WebDAV.** A browser is the only client every phone has. FTP and WebDAV need an app there; SFTP needs a whole SSH server here. WebDAV can come later on the same server, for computers.
- **Off unless asked for:** `w` in the Storage App opens a "Share" screen, and the server runs only while that screen is open.
- **A six-digit code on the screen**, new each time, typed in the page; the address is also a QR code, which carries the code. Five wrong codes close it for a minute (the Debug Console's `AuthGate`). A browser that got it right holds a cookie; starting again puts every browser out.
- **Not encrypted.** A TLS server costs about 40 KB of memory a connection. The screen says so.
- **The Storage App's rules** (`whyReadOnly`): the firmware's own folders, and files in use.
### As built
- **`WebShare`** (`src/services/web_share`): ESP-IDF's HTTP server, which is in the framework already. Seven requests: the page, the code, a listing as JSON, a download, an upload, a new folder, a delete.
- **Every access to the card is handed to the storage task**, 8 KB at a time, from the server's own task: a download and an upload are loops of "one piece from the card, one piece to the network".
- **An upload is the request's body**, as the browser's `PUT` sends it: no form to take apart. It goes to `<name>.part` and is renamed when the last byte has come; anything less is removed. A file that exists is refused unless the page asked, after asking the user.
- **The page** (`web_share_page.h`) is one file of 5.4 KB with its style and script in it, served from flash.
- **`lib/files/src/share_rules.h`** (host-tested, 5 tests): what a request names, which paths a browser may ask for (from the root, no `..`), the JSON, the code and the cookie.
- **Cost:** 57 KB of flash, most of it the server. 13 KB of memory while sharing (108.4 KB free before, 95.4 with the screen open), given back on leaving.
### Checks on the device (2026-10-07 and 08)
A scratch folder was used and removed.
| Check | Result |
|---|---|
| `w` | The QR code, the address and the code; `share: on` on the console |
| The page, and a listing without the code | 200; 401 |
| A wrong code, the right one (typed `825 132`) | 403; in |
| Upload, 2.6 MB | 11 to 17 s (150 to 230 KB/s); downloaded again and compared: the same, byte for byte |
| The same name again; with "replace" | 409; replaced |
| `..` in a path; deleting `/notes`; deleting a folder that isn't empty | 400; 403 "The firmware keeps its files in /notes"; 403 |
| Five wrong codes | The fifth and every one after: 429, the right code too. A browser that was in stays in |
| In Chromium at a phone's width | The scanned address logs in by itself; two files uploaded, one downloaded and compared, a folder made, a file deleted after asking, a replacement after asking, the refusal shown. No sideways scroll |
| Back | The server is gone (connection refused), memory is back |
**Found on the way:** the server answers one request at a time. A second request during a slow download waited until it had ended. It is said in the guide, and not changed.
**Not checked:** a real phone, and its camera on the QR code. Safari. A card pulled during a transfer. Sharing with IRC connected, when memory is shorter. Home, and the screen turning off, while sharing (the code stops the server on leaving the App; only Back was tried).
+6
View File
@@ -223,9 +223,15 @@ inline constexpr KeyHelp kStorage[] = {
{"i", "details: size, date, type"},
{"s", "sort: name, date, size"},
{"m", "Maintenance: clean-up, erase"},
{"w", "share with a browser"},
{"`", "the folder above"},
};
// storage-share: Storage, sharing with a browser
inline constexpr KeyHelp kStorageShare[] = {
{"`", "stop sharing"},
};
// storage-details: Storage, an item's details
inline constexpr KeyHelp kStorageDetails[] = {
{"; .", "scroll"},
+135
View File
@@ -0,0 +1,135 @@
#include "share_rules.h"
#include <cstdio>
namespace roro::files {
namespace {
int hexDigit(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
// Whatever the two strings hold, the time taken says nothing about where they differ.
bool sameText(const std::string& a, const std::string& b) {
unsigned diff = static_cast<unsigned>(a.size() ^ b.size());
for (size_t i = 0; i < a.size() && i < b.size(); i++) diff |= static_cast<unsigned char>(a[i]) ^ static_cast<unsigned char>(b[i]);
return diff == 0;
}
} // namespace
std::string urlDecode(const std::string& text) {
std::string out;
out.reserve(text.size());
for (size_t i = 0; i < text.size(); i++) {
int hi, lo;
if (text[i] == '%' && i + 2 < text.size() + 0 && (hi = hexDigit(text[i + 1])) >= 0 && (lo = hexDigit(text[i + 2])) >= 0) {
out += static_cast<char>(hi * 16 + lo);
i += 2;
} else {
out += text[i];
}
}
return out;
}
bool queryParam(const std::string& query, const std::string& key, std::string& out) {
for (size_t at = 0; at <= query.size();) {
size_t amp = query.find('&', at);
if (amp == std::string::npos) amp = query.size();
size_t eq = query.find('=', at);
if (eq != std::string::npos && eq < amp && query.compare(at, eq - at, key) == 0) {
out = urlDecode(query.substr(eq + 1, amp - eq - 1));
return true;
}
at = amp + 1;
}
return false;
}
std::string cookieValue(const std::string& header, const std::string& name) {
for (size_t at = 0; at < header.size();) {
while (at < header.size() && (header[at] == ' ' || header[at] == ';')) at++;
size_t end = header.find(';', at);
if (end == std::string::npos) end = header.size();
size_t eq = header.find('=', at);
if (eq != std::string::npos && eq < end && header.compare(at, eq - at, name) == 0) return header.substr(eq + 1, end - eq - 1);
at = end;
}
return "";
}
std::string checkSharePath(const std::string& path) {
if (path.empty() || path[0] != '/') return "a path starts with /";
if (path.size() > 255) return "that path is too long";
if (path.size() > 1 && path.back() == '/') return "a path doesn't end with /";
for (size_t at = 1; at < path.size();) {
size_t end = path.find('/', at);
if (end == std::string::npos) end = path.size();
std::string part = path.substr(at, end - at);
if (part.empty() || part == "." || part == "..") return "that isn't a path on the card";
for (char c : part)
if (static_cast<unsigned char>(c) < 0x20 || c == 0x7F || c == '\\' || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || c == '>' || c == '|')
return "a name can't hold that character";
at = end + 1;
}
return "";
}
std::string jsonString(const std::string& text) {
std::string out = "\"";
for (char c : text) {
unsigned char u = static_cast<unsigned char>(c);
if (c == '"' || c == '\\') {
out += '\\';
out += c;
} else if (u < 0x20) {
char buf[8];
std::snprintf(buf, sizeof buf, "\\u%04x", u);
out += buf;
} else {
out += c;
}
}
return out + "\"";
}
ShareListing::ShareListing(const std::string& path) : out_("{\"path\":" + jsonString(path) + ",\"items\":[") {}
void ShareListing::add(const std::string& name, uint32_t size, bool folder, int64_t modified) {
if (count_++) out_ += ',';
out_ += "{\"n\":" + jsonString(name) + ",\"s\":" + std::to_string(size) + ",\"d\":" + (folder ? "1" : "0") + ",\"t\":" + std::to_string(modified) + "}";
}
std::string ShareListing::json(bool more) { return out_ + "],\"more\":" + (more ? "true" : "false") + "}"; }
void ShareAuth::begin(const uint8_t random[4]) {
uint32_t n = (static_cast<uint32_t>(random[0]) << 24 | random[1] << 16 | random[2] << 8 | random[3]) % 1000000u;
char buf[8];
std::snprintf(buf, sizeof buf, "%06u", static_cast<unsigned>(n));
code_ = buf;
token_.clear();
gate_ = debug::AuthGate();
}
ShareAuth::Result ShareAuth::login(const std::string& code, uint32_t nowMs, const uint8_t random[16], std::string& token) {
if (code_.empty() || gate_.locked(nowMs)) return Result::Locked;
std::string digits;
for (char c : code)
if (c >= '0' && c <= '9') digits += c; // "123 456" is as good
if (!sameText(digits, code_)) return gate_.failed(nowMs) ? Result::Locked : Result::Wrong;
gate_.succeeded();
static const char* const kHex = "0123456789abcdef";
token_.clear();
for (int i = 0; i < 16; i++) {
token_ += kHex[random[i] >> 4];
token_ += kHex[random[i] & 15];
}
token = token_;
return Result::Ok;
}
bool ShareAuth::allowed(const std::string& token) const { return !token_.empty() && sameText(token, token_); }
} // namespace roro::files
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include <cstdint>
#include <string>
#include "debug_auth.h"
// The parts of sharing files with a browser (issue #88) that need no network: what a request
// asks for, whether it may, and the answers as JSON. The server itself is src/services/web_share.h.
namespace roro::files {
std::string urlDecode(const std::string& text); // %41 is A; a + stays a +
// The value of `key` in a query string ("path=%2Fnotes&replace=1"), decoded. False if it isn't there.
bool queryParam(const std::string& query, const std::string& key, std::string& out);
// The value of a cookie in a Cookie header ("a=1; s=abc"), or "".
std::string cookieValue(const std::string& header, const std::string& name);
// A path a browser may name: from the card's root, no "..", nothing a file name can't hold.
// "" or why not.
std::string checkSharePath(const std::string& path);
std::string jsonString(const std::string& text); // with its quotes
// A folder's listing as the page wants it: {"path":"/notes","items":[{"n":"a.txt","s":12,"d":0,"t":1791400000}],"more":false}
class ShareListing {
public:
explicit ShareListing(const std::string& path);
void add(const std::string& name, uint32_t size, bool folder, int64_t modified);
std::string json(bool more);
size_t count() const { return count_; }
private:
std::string out_;
size_t count_ = 0;
};
// Who may use the page: whoever typed the code the device's screen shows. The code is new each
// time sharing starts; five wrong ones in a row close the door for a minute (as the Debug
// Console's token does). A browser that got it right is given a token to send back as a cookie.
// Nothing here is encrypted on the way: see the issue.
class ShareAuth {
public:
enum class Result { Ok, Wrong, Locked };
void begin(const uint8_t random[4]); // a new code, and nobody is logged in
const std::string& code() const { return code_; } // six digits
Result login(const std::string& code, uint32_t nowMs, const uint8_t random[16], std::string& token);
bool allowed(const std::string& token) const;
private:
std::string code_, token_;
debug::AuthGate gate_;
};
} // namespace roro::files
+41
View File
@@ -298,3 +298,44 @@ Test pictures were copied to a scratch folder and removed afterwards, with the t
**A decoder that worked once.** The library's PNG decoder showed the first picture and refused the next five: "no memory". It wants 44 KB in one piece, and after some use the largest piece is 43 to 47 KB. Writing a decoder that needs 32 KB was less work than it sounds, and unlike the library's it has tests.
**Two sentences still said "up to 16 KB"** about editing, in the README and the Storage guide, after issue #47 lifted that. Corrected here.
## Sharing the card with a browser (issue #88)
Files reached the card through the Debug Console's `put` and `get`, or by taking the card out. A phone has neither.
### Decisions (2026-10-07; the recommendation was accepted as it stood, without a round of questions)
- **A web page, not FTP, SFTP or WebDAV.** A browser is the only client every phone has. FTP and WebDAV need an app there; SFTP needs a whole SSH server here. WebDAV can come later on the same server, for computers.
- **Off unless asked for:** `w` in the Storage App opens a "Share" screen, and the server runs only while that screen is open.
- **A six-digit code on the screen**, new each time, typed in the page; the address is also a QR code, which carries the code. Five wrong codes close it for a minute (the Debug Console's `AuthGate`). A browser that got it right holds a cookie; starting again puts every browser out.
- **Not encrypted.** A TLS server costs about 40 KB of memory a connection. The screen says so.
- **The Storage App's rules** (`whyReadOnly`): the firmware's own folders, and files in use.
### As built
- **`WebShare`** (`src/services/web_share`): ESP-IDF's HTTP server, which is in the framework already. Seven requests: the page, the code, a listing as JSON, a download, an upload, a new folder, a delete.
- **Every access to the card is handed to the storage task**, 8 KB at a time, from the server's own task: a download and an upload are loops of "one piece from the card, one piece to the network".
- **An upload is the request's body**, as the browser's `PUT` sends it: no form to take apart. It goes to `<name>.part` and is renamed when the last byte has come; anything less is removed. A file that exists is refused unless the page asked, after asking the user.
- **The page** (`web_share_page.h`) is one file of 5.4 KB with its style and script in it, served from flash.
- **`lib/files/src/share_rules.h`** (host-tested, 5 tests): what a request names, which paths a browser may ask for (from the root, no `..`), the JSON, the code and the cookie.
- **Cost:** 57 KB of flash, most of it the server. 13 KB of memory while sharing (108.4 KB free before, 95.4 with the screen open), given back on leaving.
### Checks on the device (2026-10-07 and 08)
A scratch folder was used and removed.
| Check | Result |
|---|---|
| `w` | The QR code, the address and the code; `share: on` on the console |
| The page, and a listing without the code | 200; 401 |
| A wrong code, the right one (typed `825 132`) | 403; in |
| Upload, 2.6 MB | 11 to 17 s (150 to 230 KB/s); downloaded again and compared: the same, byte for byte |
| The same name again; with "replace" | 409; replaced |
| `..` in a path; deleting `/notes`; deleting a folder that isn't empty | 400; 403 "The firmware keeps its files in /notes"; 403 |
| Five wrong codes | The fifth and every one after: 429, the right code too. A browser that was in stays in |
| In Chromium at a phone's width | The scanned address logs in by itself; two files uploaded, one downloaded and compared, a folder made, a file deleted after asking, a replacement after asking, the refusal shown. No sideways scroll |
| Back | The server is gone (connection refused), memory is back |
**Found on the way:** the server answers one request at a time. A second request during a slow download waited until it had ended. It is said in the guide, and not changed.
**Not checked:** a real phone, and its camera on the QR code. Safari. A card pulled during a transfer. Sharing with IRC connected, when memory is shorter. Home, and the screen turning off, while sharing (the code stops the server on leaving the App; only Back was tried).
+4
View File
@@ -59,6 +59,10 @@ Yes: it is [open source](https://git.twis.la/twisla/roro9stack) (GPL-3.0). The d
A secure connection takes about 52 KB of the 107 KB the device has, and IRC's takes about 40 KB. Both together do not always fit. See [When a connection says "not enough memory"](/howto/not-enough-memory/).
## How do I copy files to and from my phone?
In the Storage App, press <kbd>w</kbd>: the device serves a small web page to any browser on the same Wi-Fi. Scan the QR code it shows, type the code, and upload or download. Nothing to install. See [From a phone](/guide/storage/#from-a-phone).
## Do I need an SD card?
For the radio, GNSS position, Wi-Fi tools, IRC chat and Gemini browsing, no. For anything that is *kept*, yes: notes, IRC logs, Wi-Fi scan logs, GNSS Tracks, LoRa captures, saved Gemini pages and update files. See [Find your files on the SD card](/howto/sd-files/).
+17 -1
View File
@@ -51,6 +51,22 @@ The App says why when it refuses.
- **What can't be shown** opens as hex, with the reason: a progressive JPEG, an interlaced PNG, a BMP that is compressed or has 16 bits a pixel.
- **A PNG needs 32 KB of memory in one piece** while it is decoded, and a few more (a JPEG needs 4 KB, a GIF 17 KB). With IRC connected there may not be that much: the viewer says so. The firmware's own screenshots need none.
## From a phone
Press <kbd>w</kbd> in the Storage App to **share the card with a browser** on the same network. The screen shows an address, as a QR code and in letters, and a six-digit code.
1. On the phone (or any computer on the same Wi-Fi), scan the QR code, or type the address and then the code.
2. The page lists the card. Tap a folder to open it and a file to download it. **Upload files** sends files from the phone into the folder you are in; **New folder** and **Delete** do what they say.
3. Press Back on the device to stop. Sharing also stops when you leave the Storage App.
What to know:
- **It runs only while that screen is open**, and the code is new each time. Five wrong codes close the door for a minute.
- **It is not encrypted.** On your own network that is the usual trade; on a network you don't trust, someone listening could read the files and the code. Inside a VPN tunnel it is protected.
- **One thing at a time:** while a big file is going up or down, the page waits. About 200 KB a second.
- The same rules as on the device: the folders the firmware keeps for itself can't be deleted, and a folder has to be empty to be deleted from the page.
- An upload is written under a temporary name and renamed when it is whole, so a transfer that is cut leaves nothing behind.
## Maintenance
At the top of the card, the last row, **Maintenance** (or <kbd>m</kbd>), shows the card's usage and holds **Storage clean-up** and **Erase SD card**. They delete for good, so they sit behind a warning. Clean-up deletes old logs and captures by category and age, showing the space it would free first. Notes and Saved Pages are never offered.
@@ -61,4 +77,4 @@ The firmware warns once per start when the card passes **80%** full; past **90%*
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
{{ keys(scopes=["storage", "storage-details", "storage-name", "storage-busy", "maintenance", "viewer-text", "viewer-hex", "viewer-pcap", "viewer-packet", "viewer-gpx", "viewer-ota", "viewer-image"]) }}
{{ keys(scopes=["storage", "storage-details", "storage-name", "storage-busy", "maintenance", "viewer-text", "viewer-hex", "viewer-pcap", "viewer-packet", "viewer-gpx", "viewer-ota", "viewer-image", "storage-share"]) }}
+8
View File
@@ -249,9 +249,17 @@ rows = [
["i", "details: size, date, type"],
["s", "sort: name, date, size"],
["m", "Maintenance: clean-up, erase"],
["w", "share with a browser"],
["`", "the folder above"],
]
[[scope]]
id = "storage-share"
title = "Storage, sharing with a browser"
rows = [
["`", "stop sharing"],
]
[[scope]]
id = "storage-details"
title = "Storage, an item's details"
+56
View File
@@ -48,6 +48,7 @@ void StorageApp::onEnter() {
}
void StorageApp::onExit() {
share_.stop(); // sharing lasts as long as its screen
if (wait_ != Wait::None && wait_ != Wait::Work) {
ops_.cancel();
wait_ = Wait::None;
@@ -97,6 +98,13 @@ bool StorageApp::work(const std::string& why, const std::string& selectAfter) {
void StorageApp::update(uint32_t nowMs) {
bool present = storage_.state().present;
if (view_ == View::Share) {
if (!present || !share_.running()) {
share_.stop();
view_ = View::Browse;
}
if (nowMs - lastDrawMs_ >= 500) requestRedraw(); // what the browser is doing
}
if (present != cardPresent_) { // taken out, put in, or erased: back to the top
cardPresent_ = present;
cwd_ = want_ = "/";
@@ -281,6 +289,7 @@ void StorageApp::help(std::vector<KeyHelp>& out) const {
case View::Editor: return noteEditor_.help(out);
case View::Viewer: return viewer_.help(out);
case View::Details: return keys::add(out, keys::kStorageDetails);
case View::Share: return keys::add(out, keys::kStorageShare);
default: break;
}
if (dialog_) return keys::add(out, keys::kDialog);
@@ -295,6 +304,7 @@ const char* StorageApp::helpTitle() const {
case View::Editor: return "Storage: the editor";
case View::Viewer: return "Storage: a file";
case View::Details: return "Storage: details";
case View::Share: return "Storage: sharing";
case View::Name: return "Storage: a name";
default: return nullptr;
}
@@ -303,6 +313,14 @@ const char* StorageApp::helpTitle() const {
bool StorageApp::onKey(const KeyEvent& e) {
requestRedraw();
if (view_ == View::NoMemory) return false;
if (view_ == View::Share) {
if (e.key == Key::Back) {
share_.stop();
view_ = View::Browse;
open(cwd_); // what was uploaded or deleted meanwhile
}
return true;
}
if (view_ == View::Maintenance) {
if (!maintenance_.onKey(e)) {
view_ = View::Browse;
@@ -413,6 +431,12 @@ bool StorageApp::onBrowseKey(const KeyEvent& e) {
}
uint32_t ch = e.key == Key::Char ? (e.ch >= 'A' && e.ch <= 'Z' ? e.ch + 32 : e.ch) : 0;
if (ch == 'm') askMaintenance();
if (ch == 'w') {
std::string why = share_.start();
if (why.empty()) view_ = View::Share;
else say(why);
return true;
}
if (!cardPresent_ || !loaded_) return true;
Item item = selected();
@@ -507,6 +531,7 @@ void StorageApp::draw(Canvas& c) {
c.drawString("in Gemini) and open Storage again.", 4, area.y + 22 + theme::kLineHeight);
return;
case View::Maintenance: maintenance_.draw(c); return;
case View::Share: drawShare(c); return;
case View::Viewer: viewer_.draw(c); return;
case View::Editor: noteEditor_.draw(c); return;
case View::Details: {
@@ -539,6 +564,37 @@ void StorageApp::draw(Canvas& c) {
}
}
// The address as a QR code and in letters, the code to type, and what the browser has done.
void StorageApp::drawShare(Canvas& c) {
const auto& area = theme::kContent;
std::string url = share_.url(), code = share_.code();
const int qr = 104;
c.fillRect(2, area.y + 3, qr + 6, qr + 6, 0xFFFF); // a QR code wants a quiet border
c.qrcode((url + "#" + code).c_str(), 5, area.y + 6, qr);
int x = qr + 14, y = area.y + 4;
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.drawString("In a browser, on", x, y);
c.drawString("this network:", x, y + 10);
c.setTextColor(theme::kText);
c.drawString(url.size() > 7 ? url.substr(7).c_str() : url.c_str(), x, y + 24); // without http://
c.setTextColor(theme::kMuted);
c.drawString("Code", x, y + 40);
c.setFont(&fonts::bold);
c.setTextColor(theme::kAccent);
std::string grouped = code.size() == 6 ? code.substr(0, 3) + " " + code.substr(3) : code;
c.drawString(grouped.c_str(), x, y + 50);
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
std::string moved = formatBytes(share_.bytesIn()) + " in, " + formatBytes(share_.bytesOut()) + " out";
c.drawString(share_.requests() ? moved.c_str() : "Nothing asked yet", x, y + 72);
c.drawString("Not encrypted.", x, y + 84);
c.drawString("` stops sharing", x, y + 96);
c.setTextColor(theme::kText);
std::string last = share_.last();
if (!last.empty()) c.drawString(last.c_str(), 4, area.y + area.h - 9);
}
void StorageApp::drawBrowse(Canvas& c) {
const auto& area = theme::kContent;
StorageState card = storage_.state();
+6 -3
View File
@@ -16,6 +16,7 @@
#include "services/clock_service.h"
#include "services/file_ops.h"
#include "services/storage_service.h"
#include "services/web_share.h"
#include "ui/theme.h"
namespace roro {
@@ -26,8 +27,8 @@ namespace roro {
// usage, Storage Clean-up and erasing the card, behind a warning (Q128).
class StorageApp : public App {
public:
StorageApp(FileOps& ops, StorageService& storage, ClockService& clock, UpdateService& update, PowerService& power, EventBus& bus)
: ops_(ops), storage_(storage), bus_(bus), maintenance_(storage, clock, bus), viewer_(storage, update), noteEditor_(storage, clock, power) {}
StorageApp(FileOps& ops, StorageService& storage, ClockService& clock, UpdateService& update, PowerService& power, EventBus& bus, WebShare& share)
: ops_(ops), storage_(storage), bus_(bus), share_(share), maintenance_(storage, clock, bus), viewer_(storage, update), noteEditor_(storage, clock, power) {}
void onEnter() override;
void onExit() override;
bool onKey(const KeyEvent& e) override;
@@ -40,7 +41,7 @@ class StorageApp : public App {
const char* helpTitle() const override;
private:
enum class View { Browse, Details, Name, Viewer, Editor, Maintenance, NoMemory };
enum class View { Browse, Details, Name, Viewer, Editor, Maintenance, NoMemory, Share };
enum class Ask { None, Delete, Replace, Maintenance };
enum class Wait { None, List, CountToDelete, CountForDetails, Work }; // what the running operation is for
static constexpr int kRows = 8;
@@ -66,10 +67,12 @@ class StorageApp : public App {
void say(const std::string& text);
void drawBrowse(Canvas& c);
void drawProgress(Canvas& c);
void drawShare(Canvas& c); // `w`: the card in a browser on the same network (issue #88)
FileOps& ops_;
StorageService& storage_;
EventBus& bus_;
WebShare& share_;
MaintenancePage maintenance_;
FileViewer viewer_;
NoteEditor noteEditor_; // `e` in the text viewer (Q146)
+2 -1
View File
@@ -12,6 +12,7 @@
#include "apps/demo_app.h"
#include "apps/note_editor.h"
#include "apps/shell_app.h"
#include "services/web_share.h"
#include "apps/gemini_app.h"
#include "apps/gnss_app.h"
#include "apps/irc_app.h"
@@ -216,7 +217,7 @@ void setup() {
apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings)});
apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService)});
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)});
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus)});
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus, *new WebShare(*storageService, *fileOps, *wifi))});
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)});
apps->registerApp({"shell", "Shell", false, new ShellApp(shellRun, shellProbe, shellList, shellCount, helpText(), *apps)});
// Leaving the foreground App makes it save: a note being typed, when the device is powered off.
+335
View File
@@ -0,0 +1,335 @@
#include "services/web_share.h"
#include <Arduino.h>
#include <SD.h>
#include <esp_http_server.h>
#include <esp_random.h>
#include <memory>
#include "file_list.h"
#include "file_names.h"
#include "platform/console.h"
#include "services/web_share_page.h"
namespace roro {
namespace {
constexpr size_t kPiece = 8192; // read from or written to the card at once
constexpr size_t kMaxListed = 300;
WebShare& shareOf(httpd_req_t* req) { return *static_cast<WebShare*>(req->user_ctx); }
esp_err_t reply(httpd_req_t* req, const char* status, const std::string& text) {
httpd_resp_set_status(req, status);
httpd_resp_set_type(req, "text/plain; charset=utf-8");
httpd_resp_set_hdr(req, "Cache-Control", "no-store");
return httpd_resp_send(req, text.c_str(), static_cast<ssize_t>(text.size()));
}
std::string queryOf(httpd_req_t* req) {
size_t len = httpd_req_get_url_query_len(req);
if (!len || len > 600) return "";
std::string q(len + 1, '\0');
if (httpd_req_get_url_query_str(req, &q[0], len + 1) != ESP_OK) return "";
q.resize(len);
return q;
}
// The request's path, checked; or an answer already sent and false.
bool pathOf(httpd_req_t* req, std::string& path) {
std::string query = queryOf(req);
if (!files::queryParam(query, "path", path)) return reply(req, "400 Bad Request", "No path"), false;
std::string why = files::checkSharePath(path);
if (!why.empty()) return reply(req, "400 Bad Request", why), false;
return true;
}
// Has this browser typed the code? If not: 401, and false.
bool allowed(httpd_req_t* req) {
char cookie[160] = "";
httpd_req_get_hdr_value_str(req, "Cookie", cookie, sizeof cookie);
if (shareOf(req).auth().allowed(files::cookieValue(cookie, "s"))) return true;
reply(req, "401 Unauthorized", "The code, please");
return false;
}
esp_err_t noCard(httpd_req_t* req) { return reply(req, "503 Service Unavailable", "No SD card"); }
esp_err_t page(httpd_req_t* req) {
httpd_resp_set_type(req, "text/html; charset=utf-8");
httpd_resp_set_hdr(req, "Cache-Control", "no-store");
return httpd_resp_send(req, kWebSharePage, sizeof kWebSharePage - 1);
}
esp_err_t login(httpd_req_t* req) {
char body[64] = "";
int n = httpd_req_recv(req, body, std::min<size_t>(req->content_len, sizeof body - 1));
std::string code;
files::queryParam(n > 0 ? std::string(body, static_cast<size_t>(n)) : "", "code", code);
uint8_t random[16];
esp_fill_random(random, sizeof random);
std::string token;
switch (shareOf(req).auth().login(code, millis(), random, token)) {
case files::ShareAuth::Result::Ok: {
std::string cookie = "s=" + token + "; HttpOnly; SameSite=Strict; Path=/";
httpd_resp_set_hdr(req, "Set-Cookie", cookie.c_str());
shareOf(req).note("a browser opened", "");
return reply(req, "200 OK", "ok");
}
case files::ShareAuth::Result::Locked: return reply(req, "429 Too Many Requests", "Too many wrong codes: wait a minute");
default: return reply(req, "403 Forbidden", "That isn't the code on the screen");
}
}
esp_err_t list(httpd_req_t* req) {
std::string path;
if (!allowed(req) || !pathOf(req, path)) return ESP_OK;
WebShare& share = shareOf(req);
std::string json, why;
bool ran = share.storage().runAndWait([&]() {
File dir = SD.open(path.c_str());
if (!dir || !dir.isDirectory()) {
why = "No such folder";
return;
}
files::ShareListing listing(path);
bool more = false;
for (File f = dir.openNextFile(); f; f = dir.openNextFile()) {
if (listing.count() >= kMaxListed) {
more = true;
f.close();
break;
}
listing.add(f.name(), static_cast<uint32_t>(f.size()), f.isDirectory(), static_cast<int64_t>(f.getLastWrite()));
f.close();
}
dir.close();
json = listing.json(more);
});
if (!ran) return noCard(req);
if (!why.empty()) return reply(req, "404 Not Found", why);
share.note("listed", path);
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Cache-Control", "no-store");
return httpd_resp_send(req, json.c_str(), static_cast<ssize_t>(json.size()));
}
esp_err_t download(httpd_req_t* req) {
std::string path;
if (!allowed(req) || !pathOf(req, path)) return ESP_OK;
WebShare& share = shareOf(req);
auto file = std::make_shared<File>();
std::unique_ptr<char[]> piece(new (std::nothrow) char[kPiece]);
if (!piece) return reply(req, "503 Service Unavailable", "Not enough memory");
bool found = false;
if (!share.storage().runAndWait([&]() {
*file = SD.open(path.c_str(), FILE_READ);
found = *file && !file->isDirectory();
if (*file && !found) file->close();
}))
return noCard(req);
if (!found) return reply(req, "404 Not Found", "No such file");
share.note("sent", path);
std::string disposition = "attachment; filename=" + files::jsonString(files::baseName(path));
httpd_resp_set_type(req, "application/octet-stream");
httpd_resp_set_hdr(req, "Content-Disposition", disposition.c_str());
esp_err_t err = ESP_OK;
for (;;) {
int n = 0;
if (!share.storage().runAndWait([&]() { n = file->read(reinterpret_cast<uint8_t*>(piece.get()), kPiece); })) {
err = ESP_FAIL;
break;
}
if (n <= 0) break;
err = httpd_resp_send_chunk(req, piece.get(), n);
if (err != ESP_OK) break; // the browser went away
share.counted(0, static_cast<uint32_t>(n));
}
share.storage().runJob([file]() { file->close(); });
if (err == ESP_OK) return httpd_resp_send_chunk(req, nullptr, 0);
return ESP_FAIL; // closes the connection: the browser sees a download cut short, not a whole file
}
// The file arrives as the request's body and goes to the card a piece at a time, under a
// temporary name: the real one only ever holds a whole file.
esp_err_t upload(httpd_req_t* req) {
std::string path;
if (!allowed(req) || !pathOf(req, path)) return ESP_OK;
WebShare& share = shareOf(req);
std::string replace;
bool mayReplace = files::queryParam(queryOf(req), "replace", replace) && replace == "1";
std::string name = files::baseName(path), why = name.empty() ? "No name" : files::checkName(name);
if (!why.empty()) return reply(req, "400 Bad Request", why);
std::string part = path + ".part";
auto file = std::make_shared<File>();
std::unique_ptr<char[]> piece(new (std::nothrow) char[kPiece]);
if (!piece) return reply(req, "503 Service Unavailable", "Not enough memory");
const char* status = nullptr;
size_t total = req->content_len;
bool ran = share.storage().runAndWait([&]() {
File parent = SD.open(files::parentOf(path).c_str());
bool folder = parent && parent.isDirectory();
if (parent) parent.close();
if (!folder) {
status = "404 Not Found";
why = "No such folder";
return;
}
if (SD.exists(path.c_str())) {
File there = SD.open(path.c_str());
bool isFolder = there && there.isDirectory();
if (there) there.close();
std::string readOnly = isFolder ? "A folder has that name" : mayReplace ? share.ops().whyReadOnly(path, false) : "";
if (!readOnly.empty()) {
status = "403 Forbidden";
why = readOnly;
return;
}
if (!mayReplace) {
status = "409 Conflict";
why = "It is there already";
return;
}
}
StorageState state = share.storage().state();
if (state.totalBytes - state.usedBytes < static_cast<uint64_t>(total) + 65536) {
status = "507 Insufficient Storage";
why = "Not enough room on the card";
return;
}
*file = SD.open(part.c_str(), FILE_WRITE);
if (!*file) {
status = "500 Internal Server Error";
why = "The card refused to make the file";
}
});
if (!ran) return noCard(req);
if (status) return reply(req, status, why);
share.note("receiving", path);
size_t got = 0;
int idle = 0;
bool wrote = true;
while (got < total && wrote) {
int n = httpd_req_recv(req, piece.get(), std::min(kPiece, total - got));
if (n == HTTPD_SOCK_ERR_TIMEOUT && ++idle < 3) continue;
if (n <= 0) break;
idle = 0;
if (!share.storage().runAndWait([&]() { wrote = file->write(reinterpret_cast<const uint8_t*>(piece.get()), static_cast<size_t>(n)) == static_cast<size_t>(n); })) wrote = false;
got += static_cast<size_t>(n);
share.counted(static_cast<uint32_t>(n), 0);
}
bool whole = wrote && got == total, placed = false;
share.storage().runAndWait([&]() {
file->close();
if (whole) {
if (SD.exists(path.c_str())) SD.remove(path.c_str());
placed = SD.rename(part.c_str(), path.c_str());
}
if (!placed) SD.remove(part.c_str());
});
if (placed) {
share.note("received", path);
return reply(req, "200 OK", "ok");
}
if (got < total) return ESP_FAIL; // the browser went away, or the network did
return reply(req, "500 Internal Server Error", "The card refused a write");
}
esp_err_t remove(httpd_req_t* req) {
std::string path;
if (!allowed(req) || !pathOf(req, path)) return ESP_OK;
WebShare& share = shareOf(req);
if (path == "/") return reply(req, "403 Forbidden", "Not the card itself");
std::string why;
const char* status = "403 Forbidden";
bool ran = share.storage().runAndWait([&]() {
File f = SD.open(path.c_str());
if (!f) {
status = "404 Not Found";
why = "It isn't there";
return;
}
bool folder = f.isDirectory();
f.close();
why = share.ops().whyReadOnly(path, folder);
if (!why.empty()) return;
if (folder ? !SD.rmdir(path.c_str()) : !SD.remove(path.c_str())) why = folder ? "That folder isn't empty: delete what is in it first" : "The card refused";
});
if (!ran) return noCard(req);
if (!why.empty()) return reply(req, status, why);
share.note("deleted", path);
return reply(req, "200 OK", "ok");
}
esp_err_t makeFolder(httpd_req_t* req) {
std::string path;
if (!allowed(req) || !pathOf(req, path)) return ESP_OK;
WebShare& share = shareOf(req);
std::string why = files::checkName(files::baseName(path));
if (!why.empty()) return reply(req, "400 Bad Request", why);
bool ran = share.storage().runAndWait([&]() {
if (SD.exists(path.c_str())) why = "It is there already";
else if (!SD.mkdir(path.c_str())) why = "The card refused";
});
if (!ran) return noCard(req);
if (!why.empty()) return reply(req, "409 Conflict", why);
share.note("made", path);
return reply(req, "200 OK", "ok");
}
} // namespace
void WebShare::note(const char* what, const std::string& path) {
requests_++;
std::string text = path.empty() ? what : std::string(what) + " " + files::fitName(files::baseName(path).empty() ? "/" : files::baseName(path), 26);
strlcpy(last_, text.c_str(), sizeof last_);
}
std::string WebShare::last() const { return last_; }
std::string WebShare::url() const {
std::string ip = wifi_.ip();
return ip.empty() ? "" : "http://" + ip + "/";
}
std::string WebShare::start() {
if (server_) return "";
if (wifi_.ip().empty()) return "Wi-Fi isn't connected";
if (!storage_.state().present) return "No SD card";
uint8_t random[4];
esp_fill_random(random, sizeof random);
auth_.begin(random);
requests_ = bytesIn_ = bytesOut_ = 0;
last_[0] = 0;
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.stack_size = 8192;
config.max_open_sockets = 4;
config.lru_purge_enable = true; // a phone's browser opens more connections than it closes
config.max_uri_handlers = 8;
config.recv_wait_timeout = 10;
config.send_wait_timeout = 10;
httpd_handle_t server = nullptr;
if (httpd_start(&server, &config) != ESP_OK) return "The server couldn't start";
const httpd_uri_t routes[] = {
{"/", HTTP_GET, page, this}, {"/api/login", HTTP_POST, login, this}, {"/api/list", HTTP_GET, list, this},
{"/dl", HTTP_GET, download, this}, {"/up", HTTP_PUT, upload, this}, {"/api/delete", HTTP_POST, remove, this},
{"/api/mkdir", HTTP_POST, makeFolder, this},
};
for (const auto& r : routes) httpd_register_uri_handler(server, &r);
server_ = server;
console.printf("share: on at %s\n", url().c_str());
return "";
}
void WebShare::stop() {
if (!server_) return;
httpd_stop(static_cast<httpd_handle_t>(server_));
server_ = nullptr;
uint8_t zero[4] = {0, 0, 0, 0};
auth_.begin(zero); // nobody is logged in any more
console.println("share: off");
}
} // namespace roro
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include <atomic>
#include <string>
#include "services/file_ops.h"
#include "services/storage_service.h"
#include "services/wifi_service.h"
#include "share_rules.h"
namespace roro {
// Sharing the SD card with a browser on the same network (issue #88): a small HTTP server with
// one page and a few requests behind it: list, download, upload, new folder, delete. It runs only
// between start() and stop(), which the Storage App's "Share" screen calls on opening and
// closing. Whoever has typed the code that screen shows may use it; nothing is encrypted.
//
// The server has its own task. Every access to the card is handed to the storage task, a piece
// at a time, as everywhere else.
class WebShare {
public:
WebShare(StorageService& storage, FileOps& ops, WifiService& wifi) : storage_(storage), ops_(ops), wifi_(wifi) {}
std::string start(); // "" or why it can't
void stop();
bool running() const { return server_ != nullptr; }
std::string url() const; // http://<address>/
const std::string& code() const { return auth_.code(); }
// For the screen.
uint32_t requests() const { return requests_; }
uint32_t bytesIn() const { return bytesIn_; }
uint32_t bytesOut() const { return bytesOut_; }
std::string last() const; // the last thing asked for
// Used by the request handlers (web_share.cpp).
StorageService& storage() { return storage_; }
FileOps& ops() { return ops_; }
files::ShareAuth& auth() { return auth_; }
void note(const char* what, const std::string& path);
void counted(uint32_t in, uint32_t out) {
bytesIn_ += in;
bytesOut_ += out;
}
private:
StorageService& storage_;
FileOps& ops_;
WifiService& wifi_;
files::ShareAuth auth_;
void* server_ = nullptr; // httpd_handle_t
std::atomic<uint32_t> requests_{0}, bytesIn_{0}, bytesOut_{0};
char last_[56] = "";
};
} // namespace roro
+69
View File
@@ -0,0 +1,69 @@
#pragma once
// The page a phone's browser gets (issue #88): one file, no other request but the API's. Kept
// small: it is sent from flash as it is.
namespace roro {
inline constexpr char kWebSharePage[] = R"HTML(<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>roro9stack files</title>
<style>
:root{color-scheme:dark light;--bg:#000;--fg:#fff;--mu:#9ab;--ac:#2cf;--ln:#345;--wa:#fa3}
@media (prefers-color-scheme:light){:root{--bg:#fff;--fg:#012;--mu:#567;--ac:#06a;--ln:#bcd;--wa:#b50}}
*{box-sizing:border-box}body{margin:0;font:16px/1.4 system-ui,sans-serif;background:var(--bg);color:var(--fg)}
main{max-width:720px;margin:0 auto;padding:12px}h1{font-size:18px;margin:4px 0 12px}
button,input,.btn{font:inherit;min-height:44px;padding:0 14px;border:1px solid var(--ln);background:none;color:inherit;border-radius:6px}
button,.btn{cursor:pointer}.main{background:var(--ac);color:#000;border-color:var(--ac)}
#crumbs{margin:8px 0;word-break:break-all}#crumbs a{color:var(--ac);text-decoration:none;padding:6px 2px;display:inline-block}
ul{list-style:none;margin:0;padding:0}li{display:flex;align-items:center;gap:8px;border-top:1px solid var(--ln);min-height:48px}
li a{flex:1;color:inherit;text-decoration:none;padding:10px 0;word-break:break-all}li.d a{color:var(--ac)}
li small{color:var(--mu);white-space:nowrap}li button{min-height:36px;padding:0 10px;color:var(--mu)}
#bar{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0}#msg{color:var(--wa);min-height:24px;margin:8px 0}
progress{width:100%}.hide{display:none}#login input{width:9em;font-size:22px;letter-spacing:.15em;text-align:center}
</style></head><body><main>
<h1>roro9stack: the SD card</h1>
<form id="login" class="hide"><p>The code on the device's screen:</p>
<input id="code" inputmode="numeric" autocomplete="off" maxlength="7" autofocus> <button class="main">Open</button></form>
<div id="app" class="hide">
<div id="crumbs"></div>
<div id="bar"><label class="btn main" style="display:inline-flex;align-items:center">Upload files<input id="pick" type="file" multiple class="hide"></label>
<button id="mk">New folder</button></div>
<progress id="prog" class="hide" max="100" value="0"></progress>
<ul id="list"></ul></div>
<p id="msg"></p>
</main><script>
const $=i=>document.getElementById(i),E=encodeURIComponent;let cwd='/';
const say=t=>{$('msg').textContent=t||''};
const join=(d,n)=>d=='/'?'/'+n:d+'/'+n;
const size=s=>s<1024?s+' B':s<1048576?(s/1024).toFixed(1)+' KB':(s/1048576).toFixed(1)+' MB';
function show(app){$('login').classList.toggle('hide',app);$('app').classList.toggle('hide',!app)}
async function call(u,o){const r=await fetch(u,o);if(r.status==401){show(false);throw new Error('The code, please')}
if(!r.ok)throw new Error(await r.text()||('Error '+r.status));return r}
async function login(code){const r=await fetch('/api/login',{method:'POST',body:'code='+E(code)});
if(r.ok){show(true);list('/')}else say(await r.text())}
async function list(p){try{const j=await(await call('/api/list?path='+E(p))).json();cwd=j.path;say(j.more?'Only the first '+j.items.length+' are shown':'');
const c=$('crumbs');c.textContent='';let at='';const parts=['/'].concat(cwd.split('/').filter(x=>x));
parts.forEach((n,i)=>{at=i?join(at||'/',n):'/';const a=document.createElement('a');a.href='#';a.textContent=i?n:'SD card';const to=at;a.onclick=e=>{e.preventDefault();list(to)};
if(i)c.append(' / ');c.append(a)});
const ul=$('list');ul.textContent='';j.items.sort((a,b)=>b.d-a.d||a.n.localeCompare(b.n));
if(!j.items.length){const li=document.createElement('li');li.innerHTML='<small>Nothing here</small>';ul.append(li)}
for(const it of j.items){const li=document.createElement('li'),a=document.createElement('a'),s=document.createElement('small'),b=document.createElement('button'),full=join(cwd,it.n);
a.textContent=it.n+(it.d?'/':'');if(it.d){li.className='d';a.href='#';a.onclick=e=>{e.preventDefault();list(full)}}else{a.href='/dl?path='+E(full);a.download=it.n;s.textContent=size(it.s)}
b.textContent='Delete';b.onclick=async()=>{if(!confirm('Delete '+it.n+'?'))return;try{await call('/api/delete?path='+E(full),{method:'POST'});list(cwd)}catch(e){say(e.message)}};
li.append(a,s,b);ul.append(li)}}catch(e){say(e.message)}}
function put(f,replace){return new Promise((ok,no)=>{const x=new XMLHttpRequest();x.open('PUT','/up?path='+E(join(cwd,f.name))+(replace?'&replace=1':''));
x.upload.onprogress=e=>{if(e.lengthComputable)$('prog').value=100*e.loaded/e.total};
x.onload=()=>x.status==200?ok():x.status==409&&!replace&&confirm(f.name+' is there already. Replace it?')?put(f,1).then(ok,no):x.status==409?ok():no(new Error(x.responseText||'Error '+x.status));
x.onerror=()=>no(new Error('The connection was lost'));x.send(f)})}
$('pick').onchange=async e=>{const fs=[...e.target.files];$('prog').classList.remove('hide');
try{let n=0;for(const f of fs){say('Uploading '+f.name+' ('+(++n)+' of '+fs.length+')');$('prog').value=0;await put(f)}say('')}catch(err){say(err.message)}
$('prog').classList.add('hide');e.target.value='';list(cwd)};
$('mk').onclick=async()=>{const n=prompt('The new folder\'s name');if(!n)return;try{await call('/api/mkdir?path='+E(join(cwd,n)),{method:'POST'});list(cwd)}catch(e){say(e.message)}};
$('login').onsubmit=e=>{e.preventDefault();login($('code').value)};
async function start(){const h=location.hash.slice(1);if(h){history.replaceState(null,'','/');await login(h)}
else{const r=await fetch('/api/list?path=%2F');if(r.ok){show(true);list('/')}else show(false)}}
onhashchange=start;start();
</script></body></html>
)HTML";
} // namespace roro
+101
View File
@@ -0,0 +1,101 @@
#include <unity.h>
#include <string>
#include "share_rules.h"
using namespace roro::files;
void setUp() {}
void tearDown() {}
void test_what_a_request_asks_for() {
TEST_ASSERT_EQUAL_STRING("/notes/my list.txt", urlDecode("%2Fnotes%2Fmy%20list.txt").c_str());
TEST_ASSERT_EQUAL_STRING("a+b", urlDecode("a+b").c_str());
TEST_ASSERT_EQUAL_STRING("100%", urlDecode("100%").c_str());
TEST_ASSERT_EQUAL_STRING("%zz", urlDecode("%zz").c_str());
TEST_ASSERT_EQUAL_STRING("\xC3\xA9t\xC3\xA9", urlDecode("%C3%A9t%C3%a9").c_str());
std::string v;
TEST_ASSERT_TRUE(queryParam("path=%2Fnotes&replace=1", "path", v));
TEST_ASSERT_EQUAL_STRING("/notes", v.c_str());
TEST_ASSERT_TRUE(queryParam("path=%2Fnotes&replace=1", "replace", v));
TEST_ASSERT_EQUAL_STRING("1", v.c_str());
TEST_ASSERT_FALSE(queryParam("xpath=1&pathx=2", "path", v));
TEST_ASSERT_FALSE(queryParam("", "path", v));
TEST_ASSERT_TRUE(queryParam("a=&path=", "path", v));
TEST_ASSERT_EQUAL_STRING("", v.c_str());
TEST_ASSERT_EQUAL_STRING("abc123", cookieValue("theme=dark; s=abc123; x=1", "s").c_str());
TEST_ASSERT_EQUAL_STRING("abc123", cookieValue("s=abc123", "s").c_str());
TEST_ASSERT_EQUAL_STRING("", cookieValue("ss=abc123; xs=1", "s").c_str());
TEST_ASSERT_EQUAL_STRING("", cookieValue("", "s").c_str());
}
void test_paths_a_browser_may_name() {
TEST_ASSERT_EQUAL_STRING("", checkSharePath("/").c_str());
TEST_ASSERT_EQUAL_STRING("", checkSharePath("/notes/my list (2).txt").c_str());
TEST_ASSERT_EQUAL_STRING("", checkSharePath("/gemini/saved/\xC3\xA9t\xC3\xA9.gmi").c_str());
for (const char* bad : {"", "notes", "/notes/", "/notes/../wifi", "/..", "/a//b", "/a/./b", "/a\\b", "/a/b\n", "/a:b", "/what?", "/a*"})
TEST_ASSERT_TRUE_MESSAGE(!checkSharePath(bad).empty(), bad);
TEST_ASSERT_TRUE(!checkSharePath("/" + std::string(300, 'a')).empty());
}
void test_json() {
TEST_ASSERT_EQUAL_STRING("\"plain\"", jsonString("plain").c_str());
TEST_ASSERT_EQUAL_STRING("\"a \\\"b\\\" \\\\ c\"", jsonString("a \"b\" \\ c").c_str());
TEST_ASSERT_EQUAL_STRING("\"tab\\u0009\"", jsonString("tab\t").c_str());
TEST_ASSERT_EQUAL_STRING("\"\xC3\xA9\"", jsonString("\xC3\xA9").c_str());
ShareListing empty("/");
TEST_ASSERT_EQUAL_STRING("{\"path\":\"/\",\"items\":[],\"more\":false}", empty.json(false).c_str());
ShareListing l("/notes");
l.add("a \"b\".txt", 12, false, 1791400000);
l.add("old", 0, true, 0);
TEST_ASSERT_EQUAL_size_t(2, l.count());
TEST_ASSERT_EQUAL_STRING("{\"path\":\"/notes\",\"items\":[{\"n\":\"a \\\"b\\\".txt\",\"s\":12,\"d\":0,\"t\":1791400000},{\"n\":\"old\",\"s\":0,\"d\":1,\"t\":0}],\"more\":true}",
l.json(true).c_str());
}
void test_the_code_and_the_token() {
ShareAuth auth;
std::string token;
const uint8_t r16[16] = {0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff};
TEST_ASSERT_TRUE(auth.login("000000", 0, r16, token) == ShareAuth::Result::Locked); // not started: nobody gets in
TEST_ASSERT_FALSE(auth.allowed(""));
const uint8_t r4[4] = {0x00, 0x00, 0x30, 0x39}; // 12345
auth.begin(r4);
TEST_ASSERT_EQUAL_STRING("012345", auth.code().c_str());
TEST_ASSERT_FALSE(auth.allowed(""));
TEST_ASSERT_TRUE(auth.login("12345", 1000, r16, token) == ShareAuth::Result::Wrong); // the leading zero counts
TEST_ASSERT_TRUE(token.empty());
TEST_ASSERT_TRUE(auth.login("012 345", 2000, r16, token) == ShareAuth::Result::Ok); // typed as the screen groups it
TEST_ASSERT_EQUAL_STRING("00112233445566778899aabbccddeeff", token.c_str());
TEST_ASSERT_TRUE(auth.allowed(token));
TEST_ASSERT_FALSE(auth.allowed(token + "0"));
TEST_ASSERT_FALSE(auth.allowed("00112233445566778899aabbccddeef0"));
// Sharing started again: a new code, and yesterday's browser is out.
const uint8_t again[4] = {0xFF, 0xFF, 0xFF, 0xFF};
auth.begin(again);
TEST_ASSERT_EQUAL_size_t(6, auth.code().size());
TEST_ASSERT_FALSE(auth.allowed(token));
}
void test_five_wrong_codes_close_it_for_a_minute() {
ShareAuth auth;
const uint8_t r4[4] = {0, 0, 0, 7};
const uint8_t r16[16] = {0};
auth.begin(r4);
std::string token;
for (int i = 0; i < 4; i++) TEST_ASSERT_TRUE(auth.login("999999", 1000, r16, token) == ShareAuth::Result::Wrong);
TEST_ASSERT_TRUE(auth.login("999999", 1000, r16, token) == ShareAuth::Result::Locked);
TEST_ASSERT_TRUE(auth.login("000007", 30000, r16, token) == ShareAuth::Result::Locked); // the right one too
TEST_ASSERT_TRUE(auth.login("000007", 62000, r16, token) == ShareAuth::Result::Ok);
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_what_a_request_asks_for);
RUN_TEST(test_paths_a_browser_may_name);
RUN_TEST(test_json);
RUN_TEST(test_the_code_and_the_token);
RUN_TEST(test_five_wrong_codes_close_it_for_a_minute);
return UNITY_END();
}