Polish and fixes to the upload form.

This commit is contained in:
Henrik Rydgård
2025-10-26 23:59:40 +01:00
parent 9abd8c21e1
commit d2c9104eff
5 changed files with 284 additions and 157 deletions
+1 -1
View File
@@ -59,7 +59,7 @@ std::pair<std::string_view, std::string_view> InputSink::BufferParts() const {
}
size_t InputSink::ReadBinaryUntilTerminator(char *dest, size_t bufSize, std::string_view terminator, bool *didReadTerminator) {
Block();
Fill();
auto [part1, part2] = BufferParts();
size_t offset = SplitSearch(terminator, part1, part2);
+1
View File
@@ -145,6 +145,7 @@ enum SystemProperty {
SYSPROP_CLIPBOARD_TEXT,
SYSPROP_GPUDRIVER_VERSION,
SYSPROP_BUILD_VERSION,
SYSPROP_COMPUTER_NAME,
// Separate SD cards or similar.
// Need hacky solutions to get at this.
+77 -16
View File
@@ -32,6 +32,7 @@
#include "Common/File/VFS/VFS.h"
#include "Common/TimeUtil.h"
#include "Common/StringUtils.h"
#include "Common/System/System.h"
#include "Core/Util/RecentFiles.h"
#include "Core/Config.h"
#include "Core/Debugger/WebSocket.h"
@@ -322,6 +323,45 @@ static void HandleListing(const http::ServerRequest &request) {
}
}
std::string RenderTemplate(std::string_view input, const std::map<std::string, std::string>& values) {
std::string output;
output.reserve(input.size());
size_t i = 0;
while (i < input.size()) {
if (i + 3 < input.size() && input[i] == '{' && input[i + 1] == '{') {
size_t end = input.find("}}", i + 2);
if (end != std::string_view::npos) {
// Trim whitespace around key
size_t startKey = i + 2;
while (startKey < end && std::isspace(static_cast<unsigned char>(input[startKey])))
startKey++;
size_t endKey = end;
while (endKey > startKey && std::isspace(static_cast<unsigned char>(input[endKey - 1])))
endKey--;
std::string key(input.substr(startKey, endKey - startKey));
auto it = values.find(key);
if (it != values.end()) {
output += it->second;
} else {
// Keep the original placeholder if key not found
output.append(input.substr(i, end + 2 - i));
}
i = end + 2;
continue;
}
}
output.push_back(input[i]);
++i;
}
return output;
}
static bool ServeAssetFile(const http::ServerRequest &request) {
// Skip the slash at the start of the resource path.
std::string_view filename = request.resource().substr(1);
@@ -330,20 +370,8 @@ static bool ServeAssetFile(const http::ServerRequest &request) {
return false;
}
size_t size;
// TODO: ReadFile should take a string_view.
uint8_t *data = g_VFS.ReadFile(std::string(filename).c_str(), &size);
if (!data) {
// Try appending index.html
data = g_VFS.ReadFile((std::string(filename) + "/index.html").c_str(), &size);
if (!data) {
return false;
}
INFO_LOG(Log::HTTP, "Redirected to /index.html");
}
std::string ext = Path(filename).GetFileExtension();
const char *mimeType = "text/plain";
std::string_view mimeType = "text/plain";
if (ext == ".html") {
mimeType = "text/html";
} else if (ext == ".ico") {
@@ -358,10 +386,43 @@ static bool ServeAssetFile(const http::ServerRequest &request) {
mimeType = "text/css";
}
request.WriteHttpResponseHeader("1.0", 200, size, mimeType);
request.Out()->Push((char *)data, size);
size_t size;
// TODO: ReadFile should take a string_view.
uint8_t *data = g_VFS.ReadFile(std::string(filename).c_str(), &size);
if (!data) {
// Try appending index.html
data = g_VFS.ReadFile((std::string(filename) + "/index.html").c_str(), &size);
mimeType = "text/html";
if (!data) {
return false;
}
INFO_LOG(Log::HTTP, "Redirected to /index.html");
}
std::string html = std::string((const char *)data, size);
delete[] data;
// This is a gross, gross hack to have here in ServeAssetFile, but oh well.
if (mimeType == "text/html") {
if (html.find("<!--upload-->") != std::string::npos) {
std::string uploadPath = g_uploadPath.ToVisualString();
std::string deviceName = System_GetProperty(SYSPROP_NAME);
std::string computerName = System_GetProperty(SYSPROP_COMPUTER_NAME);
if (!computerName.empty()) {
deviceName = computerName;
}
std::map<std::string, std::string> values = {
{"upload_path", uploadPath},
{"device_name", deviceName},
};
// Use templating to insert some information.
html = RenderTemplate(html, values);
}
}
request.WriteHttpResponseHeader("1.0", 200, html.size(), std::string(mimeType).c_str());
request.Out()->Push(html.data(), html.size());
return true;
}
@@ -392,7 +453,7 @@ static void HandleFallback(const http::ServerRequest &request) {
}
if (serverFlags & WebServerFlags::FILE_UPLOAD) {
if (startsWith(request.resource(), "/upload/")) {
if (startsWith(request.resource(), "/upload")) {
if (ServeAssetFile(request)) {
return;
}
+12
View File
@@ -115,6 +115,7 @@ static std::string langRegion;
static std::string osName;
static std::string osVersion;
static std::string gpuDriverVersion;
static std::string computerName;
static std::string restartArgs;
@@ -253,6 +254,17 @@ std::string System_GetProperty(SystemProperty prop) {
return PPSSPP_GIT_VERSION;
case SYSPROP_USER_DOCUMENTS_DIR:
return Path(W32Util::UserDocumentsPath()).ToString(); // this'll reverse the slashes.
case SYSPROP_COMPUTER_NAME:
if (computerName.empty()) {
wchar_t nameBuf[MAX_COMPUTERNAME_LENGTH + 1];
DWORD size = ARRAY_SIZE(nameBuf);
if (GetComputerNameW(nameBuf, &size)) {
computerName = ConvertWStringToUTF8(std::wstring(nameBuf, size));
} else {
computerName = "(N/A)";
}
}
return computerName;
default:
return "";
}
+193 -140
View File
@@ -1,208 +1,261 @@
<!DOCTYPE html>
<!--upload-->
<!--TODO: Make localizable somehow.-->
<html lang="en">
<head>
<meta charset="utf-8">
<title>File/Folder Uploader</title>
<meta charset="UTF-8">
<title>File Uploader</title>
<style>
body {
font-family: system-ui, sans-serif;
background: #f5f7fa;
color: #333;
padding: 2em;
line-height: 1.5;
max-width: 600px;
margin: auto;
}
h1 {
font-size: 1.4em;
text-align: center;
color: #444;
}
.section {
margin-bottom: 1em;
}
input[type="file"] {
margin-bottom: 1em;
cursor: pointer;
display: block;
margin: 0.5em 0;
}
button {
background: #0078d4;
color: white;
border: none;
padding: 0.6em 1.4em;
border-radius: 6px;
cursor: pointer;
display: inline-block;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
border: none;
border-radius: 6px;
background-color: #4a90e2;
color: white;
cursor: pointer;
transition: background 0.2s;
margin-right: 0.5em;
}
button:hover:enabled {
background: #005fa3;
#uploadBtn:enabled {
background-color: #3aa270;
}
button:disabled {
background: #a0a0a0;
background-color: #ccc;
cursor: not-allowed;
}
#status {
margin-top: 1em;
button:hover:not(:disabled) {
background-color: #357ab8;
}
.btn {
display: inline-block;
padding: 0.6em 1.2em;
font-size: 1em;
border: none;
border-radius: 6px;
background-color: #4a90e2;
color: white;
cursor: pointer;
transition: background 0.2s;
margin-right: 0.5em;
}
.btn:hover {
background-color: #357ab8;
}
#fileList {
margin-top: 0.5em;
font-size: 0.95em;
color: #333;
}
progress {
width: 100%;
height: 16px;
appearance: none;
-webkit-appearance: none;
height: 20px;
margin-top: 0.5em;
border-radius: 4px;
overflow: hidden;
display: none;
}
progress::-webkit-progress-bar {
background-color: #e3e3e3;
#status {
margin-top: 0.5em;
font-weight: 500;
}
progress::-webkit-progress-value {
background-color: #0078d4;
#error {
color: #d33;
margin-top: 0.5em;
}
progress::-moz-progress-bar {
background-color: #0078d4;
pre.inline {
display: inline;
}
#totalContainer {
margin-top: 1em;
.btn input[type="file"] {
display: none; /* hide the native control */
}
#summary {
margin-top: 1em;
font-size: 0.95em;
color: #0078d4;
#resetBtn {
display: none; /* initially hidden */
}
</style>
</head>
<body>
<h1>Upload Files or Folders</h1>
<h1>Upload files to {{ device_name }}</h1>
<label>Choose files:</label>
<input type="file" id="fileInput" multiple>
<br>
<label>Or choose a folder:</label>
<input type="file" id="folderInput" webkitdirectory multiple>
<br>
<button id="uploadBtn" disabled>Upload</button>
<p>To folder: <pre class="inline">{{ upload_path }}</pre></p>
<div id="status"></div>
<progress id="fileProgress" value="0" max="100"></progress>
<div id="totalContainer">
<progress id="totalProgress" value="0" max="100"></progress>
<div class="section">
<label class="btn">
Choose individual files...
<input type="file" id="fileInput" multiple hidden>
</label>
<label class="btn">
Choose folder...
<input type="file" id="folderInput" webkitdirectory multiple>
</label>
<button id="resetBtn">
Reset
</button>
</div>
<div id="summary"></div>
<div id="fileList"></div>
<div class="section">
<button id="uploadBtn" disabled>Start upload!</button>
</div>
<progress id="progressBar" value="0" max="100"></progress>
<div id="status"></div>
<div id="error"></div>
<script>
const fileInput = document.getElementById("fileInput");
const folderInput = document.getElementById("folderInput");
const uploadBtn = document.getElementById("uploadBtn");
const fileProgress = document.getElementById("fileProgress");
const totalProgress = document.getElementById("totalProgress");
const status = document.getElementById("status");
const summary = document.getElementById("summary");
(() => {
const fileInput = document.getElementById("fileInput");
const folderInput = document.getElementById("folderInput");
const uploadBtn = document.getElementById("uploadBtn");
const resetBtn = document.getElementById("resetBtn");
const fileListDiv = document.getElementById("fileList");
const progressBar = document.getElementById("progressBar");
const statusDiv = document.getElementById("status");
const errorDiv = document.getElementById("error");
function getAllFiles() {
return [
...fileInput.files,
...folderInput.files
];
}
// Enable Upload button if either input has files
function updateUploadButton() {
uploadBtn.disabled = getAllFiles().length === 0;
}
fileInput.addEventListener("change", updateUploadButton);
folderInput.addEventListener("change", updateUploadButton);
uploadBtn.onclick = async () => {
const files = getAllFiles();
if (!files.length) return;
uploadBtn.disabled = true; // prevent double-clicks
fileProgress.style.display = "block";
totalProgress.style.display = "block";
summary.textContent = "";
const totalBytes = files.reduce((sum, f) => sum + f.size, 0);
let uploadedBytes = 0;
const startTime = performance.now();
for (let i = 0; i < files.length; i++) {
const file = files[i];
const relPath = file.webkitRelativePath || file.name;
status.textContent = `Uploading ${relPath} (${i + 1}/${files.length}) — ${file.size} bytes`;
fileProgress.value = 0;
try {
await uploadSingleFile(file, relPath, (loaded) => {
fileProgress.value = (loaded / file.size) * 100;
totalProgress.value = ((uploadedBytes + loaded) / totalBytes) * 100;
let files = [];
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 B';
const k = 1024;
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const size = parseFloat((bytes / Math.pow(k, i)).toFixed(decimals));
return `${size} ${units[i]}`;
}
function updateFileList() {
files = [...fileInput.files, ...folderInput.files];
fileListDiv.innerHTML = "";
if (files.length > 0) {
uploadBtn.disabled = false;
resetBtn.style.display = "inline-block";
const list = document.createElement("ul");
files.forEach(f => {
const li = document.createElement("li");
const formattedBytes = formatBytes(f.size, 2);
const name = f.webkitRelativePath || f.name;
li.textContent = `${name} (${formattedBytes} bytes)`;
list.appendChild(li);
});
uploadedBytes += file.size;
} catch (err) {
status.textContent = `Error uploading ${relPath}: ${err.message}`;
break;
fileListDiv.appendChild(list);
} else {
uploadBtn.disabled = true;
resetBtn.style.display = "none";
}
}
const elapsed = (performance.now() - startTime) / 1000;
const totalSizeStr = formatBytes(totalBytes);
const fileCount = files.length;
fileInput.addEventListener("change", updateFileList);
folderInput.addEventListener("change", updateFileList);
totalProgress.value = 100;
status.textContent = "All files uploaded successfully!";
summary.textContent = `Uploaded ${fileCount} file${fileCount > 1 ? "s" : ""} (${totalSizeStr}) in ${elapsed.toFixed(1)} s`;
setTimeout(() => {
fileProgress.style.display = "none";
totalProgress.style.display = "none";
updateUploadButton(); // enable upload if files still selected
}, 2000);
};
function uploadSingleFile(file, relPath, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/upload_file", true);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) onProgress(event.loaded);
};
xhr.onload = () => {
xhr.status === 200 ? resolve() : reject(new Error(`HTTP ${xhr.status}`));
};
xhr.onerror = () => reject(new Error("Network error"));
const formData = new FormData();
formData.append("file", file, relPath);
xhr.send(formData);
resetBtn.addEventListener("click", () => {
// Clear the file inputs
fileInput.value = "";
folderInput.value = "";
// Clear the files array and update the display
files = [];
updateFileList();
});
}
function formatBytes(bytes) {
const units = ["B", "KB", "MB", "GB", "TB"];
let i = 0;
while (bytes >= 1024 && i < units.length - 1) {
bytes /= 1024;
i++;
uploadBtn.addEventListener("click", async () => {
if (files.length === 0) return;
uploadBtn.disabled = true;
errorDiv.textContent = "";
statusDiv.textContent = "";
progressBar.style.display = "block";
progressBar.value = 0;
for (let i = 0; i < files.length; i++) {
const file = files[i];
statusDiv.textContent = `Uploading ${file.webkitRelativePath || file.name} (${i + 1}/${files.length})`;
try {
await uploadFile(file);
} catch (err) {
errorDiv.textContent = err.message;
progressBar.style.display = "none";
uploadBtn.disabled = false;
return;
}
}
statusDiv.textContent = "All files uploaded successfully.";
progressBar.style.display = "none";
uploadBtn.disabled = false;
});
function uploadFile(file) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/upload_file");
// Add custom metadata headers if needed:
xhr.setRequestHeader("X-Client-Version", "1.0");
xhr.setRequestHeader("X-Upload-Timestamp", new Date().toISOString());
xhr.upload.onprogress = e => {
if (e.lengthComputable) {
progressBar.value = (e.loaded / e.total) * 100;
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
let msg;
switch (xhr.status) {
case 400: msg = "Malformed request (boundary or headers invalid)."; break;
case 500: msg = "Target is probably out of disk space."; break;
default: msg = `Upload failed with status ${xhr.status}.`; break;
}
reject(new Error(msg));
}
};
xhr.onerror = () => reject(new Error("Connection error during upload."));
// Try to preserve any directory structure, if supported.
const formData = new FormData();
formData.append("file", file, file.webkitRelativePath || file.name);
xhr.send(formData);
});
}
return `${bytes.toFixed(2)} ${units[i]}`;
}
})();
</script>
</body>
</html>