Public Access
Storage: share the card with a phone's browser (#88)
`w` in the Storage App starts a small HTTP server and shows its address, as a QR code and in letters, with a six-digit code. A browser on the same network that has typed the code can list, download, upload, make folders and delete, under the Storage App's rules. The server runs only while that screen is open. Nothing is encrypted, and the screen says so. Uploads are streamed to the card under a temporary name and renamed when whole. Every access to the card is handed to the storage task, 8 KB at a time, from the server's own task. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user