Merge main (file sharing, v0.18.0) into the VPN branch
CI / build (pull_request) Successful in 1m50s
Site / build (pull_request) Successful in 10s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-08 00:40:12 +02:00
co-authored by Claude Opus 5.5
21 changed files with 1126 additions and 5 deletions
+6
View File
@@ -223,9 +223,15 @@ inline constexpr KeyHelp kStorage[] = {
{"i", "details: size, date, type"},
{"s", "sort: name, date, size"},
{"m", "Maintenance: clean-up, erase"},
{"w", "share with a browser"},
{"`", "the folder above"},
};
// storage-share: Storage, sharing with a browser
inline constexpr KeyHelp kStorageShare[] = {
{"`", "stop sharing"},
};
// storage-details: Storage, an item's details
inline constexpr KeyHelp kStorageDetails[] = {
{"; .", "scroll"},
+135
View File
@@ -0,0 +1,135 @@
#include "share_rules.h"
#include <cstdio>
namespace roro::files {
namespace {
int hexDigit(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
// Whatever the two strings hold, the time taken says nothing about where they differ.
bool sameText(const std::string& a, const std::string& b) {
unsigned diff = static_cast<unsigned>(a.size() ^ b.size());
for (size_t i = 0; i < a.size() && i < b.size(); i++) diff |= static_cast<unsigned char>(a[i]) ^ static_cast<unsigned char>(b[i]);
return diff == 0;
}
} // namespace
std::string urlDecode(const std::string& text) {
std::string out;
out.reserve(text.size());
for (size_t i = 0; i < text.size(); i++) {
int hi, lo;
if (text[i] == '%' && i + 2 < text.size() + 0 && (hi = hexDigit(text[i + 1])) >= 0 && (lo = hexDigit(text[i + 2])) >= 0) {
out += static_cast<char>(hi * 16 + lo);
i += 2;
} else {
out += text[i];
}
}
return out;
}
bool queryParam(const std::string& query, const std::string& key, std::string& out) {
for (size_t at = 0; at <= query.size();) {
size_t amp = query.find('&', at);
if (amp == std::string::npos) amp = query.size();
size_t eq = query.find('=', at);
if (eq != std::string::npos && eq < amp && query.compare(at, eq - at, key) == 0) {
out = urlDecode(query.substr(eq + 1, amp - eq - 1));
return true;
}
at = amp + 1;
}
return false;
}
std::string cookieValue(const std::string& header, const std::string& name) {
for (size_t at = 0; at < header.size();) {
while (at < header.size() && (header[at] == ' ' || header[at] == ';')) at++;
size_t end = header.find(';', at);
if (end == std::string::npos) end = header.size();
size_t eq = header.find('=', at);
if (eq != std::string::npos && eq < end && header.compare(at, eq - at, name) == 0) return header.substr(eq + 1, end - eq - 1);
at = end;
}
return "";
}
std::string checkSharePath(const std::string& path) {
if (path.empty() || path[0] != '/') return "a path starts with /";
if (path.size() > 255) return "that path is too long";
if (path.size() > 1 && path.back() == '/') return "a path doesn't end with /";
for (size_t at = 1; at < path.size();) {
size_t end = path.find('/', at);
if (end == std::string::npos) end = path.size();
std::string part = path.substr(at, end - at);
if (part.empty() || part == "." || part == "..") return "that isn't a path on the card";
for (char c : part)
if (static_cast<unsigned char>(c) < 0x20 || c == 0x7F || c == '\\' || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || c == '>' || c == '|')
return "a name can't hold that character";
at = end + 1;
}
return "";
}
std::string jsonString(const std::string& text) {
std::string out = "\"";
for (char c : text) {
unsigned char u = static_cast<unsigned char>(c);
if (c == '"' || c == '\\') {
out += '\\';
out += c;
} else if (u < 0x20) {
char buf[8];
std::snprintf(buf, sizeof buf, "\\u%04x", u);
out += buf;
} else {
out += c;
}
}
return out + "\"";
}
ShareListing::ShareListing(const std::string& path) : out_("{\"path\":" + jsonString(path) + ",\"items\":[") {}
void ShareListing::add(const std::string& name, uint32_t size, bool folder, int64_t modified) {
if (count_++) out_ += ',';
out_ += "{\"n\":" + jsonString(name) + ",\"s\":" + std::to_string(size) + ",\"d\":" + (folder ? "1" : "0") + ",\"t\":" + std::to_string(modified) + "}";
}
std::string ShareListing::json(bool more) { return out_ + "],\"more\":" + (more ? "true" : "false") + "}"; }
void ShareAuth::begin(const uint8_t random[4]) {
uint32_t n = (static_cast<uint32_t>(random[0]) << 24 | random[1] << 16 | random[2] << 8 | random[3]) % 1000000u;
char buf[8];
std::snprintf(buf, sizeof buf, "%06u", static_cast<unsigned>(n));
code_ = buf;
token_.clear();
gate_ = debug::AuthGate();
}
ShareAuth::Result ShareAuth::login(const std::string& code, uint32_t nowMs, const uint8_t random[16], std::string& token) {
if (code_.empty() || gate_.locked(nowMs)) return Result::Locked;
std::string digits;
for (char c : code)
if (c >= '0' && c <= '9') digits += c; // "123 456" is as good
if (!sameText(digits, code_)) return gate_.failed(nowMs) ? Result::Locked : Result::Wrong;
gate_.succeeded();
static const char* const kHex = "0123456789abcdef";
token_.clear();
for (int i = 0; i < 16; i++) {
token_ += kHex[random[i] >> 4];
token_ += kHex[random[i] & 15];
}
token = token_;
return Result::Ok;
}
bool ShareAuth::allowed(const std::string& token) const { return !token_.empty() && sameText(token, token_); }
} // namespace roro::files
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include <cstdint>
#include <string>
#include "debug_auth.h"
// The parts of sharing files with a browser (issue #88) that need no network: what a request
// asks for, whether it may, and the answers as JSON. The server itself is src/services/web_share.h.
namespace roro::files {
std::string urlDecode(const std::string& text); // %41 is A; a + stays a +
// The value of `key` in a query string ("path=%2Fnotes&replace=1"), decoded. False if it isn't there.
bool queryParam(const std::string& query, const std::string& key, std::string& out);
// The value of a cookie in a Cookie header ("a=1; s=abc"), or "".
std::string cookieValue(const std::string& header, const std::string& name);
// A path a browser may name: from the card's root, no "..", nothing a file name can't hold.
// "" or why not.
std::string checkSharePath(const std::string& path);
std::string jsonString(const std::string& text); // with its quotes
// A folder's listing as the page wants it: {"path":"/notes","items":[{"n":"a.txt","s":12,"d":0,"t":1791400000}],"more":false}
class ShareListing {
public:
explicit ShareListing(const std::string& path);
void add(const std::string& name, uint32_t size, bool folder, int64_t modified);
std::string json(bool more);
size_t count() const { return count_; }
private:
std::string out_;
size_t count_ = 0;
};
// Who may use the page: whoever typed the code the device's screen shows. The code is new each
// time sharing starts; five wrong ones in a row close the door for a minute (as the Debug
// Console's token does). A browser that got it right is given a token to send back as a cookie.
// Nothing here is encrypted on the way: see the issue.
class ShareAuth {
public:
enum class Result { Ok, Wrong, Locked };
void begin(const uint8_t random[4]); // a new code, and nobody is logged in
const std::string& code() const { return code_; } // six digits
Result login(const std::string& code, uint32_t nowMs, const uint8_t random[16], std::string& token);
bool allowed(const std::string& token) const;
private:
std::string code_, token_;
debug::AuthGate gate_;
};
} // namespace roro::files