From ed5eeabd25bee29b814ee661468e60ba1a7f057d Mon Sep 17 00:00:00 2001 From: Reinder Feenstra Date: Tue, 16 Sep 2025 20:40:02 +0200 Subject: [PATCH] Server now host the manual via its built in webserver., clients now by default display the server hosted manual, if not connectect the local version is opened. --- .github/workflows/build.yml | 2 +- client/src/mainwindow.cpp | 18 ++- client/src/network/connection.cpp | 12 +- client/src/network/connection.hpp | 6 +- server/CMakeLists.txt | 2 +- server/src/network/server.cpp | 111 ++++++++++++++++++ server/src/network/server.hpp | 2 + server/src/utils/endswith.hpp | 14 ++- server/vcpkg.json | 1 + .../src/traintastic/utils/standardpaths.cpp | 59 ++++++++-- 10 files changed, 206 insertions(+), 21 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f16e6466..92f3ac1a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -290,7 +290,7 @@ jobs: # Ubuntu only: - name: Install packages if: startswith(matrix.config.os, 'ubuntu') - run: sudo apt install libboost-program-options-dev liblua5.4-dev lcov libarchive-dev clang-tidy libsystemd-dev + run: sudo apt install libboost-program-options-dev libboost-url-dev liblua5.4-dev lcov libarchive-dev clang-tidy libsystemd-dev # MacOS only: - name: Install brew packages diff --git a/client/src/mainwindow.cpp b/client/src/mainwindow.cpp index a380bad4..4c313640 100644 --- a/client/src/mainwindow.cpp +++ b/client/src/mainwindow.cpp @@ -511,13 +511,25 @@ MainWindow::MainWindow(QWidget* parent) : menu = menuBar()->addMenu(Locale::tr("qtapp.mainmenu:help")); menu->addAction(Theme::getIcon("help"), Locale::tr("qtapp.mainmenu:help"), - []() + [this]() { - const auto manual = QString::fromStdString((getManualPath() / "en-us.html").string()); - if(QFile::exists(manual)) + if(m_connection) + { + QUrl url; + url.setScheme("http"); + url.setHost(m_connection->peerAddress().toString()); + url.setPort(m_connection->peerPort()); + url.setPath("/manual/en/index.html"); + QDesktopServices::openUrl(url); + } + else if(const auto manual = QString::fromStdString((getManualPath() / "en" / "index.html").string()); QFile::exists(manual)) + { QDesktopServices::openUrl(QUrl::fromLocalFile(manual)); + } else + { QDesktopServices::openUrl(QString("https://traintastic.org/manual?version=" TRAINTASTIC_VERSION_FULL)); + } })->setShortcut(QKeySequence::HelpContents); auto* subMenu = menu->addMenu(Locale::tr("qtapp.mainmenu:wizards")); subMenu->addAction(Locale::tr("wizard.introduction:title"), this, &MainWindow::showIntroductionWizard); diff --git a/client/src/network/connection.cpp b/client/src/network/connection.cpp index 2e879706..79c1aee0 100644 --- a/client/src/network/connection.cpp +++ b/client/src/network/connection.cpp @@ -3,7 +3,7 @@ * * This file is part of the traintastic source code. * - * Copyright (C) 2019-2024 Reinder Feenstra + * Copyright (C) 2019-2025 Reinder Feenstra * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License @@ -172,6 +172,16 @@ QString Connection::errorString() const return m_socket->errorString(); } +QHostAddress Connection::peerAddress() const +{ + return m_socket->peerAddress(); +} + +quint16 Connection::peerPort() const +{ + return m_socket->peerPort(); +} + void Connection::connectToHost(const QUrl& url, const QString& username, const QString& password) { m_username = username; diff --git a/client/src/network/connection.hpp b/client/src/network/connection.hpp index cb2e3dc8..e72a5f3c 100644 --- a/client/src/network/connection.hpp +++ b/client/src/network/connection.hpp @@ -3,7 +3,7 @@ * * This file is part of the traintastic source code. * - * Copyright (C) 2019-2021,2023-2024 Reinder Feenstra + * Copyright (C) 2019-2021,2023-2025 Reinder Feenstra * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -121,6 +122,9 @@ class Connection : public QObject, public std::enable_shared_from_this +#include +#include +#include #include #include +#include #include #include "clientconnection.hpp" #include "httpconnection.hpp" @@ -31,7 +35,10 @@ #include "../core/eventloop.hpp" #include "../log/log.hpp" #include "../log/logmessageexception.hpp" +#include "../utils/endswith.hpp" #include "../utils/setthreadname.hpp" +#include "../utils/startswith.hpp" +#include "../utils/stripprefix.hpp" //#define SERVE_FROM_FS // Development option, NOT for production! #ifdef SERVE_FROM_FS @@ -60,6 +67,53 @@ static constexpr std::string_view contentTypeTextHtml{"text/html"}; static constexpr std::string_view contentTypeTextCss{"text/css"}; static constexpr std::string_view contentTypeTextJavaScript{"text/javascript"}; static constexpr std::string_view contentTypeImageXIcon{"image/x-icon"}; +static constexpr std::string_view contentTypeImagePng{"image/png"}; +static constexpr std::string_view contentTypeApplicationGzip{"application/gzip"}; +static constexpr std::string_view contentTypeApplicationJson{"application/json"}; +static constexpr std::string_view contentTypeApplicationXml{"application/xml"}; + +static constexpr std::array manualAllowedExtensions{{ + ".html", + ".css", + ".js", + ".json", + ".png", + ".xml", + ".xml.gz", +}}; + +std::string_view getContentType(std::string_view filename) +{ + if(endsWith(filename, ".html")) + { + return contentTypeTextHtml; + } + else if(endsWith(filename, ".png")) + { + return contentTypeImagePng; + } + else if(endsWith(filename, ".css")) + { + return contentTypeTextCss; + } + else if(endsWith(filename, ".js")) + { + return contentTypeTextJavaScript; + } + else if(endsWith(filename, ".json")) + { + return contentTypeApplicationJson; + } + else if(endsWith(filename, ".xml")) + { + return contentTypeApplicationXml; + } + else if(endsWith(filename, ".gz")) + { + return contentTypeApplicationGzip; + } + return {}; +} http::message_generator notFound(const http::request& request) { @@ -155,6 +209,53 @@ http::message_generator textJavaScript(const http::request& r return text(request, contentTypeTextJavaScript, body); } +http::message_generator serveFileFromFileSystem(const http::request& request, std::string_view target, const std::filesystem::path& root, std::span allowedExtensions) +{ + if(request.method() != http::verb::get && request.method() != http::verb::head) + { + return methodNotAllowed(request, {http::verb::get, http::verb::head}); + } + + if(const auto url = boost::urls::parse_origin_form(target)) + { + const std::filesystem::path path = std::filesystem::weakly_canonical(root / url->path().substr(1)); + + if(std::mismatch(path.begin(), path.end(), root.begin(), root.end()).second == root.end() && std::filesystem::exists(path)) + { + const auto filename = path.string(); + + if(endsWith(filename, allowedExtensions)) + { + http::file_body::value_type file; + boost::system::error_code ec; + + file.open(filename.c_str(), boost::beast::file_mode::scan, ec); + if(!ec) + { + http::response response{ + std::piecewise_construct, + std::make_tuple(std::move(file)), + std::make_tuple(http::status::ok, request.version())}; + + response.set(http::field::server, serverHeader); + response.set(http::field::content_type, getContentType(filename)); + response.content_length(file.size()); + response.keep_alive(request.keep_alive()); + + if(request.method() == http::verb::head) + { + response.body().close(); // don’t send file contents + } + + return response; + } + } + } + } + + return notFound(request); +} + } Server::Server(bool localhostOnly, uint16_t port, bool discoverable) @@ -162,6 +263,7 @@ Server::Server(bool localhostOnly, uint16_t port, bool discoverable) , m_acceptor{m_ioContext} , m_socketUDP{m_ioContext} , m_localhostOnly{localhostOnly} + , m_manualPath{getManualPath()} { assert(isEventLoopThread()); @@ -355,6 +457,7 @@ http::message_generator Server::handleHTTPRequest(http::request" "

