From 75355386c258393ce8a7e0df017a080dc69ba105 Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Mon, 20 Jun 2022 09:46:58 +1000 Subject: [PATCH] Add helper methods to get filename from content disposition headers --- .../network/qgsnetworkcontentfetcher.sip.in | 7 +++ .../qgsnetworkcontentfetchertask.sip.in | 7 +++ .../network/qgsnetworkreply.sip.in | 14 ++++++ src/core/network/qgsnetworkcontentfetcher.cpp | 5 +++ src/core/network/qgsnetworkcontentfetcher.h | 7 +++ .../network/qgsnetworkcontentfetchertask.cpp | 6 +++ .../network/qgsnetworkcontentfetchertask.h | 7 +++ src/core/network/qgsnetworkreply.cpp | 37 +++++++++++++++ src/core/network/qgsnetworkreply.h | 14 ++++++ tests/src/python/CMakeLists.txt | 1 + tests/src/python/test_qgsnetworkreply.py | 45 +++++++++++++++++++ 11 files changed, 150 insertions(+) create mode 100644 tests/src/python/test_qgsnetworkreply.py diff --git a/python/core/auto_generated/network/qgsnetworkcontentfetcher.sip.in b/python/core/auto_generated/network/qgsnetworkcontentfetcher.sip.in index e81d9ba5a11..10be9cc1ab3 100644 --- a/python/core/auto_generated/network/qgsnetworkcontentfetcher.sip.in +++ b/python/core/auto_generated/network/qgsnetworkcontentfetcher.sip.in @@ -60,6 +60,13 @@ Optionally, authentication configuration can be set via the ``authcfg`` argument Returns a reference to the network reply :return: QNetworkReply for fetched URL content +%End + + QString contentDispositionFilename() const; +%Docstring +Returns the associated filename from the reply's content disposition header, if present. + +.. versionadded:: 3.28 %End QString contentAsString() const; diff --git a/python/core/auto_generated/network/qgsnetworkcontentfetchertask.sip.in b/python/core/auto_generated/network/qgsnetworkcontentfetchertask.sip.in index b005a54d5e1..2ad1d908744 100644 --- a/python/core/auto_generated/network/qgsnetworkcontentfetchertask.sip.in +++ b/python/core/auto_generated/network/qgsnetworkcontentfetchertask.sip.in @@ -75,6 +75,13 @@ May return ``None`` if the request has not yet completed. the :py:func:`QgsNetworkContentFetcherTask.fetched()` signal. %End + QString contentDispositionFilename() const; +%Docstring +Returns the associated filename from the reply's content disposition header, if present. + +.. versionadded:: 3.28 +%End + QString contentAsString() const; %Docstring Returns the fetched content as a string diff --git a/python/core/auto_generated/network/qgsnetworkreply.sip.in b/python/core/auto_generated/network/qgsnetworkreply.sip.in index f5d705fc792..d41b2ea5830 100644 --- a/python/core/auto_generated/network/qgsnetworkreply.sip.in +++ b/python/core/auto_generated/network/qgsnetworkreply.sip.in @@ -125,6 +125,20 @@ can only be done once. Blocking network requests (see :py:class:`QgsBlockingNetworkRequest`) will automatically populate this content. .. seealso:: :py:func:`setContent` +%End + + static QString extractFilenameFromContentDispositionHeader( QNetworkReply *reply ); +%Docstring +Extracts the filename component of the content disposition header from a network ``reply``. + +.. versionadded:: 3.28 +%End + + static QString extractFileNameFromContentDispositionHeader( const QString &header ); +%Docstring +Extracts the filename component of the content disposition header from the ``header``. + +.. versionadded:: 3.28 %End }; diff --git a/src/core/network/qgsnetworkcontentfetcher.cpp b/src/core/network/qgsnetworkcontentfetcher.cpp index 28616974472..8a44b945f3c 100644 --- a/src/core/network/qgsnetworkcontentfetcher.cpp +++ b/src/core/network/qgsnetworkcontentfetcher.cpp @@ -96,6 +96,11 @@ QNetworkReply *QgsNetworkContentFetcher::reply() return mReply; } +QString QgsNetworkContentFetcher::contentDispositionFilename() const +{ + return mReply ? QgsNetworkReplyContent::extractFilenameFromContentDispositionHeader( mReply ) : QString(); +} + QString QgsNetworkContentFetcher::contentAsString() const { if ( !mContentLoaded || !mReply ) diff --git a/src/core/network/qgsnetworkcontentfetcher.h b/src/core/network/qgsnetworkcontentfetcher.h index f4660c3407e..0241a1e5cda 100644 --- a/src/core/network/qgsnetworkcontentfetcher.h +++ b/src/core/network/qgsnetworkcontentfetcher.h @@ -73,6 +73,13 @@ class CORE_EXPORT QgsNetworkContentFetcher : public QObject */ QNetworkReply *reply(); + /** + * Returns the associated filename from the reply's content disposition header, if present. + * + * \since QGIS 3.28 + */ + QString contentDispositionFilename() const; + /** * Returns the fetched content as a string * \returns string containing network content diff --git a/src/core/network/qgsnetworkcontentfetchertask.cpp b/src/core/network/qgsnetworkcontentfetchertask.cpp index 050b81dae80..46594f1b96f 100644 --- a/src/core/network/qgsnetworkcontentfetchertask.cpp +++ b/src/core/network/qgsnetworkcontentfetchertask.cpp @@ -18,6 +18,7 @@ #include "qgsnetworkcontentfetchertask.h" #include "qgsnetworkcontentfetcher.h" +#include "qgsnetworkreply.h" #include QgsNetworkContentFetcherTask::QgsNetworkContentFetcherTask( const QUrl &url, const QString &authcfg, QgsTask::Flags flags, const QString &description ) @@ -90,6 +91,11 @@ QNetworkReply *QgsNetworkContentFetcherTask::reply() return mFetcher ? mFetcher->reply() : nullptr; } +QString QgsNetworkContentFetcherTask::contentDispositionFilename() const +{ + return mFetcher ? mFetcher->contentDispositionFilename() : QString(); +} + QString QgsNetworkContentFetcherTask::contentAsString() const { return mFetcher ? mFetcher->contentAsString() : QString(); diff --git a/src/core/network/qgsnetworkcontentfetchertask.h b/src/core/network/qgsnetworkcontentfetchertask.h index 59bec115122..64d9b679a42 100644 --- a/src/core/network/qgsnetworkcontentfetchertask.h +++ b/src/core/network/qgsnetworkcontentfetchertask.h @@ -87,6 +87,13 @@ class CORE_EXPORT QgsNetworkContentFetcherTask : public QgsTask */ QNetworkReply *reply(); + /** + * Returns the associated filename from the reply's content disposition header, if present. + * + * \since QGIS 3.28 + */ + QString contentDispositionFilename() const; + /** * Returns the fetched content as a string * diff --git a/src/core/network/qgsnetworkreply.cpp b/src/core/network/qgsnetworkreply.cpp index 3baed8d9ee3..2b456037fcf 100644 --- a/src/core/network/qgsnetworkreply.cpp +++ b/src/core/network/qgsnetworkreply.cpp @@ -15,6 +15,8 @@ #include "qgsnetworkreply.h" #include +#include +#include QgsNetworkReplyContent::QgsNetworkReplyContent( QNetworkReply *reply ) : mError( reply->error() ) @@ -75,3 +77,38 @@ QByteArray QgsNetworkReplyContent::rawHeader( const QByteArray &headerName ) con } return QByteArray(); } + +QString QgsNetworkReplyContent::extractFilenameFromContentDispositionHeader( QNetworkReply *reply ) +{ + if ( !reply ) + return QString(); + + return extractFileNameFromContentDispositionHeader( reply->header( QNetworkRequest::ContentDispositionHeader ).toString() ); +} + +QString QgsNetworkReplyContent::extractFileNameFromContentDispositionHeader( const QString &header ) +{ + const thread_local QRegularExpression rx( QStringLiteral( R"""(filename[^;\n]*=\s*(UTF-\d['"]*)?((['"]).*?[.]$\2|[^;\n]*)?)""" ), QRegularExpression::PatternOption::CaseInsensitiveOption ); + + QRegularExpressionMatchIterator i = rx.globalMatch( header, 0 ); + QString res; + // we want the last match here, as that will have the UTF filename when present + while ( i.hasNext() ) + { + const QRegularExpressionMatch match = i.next(); + res = match.captured( 2 ); + } + + if ( res.startsWith( '"' ) ) + { + res = res.mid( 1 ); + if ( res.endsWith( '"' ) ) + res.chop( 1 ); + } + if ( !res.isEmpty() ) + { + res = QUrl::fromPercentEncoding( res.toUtf8() ); + } + + return res; +} diff --git a/src/core/network/qgsnetworkreply.h b/src/core/network/qgsnetworkreply.h index cda40f1efa7..b7465208d3d 100644 --- a/src/core/network/qgsnetworkreply.h +++ b/src/core/network/qgsnetworkreply.h @@ -157,6 +157,20 @@ class CORE_EXPORT QgsNetworkReplyContent */ QByteArray content() const { return mContent; } + /** + * Extracts the filename component of the content disposition header from a network \a reply. + * + * \since QGIS 3.28 + */ + static QString extractFilenameFromContentDispositionHeader( QNetworkReply *reply ); + + /** + * Extracts the filename component of the content disposition header from the \a header. + * + * \since QGIS 3.28 + */ + static QString extractFileNameFromContentDispositionHeader( const QString &header ); + private: QNetworkReply::NetworkError mError = QNetworkReply::NoError; diff --git a/tests/src/python/CMakeLists.txt b/tests/src/python/CMakeLists.txt index ae3637bdb70..7aa1b9aa095 100644 --- a/tests/src/python/CMakeLists.txt +++ b/tests/src/python/CMakeLists.txt @@ -220,6 +220,7 @@ ADD_PYTHON_TEST(PyQgsNetworkAccessManager test_qgsnetworkaccessmanager.py) ADD_PYTHON_TEST(PyQgsNetworkContentFetcher test_qgsnetworkcontentfetcher.py) ADD_PYTHON_TEST(PyQgsNetworkContentFetcherRegistry test_qgsnetworkcontentfetcherregistry.py) ADD_PYTHON_TEST(PyQgsNetworkContentFetcherTask test_qgsnetworkcontentfetchertask.py) +ADD_PYTHON_TEST(PyQgsNetworkReply test_qgsnetworkreply.py) ADD_PYTHON_TEST(PyQgsNullSymbolRenderer test_qgsnullsymbolrenderer.py) ADD_PYTHON_TEST(PyQgsNumericFormat test_qgsnumericformat.py) ADD_PYTHON_TEST(PyQgsNumericFormatGui test_qgsnumericformatgui.py) diff --git a/tests/src/python/test_qgsnetworkreply.py b/tests/src/python/test_qgsnetworkreply.py new file mode 100644 index 00000000000..a3ea656d6e9 --- /dev/null +++ b/tests/src/python/test_qgsnetworkreply.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +"""QGIS Unit tests for QgsNetworkReplyContent + +.. 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. +""" + +from builtins import chr +from builtins import str +__author__ = 'Nyall Dawson' +__date__ = '20/06/2022' +__copyright__ = 'Copyright 2022, The QGIS Project' + +import qgis # NOQA + +import os +from qgis.testing import unittest, start_app +from qgis.core import QgsNetworkReplyContent +from utilities import unitTestDataPath +from qgis.PyQt.QtCore import QUrl +from qgis.PyQt.QtNetwork import QNetworkReply, QNetworkRequest +import socketserver +import threading +import http.server + +app = start_app() + + +class TestQgsNetworkReply(unittest.TestCase): + + def test_content_disposition_filename(self): + self.assertEqual(QgsNetworkReplyContent.extractFileNameFromContentDispositionHeader('x'), '') + self.assertEqual(QgsNetworkReplyContent.extractFileNameFromContentDispositionHeader('attachment; filename=content.txt'), 'content.txt') + self.assertEqual( + QgsNetworkReplyContent.extractFileNameFromContentDispositionHeader("attachment; filename*=UTF-8''filename.txt"), 'filename.txt') + self.assertEqual( + QgsNetworkReplyContent.extractFileNameFromContentDispositionHeader('attachment; filename="EURO rates"; filename*=utf-8\'\'%e2%82%ac%20rates'), '€ rates') + self.assertEqual( + QgsNetworkReplyContent.extractFileNameFromContentDispositionHeader('attachment; filename="omáèka.jpg"'), 'omáèka.jpg') + + +if __name__ == "__main__": + unittest.main()