diff --git a/python/core/auto_generated/qgsaction.sip.in b/python/core/auto_generated/qgsaction.sip.in index 71ae7de3669d..29a4eb531db0 100644 --- a/python/core/auto_generated/qgsaction.sip.in +++ b/python/core/auto_generated/qgsaction.sip.in @@ -27,6 +27,8 @@ Utility class that encapsulates an action based on vector attributes. Windows, Unix, OpenUrl, + SubmitUrlEncoded, + SubmitUrlMultipart, }; QgsAction(); diff --git a/resources/function_help/json/url_encode b/resources/function_help/json/url_encode new file mode 100644 index 000000000000..0cd91eab1aac --- /dev/null +++ b/resources/function_help/json/url_encode @@ -0,0 +1,18 @@ +{ + "name": "url_encode", + "type": "function", + "groups": ["Maps"], + "description": "Returns an URL encoded string from a map. Transforms all characters in their properly-encoded form producing a fully-compliant query string.
Note that the plus sign '+' is not converted.", + "arguments": [ + { + "arg": "map", + "description": "a map." + } + ], + "examples": [ + { + "expression": "url_encode(map('a&+b', 'a and plus b', 'a=b', 'a equals b'))", + "returns": "'a%26+b=a%20and%20plus%20b&a%3Db=a%20equals%20b'" + } + ] +} diff --git a/src/core/expression/qgsexpressionfunction.cpp b/src/core/expression/qgsexpressionfunction.cpp index 530373ee388c..52f43be2d13c 100644 --- a/src/core/expression/qgsexpressionfunction.cpp +++ b/src/core/expression/qgsexpressionfunction.cpp @@ -69,6 +69,7 @@ #include #include #include +#include typedef QList ExpressionFunctionList; @@ -6536,6 +6537,17 @@ static QVariant fcnToBase64( const QVariantList &values, const QgsExpressionCont return QVariant( QString( input.toBase64() ) ); } +static QVariant fcnToFormUrlEncode( const QVariantList &values, const QgsExpressionContext *, QgsExpression *parent, const QgsExpressionNodeFunction * ) +{ + const QVariantMap map = QgsExpressionUtils::getMapValue( values.at( 0 ), parent ); + QUrlQuery query; + for ( auto it = map.cbegin(); it != map.cend(); it++ ) + { + query.addQueryItem( it.key(), it.value().toString() ); + } + return query.toString( QUrl::ComponentFormattingOption::FullyEncoded ); +} + static QVariant fcnFromBase64( const QVariantList &values, const QgsExpressionContext *, QgsExpression *parent, const QgsExpressionNodeFunction * ) { const QString value = QgsExpressionUtils::getStringValue( values.at( 0 ), parent ); @@ -8020,6 +8032,8 @@ const QList &QgsExpression::Functions() << new QgsStaticExpressionFunction( QStringLiteral( "map_prefix_keys" ), QgsExpressionFunction::ParameterList() << QgsExpressionFunction::Parameter( QStringLiteral( "map" ) ) << QgsExpressionFunction::Parameter( QStringLiteral( "prefix" ) ), fcnMapPrefixKeys, QStringLiteral( "Maps" ) ) + << new QgsStaticExpressionFunction( QStringLiteral( "url_encode" ), QgsExpressionFunction::ParameterList() << QgsExpressionFunction::Parameter( QStringLiteral( "map" ) ), + fcnToFormUrlEncode, QStringLiteral( "Maps" ) ) ; diff --git a/src/core/network/qgsnetworkaccessmanager.cpp b/src/core/network/qgsnetworkaccessmanager.cpp index 01c67c08ec65..651c2002af97 100644 --- a/src/core/network/qgsnetworkaccessmanager.cpp +++ b/src/core/network/qgsnetworkaccessmanager.cpp @@ -353,6 +353,11 @@ QNetworkReply *QgsNetworkAccessManager::createRequest( QNetworkAccessManager::Op { content = buffer->buffer(); } + else if ( outgoingData ) + { + content = outgoingData->readAll(); + outgoingData->seek( 0 ); + } emit requestAboutToBeCreated( QgsNetworkRequestParameters( op, req, requestId, content ) ); Q_NOWARN_DEPRECATED_PUSH diff --git a/src/core/qgsaction.cpp b/src/core/qgsaction.cpp index 261d3ba5340c..60783ea2498a 100644 --- a/src/core/qgsaction.cpp +++ b/src/core/qgsaction.cpp @@ -19,6 +19,13 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include #include "qgspythonrunner.h" #include "qgsrunprocess.h" @@ -26,12 +33,18 @@ #include "qgsvectorlayer.h" #include "qgslogger.h" #include "qgsexpressioncontextutils.h" +#include "qgswebview.h" +#include "qgsnetworkaccessmanager.h" +#include "qgsmessagelog.h" + bool QgsAction::runable() const { return mType == Generic || mType == GenericPython || mType == OpenUrl || + mType == SubmitUrlEncoded || + mType == SubmitUrlMultipart || #if defined(Q_OS_WIN) mType == Windows #elif defined(Q_OS_MAC) @@ -52,6 +65,173 @@ void QgsAction::run( QgsVectorLayer *layer, const QgsFeature &feature, const Qgs run( actionContext ); } +void QgsAction::handleFormSubmitAction( const QString &expandedAction ) const +{ + + QUrl url{ expandedAction }; + + // Encode '+' (fully encoded doesn't encode it) + const QString payload { url.query( QUrl::ComponentFormattingOption::FullyEncoded ).replace( QChar( '+' ), QStringLiteral( "%2B" ) ) }; + + // Remove query string from URL + const QUrlQuery queryString { url.query( ) }; + url.setQuery( QString( ) ); + + QNetworkRequest req { url }; + + // Specific code for testing, produces an invalid POST but we can still listen to + // signals and examine the request + if ( url.toString().contains( QLatin1String( "fake_qgis_http_endpoint" ) ) ) + { + req.setUrl( QStringLiteral( "file://%1" ).arg( url.path() ) ); + } + + QNetworkReply *reply = nullptr; + + if ( mType != QgsAction::SubmitUrlMultipart ) + { + QString contentType { QStringLiteral( "application/x-www-form-urlencoded" ) }; + // check for json + QJsonParseError jsonError; + QJsonDocument::fromJson( payload.toUtf8(), &jsonError ); + if ( jsonError.error == QJsonParseError::ParseError::NoError ) + { + contentType = QStringLiteral( "application/json" ); + } + req.setHeader( QNetworkRequest::KnownHeaders::ContentTypeHeader, contentType ); + reply = QgsNetworkAccessManager::instance()->post( req, payload.toUtf8() ); + } + // for multipart create parts and headers + else + { + QHttpMultiPart *multiPart = new QHttpMultiPart( QHttpMultiPart::FormDataType ); + const QList> queryItems { queryString.queryItems( QUrl::ComponentFormattingOption::FullyDecoded ) }; + for ( const QPair &queryItem : std::as_const( queryItems ) ) + { + QHttpPart part; + part.setHeader( QNetworkRequest::ContentDispositionHeader, + QStringLiteral( "form-data; name=\"%1\"" ) + .arg( QString( queryItem.first ).replace( '"', QStringLiteral( R"(\")" ) ) ) ); + part.setBody( queryItem.second.toUtf8() ); + multiPart->append( part ); + } + reply = QgsNetworkAccessManager::instance()->post( req, multiPart ); + multiPart->setParent( reply ); + } + + QObject::connect( reply, &QNetworkReply::finished, reply, [ reply ] + { + if ( reply->error() == QNetworkReply::NoError ) + { + + if ( reply->attribute( QNetworkRequest::RedirectionTargetAttribute ).isNull() ) + { + + const QByteArray replyData = reply->readAll(); + + QString filename { "download.bin" }; + if ( const std::string header = reply->header( QNetworkRequest::KnownHeaders::ContentDispositionHeader ).toString().toStdString(); ! header.empty() ) + { + + std::string ascii; + const std::string q1 { R"(filename=")" }; + if ( const unsigned long pos = header.find( q1 ); pos != std::string::npos ) + { + const unsigned long len = pos + q1.size(); + + const std::string q2 { R"(")" }; + if ( unsigned long pos = header.find( q2, len ); pos != std::string::npos ) + { + bool escaped = false; + while ( pos != std::string::npos && header[pos - 1] == '\\' ) + { + pos = header.find( q2, pos + 1 ); + escaped = true; + } + ascii = header.substr( len, pos - len ); + if ( escaped ) + { + std::string cleaned; + for ( size_t i = 0; i < ascii.size(); ++i ) + { + if ( ascii[i] == '\\' ) + { + if ( i > 0 && ascii[i - 1] == '\\' ) + { + cleaned.push_back( ascii[i] ); + } + } + else + { + cleaned.push_back( ascii[i] ); + } + } + ascii = cleaned; + } + } + } + + std::string utf8; + + const std::string u { R"(UTF-8'')" }; + if ( const unsigned long pos = header.find( u ); pos != std::string::npos ) + { + utf8 = header.substr( pos + u.size() ); + } + + // Prefer ascii over utf8 + if ( ascii.empty() ) + { + if ( ! utf8.empty( ) ) + { + filename = QString::fromStdString( utf8 ); + } + } + else + { + filename = QString::fromStdString( ascii ); + } + } + else if ( !reply->header( QNetworkRequest::KnownHeaders::ContentTypeHeader ).isNull() ) + { + QString contentTypeHeader { reply->header( QNetworkRequest::KnownHeaders::ContentTypeHeader ).toString() }; + // Strip charset if any + if ( contentTypeHeader.contains( ';' ) ) + { + contentTypeHeader = contentTypeHeader.left( contentTypeHeader.indexOf( ';' ) ); + } + + QMimeType mimeType { QMimeDatabase().mimeTypeForName( contentTypeHeader ) }; + if ( mimeType.isValid() ) + { + filename = QStringLiteral( "download.%1" ).arg( mimeType.preferredSuffix() ); + } + } + + QTemporaryDir tempDir; + tempDir.setAutoRemove( false ); + tempDir.path(); + const QString tempFilePath{ tempDir.path() + QDir::separator() + filename }; + QFile tempFile{ tempFilePath }; + tempFile.open( QIODevice::WriteOnly ); + tempFile.write( replyData ); + tempFile.close(); + QDesktopServices::openUrl( QUrl::fromLocalFile( tempFilePath ) ); + } + else + { + QgsMessageLog::logMessage( QObject::tr( "Redirect is not supported!" ), QStringLiteral( "Form Submit Action" ), Qgis::MessageLevel::Critical ); + } + } + else + { + QgsMessageLog::logMessage( reply->errorString(), QStringLiteral( "Form Submit Action" ), Qgis::MessageLevel::Critical ); + } + reply->deleteLater(); + } ); + +} + void QgsAction::run( const QgsExpressionContext &expressionContext ) const { if ( !isValid() ) @@ -74,6 +254,11 @@ void QgsAction::run( const QgsExpressionContext &expressionContext ) const else QDesktopServices::openUrl( QUrl( expandedAction, QUrl::TolerantMode ) ); } + else if ( mType == QgsAction::SubmitUrlEncoded || mType == QgsAction::SubmitUrlMultipart ) + { + handleFormSubmitAction( expandedAction ); + + } else if ( mType == QgsAction::GenericPython ) { // TODO: capture output from QgsPythonRunner (like QgsRunProcess does) @@ -200,6 +385,16 @@ QString QgsAction::html() const typeText = QObject::tr( "Open URL" ); break; } + case SubmitUrlEncoded: + { + typeText = QObject::tr( "Submit URL (urlencoded or JSON)" ); + break; + } + case SubmitUrlMultipart: + { + typeText = QObject::tr( "Submit URL (multipart)" ); + break; + } } return { QObject::tr( R"html(

Action Details

diff --git a/src/core/qgsaction.h b/src/core/qgsaction.h index 95e011bdd175..e99fadbad5ce 100644 --- a/src/core/qgsaction.h +++ b/src/core/qgsaction.h @@ -42,6 +42,8 @@ class CORE_EXPORT QgsAction Windows, Unix, OpenUrl, + SubmitUrlEncoded, //!< POST data to an URL, using "application/x-www-form-urlencoded" or "application/json" if the body is valid JSON \since QGIS 3.24 + SubmitUrlMultipart, //!< POST data to an URL using "multipart/form-data" \since QGIS 3.24 }; /** @@ -255,6 +257,8 @@ class CORE_EXPORT QgsAction QString html( ) const; private: + + void handleFormSubmitAction( const QString &expandedAction ) const; ActionType mType = Generic; QString mDescription; QString mShortTitle; diff --git a/src/core/qgsactionmanager.cpp b/src/core/qgsactionmanager.cpp index bad9fba15519..edef8fd12a37 100644 --- a/src/core/qgsactionmanager.cpp +++ b/src/core/qgsactionmanager.cpp @@ -38,11 +38,9 @@ #include #include #include -#include #include #include - QUuid QgsActionManager::addAction( QgsAction::ActionType type, const QString &name, const QString &command, bool capture ) { QgsAction action( type, name, command, capture ); @@ -189,24 +187,34 @@ QList QgsActionManager::actions( const QString &actionScope ) const void QgsActionManager::runAction( const QgsAction &action ) { - if ( action.type() == QgsAction::OpenUrl ) - { - QFileInfo finfo( action.command() ); - if ( finfo.exists() && finfo.isFile() ) - QDesktopServices::openUrl( QUrl::fromLocalFile( action.command() ) ); - else - QDesktopServices::openUrl( QUrl( action.command(), QUrl::TolerantMode ) ); - } - else if ( action.type() == QgsAction::GenericPython ) - { - // TODO: capture output from QgsPythonRunner (like QgsRunProcess does) - QgsPythonRunner::run( action.command() ); - } - else + switch ( action.type() ) { - // The QgsRunProcess instance created by this static function - // deletes itself when no longer needed. - QgsRunProcess::create( action.command(), action.capture() ); + case QgsAction::OpenUrl: + { + QFileInfo finfo( action.command() ); + if ( finfo.exists() && finfo.isFile() ) + QDesktopServices::openUrl( QUrl::fromLocalFile( action.command() ) ); + else + QDesktopServices::openUrl( QUrl( action.command(), QUrl::TolerantMode ) ); + break; + } + case QgsAction::GenericPython: + case QgsAction::SubmitUrlEncoded: + case QgsAction::SubmitUrlMultipart: + { + action.run( QgsExpressionContext() ); + break; + } + case QgsAction::Generic: + case QgsAction::Mac: + case QgsAction::Unix: + case QgsAction::Windows: + { + // The QgsRunProcess instance created by this static function + // deletes itself when no longer needed. + QgsRunProcess::create( action.command(), action.capture() ); + break; + } } } diff --git a/src/gui/vector/qgsattributeactiondialog.cpp b/src/gui/vector/qgsattributeactiondialog.cpp index 7b911550ae8d..2a101cdb4db9 100644 --- a/src/gui/vector/qgsattributeactiondialog.cpp +++ b/src/gui/vector/qgsattributeactiondialog.cpp @@ -256,6 +256,10 @@ QString QgsAttributeActionDialog::textForType( QgsAction::ActionType type ) return tr( "Unix" ); case QgsAction::OpenUrl: return tr( "Open URL" ); + case QgsAction::SubmitUrlEncoded: + return tr( "Submit URL (urlencoded or JSON)" ); + case QgsAction::SubmitUrlMultipart: + return tr( "Submit URL (multipart)" ); } return QString(); } diff --git a/src/ui/qgsattributeactionpropertiesdialogbase.ui b/src/ui/qgsattributeactionpropertiesdialogbase.ui index c0556056f5a2..ecca0e177017 100644 --- a/src/ui/qgsattributeactionpropertiesdialogbase.ui +++ b/src/ui/qgsattributeactionpropertiesdialogbase.ui @@ -234,7 +234,17 @@ - Open + Open URL + + + + + Submit URL (urlencoded or JSON) + + + + + Submit URL (multipart) @@ -325,32 +335,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tests/src/core/testqgsexpression.cpp b/tests/src/core/testqgsexpression.cpp index 45a3228a00b5..5bf266064f9c 100644 --- a/tests/src/core/testqgsexpression.cpp +++ b/tests/src/core/testqgsexpression.cpp @@ -1980,6 +1980,11 @@ class TestQgsExpression: public QObject QTest::newRow( "exif bad file path" ) << QStringLiteral( "exif('bad path','Exif.Image.DateTime')" ) << false << QVariant(); QTest::newRow( "exif_geotag" ) << QStringLiteral( "geom_to_wkt(exif_geotag('%1photos/0997.JPG'))" ).arg( testDataDir ) << false << QVariant( "PointZ (149.27516667 -37.2305 422.19101124)" ); QTest::newRow( "exif_geotag bad file path" ) << QStringLiteral( "geom_to_wkt(exif_geotag('bad path'))" ).arg( testDataDir ) << false << QVariant( "Point EMPTY" ); + + // Form encoding tests + QTest::newRow( "url_encode" ) << QStringLiteral( "url_encode(map())" ).arg( testDataDir ) << false << QVariant( "" ); + QTest::newRow( "url_encode" ) << QStringLiteral( "url_encode(map('a b', 'a b', 'c &% d', 'c &% d'))" ).arg( testDataDir ) << false << QVariant( "a%20b=a%20b&c%20%26%25%20d=c%20%26%25%20d" ); + QTest::newRow( "url_encode" ) << QStringLiteral( "url_encode(map('a&+b', 'a and plus b', 'a=b', 'a equals b'))" ).arg( testDataDir ) << false << QVariant( "a%26+b=a%20and%20plus%20b&a%3Db=a%20equals%20b" ); } void run_evaluation_test( QgsExpression &exp, bool evalError, QVariant &expected ) diff --git a/tests/src/python/CMakeLists.txt b/tests/src/python/CMakeLists.txt index eaae0775623e..de1615a17314 100644 --- a/tests/src/python/CMakeLists.txt +++ b/tests/src/python/CMakeLists.txt @@ -13,6 +13,7 @@ ADD_PYTHON_TEST(PyCoreAdditions test_core_additions.py) ADD_PYTHON_TEST(PyPythonRepr test_python_repr.py) ADD_PYTHON_TEST(PyPythonUtils test_python_utils.py) ADD_PYTHON_TEST(PyQgsActionManager test_qgsactionmanager.py) +ADD_PYTHON_TEST(PyQgsAction test_qgsaction.py) ADD_PYTHON_TEST(PyQgsAFSProvider test_provider_afs.py) ADD_PYTHON_TEST(PyQgsAggregateMappingWidget test_qgsaggregatemappingwidget.py) ADD_PYTHON_TEST(PyQgsAlignmentComboBox test_qgsalignmentcombobox.py) diff --git a/tests/src/python/test_qgsaction.py b/tests/src/python/test_qgsaction.py new file mode 100644 index 000000000000..e64bb9ed26fe --- /dev/null +++ b/tests/src/python/test_qgsaction.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +"""QGIS Unit tests for QgsAction. + +From build dir, run: ctest -R PyQgsAction -V + +.. note:: This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. +""" +__author__ = 'Alessandro Pasotti' +__date__ = '24/11/2021' +__copyright__ = 'Copyright 2021, The QGIS Project' + +import qgis # NOQA switch sip api + +from qgis.core import ( + QgsExpressionContext, + QgsAction, + QgsNetworkAccessManager, + QgsNetworkRequestParameters, + QgsApplication, +) + +from qgis.PyQt.QtCore import QTemporaryDir + +from qgis.testing import start_app, unittest + +import os +import re +import time +import platform +from functools import partial + +start_app() + + +class TestQgsAction(unittest.TestCase): + + def setUp(self): + self.body = None + + def _req_logger(self, params): + self.body = bytes(params.content()) + + def test_post_urlencoded_action(self): + """Test form www urlencoded""" + + def _req_logger(self, params): + self.body = bytes(params.content()) + + QgsNetworkAccessManager.instance().requestAboutToBeCreated[QgsNetworkRequestParameters].connect(partial(_req_logger, self)) + + temp_dir = QTemporaryDir() + temp_path = temp_dir.path() + temp_file = os.path.join(temp_path, 'urlencoded.txt') + + action = QgsAction(QgsAction.SubmitUrlEncoded, 'url_encoded', "http://fake_qgis_http_endpoint" + temp_file + r"?[% url_encode(map('a&+b', 'a and plus b', 'a=b', 'a equals b')) %]") + ctx = QgsExpressionContext() + action.run(ctx) + + while not self.body: + QgsApplication.instance().processEvents() + + self.assertEqual(self.body, br"a%26%2Bb=a%20and%20plus%20b&a%3Db=a%20equals%20b") + + def test_post_multipart_action(self): + """Test multipart""" + + self.body = None + + def _req_logger(self, params): + self.body = bytes(params.content()) + + QgsNetworkAccessManager.instance().requestAboutToBeCreated[QgsNetworkRequestParameters].connect(partial(_req_logger, self)) + + temp_dir = QTemporaryDir() + temp_path = temp_dir.path() + temp_file = os.path.join(temp_path, 'multipart.txt') + + action = QgsAction(QgsAction.SubmitUrlMultipart, 'url_encoded', "http://fake_qgis_http_endpoint" + temp_file + r"?[% url_encode(map('a&+b', 'a and plus b', 'a=b', 'a equals b')) %]") + ctx = QgsExpressionContext() + action.run(ctx) + + while not self.body: + QgsApplication.instance().processEvents() + + self.assertEqual(re.sub(r'\.oOo\.[^\r]*', '.oOo.UUID', self.body.decode('utf8')), '\r\n'.join([ + '--boundary_.oOo.UUID', + 'Content-Disposition: form-data; name="a&+b"', + '', + 'a and plus b', + '--boundary_.oOo.UUID', + 'Content-Disposition: form-data; name="a=b"', + '', + 'a equals b', + '--boundary_.oOo.UUID', + ''])) + + +if __name__ == '__main__': + unittest.main()