Traintastic v" TRAINTASTIC_VERSION_FULL "

" "" "" @@ -399,6 +502,14 @@ http::message_generator Server::handleHTTPRequest(http::request #include #include +#include #include #include #include @@ -51,6 +52,7 @@ class Server : public std::enable_shared_from_this boost::asio::ip::udp::endpoint m_remoteEndpoint; const bool m_localhostOnly; std::list> m_connections; + std::filesystem::path m_manualPath; void doReceive(); static std::unique_ptr processMessage(const Message& message); diff --git a/server/src/utils/endswith.hpp b/server/src/utils/endswith.hpp index fdd57072..7fd1082d 100644 --- a/server/src/utils/endswith.hpp +++ b/server/src/utils/endswith.hpp @@ -3,7 +3,7 @@ * * This file is part of the traintastic source code. * - * Copyright (C) 2022 Reinder Feenstra + * Copyright (C) 2022,2025 Reinder Feenstra * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License @@ -30,4 +30,16 @@ constexpr bool endsWith(std::string_view sv, std::string_view suffix) return sv.size() >= suffix.size() && std::equal(suffix.rbegin(), suffix.rend(), sv.rbegin()); } +constexpr bool endsWith(std::string_view sv, std::span suffixes) +{ + for(auto suffix : suffixes) + { + if(endsWith(sv, suffix)) + { + return true; + } + } + return false; +} + #endif diff --git a/server/vcpkg.json b/server/vcpkg.json index d5fc8f3a..06d7c8f3 100644 --- a/server/vcpkg.json +++ b/server/vcpkg.json @@ -5,6 +5,7 @@ "boost-program-options", "boost-signals2", "boost-uuid", + "boost-url", { "name": "libarchive", "default-features": false, diff --git a/shared/src/traintastic/utils/standardpaths.cpp b/shared/src/traintastic/utils/standardpaths.cpp index 9feedb75..ae5a7a90 100644 --- a/shared/src/traintastic/utils/standardpaths.cpp +++ b/shared/src/traintastic/utils/standardpaths.cpp @@ -21,11 +21,48 @@ */ #include "standardpaths.hpp" +#include #ifdef WIN32 #include #include #endif +namespace { + +#if defined(WIN32) && !(defined(__MINGW32__) || defined(__MINGW64__)) + +#define getEnvironmentVariableAsPath(name) getEnvironmentVariableAsPathImpl(L ## name) + +std::optional getEnvironmentVariableAsPathImpl(const wchar_t* name) +{ + wchar_t* value = nullptr; + size_t valueLength = 0; + if(_wdupenv_s(&value, &valueLength, name) == 0 && value && valueLength != 0) + { + std::filesystem::path path(value); + free(value); + return path; + } + return std::nullopt; +} + +#else + +#define getEnvironmentVariableAsPath(name) getEnvironmentVariableAsPathImpl(name) + +std::optional getEnvironmentVariableAsPathImpl(const char* name) +{ + if(const char* value = getenv(name)) + { + return std::filesystem::path(value); + } + return std::nullopt; +} + +#endif + +} + #ifdef WIN32 static std::filesystem::path getKnownFolderPath(REFKNOWNFOLDERID rfid) { @@ -52,20 +89,10 @@ std::filesystem::path getLocalAppDataPath() std::filesystem::path getLocalePath() { -#if defined(WIN32) && !(defined(__MINGW32__) || defined(__MINGW64__)) - wchar_t* path = nullptr; - size_t pathLength = 0; - if(_wdupenv_s(&path, &pathLength, L"TRAINTASTIC_LOCALE_PATH") == 0 && path && pathLength != 0) + if(auto path = getEnvironmentVariableAsPath("TRAINTASTIC_LOCALE_PATH")) { - std::filesystem::path p(path); - free(path); - return p; + return *path; } -#else - if(const char* path = getenv("TRAINTASTIC_LOCALE_PATH")) - return std::filesystem::path(path); -#endif - #ifdef WIN32 return getProgramDataPath() / "traintastic" / "translations"; #elif defined(__linux__) @@ -77,10 +104,16 @@ std::filesystem::path getLocalePath() std::filesystem::path getManualPath() { + if(auto path = getEnvironmentVariableAsPath("TRAINTASTIC_MANUAL_PATH")) + { + return *path; + } #ifdef WIN32 return getProgramDataPath() / "traintastic" / "manual"; +#elif defined(__linux__) + return "/opt/traintastic/manual"; #else - return {}; + return std::filesystem::current_path() / "manual"; #endif }