From 658fceddaa23198c67119ab725dffaa8c8f3afd6 Mon Sep 17 00:00:00 2001 From: Mm2PL Date: Sat, 9 Mar 2024 20:16:25 +0100 Subject: [PATCH] Add plugin permissions and IO API (#5231) --- CHANGELOG.md | 2 + docs/plugin-info.schema.json | 14 +- docs/wip-plugins.md | 168 ++++++++- src/CMakeLists.txt | 4 + src/controllers/plugins/LuaAPI.cpp | 8 + src/controllers/plugins/LuaUtilities.hpp | 10 +- src/controllers/plugins/Plugin.cpp | 66 ++++ src/controllers/plugins/Plugin.hpp | 10 + src/controllers/plugins/PluginController.cpp | 52 ++- src/controllers/plugins/PluginPermission.cpp | 46 +++ src/controllers/plugins/PluginPermission.hpp | 30 ++ src/controllers/plugins/api/ChannelRef.cpp | 8 +- src/controllers/plugins/api/ChannelRef.hpp | 6 +- src/controllers/plugins/api/IOWrapper.cpp | 372 +++++++++++++++++++ src/controllers/plugins/api/IOWrapper.hpp | 98 +++++ src/widgets/settingspages/PluginsPage.cpp | 14 + 16 files changed, 889 insertions(+), 19 deletions(-) create mode 100644 src/controllers/plugins/PluginPermission.cpp create mode 100644 src/controllers/plugins/PluginPermission.hpp create mode 100644 src/controllers/plugins/api/IOWrapper.cpp create mode 100644 src/controllers/plugins/api/IOWrapper.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 95b51e5de..92100b61e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,8 @@ - Minor: Image links now reflect the scale of their image instead of an internal label. (#5201) - Minor: IPC files are now stored in the Chatterino directory instead of system directories on Windows. (#5226) - Minor: 7TV emotes now have a 4x image rather than a 3x image. (#5209) +- Minor: Add wrappers for Lua `io` library for experimental plugins feature. (#5231) +- Minor: Add permissions to experimental plugins feature. (#5231) - Bugfix: Fixed an issue where certain emojis did not send to Twitch chat correctly. (#4840) - Bugfix: Fixed capitalized channel names in log inclusion list not being logged. (#4848) - Bugfix: Trimmed custom streamlink paths on all platforms making sure you don't accidentally add spaces at the beginning or end of its path. (#4834) diff --git a/docs/plugin-info.schema.json b/docs/plugin-info.schema.json index 2b5203ef6..464201002 100644 --- a/docs/plugin-info.schema.json +++ b/docs/plugin-info.schema.json @@ -41,9 +41,21 @@ }, "license": { "type": "string", - "description": "A small description of your license.", + "description": "SPDX identifier for license of this plugin. See https://spdx.org/licenses/", "examples": ["MIT", "GPL-2.0-or-later"] }, + "permissions": { + "type": "array", + "description": "The permissions the plugin needs to work.", + "items": { + "type": "object", + "properties": { + "type": { + "enum": ["FilesystemRead", "FilesystemWrite"] + } + } + } + }, "$schema": { "type": "string" } }, "required": ["name", "description", "authors", "version", "license"] diff --git a/docs/wip-plugins.md b/docs/wip-plugins.md index ff5c221e7..1309d7bab 100644 --- a/docs/wip-plugins.md +++ b/docs/wip-plugins.md @@ -14,7 +14,9 @@ Each plugin should have its own directory. Chatterino Plugins dir/ └── plugin_name/ ├── init.lua - └── info.json + ├── info.json + └── data/ + └── This is where your data/configs can be dumped ``` `init.lua` will be the file loaded when the plugin is enabled. You may load other files using [`require` global function](#requiremodname). @@ -35,12 +37,54 @@ Example file: "homepage": "https://github.com/Chatterino/Chatterino2", "tags": ["test"], "version": "0.0.0", - "license": "MIT" + "license": "MIT", + "permissions": [] } ``` An example plugin is available at [https://github.com/Mm2PL/Chatterino-test-plugin](https://github.com/Mm2PL/Chatterino-test-plugin) +## Permissions + +Plugins can have permissions associated to them. Unless otherwise noted functions don't require permissions. +These are the valid permissions: + +### FilesystemRead + +Allows the plugin to read from its data directory. + +Example: + +```json +{ + ..., + "permissions": [ + { + "type": "FilesystemRead" + }, + ... + ] +} +``` + +### FilesystemWrite + +Allows the plugin to write to files and create files in its data directory. + +Example: + +```json +{ + ..., + "permissions": [ + { + "type": "FilesystemWrite" + }, + ... + ] +} +``` + ## Plugins with Typescript If you prefer, you may use [TypescriptToLua](https://typescripttolua.github.io) @@ -60,9 +104,10 @@ script](../scripts/make_luals_meta.py). The following parts of the Lua standard library are loaded: - `_G` (most globals) -- `table` -- `string` +- `io` - except `stdin`, `stdout`, `stderr`. Some functions require permissions. - `math` +- `string` +- `table` - `utf8` The official manual for them is available [here](https://www.lua.org/manual/5.4/manual.html#6). @@ -325,6 +370,117 @@ Returns `true` if the channel can be moderated by the current user. Returns `true` if the current user is a VIP in the channel. +### Input/Output API + +These functions are wrappers for Lua's I/O library. Functions on file pointer +objects (`FILE*`) are not modified or replaced. [You can read the documentation +for them here](https://www.lua.org/manual/5.4/manual.html#pdf-file:close). +Chatterino does _not_ give you stdin and stdout as default input and output +respectively. The following objects are missing from the `io` table exposed by +Chatterino compared to Lua's native library: `stdin`, `stdout`, `stderr`. + +#### `close([file])` + +Closes a file. If not given, `io.output()` is used instead. + +See [official documentation](https://www.lua.org/manual/5.4/manual.html#pdf-io.close) + +#### `flush()` + +Flushes `io.output()`. + +See [official documentation](https://www.lua.org/manual/5.4/manual.html#pdf-io.flush) + +#### `input([file_or_name])` + +When called with no arguments this function returns the default input file. +This variant requires no permissions. + +When called with a file object, it will set the default input file to the one +given. This one also requires no permissions. + +When called with a filename as a string, it will open that file for reading. +Equivalent to: `io.input(io.open(filename))`. This variant requires +the `FilesystemRead` permission and the given file to be within the plugin's +data directory. + +See [official documentation](https://www.lua.org/manual/5.4/manual.html#pdf-io.input) + +#### `lines([filename, ...])` + +With no arguments this function is equivalent to `io.input():lines("l")`. See +[Lua documentation for file:flush()](https://www.lua.org/manual/5.4/manual.html#pdf-file:flush). +This variant requires no permissions. + +With `filename` given it is most like `io.open(filename):lines(...)`. This +variant requires the `FilesystemRead` permission and the given file to be +within the plugin's data directory. + +See [official documentation](https://www.lua.org/manual/5.4/manual.html#pdf-io.lines) + +#### `open(filename [, mode])` + +This functions opens the given file with a mode. It requires `filename` to be +within the plugin's data directory. A call with no mode given is equivalent to +one with `mode="r"`. +Depending on the mode this function has slightly different behavior: + +| Mode | Permission | Read? | Write? | Truncate? | Create? | +| ----------- | ----------------- | ----- | ------ | --------- | ------- | +| `r` read | `FilesystemRead` | Yes | No | No | No | +| `w` write | `FilesystemWrite` | No | Yes | Yes | Yes | +| `a` append | `FilesystemWrite` | No | Append | No | Yes | +| `r+` update | `FilesystemWrite` | Yes | Yes | No | No | +| `w+` update | `FilesystemWrite` | Yes | Yes | Yes | Yes | +| `a+` update | `FilesystemWrite` | Yes | Append | No | Yes | + +To open a file in binary mode add a `b` at the end of the mode. + +See [official documentation](https://www.lua.org/manual/5.4/manual.html#pdf-io.open) + +#### `output([file_or_name])` + +This is identical to [`io.input()`](#inputfile_or_name) but operates on the +default output and opens the file in write mode instead. Requires +`FilesystemWrite` instead of `FilesystemRead`. + +See [official documentation](https://www.lua.org/manual/5.4/manual.html#pdf-io.output) + +#### `popen(exe [, mode])` + +This function is unavailable in Chatterino. Calling it results in an error +message to let you know that it's not available, no permissions needed. + +See [official documentation](https://www.lua.org/manual/5.4/manual.html#pdf-io.popen) + +#### `read(...)` + +Equivalent to `io.input():read(...)`. See [`io.input()`](#inputfile_or_name) +and [`file:read()`](https://www.lua.org/manual/5.4/manual.html#pdf-file:read). + +See [official documentation](https://www.lua.org/manual/5.4/manual.html#pdf-io.read) + +#### `tmpfile()` + +This function is unavailable in Chatterino. Calling it results in an error +message to let you know that it's not available, no permissions needed. + +See [official documentation](https://www.lua.org/manual/5.4/manual.html#pdf-io.tmpfile) + +#### `type(obj)` + +This functions allows you to tell if the object is a `file`, a `closed file` or +a different bit of data. + +See [official documentation](https://www.lua.org/manual/5.4/manual.html#pdf-io.type) + +#### `write(...)` + +Equivalent to `io.output():write(...)`. See [`io.output()`](#outputfile_or_name) +and [`file:write()`](https://www.lua.org/manual/5.4/manual.html#pdf-file:write). + +See [official documentation](https://www.lua.org/manual/5.4/manual.html#pdf-io.write) + ### Changed globals #### `load(chunk [, chunkname [, mode [, env]]])` @@ -344,7 +500,8 @@ However, the searcher and load configuration is notably different from the defau - `package.path` is not used, in its place are two searchers, - when `require()` is used, first a file relative to the currently executing file will be checked, then a file relative to the plugin directory, -- binary chunks are never loaded +- binary chunks are never loaded, +- files inside of the plugin `data` directory are never loaded As in normal Lua, dots are converted to the path separators (`'/'` on Linux and Mac, `'\'` on Windows). @@ -354,6 +511,7 @@ Example: require("stuff") -- executes Plugins/name/stuff.lua or $(dirname $CURR_FILE)/stuff.lua require("dir.name") -- executes Plugins/name/dir/name.lua or $(dirname $CURR_FILE)/dir/name.lua require("binary") -- tried to load Plugins/name/binary.lua and errors because binary is not a text file +require("data.file") -- tried to load Plugins/name/data/file.lua and errors because that is not allowed ``` #### `print(Args...)` diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 81c65832d..ba5e85b4d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -223,8 +223,12 @@ set(SOURCE_FILES controllers/plugins/api/ChannelRef.cpp controllers/plugins/api/ChannelRef.hpp + controllers/plugins/api/IOWrapper.cpp + controllers/plugins/api/IOWrapper.hpp controllers/plugins/LuaAPI.cpp controllers/plugins/LuaAPI.hpp + controllers/plugins/PluginPermission.cpp + controllers/plugins/PluginPermission.hpp controllers/plugins/Plugin.cpp controllers/plugins/Plugin.hpp controllers/plugins/PluginController.hpp diff --git a/src/controllers/plugins/LuaAPI.cpp b/src/controllers/plugins/LuaAPI.cpp index 0e4d16b0a..291e95b22 100644 --- a/src/controllers/plugins/LuaAPI.cpp +++ b/src/controllers/plugins/LuaAPI.cpp @@ -266,6 +266,14 @@ int loadfile(lua_State *L, const QString &str) L, QString("requested module is outside of the plugin directory")); return 1; } + auto datadir = QUrl(pl->dataDirectory().canonicalPath() + "/"); + if (datadir.isParentOf(str)) + { + lua::push(L, QString("requested file is data, not code, see Chatterino " + "documentation")); + return 1; + } + QFileInfo info(str); if (!info.exists()) { diff --git a/src/controllers/plugins/LuaUtilities.hpp b/src/controllers/plugins/LuaUtilities.hpp index 66ca1a9f1..f610ae25d 100644 --- a/src/controllers/plugins/LuaUtilities.hpp +++ b/src/controllers/plugins/LuaUtilities.hpp @@ -283,6 +283,7 @@ StackIdx push(lua_State *L, T inp) /** * @brief Converts a Lua object into c++ and removes it from the stack. + * If peek fails, the object is still removed from the stack. * * Relies on bool peek(lua_State*, T*, StackIdx) existing. */ @@ -291,14 +292,11 @@ bool pop(lua_State *L, T *out, StackIdx idx = -1) { StackGuard guard(L, -1); auto ok = peek(L, out, idx); - if (ok) + if (idx < 0) { - if (idx < 0) - { - idx = lua_gettop(L) + idx + 1; - } - lua_remove(L, idx); + idx = lua_gettop(L) + idx + 1; } + lua_remove(L, idx); return ok; } diff --git a/src/controllers/plugins/Plugin.cpp b/src/controllers/plugins/Plugin.cpp index 63c69c388..562b6c07b 100644 --- a/src/controllers/plugins/Plugin.cpp +++ b/src/controllers/plugins/Plugin.cpp @@ -9,6 +9,7 @@ # include # include +# include # include # include @@ -111,6 +112,48 @@ PluginMeta::PluginMeta(const QJsonObject &obj) QString("version is not a string (its type is %1)").arg(type)); this->version = semver::version(0, 0, 0); } + auto permsObj = obj.value("permissions"); + if (!permsObj.isUndefined()) + { + if (!permsObj.isArray()) + { + QString type = magic_enum::enum_name(permsObj.type()).data(); + this->errors.emplace_back( + QString("permissions is not an array (its type is %1)") + .arg(type)); + return; + } + + auto permsArr = permsObj.toArray(); + for (int i = 0; i < permsArr.size(); i++) + { + const auto &t = permsArr.at(i); + if (!t.isObject()) + { + QString type = magic_enum::enum_name(t.type()).data(); + this->errors.push_back(QString("permissions element #%1 is not " + "an object (its type is %2)") + .arg(i) + .arg(type)); + return; + } + auto parsed = PluginPermission(t.toObject()); + if (parsed.isValid()) + { + // ensure no invalid permissions slip through this + this->permissions.push_back(parsed); + } + else + { + for (const auto &err : parsed.errors) + { + this->errors.push_back( + QString("permissions element #%1: %2").arg(i).arg(err)); + } + } + } + } + auto tagsObj = obj.value("tags"); if (!tagsObj.isUndefined()) { @@ -201,5 +244,28 @@ void Plugin::removeTimeout(QTimer *timer) } } +bool Plugin::hasFSPermissionFor(bool write, const QString &path) +{ + auto canon = QUrl(this->dataDirectory().absolutePath() + "/"); + if (!canon.isParentOf(path)) + { + return false; + } + + 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) + { + if (p.type == typ) + { + return true; + } + } + return false; +} + } // namespace chatterino #endif diff --git a/src/controllers/plugins/Plugin.hpp b/src/controllers/plugins/Plugin.hpp index 67f9d35ff..6dfb3b20e 100644 --- a/src/controllers/plugins/Plugin.hpp +++ b/src/controllers/plugins/Plugin.hpp @@ -4,6 +4,7 @@ # include "Application.hpp" # include "controllers/plugins/LuaAPI.hpp" # include "controllers/plugins/LuaUtilities.hpp" +# include "controllers/plugins/PluginPermission.hpp" # include # include @@ -42,6 +43,8 @@ struct PluginMeta { // optionally tags that might help in searching for the plugin std::vector tags; + std::vector permissions; + // errors that occurred while parsing info.json std::vector errors; @@ -88,6 +91,11 @@ public: return this->loadDirectory_; } + QDir dataDirectory() const + { + return this->loadDirectory_.absoluteFilePath("data"); + } + // Note: The CallbackFunction object's destructor will remove the function from the lua stack using LuaCompletionCallback = lua::CallbackFunctiondataDirectory().mkpath("."); + qCDebug(chatterinoLua) << "Running lua file:" << index; int err = luaL_dofile(l, index.absoluteFilePath().toStdString().c_str()); if (err != 0) diff --git a/src/controllers/plugins/PluginPermission.cpp b/src/controllers/plugins/PluginPermission.cpp new file mode 100644 index 000000000..d806db4bd --- /dev/null +++ b/src/controllers/plugins/PluginPermission.cpp @@ -0,0 +1,46 @@ +#ifdef CHATTERINO_HAVE_PLUGINS +# include "controllers/plugins/PluginPermission.hpp" + +# include +# include + +namespace chatterino { + +PluginPermission::PluginPermission(const QJsonObject &obj) +{ + auto jsontype = obj.value("type"); + if (!jsontype.isString()) + { + QString tn = magic_enum::enum_name(jsontype.type()).data(); + this->errors.emplace_back(QString("permission type is defined but is " + "not a string (its type is %1)") + .arg(tn)); + } + auto strtype = jsontype.toString().toStdString(); + auto opt = magic_enum::enum_cast( + strtype, magic_enum::case_insensitive); + if (!opt.has_value()) + { + this->errors.emplace_back(QString("permission type is an unknown (%1)") + .arg(jsontype.toString())); + return; // There is no more data to get, we don't know what to do + } + this->type = opt.value(); +} + +QString PluginPermission::toHtml() const +{ + switch (this->type) + { + case PluginPermission::Type::FilesystemRead: + return "Read files in its data directory"; + case PluginPermission::Type::FilesystemWrite: + return "Write to or create files in its data directory"; + default: + assert(false && "invalid PluginPermission type in toHtml()"); + return "shut up compiler, this never happens"; + } +} + +} // namespace chatterino +#endif diff --git a/src/controllers/plugins/PluginPermission.hpp b/src/controllers/plugins/PluginPermission.hpp new file mode 100644 index 000000000..5867b7b63 --- /dev/null +++ b/src/controllers/plugins/PluginPermission.hpp @@ -0,0 +1,30 @@ +#pragma once +#ifdef CHATTERINO_HAVE_PLUGINS + +# include +# include + +# include + +namespace chatterino { + +struct PluginPermission { + explicit PluginPermission(const QJsonObject &obj); + + enum class Type { + FilesystemRead, + FilesystemWrite, + }; + Type type; + std::vector errors; + + bool isValid() const + { + return this->errors.empty(); + } + + QString toHtml() const; +}; + +} // namespace chatterino +#endif diff --git a/src/controllers/plugins/api/ChannelRef.cpp b/src/controllers/plugins/api/ChannelRef.cpp index 3dc6af7f6..8ae91cd97 100644 --- a/src/controllers/plugins/api/ChannelRef.cpp +++ b/src/controllers/plugins/api/ChannelRef.cpp @@ -74,11 +74,13 @@ ChannelPtr ChannelRef::getOrError(lua_State *L, bool expiredOk) if (lua_isuserdata(L, lua_gettop(L)) == 0) { luaL_error( - L, "Called c2.Channel method with a non Channel 'self' argument."); + L, "Called c2.Channel method with a non-userdata 'self' argument"); return nullptr; } - auto *data = WeakPtrUserData::from( - lua_touserdata(L, lua_gettop(L))); + // luaL_checkudata is no-return if check fails + auto *checked = luaL_checkudata(L, lua_gettop(L), "c2.Channel"); + auto *data = + WeakPtrUserData::from(checked); if (data == nullptr) { luaL_error(L, diff --git a/src/controllers/plugins/api/ChannelRef.hpp b/src/controllers/plugins/api/ChannelRef.hpp index 2ec3295fa..29f5173d2 100644 --- a/src/controllers/plugins/api/ChannelRef.hpp +++ b/src/controllers/plugins/api/ChannelRef.hpp @@ -1,11 +1,11 @@ #pragma once -#include "providers/twitch/TwitchChannel.hpp" - -#include #ifdef CHATTERINO_HAVE_PLUGINS # include "common/Channel.hpp" # include "controllers/plugins/LuaUtilities.hpp" # include "controllers/plugins/PluginController.hpp" +# include "providers/twitch/TwitchChannel.hpp" + +# include namespace chatterino::lua::api { // NOLINTBEGIN(readability-identifier-naming) diff --git a/src/controllers/plugins/api/IOWrapper.cpp b/src/controllers/plugins/api/IOWrapper.cpp new file mode 100644 index 000000000..7eeffaf71 --- /dev/null +++ b/src/controllers/plugins/api/IOWrapper.cpp @@ -0,0 +1,372 @@ +#ifdef CHATTERINO_HAVE_PLUGINS +# include "controllers/plugins/api/IOWrapper.hpp" + +# include "Application.hpp" +# include "controllers/plugins/LuaUtilities.hpp" +# include "controllers/plugins/PluginController.hpp" +# include "lauxlib.h" +# include "lua.h" + +# include + +namespace chatterino::lua::api { + +// Note: Parsing and then serializing the mode ensures we understand it before +// passing it to Lua + +struct LuaFileMode { + char major = 'r'; // 'r'|'w'|'a' + bool update{}; // '+' + bool binary{}; // 'b' + QString error; + + LuaFileMode() = default; + + LuaFileMode(const QString &smode) + { + if (smode.isEmpty()) + { + this->error = "Empty mode given, use one matching /[rwa][+]?b?/."; + return; + } + auto major = smode.at(0); + if (major != 'r' && major != 'w' && major != 'a') + { + this->error = "Invalid mode, use one matching /[rwa][+]?b?/. " + "Parsing failed at 1st character."; + return; + } + this->major = major.toLatin1(); + if (smode.length() > 1) + { + auto plusOrB = smode.at(1); + if (plusOrB == '+') + { + this->update = true; + } + else if (plusOrB == 'b') + { + this->binary = true; + } + else + { + this->error = "Invalid mode, use one matching /[rwa][+]?b?/. " + "Parsing failed at 2nd character."; + return; + } + } + if (smode.length() > 2) + { + auto maybeB = smode.at(2); + if (maybeB == 'b') + { + this->binary = true; + } + else + { + this->error = "Invalid mode, use one matching /[rwa][+]?b?/. " + "Parsing failed at 3rd character."; + return; + } + } + } + + QString toString() const + { + assert(this->major == 'r' || this->major == 'w' || this->major == 'a'); + QString out; + out += this->major; + if (this->update) + { + out += '+'; + } + if (this->binary) + { + out += 'b'; + } + return out; + } +}; + +int ioError(lua_State *L, const QString &value, int errnoequiv) +{ + lua_pushnil(L); + lua::push(L, value); + lua::push(L, errnoequiv); + return 3; +} + +// NOLINTBEGIN(*vararg) +int io_open(lua_State *L) +{ + auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); + if (pl == nullptr) + { + luaL_error(L, "internal error: no plugin"); + return 0; + } + LuaFileMode mode; + if (lua_gettop(L) == 2) + { + // we have a mode + QString smode; + if (!lua::pop(L, &smode)) + { + return luaL_error( + L, + "io.open mode (2nd argument) must be a string or not present"); + } + mode = LuaFileMode(smode); + if (!mode.error.isEmpty()) + { + return luaL_error(L, mode.error.toStdString().c_str()); + } + } + QString filename; + if (!lua::pop(L, &filename)) + { + return luaL_error(L, + "io.open filename (1st argument) must be a string"); + } + QFileInfo file(pl->dataDirectory().filePath(filename)); + auto abs = file.absoluteFilePath(); + qCDebug(chatterinoLua) << "[" << pl->id << ":" << pl->meta.name + << "] Plugin is opening file at " << abs + << " with mode " << mode.toString(); + bool ok = pl->hasFSPermissionFor( + mode.update || mode.major == 'w' || mode.major == 'a', abs); + if (!ok) + { + return ioError(L, + "Plugin does not have permissions to access given file.", + EACCES); + } + lua_getfield(L, LUA_REGISTRYINDEX, REG_REAL_IO_NAME); + lua_getfield(L, -1, "open"); + lua_remove(L, -2); // remove LUA_REGISTRYINDEX[REAL_IO_NAME] + lua::push(L, abs); + lua::push(L, mode.toString()); + lua_call(L, 2, 3); + return 3; +} + +int io_lines(lua_State *L) +{ + auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); + if (pl == nullptr) + { + luaL_error(L, "internal error: no plugin"); + return 0; + } + if (lua_gettop(L) == 0) + { + // io.lines() case, just call realio.lines + lua_getfield(L, LUA_REGISTRYINDEX, REG_REAL_IO_NAME); + lua_getfield(L, -1, "lines"); + lua_remove(L, -2); // remove LUA_REGISTRYINDEX[REAL_IO_NAME] + lua_call(L, 0, 1); + return 1; + } + QString filename; + if (!lua::pop(L, &filename)) + { + return luaL_error( + L, + "io.lines filename (1st argument) must be a string or not present"); + } + QFileInfo file(pl->dataDirectory().filePath(filename)); + auto abs = file.absoluteFilePath(); + qCDebug(chatterinoLua) << "[" << pl->id << ":" << pl->meta.name + << "] Plugin is opening file at " << abs + << " for reading lines"; + bool ok = pl->hasFSPermissionFor(false, abs); + if (!ok) + { + return ioError(L, + "Plugin does not have permissions to access given file.", + EACCES); + } + // Our stack looks like this: + // - {...}[1] + // - {...}[2] + // ... + // We want: + // - REG[REG_REAL_IO_NAME].lines + // - absolute file path + // - {...}[1] + // - {...}[2] + // ... + + lua_getfield(L, LUA_REGISTRYINDEX, REG_REAL_IO_NAME); + lua_getfield(L, -1, "lines"); + lua_remove(L, -2); // remove LUA_REGISTRYINDEX[REAL_IO_NAME] + lua_insert(L, 1); // move function to start of stack + lua::push(L, abs); + lua_insert(L, 2); // move file name just after the function + lua_call(L, lua_gettop(L) - 1, LUA_MULTRET); + return lua_gettop(L); +} + +namespace { + + // This is the code for both io.input and io.output + int globalFileCommon(lua_State *L, bool output) + { + auto *pl = getApp()->getPlugins()->getPluginByStatePtr(L); + if (pl == nullptr) + { + luaL_error(L, "internal error: no plugin"); + return 0; + } + // Three signature cases: + // io.input() + // io.input(file) + // io.input(name) + if (lua_gettop(L) == 0) + { + // We have no arguments, call realio.input() + lua_getfield(L, LUA_REGISTRYINDEX, REG_REAL_IO_NAME); + if (output) + { + lua_getfield(L, -1, "output"); + } + else + { + lua_getfield(L, -1, "input"); + } + lua_remove(L, -2); // remove LUA_REGISTRYINDEX[REAL_IO_NAME] + lua_call(L, 0, 1); + return 1; + } + if (lua_gettop(L) != 1) + { + return luaL_error(L, "Too many arguments given to io.input()."); + } + // Now check if we have a file or name + auto *p = luaL_testudata(L, 1, LUA_FILEHANDLE); + if (p == nullptr) + { + // this is not a file handle, send it to open + luaL_getsubtable(L, LUA_REGISTRYINDEX, REG_C2_IO_NAME); + lua_getfield(L, -1, "open"); + lua_remove(L, -2); // remove io + + lua_pushvalue(L, 1); // dupe arg + if (output) + { + lua_pushstring(L, "w"); + } + else + { + lua_pushstring(L, "r"); + } + lua_call(L, 2, 1); // call ourio.open(arg1, 'r'|'w') + // if this isn't a string ourio.open errors + + // this leaves us with: + // 1. arg + // 2. new_file + lua_remove(L, 1); // remove arg, replacing it with new_file + } + + // file handle, pass it off to realio.input + lua_getfield(L, LUA_REGISTRYINDEX, REG_REAL_IO_NAME); + if (output) + { + lua_getfield(L, -1, "output"); + } + else + { + lua_getfield(L, -1, "input"); + } + lua_remove(L, -2); // remove LUA_REGISTRYINDEX[REAL_IO_NAME] + lua_pushvalue(L, 1); // duplicate arg + lua_call(L, 1, 1); + return 1; + } + +} // namespace + +int io_input(lua_State *L) +{ + return globalFileCommon(L, false); +} + +int io_output(lua_State *L) +{ + return globalFileCommon(L, true); +} + +int io_close(lua_State *L) +{ + if (lua_gettop(L) > 1) + { + return luaL_error( + L, "Too many arguments for io.close. Expected one or zero."); + } + if (lua_gettop(L) == 0) + { + lua_getfield(L, LUA_REGISTRYINDEX, "_IO_output"); + } + lua_getfield(L, -1, "close"); + lua_pushvalue(L, -2); + lua_call(L, 1, 0); + return 0; +} + +int io_flush(lua_State *L) +{ + if (lua_gettop(L) > 1) + { + return luaL_error( + L, "Too many arguments for io.flush. Expected one or zero."); + } + lua_getfield(L, LUA_REGISTRYINDEX, "_IO_output"); + lua_getfield(L, -1, "flush"); + lua_pushvalue(L, -2); + lua_call(L, 1, 0); + return 0; +} + +int io_read(lua_State *L) +{ + if (lua_gettop(L) > 1) + { + return luaL_error( + L, "Too many arguments for io.read. Expected one or zero."); + } + lua_getfield(L, LUA_REGISTRYINDEX, "_IO_input"); + lua_getfield(L, -1, "read"); + lua_insert(L, 1); + lua_insert(L, 2); + lua_call(L, lua_gettop(L) - 1, 1); + return 1; +} + +int io_write(lua_State *L) +{ + lua_getfield(L, LUA_REGISTRYINDEX, "_IO_output"); + lua_getfield(L, -1, "write"); + lua_insert(L, 1); + lua_insert(L, 2); + // (input) + // (input).read + // args + lua_call(L, lua_gettop(L) - 1, 1); + return 1; +} + +int io_popen(lua_State *L) +{ + return luaL_error(L, "io.popen: This function is a stub!"); +} + +int io_tmpfile(lua_State *L) +{ + return luaL_error(L, "io.tmpfile: This function is a stub!"); +} + +// NOLINTEND(*vararg) + +} // namespace chatterino::lua::api +#endif diff --git a/src/controllers/plugins/api/IOWrapper.hpp b/src/controllers/plugins/api/IOWrapper.hpp new file mode 100644 index 000000000..24ee2801e --- /dev/null +++ b/src/controllers/plugins/api/IOWrapper.hpp @@ -0,0 +1,98 @@ +#pragma once +#ifdef CHATTERINO_HAVE_PLUGINS + +struct lua_State; + +namespace chatterino::lua::api { +// NOLINTBEGIN(readability-identifier-naming) +// These functions are exposed as `_G.io`, they are wrappers for native Lua functionality. + +const char *const REG_REAL_IO_NAME = "real_lua_io_lib"; +const char *const REG_C2_IO_NAME = "c2io"; + +/** + * Opens a file. + * If given a relative path, it will be relative to + * c2datadir/Plugins/pluginDir/data/ + * See https://www.lua.org/manual/5.4/manual.html#pdf-io.open + * + * @lua@param filename string + * @lua@param mode nil|"r"|"w"|"a"|"r+"|"w+"|"a+" + * @exposed io.open + */ +int io_open(lua_State *L); + +/** + * Equivalent to io.input():lines("l") or a specific iterator over given file + * If given a relative path, it will be relative to + * c2datadir/Plugins/pluginDir/data/ + * See https://www.lua.org/manual/5.4/manual.html#pdf-io.lines + * + * @lua@param filename nil|string + * @lua@param ... + * @exposed io.lines + */ +int io_lines(lua_State *L); + +/** + * Opens a file and sets it as default input or if given no arguments returns the default input. + * See https://www.lua.org/manual/5.4/manual.html#pdf-io.input + * + * @lua@param fileorname nil|string|FILE* + * @lua@return nil|FILE* + * @exposed io.input + */ +int io_input(lua_State *L); + +/** + * Opens a file and sets it as default output or if given no arguments returns the default output + * See https://www.lua.org/manual/5.4/manual.html#pdf-io.output + * + * @lua@param fileorname nil|string|FILE* + * @lua@return nil|FILE* + * @exposed io.output + */ +int io_output(lua_State *L); + +/** + * Closes given file or io.output() if not given. + * See https://www.lua.org/manual/5.4/manual.html#pdf-io.close + * + * @lua@param nil|FILE* + * @exposed io.close + */ +int io_close(lua_State *L); + +/** + * Flushes the buffer for given file or io.output() if not given. + * See https://www.lua.org/manual/5.4/manual.html#pdf-io.flush + * + * @lua@param nil|FILE* + * @exposed io.flush + */ +int io_flush(lua_State *L); + +/** + * Reads some data from the default input file + * See https://www.lua.org/manual/5.4/manual.html#pdf-io.read + * + * @lua@param nil|string + * @exposed io.read + */ +int io_read(lua_State *L); + +/** + * Writes some data to the default output file + * See https://www.lua.org/manual/5.4/manual.html#pdf-io.write + * + * @lua@param nil|string + * @exposed io.write + */ +int io_write(lua_State *L); + +int io_popen(lua_State *L); +int io_tmpfile(lua_State *L); + +// NOLINTEND(readability-identifier-naming) +} // namespace chatterino::lua::api +#endif diff --git a/src/widgets/settingspages/PluginsPage.cpp b/src/widgets/settingspages/PluginsPage.cpp index 6fb710d51..aad35f751 100644 --- a/src/widgets/settingspages/PluginsPage.cpp +++ b/src/widgets/settingspages/PluginsPage.cpp @@ -161,6 +161,20 @@ void PluginsPage::rebuildContent() } pluginEntry->addRow("Commands", new QLabel(commandsTxt, this->dataFrame_)); + if (!plugin->meta.permissions.empty()) + { + QString perms = "
    "; + for (const auto &perm : plugin->meta.permissions) + { + perms += "
  • " + perm.toHtml() + "
  • "; + } + perms += "
"; + + auto *lbl = + new QLabel("Required permissions:" + perms, this->dataFrame_); + lbl->setTextFormat(Qt::RichText); + pluginEntry->addRow(lbl); + } if (plugin->meta.isValid()) {