Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Form submit actions with HTTP POST #46384

Merged
merged 15 commits into from Dec 7, 2021
2 changes: 2 additions & 0 deletions python/core/auto_generated/qgsaction.sip.in
Expand Up @@ -27,6 +27,8 @@ Utility class that encapsulates an action based on vector attributes.
Windows,
Unix,
OpenUrl,
SubmitUrlEncoded,
SubmitUrlMultipart,
};

QgsAction();
Expand Down
18 changes: 18 additions & 0 deletions 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.<br>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'"
}
]
}
14 changes: 14 additions & 0 deletions src/core/expression/qgsexpressionfunction.cpp
Expand Up @@ -69,6 +69,7 @@
#include <QCryptographicHash>
#include <QRegularExpression>
#include <QUuid>
#include <QUrlQuery>

typedef QList<QgsExpressionFunction *> ExpressionFunctionList;

Expand Down Expand Up @@ -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 );
Expand Down Expand Up @@ -8020,6 +8032,8 @@ const QList<QgsExpressionFunction *> &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" ) )

;

Expand Down
5 changes: 5 additions & 0 deletions src/core/network/qgsnetworkaccessmanager.cpp
Expand Up @@ -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
Expand Down
195 changes: 195 additions & 0 deletions src/core/qgsaction.cpp
Expand Up @@ -19,19 +19,32 @@
#include <QDesktopServices>
#include <QFileInfo>
#include <QUrl>
#include <QUrlQuery>
#include <QDir>
#include <QTemporaryDir>
#include <QNetworkRequest>
#include <QJsonDocument>
#include <QHttpMultiPart>
#include <QMimeDatabase>

#include "qgspythonrunner.h"
#include "qgsrunprocess.h"
#include "qgsexpressioncontext.h"
#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)
Expand All @@ -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<QPair<QString, QString>> queryItems { queryString.queryItems( QUrl::ComponentFormattingOption::FullyDecoded ) };
for ( const QPair<QString, QString> &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() )
Expand All @@ -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)
Expand Down Expand Up @@ -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(
<h2>Action Details</h2>
Expand Down
4 changes: 4 additions & 0 deletions src/core/qgsaction.h
Expand Up @@ -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
};

/**
Expand Down Expand Up @@ -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;
Expand Down
46 changes: 27 additions & 19 deletions src/core/qgsactionmanager.cpp
Expand Up @@ -38,11 +38,9 @@
#include <QSettings>
#include <QDesktopServices>
#include <QUrl>
#include <QDir>
#include <QFileInfo>
#include <QRegularExpression>


QUuid QgsActionManager::addAction( QgsAction::ActionType type, const QString &name, const QString &command, bool capture )
{
QgsAction action( type, name, command, capture );
Expand Down Expand Up @@ -189,24 +187,34 @@ QList<QgsAction> 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;
}
}
}

Expand Down