From dc682fb2c8b4122563a86b2c1870ee2463707469 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Sun, 26 Oct 2025 01:00:11 +0200 Subject: [PATCH] Basic upload via web browser support, only single file almost works --- CMakeLists.txt | 7 +- Common/Net/HTTPServer.h | 4 + Common/StringUtils.cpp | 10 ++ Common/StringUtils.h | 3 + Core/WebServer.cpp | 194 ++++++++++++++++++++++++++++++++---- Core/WebServer.h | 3 + UI/MainScreen.cpp | 2 +- UI/UploadScreen.cpp | 6 +- UI/UploadScreen.h | 3 +- UWP/UWP.vcxproj | 4 + android/ab.sh | 1 + assets/upload/index.html | 208 +++++++++++++++++++++++++++++++++++++++ ppsspp.iss | 1 + 13 files changed, 420 insertions(+), 26 deletions(-) create mode 100644 assets/upload/index.html diff --git a/CMakeLists.txt b/CMakeLists.txt index 3b95915f1b..bbe6042166 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2772,6 +2772,7 @@ set(NativeAssets assets/asciifont_atlas.zim assets/asciifont_atlas.meta assets/debugger + assets/upload assets/lang assets/shaders assets/themes @@ -2915,6 +2916,7 @@ if(TargetBin) file(GLOB_RECURSE SHADER_FILES assets/shaders/*) file(GLOB_RECURSE THEME_FILE assets/themes/*) file(GLOB_RECURSE DEBUGGER_FILES assets/debugger/*) + file(GLOB_RECURSE WEB_UPLOAD_FILES assets/upload/*) file(GLOB_RECURSE VFPU_FILES assets/vfpu/*) if(NOT IOS) @@ -2926,17 +2928,18 @@ if(TargetBin) set_source_files_properties(${SHADER_FILES} PROPERTIES MACOSX_PACKAGE_LOCATION "Resources/assets/shaders") set_source_files_properties(${THEME_FILE} PROPERTIES MACOSX_PACKAGE_LOCATION "Resources/assets/themes") set_source_files_properties(${DEBUGGER_FILES} PROPERTIES MACOSX_PACKAGE_LOCATION "Resources/assets/debugger") + set_source_files_properties(${WEB_UPLOAD_FILES} PROPERTIES MACOSX_PACKAGE_LOCATION "Resources/assets/upload") set_source_files_properties(${VFPU_FILES} PROPERTIES MACOSX_PACKAGE_LOCATION "Resources/assets/vfpu") endif() if(IOS) set(AssetCatalog "${CMAKE_SOURCE_DIR}/ios/assets.xcassets") - add_executable(${TargetBin} MACOSX_BUNDLE ${NativeAssets} ${BigFontAssets} ${UIImages} ${AssetCatalog} ${SHADER_FILES} ${THEME_FILE} ${DEBUGGER_FILES} ${FLASH0_FILES} ${LANG_FILES} ${NativeAppSource} "ios/Settings.bundle" "ios/Launch Screen.storyboard") + add_executable(${TargetBin} MACOSX_BUNDLE ${NativeAssets} ${BigFontAssets} ${UIImages} ${AssetCatalog} ${SHADER_FILES} ${THEME_FILE} ${DEBUGGER_FILES} ${WEB_UPLOAD_FILES} ${FLASH0_FILES} ${LANG_FILES} ${NativeAppSource} "ios/Settings.bundle" "ios/Launch Screen.storyboard") if(NOT IOS_APP_STORE) file(INSTALL "${CMAKE_SOURCE_DIR}/ext/vulkan/iOS/Frameworks/libMoltenVK.dylib" DESTINATION "${CMAKE_BINARY_DIR}/PPSSPP.app/Frameworks/") endif() else() - add_executable(${TargetBin} MACOSX_BUNDLE ${ICON_PATH_ABS} ${NativeAssets} ${BigFontAssets} ${UIImages} ${SHADER_FILES} ${THEME_FILE} ${DEBUGGER_FILES} ${FLASH0_FILES} ${LANG_FILES} ${NativeAppSource}) + add_executable(${TargetBin} MACOSX_BUNDLE ${ICON_PATH_ABS} ${NativeAssets} ${BigFontAssets} ${UIImages} ${SHADER_FILES} ${THEME_FILE} ${DEBUGGER_FILES} ${WEB_UPLOAD_FILES} ${FLASH0_FILES} ${LANG_FILES} ${NativeAppSource}) file(INSTALL "${CMAKE_SOURCE_DIR}/ext/vulkan/macOS/Frameworks/libMoltenVK.dylib" DESTINATION "${CMAKE_BINARY_DIR}/${TargetBin}.app/Contents/Frameworks/") if(USING_QT_UI) add_custom_command(TARGET ${TargetBin} POST_BUILD COMMAND /bin/bash "${CMAKE_SOURCE_DIR}/Qt/macbundle.sh" "${CMAKE_BINARY_DIR}/PPSSPPQt.app") diff --git a/Common/Net/HTTPServer.h b/Common/Net/HTTPServer.h index bc2b2bc6e7..a60b061b7f 100644 --- a/Common/Net/HTTPServer.h +++ b/Common/Net/HTTPServer.h @@ -38,6 +38,10 @@ public: return header_.method; } + const RequestHeader &Header() const { + return header_; + } + bool GetParamValue(const char *param_name, std::string *value) const { return header_.GetParamValue(param_name, value); } diff --git a/Common/StringUtils.cpp b/Common/StringUtils.cpp index d8279380a0..c2d978b5f5 100644 --- a/Common/StringUtils.cpp +++ b/Common/StringUtils.cpp @@ -361,6 +361,16 @@ void SplitString(std::string_view str, const char delim, std::vector &output, bool trimOutput) { size_t next = 0; size_t pos = 0; diff --git a/Common/StringUtils.h b/Common/StringUtils.h index a877f9460c..33f3447fd0 100644 --- a/Common/StringUtils.h +++ b/Common/StringUtils.h @@ -110,6 +110,9 @@ void SplitString(std::string_view str, const char delim, std::vector &output, bool trimOutput = false); +// Splits on the first occurrence of delim. Returns true if the delimiter was found. +bool SplitStringOnce(std::string_view str, std::string_view *firstPart, std::string_view *secondPart, char delim); + void GetQuotedStrings(std::string_view str, std::vector &output); std::string ReplaceAll(std::string_view input, std::string_view src, std::string_view dest); diff --git a/Core/WebServer.cpp b/Core/WebServer.cpp index 0fde0fe922..3b42177215 100644 --- a/Core/WebServer.cpp +++ b/Core/WebServer.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include "Common/Net/HTTPClient.h" #include "Common/Net/HTTPServer.h" @@ -50,7 +51,10 @@ static const int REPORT_PORT = 80; static std::thread serverThread; static ServerStatus serverStatus; static std::mutex serverStatusLock; -static int serverFlags; +static WebServerFlags serverFlags; + +std::mutex g_webServerLock; +static Path g_uploadPath; // TODO: Supply this through registration instead. // NOTE: These *only* encode spaces, which is almost enough. @@ -266,7 +270,7 @@ static void HandleListing(const http::ServerRequest &request) { request.WriteHttpResponseHeader("1.0", 200, -1, "text/plain"); request.Out()->Printf("/\n"); - if (serverFlags & (int)WebServerFlags::DISCS) { + if (serverFlags & WebServerFlags::DISCS) { switch ((RemoteISOShareType)g_Config.iRemoteISOShareType) { case RemoteISOShareType::RECENT: // List the current discs in their recent order. @@ -301,22 +305,30 @@ static void HandleListing(const http::ServerRequest &request) { } } } - if (serverFlags & (int)WebServerFlags::DEBUGGER) { + if (serverFlags & WebServerFlags::DEBUGGER) { request.Out()->Printf("/debugger\n"); } } -static bool ServeDebuggerFile(const http::ServerRequest &request) { +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); - if (filename.find("..") != std::string_view::npos) + if (filename.find("..") != std::string_view::npos) { + // Don't allow directory traversal. 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) - return false; + 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"; @@ -347,12 +359,13 @@ static void RedirectToDebugger(const http::ServerRequest &request) { request.Out()->Push(payload); } +// TODO: Allow registering ServeAssetFile roots as well. static void HandleFallback(const http::ServerRequest &request) { SetCurrentThreadName("HandleFallback"); AndroidJNIThreadContext jniContext; - if ((serverFlags & (int)WebServerFlags::DEBUGGER) != 0) { + if ((serverFlags & WebServerFlags::DEBUGGER) != 0) { if (request.resource() == "/debugger/") { RedirectToDebugger(request); return; @@ -360,13 +373,21 @@ static void HandleFallback(const http::ServerRequest &request) { // Actually serve debugger files. if (startsWith(request.resource(), "/debugger/")) { - if (ServeDebuggerFile(request)) { + if (ServeAssetFile(request)) { return; } } } - if (serverFlags & (int)WebServerFlags::DISCS) { + if (serverFlags & WebServerFlags::FILE_UPLOAD) { + if (startsWith(request.resource(), "/upload/")) { + if (ServeAssetFile(request)) { + return; + } + } + } + + if (serverFlags & WebServerFlags::DISCS) { std::string_view resource = request.resource(); Path localPath = LocalFromRemotePath(resource); INFO_LOG(Log::Loader, "Serving %.*s from %s", (int)resource.size(), resource.data(), localPath.c_str()); @@ -391,7 +412,7 @@ static void ForwardDebuggerRequest(const http::ServerRequest &request) { // Hm, is this needed? AndroidJNIThreadContext jniContext; - if (serverFlags & (int)WebServerFlags::DEBUGGER) { + if (serverFlags & WebServerFlags::DEBUGGER) { // Check if this is a websocket request... std::string upgrade; if (!request.GetHeader("upgrade", &upgrade)) { @@ -426,6 +447,140 @@ static void HandleUploadUI(const http::ServerRequest &request) { static void HandleUploadPost(const http::ServerRequest &request) { AndroidJNIThreadContext jniContext; + // Do some sanity checks. + if (request.Method() != http::RequestHeader::POST) { + ERROR_LOG(Log::HTTP, "Wrong method"); + return; + } + + Path uploadPath; + { + std::lock_guard guard(g_webServerLock); + uploadPath = g_uploadPath; + } + + // Now start handling things. + std::string contentType; + if (!request.GetHeader("content-type", &contentType)) { + return; + } + size_t bpos = contentType.find("boundary="); + if (bpos == std::string::npos) { + return; + } + const std::string boundary = contentType.substr(bpos + strlen("boundary=")); + + // The total length of the entire multipart thing. + u64 contentLength = request.Header().content_length; + if (contentLength == 0) { + WARN_LOG(Log::HTTP, "Bad content length"); + return; + } + + std::string firstBoundary = request.In()->ReadLine(); + if (firstBoundary != "--" + boundary) { + WARN_LOG(Log::HTTP, "Bad boundary: Expected --%s but got %s", boundary); + return; + } + + std::string disposition = request.In()->ReadLine(); + std::vector parts; + SplitString(disposition, ';', parts); + if (parts.size() < 2 || !startsWith(parts[0], "Content-Disposition: form-data")) { + WARN_LOG(Log::HTTP, "Bad content disposition: %s", disposition); + return; + } + + std::string filename; + + for (const auto &part : parts) { + std::string_view key; + std::string_view value; + if (SplitStringOnce(part, &key, &value, '=')) { + key = StripSpaces(key); + value = StripQuotes(StripSpaces(value)); + if (key == "name") { + INFO_LOG(Log::HTTP, "Upload field name: %.*s", STR_VIEW(value)); + } else if (key == "filename") { + INFO_LOG(Log::HTTP, "Upload filename: %.*s", STR_VIEW(value)); + filename = value; + } + } else if (equalsNoCase(StripSpaces(part), "Content-Disposition: form-data")) { + // this is the first part, ok, ignore. + } else { + WARN_LOG(Log::HTTP, "Bad content disposition part: %.*s", STR_VIEW(part)); + } + } + + if (filename.empty()) { + ERROR_LOG(Log::HTTP, "Didn't receive a filename"); + return; + } + + std::string fileContentType = request.In()->ReadLine(); + std::string secondBoundary = request.In()->ReadLine(); + + Path destPath = uploadPath / filename; + + if (File::Exists(destPath)) { + INFO_LOG(Log::HTTP, "File already exists: %s", destPath.ToVisualString().c_str()); + return; + } + + // Make sure the destination exists. + File::CreateFullPath(destPath.NavigateUp()); + + INFO_LOG(Log::HTTP, "Receiving '%s', writing to '%s' (%d bytes)...", filename.c_str(), destPath.ToVisualString().c_str()); + + // OK, enter a loop where we read some data until we hit the boundary again. + // The boundary is chosen to be "unique" and unlikely to appear in the file. We trust that. + Buffer buffer; + FILE *fp = File::OpenCFile(destPath, "wb"); + if (!fp) { + ERROR_LOG(Log::HTTP, "Failed to open destination file '%s' for writing", destPath.ToVisualString().c_str()); + return; + } + u64 bytesWritten = 0; + while (true) { + // NOTE: Lines here can be extremely long, especially in compressed data. So we should split ReadLine up if needed. + std::string line = request.In()->ReadLine(); + if (line == "--" + boundary || line == "--" + boundary + "--") { + INFO_LOG(Log::HTTP, "Line matches boundary, breaking."); + // Done. + break; + } + // Write the line and a newline (since we ate it). TODO: This could be avoided. + size_t len = line.size(); + if (fwrite(line.data(), 1, len, fp) != len || fwrite("\r\n", 1, 2, fp) != 2) { + ERROR_LOG(Log::HTTP, "Failed to write %d bytes to destination file '%s' - bailing", (int)len, destPath.ToVisualString().c_str()); + fclose(fp); + return; + } + bytesWritten += line.size(); + } + + INFO_LOG(Log::HTTP, "Total bytes written: %d", (int)bytesWritten); + fclose(fp); + + // NOTE: We already read the boundary above. + + // Now the buffer should be empty. + if (!request.In()->Empty()) { + WARN_LOG(Log::HTTP, "We didn't drain the request."); + std::string extraLine = request.In()->ReadLine(); + INFO_LOG(Log::HTTP, "Extra line: %s", extraLine.c_str()); + } + + INFO_LOG(Log::HTTP, "Upload of '%s' complete.", filename.c_str()); + // request.WriteHttpResponseHeader("1.0", 200, -1, "text/plain"); + + const size_t blockSize = 16 * 1024; + +} + +void WebServerSetUploadPath(const Path &path) { + std::lock_guard guard(g_webServerLock); + g_uploadPath = path; } static void WebServerThread() { @@ -438,8 +593,7 @@ static void WebServerThread() { // This lists all the (current) recent ISOs. It also handles the debugger, which is very ugly. http->SetFallbackHandler(&HandleFallback); http->RegisterHandler("/debugger", &ForwardDebuggerRequest); - http->RegisterHandler("/upload", &HandleUploadUI); - http->RegisterHandler("/upload_post", &HandleUploadPost); + http->RegisterHandler("/upload_file", &HandleUploadPost); if (!http->Listen(g_Config.iRemoteISOPort, "debugger-webserver")) { if (!http->Listen(0, "debugger-webserver")) { @@ -479,11 +633,11 @@ bool StartWebServer(WebServerFlags flags) { std::lock_guard guard(serverStatusLock); switch (serverStatus) { case ServerStatus::RUNNING: - if ((serverFlags & (int)flags) == (int)flags) { + if (((int)serverFlags & (int)flags) == (int)flags) { // Already running with these flags. return false; } - serverFlags |= (int)flags; + serverFlags |= flags; return true; case ServerStatus::FINISHED: @@ -492,7 +646,7 @@ bool StartWebServer(WebServerFlags flags) { case ServerStatus::STOPPED: serverStatus = ServerStatus::STARTING; - serverFlags = (int)flags; + serverFlags = flags; serverThread = std::thread(&WebServerThread); return true; @@ -508,8 +662,8 @@ bool StopWebServer(WebServerFlags flags) { return false; } - serverFlags &= ~(int)flags; - if (serverFlags == 0) { + serverFlags &= ~flags; + if (serverFlags == WebServerFlags::NONE) { serverStatus = ServerStatus::STOPPING; } return true; @@ -523,7 +677,7 @@ bool WebServerStopping(WebServerFlags flags) { bool WebServerStopped(WebServerFlags flags) { std::lock_guard guard(serverStatusLock); if (serverStatus == ServerStatus::RUNNING) { - return (serverFlags & (int)flags) == 0; + return !(serverFlags & flags); } return serverStatus == ServerStatus::STOPPED || serverStatus == ServerStatus::FINISHED; } @@ -537,7 +691,7 @@ void ShutdownWebServer() { } bool WebServerRunning(WebServerFlags flags) { - return RetrieveStatus() == ServerStatus::RUNNING && (serverFlags & (int)flags) != 0; + return RetrieveStatus() == ServerStatus::RUNNING && (serverFlags & flags) != 0; } int WebServerPort() { diff --git a/Core/WebServer.h b/Core/WebServer.h index 26d2f64c71..37320ef8f8 100644 --- a/Core/WebServer.h +++ b/Core/WebServer.h @@ -19,6 +19,8 @@ #include "Common/Common.h" +class Path; + enum class WebServerFlags { NONE = 0, DISCS = 1, @@ -37,4 +39,5 @@ bool WebServerRunning(WebServerFlags flags); void ShutdownWebServer(); bool RemoteISOFileSupported(const std::string &filename); +void WebServerSetUploadPath(const Path &path); int WebServerPort(); diff --git a/UI/MainScreen.cpp b/UI/MainScreen.cpp index e4d0da1bac..fe830b3937 100644 --- a/UI/MainScreen.cpp +++ b/UI/MainScreen.cpp @@ -818,7 +818,7 @@ void GameBrowser::Refresh() { if (browseFlags_ & BrowseFlags::UPLOAD_BUTTON) { topBar->Add(new Choice(ImageID("I_FOLDER_UPLOAD"), new UI::LinearLayoutParams(WRAP_CONTENT, 64.0f)))->OnClick.Add([this](UI::EventParams &e) { - screenManager_->push(new UploadScreen()); + screenManager_->push(new UploadScreen(path_.GetPath())); }); } diff --git a/UI/UploadScreen.cpp b/UI/UploadScreen.cpp index 581e1ebe45..b7c1390891 100644 --- a/UI/UploadScreen.cpp +++ b/UI/UploadScreen.cpp @@ -4,9 +4,9 @@ #include "Common/StringUtils.h" #include "Core/WebServer.h" -UploadScreen::UploadScreen() { +UploadScreen::UploadScreen(const Path &targetFolder) : targetFolder_(targetFolder) { net::GetLocalIP4List(localIPs_); - + WebServerSetUploadPath(targetFolder); StartWebServer(WebServerFlags::FILE_UPLOAD); } @@ -36,6 +36,8 @@ void UploadScreen::CreateViews() { root_->Add(new TextView(co->T("Connecting..."))); } + root_->Add(new TextView(std::string(co->T("Uploading to: ")) + targetFolder_.ToVisualString())); + //infoText->SetTextAlignment(TEXT_ALIGN_CENTER); //verticalLayout->AddView(infoText); //AddStandardBack(verticalLayout); diff --git a/UI/UploadScreen.h b/UI/UploadScreen.h index 4f4d0bb1cd..f2824c6778 100644 --- a/UI/UploadScreen.h +++ b/UI/UploadScreen.h @@ -32,7 +32,7 @@ class UploadScreen : public UIDialogScreenWithBackground { public: - UploadScreen(); + UploadScreen(const Path &targetFolder); ~UploadScreen(); void CreateViews() override; @@ -44,4 +44,5 @@ protected: private: bool prevRunning_ = false; std::vector localIPs_; + Path targetFolder_; }; diff --git a/UWP/UWP.vcxproj b/UWP/UWP.vcxproj index 69117afafc..34ad349efd 100644 --- a/UWP/UWP.vcxproj +++ b/UWP/UWP.vcxproj @@ -311,6 +311,10 @@ true Content\vfpu\%(Filename)%(Extension) + + true + Content\upload\%(Filename)%(Extension) + true Content\debugger\%(Filename)%(Extension) diff --git a/android/ab.sh b/android/ab.sh index 3c9028bc64..761fc088b9 100755 --- a/android/ab.sh +++ b/android/ab.sh @@ -4,6 +4,7 @@ cp -r ../assets/lang assets/ cp -r ../assets/shaders assets/ cp -r ../assets/themes assets/ cp -r ../assets/debugger assets/ +cp -r ../assets/upload assets/ cp -r ../assets/ui_images assets/ cp ../assets/*.ini assets/ cp ../assets/Roboto-Condensed.ttf assets/Roboto-Condensed.ttf diff --git a/assets/upload/index.html b/assets/upload/index.html new file mode 100644 index 0000000000..0f0522ed6c --- /dev/null +++ b/assets/upload/index.html @@ -0,0 +1,208 @@ + + + + + File/Folder Uploader + + + + +

Upload Files or Folders

+ + + +
+ + +
+ + +
+ + +
+ +
+ +
+ + + + diff --git a/ppsspp.iss b/ppsspp.iss index ca028eedfb..4846e2f862 100644 --- a/ppsspp.iss +++ b/ppsspp.iss @@ -99,6 +99,7 @@ Source: "assets\*.*"; DestDir: "{app}\assets" Source: "assets\shaders\*.*"; DestDir: "{app}\assets\shaders" Source: "assets\themes\*.*"; DestDir: "{app}\assets\themes" Source: "assets\debugger\*"; DestDir: "{app}\assets\debugger"; Flags: recursesubdirs +Source: "assets\upload\*"; DestDir: "{app}\assets\upload"; Flags: recursesubdirs Source: "assets\lang\*.ini"; DestDir: "{app}\assets\lang" Source: "assets\flash0\font\*.*"; DestDir: "{app}\assets\flash0\font" Source: "assets\vfpu\*.*"; DestDir: "{app}\assets\vfpu"