Public Access
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0306dd880 | ||
|
|
e3fe618c7f | ||
|
|
d7092ee6d6 | ||
|
|
63c2da8138 | ||
|
|
057af773e4 | ||
|
|
6b02cd3d5f | ||
|
|
c35bc47693 |
@@ -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).
|
||||
|
||||
@@ -290,3 +290,46 @@ 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.
|
||||
|
||||
**On a real phone** (the maintainer's, 2026-10-08): the QR code, scanned with the phone's camera, opens the page and logs in; the page lists the card, in the phone's dark theme.
|
||||
|
||||
**Not checked:** 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).
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -298,3 +298,46 @@ 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.
|
||||
|
||||
**On a real phone** (the maintainer's, 2026-10-08): the QR code, scanned with the phone's camera, opens the page and logs in; the page lists the card, in the phone's dark theme.
|
||||
|
||||
**Not checked:** 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).
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
@@ -0,0 +1,188 @@
|
||||
+++
|
||||
title = '''Press w'''
|
||||
description = '''I asked whether roro9stack should get an FTP, SFTP or WebDAV server, to move files to and from my phone. The answer was none of them: a web page. Less than an hour later I pressed one key on the Cardputer, opened my phone's browser, and my SD card was in it. It works, and I'm still grinning.'''
|
||||
date = 2026-10-08T00:30:00+02:00
|
||||
|
||||
[extra]
|
||||
topics = '''ESP32-S3 · HTTP · Files'''
|
||||
read_label = '''Read how the card got into my phone →'''
|
||||
uid = '''<b>share:</b> on at http://172.16.42.25/'''
|
||||
dek = "A short one, written straight after it worked, because I'm too pleased to wait. [roro9stack](/devlog/roro9stack/), my firmware for the M5Stack Cardputer, can now hand its SD card to any browser on the same Wi-Fi: no cable, no app, no computer. One key, one code, done."
|
||||
byline = '''one question asked, the wrong three answers offered, a fourth taken'''
|
||||
|
||||
[extra.sign]
|
||||
label = "Apps installed on the phone to make this work"
|
||||
note = "A browser was already there."
|
||||
count = "0"
|
||||
|
||||
[[extra.cast]]
|
||||
name = "The question"
|
||||
role = "\"FTP, SFTP or WebDAV?\""
|
||||
text = "Three ways to serve files, all of which want an app on the phone. I'd have picked one and been mildly unhappy with it for months."
|
||||
|
||||
[[extra.cast]]
|
||||
name = "The w key"
|
||||
role = "in the Storage App"
|
||||
text = "Starts a web server and shows where it is. Back stops it. That is the entire user interface on the device."
|
||||
|
||||
[[extra.cast]]
|
||||
name = "The code"
|
||||
role = "six digits, new every time"
|
||||
text = "On the device's screen and nowhere else. Type it in the page and you're in. Five wrong tries and the door stays shut for a minute."
|
||||
|
||||
[[extra.cast]]
|
||||
name = "The page"
|
||||
role = "5.4 KB, one file"
|
||||
text = "A list of what's on the card, an Upload button, a New folder button, and a Delete next to every row. Served from the firmware's own flash."
|
||||
|
||||
[[extra.cast]]
|
||||
name = "The storage task"
|
||||
role = "the only one allowed to touch the card"
|
||||
text = "Every byte in either direction goes through it, 8 KB at a time. It was there long before this and didn't need to learn anything new."
|
||||
+++
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **Press `w` in the Storage App** (**v0.18.0**), scan the QR code with a phone on the same Wi-Fi, and the SD card is a web page: list, download, upload, new folder, delete.
|
||||
- **Nothing to install**, on the phone or anywhere else. That was the whole point.
|
||||
- **It runs only while that screen is open.** A six-digit code, new each time, keeps the rest of the network out.
|
||||
- **It is not encrypted,** and the screen says so. Fine at home; think first elsewhere.
|
||||
- About **200 KB a second**, one request at a time, 57 KB of flash, 13 KB of memory while it's on.
|
||||
- Also since [the last post](/devlog/roro9stack-shell/): the device shows **pictures** (v0.16.0), **Fn+p takes a screenshot** anywhere (v0.17.0), and this site has a **search**.
|
||||
|
||||
## It works!
|
||||
|
||||
I'll skip the build-up. I pressed `w`. This came up:
|
||||
|
||||
{{ figure(src="share.png", alt="The Cardputer's screen at 2x: a large QR code on the left; on the right, In a browser, on this network: 172.16.42.25/, Code 825 132 in large blue digits, Nothing asked yet, Not encrypted, and backtick stops sharing.", width=480, height=270, caption="The whole feature, as the device sees it. The code in this picture stopped being valid the moment I pressed Back.") }}
|
||||
|
||||
I pointed my phone's camera at the QR code, and there was my SD card, in the browser. No address to type, no code to type. **It works. That's fucking awesome.**
|
||||
|
||||
{{ figure(src="phone.png", alt="A phone's browser in dark mode at the address 172.16.42.25, at 00:15: roro9stack: the SD card, a link SD card, a bright blue Upload files button and a New folder button, then rows captures, gemini, gnss, irc, notes, screenshots, updates and wifi, each with a Delete button.", width=462, height=1001, caption="My phone, at a quarter past midnight. Its browser, my card, nothing else.") }}
|
||||
|
||||
{{ figure(src="device-photo.jpg", alt="A photograph of the Cardputer ADV on a wooden table. Its small screen shows the QR code, the address, the code 997 216, and 2.5 MB in, 3.2 MB out. Below the screen, the whole keyboard.", width=900, height=864, landscape=true, caption="And the other end of it, for scale. The whole server is in there, behind a screen smaller than the QR code on most posters.") }}
|
||||
|
||||
Files, from my phone, to a computer the size of a biscuit and back. Over Wi-Fi. With nothing installed on either end that wasn't there this morning.
|
||||
|
||||
Most of this devlog is about things that took a week of measuring and still bit me. This one I can explain to anybody in a sentence: it's a web page with your files on it.
|
||||
|
||||
## The question I asked, and the one I should have
|
||||
|
||||
Until tonight a file reached the card in one of two ways: the Debug Console's `put` command, which wants a PC, a Python script and a token, or pulling the card out. My phone can do neither. So I asked the obvious question: FTP, SFTP or WebDAV?
|
||||
|
||||
The answer was a table, and the table was unkind to all three:
|
||||
|
||||
{% table() %}
|
||||
| | On the phone | On the device |
|
||||
|---|---|---|
|
||||
| FTP | needs an app; passwords in clear | easy |
|
||||
| SFTP | needs an app | a whole SSH server: hundreds of KB, and a key exchange this chip would feel |
|
||||
| WebDAV | needs an app, on iOS and Android both | fine, but see the first column |
|
||||
| **A web page** | **any browser** | a small HTTP server |
|
||||
{% end %}
|
||||
|
||||
I had been choosing a protocol. What I wanted was to move a file with my thumb. Every phone made in the last fifteen years has exactly one file-transfer client that needs no setup, and it's the browser.
|
||||
|
||||
## What's in it
|
||||
|
||||
**On the device, almost nothing.** `w` starts the server and draws a screen. Back stops it. The server is the one that ships inside ESP-IDF, the framework the firmware is built on, so there was no library to choose. Seven requests: the page, the code, a listing, a download, an upload, a new folder, a delete.
|
||||
|
||||
**The QR code carries the code.** Scan it and you're in without typing; type the address by hand and the page asks for the six digits. The code is made fresh from the hardware random generator each time `w` is pressed, and pressing Back throws every browser out.
|
||||
|
||||
**An upload is just the request's body.** The browser sends the file as it is with a `PUT`, so there is no form to pick apart on a device with 100 KB of free memory. It lands on the card as `photo.jpg.part`, 8 KB at a time, and is renamed when the last byte has arrived. A transfer that dies halfway leaves nothing behind. If the name is taken, the page asks before replacing it, and the device refuses until it has.
|
||||
|
||||
**The Storage App's rules still hold.** The page can't delete the folders the firmware keeps its own files in, and it says why:
|
||||
|
||||
{{ figure(src="page.png", alt="The page in a browser at a phone's width: roro9stack: the SD card, a link SD card, buttons Upload files and New folder, then rows a-web, captures, gemini, gnss, irc, notes, screenshots, updates and wifi, each with a Delete button. Below, in orange: The firmware keeps its files in /notes.", width=390, height=630, caption="The page, at a phone's width, just after it was asked to delete `/notes`. It said no, in the device's own words.") }}
|
||||
|
||||
**Nobody gets to walk out of the card.** `/a-web/../wifi` is refused before anything looks at the disk, by a function with a test that tries a dozen ways of asking.
|
||||
|
||||
## What it isn't
|
||||
|
||||
**Encrypted.** A TLS server costs this device about 40 KB of memory per connection, and it has around 100 KB on a good day. So the files and the code cross the Wi-Fi in clear. On my own network I don't mind. On a hotel's, I'd think about it. The device's screen says "Not encrypted." in plain words every time, because a limit you have to read the docs to find is a trap.
|
||||
|
||||
**Fast.** 200 KB a second, give or take. A 2.6 MB photo takes eleven to seventeen seconds going up. I tried bigger pieces and smaller ones; the numbers moved around more between two runs of the same setting than between settings, so it's 8 KB and I stopped fiddling.
|
||||
|
||||
**Able to do two things at once.** The server answers one request at a time. Start a big download and the page waits until it's done. I found that by asking for a listing in the middle of a download and watching it time out. It's written in the guide and left as it is.
|
||||
|
||||
## What went wrong, for about four minutes each
|
||||
|
||||
**A file that included itself.** The part that can be tested on a PC lived in `web_share.h`. So did the service, in another folder. The service's header said `#include "web_share.h"`, meaning the other one, and the compiler quite reasonably gave it itself. The testable half is now called `share_rules.h`.
|
||||
|
||||
**The scanned address did nothing, sometimes.** If the page was already open and you then went to the same address with the code after the `#`, nothing happened: to a browser that isn't a new page, so the script never ran again. One line, `onhashchange`. Found by a browser test, not by me, which is the right way round.
|
||||
|
||||
That's the list. Two.
|
||||
|
||||
## What I checked, and what I didn't
|
||||
|
||||
Checked, before I went anywhere near my phone:
|
||||
|
||||
- Every request and every refusal, from a PC: wrong code, right code, a path with `..` in it, deleting a protected folder, deleting a folder that isn't empty, uploading over a file that exists.
|
||||
- **2.6 MB up, then down again, compared byte for byte.** The same.
|
||||
- The page in a real browser at a phone's width: uploads, a download, a new folder, a delete, a replace.
|
||||
- Five wrong codes: shut for a minute, for the right code too. A browser that was already in stays in.
|
||||
- Back: the server is gone and the memory comes back.
|
||||
|
||||
And then on my actual phone, where it works, which is the sentence this post exists for.
|
||||
|
||||
Not checked: Safari. Pulling the card out mid-transfer. Sharing while IRC is connected, when memory is tighter. What happens if the screen turns off while it's sharing.
|
||||
|
||||
## Also since last time
|
||||
|
||||
The day didn't stop at the [last post](/devlog/roro9stack-shell/):
|
||||
|
||||
- **Pictures.** The Storage App opens PNG, JPEG, BMP and GIF files (**v0.16.0**). The interesting part was the PNG decoder: the one in the display library wanted 44 KB in a single block, got it once, and refused the next five pictures. The firmware has its own now, which needs 32.
|
||||
- **Fn+p** takes a screenshot on any screen (**v0.17.0**), except the one that shows the Debug Console's token, where it politely declines.
|
||||
- **This site has a [search](/search/).**
|
||||
- **A WireGuard tunnel** is sitting in a pull request. It works against a test server on my own network and is waiting to meet a real one.
|
||||
|
||||
## By the numbers
|
||||
|
||||
{% table() %}
|
||||
| | |
|
||||
|---|---|
|
||||
| Keys to press on the device | 1 |
|
||||
| Apps to install on the phone | 0 |
|
||||
| Digits in the code | 6 |
|
||||
| Wrong codes before it shuts for a minute | 5 |
|
||||
| The page | 5.4 KB |
|
||||
| Flash | 57 KB |
|
||||
| Memory while sharing | 13 KB |
|
||||
| Speed | about 200 KB/s |
|
||||
| Requests at a time | 1 |
|
||||
| Bytes that differed after 2.6 MB went up and came back | 0 |
|
||||
| Host tests | 533 |
|
||||
| Things that went wrong | 2 |
|
||||
{% end %}
|
||||
|
||||
## Where it stands
|
||||
|
||||
{% steps() %}
|
||||
1. ~~M0 and M1: the skeleton, Wi-Fi, IRC, Wi-Fi Tools.~~ v0.1.0 to v0.2.1, [the first post](/devlog/roro9stack/).
|
||||
|
||||
2. ~~Updates and debugging over the air.~~ v0.3.0, [Look, no cables](/devlog/roro9stack-ota/).
|
||||
|
||||
3. ~~M2: GNSS.~~ v0.4.0, [Seventeen satellites](/devlog/roro9stack-gnss/).
|
||||
|
||||
4. ~~G1: Gemini.~~ v0.5.0, [A browser in the RAM IRC left over](/devlog/roro9stack-gemini/).
|
||||
|
||||
5. ~~M3: the LoRa radio, listening.~~ v0.6.0, [The loudest thing it hears is itself](/devlog/roro9stack-lora/).
|
||||
|
||||
6. ~~S1: the card, fixed addresses, the System App.~~ v0.6.1 to v0.8.1, [One byte too early](/devlog/roro9stack-s1/).
|
||||
|
||||
7. ~~F1 and the start of R1: files, notes, signed releases, updates from Gitea.~~ v0.9.0 to v0.11.0, [836 bytes](/devlog/roro9stack-f1-r1/).
|
||||
|
||||
8. ~~W1: the website, and one firmware with the Debug Console in it.~~ v0.12.0, [It was off](/devlog/roro9stack-console/).
|
||||
|
||||
9. ~~A help key, the Shell, notes of any size.~~ v0.13.0 to v0.15.0, [It said "No"](/devlog/roro9stack-shell/).
|
||||
|
||||
10. ~~Pictures, and a screenshot key.~~ v0.16.0 and v0.17.0.
|
||||
|
||||
11. ~~The card in a phone's browser.~~ v0.18.0, this post.
|
||||
|
||||
12. Next: the WireGuard tunnel, once it has talked to a real server. And M4, the mesh, which still wants a second node.
|
||||
{% end %}
|
||||
|
||||
{% signoff() %}
|
||||
I asked which of three servers to build and got told to build none of them. Then I pressed a key, picked up my phone, and my files were on it. Most of what this firmware does took days of careful measuring to get right. This took less than an evening, and it works, and I'm going to enjoy that at least until the next thing breaks.
|
||||
{% end %}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
@@ -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/).
|
||||
|
||||
@@ -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"]) }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user