diff --git a/docs/datasheet.rst b/docs/datasheet.rst index 8830420bc..43f23ba2f 100644 --- a/docs/datasheet.rst +++ b/docs/datasheet.rst @@ -9,8 +9,8 @@ MapMap allows its users to: * Create and destroy an unlimited amount of mappings. Each mapping is a shape on which a source paint is drawn. * Color paints can be used as masks. * Move layers. -* Save the project to a human-readable XML file. -* Load the project from a human-readable XML file. +* Save the project to a human-readable JSON file. +* Load the project from a human-readable JSON file. * Turn any quad into a mesh. * Inspect properties. diff --git a/docs/informations/osc.md b/docs/informations/osc.md index 58c951e2d..504465f8d 100644 --- a/docs/informations/osc.md +++ b/docs/informations/osc.md @@ -57,3 +57,14 @@ Pause: `/mapmap/pause` Play: `/mapmap/play` Rewind/reset: `/mapmap/rewind` Quit: `/mapmap/quit` + +## Security + +OSC has no authentication. By default MapMap listens on the loopback +interface only (`127.0.0.1`), so OSC is reachable solely from the same +computer. To control MapMap from another device, enable **Accept OSC from +the network** in *Preferences > OSC Setup* — only on a trusted show network. + +The `/mapmap/quit` command is ignored unless **Allow OSC to quit the +application** is enabled in the same panel, so a remote sender cannot close +the app during a show. diff --git a/docs/manual/manual_en.rst b/docs/manual/manual_en.rst index cfd582304..653c6d161 100644 --- a/docs/manual/manual_en.rst +++ b/docs/manual/manual_en.rst @@ -49,7 +49,7 @@ Paints can be destroyed simply by selecting it in the paint list, and then choos Save the project to a file -------------------------- -To save the current project, choose "File > Save as..." and then choose a file name. The extension file is ".mmp", but the file format is simply XML, a very common one. +To save the current project, choose "File > Save as..." and then choose a file name. The extension file is ".mmp", but the file format is simply JSON, a very common one. Load a project from a file -------------------------- diff --git a/mapmap.pro b/mapmap.pro index 4444fe50e..b5f2b039c 100644 --- a/mapmap.pro +++ b/mapmap.pro @@ -4,11 +4,16 @@ CONFIG += c++17 TEMPLATE = app -# Always use major.minor.micro version number format -VERSION = 0.6.3 +# Always use major.minor.micro version number format (kept numeric so bundle +# and library versioning stay valid). +VERSION = 1.0.0 +# Human-facing version shown in-app (About box, window title, app version). +# Single source of truth for MM::VERSION, injected via DEFINES below. +MAPMAP_VERSION = 1.0.0-alpha.1 TARGET = mapmap DEFINES += UNICODE QT_THREAD_SUPPORT QT_CORE_LIB QT_GUI_LIB QT_MESSAGELOGCONTEXT +DEFINES += MAPMAP_VERSION_STRING=\\\"$$MAPMAP_VERSION\\\" include(src/core/core.pri) include(src/shape/shape.pri) diff --git a/src/app/MainApplication.cpp b/src/app/MainApplication.cpp index 86efb27b7..7647e4049 100644 --- a/src/app/MainApplication.cpp +++ b/src/app/MainApplication.cpp @@ -44,14 +44,23 @@ MainApplication::~MainApplication() bool MainApplication::notify(QObject *receiver, QEvent *event) { + // Last-resort guard: a stray exception during event delivery must never take + // down a running show. Log loudly (qCritical survives in release builds, + // unlike qDebug) and drop the offending event instead of terminating. + const int eventType = event ? static_cast(event->type()) : -1; try { return QApplication::notify(receiver, event); } - catch (std::exception &ex) + catch (const std::exception &ex) { - qDebug() << "std::exception was caught: " << ex.what() << Qt::endl; - qDebug() << "event type: " << event->type() << Qt::endl; + qCritical() << "Unhandled std::exception during event delivery:" << ex.what() + << "(event type" << eventType << ")"; + } + catch (...) + { + qCritical() << "Unhandled non-standard exception during event delivery" + << "(event type" << eventType << ")"; } return false; diff --git a/src/app/main.cpp b/src/app/main.cpp index 23bb4c6a5..83211378f 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -195,7 +195,8 @@ int main(int argc, char *argv[]) // Load stylesheet. QFile stylesheet(":/stylesheet"); - (void)stylesheet.open(QFile::ReadOnly); + if (!stylesheet.open(QFile::ReadOnly)) + qWarning() << "Could not open embedded stylesheet" << stylesheet.fileName(); app.setStyleSheet(QLatin1String(stylesheet.readAll())); // read positional argument: diff --git a/src/control/McpServer.cpp b/src/control/McpServer.cpp index 1accace41..d7781b31f 100644 --- a/src/control/McpServer.cpp +++ b/src/control/McpServer.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -40,9 +41,38 @@ namespace mmp { +namespace { +// Host/Origin values that identify the local machine, used to reject +// DNS-rebinding and cross-origin requests to the control surface. +bool isLoopbackHost(QByteArray host) +{ + host = host.trimmed(); + if (host.startsWith('[')) // IPv6 literal, e.g. [::1]:port + { + const int end = host.indexOf(']'); + if (end > 0) host = host.mid(1, end - 1); + } + else // host:port + { + const int colon = host.indexOf(':'); + if (colon >= 0) host = host.left(colon); + } + return host == "localhost" || host == "127.0.0.1" || host == "::1"; +} + +bool isLoopbackOrigin(QByteArray origin) +{ + const int scheme = origin.indexOf("://"); + if (scheme >= 0) origin = origin.mid(scheme + 3); + return isLoopbackHost(origin); +} +} // namespace + McpServer::McpServer(MainWindow* mainWindow, QObject* parent) : QObject(parent), _mainWindow(mainWindow), _httpServer(nullptr), _port(0) { + // Per-process shared secret required on every request (see isAuthorized). + _token = QUuid::createUuid().toString(QUuid::WithoutBraces); } McpServer::~McpServer() @@ -61,6 +91,9 @@ quint16 McpServer::start(quint16 port) if (request.method() != QHttpServerRequest::Method::Post) return QHttpServerResponse("text/plain", QByteArray("Method Not Allowed"), QHttpServerResponse::StatusCode::MethodNotAllowed); + if (!isAuthorized(request)) + return QHttpServerResponse("text/plain", QByteArray("Forbidden"), + QHttpServerResponse::StatusCode::Forbidden); const QByteArray out = handleRpc(request.body()); if (out.isEmpty()) // notification: acknowledge with no body return QHttpServerResponse(QHttpServerResponse::StatusCode::Accepted); @@ -85,6 +118,19 @@ bool McpServer::isListening() const return _httpServer != nullptr && _port != 0; } +bool McpServer::isAuthorized(const QHttpServerRequest& request) const +{ + // 1. DNS-rebinding guard: the Host header must name the loopback interface. + if (!isLoopbackHost(request.value("Host"))) + return false; + // 2. If a browser sent an Origin, it must be loopback too ("null" is allowed). + const QByteArray origin = request.value("Origin"); + if (!origin.isEmpty() && origin != "null" && !isLoopbackOrigin(origin)) + return false; + // 3. Shared-secret bearer token generated at startup. + return request.value("Authorization") == QByteArray("Bearer ") + _token.toUtf8(); +} + QByteArray McpServer::handleRpc(const QByteArray& body) { QJsonParseError parseError; diff --git a/src/control/McpServer.h b/src/control/McpServer.h index ee518ecd3..1da54462f 100644 --- a/src/control/McpServer.h +++ b/src/control/McpServer.h @@ -30,6 +30,7 @@ QT_BEGIN_NAMESPACE class QHttpServer; +class QHttpServerRequest; QT_END_NAMESPACE namespace mmp { @@ -56,12 +57,19 @@ class McpServer : public QObject { /// Currently bound port (0 if not listening). quint16 port() const { return _port; } + /// Bearer token that every request must present in its Authorization header. + QString token() const { return _token; } + private: // --- JSON-RPC plumbing --- // Returns the response body, or an empty array for notifications (no reply). QByteArray handleRpc(const QByteArray& body); QJsonObject dispatch(const QJsonObject& request); + // Rejects requests that are non-loopback (DNS-rebinding), cross-origin, or + // missing the bearer token. + bool isAuthorized(const QHttpServerRequest& request) const; + static QJsonObject makeResult(const QJsonValue& id, const QJsonValue& result); static QJsonObject makeError(const QJsonValue& id, int code, const QString& message); @@ -83,6 +91,7 @@ class McpServer : public QObject { MainWindow* _mainWindow; QHttpServer* _httpServer; quint16 _port; + QString _token; }; } diff --git a/src/control/OscInterface.cpp b/src/control/OscInterface.cpp index 1e2711864..d4415dc78 100644 --- a/src/control/OscInterface.cpp +++ b/src/control/OscInterface.cpp @@ -75,8 +75,8 @@ QVector resolveLayers(MappingManager& manager, const OscAction& acti } // namespace OscInterface::OscInterface( - int listen_port) : - receiver_(listen_port), + int listen_port, bool acceptFromNetwork) : + receiver_(listen_port, acceptFromNetwork), messaging_queue_() { receiving_enabled_ = true; if (receiving_enabled_) { @@ -219,7 +219,11 @@ bool OscInterface::applyAction(MainWindow &main_window, const OscAction& action) return false; // Global transport. - case OscAction::Quit: main_window.close(); return true; + case OscAction::Quit: + if (main_window.isOscQuitAllowed()) { main_window.close(); return true; } + qWarning() << "Ignoring OSC /mapmap/quit: remote quit is disabled " + "(enable it in Preferences > OSC Setup)."; + return false; case OscAction::PlayAll: main_window.play(); return true; case OscAction::PauseAll: main_window.pause(); return true; case OscAction::RewindAll: main_window.rewind(); return true; diff --git a/src/control/OscInterface.h b/src/control/OscInterface.h index 93ffa8bc6..da799d6fd 100644 --- a/src/control/OscInterface.h +++ b/src/control/OscInterface.h @@ -42,10 +42,10 @@ class OscInterface { public: typedef QSharedPointer ptr; - OscInterface(int listen_port); + OscInterface(int listen_port, bool acceptFromNetwork = false); ~OscInterface(); - void setVerbose(bool verbose) { verbose_ = verbose; } + void setVerbose(bool verbose) { verbose_ = verbose; receiver_.setVerbose(verbose); } /// Starts listening if receiving is enabled. void start(); diff --git a/src/control/qosc/oscreceiver.cpp b/src/control/qosc/oscreceiver.cpp index 6f324944b..06ed493cd 100644 --- a/src/control/qosc/oscreceiver.cpp +++ b/src/control/qosc/oscreceiver.cpp @@ -1,14 +1,23 @@ #include "oscreceiver.h" #include "contrib/oscpack/OscTypes.h" #include "contrib/oscpack/OscReceivedElements.h" +#include "contrib/oscpack/OscException.h" -OscReceiver::OscReceiver(quint16 receivePort, QObject* parent) : +OscReceiver::OscReceiver(quint16 receivePort, bool acceptFromNetwork, QObject* parent) : QObject(parent) { m_udpSocket = new QUdpSocket(this); - // m_udpSocket->bind(QHostAddress::LocalHost, receivePort); - qDebug() << "Listening for OSC on " << receivePort; - m_udpSocket->bind(QHostAddress::Any, receivePort); + // OSC has no authentication, so bind to loopback by default: the control + // surface must not be reachable from the venue network unless the user + // explicitly opts in (Preferences > OSC Setup). + const QHostAddress bindAddress = acceptFromNetwork ? QHostAddress(QHostAddress::Any) + : QHostAddress(QHostAddress::LocalHost); + if (!m_udpSocket->bind(bindAddress, receivePort)) { + qWarning() << "OscReceiver: could not bind OSC port" << receivePort + << "on" << bindAddress.toString(); + } else { + qInfo() << "Listening for OSC on" << bindAddress.toString() << "port" << receivePort; + } connect(m_udpSocket, &QUdpSocket::readyRead, this, &OscReceiver::readyReadCb); } @@ -19,39 +28,51 @@ void OscReceiver::readyReadCb() { QByteArray data = datagram.data(); QVariantList arguments; QString oscAddress; - this->byteArrayToVariantList(arguments, oscAddress, data); - emit messageReceived(oscAddress, arguments); - qDebug() << "C++OscReceiver Received: " << oscAddress << arguments; + // A malformed datagram must never take the app down: only forward the + // message when it parsed cleanly, and keep draining the queue otherwise. + if (this->byteArrayToVariantList(arguments, oscAddress, data)) { + emit messageReceived(oscAddress, arguments); + if (m_verbose) + qDebug() << "OscReceiver received:" << oscAddress << arguments; + } } } -void OscReceiver::byteArrayToVariantList(QVariantList& outputVariantList, QString& outputOscAddress, const QByteArray& inputByteArray) { - osc::ReceivedPacket packet(inputByteArray.data(), static_cast(inputByteArray.size())); - // TODO: catch parsing exceptions - if (packet.IsMessage()) { - osc::ReceivedMessage message(packet); - // Get address pattern - QString address(message.AddressPattern()); - outputOscAddress.replace(0, address.size(), address); +bool OscReceiver::byteArrayToVariantList(QVariantList& outputVariantList, QString& outputOscAddress, const QByteArray& inputByteArray) { + // oscpack throws osc::Exception (derived from std::exception) on any + // malformed input. Catch it here so a single bad datagram is dropped + // rather than unwinding through the Qt event loop. + try { + osc::ReceivedPacket packet(inputByteArray.data(), static_cast(inputByteArray.size())); + if (packet.IsMessage()) { + osc::ReceivedMessage message(packet); + // Get address pattern + QString address(message.AddressPattern()); + outputOscAddress.replace(0, address.size(), address); - for (auto iter = message.ArgumentsBegin(); iter != message.ArgumentsEnd(); ++ iter) { - osc::ReceivedMessageArgument argument = (*iter); - if (argument.IsBool()) { - outputVariantList.append(QVariant(argument.AsBool())); - } else if (argument.IsString()) { - outputVariantList.append(QVariant(argument.AsString())); - } else if (argument.IsInt32()) { - outputVariantList.append(QVariant::fromValue(static_cast(argument.AsInt32()))); - } else if (argument.IsFloat()) { - outputVariantList.append(QVariant(argument.AsFloat())); - } else if (argument.IsChar()) { - outputVariantList.append(QVariant(argument.AsChar())); - //} else if (argument.IsInt64()) { - // outputVariantList.append(QVariant(argument.AsInt64())); - } else if (argument.IsDouble()) { - outputVariantList.append(QVariant(argument.AsDouble())); + for (auto iter = message.ArgumentsBegin(); iter != message.ArgumentsEnd(); ++ iter) { + osc::ReceivedMessageArgument argument = (*iter); + if (argument.IsBool()) { + outputVariantList.append(QVariant(argument.AsBool())); + } else if (argument.IsString()) { + outputVariantList.append(QVariant(argument.AsString())); + } else if (argument.IsInt32()) { + outputVariantList.append(QVariant::fromValue(static_cast(argument.AsInt32()))); + } else if (argument.IsFloat()) { + outputVariantList.append(QVariant(argument.AsFloat())); + } else if (argument.IsChar()) { + outputVariantList.append(QVariant(argument.AsChar())); + //} else if (argument.IsInt64()) { + // outputVariantList.append(QVariant(argument.AsInt64())); + } else if (argument.IsDouble()) { + outputVariantList.append(QVariant(argument.AsDouble())); + } + // TODO: support Array, Midi, Blob, Symbol, TimeTag, RGBA, Nil } - // TODO: support Array, Midi, Blob, Symbol, TimeTag, RGBA, Nil - } - } // TODO: also parse bundles + } // TODO: also parse bundles + } catch (const osc::Exception& e) { + qWarning() << "OscReceiver: dropped malformed OSC packet:" << e.what(); + return false; + } + return true; } diff --git a/src/control/qosc/oscreceiver.h b/src/control/qosc/oscreceiver.h index 06ea9ccd1..15ef2ea37 100644 --- a/src/control/qosc/oscreceiver.h +++ b/src/control/qosc/oscreceiver.h @@ -26,7 +26,11 @@ class QOSC_EXPORT OscReceiver : public QObject * @brief Constructor. * @param receivePort Port number to listen to. */ - explicit OscReceiver(quint16 receivePort, QObject *parent = nullptr); + explicit OscReceiver(quint16 receivePort, bool acceptFromNetwork = false, QObject *parent = nullptr); + + /// When false (default), per-message receive logging is suppressed to keep + /// the hot path quiet under high OSC rates. + void setVerbose(bool verbose) { m_verbose = verbose; } signals: /** @@ -41,7 +45,8 @@ public slots: private: QUdpSocket* m_udpSocket; - void byteArrayToVariantList(QVariantList& outputVariantList, QString& outputOscAddress, const QByteArray& inputByteArray); + bool m_verbose = false; + bool byteArrayToVariantList(QVariantList& outputVariantList, QString& outputOscAddress, const QByteArray& inputByteArray); }; #endif // OSCRECEIVER_H diff --git a/src/core/MM.cpp b/src/core/MM.cpp index e5b3321f0..ff57bc392 100644 --- a/src/core/MM.cpp +++ b/src/core/MM.cpp @@ -23,7 +23,13 @@ namespace mmp { const QString MM::APPLICATION_NAME = "MapMap"; -const QString MM::VERSION = "0.6.3"; +// Single source of truth is MAPMAP_VERSION in mapmap.pro, injected via DEFINES. +// The literal fallback keeps direct compiles working and must match the .pro. +#ifdef MAPMAP_VERSION_STRING +const QString MM::VERSION = MAPMAP_VERSION_STRING; +#else +const QString MM::VERSION = "1.0.0-alpha.1"; +#endif const QString MM::COPYRIGHT_OWNERS = "Alexandre Quessy, Sofian Audry, Dame Diongue, Mike Latona, Vasilis Liaskovitis"; const QString MM::ORGANIZATION_NAME = "MapMap"; const QString MM::ORGANIZATION_DOMAIN = "artpluscode.com"; @@ -42,7 +48,9 @@ const QString MM::VIDEO_FILES_FILTER = "*.mov *.mp4 *.avi *.ogg *.ogv *.mpeg *.m const QString MM::IMAGE_FILES_FILTER = "*.jpg *.jpeg *.gif *.png *.tiff *.tif *.bmp"; const QString MM::NAMESPACE_PREFIX = QString("%1::").arg(TOSTRING(MM_NAMESPACE)); const QString MM::SUPPORTED_LANGUAGES = "en, es, fr, zh_CN, zh_TW"; -const QString MM::SUPPORTED_FILE_VERSIONS = "\\d+\\.\\d+\\.\\d+"; // regex +// x.y.z with an optional -prerelease / +build suffix (e.g. 1.0.0-alpha.1), so +// projects saved by pre-release builds remain loadable. Anchored to reject junk. +const QString MM::SUPPORTED_FILE_VERSIONS = "^\\d+\\.\\d+\\.\\d+([-+][0-9A-Za-z.-]+)?$"; // regex const QColor MM::WHITE("#f6f5f5"); const QColor MM::BLUE_GRAY("#323541"); diff --git a/src/core/MM.h b/src/core/MM.h index ea27955fd..9e80d84a4 100644 --- a/src/core/MM.h +++ b/src/core/MM.h @@ -79,9 +79,10 @@ class MM // OSC static const int DEFAULT_OSC_PORT = 12345; - // MCP (Model Context Protocol) server. Uses a port in the IANA - // dynamic/private range (49152-65535) to minimise collisions. - static const int DEFAULT_MCP_PORT = 49452; + // MCP (Model Context Protocol) server. Disabled by default (0): the user + // opts in by setting a port in Preferences > MCP Setup. A good enabled value + // is one in the IANA dynamic/private range (49152-65535), e.g. 49452. + static const int DEFAULT_MCP_PORT = 0; // Default values static const bool DISPLAY_TEST_SIGNAL = false; diff --git a/src/core/ProjectReader.cpp b/src/core/ProjectReader.cpp index 8dcc2714c..337467c76 100644 --- a/src/core/ProjectReader.cpp +++ b/src/core/ProjectReader.cpp @@ -87,19 +87,19 @@ void ProjectReader::parseProject(const QJsonObject& project) manager.addSource(source); _window->addSourceItem(source->getId(), source->getIcon(), source->getName()); - // Locate media file if not found + // Locate media file if not found. Guard the cast: a source that claims + // to be Video/Image but cannot be cast (corrupt entry) must not be + // dereferenced — Q_CHECK_PTR is a no-op in release builds. if (source->getSourceType() == Source::SourceType::Video) { QSharedPointer