diff --git a/README.md b/README.md index 4120084..13811a3 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/milestones/F1.md b/docs/milestones/F1.md index 182bbb7..2b6767e 100644 --- a/docs/milestones/F1.md +++ b/docs/milestones/F1.md @@ -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 `.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). diff --git a/lib/core/src/app_keys.h b/lib/core/src/app_keys.h index 0569107..a1e9b72 100644 --- a/lib/core/src/app_keys.h +++ b/lib/core/src/app_keys.h @@ -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"}, diff --git a/lib/files/src/share_rules.cpp b/lib/files/src/share_rules.cpp new file mode 100644 index 0000000..57c1f37 --- /dev/null +++ b/lib/files/src/share_rules.cpp @@ -0,0 +1,135 @@ +#include "share_rules.h" + +#include + +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(a.size() ^ b.size()); + for (size_t i = 0; i < a.size() && i < b.size(); i++) diff |= static_cast(a[i]) ^ static_cast(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(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(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(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(random[0]) << 24 | random[1] << 16 | random[2] << 8 | random[3]) % 1000000u; + char buf[8]; + std::snprintf(buf, sizeof buf, "%06u", static_cast(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 diff --git a/lib/files/src/share_rules.h b/lib/files/src/share_rules.h new file mode 100644 index 0000000..49e9c35 --- /dev/null +++ b/lib/files/src/share_rules.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include + +#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 diff --git a/site/content/dev/milestones/f1.md b/site/content/dev/milestones/f1.md index c5b9237..20e2ba1 100644 --- a/site/content/dev/milestones/f1.md +++ b/site/content/dev/milestones/f1.md @@ -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 `.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). diff --git a/site/content/faq.md b/site/content/faq.md index cf92eb3..99dd0c0 100644 --- a/site/content/faq.md +++ b/site/content/faq.md @@ -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 w: 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/). diff --git a/site/content/guide/storage.md b/site/content/guide/storage.md index 22bf32f..dd9e7f0 100644 --- a/site/content/guide/storage.md +++ b/site/content/guide/storage.md @@ -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 w 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 m), 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 Fn + h 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"]) }} diff --git a/site/data/keys.toml b/site/data/keys.toml index 8457053..6047c32 100644 --- a/site/data/keys.toml +++ b/site/data/keys.toml @@ -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" diff --git a/src/apps/storage_app.cpp b/src/apps/storage_app.cpp index 31ee1c4..81a3fbe 100644 --- a/src/apps/storage_app.cpp +++ b/src/apps/storage_app.cpp @@ -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& 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(); diff --git a/src/apps/storage_app.h b/src/apps/storage_app.h index 70bbb41..ea0441c 100644 --- a/src/apps/storage_app.h +++ b/src/apps/storage_app.h @@ -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) diff --git a/src/main.cpp b/src/main.cpp index 6b5b088..9190966 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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. diff --git a/src/services/web_share.cpp b/src/services/web_share.cpp new file mode 100644 index 0000000..6795801 --- /dev/null +++ b/src/services/web_share.cpp @@ -0,0 +1,335 @@ +#include "services/web_share.h" + +#include +#include + +#include +#include + +#include + +#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(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(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(req->content_len, sizeof body - 1)); + std::string code; + files::queryParam(n > 0 ? std::string(body, static_cast(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(f.size()), f.isDirectory(), static_cast(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(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(); + std::unique_ptr 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(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(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(); + std::unique_ptr 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(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(piece.get()), static_cast(n)) == static_cast(n); })) wrote = false; + got += static_cast(n); + share.counted(static_cast(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(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 diff --git a/src/services/web_share.h b/src/services/web_share.h new file mode 100644 index 0000000..4b22154 --- /dev/null +++ b/src/services/web_share.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include + +#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://
/ + 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 requests_{0}, bytesIn_{0}, bytesOut_{0}; + char last_[56] = ""; +}; + +} // namespace roro diff --git a/src/services/web_share_page.h b/src/services/web_share_page.h new file mode 100644 index 0000000..9a309d3 --- /dev/null +++ b/src/services/web_share_page.h @@ -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( + +roro9stack files +
+

roro9stack: the SD card

+

The code on the device's screen:

+
+
+
+
+
+ +
    +

    +
    +)HTML"; + +} // namespace roro diff --git a/test/test_share_rules/test_share_rules.cpp b/test/test_share_rules/test_share_rules.cpp new file mode 100644 index 0000000..0f8e70d --- /dev/null +++ b/test/test_share_rules/test_share_rules.cpp @@ -0,0 +1,101 @@ +#include + +#include + +#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(); +}