From 1d24551ea1a95a944404b13106e1e608ece8b9c7 Mon Sep 17 00:00:00 2001 From: Jay Two <1289306+j2doll@users.noreply.github.com> Date: Wed, 14 Nov 2018 14:01:22 +0900 Subject: [PATCH] add webserver example --- README.ko.md | 3 +- README.md | 1 + WebServer/WebServer.pro | 46 +++ WebServer/context.hpp | 57 +++ WebServer/main.cpp | 167 ++++++++ WebServer/recurse.hpp | 819 ++++++++++++++++++++++++++++++++++++++++ WebServer/request.hpp | 249 ++++++++++++ WebServer/response.hpp | 323 ++++++++++++++++ WebServer/test.xlsx | Bin 0 -> 7702 bytes WebServer/ws.qrc | 6 + 10 files changed, 1670 insertions(+), 1 deletion(-) create mode 100644 WebServer/WebServer.pro create mode 100644 WebServer/context.hpp create mode 100644 WebServer/main.cpp create mode 100644 WebServer/recurse.hpp create mode 100644 WebServer/request.hpp create mode 100644 WebServer/response.hpp create mode 100644 WebServer/test.xlsx create mode 100644 WebServer/ws.qrc diff --git a/README.ko.md b/README.ko.md index 4758bca..25a4909 100644 --- a/README.ko.md +++ b/README.ko.md @@ -61,7 +61,8 @@ qDebug() << var; // 값 표시 - QXlsx 는 MIT 라이센스 입니다. [https://github.com/j2doll/QXlsx](https://github.com/j2doll/QXlsx) - QtXlsx 는 MIT 라이센스 입니다. [https://github.com/dbzhang800/QtXlsxWriter](https://github.com/dbzhang800/QtXlsxWriter) - Qt-Table-Printer 는 BSD 3-Clause 라이센스 입니다. [https://github.com/T0ny0/Qt-Table-Printer](https://github.com/T0ny0/Qt-Table-Printer) -- Qt 는 LGPL v3 라이센스 또는 상업용 라이센스 입니다. [https://www.qt.io/](https://www.qt.io/) +- recurse 는 MIT 라이센스 입니다. [https://github.com/pkoretic/recurse](https://github.com/pkoretic/recurse) +- Qt 는 LGPL v3 라이센스 또는 상업용 라이센스 입니다. [https://www.qt.io/](https://www.qt.io/) ## :email: 문의 - 이슈를 남겨 주세요. [https://github.com/j2doll/QXlsx/issues](https://github.com/j2doll/QXlsx/issues) diff --git a/README.md b/README.md index 374882c..c37042c 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ qDebug() << var; // display value - QXlsx is under MIT license. [https://github.com/j2doll/QXlsx](https://github.com/j2doll/QXlsx) - QtXlsx is under MIT license. [https://github.com/dbzhang800/QtXlsxWriter](https://github.com/dbzhang800/QtXlsxWriter) - Qt-Table-Printer is under BSD 3-Clause license. [https://github.com/T0ny0/Qt-Table-Printer](https://github.com/T0ny0/Qt-Table-Printer) +- recurse is under MIT license. [https://github.com/pkoretic/recurse](https://github.com/pkoretic/recurse) - Qt is under LGPL v3 license or Commercial license. [https://www.qt.io/](https://www.qt.io/) ## :email: Contact diff --git a/WebServer/WebServer.pro b/WebServer/WebServer.pro new file mode 100644 index 0000000..67f2a7b --- /dev/null +++ b/WebServer/WebServer.pro @@ -0,0 +1,46 @@ +# +# WebServer.pro +# +# QXlsx https://github.com/j2doll/QXlsx +# recurse https://github.com/pkoretic/recurse + +TARGET = WebServer +TEMPLATE = app + +QT += core +QT += network +QT -= gui + +CONFIG += console +CONFIG += c++14 +CONFIG -= app_bundle + +QMAKE_CXXFLAGS += -std=c++14 + +macx { + QMAKE_CXXFLAGS += -stdlib=libc++ +} + +# NOTE: You can fix value of QXlsx path of source code. +# QXLSX_PARENTPATH=./ +# QXLSX_HEADERPATH=./header/ +# QXLSX_SOURCEPATH=./source/ +include(../QXlsx/QXlsx.pri) + +# source code + +RESOURCES += \ +ws.qrc + +HEADERS += \ +recurse.hpp \ +request.hpp \ +response.hpp \ +context.hpp + +INCLUDEPATH += . + +SOURCES += \ +main.cpp + + diff --git a/WebServer/context.hpp b/WebServer/context.hpp new file mode 100644 index 0000000..f642e52 --- /dev/null +++ b/WebServer/context.hpp @@ -0,0 +1,57 @@ +#ifndef RECURSE_CONTEXT_HPP +#define RECURSE_CONTEXT_HPP + +#include +#include + +#include "request.hpp" +#include "response.hpp" + +class Context +{ + +public: + Request request; + Response response; + + //! + //! \brief set + //! Set data into context that can be passed around + //! + //! \param QString key of the data + //! \param QString value of the data + //! \return Context chainable + //! + Context &set(const QString &key, const QVariant &value) + { + m_data[key] = value; + return *this; + } + + //! + //! \brief get + //! Get data from context + //! + //! \param QString key of the data + //! \return QString value of the data + //! + QVariant get(const QString &key) const + { + return m_data[key]; + } + + //! + //! \brief data + //! expose key/value data of *void pointer to allow any type of data + //! + QHash data; + +private: + //! + //! \brief m_data + //! Context data holder + //! + QHash m_data; +}; + +#endif diff --git a/WebServer/main.cpp b/WebServer/main.cpp new file mode 100644 index 0000000..b660d25 --- /dev/null +++ b/WebServer/main.cpp @@ -0,0 +1,167 @@ +// main.cpp + +#include + +#include +#include +#include +#include + +#include "recurse.hpp" + +#include "xlsxdocument.h" +#include "xlsxchartsheet.h" +#include "xlsxcellrange.h" +#include "xlsxchart.h" +#include "xlsxrichstring.h" +#include "xlsxworkbook.h" +#include "xlsxabstractsheet.h" +#include "xlsxcelllocation.h" +#include "xlsxcell.h" +using namespace QXlsx; + +QString getHtml(QString strFilename); +bool loadXlsx(QString fileName, QString& strHtml); +QString g_htmlDoc; + +int main(int argc, char *argv[]) +{ + g_htmlDoc = getHtml(":/test.xlsx"); // convert from xlsx to html + + Recurse::Application app(argc, argv); + + app.use([](auto &ctx) + { + ctx.response.send(g_htmlDoc); + }); + + quint16 listenPort = 3001; + auto result = app.listen( listenPort ); + if ( result.error() ) + { + qDebug() << "error upon listening:" << result.lastError(); + return (-1); + } + std::cout << " listening port: " << listenPort << std::endl; + + return 0; +} + +QString getHtml(QString strFilename) +{ + QString ret; + ret = ret + QString("\n"); + ret = ret + QString("\n"); + ret = ret + QString("") + strFilename + QString("\n"); + ret = ret + QString("\n" ); + ret = ret + QString("\n"); + + ret = ret + QString("\n"); + + QString strTableStyle = \ + "\n"; + ret = ret + strTableStyle; + + if (!loadXlsx(strFilename, ret)) + return QString(""); + + ret = ret + QString("\n"); + + ret = ret + QString("\n"); + + qDebug() << ret << "\n"; + + return ret; +} + +bool loadXlsx(QString fileName, QString& strHtml) +{ + // tried to load xlsx using temporary document + QXlsx::Document xlsxTmp( fileName ); + if ( !xlsxTmp.isLoadPackage() ) + { + return false; // failed to load + } + + // load new xlsx using new document + QXlsx::Document xlsxDoc( fileName ); + xlsxDoc.isLoadPackage(); + + int sheetIndexNumber = 0; + foreach( QString curretnSheetName, xlsxDoc.sheetNames() ) + { + QXlsx::AbstractSheet* currentSheet = xlsxDoc.sheet( curretnSheetName ); + if ( NULL == currentSheet ) + continue; + + // get full cells of sheet + int maxRow = -1; + int maxCol = -1; + currentSheet->workbook()->setActiveSheet( sheetIndexNumber ); + Worksheet* wsheet = (Worksheet*) currentSheet->workbook()->activeSheet(); + if ( NULL == wsheet ) + continue; + + QString strSheetName = wsheet->sheetName(); // sheet name + strHtml = strHtml + QString("") + strSheetName + QString("
\n"); // UTF-8 + + strHtml = strHtml + QString(""); + + QVector clList = wsheet->getFullCells( &maxRow, &maxCol ); + + QVector< QVector > cellValues; + for (int rc = 0; rc < maxRow; rc++) + { + QVector tempValue; + for (int cc = 0; cc < maxCol; cc++) + { + tempValue.push_back(QString("")); + } + cellValues.push_back(tempValue); + } + + for ( int ic = 0; ic < clList.size(); ++ic ) + { + // cell location + CellLocation cl = clList.at(ic); + + int row = cl.row - 1; + int col = cl.col - 1; + + //////////////////////////////////////////////////////////////////// + // cell pointer + QSharedPointer ptrCell = cl.cell; + + /////////////////////////////////////////////////////////////////// + // value of cell + QVariant var = cl.cell.data()->value(); + QString str = var.toString(); + + cellValues[row][col] = str; + } + + QString strTableRecord; + for (int rc = 0; rc < maxRow; rc++) + { + strTableRecord = strTableRecord + QString(""); + for (int cc = 0; cc < maxCol; cc++) + { + QString strTemp = cellValues[rc][cc]; + strTableRecord = strTableRecord + QString(""); + } + strTableRecord = strTableRecord + QString("\n"); + } + strHtml = strHtml + strTableRecord; + + strHtml = strHtml + QString("
"); + strTableRecord = strTableRecord + strTemp; // UTF-8 + strTableRecord = strTableRecord + QString("
\n"); + + sheetIndexNumber++; + } + + return true; +} diff --git a/WebServer/recurse.hpp b/WebServer/recurse.hpp new file mode 100644 index 0000000..4306cd8 --- /dev/null +++ b/WebServer/recurse.hpp @@ -0,0 +1,819 @@ +#ifndef RECURSE_HPP +#define RECURSE_HPP + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "request.hpp" +#include "response.hpp" +#include "context.hpp" + +namespace Recurse +{ + + //! + //! \brief The Returns class + //! Generic exit code and response value returning class + //! + class Returns + { + private: + quint16 m_last_error = 0; + QString m_result; + + QHash codes{ + { 100, "Failed to start listening on port" }, + { 101, "No pending connections available" }, + { 200, "Generic app->exec() error" }, + { 201, "Another generic app->exec() error" }, + { 301, "SSL private key open error" }, + { 302, "SSL certificate open error" } + }; + + public: + QString lastError() + { + if (m_last_error == 0) + return "No error"; + else + return codes[m_last_error]; + } + + void setErrorCode(quint16 error_code) + { + m_last_error = error_code; + } + + quint16 errorCode() + { + return m_last_error; + } + + bool error() + { + if (m_last_error == 0) + return false; + else + return true; + } + }; + + //! + //! \brief The SslTcpServer class + //! Recurse ssl server implementation used for Application::HttpsServer + //! + class SslTcpServer : public QTcpServer + { + Q_OBJECT + Q_DISABLE_COPY(SslTcpServer) + + typedef void (QSslSocket::*RSslErrors)(const QList &); + + public: + SslTcpServer(QObject *parent = NULL); + ~SslTcpServer(); + + QSslSocket *nextPendingConnection(); + void setSslConfiguration(const QSslConfiguration &sslConfiguration); + + Q_SIGNALS : void connectionEncrypted(); + void sslErrors(const QList &errors); + void peerVerifyError(const QSslError &error); + + protected: + //! + //! \brief overridden incomingConnection from QTcpServer + //! + virtual void incomingConnection(qintptr socket_descriptor) + { + auto socket = new QSslSocket(); + + socket->setSslConfiguration(m_ssl_configuration); + socket->setSocketDescriptor(socket_descriptor); + + connect(socket, &QSslSocket::encrypted, this, &SslTcpServer::connectionEncrypted); + connect(socket, static_cast(&QSslSocket::sslErrors), this, &SslTcpServer::sslErrors); + connect(socket, &QSslSocket::peerVerifyError, this, &SslTcpServer::peerVerifyError); + + addPendingConnection(socket); + socket->startServerEncryption(); + } + + private: + QSslConfiguration m_ssl_configuration; + }; + + inline SslTcpServer::SslTcpServer(QObject *parent) + { + Q_UNUSED(parent); + } + + inline SslTcpServer::~SslTcpServer() + { + } + + //! + //! \brief SslTcpServer::setSslConfiguration + //! set ssl socket configuration + //! + //! \param sslConfiguration ssl socket configuration + //! + inline void SslTcpServer::setSslConfiguration(const QSslConfiguration &sslConfiguration) + { + m_ssl_configuration = sslConfiguration; + } + + inline QSslSocket *SslTcpServer::nextPendingConnection() + { + return static_cast(QTcpServer::nextPendingConnection()); + } + + //! + //! \brief The HttpServer class + //! Http (unsecure) server class + //! + class HttpServer : public QObject + { + Q_OBJECT + + public: + HttpServer(QObject *parent = NULL); + ~HttpServer(); + + Returns compose(quint16 port, QHostAddress address = QHostAddress::Any); + + private: + QTcpServer m_tcp_server; + quint16 m_port; + QHostAddress m_address; + Returns ret; + + signals: + void socketReady(QTcpSocket *socket); + }; + + inline HttpServer::HttpServer(QObject *parent) + { + Q_UNUSED(parent); + } + + inline HttpServer::~HttpServer() + { + } + + //! + //! \brief HttpServer::compose + //! prepare http server for request forwarding + //! + //! \param port tcp server port + //! \param address tcp server listening address + //! + inline Returns HttpServer::compose(quint16 port, QHostAddress address) + { + m_port = port; + m_address = address; + + if (!m_tcp_server.listen(address, port)) + { + ret.setErrorCode(100); + return ret; + } + + connect(&m_tcp_server, &QTcpServer::newConnection, [this] + { + QTcpSocket *socket = m_tcp_server.nextPendingConnection(); + + if (socket == 0) + { + delete socket; + // FIXME: send signal instead of only setting an error and + // erroneously (?) returning + ret.setErrorCode(101); + return ret; + } + + emit socketReady(socket); + + ret.setErrorCode(0); + return ret; + }); + + ret.setErrorCode(0); + return ret; + } + + //! + //! \brief The HttpsServer class + //! Https (secure) server class + //! + class HttpsServer : public QObject + { + Q_OBJECT + + public: + HttpsServer(QObject *parent = NULL); + ~HttpsServer(); + + Returns compose(quint16 port, QHostAddress address = QHostAddress::Any); + Returns compose(const QHash &options); + + private: + SslTcpServer m_tcp_server; + quint16 m_port; + QHostAddress m_address; + Returns ret; + + signals: + void socketReady(QTcpSocket *socket); + }; + + inline HttpsServer::HttpsServer(QObject *parent) + { + Q_UNUSED(parent); + } + + inline HttpsServer::~HttpsServer() + { + } + + //! + //! \brief HttpsServer::compose + //! prepare https server for request forwarding + //! + //! \param port tcp server port + //! \param address tcp server listening address + //! + //! \return Returns return execution status code + //! + inline Returns HttpsServer::compose(quint16 port, QHostAddress address) + { + m_port = port; + m_address = address; + + if (!m_tcp_server.listen(address, port)) + { + ret.setErrorCode(100); + return ret; + } + + connect(&m_tcp_server, &SslTcpServer::connectionEncrypted, [this] + { + QTcpSocket *socket = m_tcp_server.nextPendingConnection(); + + if (socket == 0) + { + delete socket; + // FIXME: send signal instead of throwing + ret.setErrorCode(101); + return ret; + } + + emit socketReady(socket); + + ret.setErrorCode(0); + return ret; + }); + + ret.setErrorCode(0); + return ret; + } + + //! + //! \brief HttpsServer::compose + //! overloaded function, + //! prepare https server for request forwarding + //! + //! \param options QHash options of + //! + //! \return Returns return execution status code + //! + inline Returns HttpsServer::compose(const QHash &options) + { + QByteArray priv_key; + QFile priv_key_file(options.value("private_key").toString()); + + if (!priv_key_file.open(QIODevice::ReadOnly)) + { + ret.setErrorCode(301); + return ret; + } + + priv_key = priv_key_file.readAll(); + priv_key_file.close(); + + if (priv_key.isEmpty()) + { + ret.setErrorCode(301); + return ret; + } + + QSslKey ssl_key(priv_key, QSsl::Rsa); + + QByteArray cert_key; + QFile cert_key_file(options.value("certificate").toString()); + + if (!cert_key_file.open(QIODevice::ReadOnly)) + { + ret.setErrorCode(302); + return ret; + } + + cert_key = cert_key_file.readAll(); + cert_key_file.close(); + + if (cert_key.isEmpty()) + { + ret.setErrorCode(302); + return ret; + } + + QSslCertificate ssl_cert(cert_key); + + QSslConfiguration ssl_configuration; + ssl_configuration.setPrivateKey(ssl_key); + ssl_configuration.setLocalCertificate(ssl_cert); + + m_tcp_server.setSslConfiguration(ssl_configuration); + + if (!options.contains("port")) + m_port = 0; + else + m_port = options.value("port").toUInt(); + + if (!options.contains("host")) + m_address = QHostAddress::LocalHost; + else + m_address = QHostAddress(options.value("host").toString()); + + auto r = compose(m_port, m_address); + if (r.error()) + { + ret.setErrorCode(r.errorCode()); + return ret; + } + + ret.setErrorCode(0); + return ret; + } + + using void_f = std::function; + using Prev = void_f; + using Next = void_f; + using NextPrev = std::function; + using DownstreamUpstream = std::function; + using Downstream = std::function; + using Final = std::function; + + //! + //! \brief The Recurse class + //! main class of the app + //! + class Application : public QObject + { + Q_OBJECT + + public: + Application(QCoreApplication *core_inst); + Application(int &argc, char **argv, QObject *parent = NULL); + ~Application(); + + void http_server(quint16 port, QHostAddress address = QHostAddress::Any); + void http_server(const QHash &options); + void https_server(const QHash &options); + Returns listen(quint16 port, QHostAddress address = QHostAddress::Any); + Returns listen(); + + void use(Downstream next); + void use(DownstreamUpstream next); + void use(Final next); + + public slots: + bool handleConnection(QTcpSocket *socket); + + private: + QPointer app; + QPointer http; + QPointer https; + Returns ret; + + QVector m_middleware_next; + bool m_http_set = false; + bool m_https_set = false; + quint16 m_http_port; + QHostAddress m_http_address; + QHash m_https_options; + bool m_debug = false; + bool m_int_core = false; + + void m_start_upstream(Context *ctx, QVector *middleware_prev); + void m_send_response(Context *ctx); + void m_call_next(Prev prev, Context *ctx, int current_middleware, QVector *middleware_prev); + + quint16 appExitHandler(quint16 code); + + void debug(QString message); + }; + + inline Application::Application(QCoreApplication *core_inst) + : app(core_inst) + { + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + + QRegExp debug_strings("(recurse|development)"); + + if (debug_strings.indexIn(env.value("DEBUG")) != -1) + m_debug = true; + } + + inline Application::Application(int &argc, char **argv, QObject *parent) + : Application(new QCoreApplication(argc, argv)) + { + Q_UNUSED(parent); + m_int_core = true; + } + + inline Application::~Application() + { + if (app) + delete app; + + if (http) + delete http; + + if (https) + delete https; + } + + //! + //! \brief Application::debug + //! Console debugging output wrapper based on RECURSE_DEBUG environment variable + //! + inline void Application::debug(QString message) + { + if (m_debug) + std::cout << "(recurse debug) " << message.toStdString() << std::endl; + } + + //! + //! \brief Application::end + //! final function to be called for creating/sending response + //! \param request + //! \param response + //! + inline void Application::m_start_upstream(Context *ctx, QVector *middleware_prev) + { + debug("start upstream: " + QString::number(middleware_prev->size())); + + // if there are no upstream middlewares send response directly + if (!middleware_prev->size()) + m_send_response(ctx); + else + middleware_prev->at(middleware_prev->size() - 1)(); + } + + //! + //! \brief Application::m_send_response + //! used as last middleware (upstream) to be called + //! sends response to client + //! \param ctx + //! + inline void Application::m_send_response(Context *ctx) + { + debug("end upstream"); + + auto request = ctx->request; + auto response = ctx->response; + + response.method = request.method; + response.protocol = request.protocol; + + QString reply = response.create_reply(); + + // send response to the client + request.socket->write(reply.toUtf8()); + + request.socket->disconnectFromHost(); + } + + //! + //! \brief Application::m_call_next + //! call next middleware + //! + inline void Application::m_call_next(Prev prev, Context *ctx, int current_middleware, QVector *middleware_prev) + { + debug("calling next: " + QString::number(current_middleware) + " num: " + QString::number(m_middleware_next.size())); + + ++current_middleware; + + // save previous middleware function + middleware_prev->push_back(std::move(prev)); + + // call next function with current prev + m_middleware_next[current_middleware](*ctx, std::bind(&Application::m_call_next, this, std::placeholders::_1, ctx, current_middleware, middleware_prev), prev); + } + + //! + //! \brief Application::use + //! add new middleware + //! + //! \param f middleware function that will be called later + //! + //! + //! + inline void Application::use(DownstreamUpstream f) + { + m_middleware_next.push_back(std::move(f)); + } + + //! + //! \brief Application::use + //! overload function, next middleware only, no upstream + //! + //! \param f + //! + inline void Application::use(Downstream f) + { + m_middleware_next.push_back([g = std::move(f)](Context &ctx, NextPrev next, Prev prev) + { + g(ctx, [next, prev]() + { + next([prev]() + { + prev(); + }); + }); + }); + } + + //! + //! \brief Application::use + //! overloaded function, + //! final middleware that doesn't call next, used for returning response + //! + //! \param f final middleware function that will be called last + //! + inline void Application::use(Final f) + { + m_middleware_next.push_back([g = std::move(f)](Context &ctx, NextPrev /* next */, Prev /* prev */) + { + g(ctx); + }); + } + + //! + //! \brief Application::handleConnection + //! creates new recurse context for a tcp session + //! + //! \param pointer to the socket sent from http/https server + //! + //! \return Returns return execution status code + //! + inline bool Application::handleConnection(QTcpSocket *socket) + { + debug("handling new connection"); + + auto middleware_prev = QSharedPointer>(new QVector); + middleware_prev->reserve(m_middleware_next.count()); + + auto ctx = QSharedPointer(new Context); + ctx->request.socket = socket; + + connect(socket, &QTcpSocket::readyRead, [this, ctx, middleware_prev, socket] + { + QString data(socket->readAll()); + + ctx->request.parse(data); + + if (ctx->request.length < ctx->request.getHeader("content-length").toLongLong()) + return; + + ctx->response.end = std::bind(&Application::m_start_upstream, this, ctx.data(), middleware_prev.data()); + + m_middleware_next[0]( + *ctx, + std::bind(&Application::m_call_next, this, std::placeholders::_1, ctx.data(), 0, middleware_prev.data()), + std::bind(&Application::m_send_response, this, ctx.data())); + }); + + connect(socket, &QAbstractSocket::disconnected, socket, &QObject::deleteLater); + + return true; + } + + //! + //! \brief Application::appExitHandler + //! acts according to the provided application event loop exit code + //! + //! \param code app->exec()'s exit code + //! + //! \return quint16 error code + //! + inline quint16 Application::appExitHandler(quint16 code) + { + if (code == 1) + return 201; + + return 200; + } + + //! + //! \brief Application::http_server + //! http server initialization + //! + //! \param port tcp server port + //! \param address tcp server listening address + //! + inline void Application::http_server(quint16 port, QHostAddress address) + { + http = new HttpServer(); + + m_http_port = port; + m_http_address = address; + m_http_set = true; + + debug("http server setup done"); + } + + //! + //! \brief Application::http_server + //! overloaded function, + //! http server initialization + //! + //! \param options QHash options of + //! + inline void Application::http_server(const QHash &options) + { + http = new HttpServer(); + + if (!options.contains("port")) + m_http_port = 0; + else + m_http_port = options.value("port").toUInt(); + + if (!options.contains("host")) + m_http_address = QHostAddress::Any; + else + m_http_address = QHostAddress(options.value("host").toString()); + + m_http_set = true; + + std::bind(&Application::debug, std::placeholders::_1, "http"); + debug("http server setup done"); + } + + //! + //! \brief Application::https_server + //! https (secure) server initialization + //! + //! \param options QHash options of + //! + inline void Application::https_server(const QHash &options) + { + https = new HttpsServer(this); + + m_https_options = options; + m_https_set = true; + + debug("https server setup done"); + } + + //! + //! \brief Application::listen + //! listen for tcp requests + //! + //! \param port tcp server port + //! \param address tcp server listening address + //! + //! \return Returns return execution status code + //! + inline Returns Application::listen(quint16 port, QHostAddress address) + { + use([](auto &ctx) + { + ctx.response.status(404).send("Not Found"); + }); + + // if this function is called and m_http_set is true, ignore new values + if (m_http_set) + return listen(); + + // if this function is called and m_http_set is false + // set HttpServer instance and prepare an http connection + http = new HttpServer(); + auto r = http->compose(port, address); + + if (r.error()) + { + ret.setErrorCode(r.errorCode()); + + debug("Application::listen http->compose error: " + ret.lastError()); + app->exit(1); + return ret; + } + + // connect HttpServer signal 'socketReady' to this class' 'handleConnection' slot + connect(http, &HttpServer::socketReady, this, &Application::handleConnection); + + if (m_int_core) + { + auto ok = app->exec(); + + if (!ok) + { + ret.setErrorCode(200); + + debug("Application::listen exec error: " + ret.lastError()); + app->exit(1); + return ret; + } + + debug("main loop exited"); + } + + ret.setErrorCode(0); + return ret; + } + + //! + //! \brief Application::listen + //! overloaded function, + //! listen for tcp requests + //! + //! \return Returns return execution status code + //! + inline Returns Application::listen() + { + use([](auto &ctx) + { + ctx.response.status(404).send("Not Found"); + }); + + if (m_http_set) + { + auto r = http->compose(m_http_port, m_http_address); + if (r.error()) + { + ret.setErrorCode(r.errorCode()); + + debug("Application::listen http->compose error: " + ret.lastError()); + app->exit(1); + return ret; + } + + connect(http, &HttpServer::socketReady, this, &Application::handleConnection); + } + + if (m_https_set) + { + auto r = https->compose(m_https_options); + if (r.error()) + { + ret.setErrorCode(r.errorCode()); + + debug("Application::listen https->compose error: " + ret.lastError()); + app->exit(1); + return ret; + } + + connect(https, &HttpsServer::socketReady, this, &Application::handleConnection); + } + + if (!m_http_set && !m_https_set) + return listen(0); + + if (m_int_core) + { + auto exit_code = app->exec(); + + if (exit_code != 0) + { + // TODO: set error code according to app.quit() or app->exit() method's code + ret.setErrorCode(appExitHandler(exit_code)); + + debug("Application::listen app->exec() return error: " + ret.lastError()); + return ret; + } + + debug("main loop exited"); + } + + ret.setErrorCode(0); + return ret; + } +} + +#endif diff --git a/WebServer/request.hpp b/WebServer/request.hpp new file mode 100644 index 0000000..3179779 --- /dev/null +++ b/WebServer/request.hpp @@ -0,0 +1,249 @@ +#ifndef RECURSE_REQUEST_HPP +#define RECURSE_REQUEST_HPP + +#include +#include +#include +#include + +class Request +{ + +public: + //! + //! \brief data + //! client request buffer data + //! + QString data; + + //! + //! \brief socket + //! underlying client socket + //! + QTcpSocket *socket; + + //! + //! \brief body_parsed + //! Data to be filled by body parsing middleware + //! + QHash body_parsed; + + //! + //! \brief body + //! + QString body; + + //! + //! \brief method + //! HTTP method, eg: GET + //! + QString method; + + //! + //! \brief protocol + //! Request protocol, eg: HTTP + //! + QString protocol; + + //! + //! \brief secure + //! Shorthand for protocol == "HTTPS" to check if a requet was issued via TLS + //! + bool secure = protocol == "HTTPS"; + + //! + //! \brief url + //! HTTP request url, eg: /helloworld + //! + QUrl url; + + //! + //! \brief query + //! query strings + //! + QUrlQuery query; + + //! + //! \brief params + //!r + //! request parameters that can be filled by router middlewares + //! it's easier to provide container here (which doesn't have to be used) + QHash params; + + //! + //! \brief length + //! HTTP request Content-Length + //! + qint64 length = 0; + + //! + //! \brief ip + //! Client ip address + //! + QHostAddress ip; + + //! + //! \brief hostname + //! HTTP hostname from "Host" HTTP header + //! + QString hostname; + + //! + //! \brief getHeader + //! return header value, keys are saved in lowercase + //! \param key QString + //! \return QString header value + //! + QString getHeader(const QString &key) + { + return m_headers[key]; + } + + //! + //! \brief getRawHeader + //! return original header name as sent by client + //! \param key QString case-sensitive key of the header + //! \return QString header value as sent by client + //! + QHash getRawHeaders() + { + return m_headers; + } + + //! + //! \brief getCookie + //! return cookie value with lowercase name + //! \param key case-insensitive cookie name + //! \return + //! + QString getCookie(const QString &key) + { + return m_cookies[key.toLower()]; + } + + //! + //! \brief getRawCookie + //! return cookie name as sent by client + //! \param key case-sensitive cookie name + //! \return + //! + QString getRawCookie(const QString &key) + { + return m_cookies[key]; + } + + //! + //! \brief getParam + //! return params value + //! \param key of the param, eg: name + //! \return value of the param, eg: johnny + //! + QString getParam(const QString &key) + { + return params.value(key); + } + + //! + //! \brief parse + //! parse data from request + //! + //! \param QString request + //! \return true on success, false otherwise, considered bad request + //! + bool parse(QString request); + +private: + //! + //! \brief header + //! HTTP request headers, eg: header["content-type"] = "text/plain" + //! + QHash m_headers; + + + //! + //! \brief cookies + //! HTTP cookies in key/value form + //! + QHash m_cookies; + + //! + //! \brief httpRx + //! match HTTP request line + //! + QRegExp httpRx = QRegExp("^(?=[A-Z]).* \\/.* HTTP\\/[0-9]\\.[0-9]\\r\\n"); +}; + +inline bool Request::parse(QString request) +{ + // buffer all data + this->data += request; + + // Save client ip address + this->ip = this->socket->peerAddress(); + + // if no header is present, just append all data to request.body + if (!this->data.contains(httpRx)) + { + this->body.append(this->data); + return true; + } + + auto data_list = this->data.splitRef("\r\n"); + bool is_body = false; + + for (int i = 0; i < data_list.size(); ++i) + { + if (is_body) + { + this->body.append(data_list.at(i)); + this->length += this->body.size(); + continue; + } + + auto entity_item = data_list.at(i).split(":"); + + if (entity_item.length() < 2 && entity_item.at(0).size() < 1 && !is_body) + { + is_body = true; + continue; + } + else if (i == 0 && entity_item.length() < 2) + { + auto first_line = entity_item.at(0).split(" "); + this->method = first_line.at(0).toString(); + this->url = first_line.at(1).toString(); + this->query.setQuery(this->url.query()); + this->protocol = first_line.at(2).toString(); + continue; + } + + m_headers[entity_item.at(0).toString().toLower()] = entity_item.at(1).toString(); + } + + if (m_headers.contains("host")) + this->hostname = m_headers["host"]; + + // extract cookies + // eg: USER_TOKEN=Yes;test=val + if (m_headers.contains("cookie")) + { + for (const auto &cookie : m_headers["cookie"].splitRef(";")) + { + int split = cookie.indexOf("="); + if (split == -1) + continue; + + auto key = cookie.left(split); + if (!key.size()) + continue; + + auto value = cookie.mid(split + 1); + + m_cookies[key.toString().toLower()] = value.toString(); + } + } + + return true; +} + +#endif diff --git a/WebServer/response.hpp b/WebServer/response.hpp new file mode 100644 index 0000000..e8fc185 --- /dev/null +++ b/WebServer/response.hpp @@ -0,0 +1,323 @@ +#ifndef RECURSE_RESPONSE_HPP +#define RECURSE_RESPONSE_HPP + +#include +#include +#include + +class Response +{ + +public: + //! + //! \brief get + //! Returns the HTTP response header specified by key + //! + //! \param QString case-insensitive key of the header + //! \return QString header + //! + QString getHeader(const QString &key) + { + return m_headers[key.toLower()]; + } + + //! + //! \brief set + //! Sets the response HTTP header to value. + //! + //! \param QString key of the header + //! \param QString value for the header + //! \return Response chainable + //! + Response &setHeader(const QString &key, const QString &value) + { + m_headers[key] = value; + return *this; + } + + //! + //! \brief status + //! Get HTTP response status + //! + //! \return quint16 status + //! + quint16 status() const + { + return m_status; + } + + //! + //! \brief status + //! Set HTTP response status + //! + //! \param quint16 status + //! \return Response chainable + //! + Response &status(quint16 status) + { + m_status = status; + return *this; + } + + //! + //! \brief type + //! Get the Content-Type HTTP header + //! + //! \return QString MIME content-type + //! + QString type() const + { + return m_headers["content-type"]; + } + + //! + //! \brief type + //! Sets Content-Type HTTP header + //! + //! \param QString MIME type + //! \return Response chainable + //! + Response &type(const QString &type) + { + m_headers["content-type"] = type; + return *this; + } + + //! + //! \brief body + //! Get current response body data content, useful for upstream middleware + //! + //! \return QString response content + //! + QString body() const + { + return m_body; + } + + //! + //! \brief body + //! Set response content, overrides existing + //! + //! \param QString body + //! \return Response chainable + //! + Response &body(const QString &body) + { + m_body = body; + return *this; + } + + //! + //! \brief write + //! Appends data to existing content (set by write() or body()) + //! + //! \param QString data to be added + //! \return Response chainable + //! + Response &write(const QString &data) + { + m_body += data; + return *this; + } + + //! + //! \brief send + //! Sends actual data to client + //! + //! \param QString body optional, if provided this is sent instead of current data in buffer + //! + void send(const QString &body = "") + { + if (body.size()) + m_body = body; + + end(); + } + + //! + //! \brief send + //! Overloaded function, allows sending QJsonDocument + //! + //! \param body + //! + void send(const QJsonDocument &body) + { + type("application/json"); + m_body = body.toJson(QJsonDocument::Compact); + + end(); + } + + //! \brief redirect + //! Perform a 302 redirect to `url`. + //! + //! the string "back" is used to provide Referrer support + //! when Referrer is not present `alt` is used + //! + //! Examples: + //! + //! redirect('back'); + //! redirect('back', '/index.html'); + //! redirect('/login'); + //! redirect('http://google.com'); + //! + //! To override status or body set them before calling redirect + //! + //! status(301).body("Redirecting...").redirect("http://www.google.com") + //! + //! \param url to redirect to + //! \param alt used when referrer is not present, "/" by default + + void redirect(const QString &url, const QString &alt = "/") + { + // set location + if (url == "back") + { + const QString &referrer = getHeader("referrer"); + + if (!referrer.isEmpty()) + setHeader("Location", referrer); + else + setHeader("Location", alt); + } + else + { + setHeader("Location", url); + } + + // set redirect status if not set + // https://tools.ietf.org/html/rfc7231#section-6.4 + if (status() < 300 || status() > 308) + status(302); + + // set body if not set + if (body().isEmpty()) + body("This page has moved to " % url); + + end(); + } + + //! + //! \brief end + //! final function responsible for sending data + //! this is bound to Recurse::end function, from recurse.hpp + //! ctx->response.end = std::bind(&Recurse::end, this, ctx); + //! + std::function end; + + //! + //! \brief method + //! Response method, eg: GET + //! + QString method; + + //! + //! \brief protocol + //! Response protocol, eg: HTTP + //! + QString protocol; + + //! + //! \brief http codes + //! + QHash http_codes{ + { 100, "Continue" }, + { 101, "Switching Protocols" }, + { 200, "OK" }, + { 201, "Created" }, + { 202, "Accepted" }, + { 203, "Non-Authoritative Information" }, + { 204, "No Content" }, + { 205, "Reset Content" }, + { 206, "Partial Content" }, + { 300, "Multiple Choices" }, + { 301, "Moved Permanently" }, + { 302, "Found" }, + { 303, "See Other" }, + { 304, "Not Modified" }, + { 305, "Use Proxy" }, + { 307, "Temporary Redirect" }, + { 400, "Bad Request" }, + { 401, "Unauthorized" }, + { 402, "Payment Required" }, + { 403, "Forbidden" }, + { 404, "Not Found" }, + { 405, "Method Not Allowed" }, + { 406, "Not Acceptable" }, + { 407, "Proxy Authentication Required" }, + { 408, "Request Time-out" }, + { 409, "Conflict" }, + { 410, "Gone" }, + { 411, "Length Required" }, + { 412, "Precondition Failed" }, + { 413, "Request Entity Too Large" }, + { 414, "Request-URI Too Large" }, + { 415, "Unsupported Media Type" }, + { 416, "Requested range not satisfiable" }, + { 417, "Expectation Failed" }, + { 500, "Internal Server Error" }, + { 501, "Not Implemented" }, + { 502, "Bad Gateway" }, + { 503, "Service Unavailable" }, + { 504, "Gateway Time-out" }, + { 505, "HTTP Version not supported" } + }; + + //! + //! \brief create_reply + //! create reply for sending to client + //! + //! \return QString reply to be sent + //! + QString create_reply(); + +private: + //! + //! \brief m_status + //! HTTP response status + //! + quint16 m_status = 200; + + //! + //! \brief header + //! holds all header data as key/value + //! + + QHash m_headers; + //! + //! \brief m_body + //! HTTP response content + //! + QString m_body; +}; + +// https://tools.ietf.org/html/rfc7230#page-19 +inline QString Response::create_reply() +{ + QString reply = this->protocol % " " % QString::number(this->status()) % " " + % this->http_codes[this->status()] % "\r\n"; + + // set content length + m_headers["content-length"] = QString::number(this->body().size()); + + // set content type if not set + if (!m_headers.contains("content-type")) + { + // Fixed by j2doll. + // m_headers["content-type"] = "text/plain"; + m_headers["content-type"] = "text/html"; + } + + // set custom header fields + for (auto i = m_headers.constBegin(); i != m_headers.constEnd(); ++i) + reply = reply % i.key() % ": " % i.value() % "\r\n"; + + reply += "\r\n"; + + if (this->body().size()) + reply += this->body(); + + return reply; +} + +#endif diff --git a/WebServer/test.xlsx b/WebServer/test.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..8b89846e09fc57245dc7498b17871508cdcab268 GIT binary patch literal 7702 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 + + + test.xlsx + + \ No newline at end of file