Public Access
Merge main (file sharing, v0.18.0) into the VPN branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user