Storage: share the card with a phone's browser (#88)
CI / build (pull_request) Successful in 1m51s
Site / build (pull_request) Successful in 16s

`w` in the Storage App starts a small HTTP server and shows its address,
as a QR code and in letters, with a six-digit code. A browser on the same
network that has typed the code can list, download, upload, make folders
and delete, under the Storage App's rules. The server runs only while that
screen is open. Nothing is encrypted, and the screen says so.

Uploads are streamed to the card under a temporary name and renamed when
whole. Every access to the card is handed to the storage task, 8 KB at a
time, from the server's own task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-08 00:11:55 +02:00
co-authored by Claude Opus 5.5
parent c35bc47693
commit 6b02cd3d5f
16 changed files with 934 additions and 5 deletions
+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();
}