From c980162656aa7d98ee1b11e050350c4d351ab6ca Mon Sep 17 00:00:00 2001 From: Mm2PL Date: Sat, 22 Jun 2024 12:04:30 +0200 Subject: [PATCH] Introduce HTTP API for plugins (#5383) --- CHANGELOG.md | 1 + docs/chatterino.d.ts | 32 ++ docs/plugin-meta.lua | 84 +++- docs/wip-plugins.md | 150 ++++++ scripts/make_luals_meta.py | 1 - src/CMakeLists.txt | 4 + src/common/network/NetworkCommon.hpp | 3 + src/common/network/NetworkRequest.cpp | 7 + src/common/network/NetworkRequest.hpp | 2 + src/controllers/plugins/LuaAPI.hpp | 9 +- src/controllers/plugins/Plugin.cpp | 29 +- src/controllers/plugins/Plugin.hpp | 3 + src/controllers/plugins/PluginController.cpp | 12 + src/controllers/plugins/PluginPermission.cpp | 2 + src/controllers/plugins/PluginPermission.hpp | 1 + src/controllers/plugins/api/HTTPRequest.cpp | 451 +++++++++++++++++++ src/controllers/plugins/api/HTTPRequest.hpp | 162 +++++++ src/controllers/plugins/api/HTTPResponse.cpp | 144 ++++++ src/controllers/plugins/api/HTTPResponse.hpp | 80 ++++ 19 files changed, 1166 insertions(+), 11 deletions(-) create mode 100644 src/controllers/plugins/api/HTTPRequest.cpp create mode 100644 src/controllers/plugins/api/HTTPRequest.hpp create mode 100644 src/controllers/plugins/api/HTTPResponse.cpp create mode 100644 src/controllers/plugins/api/HTTPResponse.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 141124c4..314884c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Minor: Moderators can now see when users are warned. (#5441) - Minor: Added support for Brave & google-chrome-stable browsers. (#5452) - Minor: Added drop indicator line while dragging in tables. (#5256) +- Minor: Introduce HTTP API for plugins. (#5383) - Bugfix: Fixed tab move animation occasionally failing to start after closing a tab. (#5426) - Bugfix: If a network request errors with 200 OK, Qt's error code is now reported instead of the HTTP status. (#5378) - Bugfix: Fixed restricted users usernames not being clickable. (#5405) diff --git a/docs/chatterino.d.ts b/docs/chatterino.d.ts index 95d2282b..7929fbc3 100644 --- a/docs/chatterino.d.ts +++ b/docs/chatterino.d.ts @@ -32,6 +32,8 @@ declare module c2 { is_valid(): boolean; } + interface ISharedResource {} + class RoomModes { unique_chat: boolean; subscriber_only: boolean; @@ -69,6 +71,36 @@ declare module c2 { static by_twitch_id(id: string): null | Channel; } + enum HTTPMethod { + Get, + Post, + Put, + Delete, + Patch, + } + + class HTTPResponse implements ISharedResource { + data(): string; + status(): number | null; + error(): string; + } + + type HTTPCallback = (res: HTTPResponse) => void; + class HTTPRequest implements ISharedResource { + on_success(callback: HTTPCallback): void; + on_error(callback: HTTPCallback): void; + finally(callback: () => void): void; + + set_timeout(millis: number): void; + set_payload(data: string): void; + set_header(name: string, value: string): void; + + execute(): void; + + // might error + static create(method: HTTPMethod, url: string): HTTPRequest; + } + function log(level: LogLevel, ...data: any[]): void; function register_command( name: String, diff --git a/docs/plugin-meta.lua b/docs/plugin-meta.lua index 391b5745..453e4f54 100644 --- a/docs/plugin-meta.lua +++ b/docs/plugin-meta.lua @@ -5,7 +5,6 @@ -- Add the folder this file is in to "Lua.workspace.library". c2 = {} - ---@alias c2.LogLevel integer ---@type { Debug: c2.LogLevel, Info: c2.LogLevel, Warning: c2.LogLevel, Critical: c2.LogLevel } c2.LogLevel = {} @@ -159,6 +158,89 @@ function c2.Channel.by_twitch_id(id) end -- End src/controllers/plugins/api/ChannelRef.hpp +-- Begin src/controllers/plugins/api/HTTPRequest.hpp + +---@class HTTPResponse +---@field data string Data received from the server +---@field status integer? HTTP Status code returned by the server +---@field error string A somewhat human readable description of an error if such happened + +---@alias HTTPCallback fun(result: HTTPResponse): nil +---@class HTTPRequest +HTTPRequest = {} + +--- Sets the success callback +--- +---@param callback HTTPCallback Function to call when the HTTP request succeeds +function HTTPRequest:on_success(callback) end + +--- Sets the failure callback +--- +---@param callback HTTPCallback Function to call when the HTTP request fails or returns a non-ok status +function HTTPRequest:on_error(callback) end + +--- Sets the finally callback +--- +---@param callback fun(): nil Function to call when the HTTP request finishes +function HTTPRequest:finally(callback) end + +--- Sets the timeout +--- +---@param timeout integer How long in milliseconds until the times out +function HTTPRequest:set_timeout(timeout) end + +--- Sets the request payload +--- +---@param data string +function HTTPRequest:set_payload(data) end + +--- Sets a header in the request +--- +---@param name string +---@param value string +function HTTPRequest:set_header(name, value) end + +--- Executes the HTTP request +--- +function HTTPRequest:execute() end + +--- Creates a new HTTPRequest +--- +---@param method HTTPMethod Method to use +---@param url string Where to send the request to +---@return HTTPRequest +function HTTPRequest.create(method, url) end + +-- End src/controllers/plugins/api/HTTPRequest.hpp + +-- Begin src/controllers/plugins/api/HTTPResponse.hpp + +---@class HTTPResponse +HTTPResponse = {} + +--- Returns the data. This is not guaranteed to be encoded using any +--- particular encoding scheme. It's just the bytes the server returned. +--- +function HTTPResponse:data() end + +--- Returns the status code. +--- +function HTTPResponse:status() end + +--- A somewhat human readable description of an error if such happened +--- +function HTTPResponse:error() end + +-- End src/controllers/plugins/api/HTTPResponse.hpp + +-- Begin src/common/network/NetworkCommon.hpp + +---@alias HTTPMethod integer +---@type { Get: HTTPMethod, Post: HTTPMethod, Put: HTTPMethod, Delete: HTTPMethod, Patch: HTTPMethod } +HTTPMethod = {} + +-- End src/common/network/NetworkCommon.hpp + --- Registers a new command called `name` which when executed will call `handler`. --- ---@param name string The name of the command. diff --git a/docs/wip-plugins.md b/docs/wip-plugins.md index 32eda387..bf49e0f4 100644 --- a/docs/wip-plugins.md +++ b/docs/wip-plugins.md @@ -85,6 +85,24 @@ Example: } ``` +### Network + +Allows the plugin to send HTTP requests. + +Example: + +```json +{ + ..., + "permissions": [ + { + "type": "Network" + }, + ... + ] +} +``` + ## Plugins with Typescript If you prefer, you may use [TypescriptToLua](https://typescripttolua.github.io) @@ -370,6 +388,138 @@ Returns `true` if the channel can be moderated by the current user. Returns `true` if the current user is a VIP in the channel. +#### `HTTPMethod` enum + +This table describes HTTP methods available to Lua Plugins. The values behind +the names may change, do not count on them. It has the following keys: + +- `Get` +- `Post` +- `Put` +- `Delete` +- `Patch` + +#### `HTTPResponse` + +An `HTTPResponse` is a table you receive in the callback after a completed `HTTPRequest`. + +##### `HTTPResponse.data()` + +This function returns the data received from the server as a string. Usually +this will be UTF-8-encoded however that is not guaranteed, this could be any +binary data. + +##### `HTTPResponse.error()` + +If an error happened this function returns a human readable description of it. + +It returns something like: `"ConnectionRefusedError"`, `"401"`. + +##### `HTTPResponse.status()` + +This function returns the HTTP status code of the request or `nil` if there was +an error before a status code could be received. + +```lua +{ + data = "This is the data received from the server as a string", + status = 200, -- HTTP status code returned by the server or nil if no response was received because of an error + error = "A somewhat human readable description of an error if such happened" +} +``` + +#### `HTTPRequest` + +Allows you to send an HTTP request to a URL. Do not create requests that you +don't want to call `execute()` on. For the time being that leaks callback +functions and all their upvalues with them. + +##### `HTTPRequest.create(method, url)` + +Creates a new `HTTPRequest`. The `method` argument is an +[`HTTPMethod`](#HTTPMethod-enum). The `url` argument must be a string +containing a valid URL (ex. `https://example.com/path/to/api`). + +```lua +local req = c2.HTTPRequest.create(c2.HTTPMethod.Get, "https://example.com") +req:on_success(function (res) + print(res.data) +end) +req:execute() +``` + +##### `HTTPRequest:on_success(callback)` + +Sets the success callback. It accepts a function that takes a single parameter +of type `HTTPResponse`. The callback will be called on success. This function +returns nothing. + +##### `HTTPRequest:on_error(callback)` + +Sets the error callback. It accepts a function that takes a single parameter of +type `HTTPResponse`. The callback will be called if the request fails. To see why +it failed check the `error` field of the result. This function returns nothing. + +##### `HTTPRequest:finally(callback)` + +Sets the finally callback. It accepts a function that takes no parameters and +returns nothing. It will be always called after `success` or `error`. This +function returns nothing. + +##### `HTTPRequest:set_timeout(timeout)` + +Sets how long the request will take before it times out. The `timeout` +parameter is in milliseconds. This function returns nothing. + +##### `HTTPRequest:set_payload(data)` + +Sets the data that will be sent with the request. The `data` parameter must be +a string. This function returns nothing. + +##### `HTTPRequest:set_header(name, value)` + +Adds or overwrites a header in the request. Both `name` and `value` should be +strings. If they are not strings they will be converted to strings. This +function returns nothing. + +##### `HTTPRequest:execute()` + +Sends the request. This function returns nothing. + +```lua +local url = "http://localhost:8080/thing" +local request = c2.HTTPRequest.create("Post", url) +request:set_timeout(1000) +request:set_payload("TEST!") +request:set_header("X-Test", "Testing!") +request:set_header("Content-Type", "text/plain") +request:on_success(function (res) + print('Success!') + -- Data is in res.data + print(res.status) +end) +request:on_error(function (res) + print('Error!') + print(res.status) + print(res.error) +end) +request:finally(function () + print('Finally') +end) +request:execute() + +-- This prints: +-- Success! +-- [content of /thing] +-- 200 +-- Finally + +-- Or: +-- Error! +-- nil +-- ConnectionRefusedError +``` + ### Input/Output API These functions are wrappers for Lua's I/O library. Functions on file pointer diff --git a/scripts/make_luals_meta.py b/scripts/make_luals_meta.py index adbbeb9d..f9f44e7e 100644 --- a/scripts/make_luals_meta.py +++ b/scripts/make_luals_meta.py @@ -41,7 +41,6 @@ BOILERPLATE = """ -- Add the folder this file is in to "Lua.workspace.library". c2 = {} - """ repo_root = Path(__file__).parent.parent diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 82634375..4a7bcde6 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -227,6 +227,10 @@ set(SOURCE_FILES controllers/plugins/api/ChannelRef.hpp controllers/plugins/api/IOWrapper.cpp controllers/plugins/api/IOWrapper.hpp + controllers/plugins/api/HTTPRequest.cpp + controllers/plugins/api/HTTPRequest.hpp + controllers/plugins/api/HTTPResponse.cpp + controllers/plugins/api/HTTPResponse.hpp controllers/plugins/LuaAPI.cpp controllers/plugins/LuaAPI.hpp controllers/plugins/PluginPermission.cpp diff --git a/src/common/network/NetworkCommon.hpp b/src/common/network/NetworkCommon.hpp index a5a44430..215b828a 100644 --- a/src/common/network/NetworkCommon.hpp +++ b/src/common/network/NetworkCommon.hpp @@ -15,6 +15,9 @@ using NetworkSuccessCallback = std::function; using NetworkErrorCallback = std::function; using NetworkFinallyCallback = std::function; +/** + * @exposeenum HTTPMethod + */ enum class NetworkRequestType { Get, Post, diff --git a/src/common/network/NetworkRequest.cpp b/src/common/network/NetworkRequest.cpp index b436216a..f46da079 100644 --- a/src/common/network/NetworkRequest.cpp +++ b/src/common/network/NetworkRequest.cpp @@ -98,6 +98,13 @@ NetworkRequest NetworkRequest::header(QNetworkRequest::KnownHeaders header, return std::move(*this); } +NetworkRequest NetworkRequest::header(const QByteArray &headerName, + const QByteArray &value) && +{ + this->data->request.setRawHeader(headerName, value); + return std::move(*this); +} + NetworkRequest NetworkRequest::headerList( const std::vector> &headers) && { diff --git a/src/common/network/NetworkRequest.hpp b/src/common/network/NetworkRequest.hpp index 4da4c7a9..1308fb02 100644 --- a/src/common/network/NetworkRequest.hpp +++ b/src/common/network/NetworkRequest.hpp @@ -57,6 +57,8 @@ public: NetworkRequest header(const char *headerName, const char *value) &&; NetworkRequest header(const char *headerName, const QByteArray &value) &&; NetworkRequest header(const char *headerName, const QString &value) &&; + NetworkRequest header(const QByteArray &headerName, + const QByteArray &value) &&; NetworkRequest header(QNetworkRequest::KnownHeaders header, const QVariant &value) &&; NetworkRequest headerList( diff --git a/src/controllers/plugins/LuaAPI.hpp b/src/controllers/plugins/LuaAPI.hpp index fd2c12e4..dd3ee830 100644 --- a/src/controllers/plugins/LuaAPI.hpp +++ b/src/controllers/plugins/LuaAPI.hpp @@ -82,6 +82,9 @@ struct CompletionEvent { /** * @includefile common/Channel.hpp * @includefile controllers/plugins/api/ChannelRef.hpp + * @includefile controllers/plugins/api/HTTPRequest.hpp + * @includefile controllers/plugins/api/HTTPResponse.hpp + * @includefile common/network/NetworkCommon.hpp */ /** @@ -133,7 +136,11 @@ int searcherRelative(lua_State *L); // This is a fat pointer that allows us to type check values given to functions needing a userdata. // Ensure ALL userdata given to Lua are a subclass of this! Otherwise we garbage as a pointer! struct UserData { - enum class Type { Channel }; + enum class Type { + Channel, + HTTPRequest, + HTTPResponse, + }; Type type; bool isWeak; }; diff --git a/src/controllers/plugins/Plugin.cpp b/src/controllers/plugins/Plugin.cpp index 4609fee7..4a8f89f0 100644 --- a/src/controllers/plugins/Plugin.cpp +++ b/src/controllers/plugins/Plugin.cpp @@ -1,8 +1,10 @@ #ifdef CHATTERINO_HAVE_PLUGINS # include "controllers/plugins/Plugin.hpp" +# include "common/network/NetworkCommon.hpp" # include "common/QLogging.hpp" # include "controllers/commands/CommandController.hpp" +# include "controllers/plugins/PluginPermission.hpp" # include "util/QMagicEnum.hpp" extern "C" { @@ -11,6 +13,8 @@ extern "C" { # include # include # include +# include +# include # include # include @@ -258,16 +262,25 @@ bool Plugin::hasFSPermissionFor(bool write, const QString &path) using PType = PluginPermission::Type; auto typ = write ? PType::FilesystemWrite : PType::FilesystemRead; - // XXX: Older compilers don't have support for std::ranges - // NOLINTNEXTLINE(readability-use-anyofallof) - for (const auto &p : this->meta.permissions) + return std::ranges::any_of(this->meta.permissions, [=](const auto &p) { + return p.type == typ; + }); +} + +bool Plugin::hasHTTPPermissionFor(const QUrl &url) +{ + auto proto = url.scheme(); + if (proto != "http" && proto != "https") { - if (p.type == typ) - { - return true; - } + qCWarning(chatterinoLua).nospace() + << "Plugin " << this->id << " (" << this->meta.name + << ") is trying to use a non-http protocol"; + return false; } - return false; + + return std::ranges::any_of(this->meta.permissions, [](const auto &p) { + return p.type == PluginPermission::Type::Network; + }); } } // namespace chatterino diff --git a/src/controllers/plugins/Plugin.hpp b/src/controllers/plugins/Plugin.hpp index 2adbe906..f8375247 100644 --- a/src/controllers/plugins/Plugin.hpp +++ b/src/controllers/plugins/Plugin.hpp @@ -2,12 +2,14 @@ #ifdef CHATTERINO_HAVE_PLUGINS # include "Application.hpp" +# include "common/network/NetworkCommon.hpp" # include "controllers/plugins/LuaAPI.hpp" # include "controllers/plugins/LuaUtilities.hpp" # include "controllers/plugins/PluginPermission.hpp" # include # include +# include # include # include @@ -139,6 +141,7 @@ public: void removeTimeout(QTimer *timer); bool hasFSPermissionFor(bool write, const QString &path); + bool hasHTTPPermissionFor(const QUrl &url); private: QDir loadDirectory_; diff --git a/src/controllers/plugins/PluginController.cpp b/src/controllers/plugins/PluginController.cpp index 199e0f4e..2fa2865f 100644 --- a/src/controllers/plugins/PluginController.cpp +++ b/src/controllers/plugins/PluginController.cpp @@ -3,10 +3,13 @@ # include "Application.hpp" # include "common/Args.hpp" +# include "common/network/NetworkCommon.hpp" # include "common/QLogging.hpp" # include "controllers/commands/CommandContext.hpp" # include "controllers/commands/CommandController.hpp" # include "controllers/plugins/api/ChannelRef.hpp" +# include "controllers/plugins/api/HTTPRequest.hpp" +# include "controllers/plugins/api/HTTPResponse.hpp" # include "controllers/plugins/api/IOWrapper.hpp" # include "controllers/plugins/LuaAPI.hpp" # include "controllers/plugins/LuaUtilities.hpp" @@ -174,10 +177,19 @@ void PluginController::openLibrariesFor(lua_State *L, const PluginMeta &meta, lua::pushEnumTable(L); lua_setfield(L, c2libIdx, "ChannelType"); + lua::pushEnumTable(L); + lua_setfield(L, c2libIdx, "HTTPMethod"); + // Initialize metatables for objects lua::api::ChannelRef::createMetatable(L); lua_setfield(L, c2libIdx, "Channel"); + lua::api::HTTPRequest::createMetatable(L); + lua_setfield(L, c2libIdx, "HTTPRequest"); + + lua::api::HTTPResponse::createMetatable(L); + lua_setfield(L, c2libIdx, "HTTPResponse"); + lua_setfield(L, gtable, "c2"); // ban functions diff --git a/src/controllers/plugins/PluginPermission.cpp b/src/controllers/plugins/PluginPermission.cpp index 09204f93..9a4bc925 100644 --- a/src/controllers/plugins/PluginPermission.cpp +++ b/src/controllers/plugins/PluginPermission.cpp @@ -37,6 +37,8 @@ QString PluginPermission::toHtml() const return "Read files in its data directory"; case PluginPermission::Type::FilesystemWrite: return "Write to or create files in its data directory"; + case PluginPermission::Type::Network: + return "Make requests over the internet to third party websites"; default: assert(false && "invalid PluginPermission type in toHtml()"); return "shut up compiler, this never happens"; diff --git a/src/controllers/plugins/PluginPermission.hpp b/src/controllers/plugins/PluginPermission.hpp index 5867b7b6..ffa728e7 100644 --- a/src/controllers/plugins/PluginPermission.hpp +++ b/src/controllers/plugins/PluginPermission.hpp @@ -14,6 +14,7 @@ struct PluginPermission { enum class Type { FilesystemRead, FilesystemWrite, + Network, }; Type type; std::vector errors; diff --git a/src/controllers/plugins/api/HTTPRequest.cpp b/src/controllers/plugins/api/HTTPRequest.cpp new file mode 100644 index 00000000..6defabb3 --- /dev/null +++ b/src/controllers/plugins/api/HTTPRequest.cpp @@ -0,0 +1,451 @@ +#ifdef CHATTERINO_HAVE_PLUGINS +# include "controllers/plugins/api/HTTPRequest.hpp" + +# include "Application.hpp" +# include "common/network/NetworkCommon.hpp" +# include "common/network/NetworkRequest.hpp" +# include "common/network/NetworkResult.hpp" +# include "controllers/plugins/api/HTTPResponse.hpp" +# include "controllers/plugins/LuaAPI.hpp" +# include "controllers/plugins/LuaUtilities.hpp" +# include "util/DebugCount.hpp" + +extern "C" { +# include +# include +} +# include +# include + +# include +# include + +namespace chatterino::lua::api { +// NOLINTBEGIN(*vararg) +// NOLINTNEXTLINE(*-avoid-c-arrays) +static const luaL_Reg HTTP_REQUEST_METHODS[] = { + {"on_success", &HTTPRequest::on_success_wrap}, + {"on_error", &HTTPRequest::on_error_wrap}, + {"finally", &HTTPRequest::finally_wrap}, + + {"execute", &HTTPRequest::execute_wrap}, + {"set_timeout", &HTTPRequest::set_timeout_wrap}, + {"set_payload", &HTTPRequest::set_payload_wrap}, + {"set_header", &HTTPRequest::set_header_wrap}, + // static + {"create", &HTTPRequest::create}, + {nullptr, nullptr}, +}; + +std::shared_ptr HTTPRequest::getOrError(lua_State *L, + StackIdx where) +{ + if (lua_gettop(L) < 1) + { + // The nullptr is there just to appease the compiler, luaL_error is no return + luaL_error(L, "Called c2.HTTPRequest method without a request object"); + return nullptr; + } + if (lua_isuserdata(L, where) == 0) + { + luaL_error( + L, + "Called c2.HTTPRequest method with a non-userdata 'self' argument"); + return nullptr; + } + // luaL_checkudata is no-return if check fails + auto *checked = luaL_checkudata(L, where, "c2.HTTPRequest"); + auto *data = + SharedPtrUserData::from( + checked); + if (data == nullptr) + { + luaL_error(L, "Called c2.HTTPRequest method with an invalid pointer"); + return nullptr; + } + lua_remove(L, where); + if (data->target == nullptr) + { + luaL_error( + L, "Internal error: SharedPtrUserData::target was null. This is a Chatterino bug!"); + return nullptr; + } + if (data->target->done) + { + luaL_error(L, "This c2.HTTPRequest has already been executed!"); + return nullptr; + } + return data->target; +} + +void HTTPRequest::createMetatable(lua_State *L) +{ + lua::StackGuard guard(L, 1); + + luaL_newmetatable(L, "c2.HTTPRequest"); + lua_pushstring(L, "__index"); + lua_pushvalue(L, -2); // clone metatable + lua_settable(L, -3); // metatable.__index = metatable + + // Generic ISharedResource stuff + lua_pushstring(L, "__gc"); + lua_pushcfunction(L, (&SharedPtrUserData::destroy)); + lua_settable(L, -3); // metatable.__gc = SharedPtrUserData<...>::destroy + + luaL_setfuncs(L, HTTP_REQUEST_METHODS, 0); +} + +int HTTPRequest::on_success_wrap(lua_State *L) +{ + lua::StackGuard guard(L, -2); + auto ptr = HTTPRequest::getOrError(L, 1); + return ptr->on_success(L); +} + +int HTTPRequest::on_success(lua_State *L) +{ + auto top = lua_gettop(L); + if (top != 1) + { + return luaL_error( + L, "HTTPRequest:on_success needs 1 argument (a callback " + "that takes an HTTPResult and doesn't return anything)"); + } + if (!lua_isfunction(L, top)) + { + return luaL_error( + L, "HTTPRequest:on_success needs 1 argument (a callback " + "that takes an HTTPResult and doesn't return anything)"); + } + auto shared = this->pushPrivate(L); + lua_pushvalue(L, -2); + lua_setfield(L, shared, "success"); // this deletes the function copy + lua_pop(L, 2); // delete the table and function original + return 0; +} + +int HTTPRequest::on_error_wrap(lua_State *L) +{ + lua::StackGuard guard(L, -2); + auto ptr = HTTPRequest::getOrError(L, 1); + return ptr->on_error(L); +} + +int HTTPRequest::on_error(lua_State *L) +{ + auto top = lua_gettop(L); + if (top != 1) + { + return luaL_error( + L, "HTTPRequest:on_error needs 1 argument (a callback " + "that takes an HTTPResult and doesn't return anything)"); + } + if (!lua_isfunction(L, top)) + { + return luaL_error( + L, "HTTPRequest:on_error needs 1 argument (a callback " + "that takes an HTTPResult and doesn't return anything)"); + } + auto shared = this->pushPrivate(L); + lua_pushvalue(L, -2); + lua_setfield(L, shared, "error"); // this deletes the function copy + lua_pop(L, 2); // delete the table and function original + return 0; +} + +int HTTPRequest::set_timeout_wrap(lua_State *L) +{ + lua::StackGuard guard(L, -2); + auto ptr = HTTPRequest::getOrError(L, 1); + return ptr->set_timeout(L); +} + +int HTTPRequest::set_timeout(lua_State *L) +{ + auto top = lua_gettop(L); + if (top != 1) + { + return luaL_error( + L, "HTTPRequest:set_timeout needs 1 argument (a number of " + "milliseconds after which the request will time out)"); + } + + int temporary = -1; + if (!lua::pop(L, &temporary)) + { + return luaL_error( + L, "HTTPRequest:set_timeout failed to get timeout, expected a " + "positive integer"); + } + if (temporary <= 0) + { + return luaL_error( + L, "HTTPRequest:set_timeout failed to get timeout, expected a " + "positive integer"); + } + this->timeout_ = temporary; + return 0; +} + +int HTTPRequest::finally_wrap(lua_State *L) +{ + lua::StackGuard guard(L, -2); + auto ptr = HTTPRequest::getOrError(L, 1); + return ptr->finally(L); +} + +int HTTPRequest::finally(lua_State *L) +{ + auto top = lua_gettop(L); + if (top != 1) + { + return luaL_error(L, "HTTPRequest:finally needs 1 argument (a callback " + "that takes nothing and doesn't return anything)"); + } + if (!lua_isfunction(L, top)) + { + return luaL_error(L, "HTTPRequest:finally needs 1 argument (a callback " + "that takes nothing and doesn't return anything)"); + } + auto shared = this->pushPrivate(L); + lua_pushvalue(L, -2); + lua_setfield(L, shared, "finally"); // this deletes the function copy + lua_pop(L, 2); // delete the table and function original + return 0; +} + +int HTTPRequest::set_payload_wrap(lua_State *L) +{ + lua::StackGuard guard(L, -2); + auto ptr = HTTPRequest::getOrError(L, 1); + return ptr->set_payload(L); +} + +int HTTPRequest::set_payload(lua_State *L) +{ + auto top = lua_gettop(L); + if (top != 1) + { + return luaL_error( + L, "HTTPRequest:set_payload needs 1 argument (a string payload)"); + } + + std::string temporary; + if (!lua::pop(L, &temporary)) + { + return luaL_error( + L, "HTTPRequest:set_payload failed to get payload, expected a " + "string"); + } + this->req_ = + std::move(this->req_).payload(QByteArray::fromStdString(temporary)); + return 0; +} + +int HTTPRequest::set_header_wrap(lua_State *L) +{ + lua::StackGuard guard(L, -3); + auto ptr = HTTPRequest::getOrError(L, 1); + return ptr->set_header(L); +} + +int HTTPRequest::set_header(lua_State *L) +{ + auto top = lua_gettop(L); + if (top != 2) + { + return luaL_error( + L, "HTTPRequest:set_header needs 2 arguments (a header name " + "and a value)"); + } + + std::string value; + if (!lua::pop(L, &value)) + { + return luaL_error( + L, "cannot get value (2nd argument of HTTPRequest:set_header)"); + } + std::string name; + if (!lua::pop(L, &name)) + { + return luaL_error( + L, "cannot get name (1st argument of HTTPRequest:set_header)"); + } + this->req_ = std::move(this->req_) + .header(QByteArray::fromStdString(name), + QByteArray::fromStdString(value)); + return 0; +} + +int HTTPRequest::create(lua_State *L) +{ + lua::StackGuard guard(L, -1); + if (lua_gettop(L) != 2) + { + return luaL_error( + L, "HTTPRequest.create needs exactly 2 arguments (method " + "and url)"); + } + QString url; + if (!lua::pop(L, &url)) + { + return luaL_error(L, + "cannot get url (2nd argument of HTTPRequest.create, " + "expected a string)"); + } + auto parsedurl = QUrl(url); + if (!parsedurl.isValid()) + { + return luaL_error( + L, "cannot parse url (2nd argument of HTTPRequest.create, " + "got invalid url in argument)"); + } + NetworkRequestType method{}; + if (!lua::pop(L, &method)) + { + return luaL_error( + L, "cannot get method (1st argument of HTTPRequest.create, " + "expected a string)"); + } + auto *pl = getIApp()->getPlugins()->getPluginByStatePtr(L); + if (!pl->hasHTTPPermissionFor(parsedurl)) + { + return luaL_error( + L, "Plugin does not have permission to send HTTP requests " + "to this URL"); + } + NetworkRequest r(parsedurl, method); + lua::push( + L, std::make_shared(ConstructorAccessTag{}, std::move(r))); + return 1; +} + +int HTTPRequest::execute_wrap(lua_State *L) +{ + auto ptr = HTTPRequest::getOrError(L, 1); + return ptr->execute(L); +} + +int HTTPRequest::execute(lua_State *L) +{ + auto shared = this->shared_from_this(); + this->done = true; + std::move(this->req_) + .onSuccess([shared, L](const NetworkResult &res) { + lua::StackGuard guard(L); + auto *thread = lua_newthread(L); + + auto priv = shared->pushPrivate(thread); + lua_getfield(thread, priv, "success"); + auto cb = lua_gettop(thread); + if (lua_isfunction(thread, cb)) + { + lua::push(thread, std::make_shared(res)); + // one arg, no return, no msgh + lua_pcall(thread, 1, 0, 0); + } + else + { + lua_pop(thread, 1); // remove callback + } + lua_closethread(thread, nullptr); + lua_pop(L, 1); // remove thread from L + }) + .onError([shared, L](const NetworkResult &res) { + lua::StackGuard guard(L); + auto *thread = lua_newthread(L); + + auto priv = shared->pushPrivate(thread); + lua_getfield(thread, priv, "error"); + auto cb = lua_gettop(thread); + if (lua_isfunction(thread, cb)) + { + lua::push(thread, std::make_shared(res)); + // one arg, no return, no msgh + lua_pcall(thread, 1, 0, 0); + } + else + { + lua_pop(thread, 1); // remove callback + } + lua_closethread(thread, nullptr); + lua_pop(L, 1); // remove thread from L + }) + .finally([shared, L]() { + lua::StackGuard guard(L); + auto *thread = lua_newthread(L); + + auto priv = shared->pushPrivate(thread); + lua_getfield(thread, priv, "finally"); + auto cb = lua_gettop(thread); + if (lua_isfunction(thread, cb)) + { + // no args, no return, no msgh + lua_pcall(thread, 0, 0, 0); + } + else + { + lua_pop(thread, 1); // remove callback + } + // remove our private data + lua_pushnil(thread); + lua_setfield(thread, LUA_REGISTRYINDEX, + shared->privateKey.toStdString().c_str()); + lua_closethread(thread, nullptr); + lua_pop(L, 1); // remove thread from L + + // we removed our private table, forget the key for it + shared->privateKey = QString(); + }) + .timeout(this->timeout_) + .execute(); + return 0; +} + +HTTPRequest::HTTPRequest(HTTPRequest::ConstructorAccessTag /*ignored*/, + NetworkRequest req) + : req_(std::move(req)) +{ + DebugCount::increase("lua::api::HTTPRequest"); +} + +HTTPRequest::~HTTPRequest() +{ + DebugCount::decrease("lua::api::HTTPRequest"); + // We might leak a Lua function or two here if the request isn't executed + // but that's better than accessing a possibly invalid lua_State pointer. +} + +StackIdx HTTPRequest::pushPrivate(lua_State *L) +{ + if (this->privateKey.isEmpty()) + { + this->privateKey = QString("HTTPRequestPrivate%1") + .arg(QRandomGenerator::system()->generate()); + pushEmptyTable(L, 4); + lua_setfield(L, LUA_REGISTRYINDEX, + this->privateKey.toStdString().c_str()); + } + lua_getfield(L, LUA_REGISTRYINDEX, this->privateKey.toStdString().c_str()); + return lua_gettop(L); +} + +// NOLINTEND(*vararg) +} // namespace chatterino::lua::api + +namespace chatterino::lua { + +StackIdx push(lua_State *L, std::shared_ptr request) +{ + using namespace chatterino::lua::api; + + SharedPtrUserData::create( + L, std::move(request)); + luaL_getmetatable(L, "c2.HTTPRequest"); + lua_setmetatable(L, -2); + return lua_gettop(L); +} +} // namespace chatterino::lua +#endif diff --git a/src/controllers/plugins/api/HTTPRequest.hpp b/src/controllers/plugins/api/HTTPRequest.hpp new file mode 100644 index 00000000..c373dcec --- /dev/null +++ b/src/controllers/plugins/api/HTTPRequest.hpp @@ -0,0 +1,162 @@ +#pragma once +#ifdef CHATTERINO_HAVE_PLUGINS +# include "common/network/NetworkRequest.hpp" +# include "controllers/plugins/LuaUtilities.hpp" +# include "controllers/plugins/PluginController.hpp" + +# include + +namespace chatterino::lua::api { +// NOLINTBEGIN(readability-identifier-naming) + +/** + * @lua@class HTTPResponse + * @lua@field data string Data received from the server + * @lua@field status integer? HTTP Status code returned by the server + * @lua@field error string A somewhat human readable description of an error if such happened + */ + +/** + * @lua@alias HTTPCallback fun(result: HTTPResponse): nil + */ + +/** + * @lua@class HTTPRequest + */ +class HTTPRequest : public std::enable_shared_from_this +{ + // This type is private to prevent the accidental construction of HTTPRequest without a shared pointer + struct ConstructorAccessTag { + }; + +public: + HTTPRequest(HTTPRequest::ConstructorAccessTag, NetworkRequest req); + HTTPRequest(HTTPRequest &&other) = default; + HTTPRequest &operator=(HTTPRequest &&) = default; + HTTPRequest &operator=(HTTPRequest &) = delete; + HTTPRequest(const HTTPRequest &other) = delete; + ~HTTPRequest(); + +private: + NetworkRequest req_; + + static void createMetatable(lua_State *L); + friend class chatterino::PluginController; + + /** + * @brief Get the content of the top object on Lua stack, usually the first argument as an HTTPRequest + * + * If the object given is not a userdatum or the pointer inside that + * userdatum doesn't point to a HTTPRequest, a lua error is thrown. + * + * This function always returns a non-null pointer. + */ + static std::shared_ptr getOrError(lua_State *L, + StackIdx where = -1); + /** + * Pushes the private table onto the lua stack. + * + * This might create it if it doesn't exist. + */ + StackIdx pushPrivate(lua_State *L); + + // This is the key in the registry the private table it held at (if it exists) + // This might be a null QString if the request has already been executed or + // the table wasn't created yet. + QString privateKey; + int timeout_ = 10'000; + bool done = false; + +public: + // These functions are wrapped so data can be accessed more easily. When a call from Lua comes in: + // - the static wrapper function is called + // - it calls getOrError + // - and then the wrapped method + + /** + * Sets the success callback + * + * @lua@param callback HTTPCallback Function to call when the HTTP request succeeds + * @exposed HTTPRequest:on_success + */ + static int on_success_wrap(lua_State *L); + int on_success(lua_State *L); + + /** + * Sets the failure callback + * + * @lua@param callback HTTPCallback Function to call when the HTTP request fails or returns a non-ok status + * @exposed HTTPRequest:on_error + */ + static int on_error_wrap(lua_State *L); + int on_error(lua_State *L); + + /** + * Sets the finally callback + * + * @lua@param callback fun(): nil Function to call when the HTTP request finishes + * @exposed HTTPRequest:finally + */ + static int finally_wrap(lua_State *L); + int finally(lua_State *L); + + /** + * Sets the timeout + * + * @lua@param timeout integer How long in milliseconds until the times out + * @exposed HTTPRequest:set_timeout + */ + static int set_timeout_wrap(lua_State *L); + int set_timeout(lua_State *L); + + /** + * Sets the request payload + * + * @lua@param data string + * @exposed HTTPRequest:set_payload + */ + static int set_payload_wrap(lua_State *L); + int set_payload(lua_State *L); + + /** + * Sets a header in the request + * + * @lua@param name string + * @lua@param value string + * @exposed HTTPRequest:set_header + */ + static int set_header_wrap(lua_State *L); + int set_header(lua_State *L); + + /** + * Executes the HTTP request + * + * @exposed HTTPRequest:execute + */ + static int execute_wrap(lua_State *L); + int execute(lua_State *L); + + /** + * Static functions + */ + + /** + * Creates a new HTTPRequest + * + * @lua@param method HTTPMethod Method to use + * @lua@param url string Where to send the request to + * + * @lua@return HTTPRequest + * @exposed HTTPRequest.create + */ + static int create(lua_State *L); +}; + +// NOLINTEND(readability-identifier-naming) +} // namespace chatterino::lua::api + +namespace chatterino::lua { +StackIdx push(lua_State *L, std::shared_ptr request); +} // namespace chatterino::lua + +#endif diff --git a/src/controllers/plugins/api/HTTPResponse.cpp b/src/controllers/plugins/api/HTTPResponse.cpp new file mode 100644 index 00000000..f6d6ea1d --- /dev/null +++ b/src/controllers/plugins/api/HTTPResponse.cpp @@ -0,0 +1,144 @@ +#ifdef CHATTERINO_HAVE_PLUGINS +# include "controllers/plugins/api/HTTPResponse.hpp" + +# include "common/network/NetworkResult.hpp" +# include "controllers/plugins/LuaAPI.hpp" +# include "util/DebugCount.hpp" + +extern "C" { +# include +} +# include + +namespace chatterino::lua::api { +// NOLINTBEGIN(*vararg) +// NOLINTNEXTLINE(*-avoid-c-arrays) +static const luaL_Reg HTTP_RESPONSE_METHODS[] = { + {"data", &HTTPResponse::data_wrap}, + {"status", &HTTPResponse::status_wrap}, + {"error", &HTTPResponse::error_wrap}, + {nullptr, nullptr}, +}; + +void HTTPResponse::createMetatable(lua_State *L) +{ + lua::StackGuard guard(L, 1); + + luaL_newmetatable(L, "c2.HTTPResponse"); + lua_pushstring(L, "__index"); + lua_pushvalue(L, -2); // clone metatable + lua_settable(L, -3); // metatable.__index = metatable + + // Generic ISharedResource stuff + lua_pushstring(L, "__gc"); + lua_pushcfunction(L, (&SharedPtrUserData::destroy)); + lua_settable(L, -3); // metatable.__gc = SharedPtrUserData<...>::destroy + + luaL_setfuncs(L, HTTP_RESPONSE_METHODS, 0); +} + +std::shared_ptr HTTPResponse::getOrError(lua_State *L, + StackIdx where) +{ + if (lua_gettop(L) < 1) + { + // The nullptr is there just to appease the compiler, luaL_error is no return + luaL_error(L, "Called c2.HTTPResponse method without a request object"); + return nullptr; + } + if (lua_isuserdata(L, where) == 0) + { + luaL_error(L, "Called c2.HTTPResponse method with a non-userdata " + "'self' argument"); + return nullptr; + } + // luaL_checkudata is no-return if check fails + auto *checked = luaL_checkudata(L, where, "c2.HTTPResponse"); + auto *data = + SharedPtrUserData::from( + checked); + if (data == nullptr) + { + luaL_error(L, "Called c2.HTTPResponse method with an invalid pointer"); + return nullptr; + } + lua_remove(L, where); + if (data->target == nullptr) + { + luaL_error( + L, + "Internal error: SharedPtrUserData::target was null. This is a Chatterino bug!"); + return nullptr; + } + return data->target; +} + +HTTPResponse::HTTPResponse(NetworkResult res) + : result_(std::move(res)) +{ + DebugCount::increase("lua::api::HTTPResponse"); +} +HTTPResponse::~HTTPResponse() +{ + DebugCount::decrease("lua::api::HTTPResponse"); +} + +int HTTPResponse::data_wrap(lua_State *L) +{ + lua::StackGuard guard(L, 0); // 1 in, 1 out + auto ptr = HTTPResponse::getOrError(L, 1); + return ptr->data(L); +} + +int HTTPResponse::data(lua_State *L) +{ + lua::push(L, this->result_.getData().toStdString()); + return 1; +} + +int HTTPResponse::status_wrap(lua_State *L) +{ + lua::StackGuard guard(L, 0); // 1 in, 1 out + auto ptr = HTTPResponse::getOrError(L, 1); + return ptr->status(L); +} + +int HTTPResponse::status(lua_State *L) +{ + lua::push(L, this->result_.status()); + return 1; +} + +int HTTPResponse::error_wrap(lua_State *L) +{ + lua::StackGuard guard(L, 0); // 1 in, 1 out + auto ptr = HTTPResponse::getOrError(L, 1); + return ptr->error(L); +} + +int HTTPResponse::error(lua_State *L) +{ + lua::push(L, this->result_.formatError()); + return 1; +} + +// NOLINTEND(*vararg) +} // namespace chatterino::lua::api + +namespace chatterino::lua { +StackIdx push(lua_State *L, std::shared_ptr request) +{ + using namespace chatterino::lua::api; + + // Prepare table + SharedPtrUserData::create( + L, std::move(request)); + luaL_getmetatable(L, "c2.HTTPResponse"); + lua_setmetatable(L, -2); + + return lua_gettop(L); +} +} // namespace chatterino::lua +#endif diff --git a/src/controllers/plugins/api/HTTPResponse.hpp b/src/controllers/plugins/api/HTTPResponse.hpp new file mode 100644 index 00000000..205aae01 --- /dev/null +++ b/src/controllers/plugins/api/HTTPResponse.hpp @@ -0,0 +1,80 @@ +#pragma once +#ifdef CHATTERINO_HAVE_PLUGINS +# include "common/network/NetworkResult.hpp" +# include "controllers/plugins/LuaUtilities.hpp" + +# include +extern "C" { +# include +} + +namespace chatterino { +class PluginController; +} // namespace chatterino + +namespace chatterino::lua::api { +// NOLINTBEGIN(readability-identifier-naming) + +/** + * @lua@class HTTPResponse + */ +class HTTPResponse : public std::enable_shared_from_this +{ + NetworkResult result_; + +public: + HTTPResponse(NetworkResult res); + HTTPResponse(HTTPResponse &&other) = default; + HTTPResponse &operator=(HTTPResponse &&) = default; + HTTPResponse &operator=(HTTPResponse &) = delete; + HTTPResponse(const HTTPResponse &other) = delete; + ~HTTPResponse(); + +private: + static void createMetatable(lua_State *L); + friend class chatterino::PluginController; + + /** + * @brief Get the content of the top object on Lua stack, usually the first argument as an HTTPResponse + * + * If the object given is not a userdatum or the pointer inside that + * userdatum doesn't point to a HTTPResponse, a lua error is thrown. + * + * This function always returns a non-null pointer. + */ + static std::shared_ptr getOrError(lua_State *L, + StackIdx where = -1); + +public: + /** + * Returns the data. This is not guaranteed to be encoded using any + * particular encoding scheme. It's just the bytes the server returned. + * + * @exposed HTTPResponse:data + */ + static int data_wrap(lua_State *L); + int data(lua_State *L); + + /** + * Returns the status code. + * + * @exposed HTTPResponse:status + */ + static int status_wrap(lua_State *L); + int status(lua_State *L); + + /** + * A somewhat human readable description of an error if such happened + * @exposed HTTPResponse:error + */ + + static int error_wrap(lua_State *L); + int error(lua_State *L); +}; + +// NOLINTEND(readability-identifier-naming) +} // namespace chatterino::lua::api +namespace chatterino::lua { +StackIdx push(lua_State *L, std::shared_ptr request); +} // namespace chatterino::lua +#endif